Files
privatae-bastion/docs/security-model.md
T
Privatae 77b7853ae1
ci / personal-data (push) Successful in 6s
ci / fuzz (push) Successful in 2m49s
ci / test (push) Successful in 3m7s
bastion 0.54.1
2026-10-21 23:39:13 +00:00

11 KiB

Security model

  • The bastion dials out. Every lane is a reverse connection from this machine to the host. No port is opened on an externally reachable interface; the control ports and console bind loopback only, and the optional memory-mirror forwarder binds loopback plus the replica container's private gateway address, with every accept source-checked.
  • Inside the tunnel, only gated listeners. The host can connect to up to three inference forwards (local ollama, an OpenAI-compatible server, a PII sterilizer), each a proxy to a service you run, on loopback by default and elsewhere only if you point it there by environment variable; and to one port per server in the service slot (18100 to 18199), joined to that server's socket; and to one port per coder run (18200 to 18299), joined to that run's control socket. Every connection is checked when it is accepted: a forward needs the pairing to hold the inference capability family, a service slot port needs the services family and a reach grant for that server naming that host, and a coder run port needs the harness family, the run to be that host's and within its deadline, and the connection to come from that host's own tunnel address. Nothing else in the tunnel accepts a connection.
  • A Host on this machine gets the same walls. Its lanes go over its lane socket with mutual TLS against the CA pinned at pairing, the pairing's name checked and the bearer on every lane; a pairing with no bearer opens none. A coder run it owns is a socket in its own runs directory (0660, group privatae-host, else root), checked at every accept as a run port is, and removed at run-down and at the watchdog's stop. The runs directory must be one only root can change (root's, writable by nobody else, under directories that are root's and not writable by others unless sticky), or no socket is made in it. On an anchored machine its run-up needs the standing coder grant like any pairing's.
  • Scoped tunnel. The WireGuard peer's only allowed address is the paired host's single tunnel address, so the tunnel can reach nothing else. A pairing that offers anything wider is refused, and a pairing file that holds one brings no tunnel up.
  • No MCP endpoint. Nothing on this machine drives the host.
  • Grants are verified here. What the host may do arrives signed. The verify key is pinned on first use, and a deny always beats an allow.
  • Fixed argv. Every management op runs a fixed command line. File ops are dedicated Go handlers with no shell. Text extraction runs a named local tool from a fixed template.
  • Approval on the machine, once enrolled. With a second factor enrolled here, folder grants, move plans, model pulls and root sessions each need a code from this machine, bound to that exact request, single use. Without one, grants and move plans rest on the signed grant alone, and model pulls and root sessions are refused.
  • On an anchored machine, a write needs an approval verified and spent here. It comes from the second factor enrolled here or from your pinned approval device, is bound to the op and a digest of its arguments, and is used once; with neither enrolled, those writes are refused. The exceptions take no fresh approval: standing work under a place you set up at the machine (backup fetch and backup push, the host's context store), a backup restore request (approved on its own, below), runs under op terms you approved earlier, a root command under the grant you gave here with the code, the anchor host's signed pairing records, revokes, and a self-update to a newer version we signed. Repeated refused writes close a pairing's write lanes for a while.
  • Host-written programs run sandboxed first. An op runs only under signed terms you approved, as a dry run in a sandbox with no network, and never changes anything outside its declared file effects. A one-time apply lands only the change list you saw; a run under a standing grant is held to the grant's bounds.
  • A server in the service slot has no network. The host's tunnel is its only way in, and it cannot reach this machine, your network or the internet.
  • Egress for someone else reaches the public internet, never this machine. A coder run's proxy, always, and the egress lane on an anchored machine refuse loopback, link-local (the cloud metadata address included), private, CGNAT and reserved addresses, and any address this machine takes as its own: one on its interfaces (a public one included), and on Linux any address the kernel's routing table delivers here (a local, broadcast, anycast or multicast route, such as an AnyIP range on lo), asked of the kernel per connection. The IPv4 address inside an IPv4-mapped, NAT64 or 6to4 address is checked too, every address a name resolves to is checked, and the address checked is the one dialled. When the machine cannot tell, it refuses. A coder run reaches a LAN host only when you named it in coders set --allow-lan; the anchored egress lane has no such exception. The anchored lane's refusal of this machine's own non-private addresses is new in 0.50.0; before it, that lane refused only the internal ranges. A local (unanchored) machine's egress lane is unchanged and vets no destination. The backup lanes (backup_fetch and backup_push, anchored or not) apply the same check of this machine's own addresses, to every address the URL's name resolves to, to each redirect a fetch follows and to the address dialled; this too is new in 0.50.0, and before it they refused only loopback, private, link-local, multicast, unspecified, CGNAT and ULA addresses. Also since 0.50.0 they classify an address as a coder run's proxy does: the IPv4 address inside a NAT64 64:ff9b::/96 or 6to4 address is judged, and IPv4-compatible and IPv4-translated addresses, the rest of 64:ff9b::/32, site-local fec0::/10, 0.0.0.0/8, 198.18.0.0/15 and 240.0.0.0/4 are refused. Not covered: a public address that reaches this machine only through a 1:1 NAT in front of it, which is on no interface and in no local route. The bastion does not learn that address.
  • A terminal is checked here and logged. On a local (unanchored) machine, a terminal opens when the host asks, with no approval. No signed grant is needed, but one that is sent is verified, and a deny in it refuses. On an anchored machine, a host other than the anchor needs the session family in its pairing grant, and each terminal needs an approval from your pinned approval device or the second factor enrolled here, bound to that one session and used once. A root terminal, on either kind of machine, needs the bastion to run as root, a second factor enrolled here, and a code entered on this machine for that session; an approval device cannot approve it. Each session, and each refusal of a well-formed request, is written to the audit log on this machine when the pairing has a grant store; a malformed request is refused with no audit line.
  • Root commands run only under a grant given here with the code. Only the anchor host can hold it. It names this machine, ends when you said, is revoked from this console, and needs the bastion to run as root. A revoke or an expiry also stops a command already running, within 2 seconds. Every command and its output is written to a transcript here and to the audit log; refusals, malformed commands and setup failures are audited too.
  • Root only where there is no one else to ask. Claiming ownership or the anchor requires root because those are machine-wide facts.
  • On macOS and Windows, a networked bastion is the system's service. It runs as root under launchd, or as LocalSystem under the Windows service manager, starts at boot and is restarted when it stops, so the signed-in user cannot stop it. It is registered only from a program that only root or an administrator can replace. Beside it, pair, run and status from an account that does not administer the machine are refused, so this binary will not start a second bastion in that account. An older or modified copy is not bound by that refusal; what keeps the service in place is that the signed-in user does not administer the machine.
  • Observation changes nothing on the machine. Observe ops read and report. No input injection lives in the bastion. The observe ops that send traffic are observe:net, an outbound probe to a host you name; observe:k8s, a read-only query of a cluster API; and the three monitoring ops (observe:alerts, observe:metrics, observe:logs), read-only HTTP against an Alertmanager/Prometheus/Loki on the box's own network — private addresses only, re-checked at the socket so neither a redirect nor a DNS answer can walk a query out of that neighbourhood.
  • A backup's key goes to a host only when you approve it here. A host's request to restore files a request and gets nothing until you approve it on this machine; the key sealed to the host then opens that one backup and no other, and this bastion's key never leaves this machine. Thinning the backup folder removes only backup files this bastion recorded fetching, still as they arrived, judged by arrival here. A host sends from this machine only backup files in its own folder or a folder you shared with it.
  • No delete op on shared stores. The one write lane is a move plan you approved, and it archives rather than deletes.
  • Verified releases. The self-updater checks the release manifest's signature against the release keys compiled into it, then checks the binary's SHA-256 against that manifest before anything is swapped. The agent download is checked the same way, and an installed agent is never replaced with an older one. The release key never reaches the machine that builds and publishes: each release waits until the key holder signs it on their own computer. The installer checks the SHA-256; see SECURITY.md for what that does and does not prove.
  • No secrets cached for git. The credential helper fetches a token from the host on every auth and stores nothing itself, and git-setup resets the forge's helper chain before adding it. With git 2.46 or later, git's own store and cache helpers keep nothing, wherever they are configured; a helper that records everything it is given still receives the token. A git older than 2.46 gets the username and password, and for it only helpers configured before git-setup's lines are kept out.

How each claim is tested

claims.md names, for each point above, the test that fails if it stops being true, and says where there is none.

To report a security issue, please do not open a public issue. See SECURITY.md.