Concepts

Pairing (end-to-end, over an untrusted rendezvous)

Pairing is a persistent, end-to-end-encrypted relationship between a client (the CLI today; mobile/web later) and a daemon (agentproto serve). Its goal is to let a client reach a daemon that only ever dials outbound, through a broker that cannot read or forge the traffic — bootstrapped by a single offer URL / QR code, with no accounts, no DNS, no inbound ports, and no trusted middlebox. This page describes what ships today: the cryptographic library layer, the rendezvous broker, the pair CLI/MCP verbs, on-disk persistence, reconnect epochs, and autoconnect on boot. Pairing also works with no config — pair offer defaults to the hosted broker wss://rdv.agentproto.sh/v1, which relays only ciphertext (see The hosted default). Still to come: the mobile deep-link page and the pairing spec, AIP-59 (draft, agentproto/agentproto#41) — see Status at the bottom.

Jump to the commands: pair (offer / accept / ls / revoke / exec) and rendezvous (self-host the broker).

Why

Every remote path into a daemon today trusts an intermediary with plaintext:

SurfaceIntermediary seesAuth
tunnel_create (cloudflare/ngrok)everything (TLS terminates at their edge)daemon bearer
remote_enable (quick tunnel)everythingper-enable bearer
serve --connect reverse tunneleverything (host terminates the WS, frames are plaintext)apt_ token at upgrade

Pairing removes the trusted middle: the broker splices two sockets and relays ciphertext byte-for-byte. It learns the route token each peer dials, the peers' IPs, timing, and ciphertext sizes — never the content, and it cannot inject or alter frames. A route token is an opaque meeting-point name that authenticates nothing (see Route and auth tokens).

Threat model

AdversaryCapabilityMitigation
Rendezvous operator (or anyone logging its upgrade URLs)read / modify / replay bytes; learns every route token; can connect to any route as a client and send a well-formed hello sealed to the daemon's public keyE2E AEAD + transcript-bound signature; the daemon authorises only a sealed auth token that is a separate one-way HKDF output, not derivable from the route — a hello presenting the route (or any guess) is refused, spends no offer, and is never served
Offer-URL thief (pre-expiry)pair as a new clientshort TTL + single-use offer secret; daemon shows name + fingerprint on accept; pair revoke
Evil "daemon" (wrong QR)impersonate the daemonfingerprint shown at offer and accept; keys pinned after first pair
Stolen client credstoreact as that clientper-client revocation; pairings.json audit (lastSeen); browser pair root is a non-extractable CryptoKey
Broker DoSdrop / delay trafficreconnect-with-backoff; self-host escape hatch
Broker fakes "revoked"make a client give up / forget its pairingthe pairing_revoked signal only counts inside the E2E channel, after the daemon's signature verified; a broker close code or reason never does

Out of scope for v1: post-compromise security (no ratchet — rekey on reconnect only), multi-device sync, and broker federation.

The library layer

Three pieces, all built on X25519, Ed25519, HKDF-SHA256 and AES-256-GCM with zero native dependencies. The primitives sit behind a small async crypto interface with two implementations — node:crypto (the default in Node) and WebCrypto (the default in a browser) — so one implementation of the protocol runs in both, byte-for-byte identically. The browser-safe entry points are @agentproto/secrets/pairing/browser (handshake, offer codec, seal, identity signatures) and @agentproto/acp/tunnel/browser (frame codec, wrapE2E, handshake-over-sink); nothing reachable from them imports a node: builtin.

1. Daemon identity — @agentproto/secrets/identity

A daemon's persistent identity, stored ~/.agentproto/identity.json (mode 0600, atomic write), created lazily:

  • an X25519 keypair for key agreement (the client seals its hello to it, and it is one ECDH input to the session key), and
  • an Ed25519 keypair for authenticity (the daemon signs the handshake transcript so the client can prove it reached the daemon it scanned).

The fingerprint is the first 32 lowercase hex chars (128 bits) of sha256(x25519 pub), and is what a human confirms at offer and accept time. It also names the daemon's browser origin (<fingerprint>.agentproto.cloud), so it is sized so nobody can grind a key whose fingerprint lands on another daemon's origin. A seal key id is its first 16 hex chars.

2. Handshake — pair/v2 (@agentproto/secrets/pairing)

A minimal, Noise-flavoured, two-message handshake:

client → daemon:  e_pub                        // ephemeral X25519
                  ct₀ = Seal(to = daemon_x25519,
                        {clientPub: e_pub, clientName, auth})
daemon → client:  d_e_pub, sig = Ed25519(daemon_ed25519,
                        transcript = sha256(e_pub ‖ ct₀ ‖ d_e_pub))
