diff --git a/packages/paperclip-runner/docs/phase-03-durable-transport.md b/packages/paperclip-runner/docs/phase-03-durable-transport.md index 1c2434b0f1d..1635dfd8f81 100644 --- a/packages/paperclip-runner/docs/phase-03-durable-transport.md +++ b/packages/paperclip-runner/docs/phase-03-durable-transport.md @@ -5,9 +5,12 @@ Rust `paperclip-runnerd` process is the client. The TypeScript mock core is the remote peer. Neither side imports Paperclip server, UI, database, or shared control-plane code. -This is a local reliability proof. The mock uses `ws://127.0.0.1` so tests do -not need certificates. A production bridge must use `wss://` and remains a -later, separately reviewed phase. +This is a local reliability and authenticated-transport proof. The mock keeps +the RFC 6455 carrier on `ws://127.0.0.1`, but PRP application frames use a +mutually authenticated, encrypted session. Loopback position and the +WebSocket acceptance header are not authentication. A production bridge should +still use `wss://` for defense in depth and remains a separately reviewed +deployment phase. ## Connection and authentication @@ -16,23 +19,36 @@ The connection starts in this order: 1. The mock core creates a random bootstrap ticket with a five-second lifetime. 2. The ticket is passed to the runner through `PAPERCLIP_RUNNER_BOOTSTRAP_TICKET`. It is not a command-line argument. -3. The runner opens an outbound WebSocket and sends the ticket in the HTTP - `Authorization` header. -4. The runner sends a PRP v1 `hello` envelope. It names the stable runner and - environment lease, approved runner version and digest, platform, - capabilities, and durable resume cursors. -5. The mock core consumes the ticket once and sends a `welcome` envelope. The - welcome selects PRP v1, returns a short-lived connection lease, reports the - cumulative committed event cursor, and carries at most one pending command. -6. Later connections authenticate with the connection lease. A real runner +3. The runner opens an unauthenticated WebSocket upgrade with no bearer header, + then sends only a public credential locator, a fresh client nonce, complete + runner/run/session identity, approved runner version and digest, negotiated + protocol range, and durable resume cursors. +4. The core returns a fresh server nonce plus an HMAC-SHA-256 proof over the + complete transcript. The proof binds runner, environment lease, run, + normalized session, turn, item, runner artifact, selected protocol, + credential/connection lease identity, expiry, and revocation epoch. +5. The runner validates that proof before returning its own transcript-bound + proof. The bootstrap ticket or connection lease token itself never crosses + the socket. A failed proof does not consume a one-use bootstrap ticket. +6. Both sides derive directional AES-256-GCM keys from the capability and both + nonces. Strict per-direction counters reject replays and out-of-order frames. +7. Only after mutual authentication does the core send an encrypted `welcome`. + The welcome selects PRP v1, returns a short-lived connection lease, reports + the cumulative committed event cursor, and carries at most one pending + command. Every later ACK, command, revoke, event, and command result remains + inside the encrypted session. +8. Later connections authenticate with the connection lease. A real runner process restart receives a new one-time bootstrap ticket and loads the same durable state before it connects. The runner keeps the live connection lease token in memory only. Its state file does not contain the bootstrap ticket or connection lease token. The mock core -persists only SHA-256 digests of those capabilities. A used or expired ticket is -rejected with `401`. The mock also rejects a changed runner identity, environment -lease, runner version, or runner digest. +persists domain-separated SHA-256 authentication keys instead of raw +capabilities. Used, expired, revoked, or identity-mismatched credentials fail +closed during the challenge. The runner validates the complete encrypted +welcome and every control envelope against the authenticated connection, +runner, environment lease, run, normalized session, turn, item, protocol, +lease ID, expiry, and revocation metadata before applying an ACK or command. The daemon captures and removes the bootstrap environment variable before it parses arguments or starts child work. Secret buffers are overwritten when they @@ -67,13 +83,19 @@ The state contains: - stable runner, environment lease, run, session, turn, and item IDs; - next source event sequence and cumulative acknowledged source sequence; - unacknowledged event envelopes and their byte counts; -- a processed-command cache with a non-secret command digest and prior result; +- a bounded recent processed-command cache with a SHA-256 command digest and + prior result, plus a fixed-size fail-closed replay filter for compacted IDs; - lifecycle, reconnect, backpressure, harness generation, and recovery facts; - bounded, redacted diagnostics. Authentication capabilities and arbitrary command bodies are not stored. A -command is represented by an FNV-1a comparison digest, its stable ID and -controller sequence, the redacted result, and the logical-effect count. +recent command is represented by a SHA-256 comparison digest from the vetted +RustCrypto implementation, its stable ID and controller sequence, the redacted +result, and the logical-effect count. The exact cache keeps at most 128 entries. +Older IDs are added to a fixed 4 KiB Bloom filter before their exact records are +removed. A possible filter match is rejected with zero effects, so Bloom false +positives can reject new work but can never make an old command effective again. +The durable controller sequence remains monotonic across compaction and restart. ## Event delivery and ACKs @@ -113,6 +135,25 @@ The trace records `logicalEffectCount`. Every completed command has exactly one logical effect. A policy rejection, such as a new turn during drain or storage pressure, has zero effects. +## Residual local trust and revocation window + +The authenticated session removes trust in whichever process wins the configured +loopback port: a relay can forward opaque bytes, but it cannot learn a bootstrap +or lease capability, decrypt control data, or forge a welcome, ACK, command, or +revoke frame. The remaining local-host assumption is that the OS protects the +runner process memory, inherited bootstrap environment at launch, private +`0700`/`0600` state paths, and the mock core's derived authentication keys from +other same-user processes with debugging, memory-reading, or filesystem access. +An attacker with those privileges is outside this transport boundary. + +Lease expiry is checked locally before control data is applied. Explicit +revocation reaches an already-connected runner through an authenticated revoke +frame; if that frame cannot be delivered, the session remains usable until the +connection closes or the runner reaches the signed lease expiry. The mock uses +a 30-second lease, so that is the maximum demonstrated revocation window. A +production core should close active sessions when it revokes a lease and choose +the lease TTL to match its required revocation bound. + ## Restart and reconciliation A socket drop keeps the same Rust process and in-memory lease. The process