berthdocs

Concepts

Security model

Identity, pinned mutual TLS, pairing, where berthd listens, and what is still open.

Every connection in Berth is between two keys that have met before. Nothing on a box is reachable without a paired laptop, and nothing is public unless someone shares it.

Identity and transport

Every installation has an Ed25519 key; its identity is the SHA-256 of the key's SubjectPublicKeyInfo. Certificates are regenerated on every start and carry no meaning beyond the key.

Connections are TLS 1.3 with mutual certificates. Each side accepts only the other's pinned key, never a certificate authority. On top, HTTP/2 multiplexes pings, API calls, TCP streams and terminals over one connection per box, with health pings to notice a connection that died silently.

An unpaired client can complete a handshake but only attempt pairing, which is rate limited. Every other request is refused with the same answer, whatever the reason. A trust store that cannot be read authorizes nobody.

Pairing

  1. On the box, berthd pair stores a random 256-bit single-use code (ten minute expiry) and prints berth://HOST:PORT?code=…&fp=<box fingerprint>.
  2. The laptop connects to HOST:PORT and pins fp.
  3. The laptop proves it holds the code without sending it: an HMAC keyed by the code over TLS exported keying material (RFC 5705) and the laptop's own fingerprint, so the proof is bound to this session and this key.
  4. The box consumes the code under a file lock and pins the laptop's key.

berth add ssh HOST does all of this over one SSH session: it detects the box's platform, uploads the matching berthd, installs the service, and pairs. The install script runs on the box itself and ends with step 1, so the laptop needs no SSH at all.

On the box, berthd clients lists paired laptops and berthd revoke <name|fingerprint> stops trusting one. On the laptop, berth forget BOX removes a box.

Where berthd listens

By default berthd listens on the box's tailnet address only and refuses to start if there is none. Listening on the internet takes an explicit --listen 0.0.0.0:7444. berthd pair advertises the address it actually listens on.

Reaching other tailnets

A laptop's system Tailscale can be on only one tailnet. For boxes on another (a personal tailnet while the Mac is on a work one), the agent runs an embedded Tailscale node per named network (berth network login personal, a one-time browser sign-in). Each paired box records the network it is reached through; pairing, the agent's connections and add ssh all dial through it. Berth's own pinned TLS still runs on top: the tailnet only provides the route.

Local URLs

Every port on a box is reachable at http://<port>.<box>.localhost:1377/. All operating systems resolve *.localhost to loopback, so no DNS is involved. The proxy presents requests to the app as localhost:<port> and rewrites the app's own redirects back, so dev servers need no configuration. Unknown hosts are refused, which blocks DNS rebinding. Fixed-port forwards listen on both loopback addresses and refuse a port another program already holds on ::1, where localhost resolves first on macOS.

The app's API on 127.0.0.1:1378 takes a bearer token from a file only your user can read, checks the Host header, and allows only the app's own origins; see the app's API.

Public shares

Sharing is opt-in, one port at a time (berth share BOX PORT). The box runs a Cloudflare quick tunnel, so a link survives the laptop sleeping. Shares end when revoked or when berthd stops or upgrades; none outlive the daemon that manages them. Agents' skills tell them never to share a port unless a person asked.

Upgrades

berth upgrade BOX uploads the matching daemon over Berth's own connection. The box runs the upload once and requires it to report the box's own fingerprint, swaps it in, and re-executes in place, keeping its PID so the supervisor sees no restart and sessions are untouched. Public shares stop first.

Secrets and prompts

  • A project's environment can name secrets (op://… for 1Password, env://… for a variable in berthd's own environment) instead of holding them. The box reads them when needed and never writes a value to disk, a log, an event or the API; see secrets.
  • Prompts never appear in events or logs: session.sent carries only the session's name, and agent tool hooks forward only identifiers and paths.
  • The offline queue keeps prompts on the laptop, in its state directory, and never puts them in an event.

The phone

Each box can serve a small phone app on its tailnet address, off by default, with a token per box. Its threat model is on the phone page.

Gates

before: hooks on a box can refuse actions (creating worktrees, starting sessions, sending prompts, running exec, installing kits, changing config), whoever asks: the app, the CLI, a flow, an agent or the phone. See hooks.

Open questions

  • An independent review of the pairing protocol and the pre-authentication surface before teammates rely on it.
  • Handshake flooding: pairing is rate limited, TLS handshakes are not.
  • Team sharing: one box used by several people, and how access is granted and revoked beyond per-laptop pairing.
  • Windows, and dropping the proxy port with a privileged helper.

Ready to pair? Add a box.

On this page