both:             K  = HKDF-SHA256(ECDH(e, d_e) ‖ ECDH(e, daemon_x25519),
                        salt = transcript, info = "agentproto/pair/v2")
                  → K_c2d, K_d2c   (two AES-256-GCM keys)
  • The client verifies sig against the Ed25519 key it learned out-of-band (the offer URL) → daemon authenticity, no CA.
  • The daemon opens ct₀ (only its X25519 private key can) and checks the sealed auth token in constant time → client authenticity. auth is never the value the broker saw on the upgrade URL (see below).
  • Everything is transcript-bound: sig covers the whole transcript and the transcript salts the key schedule, so any tampering with e_pub, ct₀, or d_e_pub in flight makes the signature or the derived keys disagree. The handshake fails closed with a typed PairingError, never continuing on attacker-chosen material.

This module is transport-agnostic: it produces and consumes plain messages, so the code that pumps them over a socket never touches key material beyond the two derived session keys.

3. Channel — wrapE2E (@agentproto/acp/tunnel)

There is no new wire protocol. The existing agentproto/tunnel/v1 frames (http_request, ws_open, spawn, …) are reused verbatim; wrapE2E wraps a FrameSink so each outgoing frame is serialized then AEAD-encrypted, and each incoming envelope is decrypted and counter-checked before it reaches the tunnel:

wrapE2E(sink: FrameSink, keys: { sendKey, recvKey }, opts?: { aead? }): FrameSink

Encryption is async (WebCrypto is), but send stays fire-and-forget: each frame gets its counter when send is called, and ciphertexts go out — and decrypted frames come in — strictly in counter order.

It is transparent — createTunnelClient / createTunnelServer work unchanged over a wrapped sink, so the whole daemon HTTP surface (MCP, sessions, permissions inbox, PTY) rides a pairing with no code changes.

Nonce discipline (the security-critical part):

  • Two independent keys, one per direction — a frame can never be reflected and decrypt.
  • A per-direction, strictly-monotonic 64-bit counter is the GCM nonce (never random — GCM nonce reuse is catastrophic) and is also bound as AEAD associated data.
  • The receiver requires the exact next counter. An older/repeated counter (replay), a higher one (drop / reorder), a flipped byte (auth), or a plaintext frame (downgrade) each becomes a typed E2eError that closes the channel — no tampered or out-of-order frame is ever delivered upward.
  • A rekey guard errors before the counter could ever overflow (2³²); v1 has no ratchet, so it rekeys on reconnect rather than mid-session.

Bearer interaction is unchanged: pairing authenticates the peer; spawn authorization (authorize(spawn)) stays a separate decision, and the pairing layer never learns or transports the user's daemon bearer.

Broker, ceremony, persistence

The rendezvous broker — @agentproto/rendezvous

A deliberately dumb WebSocket server: it matches two sockets sharing a one-time token and splices them byte-for-byte, never parsing payloads. Hygiene only — park timeout, post-splice idle timeout, max message size, per-IP token-attempt rate limiting, single-use tokens, constant-time token compare. Self-hostable via agentproto rendezvous serve.

The ceremony — agentproto pair

  • pair offer (daemon) mints a single-use offer URL + QR, dials the broker outbound, and parks. pair accept (client) validates the URL, runs the client handshake, pins the daemon's keys, and persists the pairing.
  • pair offer --qr shows the same offer as a phone link: the web pair page with the offer in its URL fragment (https://<fingerprint>.agentproto.cloud/pair#v=2&rv=…). A fragment never reaches a server. The page runs the client handshake in the browser with @agentproto/pair-client (WebCrypto, WebSocket, IndexedDB), which speaks the same wire protocol as pair accept, so the daemon can't tell the two apart. parseOfferUrl accepts both forms. Which page serves the link is configurable (pairing.pairPage, --pair-page); see The phone pair page.
  • pair ls lists pairings (daemon REST, or the client store when offline); pair revoke drops one so its client can no longer reconnect.
  • Revocation is announced. Otherwise a revoked client and an offline daemon look the same: the client parks at the broker and gets a park timeout. So for a grace window (14 days) the daemon keeps a tombstone of the revoked pairing: the epoch route and auth tokens for the window only, not the pair root. It still parks on the routes. For a hello that carries that epoch's auth token, it completes the handshake, which authenticates it to the client. It then sends a single E2E error{code:"pairing_revoked"} and closes. A client that sees it stops retrying and asks the user to pair again. A live channel gets the same frame at revoke time.
    • The broker learns nothing new: it sees the same parking and a short splice.
    • It can't forge the signal, which is authenticated like every tunnel frame.
    • It can't elicit the signal either: a route is never accepted as proof.
    • A revoked legacy (pair/v1) pairing gets no tombstone; its client can only be told to re-pair.
  • pair exec <name> -- <verb> routes any verb over the pairing (the P2 client routing seam — a loopback bridge that a child agentproto <verb> drives via AGENTPROTO_DAEMON_URL).

MCP tools mirror the daemon-side verbs: pair_offer, pair_list, pair_revoke. REST routes: POST /pairings/offer, GET /pairings, DELETE /pairings/:fp.

Persistence, reconnect epochs, autoconnect

  • Daemon pairings live in ~/.agentproto/pairings.json (0600); the client half (pinned daemon keys + the pairRoot secret) in ~/.agentproto/pair-credentials.json (0600). A browser client keeps the same record in IndexedDB, with the pair root imported as a non-extractable WebCrypto HKDF key. It never uses localStorage.
  • After the first pairing there is no live offer, so reconnects use pairing-derived epoch tokens (epoch = UTC day number): the client dials the epoch route and proves the epoch auth inside the sealed hello (see Route and auth tokens). Both sides derive them; the daemon accepts the current and previous epoch to bridge clock skew, and rotating them per day keeps the broker from linking sessions across days.
  • A pairing made under the retired pair/v1 protocol can't reconnect — see Protocol v2 and re-pairing.
  • With pairing.autoconnect on (default when a rendezvous is set), the daemon opens a standing rendezvous connection for every persisted pairing on boot — the same pattern as tunnel.autoconnect — so a paired client can reconnect anytime. Config keys: pairing.rendezvous, pairing.autoconnect (see config-schema.md).

Route and auth tokens

Every pairing secret is split into two HKDF-SHA256 outputs with distinct labels. The route is the only value that ever goes on a broker upgrade URL (?side=…&t=<route>); the auth token travels only inside the sealed hello, which only the daemon's X25519 key can open, and the daemon compares it in constant time. HKDF is one-way, so knowing a route — which the broker always does — doesn't give you its auth token.

TokenIKMsaltinfoBytes
offer routeoffer secret (URL s, UTF-8)agentproto/pair-offeragentproto/rv-route16 (22 b64url chars)
offer authoffer secret (URL s, UTF-8)agentproto/pair-offeragentproto/rv-auth32
epoch routepairRootagentproto/rv-route-saltagentproto/rv-route ‖ u64be(epoch)16 (22 b64url chars)
epoch authpairRootagentproto/rv-auth-saltagentproto/rv-auth ‖ u64be(epoch)32
  • Offer: the daemon parks on the offer route and the client dials it; the hello carries the offer auth. The offer stays single-use and is spent only by a hello with the correct auth — a wrong one spends nothing, so the legitimate client can still pair until the offer expires.
  • Reconnect: the daemon parks on the current and previous epoch routes; the hello carries that epoch's auth.
  • What the broker learns: route tokens only. They are opaque (a random- looking 22-char string), rotate daily for reconnects, and authenticate nothing — the broker can meet a daemon on one, but it can't get past the handshake. The offer secret and every auth token never touch the broker.

Protocol v2 and re-pairing

pair/v1 used one token for both jobs: the offer token (and, on reconnect, the epoch token) was both the broker route and the proof inside the sealed hello. A broker — or anyone logging its upgrade URLs — could therefore connect to the route as a client, seal a hello to the daemon's public key carrying the route it had just seen, and pair (first contact) or be served a full channel (reconnect replay). pair/v2 fixes this with the route/auth split above.

There is no compatibility window, and v1 pairings aren't upgraded in place (under v1 any stored pairing might be the broker's own). Re-pair once: run agentproto pair offer on the daemon and agentproto pair accept on the client, then agentproto pair revoke <name> the old entry. Mismatches fail with that instruction, not a timeout:

  • A v1 offer URL (v=1) is refused by a v2 client (pairing_protocol_outdated); a v1 client refuses a v2 URL (unsupported offer version "2" — upgrade it).
  • A v1 pairings.json / pair-credentials.json loads with every entry flagged legacy: pair ls marks them, the daemon logs them, and they are never served or dialed.
  • An un-upgraded v1 client that reconnects to a v2 daemon still meets it (the epoch route is unchanged from v1). The daemon completes that client's v1 handshake only to send the re-pair notice inside the encrypted channel, then closes. It checks no token and serves nothing.
  • A v2 client whose daemon hangs up on its hello (a v1 daemon) gets an error that names the likely cause and the fix.

The phone pair page: one origin per daemon

The page a phone opens from the QR is a static web app. It runs the handshake with @agentproto/pair-client, keeps the credential in IndexedDB, and installs the service worker that proxies the daemon's UI. The browser scopes all of that to the page's origin. If every daemon's pages shared one origin, a script on that origin could read every pairing's credential and reach every daemon's UI, and a bug in one daemon's app would reach the others.

So the pair page is a template with {fp} in the hostname, filled with the daemon's identity fingerprint. The default is:

https://{fp}.agentproto.cloud/pair   →   https://a1b2c3d4e5f607189c3e5d7f1a2b4c6d.agentproto.cloud/pair#v=2&…

Each daemon then gets its own origin. The browser keeps its credential, storage, service worker and UI apart from every other pairing, with no shared state to leak.

  • Rules: {fp} is only allowed in the hostname. The fingerprint must be a valid DNS label (it's 32 lowercase hex chars).
  • The page refuses an offer whose daemon fingerprint isn't its own origin's first label, before any network I/O, and stores only that daemon's credential (AIP-59 §5.8). expectedPairHost(template, fingerprint) gives a page the host to compare with location.host.
  • The default is https://{fp}.agentproto.cloud/pair (PAIR_WEB_URL_TEMPLATE_CLOUD): one static page (packages/pair-page) served on every <fingerprint>.agentproto.cloud by a Cloudflare Worker, which answers 404 for any host whose first label isn't a fingerprint. Change it with pairing.pairPage in config.json or pair offer --qr --pair-page <…> (the flag wins).

Self-hosting the pair page. The page is a static bundle (packages/pair-page: no server-side code, and the offer stays in the URL fragment). Serve it from your own host and point pairing.pairPage at it:

  • one origin per daemon, "https://{fp}.pair.example.com/pair", which needs a wildcard DNS record and TLS certificate (recommended), or
  • a single shared origin, "https://pair.example.com/pair". Every paired daemon's UI then shares that origin with every other pairing's credential (AIP-59 §5.8), so tell your users.

The page only needs to reach the rendezvous broker named in the offer.

The hosted default

pair offer needs a meeting point. So that it works out of the box, the daemon defaults to a hosted broker when none is configured:

Precedence: --rendezvous flag → pairing.rendezvous in config.json → the hosted default wss://rdv.agentproto.sh/v1.

What the hosted broker can see is exactly what any rendezvous can see, and no more: it splices two sockets by route token and relays ciphertext byte-for-byte. It learns the route tokens, the peers' IPs, ciphertext sizes, and timing — never plaintext, never an auth token — and it cannot pair, inject, alter, or replay frames (the pair/v2 handshake is transcript-bound and every frame is AEAD-sealed with a monotonic nonce; see Threat model). This is the same guarantee as a self-hosted broker; the only thing that changes by default is who runs the box.

Because that is a trust decision, it is never silent: pair offer names the broker it used and flags the hosted default (the REST/MCP surfaces carry a rendezvousIsHostedDefault field).

Pointing elsewhere. Self-host the broker with agentproto rendezvous serve and set pairing.rendezvous in config.json (or pass --rendezvous per offer). To disable the default entirely — a daemon that must never reach the hosted broker unless an endpoint is named explicitly — set pairing.rendezvous: ""; pair offer then requires an explicit --rendezvous.

Offer scope (reverse pairing)

pair offer --host mints an offer carrying &scope=host (@agentproto/secrets/pairing's offer-url.ts). It changes nothing about the pair/v2 wire handshake — scope is plaintext metadata in the offer URL's query string, never inside the sealed hello — and it changes nothing about actual technical capability: a plain offer and a host-scoped offer are cryptographically identical in what they grant. pair accept + pair exec already give the accepting side full CLI-verb-equivalent control of the offering daemon, today, with no --host involved.

What scope actually gates is registration: agentproto devices add refuses to register a daemon as a driveable host from an offer that isn't host-scoped. That stops an ordinary "let my phone remote-control me" offer from silently becoming a host registration on the accepting side. There is no override flag.

Because scope is unauthenticated, a relay could flip it in transit — that can only mislead the accepting side's own bookkeeping about what it thinks it registered; it cannot grant it anything it couldn't already reach via pair accept + pair exec. The offering daemon's own record of what it granted (its OfferEntry, then the persisted PairingRecord.scope) is set from what it asked for when it minted the offer, never from anything a client's hello claims — the hello has no scope field at all.

Revocation is unchanged: pair revoke / devices revoke on the offering daemon drops a pairing (host-scoped or not) exactly as it does today.

Setting up a host-scoped pairing (and sharing its local inference) from a locked-down machine with no admin rights and behind a corporate HTTPS proxy is covered end to end in No-admin install.

Status

  • Shipped: the daemon identity module, the pair/v2 handshake, the wrapE2E channel, and the adversarial test suite (tampered-broker vectors: flip / drop / reorder / replay / downgrade), proven end-to-end over an in-process socket pair; the @agentproto/rendezvous broker package; the pair CLI/MCP verbs (offer / accept / ls / revoke / exec); pairing persistence, reconnect epochs, and autoconnect on boot; and the hosted broker wss://rdv.agentproto.sh/v1, deployed and now the default meeting point for pair offer (see The hosted default).
  • Still to come: the mobile deep-link page and the pairing spec, AIP-59 (draft, agentproto/agentproto#41).