From bfef7c8dc42106e05bf565c385383ccd49078421 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:02:38 -0700 Subject: [PATCH 01/92] docs(ssh-relay): Milestone 2 sealed agent relay design spec Resolves the five open items from the ssh-anywhere design section 16: machine identity bootstrapped at enroll, destination upgrade reuses ordinary enrollment, first-use approval creates a grant, agent-only key home, node-link relay frames as the sealed shuttle. --- .../2026-10-08-ssh-agent-relay-design.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md new file mode 100644 index 000000000..1069582a3 --- /dev/null +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -0,0 +1,181 @@ +# SSH sealed agent relay (Milestone 2) design + +**Status:** approved design, pre-plan. +**Date:** 2026-10-08. +**Parent:** [`2026-10-07-ssh-anywhere-design.md`](./2026-10-07-ssh-anywhere-design.md). This spec is Milestone 2 of that program and does not restate mechanics the parent already fixes. Where they agree on a detail (the §8 relay protocol, §9 host-key doctrine, §12 failure convention, §13 security rules, §14 M2 test list), this file is the elaboration and the parent is the summary; where this file resolves a parent §16 open item, this file governs. +**Milestone 1** (terminal + jump, PRs #334/#336/#337 on `feat/ssh-anywhere-1/2/3`) is the base. This work builds on the seam its launcher exposes and is its own spec and its own security review. + +## 1. Goal and scope + +Milestone 2 delivers relay mode: an interactive SSH pane that runs on a connecting machine **B** which holds no signing key, authenticating to an arbitrary sshd-only destination **D** through the ssh-agent of a different machine **A** that does, over a shuttle the plane routes but cannot read. It also delivers the two things relay mode needs to be operable (the machine-identity roster and the key grants) and the one act that turns a successful relay destination into a first-class node ("Set up Subshell here"). + +Milestone 2 is done when the parent's §17 sentence for M2 holds: the same pane runs on a machine with no key at all, authenticating through a machine that does, with a CI test proving the plane sees only opaque envelopes and the key never leaves its home. + +**In scope:** the per-machine signing identity and its enrollment/bootstrap; the node-side machine pin store; the sealed agent relay (proxy on B, responder on A, broker and blind router on the plane); the method allow-list and identity scoping; `ssh_key_grants` and the first-use approval that creates them; the M2 host-key pin path (`ssh_host_pins`); the destination upgrade; grants/trust UI; audits; the tests in §14. + +**Out of scope (unchanged from the parent §2):** a general SSH-protocol proxy, X11/agent forwarding toward D, port forwarding, key custody or copying of any kind, multi-operator separation of duties, and any change to the OS-user trust boundary. A `ProxyCommand`-based hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. + +## 2. Terminology (adds to the parent §3) + +- **A / key home:** the machine whose ssh-agent signs. Must have `ssh_enabled` on (§4.3 parent) and be online for the handshake window. +- **B / connecting machine:** the machine that runs the pane and the `ssh D` process, and holds no key. `ssh_enabled` on. +- **D / destination:** any sshd. Not gated; dialed to. +- **Signing identity:** the per-machine ES256 keypair introduced here. Distinct from the encryption identity (ECDH-ES) that already lives in `apps/node/agent/src/identity.ts` and from the plane-held ES256 command keypair in `control-keys.ts`. +- **Machine pin:** a byte-equality pin of a peer's signing public key, held in the node-side machine pin store (new), never in the channels' `peers.json`. +- **Relay session:** a bounded plane-brokered pairing of (grant, A, B, pane/subshell id, opaque routing ref, lifetime). + +## 3. The five open items, decided + +These close the parent §16 list. Each is elaborated in its section. + +1. **Machine-identity bootstrap** (§4): the signing keypair is minted and registered at **enroll**, so relay trust anchors on the enroll event rather than a fresh plane-mediated first-use. A machine trust card carries the fingerprint confirmation. +2. **Destination upgrade** (§7): reuse ordinary enrollment over the open session. No #330 runtime-session re-port. +3. **Per-use approval on A** (§6): a first-use prompt creates a standing grant; later uses under that grant are silent. The approval flag ships as the act of grant creation, not a separate toggle. +4. **A's identity source** (§5.4): agent-only. A's identity files on disk are never read or served; a key becomes usable remotely only once loaded in A's agent. +5. **Relay-only destination auto-enroll** (§7): collapses into the destination upgrade. Enrollment creates the node row; "Set up Subshell here" wires mint-key to run-enroll to the node appearing. + +## 4. Machine identity and pin store + +### 4.1 A per-machine signing keypair + +`identity.ts` today generates and holds only the ECDH-ES encryption keypair; the ES256 keypair that signs node commands is plane-held (`control-keys.ts`), so no per-machine signing key exists. M2 adds one: the node generates an ES256 keypair at the same moment and to the same posture as the encryption pair (0600 in the node data dir, private half never leaves the machine, regenerated only by explicit re-enroll or key rotation). It signs the relay payloads of §5.6; it is not a command-signing key and never authorizes a plane command on its own. + +### 4.2 Register at enroll + +The enroll route already carries the node's ECDH-ES public key into the identities store under the `node:` principal. It now also carries the signing public key, over the same setup-key-authenticated request. The identities store (or its `node:` record) gains a signing-key slot beside the encryption slot. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the moment an operator deliberately attached the machine. + +### 4.3 Bootstrap for pre-M2 nodes + +Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command (`ssh_register_identity`) delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane stores it in the same slot. This is exactly the transport the parent §5.3 requires ("the peer's JWK must be delivered to it inside a signed command on the existing node link"), reused for the node's own key at first contact. Delivery is idempotent: a node that has already reported re-reports the same bytes and the store is unchanged; a node that reports a different key than one on file hits the §4.5 changed-key block rather than silently overwriting. + +### 4.4 The node-side machine pin store + +A relay is always pin-strict. A holds the peer signing keys it will verify against in a **new** node-side store, a distinct file from the pane-side channels `peers.json`, with its own reader that never consults `SUBSHELL_CHANNEL_PIN`. When A first needs to verify B (or B to verify A), the plane brokers the pairing and delivers the peer's signing public key inside the relay-open command; the verifying machine writes it to the machine pin store and thereafter enforces byte equality, mirroring `pin-store.ts`'s TOFU rule for the *comparison* only, never its escape hatch. A changed peer signing key is a hard block that names the peer machine and points at the recovery act of §4.5. + +### 4.5 Pin display and recovery + +The security rule the parent flags ("there is no operator-visible machine pin display or recovery flow") is closed by a **machine trust card** (§8.3): for every machine this one has pinned, and for this machine's own registered signing key, the UI shows a short fingerprint. Recovery from a genuinely rotated peer key (or a re-enrolled machine) is an explicit "re-pair" act that replaces the stored pin after a fresh enroll-time-anchored delivery; it is owner-only and audited. There is no "trust anyway" escape and no reuse of the channel escape. + +### 4.6 What this narrows, honestly + +The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Anchoring the signing key to enroll moves that boundary. The key an operator sees on the trust card is bound to the enroll event that only the setup key could create, so a plane compromised **after** any machine enrolled cannot swap it without tripping the byte-equality block. The residual window is a plane compromised **before B enrolled**, which is `docs/security.md` §0's accepted, not-defended control-plane-compromise boundary, not a new hole. The trust-card fingerprint is the optional out-of-band confirmation that lets the operator close even that by comparing the two machines' fingerprints directly; it is offered, not mandatory, so first use stays one approval (§6) rather than a copy-the-fingerprint ceremony. + +## 5. Relay runtime + +The protocol in the parent §8 is unchanged and governs; this section pins the pieces and the one new transport decision. + +### 5.1 Transport: node-link relay frames + +The sealed envelopes ride the `/ws/node` link, not the browser-facing `/api/channels` path. The reason is hard-wired in the parent §5.3: the node daemon holds no REST credential, so it cannot post to a channel; the node link is its only authenticated, encrypted, low-latency transport, and it is already app-layer encrypted (protocol 14) and authenticated by the node key. It also fits the traffic shape: the agent exchange is bidirectional (B to A requests, A to B responses) and bursty over a few seconds, which the one-shot plane-to-node command/reply model serves poorly and a channel log serves only by persisting traffic it should not keep. + +Concretely: the plane sends A and B each one signed command to **open** a relay session (carrying the routing ref, the peer's signing public key to pin, and the lifetime). After that, a new lightweight frame type on the open link carries sealed blobs both ways, keyed by the routing ref. These frames add no per-message command signing: the link already authenticates the machine, and origin comes from the inner ES256 signature (§5.6), so signing every frame would be redundant overhead on a stream. The plane matches the two links by routing ref and forwards the opaque blob without opening it. **Open**, **route**, and **close** are the plane's only roles in the payload's life. + +Because this adds a link frame type, it rides the `NODE_PROTOCOL_VERSION` bump already required for `ssh_enabled` (parent §4.3). The relay frames and the gate flip in one version. + +### 5.2 B: the agent proxy socket + +On B the launcher exports a scoped `SSH_AUTH_SOCK` (the parent §5.4 exception, into the `ssh` invocation only) pointing at a Unix socket B's node creates in a per-session directory, mode 0600, alongside the rendered connection config. The socket speaks the ssh-agent wire locally. Each request is framed, sealed to A, and handed to the plane as a relay frame; each reply is opened and written back to the socket. The socket is created for the handshake window and torn down when it ends; the established pane then needs no agent (parent §8, rekey re-verifies the host key, not the agent). + +### 5.3 Plane: the broker + +The plane brokers a pairing only when a live grant is used (§6). In-memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a quota; durable it writes only audit rows (§9). It never holds the agent payload; the payload lives solely inside sealed envelopes it shuttles. Teardown fires on handshake completion, on lifetime expiry, on A dropping, and on grant revoke. + +### 5.4 A: the responder + +On A a responder accepts only requests the plane brokered for a live grant, for a session naming this A. It enforces the parent §8 method allow-list: forward `SSH_AGENTC_REQUEST_IDENTITIES` and the sign/authenticate requests OpenSSH needs; refuse `ADD_IDENTITY`, `REMOVE_IDENTITY`, `LOCK`/`UNLOCK`, and extension requests, so a granted window cannot mutate or lock A's agent against its other panes. `REQUEST_IDENTITIES` is answered with A's agent keys **filtered to the identity refs the resolved destination snapshot names** (bounded by `SSH_MAX_IDENTITY_REFS`), never A's whole list. + +**Agent-only (§3.4):** the responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is simply absent from the filtered roster; the handshake fails as "no matching key in A's agent" (parent §12), which points at loading it and never reads the file. This keeps key bytes off both the wire and A's disk reads by Subshell. + +### 5.5 Plane blindness, restated for the record + +The envelope format is `packages/mcp-core/src/crypto.ts` `seal`/`open` (ephemeral-static ECDH-ES+A256KW, A256GCM, plaintext recipient-id header for blind routing). The plane reads the routing ref to pair and nothing else. A wire capture at the plane during the §10 crown-jewel test shows opaque envelopes and plaintext routing ids only. + +### 5.6 Origin, replay, and lifetime + +Confidentiality is not authenticity: anyone holding A's public key, the plane included, could craft a seal A opens. Origin therefore rides a signature, not the seal: the sender signs the inner payload with its machine signing key; the receiver verifies against its **machine pin** of the sender (§4.4), the ES256 JWS precedent from node command signing, the signature living inside the sealed envelope so the plane never sees it. Signatures run both directions and the relay-session nonce is bound into both, so a captured envelope is inert on replay even though the plane stores envelopes. Lifetime, the method allow-list, and "no forwarding toward D" are exactly the parent §8 last three bullets and are not relaxed here. + +## 6. Grants and the first-use approval + +### 6.1 The grant record + +`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), destination selector (an alias, a concrete host, or a host glob matched against the resolved destination hostname as policy only, unrelated to discovery's wildcards). No secret, no key bytes. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§7 of the parent, `ssh_host_pins`), so one selector covering many hosts yields many pins. + +### 6.2 First use creates the grant + +The first time a launch would route through a `(A, B, destination)` combination with no standing grant, A's operator receives one approval naming the requester, the key home, and the destination, through the existing notification/confirm surface. Approving writes the grant and proceeds; refusing aborts with a loud, named refusal (parent §12 convention). Subsequent uses under a matching grant are silent. The approval is the creation gesture; there is no separate per-grant "ask each time" flag shipped in M2, because the first-use prompt already carries that role and the residual scope risk is bounded the same way the parent §8 already bounds it (agent sign requests carry no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by serving only snapshot-named identities plus instant revoke). + +### 6.3 Management and revoke + +A grants screen (owner-scoped) lists, edits the selector/name, and revokes grants. Revocation is instant and cuts both ways: the plane row goes, and a live relay session using it is torn down so signing stops mid-session. A can also kill a session from its own trust surface. Creation via the screen and via first use write the same row and the same audit event. + +## 7. Destination upgrade: "Set up Subshell here" + +A secondary act inside an open SSH-terminal pane on D. It does not re-port #330's brokered runtime session. It reuses ordinary enrollment: the plane mints a setup key (existing route) and the act drives the standard `install.sh` enrollment from D over the already-open authenticated session, so D dials the plane and becomes an ordinary enrolled node, exactly as if the operator had run the one-liner by hand. The relay/launcher is the consumer that carries the command; it is not a new product surface. + +Consequences the parent §16 asked about, now fixed: a relay-only destination becomes a first-class node **by enrolling**, because that is the only way the node row is ever created; and the "lighter boot" question is moot because there is no bespoke runtime-session protocol in play. If D cannot reach the plane (no egress on D's network back to the plane URL), the act refuses cleanly with that named cause, distinct from any key error, and the working pane is unaffected. + +## 8. UI (adds to the parent §11) + +- The destination-first flow is unchanged. Its step-2 follow-up "sign with [machine]'s keys" now resolves to a relay: pick A as the key home, and the grant/approval path (§6) applies. +- **Grants screen:** list/create/edit/revoke, owner-scoped, under the SSH settings area. Normal creation is the first-use prompt; the screen manages and hand-adds. +- **Machine trust card:** this machine's own signing fingerprint, and every peer signing key it has pinned, each with a short fingerprint and a re-pair act (§4.5). Surfaced on the node detail so the operator can confirm out-of-band. +- **Host-key display:** destination pins visible with the changed-key block surfaced when a connect is refused on a byte mismatch. +- **"Set up Subshell here":** an act in the SSH-terminal pane, not a landing-screen mode (parent §11 keeps the destination-first landing intact). + +Copy follows the design system: role tokens only, at most two sentences per explanation, no em dashes. + +## 9. Data model, config, and audits (adds to parent §7, §10) + +**Schema.** A signing public key per node (a `nodes` column or an identities-store `node:` slot). `ssh_key_grants` and `ssh_host_pins` tables (M2 rows from parent §7; both ship in M2). Relay session state is in-memory on the plane with a durable audit row, no table. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and its mirror ship in M1. + +**New audit event names**, to add to `docs/security.md` §10, naming ids/hosts/field-names only, never payload, challenge, or signature: + +- `node.ssh_identity.register` (enroll-time signing key registration; and the pre-M2 link delivery, distinguished by a `via` field). +- `node.ssh_grant.create|update|delete`. +- `node.ssh_relay.open|close` (open names grant/A/B/pane and the routing ref; close names the reason). +- `node.ssh_host_pin.create|rotate|delete`. + +The relay pairing metadata the plane already holds (which machines talk, timing, sizes, routing ids) is disclosed by §10 of the parent as retained, not new. + +## 10. Trust model delta (refines parent §10) + +Gained, as the parent states, plus two specifics from these decisions: the plane never reads an agent payload (blind routing over its own link), and a plane compromised after any relevant enroll cannot forge a request A honors, because the signing key is enroll-anchored and byte-equality pinned. The §4.6 boundary is the only residual, and it equals §0's not-defended control-plane-compromise risk. Agent-only (§5.4) removes a whole class of exposure: no identity file is ever read by Subshell, no passphrase prompt lives on A, no key file transits. Revoke cuts a live session. A compromised B during its own granted window is still bounded by the allow-list and identity scoping (parent §10), unchanged. + +## 11. Failure modes (parent §12, plus M2-specific) + +Everything in parent §12 stands (A offline before handshake, no matching key, changed host key, no-reach vs no-key, relay expiry mid-handshake, loud quota/policy refusal). Add: + +- **No signing key on a node** (a pre-M2 node that has not yet delivered, or a corrupt store): name the machine and offer the register/refresh act; never proceed unsigned. +- **Peer signing key changed** since pinning: hard block naming the peer, pointing at re-pair (§4.5). +- **D cannot reach the plane during "Set up Subshell here":** a clean refusal naming egress, the working pane untouched. +- **Relay frame on a pre-bump agent:** the exact-match `NODE_PROTOCOL_VERSION` gate refuses pairing before any relay command, naming the version remedy (upgrade the agent), never silently half-relaying. + +## 12. Security rules honored (parent §13, restated where M2 leans on them) + +No key/challenge/signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config 0600 in a per-session dir, removed with the pane. `shellQuote` on every built-command token. Node command/link transport unchanged in trust: relay open/close are signed commands, relay frames ride the authenticated, encrypted link, origin is the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, not a bespoke cipher, and never honor `SUBSHELL_CHANNEL_PIN`. The method allow-list and identity scoping bound a granted window. Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. + +## 13. Testing + +The parent §14 M2 list is required and is not repeated here. Add coverage for the decisions: + +- **Units.** Enroll registers the signing pubkey alongside the encryption pubkey; `ssh_register_identity` pre-M2 delivery is idempotent and a mismatched key trips the block. The machine pin store is byte-equality strict, is a separate file from `peers.json`, and ignores `SUBSHELL_CHANNEL_PIN`; first pin then change is a block naming the peer. Grant CRUD and selector matching (alias / concrete host / glob-as-policy); first-use prompt creates the grant and a second use is silent; revoke stops signing mid-session. Agent-only filter: an identity file present on A but not in A's agent is refused with "no matching key," and A's agent roster is filtered to the snapshot-named refs. Relay frame codec round-trips and the protocol bump gates a pre-bump agent. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks and names D. +- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; replay of a captured envelope inside the window is inert. +- **End to end (the crown jewel).** The parent §14 M2 e2e verbatim: A with the key in its agent, B with none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it to assert a grant revoke between handshake and D is refused, and "Set up Subshell here" turns D into an enrolled node over an egressing D. +- **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. + +## 14. Branching, phasing, and review + +This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhere-3`), its own PR, and, per the parent §6.2, an expected focused security review pass; review-until-clean over the whole branch before any merge consideration. M1 remains unmerged at the operator's direction; when M1 lands, this branch rebases onto it. It is deliberately isolated behind the launcher seam M1 exposes, so the relay can be turned off without disturbing M1's direct and jump paths. + +**Likely task slices (for the implementation plan, not yet a plan):** + +1. **Protocol + node identity:** signing keypair generation, enroll registration, `ssh_register_identity` link delivery, identities-store slot, `NODE_PROTOCOL_VERSION` bump + relay frame types + validators + census, node-side machine pin store + strictness. +2. **Relay runtime:** plane broker (open/route/close, lifetime, quota, blind), A responder (origin verify, allow-list, identity filter), B agent proxy socket + framing + seal/open, nonce/replay. +3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, first-use approval flow, grants screen, the new `docs/security.md` §10 event names. +4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, `UserKnownHostsFile` render, changed-key block, trust card + re-pair. +5. **Destination upgrade:** "Set up Subshell here" minting a key and driving enrollment over the open session, egress refusal, node appears. +6. **Crown-jewel e2e + boundary + review loop.** + +## 15. Non-goals and accepted risks (carry-forward, do not "fix") + +Single-operator posture unchanged. The plane retains denial-of-service and metadata (parent §10). The §4.6 pre-enroll window equals the accepted control-plane-compromise boundary. B cannot be bound to a host at the agent-protocol layer, so a grant bounds exposure by identity-scoping and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no key file read, no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. From 62e4d7a2e2f1a2c17d419792d5d142ea270d8003 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:09:08 -0700 Subject: [PATCH 02/92] docs(ssh-relay): M2 spec review round 1 - security honesty, protocol bump, grant scoping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the first review pass: - §4.6/§10 now state honestly that enroll-anchoring does NOT move the parent §10 pre-first-pin window; the out-of-band dashboard comparison is the optional first-secure-contact that closes it. - M2 carries its own NODE_PROTOCOL_VERSION bump 17->18 (M1 consumed 16/17). - Agent method allow-list is default-deny (two symbolic request types), not an incomplete name list; identity scoping moves to operator-selected fingerprints on the grant (snapshot paths are not cross-machine portable). - §7 destination upgrade runs non-interactive so the setup key stays off the pane/log; §5.6 nonces are endpoint-contributed; relay lifetime framed. - Cross-reference fixes: parent-§ prefixes, crown-jewel §13, audit §10, re-pair audit event, and grant alias stored resolved. --- .../2026-10-08-ssh-agent-relay-design.md | 170 ++++++++++-------- 1 file changed, 96 insertions(+), 74 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 1069582a3..7a5e5cae0 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -2,180 +2,202 @@ **Status:** approved design, pre-plan. **Date:** 2026-10-08. -**Parent:** [`2026-10-07-ssh-anywhere-design.md`](./2026-10-07-ssh-anywhere-design.md). This spec is Milestone 2 of that program and does not restate mechanics the parent already fixes. Where they agree on a detail (the §8 relay protocol, §9 host-key doctrine, §12 failure convention, §13 security rules, §14 M2 test list), this file is the elaboration and the parent is the summary; where this file resolves a parent §16 open item, this file governs. -**Milestone 1** (terminal + jump, PRs #334/#336/#337 on `feat/ssh-anywhere-1/2/3`) is the base. This work builds on the seam its launcher exposes and is its own spec and its own security review. +**Parent:** [`2026-10-07-ssh-anywhere-design.md`](./2026-10-07-ssh-anywhere-design.md). This is Milestone 2 of that program. Cross-reference rule used throughout: **`§N` below means this document; `parent §N` means the parent.** This file elaborates where the parent summarizes, and it is decisive wherever it resolves a parent §16 open item. +**Base:** Milestone 1 (terminal + jump, PRs #334/#336/#337 on `feat/ssh-anywhere-1/2/3`) is unmerged and is the base; this work lands on the launcher seam M1 exposes, as its own spec and its own security review. ## 1. Goal and scope -Milestone 2 delivers relay mode: an interactive SSH pane that runs on a connecting machine **B** which holds no signing key, authenticating to an arbitrary sshd-only destination **D** through the ssh-agent of a different machine **A** that does, over a shuttle the plane routes but cannot read. It also delivers the two things relay mode needs to be operable (the machine-identity roster and the key grants) and the one act that turns a successful relay destination into a first-class node ("Set up Subshell here"). +Milestone 2 delivers relay mode: an interactive SSH pane that runs on a connecting machine **B** holding no signing key, authenticating to an arbitrary sshd-only destination **D** through the ssh-agent of a different machine **A** that does, over a shuttle the plane routes but cannot read. It ships the two things relay mode needs to be operable (the machine-identity roster and the key grants) and the act that turns a relay destination into a first-class node ("Set up Subshell here"). -Milestone 2 is done when the parent's §17 sentence for M2 holds: the same pane runs on a machine with no key at all, authenticating through a machine that does, with a CI test proving the plane sees only opaque envelopes and the key never leaves its home. +Done when the parent §17 M2 sentence holds: the same pane runs on a machine with no key at all, authenticating through a machine that does, with a CI test proving the plane sees only opaque envelopes and the key never leaves its home. -**In scope:** the per-machine signing identity and its enrollment/bootstrap; the node-side machine pin store; the sealed agent relay (proxy on B, responder on A, broker and blind router on the plane); the method allow-list and identity scoping; `ssh_key_grants` and the first-use approval that creates them; the M2 host-key pin path (`ssh_host_pins`); the destination upgrade; grants/trust UI; audits; the tests in §14. +**In scope:** the per-machine signing identity and its enroll-time registration; the node-side machine pin store and its display/recovery; the sealed agent relay (proxy on B, responder on A, broker and blind router on the plane); the agent method allow-list and identity scoping; `ssh_key_grants` and the first-use approval that creates them; the M2 host-key pin path (`ssh_host_pins`); the destination upgrade; the matching UI, audits, and §13 tests. -**Out of scope (unchanged from the parent §2):** a general SSH-protocol proxy, X11/agent forwarding toward D, port forwarding, key custody or copying of any kind, multi-operator separation of duties, and any change to the OS-user trust boundary. A `ProxyCommand`-based hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. +**Out of scope (parent §2, unchanged):** a general SSH-protocol proxy; X11 or agent forwarding toward D; port forwarding; any key custody or copying; multi-operator separation of duties; any change to the OS-user boundary. A `ProxyCommand` hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. -## 2. Terminology (adds to the parent §3) +## 2. Terminology (adds to parent §3) -- **A / key home:** the machine whose ssh-agent signs. Must have `ssh_enabled` on (§4.3 parent) and be online for the handshake window. -- **B / connecting machine:** the machine that runs the pane and the `ssh D` process, and holds no key. `ssh_enabled` on. +- **A / key home:** the machine whose ssh-agent signs. Needs `ssh_enabled` on (parent §4.3) and to be online for the handshake window. +- **B / connecting machine:** the machine that runs the pane and the `ssh D` process, holding no key. `ssh_enabled` on. - **D / destination:** any sshd. Not gated; dialed to. -- **Signing identity:** the per-machine ES256 keypair introduced here. Distinct from the encryption identity (ECDH-ES) that already lives in `apps/node/agent/src/identity.ts` and from the plane-held ES256 command keypair in `control-keys.ts`. -- **Machine pin:** a byte-equality pin of a peer's signing public key, held in the node-side machine pin store (new), never in the channels' `peers.json`. +- **Signing identity:** the per-machine ES256 keypair introduced here, distinct from the ECDH-ES encryption pair already in `apps/node/agent/src/identity.ts` and from the plane-held ES256 command key in `control-keys.ts`. +- **Machine pin:** a byte-equality pin of a peer's signing public key, held in a new node-side machine pin store (never the channels' `peers.json`). - **Relay session:** a bounded plane-brokered pairing of (grant, A, B, pane/subshell id, opaque routing ref, lifetime). +- **TOFU:** trust-on-first-use; a key is pinned from the first copy received, and any later differing copy is refused. +- **Relay frame:** the new `/ws/node` link frame that carries one sealed envelope (§5.1). +- **Wire-frame census:** the repo convention (a `node-frames` test) that inventories every frame type; the relay frames join it. ## 3. The five open items, decided -These close the parent §16 list. Each is elaborated in its section. +These close the parent §16 list; each is elaborated in its section. -1. **Machine-identity bootstrap** (§4): the signing keypair is minted and registered at **enroll**, so relay trust anchors on the enroll event rather than a fresh plane-mediated first-use. A machine trust card carries the fingerprint confirmation. -2. **Destination upgrade** (§7): reuse ordinary enrollment over the open session. No #330 runtime-session re-port. -3. **Per-use approval on A** (§6): a first-use prompt creates a standing grant; later uses under that grant are silent. The approval flag ships as the act of grant creation, not a separate toggle. -4. **A's identity source** (§5.4): agent-only. A's identity files on disk are never read or served; a key becomes usable remotely only once loaded in A's agent. -5. **Relay-only destination auto-enroll** (§7): collapses into the destination upgrade. Enrollment creates the node row; "Set up Subshell here" wires mint-key to run-enroll to the node appearing. +1. **Machine-identity bootstrap** (§4): the signing keypair is minted and its public half registered at **enroll**, and the node-side machine pin store is byte-strict. This removes a fresh first-use TOFU ceremony; it does not, on its own, move the parent §10 pre-first-pin window (§4.6 states that honestly), and the trust card offers an optional out-of-band check. +2. **Destination upgrade** (§7): reuse ordinary enrollment over the machine's own connection. No #330 runtime-session re-port. +3. **Per-use approval on A** (§6): a first-use prompt creates a standing grant; later uses under it are silent. The approval is the creation act; there is no separate per-grant "ask each time" flag. +4. **A's identity source** (§5.4): agent-only. A's identity files are never read or served; a key becomes usable remotely only once loaded in A's agent. +5. **Relay-only destination auto-enroll** (§7): enrollment creates the node row; "Set up Subshell here" wires mint-key to run-enroll to the node appearing. No separate auto-enroll path. ## 4. Machine identity and pin store ### 4.1 A per-machine signing keypair -`identity.ts` today generates and holds only the ECDH-ES encryption keypair; the ES256 keypair that signs node commands is plane-held (`control-keys.ts`), so no per-machine signing key exists. M2 adds one: the node generates an ES256 keypair at the same moment and to the same posture as the encryption pair (0600 in the node data dir, private half never leaves the machine, regenerated only by explicit re-enroll or key rotation). It signs the relay payloads of §5.6; it is not a command-signing key and never authorizes a plane command on its own. +`identity.ts` generates and holds only the ECDH-ES encryption keypair; the ES256 key that signs node commands is plane-held (`control-keys.ts`), so no per-machine signing key exists. M2 adds one: the node generates an ES256 keypair at the same moment and posture as the encryption pair (0600 in the node data dir, private half never leaves the machine, replaced only by explicit re-enroll or key rotation). It signs the relay payloads of §5.6 and nothing else; it never authorizes a plane command on its own. ### 4.2 Register at enroll -The enroll route already carries the node's ECDH-ES public key into the identities store under the `node:` principal. It now also carries the signing public key, over the same setup-key-authenticated request. The identities store (or its `node:` record) gains a signing-key slot beside the encryption slot. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the moment an operator deliberately attached the machine. +The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. ### 4.3 Bootstrap for pre-M2 nodes -Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command (`ssh_register_identity`) delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane stores it in the same slot. This is exactly the transport the parent §5.3 requires ("the peer's JWK must be delivered to it inside a signed command on the existing node link"), reused for the node's own key at first contact. Delivery is idempotent: a node that has already reported re-reports the same bytes and the store is unchanged; a node that reports a different key than one on file hits the §4.5 changed-key block rather than silently overwriting. +Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips the §4.5 changed-key block rather than silently overwriting. ### 4.4 The node-side machine pin store -A relay is always pin-strict. A holds the peer signing keys it will verify against in a **new** node-side store, a distinct file from the pane-side channels `peers.json`, with its own reader that never consults `SUBSHELL_CHANNEL_PIN`. When A first needs to verify B (or B to verify A), the plane brokers the pairing and delivers the peer's signing public key inside the relay-open command; the verifying machine writes it to the machine pin store and thereafter enforces byte equality, mirroring `pin-store.ts`'s TOFU rule for the *comparison* only, never its escape hatch. A changed peer signing key is a hard block that names the peer machine and points at the recovery act of §4.5. +Relay is always pin-strict. Each side keeps the peer signing keys it verifies against in a **new** node-side store, a distinct file from the pane-side channels `peers.json`, read by its own path that never consults `SUBSHELL_CHANNEL_PIN`. When A first needs to verify B (or B A), the plane brokers the pairing and delivers the peer's signing public key inside the relay-open command; the verifying machine writes it to the machine pin store and thereafter enforces byte equality, mirroring `pin-store.ts`'s comparison rule but never its escape hatch. A changed peer signing key is a hard block naming the peer and pointing at recovery (§4.5). ### 4.5 Pin display and recovery -The security rule the parent flags ("there is no operator-visible machine pin display or recovery flow") is closed by a **machine trust card** (§8.3): for every machine this one has pinned, and for this machine's own registered signing key, the UI shows a short fingerprint. Recovery from a genuinely rotated peer key (or a re-enrolled machine) is an explicit "re-pair" act that replaces the stored pin after a fresh enroll-time-anchored delivery; it is owner-only and audited. There is no "trust anyway" escape and no reuse of the channel escape. +The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing fingerprint and every peer signing key it has pinned, each as a short fingerprint with a re-pair act. The values are carried to the plane on the existing node `ready`/runtime report (own fingerprint plus the pinned-peer list), which the parent already renders only for an online agent node; an offline node shows the last-known card marked stale. -### 4.6 What this narrows, honestly +**Re-pair** replaces a stored peer pin after a fresh enroll-anchored delivery of the peer's registered key (a re-run of the §4.3 delivery), and is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A genuinely rotated peer key is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. The machine's own signing fingerprint is also readable on the node's **own loopback dashboard** (a plane-independent surface), which is the value an operator compares against when they choose to confirm out-of-band (§4.6). -The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Anchoring the signing key to enroll moves that boundary. The key an operator sees on the trust card is bound to the enroll event that only the setup key could create, so a plane compromised **after** any machine enrolled cannot swap it without tripping the byte-equality block. The residual window is a plane compromised **before B enrolled**, which is `docs/security.md` §0's accepted, not-defended control-plane-compromise boundary, not a new hole. The trust-card fingerprint is the optional out-of-band confirmation that lets the operator close even that by comparing the two machines' fingerprints directly; it is offered, not mandatory, so first use stays one approval (§6) rather than a copy-the-fingerprint ceremony. +### 4.6 What this defends, honestly + +The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's key: A's first copy of B's signing key still arrives inside the plane's relay-open command (§4.4), so the **residual window is exactly the parent §10's "a plane compromised before A's first pin of B,"** and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. + +The one control that closes it is an out-of-band comparison, and this design offers one: the first-use approval (§6.2) displays the peer fingerprint A is being asked to pin, and B's own signing fingerprint is readable on B's **plane-independent local surface** (its loopback dashboard, §4.5). An operator who compares the two is doing the "first-secure-contact" the parent §10 asked for; a match rules out a plane substitution, a mismatch is a hard stop. That comparison is **optional, not mandatory**: skipping it leaves the parent §10 window open (accepted under §0). Making it mandatory was considered and rejected as per-pair ceremony the single-operator posture does not demand. ## 5. Relay runtime -The protocol in the parent §8 is unchanged and governs; this section pins the pieces and the one new transport decision. +The parent §8 protocol governs; this section pins the pieces and the transport decision. ### 5.1 Transport: node-link relay frames -The sealed envelopes ride the `/ws/node` link, not the browser-facing `/api/channels` path. The reason is hard-wired in the parent §5.3: the node daemon holds no REST credential, so it cannot post to a channel; the node link is its only authenticated, encrypted, low-latency transport, and it is already app-layer encrypted (protocol 14) and authenticated by the node key. It also fits the traffic shape: the agent exchange is bidirectional (B to A requests, A to B responses) and bursty over a few seconds, which the one-shot plane-to-node command/reply model serves poorly and a channel log serves only by persisting traffic it should not keep. +The sealed envelopes ride the `/ws/node` link, not the browser-facing `/api/channels` path. The reason is fixed in parent §5.3: the node daemon holds **no REST credential**, so it cannot post to a channel; the node link is its only authenticated, encrypted, low-latency, bidirectional transport, already app-layer encrypted since protocol 14 and authenticated by the node key. It also fits the traffic: the agent exchange is bidirectional (B to A requests, A to B responses) and bursty over seconds, which the one-shot plane-to-node command/reply model serves poorly and a channel log serves only by persisting traffic it should not keep. -Concretely: the plane sends A and B each one signed command to **open** a relay session (carrying the routing ref, the peer's signing public key to pin, and the lifetime). After that, a new lightweight frame type on the open link carries sealed blobs both ways, keyed by the routing ref. These frames add no per-message command signing: the link already authenticates the machine, and origin comes from the inner ES256 signature (§5.6), so signing every frame would be redundant overhead on a stream. The plane matches the two links by routing ref and forwards the opaque blob without opening it. **Open**, **route**, and **close** are the plane's only roles in the payload's life. +Concretely: the plane sends A and B each one signed **open** command carrying the full pairing: the relay-session id, the opaque routing ref, both node ids with their roles (naming each machine as A or B), the peer's signing public key to pin, the grant id, and the lifetime. After that a new lightweight frame type on the open link carries sealed blobs both ways keyed by the routing ref, with **no per-message command signing**: the link already authenticates the machine and origin comes from the inner ES256 signature (§5.6), so signing each frame is redundant on a stream. The plane matches the two links by routing ref and forwards the opaque blob unopened. **Open, route, close** are the plane's only roles in a payload's life. A relay frame is capped by a new `SSH_RELAY_FRAME_MAX_BYTES` (bounded to the channel envelope scale); an over-cap frame is refused and the session closed with a named reason, matching the repo's posture that wire caps are law. -Because this adds a link frame type, it rides the `NODE_PROTOCOL_VERSION` bump already required for `ssh_enabled` (parent §4.3). The relay frames and the gate flip in one version. +This adds a link frame type, so **M2 carries its own `NODE_PROTOCOL_VERSION` bump, 17 to 18**: the ssh_enabled and ssh-launch bumps the parent §4.3 required were already consumed by M1 at tiers 16 and 17, so an agent shipping with M1 speaks tier 17 and would otherwise pass the exact-match gate while understanding no relay frame. The 17→18 bump is what makes §11's "pre-bump agent refused" and §13's bump gate true. ### 5.2 B: the agent proxy socket -On B the launcher exports a scoped `SSH_AUTH_SOCK` (the parent §5.4 exception, into the `ssh` invocation only) pointing at a Unix socket B's node creates in a per-session directory, mode 0600, alongside the rendered connection config. The socket speaks the ssh-agent wire locally. Each request is framed, sealed to A, and handed to the plane as a relay frame; each reply is opened and written back to the socket. The socket is created for the handshake window and torn down when it ends; the established pane then needs no agent (parent §8, rekey re-verifies the host key, not the agent). +The launcher exports a scoped `SSH_AUTH_SOCK` (parent §5.4, into the `ssh` invocation only) pointing at a Unix socket B's node creates in the pane's per-session ssh directory (`/ssh//`, the M1 dir), mode 0600, beside the rendered config. The socket speaks the ssh-agent wire locally: each request is framed, sealed to A, and handed to the plane as a relay frame; each reply is opened and written back. It exists only for the handshake window (§5.6 lifetime), then is torn down; the established pane needs no agent afterward (parent §8: rekeying re-verifies the host key, not the client agent). ### 5.3 Plane: the broker -The plane brokers a pairing only when a live grant is used (§6). In-memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a quota; durable it writes only audit rows (§9). It never holds the agent payload; the payload lives solely inside sealed envelopes it shuttles. Teardown fires on handshake completion, on lifetime expiry, on A dropping, and on grant revoke. +The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a quota; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake established, lifetime expiry, A dropping, grant revoke). ### 5.4 A: the responder -On A a responder accepts only requests the plane brokered for a live grant, for a session naming this A. It enforces the parent §8 method allow-list: forward `SSH_AGENTC_REQUEST_IDENTITIES` and the sign/authenticate requests OpenSSH needs; refuse `ADD_IDENTITY`, `REMOVE_IDENTITY`, `LOCK`/`UNLOCK`, and extension requests, so a granted window cannot mutate or lock A's agent against its other panes. `REQUEST_IDENTITIES` is answered with A's agent keys **filtered to the identity refs the resolved destination snapshot names** (bounded by `SSH_MAX_IDENTITY_REFS`), never A's whole list. +A's responder accepts only requests the plane brokered for a live grant on a session naming this A. Two rules: + +**Method allow-list, default-deny.** Forward exactly the two request types SSH publickey auth uses, `SSH2_AGENTC_REQUEST_IDENTITIES` and `SSH2_AGENTC_SIGN_REQUEST` (by symbolic type, matched on the wire); **refuse every other type**: all identity mutations (`ADD_IDENTITY`, `ADD_ID_CONSTRAINED`, smartcard add/remove, `REMOVE_IDENTITY`, `REMOVE_ALL_IDENTITIES`), `LOCK`/`UNLOCK`, `EXTENSION`/`EXTENSION_RESPONSE`, every SSH1-era code, and any unknown type. A granted window therefore cannot mutate or lock A's agent against its other panes, by default-deny rather than a name list that could miss a code. + +**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). -**Agent-only (§3.4):** the responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is simply absent from the filtered roster; the handshake fails as "no matching key in A's agent" (parent §12), which points at loading it and never reads the file. This keeps key bytes off both the wire and A's disk reads by Subshell. +**Agent-only (§3 item 4).** The responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is absent from the roster; Subshell never opens a private key file to compute a match, so key bytes never move and no passphrase path exists on A. -### 5.5 Plane blindness, restated for the record +### 5.5 Plane blindness, for the record -The envelope format is `packages/mcp-core/src/crypto.ts` `seal`/`open` (ephemeral-static ECDH-ES+A256KW, A256GCM, plaintext recipient-id header for blind routing). The plane reads the routing ref to pair and nothing else. A wire capture at the plane during the §10 crown-jewel test shows opaque envelopes and plaintext routing ids only. +The envelope format is `packages/mcp-core/src/crypto.ts` `seal`/`open` (ephemeral-static ECDH-ES+A256KW, A256GCM, plaintext recipient-id header for blind routing). The plane reads the routing ref to pair and nothing else. A wire capture at the plane during the §13 crown-jewel test (parent §14) shows opaque envelopes and plaintext routing ids only. ### 5.6 Origin, replay, and lifetime -Confidentiality is not authenticity: anyone holding A's public key, the plane included, could craft a seal A opens. Origin therefore rides a signature, not the seal: the sender signs the inner payload with its machine signing key; the receiver verifies against its **machine pin** of the sender (§4.4), the ES256 JWS precedent from node command signing, the signature living inside the sealed envelope so the plane never sees it. Signatures run both directions and the relay-session nonce is bound into both, so a captured envelope is inert on replay even though the plane stores envelopes. Lifetime, the method allow-list, and "no forwarding toward D" are exactly the parent §8 last three bullets and are not relaxed here. +Confidentiality is not authenticity: anyone holding A's public key, the plane included, can craft a seal A opens (`crypto.ts` `seal` is ephemeral-static with only a plaintext recipient header). Origin rides a signature, not the seal: the sender signs the inner payload with its machine signing key and the receiver verifies against its **machine pin** of the sender (§4.4), reusing the ES256 JWS precedent from node command signing, the signature living inside the sealed envelope so the plane never sees it. + +**Replay.** The nonces are contributed by the endpoints, not the plane: B's first sealed request and A's first sealed response each carry a fresh 128-bit random, and each side records the peer's value and accepts it only once per session. The ES256 signature covers a canonicalized inner message that includes the relay-session id, the routing ref, a direction tag, both nonces, and the agent bytes. Together these make a captured envelope inert on replay even though the plane **routes every envelope and can resend any of them** (it stores none durably). + +**Lifetime.** The parent §8 bullets on the method allow-list, no-forwarding-toward-D, and handshake-window lifetime are adopted unchanged. The relay lifetime is a new named constant in `ssh-limits.ts` (the existing `SSH_SESSION_OPEN_DEADLINE_MS` of 30 s is its model), and the handshake-completion signal is defined because B's proxy cannot see ssh's handshake to D: B tears the proxy down when the `ssh` child has been alive a grace period with no agent error (and immediately on child exit), with the hard lifetime cap as the ceiling and A-drop and grant revoke as earlier cuts. ## 6. Grants and the first-use approval ### 6.1 The grant record -`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), destination selector (an alias, a concrete host, or a host glob matched against the resolved destination hostname as policy only, unrelated to discovery's wildcards). No secret, no key bytes. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§7 of the parent, `ssh_host_pins`), so one selector covering many hosts yields many pins. +`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set. No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. ### 6.2 First use creates the grant -The first time a launch would route through a `(A, B, destination)` combination with no standing grant, A's operator receives one approval naming the requester, the key home, and the destination, through the existing notification/confirm surface. Approving writes the grant and proceeds; refusing aborts with a loud, named refusal (parent §12 convention). Subsequent uses under a matching grant are silent. The approval is the creation gesture; there is no separate per-grant "ask each time" flag shipped in M2, because the first-use prompt already carries that role and the residual scope risk is bounded the same way the parent §8 already bounds it (agent sign requests carry no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by serving only snapshot-named identities plus instant revoke). +The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a pending grant-approval and notifies A's owner (via `notify.service.ts`), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, A's own public agent identities to choose from, and B's signing fingerprint for the optional §4.6 out-of-band check. **Approving** selects the key fingerprints, writes the grant (and records the pin of B); **denying or letting it expire** (bounded, at setup-key scale) writes nothing. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). -### 6.3 Management and revoke +### 6.3 Matching, management, and revoke -A grants screen (owner-scoped) lists, edits the selector/name, and revokes grants. Revocation is instant and cuts both ways: the plane row goes, and a live relay session using it is torn down so signing stops mid-session. A can also kill a session from its own trust surface. Creation via the screen and via first use write the same row and the same audit event. +The match predicate is (owner + the A chosen in the launch + the grant's resolved selector matched against the resolved destination). **B is not part of the grant's scope:** a grant authorizes reaching the destination with A's selected keys from any of the owner's connecting machines, and the approval copy says so plainly (under the single-operator posture this is a disclosure note, not a privilege widening). When multiple grants match one launch, the most specific selector wins, ties break to the oldest grant, and a distinct key home is disambiguated by the A the operator picked at launch. Revocation is instant and cuts both ways: the plane row goes and any live relay session using it is torn down so signing stops mid-session. Creation via the screen and via first use write the same row and the same audit event. ## 7. Destination upgrade: "Set up Subshell here" -A secondary act inside an open SSH-terminal pane on D. It does not re-port #330's brokered runtime session. It reuses ordinary enrollment: the plane mints a setup key (existing route) and the act drives the standard `install.sh` enrollment from D over the already-open authenticated session, so D dials the plane and becomes an ordinary enrolled node, exactly as if the operator had run the one-liner by hand. The relay/launcher is the consumer that carries the command; it is not a new product surface. +A secondary act inside an open SSH-terminal pane on D. It does not re-port #330's brokered runtime session. It reuses ordinary enrollment: the plane mints a setup key (existing route) and the act drives the standard `install.sh` enrollment on D, so D dials the plane and becomes an ordinary enrolled node, exactly as if the operator ran the one-liner by hand. + +**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). Only the command's final line, success or the named egress refusal, is echoed back into the pane. The setup key lands in D's own shell history under the accepted enrollment-link posture (`docs/security.md`), its life single-use and at most 24 h. (This is the enrollment credential, an accepted-argv exception; it is not SSH key material, which §12 still never logs.) -Consequences the parent §16 asked about, now fixed: a relay-only destination becomes a first-class node **by enrolling**, because that is the only way the node row is ever created; and the "lighter boot" question is moot because there is no bespoke runtime-session protocol in play. If D cannot reach the plane (no egress on D's network back to the plane URL), the act refuses cleanly with that named cause, distinct from any key error, and the working pane is unaffected. +**Completion and failure.** Success is the new node's `ready`/enroll event, awaited with a bounded wait; a midway failure names the stage. If D cannot reach the plane (no egress back to the plane URL), the act refuses cleanly with that named cause, distinct from any key error, and the working pane is unaffected. A relay-only destination becomes a first-class node **by enrolling**, because enrollment is the only way a node row is ever created, so the parent §16 "auto-enroll after set up here" collapses into this act. -## 8. UI (adds to the parent §11) +## 8. UI (adds to parent §11) -- The destination-first flow is unchanged. Its step-2 follow-up "sign with [machine]'s keys" now resolves to a relay: pick A as the key home, and the grant/approval path (§6) applies. -- **Grants screen:** list/create/edit/revoke, owner-scoped, under the SSH settings area. Normal creation is the first-use prompt; the screen manages and hand-adds. -- **Machine trust card:** this machine's own signing fingerprint, and every peer signing key it has pinned, each with a short fingerprint and a re-pair act (§4.5). Surfaced on the node detail so the operator can confirm out-of-band. -- **Host-key display:** destination pins visible with the changed-key block surfaced when a connect is refused on a byte mismatch. -- **"Set up Subshell here":** an act in the SSH-terminal pane, not a landing-screen mode (parent §11 keeps the destination-first landing intact). +- The destination-first flow is unchanged; its step-2 follow-up "sign with [machine]'s keys" now resolves to a relay and the grant/approval path (§6). +- **Grants screen:** list, create, edit the selector/name, revoke, and answer pending grant-approvals (§6.2); owner-scoped, under SSH settings. +- **Machine trust card** (this is the card §4.5 cites): this machine's own signing fingerprint, every pinned peer with its fingerprint, and a re-pair act each; fed by the `ready`/runtime report and stale-marked offline. Surfaced on the node detail. +- **Host-key display:** destination pins visible; the changed-key refusal is surfaced on the failed connect. +- **"Set up Subshell here":** an act in the SSH-terminal pane, not a landing-screen mode (parent §11's destination-first landing stays intact). Copy follows the design system: role tokens only, at most two sentences per explanation, no em dashes. -## 9. Data model, config, and audits (adds to parent §7, §10) +## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node (a `nodes` column or an identities-store `node:` slot). `ssh_key_grants` and `ssh_host_pins` tables (M2 rows from parent §7; both ship in M2). Relay session state is in-memory on the plane with a durable audit row, no table. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and its mirror ship in M1. +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constant `SSH_RELAY_FRAME_MAX_BYTES` and a relay-lifetime constant in `ssh-limits.ts`. -**New audit event names**, to add to `docs/security.md` §10, naming ids/hosts/field-names only, never payload, challenge, or signature: +**New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): -- `node.ssh_identity.register` (enroll-time signing key registration; and the pre-M2 link delivery, distinguished by a `via` field). -- `node.ssh_grant.create|update|delete`. -- `node.ssh_relay.open|close` (open names grant/A/B/pane and the routing ref; close names the reason). +- `node.ssh_identity.register` (enroll-time registration, and the pre-M2 link delivery, distinguished by a `via` field). +- `node.ssh_grant.create|update|delete` and `node.ssh_grant.approve|deny` (approve names the chosen key fingerprints and destination; never any key material). +- `node.ssh_machine_pin.repair` (the §4.5 re-pair, naming the peer id only). +- `node.ssh_relay.open|close` (open names grant/A/B/pane and routing ref; close names the reason). - `node.ssh_host_pin.create|rotate|delete`. +- `node.ssh_upgrade.run` (the §7 destination upgrade, naming D, the grant, and the outcome; never the setup key). -The relay pairing metadata the plane already holds (which machines talk, timing, sizes, routing ids) is disclosed by §10 of the parent as retained, not new. +The relay metadata the plane always held (which machines talk, timing, sizes, routing ids) is disclosed by parent §10 as retained, not new. ## 10. Trust model delta (refines parent §10) -Gained, as the parent states, plus two specifics from these decisions: the plane never reads an agent payload (blind routing over its own link), and a plane compromised after any relevant enroll cannot forge a request A honors, because the signing key is enroll-anchored and byte-equality pinned. The §4.6 boundary is the only residual, and it equals §0's not-defended control-plane-compromise risk. Agent-only (§5.4) removes a whole class of exposure: no identity file is ever read by Subshell, no passphrase prompt lives on A, no key file transits. Revoke cuts a live session. A compromised B during its own granted window is still bounded by the allow-list and identity scoping (parent §10), unchanged. +Gained, beyond what parent §10 lists: the plane never reads an agent payload (blind routing over its own link), and after A has pinned B a plane cannot swap B's key into A without tripping the byte-equality block. Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 out-of-band comparison; it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. -## 11. Failure modes (parent §12, plus M2-specific) +## 11. Failure modes (parent §12, plus M2) -Everything in parent §12 stands (A offline before handshake, no matching key, changed host key, no-reach vs no-key, relay expiry mid-handshake, loud quota/policy refusal). Add: +Parent §12 stands in full (A offline before handshake, no matching key, changed host key, no-reach vs no-key, relay expiry mid-handshake, loud quota/policy refusal). Add: -- **No signing key on a node** (a pre-M2 node that has not yet delivered, or a corrupt store): name the machine and offer the register/refresh act; never proceed unsigned. +- **Approval requested, unanswered:** B's launch failed fast with the named refusal; the pending approval expires at its bound writing nothing (§6.2). +- **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and offer the identity register act (§4.3); never proceed unsigned. - **Peer signing key changed** since pinning: hard block naming the peer, pointing at re-pair (§4.5). -- **D cannot reach the plane during "Set up Subshell here":** a clean refusal naming egress, the working pane untouched. -- **Relay frame on a pre-bump agent:** the exact-match `NODE_PROTOCOL_VERSION` gate refuses pairing before any relay command, naming the version remedy (upgrade the agent), never silently half-relaying. +- **D cannot reach the plane during "Set up Subshell here":** a clean egress refusal, the working pane untouched (§7). +- **Reverse verification fails:** B rejects a response whose A signature does not match its pin of A; the handshake fails cleanly rather than hanging. +- **Over-cap relay frame:** refused, session closed with a named reason (§5.1). +- **Relay frame on a pre-bump agent:** the 17→18 `NODE_PROTOCOL_VERSION` exact-match gate refuses pairing before any relay command, naming the agent-upgrade remedy, never half-relaying. -## 12. Security rules honored (parent §13, restated where M2 leans on them) +## 12. Security rules honored (parent §13) -No key/challenge/signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config 0600 in a per-session dir, removed with the pane. `shellQuote` on every built-command token. Node command/link transport unchanged in trust: relay open/close are signed commands, relay frames ride the authenticated, encrypted link, origin is the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, not a bespoke cipher, and never honor `SUBSHELL_CHANNEL_PIN`. The method allow-list and identity scoping bound a granted window. Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. +Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config 0600 in the per-session dir, removed with the pane. `shellQuote` on every built-command token. The relay open/close are signed commands and relay frames ride the authenticated, encrypted link, with origin the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, never a bespoke cipher and never honoring `SUBSHELL_CHANNEL_PIN`. The destination-upgrade act runs non-interactively so the enrollment setup key stays off the pane and B's log (its landing in D's shell history is the accepted enrollment-link posture, and it is an enrollment credential, not SSH key material). Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. ## 13. Testing -The parent §14 M2 list is required and is not repeated here. Add coverage for the decisions: +The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers the signing pubkey alongside the encryption pubkey; `ssh_register_identity` pre-M2 delivery is idempotent and a mismatched key trips the block. The machine pin store is byte-equality strict, is a separate file from `peers.json`, and ignores `SUBSHELL_CHANNEL_PIN`; first pin then change is a block naming the peer. Grant CRUD and selector matching (alias / concrete host / glob-as-policy); first-use prompt creates the grant and a second use is silent; revoke stops signing mid-session. Agent-only filter: an identity file present on A but not in A's agent is refused with "no matching key," and A's agent roster is filtered to the snapshot-named refs. Relay frame codec round-trips and the protocol bump gates a pre-bump agent. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks and names D. -- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; replay of a captured envelope inside the window is inert. -- **End to end (the crown jewel).** The parent §14 M2 e2e verbatim: A with the key in its agent, B with none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it to assert a grant revoke between handshake and D is refused, and "Set up Subshell here" turns D into an enrolled node over an egressing D. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; first pin then change blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant and pins, deny and expiry write nothing, and a matching grant serves silently on second use. Identity scoping: only the grant's selected fingerprints are served; a grant naming none reads as "no matching key"; the responder never opens a private key file. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated in the test and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. The `NODE_PROTOCOL_VERSION` bump 17 to 18 gates a pre-bump agent. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D. An over-cap relay frame is refused and the session closed. +- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; and **B rejects a bad A response signature** (reverse direction). +- **End to end (the crown jewel).** Parent §14 verbatim: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. ## 14. Branching, phasing, and review -This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhere-3`), its own PR, and, per the parent §6.2, an expected focused security review pass; review-until-clean over the whole branch before any merge consideration. M1 remains unmerged at the operator's direction; when M1 lands, this branch rebases onto it. It is deliberately isolated behind the launcher seam M1 exposes, so the relay can be turned off without disturbing M1's direct and jump paths. +This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhere-3`), its own PR, and per parent §6.2 an expected focused security review; review-until-clean over the whole branch before any merge consideration. M1 stays unmerged at the operator's direction; when M1 lands, this rebases onto it. It is isolated behind the launcher seam M1 exposes, so relay can be off without disturbing M1's direct and jump paths. **Likely task slices (for the implementation plan, not yet a plan):** -1. **Protocol + node identity:** signing keypair generation, enroll registration, `ssh_register_identity` link delivery, identities-store slot, `NODE_PROTOCOL_VERSION` bump + relay frame types + validators + census, node-side machine pin store + strictness. -2. **Relay runtime:** plane broker (open/route/close, lifetime, quota, blind), A responder (origin verify, allow-list, identity filter), B agent proxy socket + framing + seal/open, nonce/replay. -3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, first-use approval flow, grants screen, the new `docs/security.md` §10 event names. -4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, `UserKnownHostsFile` render, changed-key block, trust card + re-pair. -5. **Destination upgrade:** "Set up Subshell here" minting a key and driving enrollment over the open session, egress refusal, node appears. -6. **Crown-jewel e2e + boundary + review loop.** +1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus relay-frame types, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES` and the relay-lifetime constants, the node-side machine pin store with byte-strictness and `node.ssh_machine_pin.repair`, and the `ready`/runtime-report fingerprint carriage. +2. **Relay runtime:** plane broker (open/route/close, lifetime, quota, blind), A responder (origin verify, default-deny allow-list, fingerprint filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the canonicalized signed message, and replay tests. +3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the pending grant-approval flow plus its approve/deny affordance and expiry, the grants screen, and every new `docs/security.md` §10 event name. +4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, the `UserKnownHostsFile` render, the changed-key block, and the trust card plus re-pair UI. +5. **Destination upgrade:** the non-interactive "Set up Subshell here" exec, the egress refusal, and the enrolled-node-appears detection. +6. **Crown-jewel e2e, boundary verification, and the review loop.** ## 15. Non-goals and accepted risks (carry-forward, do not "fix") -Single-operator posture unchanged. The plane retains denial-of-service and metadata (parent §10). The §4.6 pre-enroll window equals the accepted control-plane-compromise boundary. B cannot be bound to a host at the agent-protocol layer, so a grant bounds exposure by identity-scoping and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no key file read, no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. +The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 out-of-band comparison can close it on the operator's initiative but is not mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. From da80ea3b66f31152be9e02a17852be8675890216 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:13:54 -0700 Subject: [PATCH 03/92] docs(ssh-relay): M2 spec review round 2 - signing scope, replay seq, out-of-band, local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the second review pass: - §5.4/§5.6: a SIGN_REQUEST blob is scoped to the grant's fingerprints and refused at the responder; replay now uses per-direction monotonic seq with endpoint session nonces so multi-key handshakes complete while resends are inert. - §4.5/§4.6/§10/§15: the out-of-band check is now both-sides-plane-independent (A's dashboard pinned-peer fp vs B's dashboard own fp), not off the plane UI. - §5.6: the parent §8 snapshot-files filter supersession is declared, not implied. - §9: durable pending-approval row + restart behavior; drop the nonexistent host-pin rotate event; add node.ssh_grant.request. - §7: name the real accepted sinks (B/D process tables, install URL), not shell history. - §15/§11: relay requires the node agent, so local is not an A/B in M2. - Cross-ref fixes and §13 verbatim label removed. --- .../2026-10-08-ssh-agent-relay-design.md | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 7a5e5cae0..531bb778e 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -49,7 +49,7 @@ The enroll route already writes the node's ECDH-ES public key into the identitie ### 4.3 Bootstrap for pre-M2 nodes -Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips the §4.5 changed-key block rather than silently overwriting. +Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips the §4.4 changed-key block (recovery via §4.5) rather than silently overwriting. ### 4.4 The node-side machine pin store @@ -57,7 +57,7 @@ Relay is always pin-strict. Each side keeps the peer signing keys it verifies ag ### 4.5 Pin display and recovery -The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing fingerprint and every peer signing key it has pinned, each as a short fingerprint with a re-pair act. The values are carried to the plane on the existing node `ready`/runtime report (own fingerprint plus the pinned-peer list), which the parent already renders only for an online agent node; an offline node shows the last-known card marked stale. +The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing fingerprint and every peer signing key it has pinned, each as a short fingerprint with a re-pair act. The values are carried to the plane on the existing node `ready`/runtime report (own fingerprint plus the pinned-peer list), which `docs/security.md` §6 attaches only for an online agent node; an offline node shows the last-known card marked stale. The same own-and-pinned fingerprints are also rendered on the node's **own plane-independent loopback dashboard**, and that dashboard copy of the pinned-peer list is the plane-independent side of §4.6's out-of-band check. **Re-pair** replaces a stored peer pin after a fresh enroll-anchored delivery of the peer's registered key (a re-run of the §4.3 delivery), and is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A genuinely rotated peer key is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. The machine's own signing fingerprint is also readable on the node's **own loopback dashboard** (a plane-independent surface), which is the value an operator compares against when they choose to confirm out-of-band (§4.6). @@ -65,11 +65,11 @@ The parent §5.3 gap "there is no operator-visible machine pin display or recove The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's key: A's first copy of B's signing key still arrives inside the plane's relay-open command (§4.4), so the **residual window is exactly the parent §10's "a plane compromised before A's first pin of B,"** and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. -The one control that closes it is an out-of-band comparison, and this design offers one: the first-use approval (§6.2) displays the peer fingerprint A is being asked to pin, and B's own signing fingerprint is readable on B's **plane-independent local surface** (its loopback dashboard, §4.5). An operator who compares the two is doing the "first-secure-contact" the parent §10 asked for; a match rules out a plane substitution, a mismatch is a hard stop. That comparison is **optional, not mandatory**: skipping it leaves the parent §10 window open (accepted under §0). Making it mandatory was considered and rejected as per-pair ceremony the single-operator posture does not demand. +The one control that closes it is an out-of-band comparison **in which neither side is plane-rendered**. After pinning, A's loopback dashboard shows the peer fingerprint it actually stored (read from its own node-local pin store, §4.5), and B's loopback dashboard shows B's own signing fingerprint computed from B's local private key. An operator comparing those two plane-independent values is doing the "first-secure-contact" the parent §10 asked for: a match means the plane delivered B's real key, a mismatch (the plane substituted) is a hard stop. Comparing the approval screen against anything does **not** close the window, because that screen is the plane's own UI, so the check reads both values off the two dashboards, not off the plane. The check is **optional, not mandatory**: skipping it leaves the parent §10 window open (accepted under §0); making it mandatory was considered and rejected as per-pair ceremony the single-operator posture does not demand. ## 5. Relay runtime -The parent §8 protocol governs; this section pins the pieces and the transport decision. +The parent §8 protocol governs; this section pins the pieces and the transport decision, with one declared deviation: §5.4 supersedes parent §8's snapshot-files identity-filter clause (paths resolved on B are not portable to A's agent), noted again at §5.6. ### 5.1 Transport: node-link relay frames @@ -93,7 +93,7 @@ A's responder accepts only requests the plane brokered for a live grant on a ses **Method allow-list, default-deny.** Forward exactly the two request types SSH publickey auth uses, `SSH2_AGENTC_REQUEST_IDENTITIES` and `SSH2_AGENTC_SIGN_REQUEST` (by symbolic type, matched on the wire); **refuse every other type**: all identity mutations (`ADD_IDENTITY`, `ADD_ID_CONSTRAINED`, smartcard add/remove, `REMOVE_IDENTITY`, `REMOVE_ALL_IDENTITIES`), `LOCK`/`UNLOCK`, `EXTENSION`/`EXTENSION_RESPONSE`, every SSH1-era code, and any unknown type. A granted window therefore cannot mutate or lock A's agent against its other panes, by default-deny rather than a name list that could miss a code. -**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). +**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses any whose blob is not one of the grant's selected fingerprints, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. **Agent-only (§3 item 4).** The responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is absent from the roster; Subshell never opens a private key file to compute a match, so key bytes never move and no passphrase path exists on A. @@ -105,9 +105,9 @@ The envelope format is `packages/mcp-core/src/crypto.ts` `seal`/`open` (ephemera Confidentiality is not authenticity: anyone holding A's public key, the plane included, can craft a seal A opens (`crypto.ts` `seal` is ephemeral-static with only a plaintext recipient header). Origin rides a signature, not the seal: the sender signs the inner payload with its machine signing key and the receiver verifies against its **machine pin** of the sender (§4.4), reusing the ES256 JWS precedent from node command signing, the signature living inside the sealed envelope so the plane never sees it. -**Replay.** The nonces are contributed by the endpoints, not the plane: B's first sealed request and A's first sealed response each carry a fresh 128-bit random, and each side records the peer's value and accepts it only once per session. The ES256 signature covers a canonicalized inner message that includes the relay-session id, the routing ref, a direction tag, both nonces, and the agent bytes. Together these make a captured envelope inert on replay even though the plane **routes every envelope and can resend any of them** (it stores none durably). +**Replay.** Two session nonces bind the pair and are contributed by the endpoints, not the plane: B's first sealed request carries a fresh 128-bit `nB` and A's first sealed response a fresh `nA`, and each side records the peer's value and signs it into every later message. Anti-replay is per direction by a **monotonic sequence number** inside the signed envelope: the receiver keeps the highest `seq` seen for each direction and rejects any message whose `seq` is not strictly greater, so a multi-key handshake (several `SIGN_REQUEST`s in one session) completes while a byte-identical resend is refused. The ES256 signature covers a canonicalized inner message of the relay-session id, the routing ref, a direction tag, the `seq`, the agent bytes, and whichever session nonces are known there (`nB` alone in B's first message, both thereafter). A resent or non-increasing-`seq` envelope is refused even though the plane **routes every envelope and can resend any of them** (it stores none durably), and because the signature covers the session id, an old envelope cannot ride a later session that reuses a routing ref. -**Lifetime.** The parent §8 bullets on the method allow-list, no-forwarding-toward-D, and handshake-window lifetime are adopted unchanged. The relay lifetime is a new named constant in `ssh-limits.ts` (the existing `SSH_SESSION_OPEN_DEADLINE_MS` of 30 s is its model), and the handshake-completion signal is defined because B's proxy cannot see ssh's handshake to D: B tears the proxy down when the `ssh` child has been alive a grace period with no agent error (and immediately on child exit), with the hard lifetime cap as the ceiling and A-drop and grant revoke as earlier cuts. +**Lifetime.** The parent §8 method allow-list, no-forwarding-toward-D, and handshake-window lifetime bullets are adopted; within the allow-list bullet the clause filtering identities to the snapshot's named identity *files* is superseded by §5.4's fingerprint set (B-resolved paths are not portable to A's agent), which is the deviation §5 declares. The relay lifetime is a new named constant in `ssh-limits.ts` (the existing `SSH_SESSION_OPEN_DEADLINE_MS` of 30 s is its model), and the handshake-completion signal is defined because B's proxy cannot see ssh's handshake to D: B tears the proxy down when the `ssh` child has been alive a grace period with no agent error (and immediately on child exit), with the hard lifetime cap as the ceiling and A-drop and grant revoke as earlier cuts. ## 6. Grants and the first-use approval @@ -117,7 +117,7 @@ Confidentiality is not authenticity: anyone holding A's public key, the plane in ### 6.2 First use creates the grant -The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a pending grant-approval and notifies A's owner (via `notify.service.ts`), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, A's own public agent identities to choose from, and B's signing fingerprint for the optional §4.6 out-of-band check. **Approving** selects the key fingerprints, writes the grant (and records the pin of B); **denying or letting it expire** (bounded, at setup-key scale) writes nothing. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). +The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a durable pending grant-approval row (§9) and notifies A's owner via a new owner-addressed kind in `notify.service.ts` (whose existing surface is subshell-shaped and needs one added kind), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, A's own public agent identities to choose from, and B's signing fingerprint for the optional §4.6 out-of-band check. **Approving** selects the key fingerprints, writes the grant (and records the pin of B); **denying** writes the `node.ssh_grant.deny` audit row but no grant and no pin; **expiry** (bounded, at setup-key scale) writes nothing at all. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). ### 6.3 Matching, management, and revoke @@ -127,7 +127,7 @@ The match predicate is (owner + the A chosen in the launch + the grant's resolve A secondary act inside an open SSH-terminal pane on D. It does not re-port #330's brokered runtime session. It reuses ordinary enrollment: the plane mints a setup key (existing route) and the act drives the standard `install.sh` enrollment on D, so D dials the plane and becomes an ordinary enrolled node, exactly as if the operator ran the one-liner by hand. -**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). Only the command's final line, success or the named egress refusal, is echoed back into the pane. The setup key lands in D's own shell history under the accepted enrollment-link posture (`docs/security.md`), its life single-use and at most 24 h. (This is the enrollment credential, an accepted-argv exception; it is not SSH key material, which §12 still never logs.) +**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). Only the command's final line, success or the named egress refusal, is echoed back into the pane. The setup key surfaces only where the accepted postures already allow a non-secret credential: briefly in **B's and D's process tables** while the exec and install child run (the `ps`-visible-argv posture), and in the plane's request/access log only if it rides an install URL (the enrollment-link posture); a non-interactive `sh -c` exec does not append to shell history, so D's history is generally not a sink. Its life is single-use and at most 24 h; it is an enrollment credential, not SSH key material, which §12 still never logs. **Completion and failure.** Success is the new node's `ready`/enroll event, awaited with a bounded wait; a midway failure names the stage. If D cannot reach the plane (no egress back to the plane URL), the act refuses cleanly with that named cause, distinct from any key error, and the working pane is unaffected. A relay-only destination becomes a first-class node **by enrolling**, because enrollment is the only way a node row is ever created, so the parent §16 "auto-enroll after set up here" collapses into this act. @@ -143,22 +143,22 @@ Copy follows the design system: role tokens only, at most two sentences per expl ## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constant `SSH_RELAY_FRAME_MAX_BYTES` and a relay-lifetime constant in `ssh-limits.ts`. +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The §6.2 pending grant-approval is a **durable** row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constant `SSH_RELAY_FRAME_MAX_BYTES` and a relay-lifetime constant in `ssh-limits.ts`. **New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): - `node.ssh_identity.register` (enroll-time registration, and the pre-M2 link delivery, distinguished by a `via` field). -- `node.ssh_grant.create|update|delete` and `node.ssh_grant.approve|deny` (approve names the chosen key fingerprints and destination; never any key material). +- `node.ssh_grant.request|create|update|delete` and `node.ssh_grant.approve|deny` (request names the launch that asked, A, B, and destination; approve names the chosen key fingerprints and destination; never any key material). - `node.ssh_machine_pin.repair` (the §4.5 re-pair, naming the peer id only). - `node.ssh_relay.open|close` (open names grant/A/B/pane and routing ref; close names the reason). -- `node.ssh_host_pin.create|rotate|delete`. +- `node.ssh_host_pin.create|delete` (a changed D host key is the parent §9 hard block; recovery is delete plus a fresh grant-creation TOFU, no separate rotate act). - `node.ssh_upgrade.run` (the §7 destination upgrade, naming D, the grant, and the outcome; never the setup key). The relay metadata the plane always held (which machines talk, timing, sizes, routing ids) is disclosed by parent §10 as retained, not new. ## 10. Trust model delta (refines parent §10) -Gained, beyond what parent §10 lists: the plane never reads an agent payload (blind routing over its own link), and after A has pinned B a plane cannot swap B's key into A without tripping the byte-equality block. Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 out-of-band comparison; it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. +Gained, beyond what parent §10 lists: the plane never reads an agent payload (blind routing over its own link), and after A has pinned B a plane cannot swap B's key into A without tripping the byte-equality block. Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 comparison (both values read off the two machines' own plane-independent dashboards, never off the plane's UI); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. ## 11. Failure modes (parent §12, plus M2) @@ -169,6 +169,7 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed - **Peer signing key changed** since pinning: hard block naming the peer, pointing at re-pair (§4.5). - **D cannot reach the plane during "Set up Subshell here":** a clean egress refusal, the working pane untouched (§7). - **Reverse verification fails:** B rejects a response whose A signature does not match its pin of A; the handshake fails cleanly rather than hanging. +- **Relay attempted through `local`:** not offered; both relay roles require the node-agent surfaces (§15), so the server's own `local` node does not serve as A or B. - **Over-cap relay frame:** refused, session closed with a named reason (§5.1). - **Relay frame on a pre-bump agent:** the 17→18 `NODE_PROTOCOL_VERSION` exact-match gate refuses pairing before any relay command, naming the agent-upgrade remedy, never half-relaying. @@ -180,9 +181,9 @@ Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or si The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; first pin then change blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant and pins, deny and expiry write nothing, and a matching grant serves silently on second use. Identity scoping: only the grant's selected fingerprints are served; a grant naming none reads as "no matching key"; the responder never opens a private key file. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated in the test and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. The `NODE_PROTOCOL_VERSION` bump 17 to 18 gates a pre-bump agent. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D. An over-cap relay frame is refused and the session closed. -- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; and **B rejects a bad A response signature** (reverse direction). -- **End to end (the crown jewel).** Parent §14 verbatim: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; first pin then change blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant and pins, deny and expiry write nothing, and a matching grant serves silently on second use. Identity scoping: only the grant's selected fingerprints are served; a grant naming none reads as "no matching key"; the responder never opens a private key file; and a `SIGN_REQUEST` naming a blob outside the selected set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated in the test and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. The `NODE_PROTOCOL_VERSION` bump 17 to 18 gates a pre-bump agent. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D. An over-cap relay frame is refused and the session closed. +- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). +- **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. ## 14. Branching, phasing, and review @@ -191,13 +192,14 @@ This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhe **Likely task slices (for the implementation plan, not yet a plan):** -1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus relay-frame types, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES` and the relay-lifetime constants, the node-side machine pin store with byte-strictness and `node.ssh_machine_pin.repair`, and the `ready`/runtime-report fingerprint carriage. +1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus relay-frame types, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES` and the relay-lifetime constants, the node-side machine pin store with byte-strictness and `node.ssh_machine_pin.repair`, the `ready`/runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). 2. **Relay runtime:** plane broker (open/route/close, lifetime, quota, blind), A responder (origin verify, default-deny allow-list, fingerprint filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the canonicalized signed message, and replay tests. -3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the pending grant-approval flow plus its approve/deny affordance and expiry, the grants screen, and every new `docs/security.md` §10 event name. +3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the durable pending grant-approval row plus its approve/deny affordance and expiry sweep, the grants screen, and every new `docs/security.md` §10 event name. 4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, the `UserKnownHostsFile` render, the changed-key block, and the trust card plus re-pair UI. 5. **Destination upgrade:** the non-interactive "Set up Subshell here" exec, the egress refusal, and the enrolled-node-appears detection. 6. **Crown-jewel e2e, boundary verification, and the review loop.** ## 15. Non-goals and accepted risks (carry-forward, do not "fix") -The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 out-of-band comparison can close it on the operator's initiative but is not mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. +The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 comparison (both values off the two machines' own dashboards) can close it on the operator's initiative but is not mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. +- **Relay needs the node agent.** Both roles depend on the node-agent pin store, `ready`-report carriage, and loopback dashboard, so the `local` server node (no agent link or dashboard) does not serve as A or B in relay mode; `local` keeps M1's direct and jump paths, and `local` relay is deferred rather than half-built here. From 9c8269acae7047bdadfb5666e5c4aed060d11048 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:20:55 -0700 Subject: [PATCH 04/92] docs(ssh-relay): M2 spec review round 3 - encryption-key pinning, out-of-band honesty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the third review pass: - CRUX: sealing uses the peer ENCRYPTION key, so the pairing now delivers and byte-pins BOTH the peer signing and encryption keys (§4.4/§5.1/§5.5); the trust card and dashboard show both. Blindness rests on the encryption pin. - The grant fingerprint SET is now provisioned to A in the relay-open command; §10 notes a plane could widen it (within docs/security.md §0). - §6.2/§3/§10/§15: the out-of-band check reads BOTH values off the two machines' own dashboards; the plane-rendered approval value is display only. - Pin lands on A at relay-open, not at approval (§6.2/§4.4/§13). - §7: never echo raw installer output (its 404 branch prints the unspent key); emit a redacted status verb; fix the installer line. - §5.6 replay: per-direction seq, sender-known nonces, plane-collision caveat. - Named SHA256 fingerprint canonical form, SSH_RELAY_MAX_PER_NODE quota, ssh_grant_requests table; §12/§1 cross-ref and staleness fixes. --- .../2026-10-08-ssh-agent-relay-design.md | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 531bb778e..496a07624 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -13,7 +13,7 @@ Done when the parent §17 M2 sentence holds: the same pane runs on a machine wit **In scope:** the per-machine signing identity and its enroll-time registration; the node-side machine pin store and its display/recovery; the sealed agent relay (proxy on B, responder on A, broker and blind router on the plane); the agent method allow-list and identity scoping; `ssh_key_grants` and the first-use approval that creates them; the M2 host-key pin path (`ssh_host_pins`); the destination upgrade; the matching UI, audits, and §13 tests. -**Out of scope (parent §2, unchanged):** a general SSH-protocol proxy; X11 or agent forwarding toward D; port forwarding; any key custody or copying; multi-operator separation of duties; any change to the OS-user boundary. A `ProxyCommand` hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. +**Out of scope (parent §2 and §8 postures, stated together):** a general SSH-protocol proxy; X11 or agent forwarding toward D; port forwarding; any key custody or copying; multi-operator separation of duties; any change to the OS-user boundary. A `ProxyCommand` hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. ## 2. Terminology (adds to parent §3) @@ -31,7 +31,7 @@ Done when the parent §17 M2 sentence holds: the same pane runs on a machine wit These close the parent §16 list; each is elaborated in its section. -1. **Machine-identity bootstrap** (§4): the signing keypair is minted and its public half registered at **enroll**, and the node-side machine pin store is byte-strict. This removes a fresh first-use TOFU ceremony; it does not, on its own, move the parent §10 pre-first-pin window (§4.6 states that honestly), and the trust card offers an optional out-of-band check. +1. **Machine-identity bootstrap** (§4): the signing keypair is minted and its public half registered at **enroll**, and the node-side machine pin store is byte-strict. This removes a fresh first-use TOFU ceremony; it does not, on its own, move the parent §10 pre-first-pin window (§4.6 states that honestly), and the two machines' own loopback dashboards offer an optional out-of-band check. 2. **Destination upgrade** (§7): reuse ordinary enrollment over the machine's own connection. No #330 runtime-session re-port. 3. **Per-use approval on A** (§6): a first-use prompt creates a standing grant; later uses under it are silent. The approval is the creation act; there is no separate per-grant "ask each time" flag. 4. **A's identity source** (§5.4): agent-only. A's identity files are never read or served; a key becomes usable remotely only once loaded in A's agent. @@ -53,31 +53,31 @@ Nodes enrolled before M2 hold a signing keypair after the agent upgrades but hav ### 4.4 The node-side machine pin store -Relay is always pin-strict. Each side keeps the peer signing keys it verifies against in a **new** node-side store, a distinct file from the pane-side channels `peers.json`, read by its own path that never consults `SUBSHELL_CHANNEL_PIN`. When A first needs to verify B (or B A), the plane brokers the pairing and delivers the peer's signing public key inside the relay-open command; the verifying machine writes it to the machine pin store and thereafter enforces byte equality, mirroring `pin-store.ts`'s comparison rule but never its escape hatch. A changed peer signing key is a hard block naming the peer and pointing at recovery (§4.5). +Relay is always pin-strict. Each side keeps BOTH of the peer's public keys (the ES256 signing key of §4.1 and the ECDH-ES encryption key `seal`/`open` uses) in a **new** node-side store, a distinct file from the pane-side channels `peers.json`, read by its own path that never consults `SUBSHELL_CHANNEL_PIN`. When A first needs to verify or seal to B (or B to A), the plane brokers the pairing and delivers the peer's registered signing AND encryption public keys inside the relay-open command; the receiving machine writes both to the machine pin store and thereafter enforces byte equality on each, mirroring `pin-store.ts`'s comparison rule but never its escape hatch. Pinning the encryption key is what makes §5.5's blindness real: sealing uses the recipient's ECDH-ES key, so a plane that could hand B a plane-controlled encryption key for A would read every challenge, and this byte-equality block is what refuses it. A changed peer key (either half) is a hard block naming the peer and pointing at recovery (§4.5). ### 4.5 Pin display and recovery -The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing fingerprint and every peer signing key it has pinned, each as a short fingerprint with a re-pair act. The values are carried to the plane on the existing node `ready`/runtime report (own fingerprint plus the pinned-peer list), which `docs/security.md` §6 attaches only for an online agent node; an offline node shows the last-known card marked stale. The same own-and-pinned fingerprints are also rendered on the node's **own plane-independent loopback dashboard**, and that dashboard copy of the pinned-peer list is the plane-independent side of §4.6's out-of-band check. +The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing and encryption fingerprints and every pinned peer with both, each a short `SHA256:` base64 fingerprint over the key's public material, with a re-pair act. That canonical form is fixed so the honest dashboard comparison of §4.6 is well-posed. The values are carried to the plane on the existing node **runtime report** (which `docs/security.md` §6 attaches only for an online agent node and only to owner or `edit` viewers, never `view` or `local`); an offline node shows the last-known card marked stale. The same own-and-pinned fingerprints are also rendered on the node's **own plane-independent loopback dashboard**, which is the plane-independent side of §4.6's check. -**Re-pair** replaces a stored peer pin after a fresh enroll-anchored delivery of the peer's registered key (a re-run of the §4.3 delivery), and is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A genuinely rotated peer key is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. The machine's own signing fingerprint is also readable on the node's **own loopback dashboard** (a plane-independent surface), which is the value an operator compares against when they choose to confirm out-of-band (§4.6). +**Re-pair** replaces a stored peer pin after the plane re-delivers the peer's registered keys to A over the relay-open-style leg (§4.4), not the §4.3 self-report leg (which carries a machine's own key); it is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A machine whose OWN registration slot holds a wrong key recovers by re-enroll plus per-peer re-pairs. A genuinely rotated peer key is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. ### 4.6 What this defends, honestly -The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's key: A's first copy of B's signing key still arrives inside the plane's relay-open command (§4.4), so the **residual window is exactly the parent §10's "a plane compromised before A's first pin of B,"** and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. +The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's keys: A's first copy of B's signing and encryption keys still arrives inside the plane's relay-open command (§4.4), so the **residual window is exactly the parent §10's "a plane compromised before A's first pin of B,"** and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. -The one control that closes it is an out-of-band comparison **in which neither side is plane-rendered**. After pinning, A's loopback dashboard shows the peer fingerprint it actually stored (read from its own node-local pin store, §4.5), and B's loopback dashboard shows B's own signing fingerprint computed from B's local private key. An operator comparing those two plane-independent values is doing the "first-secure-contact" the parent §10 asked for: a match means the plane delivered B's real key, a mismatch (the plane substituted) is a hard stop. Comparing the approval screen against anything does **not** close the window, because that screen is the plane's own UI, so the check reads both values off the two dashboards, not off the plane. The check is **optional, not mandatory**: skipping it leaves the parent §10 window open (accepted under §0); making it mandatory was considered and rejected as per-pair ceremony the single-operator posture does not demand. +The one control that closes it is an out-of-band comparison **in which neither side is plane-rendered**. After pinning, A's loopback dashboard shows the peer fingerprints it actually stored (read from its own node-local pin store, §4.5), and B's loopback dashboard shows B's own fingerprints, computed locally from its private keys. An operator comparing those plane-independent values, for either or both keys, is doing the "first-secure-contact" the parent §10 asked for: a match means the plane delivered B's real keys, a mismatch (the plane substituted) is a hard stop. Comparing the approval screen against anything does **not** close the window, because that screen is the plane's own UI, so the check reads values off the two dashboards, not off the plane. It is **optional, not mandatory**: skipping it leaves the parent §10 window open (accepted under `docs/security.md` §0); making it mandatory was considered and rejected as per-pair ceremony the single-operator posture does not demand. ## 5. Relay runtime -The parent §8 protocol governs; this section pins the pieces and the transport decision, with one declared deviation: §5.4 supersedes parent §8's snapshot-files identity-filter clause (paths resolved on B are not portable to A's agent), noted again at §5.6. +The parent §8 protocol governs, with two declared deviations: §5.4 supersedes its snapshot-files identity-filter clause (B-resolved paths are not portable to A's agent), and here the relay is route-only, so parent §8's "the plane stores envelopes" phrasing describes the channels precedent, not this posture (it stores none durably). This section pins the pieces and the transport decision. ### 5.1 Transport: node-link relay frames The sealed envelopes ride the `/ws/node` link, not the browser-facing `/api/channels` path. The reason is fixed in parent §5.3: the node daemon holds **no REST credential**, so it cannot post to a channel; the node link is its only authenticated, encrypted, low-latency, bidirectional transport, already app-layer encrypted since protocol 14 and authenticated by the node key. It also fits the traffic: the agent exchange is bidirectional (B to A requests, A to B responses) and bursty over seconds, which the one-shot plane-to-node command/reply model serves poorly and a channel log serves only by persisting traffic it should not keep. -Concretely: the plane sends A and B each one signed **open** command carrying the full pairing: the relay-session id, the opaque routing ref, both node ids with their roles (naming each machine as A or B), the peer's signing public key to pin, the grant id, and the lifetime. After that a new lightweight frame type on the open link carries sealed blobs both ways keyed by the routing ref, with **no per-message command signing**: the link already authenticates the machine and origin comes from the inner ES256 signature (§5.6), so signing each frame is redundant on a stream. The plane matches the two links by routing ref and forwards the opaque blob unopened. **Open, route, close** are the plane's only roles in a payload's life. A relay frame is capped by a new `SSH_RELAY_FRAME_MAX_BYTES` (bounded to the channel envelope scale); an over-cap frame is refused and the session closed with a named reason, matching the repo's posture that wire caps are law. +Concretely: the plane sends A and B each one signed **open** command carrying the full pairing: the relay-session id, the opaque routing ref, both node ids with their roles (naming each machine as A or B), the peer's registered signing AND encryption public keys to pin, the grant id and its selected key-fingerprint set (§6.1, the set delivered so A's responder can enforce it without a REST call it cannot make), and the lifetime. After that a new lightweight frame type on the open link carries sealed blobs both ways keyed by the routing ref, with **no per-message command signing**: the link already authenticates the machine and origin comes from the inner ES256 signature (§5.6), so signing each frame is redundant on a stream. The plane matches the two links by routing ref and forwards the opaque blob unopened. **Open, route, close** are the plane's only roles in a payload's life. A relay frame is capped by a new `SSH_RELAY_FRAME_MAX_BYTES` (bounded to the channel envelope scale); an over-cap frame is refused and the session closed with a named reason, matching the repo's posture that wire caps are law. -This adds a link frame type, so **M2 carries its own `NODE_PROTOCOL_VERSION` bump, 17 to 18**: the ssh_enabled and ssh-launch bumps the parent §4.3 required were already consumed by M1 at tiers 16 and 17, so an agent shipping with M1 speaks tier 17 and would otherwise pass the exact-match gate while understanding no relay frame. The 17→18 bump is what makes §11's "pre-bump agent refused" and §13's bump gate true. +This adds a link frame type, so **M2 carries its own `NODE_PROTOCOL_VERSION` bump, 17 to 18**: the ssh_enabled bump the parent §4.3 required and M1's own ssh-launch bump were already consumed at tiers 16 and 17, so an agent shipping with M1 speaks tier 17 and would otherwise pass the exact-match gate while understanding no relay frame. The 17→18 bump is what makes §11's "pre-bump agent refused" and §13's bump gate true. ### 5.2 B: the agent proxy socket @@ -85,7 +85,7 @@ The launcher exports a scoped `SSH_AUTH_SOCK` (parent §5.4, into the `ssh` invo ### 5.3 Plane: the broker -The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a quota; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake established, lifetime expiry, A dropping, grant revoke). +The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a per-node concurrent-relay-session cap `SSH_RELAY_MAX_PER_NODE`, whose exceedance is a loud refusal; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake established, lifetime expiry, A dropping, grant revoke). ### 5.4 A: the responder @@ -93,19 +93,19 @@ A's responder accepts only requests the plane brokered for a live grant on a ses **Method allow-list, default-deny.** Forward exactly the two request types SSH publickey auth uses, `SSH2_AGENTC_REQUEST_IDENTITIES` and `SSH2_AGENTC_SIGN_REQUEST` (by symbolic type, matched on the wire); **refuse every other type**: all identity mutations (`ADD_IDENTITY`, `ADD_ID_CONSTRAINED`, smartcard add/remove, `REMOVE_IDENTITY`, `REMOVE_ALL_IDENTITIES`), `LOCK`/`UNLOCK`, `EXTENSION`/`EXTENSION_RESPONSE`, every SSH1-era code, and any unknown type. A granted window therefore cannot mutate or lock A's agent against its other panes, by default-deny rather than a name list that could miss a code. -**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses any whose blob is not one of the grant's selected fingerprints, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. +**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. **Agent-only (§3 item 4).** The responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is absent from the roster; Subshell never opens a private key file to compute a match, so key bytes never move and no passphrase path exists on A. ### 5.5 Plane blindness, for the record -The envelope format is `packages/mcp-core/src/crypto.ts` `seal`/`open` (ephemeral-static ECDH-ES+A256KW, A256GCM, plaintext recipient-id header for blind routing). The plane reads the routing ref to pair and nothing else. A wire capture at the plane during the §13 crown-jewel test (parent §14) shows opaque envelopes and plaintext routing ids only. +The envelope format is `packages/mcp-core/src/crypto.ts` `seal`/`open` (ephemeral-static ECDH-ES+A256KW, A256GCM, plaintext recipient-id header for blind routing). Each side seals to the peer's **pinned** encryption key (§4.4), so plane blindness rests on the byte-equality pin, not on the plane's honesty. The plane reads the routing ref to pair and nothing else. A wire capture at the plane during the §13 crown-jewel test (parent §14) shows opaque envelopes and plaintext routing ids only. ### 5.6 Origin, replay, and lifetime Confidentiality is not authenticity: anyone holding A's public key, the plane included, can craft a seal A opens (`crypto.ts` `seal` is ephemeral-static with only a plaintext recipient header). Origin rides a signature, not the seal: the sender signs the inner payload with its machine signing key and the receiver verifies against its **machine pin** of the sender (§4.4), reusing the ES256 JWS precedent from node command signing, the signature living inside the sealed envelope so the plane never sees it. -**Replay.** Two session nonces bind the pair and are contributed by the endpoints, not the plane: B's first sealed request carries a fresh 128-bit `nB` and A's first sealed response a fresh `nA`, and each side records the peer's value and signs it into every later message. Anti-replay is per direction by a **monotonic sequence number** inside the signed envelope: the receiver keeps the highest `seq` seen for each direction and rejects any message whose `seq` is not strictly greater, so a multi-key handshake (several `SIGN_REQUEST`s in one session) completes while a byte-identical resend is refused. The ES256 signature covers a canonicalized inner message of the relay-session id, the routing ref, a direction tag, the `seq`, the agent bytes, and whichever session nonces are known there (`nB` alone in B's first message, both thereafter). A resent or non-increasing-`seq` envelope is refused even though the plane **routes every envelope and can resend any of them** (it stores none durably), and because the signature covers the session id, an old envelope cannot ride a later session that reuses a routing ref. +**Replay.** Two session nonces bind the pair and are contributed by the endpoints, not the plane: B's first sealed request carries a fresh 128-bit `nB` and A's first sealed response a fresh `nA`, and each side records the peer's value and signs it into every later message. Anti-replay is per direction by a **monotonic sequence number** inside the signed envelope: the receiver keeps the highest `seq` seen for each direction and rejects any message whose `seq` is not strictly greater, so a multi-key handshake (several `SIGN_REQUEST`s in one session) completes while a byte-identical resend is refused. The ES256 signature covers a canonicalized inner message of the relay-session id, the routing ref, a direction tag, the `seq`, the agent bytes, and whichever session nonces the sender knows at send time (`nB` alone in B's first message; both once each side has seen the peer's), and the receiver verifies against what it holds. A resent or non-increasing-`seq` envelope is refused even though the plane **routes every envelope and can resend any of them** (it stores none durably). Cross-session replay is closed by the signature covering the relay-session id: an old envelope cannot ride a later session unless the plane mints a colliding id, and if it did the survivor is a stale-challenge signature inert outside the already-closed D connection, self-DoS the plane already holds rather than a forgery. **Lifetime.** The parent §8 method allow-list, no-forwarding-toward-D, and handshake-window lifetime bullets are adopted; within the allow-list bullet the clause filtering identities to the snapshot's named identity *files* is superseded by §5.4's fingerprint set (B-resolved paths are not portable to A's agent), which is the deviation §5 declares. The relay lifetime is a new named constant in `ssh-limits.ts` (the existing `SSH_SESSION_OPEN_DEADLINE_MS` of 30 s is its model), and the handshake-completion signal is defined because B's proxy cannot see ssh's handshake to D: B tears the proxy down when the `ssh` child has been alive a grace period with no agent error (and immediately on child exit), with the hard lifetime cap as the ceiling and A-drop and grant revoke as earlier cuts. @@ -113,11 +113,11 @@ Confidentiality is not authenticity: anyone holding A's public key, the plane in ### 6.1 The grant record -`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set. No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. +`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set (OpenSSH `SHA256:` base64 over the key's public material, the same canonical form the trust card shows). No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. ### 6.2 First use creates the grant -The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a durable pending grant-approval row (§9) and notifies A's owner via a new owner-addressed kind in `notify.service.ts` (whose existing surface is subshell-shaped and needs one added kind), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, A's own public agent identities to choose from, and B's signing fingerprint for the optional §4.6 out-of-band check. **Approving** selects the key fingerprints, writes the grant (and records the pin of B); **denying** writes the `node.ssh_grant.deny` audit row but no grant and no pin; **expiry** (bounded, at setup-key scale) writes nothing at all. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). +The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a durable pending grant-approval row (§9) and notifies A's owner via a new owner-addressed kind in `notify.service.ts` (whose existing surface is subshell-shaped and needs one added kind), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, and A's own public agent identities to choose from. It may display B's registered fingerprint, but that value is plane-rendered and for display only, never the §4.6 check (which reads off the two dashboards). **Approving** selects the key fingerprints and writes the grant plus A's selection; B's pin lands on A at the retried launch's relay-open command (§4.4), not at approval. **Denying** writes the `node.ssh_grant.deny` audit row but no grant and no pin; **expiry** (bounded, at setup-key scale) writes nothing at all. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). ### 6.3 Matching, management, and revoke @@ -127,7 +127,7 @@ The match predicate is (owner + the A chosen in the launch + the grant's resolve A secondary act inside an open SSH-terminal pane on D. It does not re-port #330's brokered runtime session. It reuses ordinary enrollment: the plane mints a setup key (existing route) and the act drives the standard `install.sh` enrollment on D, so D dials the plane and becomes an ordinary enrolled node, exactly as if the operator ran the one-liner by hand. -**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). Only the command's final line, success or the named egress refusal, is echoed back into the pane. The setup key surfaces only where the accepted postures already allow a non-secret credential: briefly in **B's and D's process tables** while the exec and install child run (the `ps`-visible-argv posture), and in the plane's request/access log only if it rides an install URL (the enrollment-link posture); a non-interactive `sh -c` exec does not append to shell history, so D's history is generally not a sink. Its life is single-use and at most 24 h; it is an enrollment credential, not SSH key material, which §12 still never logs. +**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). The act never echoes raw installer output: the current `install.sh` prints the *unspent* key on its 404 advice branch, so the act runs a small parser that emits only a controlled status verb (success, or the named egress/other failure) and redacts any line matching the setup-key shape; the installer's 404 line is itself fixed to print `--key ` rather than the value. The setup key therefore surfaces only where the accepted postures already allow a non-secret credential: briefly in **B's and D's process tables** while the exec and install child run (the `ps`-visible-argv posture), and in the plane's request/access log only if it rides an install URL (the enrollment-link posture); a non-interactive `sh -c` exec does not append to shell history, so D's history is generally not a sink. Its life is single-use and at most 24 h; it is an enrollment credential, not SSH key material, and `node.ssh_upgrade.run` (§9) never names it, matching the `setup_key.create` no-metadata precedent. **Completion and failure.** Success is the new node's `ready`/enroll event, awaited with a bounded wait; a midway failure names the stage. If D cannot reach the plane (no egress back to the plane URL), the act refuses cleanly with that named cause, distinct from any key error, and the working pane is unaffected. A relay-only destination becomes a first-class node **by enrolling**, because enrollment is the only way a node row is ever created, so the parent §16 "auto-enroll after set up here" collapses into this act. @@ -135,7 +135,7 @@ A secondary act inside an open SSH-terminal pane on D. It does not re-port #330' - The destination-first flow is unchanged; its step-2 follow-up "sign with [machine]'s keys" now resolves to a relay and the grant/approval path (§6). - **Grants screen:** list, create, edit the selector/name, revoke, and answer pending grant-approvals (§6.2); owner-scoped, under SSH settings. -- **Machine trust card** (this is the card §4.5 cites): this machine's own signing fingerprint, every pinned peer with its fingerprint, and a re-pair act each; fed by the `ready`/runtime report and stale-marked offline. Surfaced on the node detail. +- **Machine trust card** (the card §4.5 defines): this machine's own signing and encryption fingerprints, every pinned peer with both, and a re-pair act each; fed by the runtime report, so it renders for owner or `edit` viewers only (never `view` or `local`) and is stale-marked offline. Surfaced on the node detail. - **Host-key display:** destination pins visible; the changed-key refusal is surfaced on the failed connect. - **"Set up Subshell here":** an act in the SSH-terminal pane, not a landing-screen mode (parent §11's destination-first landing stays intact). @@ -143,7 +143,7 @@ Copy follows the design system: role tokens only, at most two sentences per expl ## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The §6.2 pending grant-approval is a **durable** row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constant `SSH_RELAY_FRAME_MAX_BYTES` and a relay-lifetime constant in `ssh-limits.ts`. +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap). **New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): @@ -158,7 +158,7 @@ The relay metadata the plane always held (which machines talk, timing, sizes, ro ## 10. Trust model delta (refines parent §10) -Gained, beyond what parent §10 lists: the plane never reads an agent payload (blind routing over its own link), and after A has pinned B a plane cannot swap B's key into A without tripping the byte-equality block. Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 comparison (both values read off the two machines' own plane-independent dashboards, never off the plane's UI); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. +Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance, and the operator sees A's actually-pinned keys on its dashboard (§4.5). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 comparison (both values read off the two machines' own plane-independent dashboards, never off the plane's UI); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. ## 11. Failure modes (parent §12, plus M2) @@ -175,13 +175,13 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed ## 12. Security rules honored (parent §13) -Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config 0600 in the per-session dir, removed with the pane. `shellQuote` on every built-command token. The relay open/close are signed commands and relay frames ride the authenticated, encrypted link, with origin the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, never a bespoke cipher and never honoring `SUBSHELL_CHANNEL_PIN`. The destination-upgrade act runs non-interactively so the enrollment setup key stays off the pane and B's log (its landing in D's shell history is the accepted enrollment-link posture, and it is an enrollment credential, not SSH key material). Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. +Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config 0600 in the per-session dir, removed with the pane. `shellQuote` on every built-command token. The relay open/close are signed commands and relay frames ride the authenticated, encrypted link, with origin the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, never a bespoke cipher and never honoring `SUBSHELL_CHANNEL_PIN`. The destination-upgrade act runs non-interactively so the enrollment setup key stays off the pane and B's log, surfacing only in the accepted process-table and install-URL postures (§7); it is an enrollment credential, not the SSH key material this section never logs. Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. ## 13. Testing The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; first pin then change blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant and pins, deny and expiry write nothing, and a matching grant serves silently on second use. Identity scoping: only the grant's selected fingerprints are served; a grant naming none reads as "no matching key"; the responder never opens a private key file; and a `SIGN_REQUEST` naming a blob outside the selected set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated in the test and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. The `NODE_PROTOCOL_VERSION` bump 17 to 18 gates a pre-bump agent. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D. An over-cap relay frame is refused and the session closed. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints, the trust card and loopback dashboard render them from the local store, an offline node stale-marks, and a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. - **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). - **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. @@ -192,8 +192,8 @@ This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhe **Likely task slices (for the implementation plan, not yet a plan):** -1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus relay-frame types, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES` and the relay-lifetime constants, the node-side machine pin store with byte-strictness and `node.ssh_machine_pin.repair`, the `ready`/runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). -2. **Relay runtime:** plane broker (open/route/close, lifetime, quota, blind), A responder (origin verify, default-deny allow-list, fingerprint filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the canonicalized signed message, and replay tests. +1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus a relay-frame type, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES`, `SSH_RELAY_MAX_PER_NODE`, and the relay-lifetime constants, the node-side machine pin store byte-pinning BOTH peer keys with `node.ssh_machine_pin.repair`, the runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). +2. **Relay runtime:** plane broker (open/route/close, lifetime, `SSH_RELAY_MAX_PER_NODE` cap, blind routing on pinned keys), A responder (origin verify, default-deny allow-list, fingerprint-blob filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the per-direction `seq` and canonicalized signed message, and replay tests. 3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the durable pending grant-approval row plus its approve/deny affordance and expiry sweep, the grants screen, and every new `docs/security.md` §10 event name. 4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, the `UserKnownHostsFile` render, the changed-key block, and the trust card plus re-pair UI. 5. **Destination upgrade:** the non-interactive "Set up Subshell here" exec, the egress refusal, and the enrolled-node-appears detection. From a480cac81a7cf20f3ccf9c9b636b0ff61ead4ba2 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:25:01 -0700 Subject: [PATCH 05/92] docs(ssh-relay): M2 spec review round 4 - roster enumeration, grant-set honesty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §5.4/§6.2/§11/§13/§14: add the ssh_agent_identities plane->A signed command that enumerates A's public agent identities (fingerprints+comments, no blobs) for the approval screen, with an A-offline-pending failure mode and test; the plane cannot otherwise reach A's roster to offer a selection. - §10: the enforced agent-key set is plane-widenable and NO plane-independent surface shows it (dashboard = machine pins); the durable record is the node.ssh_grant.approve audit row. - §2 glossary + §11 + §10: machine pin covers BOTH keys, not signing only. - §1 out-of-scope attribution to parent §2/§5.2/§8; §9 teardown-grace constant; §13 trust-card-vs-dashboard render split; §15 folded bullet; minor phrasing. --- .../2026-10-08-ssh-agent-relay-design.md | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 496a07624..8de928bc4 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -13,7 +13,7 @@ Done when the parent §17 M2 sentence holds: the same pane runs on a machine wit **In scope:** the per-machine signing identity and its enroll-time registration; the node-side machine pin store and its display/recovery; the sealed agent relay (proxy on B, responder on A, broker and blind router on the plane); the agent method allow-list and identity scoping; `ssh_key_grants` and the first-use approval that creates them; the M2 host-key pin path (`ssh_host_pins`); the destination upgrade; the matching UI, audits, and §13 tests. -**Out of scope (parent §2 and §8 postures, stated together):** a general SSH-protocol proxy; X11 or agent forwarding toward D; port forwarding; any key custody or copying; multi-operator separation of duties; any change to the OS-user boundary. A `ProxyCommand` hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. +**Out of scope (parent §2, parent §5.2 and parent §8 postures, stated together):** a general SSH-protocol proxy; X11 or agent forwarding toward D; port forwarding; any key custody or copying; multi-operator separation of duties; any change to the OS-user boundary. A `ProxyCommand` hop is refused at resolve exactly as in M1; relay is not a way to reintroduce it. ## 2. Terminology (adds to parent §3) @@ -21,7 +21,7 @@ Done when the parent §17 M2 sentence holds: the same pane runs on a machine wit - **B / connecting machine:** the machine that runs the pane and the `ssh D` process, holding no key. `ssh_enabled` on. - **D / destination:** any sshd. Not gated; dialed to. - **Signing identity:** the per-machine ES256 keypair introduced here, distinct from the ECDH-ES encryption pair already in `apps/node/agent/src/identity.ts` and from the plane-held ES256 command key in `control-keys.ts`. -- **Machine pin:** a byte-equality pin of a peer's signing public key, held in a new node-side machine pin store (never the channels' `peers.json`). +- **Machine pin:** a byte-equality pin of a peer's public keys, signing and encryption halves alike (§4.4), held in a new node-side machine pin store (never the channels' `peers.json`). - **Relay session:** a bounded plane-brokered pairing of (grant, A, B, pane/subshell id, opaque routing ref, lifetime). - **TOFU:** trust-on-first-use; a key is pinned from the first copy received, and any later differing copy is refused. - **Relay frame:** the new `/ws/node` link frame that carries one sealed envelope (§5.1). @@ -77,7 +77,7 @@ The sealed envelopes ride the `/ws/node` link, not the browser-facing `/api/chan Concretely: the plane sends A and B each one signed **open** command carrying the full pairing: the relay-session id, the opaque routing ref, both node ids with their roles (naming each machine as A or B), the peer's registered signing AND encryption public keys to pin, the grant id and its selected key-fingerprint set (§6.1, the set delivered so A's responder can enforce it without a REST call it cannot make), and the lifetime. After that a new lightweight frame type on the open link carries sealed blobs both ways keyed by the routing ref, with **no per-message command signing**: the link already authenticates the machine and origin comes from the inner ES256 signature (§5.6), so signing each frame is redundant on a stream. The plane matches the two links by routing ref and forwards the opaque blob unopened. **Open, route, close** are the plane's only roles in a payload's life. A relay frame is capped by a new `SSH_RELAY_FRAME_MAX_BYTES` (bounded to the channel envelope scale); an over-cap frame is refused and the session closed with a named reason, matching the repo's posture that wire caps are law. -This adds a link frame type, so **M2 carries its own `NODE_PROTOCOL_VERSION` bump, 17 to 18**: the ssh_enabled bump the parent §4.3 required and M1's own ssh-launch bump were already consumed at tiers 16 and 17, so an agent shipping with M1 speaks tier 17 and would otherwise pass the exact-match gate while understanding no relay frame. The 17→18 bump is what makes §11's "pre-bump agent refused" and §13's bump gate true. +This adds a link frame type, so **M2 carries its own `NODE_PROTOCOL_VERSION` bump, 17 to 18**: the ssh_enabled bump the parent §4.3 required and M1's own ssh-launch bump were already consumed at tiers 16 and 17, so an agent shipping with M1 speaks tier 17 and would otherwise pass the exact-match gate while understanding no relay frame. The 17 to 18 bump is what makes §11's pre-bump refusal and §13's bump gate true. ### 5.2 B: the agent proxy socket @@ -95,6 +95,8 @@ A's responder accepts only requests the plane brokered for a live grant on a ses **Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. +**Roster enumeration for approval.** So the operator has something to choose, the plane can ask A's responder (a signed `ssh_agent_identities` command, no relay session required) for A's agent's public identities as `SHA256:` fingerprints plus their comments, blobs withheld. Like the `detect` answer it discloses nothing but what is asked, writes no audit row, and fails closed if A is offline (§11). + **Agent-only (§3 item 4).** The responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is absent from the roster; Subshell never opens a private key file to compute a match, so key bytes never move and no passphrase path exists on A. ### 5.5 Plane blindness, for the record @@ -117,7 +119,7 @@ Confidentiality is not authenticity: anyone holding A's public key, the plane in ### 6.2 First use creates the grant -The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a durable pending grant-approval row (§9) and notifies A's owner via a new owner-addressed kind in `notify.service.ts` (whose existing surface is subshell-shaped and needs one added kind), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, and A's own public agent identities to choose from. It may display B's registered fingerprint, but that value is plane-rendered and for display only, never the §4.6 check (which reads off the two dashboards). **Approving** selects the key fingerprints and writes the grant plus A's selection; B's pin lands on A at the retried launch's relay-open command (§4.4), not at approval. **Denying** writes the `node.ssh_grant.deny` audit row but no grant and no pin; **expiry** (bounded, at setup-key scale) writes nothing at all. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). +The first time a launch would route through an `(A, destination)` combination with no standing grant, the plane records a durable pending grant-approval row (§9) and notifies A's owner via a new owner-addressed kind in `notify.service.ts` (whose existing surface is subshell-shaped and needs one added kind), then **B's launch fails fast** with a loud, named refusal ("asked to approve, connect again once approved"), never hanging a pane on a human. A's operator answers on the grants screen (a new approve/deny affordance, modeled on the signup `pending-approvals.ts` queue), which shows the requester, the destination, and A's own public agent identities to choose from (enumerated by the §5.4 `ssh_agent_identities` command; with A offline the screen cannot offer a selection and the request stays pending until A is reachable). It may display B's registered fingerprint, but that value is plane-rendered and for display only, never the §4.6 check (which reads off the two dashboards). **Approving** selects the key fingerprints and writes the grant plus A's selection; B's pin lands on A at the retried launch's relay-open command (§4.4), not at approval. **Denying** writes the `node.ssh_grant.deny` audit row but no grant and no pin; **expiry** (bounded, at setup-key scale) writes nothing at all. Later uses under a matching grant are silent. There is no separate per-grant "ask each time" flag: the first-use prompt already carries that role, and the residual scope risk is bounded exactly as parent §8 bounds it (an agent sign request carries no destination, so a grant cannot bind the agent to a host at the protocol layer; that residual is accepted under the single-operator posture and bounded by the fingerprint set plus instant revoke). ### 6.3 Matching, management, and revoke @@ -143,7 +145,7 @@ Copy follows the design system: role tokens only, at most two sentences per expl ## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap). +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), and a handshake-teardown grace constant for §5.6. **New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): @@ -158,15 +160,16 @@ The relay metadata the plane always held (which machines talk, timing, sizes, ro ## 10. Trust model delta (refines parent §10) -Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance, and the operator sees A's actually-pinned keys on its dashboard (§4.5). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 comparison (both values read off the two machines' own plane-independent dashboards, never off the plane's UI); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. +Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 comparison (both values read off the two machines' own plane-independent dashboards, never off the plane's UI); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. ## 11. Failure modes (parent §12, plus M2) Parent §12 stands in full (A offline before handshake, no matching key, changed host key, no-reach vs no-key, relay expiry mid-handshake, loud quota/policy refusal). Add: - **Approval requested, unanswered:** B's launch failed fast with the named refusal; the pending approval expires at its bound writing nothing (§6.2). +- **Roster fetch with A offline:** the approval screen cannot enumerate A's agent identities, so a first-use request stays pending until A is reachable (§5.4, §6.2). - **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and offer the identity register act (§4.3); never proceed unsigned. -- **Peer signing key changed** since pinning: hard block naming the peer, pointing at re-pair (§4.5). +- **Peer key changed** (either the signing or encryption half) since pinning: hard block naming the peer, pointing at re-pair (§4.5). - **D cannot reach the plane during "Set up Subshell here":** a clean egress refusal, the working pane untouched (§7). - **Reverse verification fails:** B rejects a response whose A signature does not match its pin of A; the handshake fails cleanly rather than hanging. - **Relay attempted through `local`:** not offered; both relay roles require the node-agent surfaces (§15), so the server's own `local` node does not serve as A or B. @@ -181,7 +184,7 @@ Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or si The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints, the trust card and loopback dashboard render them from the local store, an offline node stale-marks, and a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. - **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). - **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. @@ -194,12 +197,11 @@ This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhe 1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus a relay-frame type, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES`, `SSH_RELAY_MAX_PER_NODE`, and the relay-lifetime constants, the node-side machine pin store byte-pinning BOTH peer keys with `node.ssh_machine_pin.repair`, the runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). 2. **Relay runtime:** plane broker (open/route/close, lifetime, `SSH_RELAY_MAX_PER_NODE` cap, blind routing on pinned keys), A responder (origin verify, default-deny allow-list, fingerprint-blob filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the per-direction `seq` and canonicalized signed message, and replay tests. -3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the durable pending grant-approval row plus its approve/deny affordance and expiry sweep, the grants screen, and every new `docs/security.md` §10 event name. +3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the `ssh_agent_identities` roster-fetch signed command, the durable `ssh_grant_requests` pending-approval row plus its approve/deny affordance and expiry sweep, the grants screen, and every new `docs/security.md` §10 event name. 4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, the `UserKnownHostsFile` render, the changed-key block, and the trust card plus re-pair UI. 5. **Destination upgrade:** the non-interactive "Set up Subshell here" exec, the egress refusal, and the enrolled-node-appears detection. 6. **Crown-jewel e2e, boundary verification, and the review loop.** ## 15. Non-goals and accepted risks (carry-forward, do not "fix") -The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 comparison (both values off the two machines' own dashboards) can close it on the operator's initiative but is not mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. -- **Relay needs the node agent.** Both roles depend on the node-agent pin store, `ready`-report carriage, and loopback dashboard, so the `local` server node (no agent link or dashboard) does not serve as A or B in relay mode; `local` keeps M1's direct and jump paths, and `local` relay is deferred rather than half-built here. +The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 comparison (both values off the two machines' own dashboards) can close it on the operator's initiative but is not mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. Both roles also depend on the node-agent pin store, runtime-report carriage, and loopback dashboard, so the `local` server node (no agent link or dashboard) does not serve as A or B in relay mode; `local` keeps M1's direct and jump paths, and `local` relay is deferred rather than half-built here. From c5193f135e14f889798eb8d84eea14c4dfa368ca Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:30:43 -0700 Subject: [PATCH 06/92] docs(ssh-relay): M2 spec review round 5 - detection-not-prevention honesty, roster command shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §4.6/§10/§15: the dashboard check is post-hoc DETECTION, not prevention or 'first-secure-contact' - pins only land at first relay-open, so the first handshake already ran on plane-delivered keys; remedy is revoke/re-enroll/ re-pair, not an automatic stop. - §5.4: responder invariant scoped to agent-protocol traffic; ssh_agent_identities is a signed node command (no grant needed); §13 test split so it isn't deadlocked. - §4.2/§4.3/§11/§13: node key rotation clears signingPublicKey so the redial re-report is a first fill, not a §4.3 block; peers re-pair. - §4.5: machine-key fingerprint preimage pinned to DER SPKI (not JSON JWK). - §4.5/§9: the runtime-report fingerprint block is mirrored onto the nodes row so the offline stale-mark is backed; new SSH_MAX_GRANT_FINGERPRINTS (distinct from SSH_MAX_IDENTITY_REFS); §10 notes the roster fetch discloses A's whole roster. --- .../2026-10-08-ssh-agent-relay-design.md | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 8de928bc4..eabeb7c5b 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -45,11 +45,11 @@ These close the parent §16 list; each is elaborated in its section. ### 4.2 Register at enroll -The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. +The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation**, which today clears `encryptPublicKey` inside its transaction, clears `signingPublicKey` in the same transaction, so a rotated node's redial re-report fills an empty slot as a first registration rather than tripping §4.3's guard; peers that had pinned the old key recover through §4.5's re-pair. ### 4.3 Bootstrap for pre-M2 nodes -Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips the §4.4 changed-key block (recovery via §4.5) rather than silently overwriting. +Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips §4.3's own-registration guard rather than silently overwriting (a post-rotation re-report is exempt, because rotation emptied the slot first, §4.2); recovery is re-enroll plus per-peer re-pairs (§4.5). ### 4.4 The node-side machine pin store @@ -57,7 +57,7 @@ Relay is always pin-strict. Each side keeps BOTH of the peer's public keys (the ### 4.5 Pin display and recovery -The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing and encryption fingerprints and every pinned peer with both, each a short `SHA256:` base64 fingerprint over the key's public material, with a re-pair act. That canonical form is fixed so the honest dashboard comparison of §4.6 is well-posed. The values are carried to the plane on the existing node **runtime report** (which `docs/security.md` §6 attaches only for an online agent node and only to owner or `edit` viewers, never `view` or `local`); an offline node shows the last-known card marked stale. The same own-and-pinned fingerprints are also rendered on the node's **own plane-independent loopback dashboard**, which is the plane-independent side of §4.6's check. +The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing and encryption fingerprints and every pinned peer with both, each a short `SHA256:` base64 fingerprint over the key's public material, with a re-pair act. The preimage is pinned: agent keys (§5.4) hash their agent wire encoding, and the ES256/ECDH-ES machine keys hash their DER `SubjectPublicKeyInfo`, never a JSON-JWK serialization (which is not canonical), so the honest dashboard comparison of §4.6 is well-posed rather than serialization-dependent. The values are carried to the plane on the existing node **runtime report** (which `docs/security.md` §6 attaches only for an online agent node and only to owner or `edit` viewers, never `view` or `local`); an offline node shows the last-known card marked stale, which the plane can render because it mirrors the last reported fingerprint block durably onto the `nodes` row (§9) rather than reading only the live socket. The same own-and-pinned fingerprints are also rendered on the node's **own plane-independent loopback dashboard**, which is the plane-independent side of §4.6's check. **Re-pair** replaces a stored peer pin after the plane re-delivers the peer's registered keys to A over the relay-open-style leg (§4.4), not the §4.3 self-report leg (which carries a machine's own key); it is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A machine whose OWN registration slot holds a wrong key recovers by re-enroll plus per-peer re-pairs. A genuinely rotated peer key is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. @@ -65,7 +65,7 @@ The parent §5.3 gap "there is no operator-visible machine pin display or recove The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's keys: A's first copy of B's signing and encryption keys still arrives inside the plane's relay-open command (§4.4), so the **residual window is exactly the parent §10's "a plane compromised before A's first pin of B,"** and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. -The one control that closes it is an out-of-band comparison **in which neither side is plane-rendered**. After pinning, A's loopback dashboard shows the peer fingerprints it actually stored (read from its own node-local pin store, §4.5), and B's loopback dashboard shows B's own fingerprints, computed locally from its private keys. An operator comparing those plane-independent values, for either or both keys, is doing the "first-secure-contact" the parent §10 asked for: a match means the plane delivered B's real keys, a mismatch (the plane substituted) is a hard stop. Comparing the approval screen against anything does **not** close the window, because that screen is the plane's own UI, so the check reads values off the two dashboards, not off the plane. It is **optional, not mandatory**: skipping it leaves the parent §10 window open (accepted under `docs/security.md` §0); making it mandatory was considered and rejected as per-pair ceremony the single-operator posture does not demand. +The one control against it is a **post-hoc** out-of-band comparison in which neither side is plane-rendered. Because B's pin lands on A only at the first relay-open command (§4.4, §6.2), the very first handshake already runs on plane-delivered keys, so this is DETECTION after the fact, not prevention: a substituting plane that holds the substituted signing key keeps signing successfully, A's store is self-consistent under the substitution, and nothing trips mechanically. What the comparison gives is a way to notice. A's loopback dashboard shows the peer fingerprints it actually stored (from its node-local pin store, §4.5); B's dashboard shows B's own fingerprints computed locally from its private keys; a mismatch means the plane substituted, and the remedy is manual: revoke the grant, re-enroll, and re-pair (§4.5), not an automatic stop. Reading the plane's approval screen is worthless here, since that screen is plane-rendered; only the two dashboards count. Preventing the first substituted handshake would need pins delivered out of band before any relay, which this milestone deliberately does not build: the mandatory-fingerprint variant was considered and rejected as per-pair ceremony the single-operator posture does not demand. Skipping the comparison altogether leaves the parent §10 window open, accepted under `docs/security.md` §0. ## 5. Relay runtime @@ -89,13 +89,13 @@ The plane brokers a pairing only when a live grant is used (§6). In memory it h ### 5.4 A: the responder -A's responder accepts only requests the plane brokered for a live grant on a session naming this A. Two rules: +A's responder accepts an **agent-protocol** request only when the plane brokered it for a live grant on a session naming this A. (The roster command below is the one non-agent exception: it is a node-link signed command like `detect`, answered with no grant outstanding.) Two rules: **Method allow-list, default-deny.** Forward exactly the two request types SSH publickey auth uses, `SSH2_AGENTC_REQUEST_IDENTITIES` and `SSH2_AGENTC_SIGN_REQUEST` (by symbolic type, matched on the wire); **refuse every other type**: all identity mutations (`ADD_IDENTITY`, `ADD_ID_CONSTRAINED`, smartcard add/remove, `REMOVE_IDENTITY`, `REMOVE_ALL_IDENTITIES`), `LOCK`/`UNLOCK`, `EXTENSION`/`EXTENSION_RESPONSE`, every SSH1-era code, and any unknown type. A granted window therefore cannot mutate or lock A's agent against its other panes, by default-deny rather than a name list that could miss a code. -**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by `SSH_MAX_IDENTITY_REFS`. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. +**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by a new `SSH_MAX_GRANT_FINGERPRINTS` (kept distinct from `SSH_MAX_IDENTITY_REFS`, which bounds snapshot paths, not grant selections). A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. -**Roster enumeration for approval.** So the operator has something to choose, the plane can ask A's responder (a signed `ssh_agent_identities` command, no relay session required) for A's agent's public identities as `SHA256:` fingerprints plus their comments, blobs withheld. Like the `detect` answer it discloses nothing but what is asked, writes no audit row, and fails closed if A is offline (§11). +**Roster enumeration for approval.** So the operator has something to choose, the plane sends A a signed `ssh_agent_identities` node-link command (like `detect`, answered with no grant outstanding and no relay session) that asks A's agent for its public identities as `SHA256:` fingerprints plus comments, blobs withheld. It answers only what is asked and writes no audit row; it fails closed if A is offline (§11). Each fetch hands the plane A's whole public roster and labels, new plane-side metadata (§10). **Agent-only (§3 item 4).** The responder talks to A's running agent socket and nothing else. A private key that exists as a file but is not loaded in the agent is absent from the roster; Subshell never opens a private key file to compute a match, so key bytes never move and no passphrase path exists on A. @@ -145,7 +145,7 @@ Copy follows the design system: role tokens only, at most two sentences per expl ## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), and a handshake-teardown grace constant for §5.6. +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The last runtime-report ssh-fingerprint block is mirrored onto the `nodes` row so the trust card survives an offline node or a plane restart (§4.5). The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), a handshake-teardown grace constant for §5.6, and `SSH_MAX_GRANT_FINGERPRINTS` (§5.4). **New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): @@ -160,7 +160,7 @@ The relay metadata the plane always held (which machines talk, timing, sizes, ro ## 10. Trust model delta (refines parent §10) -Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and closable by the optional §4.6 comparison (both values read off the two machines' own plane-independent dashboards, never off the plane's UI); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. +Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and detectable after the fact by the optional §4.6 dashboard comparison (which prevents nothing before first use); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. The `ssh_agent_identities` fetch (§5.4) discloses A's whole public roster and labels to the plane on every call, fingerprints and comments only and no blobs, accepted under `docs/security.md` §0 like the rest of the retained metadata. ## 11. Failure modes (parent §12, plus M2) @@ -170,6 +170,7 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed - **Roster fetch with A offline:** the approval screen cannot enumerate A's agent identities, so a first-use request stays pending until A is reachable (§5.4, §6.2). - **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and offer the identity register act (§4.3); never proceed unsigned. - **Peer key changed** (either the signing or encryption half) since pinning: hard block naming the peer, pointing at re-pair (§4.5). +- **Node key rotation:** clears both registration slots (§4.2), the node re-reports on redial, and every peer must re-pair it (§4.5); a stale peer pin blocks until re-paired. - **D cannot reach the plane during "Set up Subshell here":** a clean egress refusal, the working pane untouched (§7). - **Reverse verification fails:** B rejects a response whose A signature does not match its pin of A; the handshake fails cleanly rather than hanging. - **Relay attempted through `local`:** not offered; both relay roles require the node-agent surfaces (§15), so the server's own `local` node does not serve as A or B. @@ -184,8 +185,8 @@ Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or si The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. -- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin request is refused; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation clears `signingPublicKey` so the redial re-report is a first fill, not a block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. +- **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin **agent** request is refused while `ssh_agent_identities` is accepted with no grant outstanding; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). - **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. @@ -195,7 +196,7 @@ This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhe **Likely task slices (for the implementation plan, not yet a plan):** -1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus a relay-frame type, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES`, `SSH_RELAY_MAX_PER_NODE`, and the relay-lifetime constants, the node-side machine pin store byte-pinning BOTH peer keys with `node.ssh_machine_pin.repair`, the runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). +1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration and rotation clearing both slots, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus a relay-frame type, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES`, `SSH_RELAY_MAX_PER_NODE`, `SSH_MAX_GRANT_FINGERPRINTS`, and the relay-lifetime/teardown-grace constants, the node-side machine pin store byte-pinning BOTH peer keys with `node.ssh_machine_pin.repair`, the runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). 2. **Relay runtime:** plane broker (open/route/close, lifetime, `SSH_RELAY_MAX_PER_NODE` cap, blind routing on pinned keys), A responder (origin verify, default-deny allow-list, fingerprint-blob filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the per-direction `seq` and canonicalized signed message, and replay tests. 3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the `ssh_agent_identities` roster-fetch signed command, the durable `ssh_grant_requests` pending-approval row plus its approve/deny affordance and expiry sweep, the grants screen, and every new `docs/security.md` §10 event name. 4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, the `UserKnownHostsFile` render, the changed-key block, and the trust card plus re-pair UI. @@ -204,4 +205,4 @@ This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhe ## 15. Non-goals and accepted risks (carry-forward, do not "fix") -The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 comparison (both values off the two machines' own dashboards) can close it on the operator's initiative but is not mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. Both roles also depend on the node-agent pin store, runtime-report carriage, and loopback dashboard, so the `local` server node (no agent link or dashboard) does not serve as A or B in relay mode; `local` keeps M1's direct and jump paths, and `local` relay is deferred rather than half-built here. +The single-operator posture is unchanged. The plane retains denial-of-service and metadata (parent §10). The parent §10 pre-first-pin window is accepted under `docs/security.md` §0; the §4.6 comparison (both values off the two machines' own dashboards) can DETECT a pre-first-pin substitution after the fact on the operator's initiative, but neither prevents it before first use nor is mandatory. A grant cannot bind the agent to a host at the protocol layer, so exposure is bounded by the fingerprint set and instant revoke rather than host binding. The agent proxy is transient and never forwarded toward D. There is no key custody, no identity-file read, and no passphrase handling. Relay needs A online for the handshake; an offline A is a clear error, never a copy-the-key fallback. Both roles also depend on the node-agent pin store, runtime-report carriage, and loopback dashboard, so the `local` server node (no agent link or dashboard) does not serve as A or B in relay mode; `local` keeps M1's direct and jump paths, and `local` relay is deferred rather than half-built here. From 84a329c76c7d88e98e56717434d69cc3be04a5ee Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:34:03 -0700 Subject: [PATCH 07/92] docs(ssh-relay): M2 spec review round 6 - cap enforcement, mirror gating, installer test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §5.4/§13: SSH_MAX_GRANT_FINGERPRINTS overflow is a hard red grants-screen error (not truncation), with a test. - §8/§9/§13: the offline nodes-row fingerprint mirror serializes under the same owner-or-edit gate as the live runtime field (never view/local); test. - §13: a unit pins the installer 404 branch prints --key , no value. - §6.1: fingerprint wording fixed - grant fingerprints hash agent wire encoding, the trust card shows machine-key DER-SPKI hashes; same notation, not same preimage. --- .../specs/2026-10-08-ssh-agent-relay-design.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index eabeb7c5b..fb111273b 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -93,7 +93,7 @@ A's responder accepts an **agent-protocol** request only when the plane brokered **Method allow-list, default-deny.** Forward exactly the two request types SSH publickey auth uses, `SSH2_AGENTC_REQUEST_IDENTITIES` and `SSH2_AGENTC_SIGN_REQUEST` (by symbolic type, matched on the wire); **refuse every other type**: all identity mutations (`ADD_IDENTITY`, `ADD_ID_CONSTRAINED`, smartcard add/remove, `REMOVE_IDENTITY`, `REMOVE_ALL_IDENTITIES`), `LOCK`/`UNLOCK`, `EXTENSION`/`EXTENSION_RESPONSE`, every SSH1-era code, and any unknown type. A granted window therefore cannot mutate or lock A's agent against its other panes, by default-deny rather than a name list that could miss a code. -**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by a new `SSH_MAX_GRANT_FINGERPRINTS` (kept distinct from `SSH_MAX_IDENTITY_REFS`, which bounds snapshot paths, not grant selections). A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. +**Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by a new `SSH_MAX_GRANT_FINGERPRINTS` (kept distinct from `SSH_MAX_IDENTITY_REFS`, which bounds snapshot paths, not grant selections); an approval selecting more than the cap is a hard red error on the grants screen, never a silent truncation. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. **Roster enumeration for approval.** So the operator has something to choose, the plane sends A a signed `ssh_agent_identities` node-link command (like `detect`, answered with no grant outstanding and no relay session) that asks A's agent for its public identities as `SHA256:` fingerprints plus comments, blobs withheld. It answers only what is asked and writes no audit row; it fails closed if A is offline (§11). Each fetch hands the plane A's whole public roster and labels, new plane-side metadata (§10). @@ -115,7 +115,7 @@ Confidentiality is not authenticity: anyone holding A's public key, the plane in ### 6.1 The grant record -`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set (OpenSSH `SHA256:` base64 over the key's public material, the same canonical form the trust card shows). No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. +`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set (OpenSSH `SHA256:` base64; agent keys hash their agent wire encoding, §4.5, the same notation the trust card uses). No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. ### 6.2 First use creates the grant @@ -137,7 +137,7 @@ A secondary act inside an open SSH-terminal pane on D. It does not re-port #330' - The destination-first flow is unchanged; its step-2 follow-up "sign with [machine]'s keys" now resolves to a relay and the grant/approval path (§6). - **Grants screen:** list, create, edit the selector/name, revoke, and answer pending grant-approvals (§6.2); owner-scoped, under SSH settings. -- **Machine trust card** (the card §4.5 defines): this machine's own signing and encryption fingerprints, every pinned peer with both, and a re-pair act each; fed by the runtime report, so it renders for owner or `edit` viewers only (never `view` or `local`) and is stale-marked offline. Surfaced on the node detail. +- **Machine trust card** (the card §4.5 defines): this machine's own signing and encryption fingerprints, every pinned peer with both, and a re-pair act each; fed by the runtime report and mirrored onto the node row for the offline case, gated to owner or `edit` viewers on both paths (never `view` or `local`) and stale-marked offline. Surfaced on the node detail. - **Host-key display:** destination pins visible; the changed-key refusal is surfaced on the failed connect. - **"Set up Subshell here":** an act in the SSH-terminal pane, not a landing-screen mode (parent §11's destination-first landing stays intact). @@ -145,7 +145,7 @@ Copy follows the design system: role tokens only, at most two sentences per expl ## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The last runtime-report ssh-fingerprint block is mirrored onto the `nodes` row so the trust card survives an offline node or a plane restart (§4.5). The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), a handshake-teardown grace constant for §5.6, and `SSH_MAX_GRANT_FINGERPRINTS` (§5.4). +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The last runtime-report ssh-fingerprint block is mirrored onto the `nodes` row so the trust card survives an offline node or a plane restart (§4.5); the mirrored block serializes only under the same owner-or-`edit` gate as the live runtime field, never `view` and never `local`. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), a handshake-teardown grace constant for §5.6, and `SSH_MAX_GRANT_FINGERPRINTS` (§5.4). **New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): @@ -185,7 +185,7 @@ Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or si The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation clears `signingPublicKey` so the redial re-report is a first fill, not a block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation clears `signingPublicKey` so the redial re-report is a first fill, not a block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. Selecting more than `SSH_MAX_GRANT_FINGERPRINTS` is a hard refusal; the `view` grantee never sees the mirrored fingerprint block; and the rendered installer 404 branch prints `--key ` with no key value. - **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin **agent** request is refused while `ssh_agent_identities` is accepted with no grant outstanding; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). - **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. From 60e2de9697a614d0e5f69dfaa261d15d90936302 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:38:53 -0700 Subject: [PATCH 08/92] docs(ssh-relay): M2 spec review round 7 - rotation is not an identity reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §4.1/§4.2/§4.3/§11/§13: node key rotation is a bearer-credential act; the machine's on-disk signing/encryption keypairs survive it, so the redial re-reports the SAME bytes, no guard trips, and peer pins are unaffected. Only a re-enroll or replaced data dir changes the signing identity and forces re-pairs. Corrects the round-5 containment over-claim. - §7/§11: the 'Set up Subshell here' exec in relay mode re-authenticates through A's agent, so it re-opens a relay and needs A online; add that failure mode. - §5.3 teardown 'handshake established' -> 'handshake grace elapsed' (§5.6 says the handshake is unobservable). - §12: scope 'removed with the pane' to the per-session dir/config; socket unbinds at teardown. --- .../specs/2026-10-08-ssh-agent-relay-design.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index fb111273b..17981507e 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -41,15 +41,15 @@ These close the parent §16 list; each is elaborated in its section. ### 4.1 A per-machine signing keypair -`identity.ts` generates and holds only the ECDH-ES encryption keypair; the ES256 key that signs node commands is plane-held (`control-keys.ts`), so no per-machine signing key exists. M2 adds one: the node generates an ES256 keypair at the same moment and posture as the encryption pair (0600 in the node data dir, private half never leaves the machine, replaced only by explicit re-enroll or key rotation). It signs the relay payloads of §5.6 and nothing else; it never authorizes a plane command on its own. +`identity.ts` generates and holds only the ECDH-ES encryption keypair; the ES256 key that signs node commands is plane-held (`control-keys.ts`), so no per-machine signing key exists. M2 adds one: the node generates an ES256 keypair at the same moment and posture as the encryption pair (0600 in the node data dir, private half never leaves the machine, replaced only when the node's data dir is reset or it re-enrolls; plane-side key rotation does NOT touch it (§4.2)). It signs the relay payloads of §5.6 and nothing else; it never authorizes a plane command on its own. ### 4.2 Register at enroll -The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation**, which today clears `encryptPublicKey` inside its transaction, clears `signingPublicKey` in the same transaction, so a rotated node's redial re-report fills an empty slot as a first registration rather than tripping §4.3's guard; peers that had pinned the old key recover through §4.5's re-pair. +The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation** is a bearer-credential act: it nulls both plane-side registration slots, but the node's own signing and encryption keypairs live on its disk and survive rotation unchanged, so the redial re-reports the SAME bytes, §4.3's guard never trips, and peers' pins are unaffected. Replacing a machine's signing identity for real means a re-enroll or a wiped data dir, and that, not rotation, is the event §4.5's re-pair recovers peers from. ### 4.3 Bootstrap for pre-M2 nodes -Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips §4.3's own-registration guard rather than silently overwriting (a post-rotation re-report is exempt, because rotation emptied the slot first, §4.2); recovery is re-enroll plus per-peer re-pairs (§4.5). +Nodes enrolled before M2 hold a signing keypair after the agent upgrades but have never reported its public key. A new signed command `ssh_register_identity` delivers the signing public key once over the already-authenticated, app-layer-encrypted node link, and the plane writes it into the `signingPublicKey` slot. This reuses the transport parent §5.3 requires (the peer key reaches the node "inside a signed command on the existing node link") for the node's own key at first contact. It is idempotent: a re-report of the same bytes is a no-op; a report of a **different** key than one already on file trips §4.3's own-registration guard rather than silently overwriting (a redial after rotation first-fills the emptied slot, which is not a mismatch, §4.2); a genuine change here means a re-enroll or replaced data dir, recovered by re-enroll plus per-peer re-pairs (§4.5). ### 4.4 The node-side machine pin store @@ -85,7 +85,7 @@ The launcher exports a scoped `SSH_AUTH_SOCK` (parent §5.4, into the `ssh` invo ### 5.3 Plane: the broker -The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a per-node concurrent-relay-session cap `SSH_RELAY_MAX_PER_NODE`, whose exceedance is a loud refusal; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake established, lifetime expiry, A dropping, grant revoke). +The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a per-node concurrent-relay-session cap `SSH_RELAY_MAX_PER_NODE`, whose exceedance is a loud refusal; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake grace elapsed, lifetime expiry, A dropping, grant revoke). ### 5.4 A: the responder @@ -129,7 +129,7 @@ The match predicate is (owner + the A chosen in the launch + the grant's resolve A secondary act inside an open SSH-terminal pane on D. It does not re-port #330's brokered runtime session. It reuses ordinary enrollment: the plane mints a setup key (existing route) and the act drives the standard `install.sh` enrollment on D, so D dials the plane and becomes an ordinary enrolled node, exactly as if the operator ran the one-liner by hand. -**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). The act never echoes raw installer output: the current `install.sh` prints the *unspent* key on its 404 advice branch, so the act runs a small parser that emits only a controlled status verb (success, or the named egress/other failure) and redacts any line matching the setup-key shape; the installer's 404 line is itself fixed to print `--key ` rather than the value. The setup key therefore surfaces only where the accepted postures already allow a non-secret credential: briefly in **B's and D's process tables** while the exec and install child run (the `ps`-visible-argv posture), and in the plane's request/access log only if it rides an install URL (the enrollment-link posture); a non-interactive `sh -c` exec does not append to shell history, so D's history is generally not a sink. Its life is single-use and at most 24 h; it is an enrollment credential, not SSH key material, and `node.ssh_upgrade.run` (§9) never names it, matching the `setup_key.create` no-metadata precedent. +**Transport, stated so the key stays off shared surfaces.** The command runs on D as a **non-interactive `ssh D ''` exec on its own short-lived connection**, separate from the interactive pane; the minted setup key is therefore never typed into the pane (so it reaches neither the pane screen, which streams to view-grantees, nor B's pane log). The act never echoes raw installer output: the current `install.sh` prints the *unspent* key on its 404 advice branch, so the act runs a small parser that emits only a controlled status verb (success, or the named egress/other failure) and redacts any line matching the setup-key shape; the installer's 404 line is itself fixed to print `--key ` rather than the value. The setup key therefore surfaces only where the accepted postures already allow a non-secret credential: briefly in **B's and D's process tables** while the exec and install child run (the `ps`-visible-argv posture), and in the plane's request/access log only if it rides an install URL (the enrollment-link posture); a non-interactive `sh -c` exec does not append to shell history, so D's history is generally not a sink. Its life is single-use and at most 24 h; it is an enrollment credential, not SSH key material, and `node.ssh_upgrade.run` (§9) never names it, matching the `setup_key.create` no-metadata precedent. In relay mode this second `ssh D` re-authenticates through A's agent, so it re-opens a relay under §5.6's adopted lifetime rule and A must be online; direct and jump panes authenticate with B's own key and need no relay. **Completion and failure.** Success is the new node's `ready`/enroll event, awaited with a bounded wait; a midway failure names the stage. If D cannot reach the plane (no egress back to the plane URL), the act refuses cleanly with that named cause, distinct from any key error, and the working pane is unaffected. A relay-only destination becomes a first-class node **by enrolling**, because enrollment is the only way a node row is ever created, so the parent §16 "auto-enroll after set up here" collapses into this act. @@ -170,7 +170,8 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed - **Roster fetch with A offline:** the approval screen cannot enumerate A's agent identities, so a first-use request stays pending until A is reachable (§5.4, §6.2). - **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and offer the identity register act (§4.3); never proceed unsigned. - **Peer key changed** (either the signing or encryption half) since pinning: hard block naming the peer, pointing at re-pair (§4.5). -- **Node key rotation:** clears both registration slots (§4.2), the node re-reports on redial, and every peer must re-pair it (§4.5); a stale peer pin blocks until re-paired. +- **Node key rotation** (bearer credential): nulls both registration slots, the node re-reports the same keys on redial, and peer pins are unaffected (§4.2); only a re-enroll or a replaced data dir changes the signing identity and forces per-peer re-pairs (§4.5). +- **A offline during "Set up Subshell here" in relay mode:** the act's exec cannot re-authenticate without the relay, so it refuses naming that A must be online (§7). - **D cannot reach the plane during "Set up Subshell here":** a clean egress refusal, the working pane untouched (§7). - **Reverse verification fails:** B rejects a response whose A signature does not match its pin of A; the handshake fails cleanly rather than hanging. - **Relay attempted through `local`:** not offered; both relay roles require the node-agent surfaces (§15), so the server's own `local` node does not serve as A or B. @@ -179,13 +180,13 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed ## 12. Security rules honored (parent §13) -Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config 0600 in the per-session dir, removed with the pane. `shellQuote` on every built-command token. The relay open/close are signed commands and relay frames ride the authenticated, encrypted link, with origin the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, never a bespoke cipher and never honoring `SUBSHELL_CHANNEL_PIN`. The destination-upgrade act runs non-interactively so the enrollment setup key stays off the pane and B's log, surfacing only in the accepted process-table and install-URL postures (§7); it is an enrollment credential, not the SSH key material this section never logs. Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. +Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or signature in any log, argv, audit row, or notification. `SSH_AUTH_SOCK` scoped to the ssh invocation only; the agent proxy socket and rendered config are 0600, the socket unbinds at relay teardown (§5.2), and the per-session dir with its rendered config is removed with the pane. `shellQuote` on every built-command token. The relay open/close are signed commands and relay frames ride the authenticated, encrypted link, with origin the inner ES256 signature. Machine-to-machine payloads reuse `mcp-core` seal/open and the TOFU byte-equality rule in the separate always-strict machine store, never a bespoke cipher and never honoring `SUBSHELL_CHANNEL_PIN`. The destination-upgrade act runs non-interactively so the enrollment setup key stays off the pane and B's log, surfacing only in the accepted process-table and install-URL postures (§7); it is an enrollment credential, not the SSH key material this section never logs. Static imports only. An agent-capable SSH pane stays view-only to sharees (parent §5.4). Outbound SSH stays gated per node, default-off, fail-closed. ## 13. Testing The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation clears `signingPublicKey` so the redial re-report is a first fill, not a block. The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. Selecting more than `SSH_MAX_GRANT_FINGERPRINTS` is a hard refusal; the `view` grantee never sees the mirrored fingerprint block; and the rendered installer 404 branch prints `--key ` with no key value. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation nulls both registration slots but re-reports the same bytes, so the redial first-fills the slot with no block and no peer re-pair (§4.2). The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. Selecting more than `SSH_MAX_GRANT_FINGERPRINTS` is a hard refusal; the `view` grantee never sees the mirrored fingerprint block; and the rendered installer 404 branch prints `--key ` with no key value. - **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin **agent** request is refused while `ssh_agent_identities` is accepted with no grant outstanding; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). - **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. From a07ea6952d363915a8797c4031153adcd214333e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:41:20 -0700 Subject: [PATCH 09/92] docs(ssh-relay): M2 spec review round 8 - symmetric pre-first-pin window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §4.6/§10: the residual window is SYMMETRIC (a plane compromised before either machine first pins the other can substitute, including handing B a bad A encryption key to read every challenge); the §4.6 dashboard check is now two readings, one per direction. - §4.5: 'genuinely rotated peer key' -> 'genuinely changed peer key (re-enroll or replaced data dir)', matching the round-7 rotation story. - §5.3 teardown: add 'child exit' to the signal list. - §11: 'no signing key' names the redial remedy instead of an undefined act. - §9: constant-list connector cleanup. --- .../specs/2026-10-08-ssh-agent-relay-design.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 17981507e..6b7ec7354 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -59,13 +59,13 @@ Relay is always pin-strict. Each side keeps BOTH of the peer's public keys (the The parent §5.3 gap "there is no operator-visible machine pin display or recovery flow" is closed by a **machine trust card** (§8): it shows this machine's own signing and encryption fingerprints and every pinned peer with both, each a short `SHA256:` base64 fingerprint over the key's public material, with a re-pair act. The preimage is pinned: agent keys (§5.4) hash their agent wire encoding, and the ES256/ECDH-ES machine keys hash their DER `SubjectPublicKeyInfo`, never a JSON-JWK serialization (which is not canonical), so the honest dashboard comparison of §4.6 is well-posed rather than serialization-dependent. The values are carried to the plane on the existing node **runtime report** (which `docs/security.md` §6 attaches only for an online agent node and only to owner or `edit` viewers, never `view` or `local`); an offline node shows the last-known card marked stale, which the plane can render because it mirrors the last reported fingerprint block durably onto the `nodes` row (§9) rather than reading only the live socket. The same own-and-pinned fingerprints are also rendered on the node's **own plane-independent loopback dashboard**, which is the plane-independent side of §4.6's check. -**Re-pair** replaces a stored peer pin after the plane re-delivers the peer's registered keys to A over the relay-open-style leg (§4.4), not the §4.3 self-report leg (which carries a machine's own key); it is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A machine whose OWN registration slot holds a wrong key recovers by re-enroll plus per-peer re-pairs. A genuinely rotated peer key is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. +**Re-pair** replaces a stored peer pin after the plane re-delivers the peer's registered keys to A over the relay-open-style leg (§4.4), not the §4.3 self-report leg (which carries a machine's own key); it is owner-only and audited (`node.ssh_machine_pin.repair`, naming the peer id only). A machine whose OWN registration slot holds a wrong key recovers by re-enroll plus per-peer re-pairs. A genuinely changed peer key (a re-enroll or a replaced data dir, §4.2) is recovered this way; there is no "trust anyway" escape and no reuse of the channel escape. ### 4.6 What this defends, honestly -The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's keys: A's first copy of B's signing and encryption keys still arrives inside the plane's relay-open command (§4.4), so the **residual window is exactly the parent §10's "a plane compromised before A's first pin of B,"** and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. +The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's keys: A's first copy of B's signing and encryption keys still arrives inside the plane's relay-open command (§4.4), so the **residual window is the parent §10's, generalized to both directions: "a plane compromised before either machine first pins the other"** (A's first copy of B's keys, and B's first copy of A's keys, each arrive through the plane at first pairing, §4.4), and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. -The one control against it is a **post-hoc** out-of-band comparison in which neither side is plane-rendered. Because B's pin lands on A only at the first relay-open command (§4.4, §6.2), the very first handshake already runs on plane-delivered keys, so this is DETECTION after the fact, not prevention: a substituting plane that holds the substituted signing key keeps signing successfully, A's store is self-consistent under the substitution, and nothing trips mechanically. What the comparison gives is a way to notice. A's loopback dashboard shows the peer fingerprints it actually stored (from its node-local pin store, §4.5); B's dashboard shows B's own fingerprints computed locally from its private keys; a mismatch means the plane substituted, and the remedy is manual: revoke the grant, re-enroll, and re-pair (§4.5), not an automatic stop. Reading the plane's approval screen is worthless here, since that screen is plane-rendered; only the two dashboards count. Preventing the first substituted handshake would need pins delivered out of band before any relay, which this milestone deliberately does not build: the mandatory-fingerprint variant was considered and rejected as per-pair ceremony the single-operator posture does not demand. Skipping the comparison altogether leaves the parent §10 window open, accepted under `docs/security.md` §0. +The one control against it is a **post-hoc** out-of-band comparison in which neither side is plane-rendered. Because B's pin lands on A only at the first relay-open command (§4.4, §6.2), the very first handshake already runs on plane-delivered keys, so this is DETECTION after the fact, not prevention: a substituting plane that holds the substituted signing key keeps signing successfully, A's store is self-consistent under the substitution, and nothing trips mechanically. What the comparison gives is a way to notice, and it has two readings, one per direction, because the exposure is symmetric. Each machine's loopback dashboard shows the peer fingerprints it actually stored (from its node-local pin store, §4.5), and the peer's own dashboard shows what it computes locally from its private keys; the operator compares A's stored copy of B against B's own, and B's stored copy of A against A's own. A mismatch in either direction means the plane substituted there, and the remedy is manual: revoke the grant, re-enroll, and re-pair (§4.5), not an automatic stop. Reading the plane's approval screen is worthless here, since that screen is plane-rendered; only the two dashboards count. Preventing the first substituted handshake would need pins delivered out of band before any relay, which this milestone deliberately does not build: the mandatory-fingerprint variant was considered and rejected as per-pair ceremony the single-operator posture does not demand. Skipping the comparison altogether leaves the parent §10 window open, accepted under `docs/security.md` §0. ## 5. Relay runtime @@ -85,7 +85,7 @@ The launcher exports a scoped `SSH_AUTH_SOCK` (parent §5.4, into the `ssh` invo ### 5.3 Plane: the broker -The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a per-node concurrent-relay-session cap `SSH_RELAY_MAX_PER_NODE`, whose exceedance is a loud refusal; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake grace elapsed, lifetime expiry, A dropping, grant revoke). +The plane brokers a pairing only when a live grant is used (§6). In memory it holds the relay session (grant id, A, B, pane/subshell id, routing ref, expiry) plus a per-node concurrent-relay-session cap `SSH_RELAY_MAX_PER_NODE`, whose exceedance is a loud refusal; durable it writes only audit rows (§9). It never holds the agent payload; that lives solely inside sealed envelopes it shuttles. Teardown fires on the signals in §5.6 (handshake grace elapsed, child exit, lifetime expiry, A dropping, grant revoke). ### 5.4 A: the responder @@ -145,7 +145,7 @@ Copy follows the design system: role tokens only, at most two sentences per expl ## 9. Data model and audits (adds to parent §7; audit names to `docs/security.md` §10) -**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The last runtime-report ssh-fingerprint block is mirrored onto the `nodes` row so the trust card survives an offline node or a plane restart (§4.5); the mirrored block serializes only under the same owner-or-`edit` gate as the live runtime field, never `view` and never `local`. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, and `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), a handshake-teardown grace constant for §5.6, and `SSH_MAX_GRANT_FINGERPRINTS` (§5.4). +**Schema.** A signing public key per node, in the identities-store `node:` record's `signingPublicKey` slot (§4.2). Tables `ssh_key_grants` and `ssh_host_pins` (parent §7 M2 rows; both ship now). Relay session state is in-memory on the plane with a durable audit row, no table. The last runtime-report ssh-fingerprint block is mirrored onto the `nodes` row so the trust card survives an offline node or a plane restart (§4.5); the mirrored block serializes only under the same owner-or-`edit` gate as the live runtime field, never `view` and never `local`. The §6.2 pending grant-approval is a **durable** `ssh_grant_requests` row (borrowing the `pending-approvals.ts` signup-queue shape: owner, request, and an expiry sweep), so a plane restart does not forget outstanding approvals; because B already failed fast, a re-launch simply finds the pending row. Node-side: the machine pin store file (§4.4), separate from `peers.json`. The `ssh_enabled` column and mirror ship in M1. New constants in `ssh-limits.ts`: `SSH_RELAY_FRAME_MAX_BYTES`, a relay-lifetime constant modeled on `SSH_SESSION_OPEN_DEADLINE_MS`, `SSH_RELAY_MAX_PER_NODE` (the §5.3 concurrent-relay-session cap), a handshake-teardown grace constant for §5.6, and `SSH_MAX_GRANT_FINGERPRINTS` (§5.4). **New audit events** (ids/hosts/field-names only; never payload, challenge, or signature): @@ -160,7 +160,7 @@ The relay metadata the plane always held (which machines talk, timing, sizes, ro ## 10. Trust model delta (refines parent §10) -Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is precisely parent §10's "a plane compromised before A's first pin of B," accepted under `docs/security.md` §0 and detectable after the fact by the optional §4.6 dashboard comparison (which prevents nothing before first use); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. The `ssh_agent_identities` fetch (§5.4) discloses A's whole public roster and labels to the plane on every call, fingerprints and comments only and no blobs, accepted under `docs/security.md` §0 like the rest of the retained metadata. +Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is parent §10's, generalized to both directions ("a plane compromised before either machine first pins the other"), accepted under `docs/security.md` §0 and detectable after the fact by the optional two-way §4.6 dashboard comparison (which prevents nothing before first use); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. The `ssh_agent_identities` fetch (§5.4) discloses A's whole public roster and labels to the plane on every call, fingerprints and comments only and no blobs, accepted under `docs/security.md` §0 like the rest of the retained metadata. ## 11. Failure modes (parent §12, plus M2) @@ -168,7 +168,7 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed - **Approval requested, unanswered:** B's launch failed fast with the named refusal; the pending approval expires at its bound writing nothing (§6.2). - **Roster fetch with A offline:** the approval screen cannot enumerate A's agent identities, so a first-use request stays pending until A is reachable (§5.4, §6.2). -- **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and offer the identity register act (§4.3); never proceed unsigned. +- **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and state that it registers its signing identity on its next link contact (a redial, §4.3); never proceed unsigned. - **Peer key changed** (either the signing or encryption half) since pinning: hard block naming the peer, pointing at re-pair (§4.5). - **Node key rotation** (bearer credential): nulls both registration slots, the node re-reports the same keys on redial, and peer pins are unaffected (§4.2); only a re-enroll or a replaced data dir changes the signing identity and forces per-peer re-pairs (§4.5). - **A offline during "Set up Subshell here" in relay mode:** the act's exec cannot re-authenticate without the relay, so it refuses naming that A must be online (§7). From 971d680ed03b81a40175ca55b1c551cfa1735983 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:45:18 -0700 Subject: [PATCH 10/92] docs(ssh-relay): M2 spec review round 9 - name the three rotation stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §4.2/§11/§13/§14: rotation clears nodes.encryptPublicKey (link pin, register self-heal re-pins same bytes) + the signingPublicKey slot (§4.3 re-reports same bytes); it NEVER clears the identities ECDH publicKey. 'both slots' was ambiguous and implied a cleared ECDH registration nothing refills. - §4.6/§10: post-pin gain phrased symmetrically ('a machine has pinned'). - §13: add the relay-teardown (grace + child exit) and egress-refusal tests. --- .../specs/2026-10-08-ssh-agent-relay-design.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 6b7ec7354..c09b66b4d 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -45,7 +45,7 @@ These close the parent §16 list; each is elaborated in its section. ### 4.2 Register at enroll -The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation** is a bearer-credential act: it nulls both plane-side registration slots, but the node's own signing and encryption keypairs live on its disk and survive rotation unchanged, so the redial re-reports the SAME bytes, §4.3's guard never trips, and peers' pins are unaffected. Replacing a machine's signing identity for real means a re-enroll or a wiped data dir, and that, not rotation, is the event §4.5's re-pair recovers peers from. +The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation** is a bearer-credential act, and it touches three distinct stores, not one. It clears `nodes.encryptPublicKey` (the node-link pin) as it does today, and in M2 also the identities record's `signingPublicKey` slot; it never clears the identities record's ECDH `publicKey`. The redial re-pins the link static through the existing register self-heal and re-reports the signing key via §4.3, both the SAME bytes (the node's on-disk keypairs survive rotation, §4.1), so §4.3's guard never trips and peers' machine pins are unaffected. Replacing a machine's signing identity for real means a re-enroll or a wiped data dir, and that, not rotation, is the event §4.5's re-pair recovers peers from. ### 4.3 Bootstrap for pre-M2 nodes @@ -63,7 +63,7 @@ The parent §5.3 gap "there is no operator-visible machine pin display or recove ### 4.6 What this defends, honestly -The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's keys: A's first copy of B's signing and encryption keys still arrives inside the plane's relay-open command (§4.4), so the **residual window is the parent §10's, generalized to both directions: "a plane compromised before either machine first pins the other"** (A's first copy of B's keys, and B's first copy of A's keys, each arrive through the plane at first pairing, §4.4), and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** A has pinned it without tripping the block. It does **not** close the pre-first-pin window. +The parent §10 says the load-bearing word is "after": a plane compromised before A first pinned B can substitute its own key. Enroll-anchoring changes what is *stored*, not how A *learns* B's keys: A's first copy of B's signing and encryption keys still arrives inside the plane's relay-open command (§4.4), so the **residual window is the parent §10's, generalized to both directions: "a plane compromised before either machine first pins the other"** (A's first copy of B's keys, and B's first copy of A's keys, each arrive through the plane at first pairing, §4.4), and it stays accepted under `docs/security.md` §0 like every other control-plane-compromise risk. Enroll-anchoring buys three real things: no fresh first-use TOFU ceremony (the key is already registered), byte-strict pins with explicit re-pair recovery, and a plane that cannot swap a key **after** a machine has pinned it without tripping the block. It does **not** close the pre-first-pin window. The one control against it is a **post-hoc** out-of-band comparison in which neither side is plane-rendered. Because B's pin lands on A only at the first relay-open command (§4.4, §6.2), the very first handshake already runs on plane-delivered keys, so this is DETECTION after the fact, not prevention: a substituting plane that holds the substituted signing key keeps signing successfully, A's store is self-consistent under the substitution, and nothing trips mechanically. What the comparison gives is a way to notice, and it has two readings, one per direction, because the exposure is symmetric. Each machine's loopback dashboard shows the peer fingerprints it actually stored (from its node-local pin store, §4.5), and the peer's own dashboard shows what it computes locally from its private keys; the operator compares A's stored copy of B against B's own, and B's stored copy of A against A's own. A mismatch in either direction means the plane substituted there, and the remedy is manual: revoke the grant, re-enroll, and re-pair (§4.5), not an automatic stop. Reading the plane's approval screen is worthless here, since that screen is plane-rendered; only the two dashboards count. Preventing the first substituted handshake would need pins delivered out of band before any relay, which this milestone deliberately does not build: the mandatory-fingerprint variant was considered and rejected as per-pair ceremony the single-operator posture does not demand. Skipping the comparison altogether leaves the parent §10 window open, accepted under `docs/security.md` §0. @@ -160,7 +160,7 @@ The relay metadata the plane always held (which machines talk, timing, sizes, ro ## 10. Trust model delta (refines parent §10) -Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after A has pinned both of B's keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is parent §10's, generalized to both directions ("a plane compromised before either machine first pins the other"), accepted under `docs/security.md` §0 and detectable after the fact by the optional two-way §4.6 dashboard comparison (which prevents nothing before first use); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. The `ssh_agent_identities` fetch (§5.4) discloses A's whole public roster and labels to the plane on every call, fingerprints and comments only and no blobs, accepted under `docs/security.md` §0 like the rest of the retained metadata. +Gained, beyond what parent §10 lists: the plane never reads an agent payload, which now rests on the recipient's **encryption** key being pinned (§4.4, §5.5) rather than on the plane's honesty; after a machine has pinned the other's two keys a plane cannot swap either without tripping the byte-equality block. A's responder enforces a fingerprint set the plane delivers (A has no REST path to fetch it), so a plane could widen the set it hands A; that widening sits inside `docs/security.md` §0's control-plane acceptance; no plane-independent surface shows which set A's responder enforced (the §4.5 dashboard carries machine pins, not the grant's agent-key set), so the durable record of the operator's selection is the `node.ssh_grant.approve` audit row naming the chosen fingerprints (§9). Agent-only (§5.4) removes a class of exposure: no identity file is read, no passphrase prompt lives on A, no key file transits. A grant revoke cuts a live session. The residual is parent §10's, generalized to both directions ("a plane compromised before either machine first pins the other"), accepted under `docs/security.md` §0 and detectable after the fact by the optional two-way §4.6 dashboard comparison (which prevents nothing before first use); it is **not** narrowed by enroll-anchoring, and this spec does not claim it is. A compromised B inside its own granted window is still bounded by the default-deny allow-list and the fingerprint set (parent §10), unchanged. The `ssh_agent_identities` fetch (§5.4) discloses A's whole public roster and labels to the plane on every call, fingerprints and comments only and no blobs, accepted under `docs/security.md` §0 like the rest of the retained metadata. ## 11. Failure modes (parent §12, plus M2) @@ -170,7 +170,7 @@ Parent §12 stands in full (A offline before handshake, no matching key, changed - **Roster fetch with A offline:** the approval screen cannot enumerate A's agent identities, so a first-use request stays pending until A is reachable (§5.4, §6.2). - **No signing key on a node** (a pre-M2 node not yet delivered, or a corrupt store): name the machine and state that it registers its signing identity on its next link contact (a redial, §4.3); never proceed unsigned. - **Peer key changed** (either the signing or encryption half) since pinning: hard block naming the peer, pointing at re-pair (§4.5). -- **Node key rotation** (bearer credential): nulls both registration slots, the node re-reports the same keys on redial, and peer pins are unaffected (§4.2); only a re-enroll or a replaced data dir changes the signing identity and forces per-peer re-pairs (§4.5). +- **Node key rotation** (bearer credential): clears the node-link pin and the signing slot (§4.2); the redial re-pins and re-reports the same bytes, so peer pins are unaffected. Only a re-enroll or a replaced data dir changes the signing identity and forces per-peer re-pairs (§4.5). - **A offline during "Set up Subshell here" in relay mode:** the act's exec cannot re-authenticate without the relay, so it refuses naming that A must be online (§7). - **D cannot reach the plane during "Set up Subshell here":** a clean egress refusal, the working pane untouched (§7). - **Reverse verification fails:** B rejects a response whose A signature does not match its pin of A; the handshake fails cleanly rather than hanging. @@ -186,7 +186,7 @@ Parent §13 stands; these are where M2 leans on it. No SSH key, challenge, or si The parent §14 M2 list is required. Add coverage for these decisions: -- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation nulls both registration slots but re-reports the same bytes, so the redial first-fills the slot with no block and no peer re-pair (§4.2). The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. Selecting more than `SSH_MAX_GRANT_FINGERPRINTS` is a hard refusal; the `view` grantee never sees the mirrored fingerprint block; and the rendered installer 404 branch prints `--key ` with no key value. +- **Units.** Enroll registers `signingPublicKey` beside `publicKey`; `ssh_register_identity` pre-M2 delivery is idempotent and a changed key trips the block; node key rotation clears the link pin and the signing slot but the redial re-reports the same bytes, so there is no block and no peer re-pair (§4.2). The machine pin store is byte-equality strict on BOTH the peer signing and encryption keys, is a separate file from `peers.json`, ignores `SUBSHELL_CHANNEL_PIN`; substituting either half after pinning blocks naming the peer; re-pair replaces the pin and unblocks, and without it the block persists. The runtime report carries own and pinned-peer fingerprints; the loopback dashboard renders them from the node's local store while the trust card renders from the report's carriage; an offline node stale-marks; a fingerprint is canonical `SHA256:` base64 so the honest compare is well-posed. The `ssh_agent_identities` command returns A's agent identities as fingerprints plus comments with blobs withheld, is refused unsigned or by a wrong origin, and writes no audit row. Grant CRUD and the match predicate (resolved selector, most-specific-wins, oldest-tie); an alias typed at approval is stored resolved so a config edit does not re-point it; the first-use approve path creates the grant (B's pin lands on A at the retried relay-open), deny writes only the `node.ssh_grant.deny` row while expiry writes nothing, and a matching grant serves silently on second use; a durable `ssh_grant_requests` row survives a plane restart and is swept at expiry. Identity scoping: only the grant's selected fingerprints are served, the responder never opens a private key file, and a `SIGN_REQUEST` whose blob hash falls outside the set is refused. Method allow-list: every request type in OpenSSH's `agent-proto.h` is enumerated and only `REQUEST_IDENTITIES` and `SIGN_REQUEST` forward. Replay: a multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes, while a resent or non-increasing-`seq` envelope is refused. `NODE_PROTOCOL_VERSION` 17 to 18 gates a pre-bump agent; an over-cap relay frame is refused and the session closed; exceeding `SSH_RELAY_MAX_PER_NODE` is a loud refusal. Host-pin render into `UserKnownHostsFile` with `StrictHostKeyChecking=yes`; a mismatched pin blocks naming D, and delete-plus-fresh-grant TOFU recovers after a changed D key. Selecting more than `SSH_MAX_GRANT_FINGERPRINTS` is a hard refusal; the `view` grantee never sees the mirrored fingerprint block; the rendered installer 404 branch prints `--key ` with no key value; the relay proxy socket unbinds once the §5.6 grace elapses and immediately on child exit; and a non-egressing D refuses "Set up Subshell here" with the named egress cause, pane untouched. - **Integration.** Two in-process machine identities A and B over a fake plane broker: a sign request reaches A's stub agent and returns sealed; a non-allow-listed agent method (e.g. `ADD_IDENTITY` or `REMOVE_ALL_IDENTITIES`) is refused at the responder; an ungranted or wrong-origin **agent** request is refused while `ssh_agent_identities` is accepted with no grant outstanding; a relayed blob is byte-opaque at the fake plane; a captured envelope replayed inside the window **and** replayed into a later session with a reused routing ref is inert; a `SIGN_REQUEST` naming an ungranted blob is refused even after a correctly filtered identities answer; and **B rejects a bad A response signature** (reverse direction). - **End to end (the crown jewel).** Per parent §14: A holds the key in its agent, B holds none, B authenticates to D through the plane relay; the plane-side wire capture shows only opaque envelopes plus plaintext routing ids; the key never appears on B or the plane. Extend it: a grant revoke between handshake and D is refused; and "Set up Subshell here" turns an egressing D into an enrolled node without the setup key appearing in B's pane log. - **CI regime** per `verification.md`: focused files while iterating, full trio plus `lint:prose` at the boundary, e2e under the serial container, `env -u SHELLOPTS -u BASHOPTS` for shell-spawning suites, never the live `:3080`, never run the M2 migration against a live DB. @@ -197,7 +197,7 @@ This is its own branch (`feat/ssh-anywhere-m2`) off the M1 top (`feat/ssh-anywhe **Likely task slices (for the implementation plan, not yet a plan):** -1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration and rotation clearing both slots, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus a relay-frame type, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES`, `SSH_RELAY_MAX_PER_NODE`, `SSH_MAX_GRANT_FINGERPRINTS`, and the relay-lifetime/teardown-grace constants, the node-side machine pin store byte-pinning BOTH peer keys with `node.ssh_machine_pin.repair`, the runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). +1. **Protocol + node identity:** signing keypair generation, `signingPublicKey` enroll registration and rotation clearing the link pin plus the signing slot, `ssh_register_identity` link delivery, the `NODE_PROTOCOL_VERSION` 17→18 bump plus a relay-frame type, validators, and census, `SSH_RELAY_FRAME_MAX_BYTES`, `SSH_RELAY_MAX_PER_NODE`, `SSH_MAX_GRANT_FINGERPRINTS`, and the relay-lifetime/teardown-grace constants, the node-side machine pin store byte-pinning BOTH peer keys with `node.ssh_machine_pin.repair`, the runtime-report fingerprint carriage, and the node's own loopback-dashboard display of its own and pinned-peer fingerprints (the §4.6 out-of-band surface). 2. **Relay runtime:** plane broker (open/route/close, lifetime, `SSH_RELAY_MAX_PER_NODE` cap, blind routing on pinned keys), A responder (origin verify, default-deny allow-list, fingerprint-blob filter), B agent proxy socket plus framing plus seal/open, endpoint-contributed nonces plus the per-direction `seq` and canonicalized signed message, and replay tests. 3. **Grants + approval + audit:** `ssh_key_grants` table/repo/routes, the `ssh_agent_identities` roster-fetch signed command, the durable `ssh_grant_requests` pending-approval row plus its approve/deny affordance and expiry sweep, the grants screen, and every new `docs/security.md` §10 event name. 4. **Host-pin M2 path:** `ssh_host_pins` capture at grant creation, the `UserKnownHostsFile` render, the changed-key block, and the trust card plus re-pair UI. From 439447df4aa2d61fb9fe1fec4ec1e1f1077c6d90 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 03:46:31 -0700 Subject: [PATCH 11/92] docs(ssh-relay): M2 spec review round 10 - 'concerns' three stores (review clean) Round 10 returned no Critical/Important/Minor findings; this is the lone optional nit: rotation *concerns* three stores (it clears two, never the ECDH publicKey). --- docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index c09b66b4d..6f2936b75 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -45,7 +45,7 @@ These close the parent §16 list; each is elaborated in its section. ### 4.2 Register at enroll -The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation** is a bearer-credential act, and it touches three distinct stores, not one. It clears `nodes.encryptPublicKey` (the node-link pin) as it does today, and in M2 also the identities record's `signingPublicKey` slot; it never clears the identities record's ECDH `publicKey`. The redial re-pins the link static through the existing register self-heal and re-reports the signing key via §4.3, both the SAME bytes (the node's on-disk keypairs survive rotation, §4.1), so §4.3's guard never trips and peers' machine pins are unaffected. Replacing a machine's signing identity for real means a re-enroll or a wiped data dir, and that, not rotation, is the event §4.5's re-pair recovers peers from. +The enroll route already writes the node's ECDH-ES public key into the identities store under the `node:` principal. It now carries the signing public key in the same request, stored in the **same `node:` identities record** as a `signingPublicKey` field beside the existing `publicKey`. Because enroll is gated by a single-use, 24 h, operator-minted setup key, the signing key inherits that event's trust: it is bound to the deliberate act of attaching the machine, and node deletion cascades it exactly as the encryption key already does. Node key **rotation** is a bearer-credential act, and it concerns three distinct stores, not one. It clears `nodes.encryptPublicKey` (the node-link pin) as it does today, and in M2 also the identities record's `signingPublicKey` slot; it never clears the identities record's ECDH `publicKey`. The redial re-pins the link static through the existing register self-heal and re-reports the signing key via §4.3, both the SAME bytes (the node's on-disk keypairs survive rotation, §4.1), so §4.3's guard never trips and peers' machine pins are unaffected. Replacing a machine's signing identity for real means a re-enroll or a wiped data dir, and that, not rotation, is the event §4.5's re-pair recovers peers from. ### 4.3 Bootstrap for pre-M2 nodes From 924da8257aa66467b7d9414d7076cdf7e1ede261 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 11:22:12 -0700 Subject: [PATCH 12/92] docs(ssh-relay): M2 implementation plan (16 tasks, subagent-driven) --- .../plans/2026-10-08-ssh-relay-plan.md | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-08-ssh-relay-plan.md diff --git a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md new file mode 100644 index 000000000..abfdc3f2e --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md @@ -0,0 +1,324 @@ +# SSH sealed agent relay (Milestone 2) implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship relay mode: an SSH pane running on a keyless connecting machine B authenticates to an sshd-only destination D through machine A's ssh-agent over sealed envelopes the plane routes but cannot read, plus the machine-identity roster, grants/approval, host-key pins, and the "Set up Subshell here" upgrade that the parent spec's Milestone 2 requires. + +**Architecture:** Reuse the E2EE channel crypto (`mcp-core` seal/open) and the TOFU byte-equality rule in a NEW always-strict node-side machine pin store. Per-machine ES256 signing keys (minted at enroll) prove origin; the peer's signing AND encryption keys are byte-pinned. Sealed agent traffic rides a new frame type on the existing app-layer-encrypted `/ws/node` link (the daemon's only authenticated transport), which the plane blind-routes by an opaque ref. Grants and their first-use approval, destination host pins, and a durable relay-session broker live on the plane; the ssh-agent proxy socket lives on B and the responder on A. The destination upgrade re-uses ordinary enrollment driven non-interactively over the open session. + +**Tech stack:** Bun, TypeScript, Elysia, Kysely (SQLite), jose (JWE/JWS via `mcp-core`), libsodium `crypto_kx`/secretstream node link, node-ED25519/P-256 through WebCrypto, React SPA (`apps/server/web`), Playwright e2e, an sshd test fixture (M1). + +## Global Constraints + +Every task implicitly includes these; values are copied from `docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md` and the parent. + +- The relay is its own branch `feat/ssh-anywhere-m2` off `feat/ssh-anywhere-3`; commit as `Theo Gravity `. Never push or merge without the operator's explicit word; M1 stays unmerged. +- No key material, challenge, signature, or agent payload in any log, argv, audit row, or notification. Audit rows name ids/hosts/field-names only. +- `shellQuote` on every token of the built `ssh` command; the connection is one tmux shell string (never reasoned away). `SSH_AUTH_SOCK` is exported into the `ssh` invocation only, never the whole pane env. An agent-capable SSH pane is view-only to sharees. +- Outbound SSH stays gated per node (`ssh_enabled`, default off, fail-closed). Both relay roles require an agent node; `local` is not an A/B. +- Sealing reuses `packages/mcp-core/src/crypto.ts`; the machine pin store is a separate file, always strict, and NEVER honors `SUBSHELL_CHANNEL_PIN`. Pin the peer's signing AND encryption keys byte-equal. +- Origin = ES256 signature over the canonicalized inner payload, verified against the pinned machine key. Replay = per-direction monotonic `seq` + endpoint-contributed session nonces, both signed. +- Static imports only (the lone sanctioned `await import` is `plugin-runtime.ts`, untouched). +- New wire caps are constants in `packages/subshell-protocol/src/ssh-limits.ts` and are law: `SSH_RELAY_FRAME_MAX_BYTES = 131_072`, `SSH_RELAY_MAX_PER_NODE = 8`, `SSH_RELAY_LIFETIME_MS = 30_000`, `SSH_RELAY_TEARDOWN_GRACE_MS = 5_000`, `SSH_MAX_GRANT_FINGERPRINTS = 8` (a distinct name from `SSH_MAX_IDENTITY_REFS`). +- `NODE_PROTOCOL_VERSION` bumps 17 to 18 (M1 already consumed 16 for the gate and 17 for the ssh-launch grammar); relay frames and the open/close commands ship in that bump. +- Migrations continue at `0049` (`apps/server/api/src/db/migrations/`); never run an M2 migration against a live DB; the ceiling/strip-set fixtures used in M1 must include the new migrations. +- Voice: no U+2013/U+2014 in prose or shipped strings (`bun run lint:prose` gates it); UI copy is at most two sentences, role tokens only. +- Verification: focused files while iterating; the full trio (`bun run verify-types`, `lint:check`, `lint:prose`) plus `bun run test` at every task boundary; `bunx turbo build` before any server/web/e2e test that consumes `packages/`; happy-dom tests run from `apps/server/web`; run shell-spawning suites with `env -u SHELLOPTS -u BASHOPTS` and check bun's file count; never target the live `:3080`. + +--- + +## File structure (what gets created or modified) + +**Protocol (`packages/subshell-protocol/src/`)** +- Modify `node-frames.ts`: `NODE_PROTOCOL_VERSION` 18; new command arms `ssh_register_identity`, `ssh_agent_identities`, `ssh_relay_open`, `ssh_relay_close`; new relay frame kind + `SshRelayFrame`; dispatch in `parseNodeCommandBody`. +- Modify `ssh-frames.ts` / `ssh-results.ts`: validators + result shapes for the four new commands and the relay frame. +- Modify `ssh-limits.ts`: the five new constants. +- New `ssh-pin-store.ts`: shared fingerprint + byte-equality helpers (pure, no node builtin). +- New `ssh-relay.ts`: pure relay envelope codec (canonicalize/sign/verify/nonce/seq), sealing via injected `seal`/`open` (no `node:` import; Metro-safe). + +**Node agent (`apps/node/agent/src/`)** +- Modify `identity.ts`: generate + persist (0600) an ES256 signing keypair beside the ECDH pair; report its public JWK. +- Modify `enroll.ts`, `commands/index.ts`, `commands/report.ts` (ready report), and link register path to carry/register the signing key. +- New `commands/ssh-identity.ts` (`ssh_register_identity`, `ssh_agent_identities` handlers, copying `commands/set-ssh-enabled.ts`). +- New `commands/ssh-relay.ts` (the A-side responder) + `machine-pin-store.ts` (node-local pin file). +- New `relay-proxy.ts` (the B-side agent proxy socket) wired into `commands/ssh-relay.ts`. +- Modify `ssh-shared.ts` for shared relay/socket helpers. + +**Server API (`apps/server/api/src/`)** +- New `db/migrations/0049-ssh-relay-identity.ts` (identities `signingPublicKey`; `nodes.ssh_fingerprint` mirror block), `0050-ssh-grants.ts` (`ssh_key_grants`, `ssh_grant_requests`, `ssh_host_pins`). +- Modify `db/types/identities.db-types.ts`, `db/types/nodes.db-types.ts`; new `db/types/ssh-grants.db-types.ts`. +- Modify `db/repositories/identities.repository.ts` (signing slot); new `ssh-grants.repository.ts`. +- New `services/ssh-relay.service.ts` (broker, route, responder RPC, grant/approval logic), `services/ssh-grants.service.ts`, `services/ssh-host-pins.service.ts`. +- New `services/nodes/relay-frames.ts` (link relay frame pump) and modify `services/nodes/node-ws-handler.ts` + `link-session.ts` for the new frame type + pairing. +- Modify `api/nodes/enroll.route.ts` (register signing key), `rotate-node-key.route.ts` (clear signing slot), `nodes/node-view.ts` + `get-node.route.ts` (fingerprint carriage, owner/edit gate). +- New `api/ssh/grants.route.ts`, `approvals.route.ts`, `trust.route.ts`, `setup-here.route.ts`; modify `api/ssh/index.ts`. +- Modify `services/notify.service.ts` (`grant_approval` kind + owner-addressed send); new pending-approval expiry sweep hook. + +**Pane runtime (`packages/pane-runtime/src/ssh/`)** +- Modify `ssh-render.ts`: a pinned-config render path (UserKnownHostsFile from the host-pin + `StrictHostKeyChecking yes`) used only in relay mode. + +**SPA (`apps/server/web/src/`)** +- Modify `lib/ssh.ts` (grant/approval/trust/setup-here calls + types); new `components/ssh/grants-screen.tsx`, `pending-approvals.tsx`, `trust-card.tsx`; route + nav wiring. + +**Backend client:** `packages/backend-client` re-exports the app type; rebuild after routes land. + +**Tests + docs:** `apps/node/agent/src/**/__tests__`, `apps/server/api/src/**/__tests__`, `apps/server/web/src/**/__tests__`, `packages/subshell-protocol/src/__tests__`, `e2e/tests/23-ssh-relay.spec.ts`; `docs/security.md` §10 event list. + +--- + +## Task 1: Signing keypair at the node + enroll registration + +**Files:** +- Modify: `apps/node/agent/src/identity.ts` +- Modify: `apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts` (create), `apps/server/api/src/db/types/identities.db-types.ts`, `apps/server/api/src/db/repositories/identities.repository.ts` +- Modify: `apps/server/api/src/api/nodes/enroll.route.ts`, `apps/node/agent/src/enroll.ts` +- Test: `apps/node/agent/src/__tests__/identity.test.ts`, `apps/server/api/src/api/nodes/__tests__/enroll.route.test.ts` (existing file), `apps/server/api/src/db/migrations/__tests__` ceiling + +**Interfaces:** +- Produces: `identity.signingKeyPair: CryptoKeyPair` (ES256, P-256) persisted 0600 with a sibling `node-signing-identity.json`; `identity.signingPublicJwk: string`. Identities record gains `signingPublicKey: string | null`. `register({ principalId, publicKey, signingPublicKey, displayName })`. + +- [ ] **Step 1: Write the failing node identity test.** In `identity.test.ts`, assert the loaded identity exposes an ES256 (`"ES256"`/P-256) public JWK on `signingPublicJwk`, that a second load returns the SAME signing key (persisted), and that the file is mode 0600. +- [ ] **Step 2: Run it, confirm FAIL** (`env -u SHELLOPTS -u BASHOPTS bun test apps/node/agent/src/__tests__/identity.test.ts`). +- [ ] **Step 3: Implement.** In `identity.ts`, after the ECDH pair, `generateKeyPair("ES256", { crv: "P-256", extractable: true })`, persist to `node-signing-identity.json` 0600 (write the private JWK, `chmod 0o600`), load-or-generate on ENOENT only (mirror the ECDH loader); export `signingPublicJwk`. Do NOT regenerate on any error other than ENOENT. +- [ ] **Step 4: Migration 0049** adds `signingPublicKey` (`text` nullable) to the identities table and an `ssh_fingerprint` (`text` nullable, JSON mirror block) to `nodes`; add both to `IdentityTable` / `NodesTable` types. Follow `0048-ssh-launch-and-saved-hosts.ts`. +- [ ] **Step 5: Enroll carries it.** In `enroll.ts` add `signingPublicKey: identity.signingPublicJwk` to the enroll body; in `enroll.route.ts` validate it is a P-256 public JWK (mirror the `publicKey` decode/length block) and pass it to `IdentitiesRepository.register({ ..., signingPublicKey })`. Extend `reregister-node.ts` to swap it atomically. +- [ ] **Step 6: Register + repo.** `identities.repository.ts.register` writes `signingPublicKey`; add it to the interface. Node-delete cascade unchanged (row-scoped). +- [ ] **Step 7: Ceiling test** add 0049 to the migration-count/strip-set fixtures; run `env -u SHELLOPTS -u BASHOPTS bun test apps/server/api/src/db` and the enroll test; PASS. +- [ ] **Step 8: Commit** `git commit -m "feat(ssh-relay): per-machine ES256 signing keypair, registered at enroll"`. + +## Task 2: Machine pin store + fingerprint helpers + +**Files:** +- Create: `packages/subshell-protocol/src/ssh-pin-store.ts` +- Create: `apps/node/agent/src/machine-pin-store.ts` +- Test: `packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts`, `apps/node/agent/src/__tests__/machine-pin-store.test.ts` + +**Interfaces:** +- Produces (protocol): `fingerprintJwk(publicJwkString): string` (SHA-256 over DER `SubjectPublicKeyInfo`, `SHA256:` + base64url no-pad), `bytesOfJwk(jwk): Uint8Array` canonicalization. (agent) `class MachinePinStore` with `get(nodeId): { signing: string; encryption: string } | null`, `pin(nodeId, { signing, encryption })`, `check(nodeId, candidate): "ok" | "changed"`, file `/ssh-machine-pins.json` 0600. + +- [ ] **Step 1: Failing protocol test.** `fingerprintJwk` returns a stable `SHA256:` string; two serializations of the same key (different JWK member order) yield the SAME fingerprint (proves DER-SPKI, not JSON). Wrong bytes differ. +- [ ] **Step 2: FAIL run** on the new test path. +- [ ] **Step 3: Implement `ssh-pin-store.ts`.** Import no `node:` builtin; use WebCrypto `crypto.subtle.importKey("jwk", ...)` + `exportKey("spki", ...)` then `digest("SHA-256")`, format `SHA256:` + base64url(no pad). Export the pure helpers. +- [ ] **Step 4: Failing store test.** `MachinePinStore`: first `pin` succeeds; `check` with the same bytes = ok; with different = "changed"; the file is a SEPARATE path from the pane `peers.json`; and reading never consults `SUBSHELL_CHANNEL_PIN` (assert the env var has no effect). +- [ ] **Step 5: Implement `machine-pin-store.ts`.** JSON map `nodeId -> {signing, encryption}`; byte-equality on both halves via the stored raw public strings, not fingerprints; 0600 after write (chmod); no fallback to `peers.json`; a distinct reader function. No `SUBSHELL_CHANNEL_PIN` reference. +- [ ] **Step 6: PASS** both test files. +- [ ] **Step 7: Commit** `git commit -m "feat(ssh-relay): always-strict node machine pin store (both keys, no channel escape)"`. + +## Task 3: `ssh_register_identity` link delivery for pre-M2 nodes + +**Files:** +- Modify: `packages/subshell-protocol/src/ssh-frames.ts`, `node-frames.ts`, `ssh-results.ts` +- Create: `apps/node/agent/src/commands/ssh-identity.ts` +- Modify: `apps/node/agent/src/commands/index.ts`, `apps/server/api/src/services/nodes/*` (ready handler), a new `ssh-rpc` helper +- Test: `apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts` + +**Interfaces:** +- Consumes: `MachinePinStore` (Task 2), signing slot (Task 1). Produces: signed command `ssh_register_identity` returning `{ signingPublicKey }`; server `deliverSigningKey(node): Promise` that stores into the slot and, when a DIFFERENT key is already present, refuses (own-registration guard) and returns the existing. + +- [ ] **Step 1: Failing test.** `deliverSigningKey` writes a first-time report, is idempotent on a re-report of the same bytes, and rejects a report of a different key (returns existing, writes nothing). +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Command shape.** Add the `ssh_register_identity` arm to `parseNodeCommandBody` + `SSH_COMMAND_TYPES` (census) + an `ssh-results.ts` validator for `{ signingPublicKey }`. Handler mirrors `commands/set-ssh-enabled.ts`: read `identity.signingPublicJwk`, return it. +- [ ] **Step 4: Server side.** In the `ready` handler, when an online agent reports no `signingPublicKey` slot, `sendCommand("ssh_register_identity")` once and store the answer into the slot through `deliverSigningKey`. Idempotent, audited `node.ssh_identity.register` with `via:"link"` (add the event name to `docs/security.md` §10). +- [ ] **Step 5: PASS** + protocol test that `parseNodeCommandBody` narrows the new arm and the census count matches. +- [ ] **Step 6: Commit** `git commit -m "feat(ssh-relay): ssh_register_identity delivers signing keys for pre-M2 nodes"`. + +## Task 4: Protocol bump + relay frame type + constants + +**Files:** +- Modify: `packages/subshell-protocol/src/node-frames.ts`, `ssh-limits.ts`, `index.ts` +- Test: `packages/subshell-protocol/src/__tests__/node-frames.test.ts` (census), a new relay-frame codec test + +**Interfaces:** +- Produces: `NODE_PROTOCOL_VERSION = 18`; frame kinds `ssh_relay_open`/`ssh_relay_close` (signed commands) and a `relay` link frame `{ ref, seq, direction, blob }`; the five caps (Global Constraints). `parseRelayFrame(value): RelayFrame | null`. + +- [ ] **Step 1: Failing test.** `NODE_PROTOCOL_VERSION === 18`; `parseRelayFrame` accepts `{ref:"r1",seq:0,direction:"B2A",blob:}` and rejects over-cap blobs (`> SSH_RELAY_FRAME_MAX_BYTES`) and malformed; the open/close command arms validate. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement.** Bump the version; add the open/close command arms (validators name: session id, routing ref, both node ids + roles, peer signing + encryption public keys, grant id + selected fingerprint set, lifetime); add the `relay` frame + `parseRelayFrame` enforcing the cap. Export from `index.ts`. +- [ ] **Step 4: Wire the link pump.** In `apps/server/api/src/services/nodes/link-session.ts` + `node-ws-handler.ts`, accept the `relay` frame after `open`, key pairing by `ref`, and forward between A's and B's sockets without parsing `blob`. Refuse on a pre-18 agent via the existing exact-match gate. +- [ ] **Step 5: PASS** (protocol tests + a plane-side pairing unit test with two fake sockets). +- [ ] **Step 6: Commit** `git commit -m "feat(ssh-relay): protocol 18, relay open/close commands + blind relay frame"`. + +## Task 5: Relay envelope codec (origin, seq, nonces) + +**Files:** +- Create: `packages/subshell-protocol/src/ssh-relay.ts` +- Test: `packages/subshell-protocol/src/__tests__/ssh-relay.test.ts` + +**Interfaces:** +- Consumes: `MachinePinStore` check, `crypto.ts` seal/open, the signing key. Produces: `canonicalize(msg): string`, `sealAgentRequest(...)`, `openAgentRequest(...)`, per-direction `SeqGate` (`accept(dir, seq): boolean` monotonic), nonce helpers. + +- [ ] **Step 1: Failing test.** A message signed by A verifies against A's pinned key and fails against a swapped key; `SeqGate` accepts increasing `seq` per direction and rejects equal/decreasing; a canonicalized payload binds session id + ref + direction + seq + nonces + agent bytes (changing any fails verification); `open` by the wrong recipient id throws. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement `ssh-relay.ts`.** Canonicalize deterministically (sorted-key JSON with fixed number formatting, base64url blobs); ES256 `sign`/`verify` over the canonical string (WebCrypto, injected key); wrap in `seal`/`open` (recipient encryption key from the pin); expose `SeqGate`. No `node:` import (Metro-safe). +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): sealed envelope codec with signed canonical payload + seq gate"`. + +## Task 6: B-side agent proxy socket + +**Files:** +- Create: `apps/node/agent/src/relay-proxy.ts` +- Modify: `apps/node/agent/src/commands/ssh-relay.ts` (new, B branch), `ssh-shared.ts` +- Test: `apps/node/agent/src/__tests__/relay-proxy.test.ts` + +**Interfaces:** +- Consumes: `ssh-relay.ts` codec, the relay frame pump, the machine pin of A. Produces: `startAgentProxy({ paneId, peerNodeId, sessionRef }): { socketPath, close() }` speaking the ssh-agent Unix wire: each request → sealed relay frame (direction B2A, next `seq`); each reply frame → opened and written back; `close()` unbinds. + +- [ ] **Step 1: Failing test.** Against a stub agent, `startAgentProxy` accepts an `SSH2_AGENTC_REQUEST_IDENTITIES` on the socket, emits one sealed relay frame with `seq:0 direction:"B2A"`, and writes the reply bytes back to the socket verbatim; a second `SIGN_REQUEST` gets `seq:1`. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement.** Unix socket in `/ssh//agent.sock` (0600), framed length-prefixed agent messages; seal each to A's pinned encryption key with the ES256 signature; hand the sealed blob to the link pump as a `relay` frame; on reply, verify A's signature (pin) + `SeqGate` then write back. Never export the socket path beyond `SSH_AUTH_SOCK` (the pane env exception). +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): B-side ssh-agent proxy socket over sealed relay frames"`. + +## Task 7: A-side responder (allow-list, filter, origin) + +**Files:** +- Create: `apps/node/agent/src/commands/ssh-relay.ts` (A branch) + `relay-responder.ts` +- Test: `apps/node/agent/src/__tests__/relay-responder.test.ts` + +**Interfaces:** +- Consumes: `MachinePinStore`, `ssh-relay.ts`, the relay open command (grant id + selected fingerprint set + peer keys). Produces: `handleRelayRequest(frame, session): Promise` that verifies origin, default-denies agent methods, filters identities, and forwards to A's real agent socket. + +- [ ] **Step 1: Failing test.** A valid `REQUEST_IDENTITIES` forwarded to a stub agent returns a roster filtered to the grant's fingerprints; a `SIGN_REQUEST` whose blob hash is outside the set is refused (null) and never forwarded; `ADD_IDENTITY`/`REMOVE_ALL_IDENTITIES`/`EXTENSION` refused; a wrong-origin (bad B signature) request refused. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (26) and `SSH2_AGENTC_SIGN_REQUEST` (15) by numeric type, refuse all else; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). +- [ ] **Step 4: PASS** + a test that the responder runs only for a brokered session naming this A and refuses an unbrokered one. +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): A-side responder enforces origin, method allow-list, and grant fingerprint set"`. + +## Task 8: Plane relay broker + lifetime/quota/teardown + audit + +**Files:** +- Create: `apps/server/api/src/services/ssh-relay.service.ts` +- Modify: `services/nodes/relay-frames.ts` (create), `node-ws-handler.ts` +- Test: `apps/server/api/src/services/__tests__/ssh-relay.service.test.ts` + +**Interfaces:** +- Produces: `openRelay({ grantId, aNode, bNode, paneId }): { sessionRef, expiry }` (in-memory), `routeRelayFrame(...)`, `closeRelay(ref, reason)`, quota check, timer teardown; emits `node.ssh_relay.open|close`. + +- [ ] **Step 1: Failing test.** `openRelay` pairs A+B with an opaque ref and stores no payload; exceeding `SSH_RELAY_MAX_PER_NODE` throws a loud refusal; `closeRelay` on lifetime/grant-revoke/A-drop fires; frames route by ref without the plane holding bytes. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement.** In-memory `Map` + a per-node count; deliver the two `ssh_relay_open` signed commands (carrying both peer keys + the grant fingerprint set + lifetime); pump `relay` frames between the two sockets blind; `closeRelay` on expiry, A offline, grant revoke, handshake grace, child exit; audit open/close. +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): plane relay broker with lifetime, quota, blind routing, audit"`. + +## Task 9: Relay integration + replay tests + +**Files:** +- Test: `apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts` + +- [ ] **Step 1: Write the integration matrix.** Two in-process machine identities over a fake plane broker: sign reaches A's stub agent and returns sealed; a non-allow-listed method refused; wrong-origin refused; a `SIGN_REQUEST` with an ungranted blob refused after a correct filtered roster; relayed blob byte-opaque at the plane; captured-envelope replay inside the window AND into a later reused-ref session inert; multi-key session (several `SIGN_REQUEST`s at increasing `seq`) completes; a resent/non-increasing `seq` refused; B rejects a bad A response signature; `ssh_agent_identities` accepted with no grant. +- [ ] **Step 2: Implement any seams the tests expose** (do not add behavior not in Tasks 5-8; if a gap appears, it is a real defect to fix in the owning module, not a test workaround). +- [ ] **Step 3: PASS all.** +- [ ] **Step 4: Commit** `git commit -m "test(ssh-relay): relay integration matrix, replay inertness, reverse verification"`. + +## Task 10: Grants + first-use approval + audit events + +**Files:** +- Create: `db/migrations/0050-ssh-grants.ts`, `db/types/ssh-grants.db-types.ts`, `db/repositories/ssh-grants.repository.ts`, `services/ssh-grants.service.ts` +- Create: `api/ssh/grants.route.ts`, `api/ssh/approvals.route.ts`; modify `api/ssh/index.ts` +- Modify: `services/notify.service.ts` (a `grant_approval` owner-addressed kind), a pending-expiry sweep hook +- Modify: `docs/security.md` §10 event list +- Test: `apps/server/api/src/services/__tests__/ssh-grants.service.test.ts`, `api/ssh/__tests__/grants-route.test.ts` + +**Interfaces:** +- Produces: `ssh_key_grants` (id, owner, name, keyHomeNodeId A, resolved selector, `fingerprints` JSON, createdVia); `ssh_grant_requests` (durable pending row + expiry sweep); `requestFirstUse`, `approveGrant`, `denyGrant`, `revokeGrant`, `matchGrant(owner, aNode, resolvedHost)`. `SSH_MAX_GRANT_FINGERPRINTS` overflow is a red error. + +- [ ] **Step 1: Failing service test.** First use creates a `ssh_grant_requests` row and notifies; approve writes the grant with the chosen fingerprints and pins; second matching use is silent; deny writes the audit row only; expiry writes nothing; a `ssh_grant_requests` row survives a simulated restart and is swept at expiry; more-than-cap selection refuses. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement migration + repo + service + routes** (cookie + bearer-follows-ownership like the sibling ssh routes; owner-scoped; audit `node.ssh_grant.request|create|update|delete|approve|deny`). B's launch calls `requestFirstUse` and fails fast with the named refusal. +- [ ] **Step 4: notify kind.** Add `grant_approval` to `NotifyKind` + body/urgency maps, and an owner-addressed send path (the existing `notifyDevices` is subshell-row-shaped; add a sibling that takes the owner + a message, reusing the device-token machinery). It writes no secret. +- [ ] **Step 5: PASS** + `docs/security.md` §10 lists every new event. +- [ ] **Step 6: Commit** `git commit -m "feat(ssh-relay): key grants with first-use approval, durable pending row, audits"`. + +## Task 11: `ssh_agent_identities` roster command + +**Files:** +- Modify: `ssh-frames.ts`/`ssh-results.ts`, `node-frames.ts`; `apps/node/agent/src/commands/ssh-identity.ts`; `services/ssh-grants.service.ts` (approval screen feeds from it) +- Test: `apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts` + +- [ ] **Step 1: Failing test.** The plane sends `ssh_agent_identities` to A, gets `{ identities: [{ fingerprint, comment }] }` (blobs withheld), writes no audit row, and fails closed (returns an error, keeps the request pending) when A is offline. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement** the command arm + a handler that enumerates A's live agent public identities as `SHA256:` (agent-wire) + comment; the approvals route calls it to populate the operator's choice. +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): ssh_agent_identities enumerates A's public roster for grant approval"`. + +## Task 12: Host-key pins (capture, render, block, display) + +**Files:** +- Create: `services/ssh-host-pins.service.ts` +- Modify: `packages/pane-runtime/src/ssh/ssh-render.ts`, `apps/node/agent/src/commands/ssh-relay.ts` (B render path), `api/ssh/trust.route.ts` (create) +- Test: `ssh-host-pins.service.test.ts`, `ssh-render.test.ts` (extend), `node-frames` unaffected + +**Interfaces:** +- Produces: `captureHostPin(user@host:port, aKnownHostsOrPin)`, `hostPinFor(resolved)`, `deleteHostPin(resolved)`; render adds `UserKnownHostsFile ` + `StrictHostKeyChecking yes` in relay mode only. + +- [ ] **Step 1: Failing test.** Grant creation captures A's host key as `ssh_host_pins` for `user@host:port`; relay render emits the pinned `UserKnownHostsFile` and `yes`; a changed D key refuses naming D; delete + fresh grant TOFU recovers. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement.** Capture at approve from A's `~/.ssh/known_hosts` (via a relay resolve) or an explicit pin; extend `ssh-render.ts` with a relay-mode branch (leave the M1 `accept-new` path untouched); refuse on mismatch; audit `node.ssh_host_pin.create|delete`. +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): per-destination host-key pins, pinned render, changed-key block"`. + +## Task 13: Machine trust card + fingerprint carriage + mirror + dashboard + +**Files:** +- Modify: `apps/node/agent/src/commands/report.ts` (ready/runtime carries own + pinned-peer fingerprints), `services/nodes/node-view.ts`, `api/nodes/get-node.route.ts`, `rotate-node-key.route.ts` (clear signing slot); `apps/node/agent/src/dashboard/*` (local display); SPA `components/ssh/trust-card.tsx` +- Test: node-view gate test, dashboard test, `rotate-node-key.route.test.ts` (extend) + +**Interfaces:** +- Produces: runtime report gains `sshFingerprint: { own: {signing,encryption}, peers: [{nodeId, signing, encryption}] }`; mirrored onto `nodes.ssh_fingerprint` (owner/`edit` gate, never `view`/`local`), stale-marked offline; loopback dashboard shows own + pinned-peer fingerprints. + +- [ ] **Step 1: Failing tests.** A `view` grantee never sees the mirrored block; an offline agent node shows a stale-marked card; rotation clears the link pin + signing slot and the redial re-reports the same bytes (no block, no peer re-pair). +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement** carriage + mirror + gating (mirror serializes under the same `nodeCanConfigure`/config-capable rule as `runtime`); dashboard render from the node-local store; SPA card on node detail (owner/`edit`). +- [ ] **Step 4: PASS** (server + agent + happy-dom web from `apps/server/web`). +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): machine trust card, mirrored fingerprints, rotation clearing, dashboard display"`. + +## Task 14: Destination upgrade "Set up Subshell here" + +**Files:** +- Create: `api/ssh/setup-here.route.ts`, `services/ssh-setup-here.service.ts` +- Modify: `apps/server/api/src/api/install-script.ts` (404 branch prints `--key `), `apps/node/agent/src/commands/ssh-relay.ts` (non-interactive exec + redaction), SPA pane action +- Test: `ssh-setup-here.service.test.ts`, `install-script.test.ts` (extend) + +**Interfaces:** +- Produces: `setupHere(paneId, ...)`: mint a setup key, run `ssh D ''` as a SEPARATE non-interactive connection (re-opening the relay in relay mode, requiring A online), parse only a status verb with a redaction rule, await the node `ready`. + +- [ ] **Step 1: Failing tests.** The rendered installer 404 branch contains `--key ` and no key value; a non-egressing D refuses with the named egress cause and the pane untouched; the relay-mode act re-opens a relay and refuses naming A-online when A is down; success is detected via the new node's ready event. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement** the exec (never typed into the PTY; redactor drops any `nsk_…` line), the installer line fix, egress refusal, `node.ssh_upgrade.run` audit (never the key). +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): non-interactive 'Set up Subshell here' enrollment over the open session"`. + +## Task 15: SPA grants/approvals screens + nav + client + +**Files:** +- Modify: `apps/server/web/src/lib/ssh.ts` (+ types/calls), routes + nav; Create `components/ssh/grants-screen.tsx`, `pending-approvals.tsx` +- Test: `apps/server/web/src/components/ssh/__tests__/grants-screen.test.tsx`, `lib/__tests__/ssh.test.ts` + +**Interfaces:** Consumes the Eden-treaty client types (rebuild `@internal/backend-client` + `bunx turbo build` first). + +- [ ] **Step 1: Failing web tests** (run from `apps/server/web`): grant list/revoke render; an approval card shows requester/destination/roster choices; a >-cap selection shows the red error; the trust card renders owner/edit only. +- [ ] **Step 2: FAIL run.** +- [ ] **Step 3: Implement** the screens + copy (two sentences, role tokens, no em dash), wire nav, redact B's plane-rendered fingerprint label as "for display only." +- [ ] **Step 4: PASS.** +- [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): grants, pending-approvals, and trust screens in the SPA"`. + +## Task 16: e2e crown jewel + boundary + PR + review loop + +**Files:** +- Create: `e2e/tests/23-ssh-relay.spec.ts`; modify the e2e sshd fixture wiring for a second machine A +- Add: `.changeset/ssh-relay-milestone-2.md` + +- [ ] **Step 1: Write the e2e.** A holds the key in its agent, B holds none, B authenticates to D through the plane relay; a plane-side wire capture shows only opaque envelopes + routing ids; the key never appears on B or the plane; a revoke between handshake and D is refused; "Set up Subshell here" enrolls an egressing D without the setup key in B's log. +- [ ] **Step 2: Run it** (`bun run test:e2e` path for this spec, serial regime, `env -u SHELLOPTS -u BASHOPTS`), fix until green (rebuild `@internal/server-web` + `bunx turbo build` first; e2e serves web dist from disk). +- [ ] **Step 3: Boundary verification.** `bunx turbo build`, `bun run verify-types`, `bun run lint:check`, `bun run lint:prose`, `bun run test` all green; add the changeset (major for the branch, list every touched package). +- [ ] **Step 4: PR.** Branch off `feat/ssh-anywhere-3`; `gh pr create` with a body linking the spec and summarizing the six slices; DO NOT merge (operator directive); push via `ssh.github.com:443` if port 22 stalls. +- [ ] **Step 5: Review loop.** Dispatch the whole-branch `code-reviewer` (most capable model), quote-verify every finding, fix all MAJOR+MINOR, re-review until a pass is clean. +- [ ] **Step 6: Commit + push** the changeset and any review fixes; leave merge to the operator. + +--- + +## Self-review notes (author, after writing) + +- Spec coverage: §4 identity (T1-T3), §5 runtime (T4-T9), §6 grants/approval (T10-T11), §7 upgrade (T14), §9 data/audit (T1/T10/T12/T13/T14 + `docs/security.md`), §8 UI (T13/T15), §13 tests (each task's TDD + T9/T16). §4.6 detection-only posture needs no prevention code (dashboards only, T13). +- Type/name consistency across tasks: `signingPublicKey` slot (T1) used by T3/T13; `MachinePinStore.check` (T2) used by T5/T6/T7; `SeqGate` (T5) used by T6/T7/T8; `ssh_agent_identities` (T11) feeds T10 approvals; `SSH_MAX_GRANT_FINGERPRINTS` (Global) enforced T10, tested T10/T15. +- No placeholders: caps, version bump, migration numbers, file paths, and the non-obvious crypto/quoting rules are named. Routine handlers cite the file to copy (`set-ssh-enabled.ts`). From 8751accbe8ee2f9b2ae6aaf3825a68ffaedc3e37 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 11:32:53 -0700 Subject: [PATCH 13/92] docs(ssh-relay): plan pre-flight - correct agent request codes to 13/11 (default-deny) --- docs/superpowers/plans/2026-10-08-ssh-relay-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md index abfdc3f2e..67fb185b9 100644 --- a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md +++ b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md @@ -180,7 +180,7 @@ Every task implicitly includes these; values are copied from `docs/superpowers/s - [ ] **Step 1: Failing test.** A valid `REQUEST_IDENTITIES` forwarded to a stub agent returns a roster filtered to the grant's fingerprints; a `SIGN_REQUEST` whose blob hash is outside the set is refused (null) and never forwarded; `ADD_IDENTITY`/`REMOVE_ALL_IDENTITIES`/`EXTENSION` refused; a wrong-origin (bad B signature) request refused. - [ ] **Step 2: FAIL run.** -- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (26) and `SSH2_AGENTC_SIGN_REQUEST` (15) by numeric type, refuse all else; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). +- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (13) and `SSH2_AGENTC_SIGN_REQUEST` (11) by numeric type (confirm against the target OpenSSH's `agent-proto.h`), refuse all else by default-deny; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). - [ ] **Step 4: PASS** + a test that the responder runs only for a brokered session naming this A and refuses an unbrokered one. - [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): A-side responder enforces origin, method allow-list, and grant fingerprint set"`. From 7d40691be627babd65c6d931c3f3caa7cf23f661 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 11:43:29 -0700 Subject: [PATCH 14/92] feat(ssh-relay): per-machine ES256 signing keypair, registered at enroll --- apps/node/agent/src/__tests__/enroll.test.ts | 22 ++- .../node/agent/src/__tests__/identity.test.ts | 52 +++++++- apps/node/agent/src/enroll.ts | 3 + apps/node/agent/src/identity.ts | 100 ++++++++++---- apps/node/agent/src/index.ts | 2 +- apps/server/api/src/api/identities.route.ts | 4 + .../api/nodes/__tests__/enroll-route.test.ts | 94 ++++++++++++- apps/server/api/src/api/nodes/enroll.route.ts | 52 +++++++- apps/server/api/src/api/public-jwk.ts | 55 ++++++-- .../__tests__/backup-cli-subprocess.test.ts | 27 ++-- apps/server/api/src/db/migrate.ts | 5 + .../db/migrations/0049-ssh-relay-identity.ts | 26 ++++ .../__tests__/0049-ssh-relay-identity.test.ts | 126 ++++++++++++++++++ .../__tests__/channels.repository.test.ts | 24 +++- .../db/repositories/identities.repository.ts | 11 +- .../api/src/db/types/identities.db-types.ts | 8 ++ .../server/api/src/db/types/nodes.db-types.ts | 8 ++ .../api/src/services/backups/database.ts | 2 +- .../nodes/__tests__/remote-launcher.test.ts | 2 + .../api/src/services/nodes/reregister-node.ts | 11 ++ 20 files changed, 572 insertions(+), 62 deletions(-) create mode 100644 apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0049-ssh-relay-identity.test.ts diff --git a/apps/node/agent/src/__tests__/enroll.test.ts b/apps/node/agent/src/__tests__/enroll.test.ts index fe416f0d9..c53b75004 100644 --- a/apps/node/agent/src/__tests__/enroll.test.ts +++ b/apps/node/agent/src/__tests__/enroll.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { hostname, tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; @@ -123,7 +123,17 @@ test("enroll posts the route-shaped body, persists config at 0600, exits 0", asy // (measured: `-`/`_`, no padding) and its `from_base64` REFUSES the padded // standard spelling, so this is the alphabet that survives the round trip. expect(Object.keys(seen ?? {}).sort()).toEqual( - ["agentVersion", "arch", "encryptPublicKey", "hostname", "name", "os", "publicKey", "setupKey"].sort(), + [ + "agentVersion", + "arch", + "encryptPublicKey", + "hostname", + "name", + "os", + "publicKey", + "setupKey", + "signingPublicKey", + ].sort(), ); expect(String(seen?.encryptPublicKey)).toMatch(/^[A-Za-z0-9_-]{43,44}$/); expect((["linux", "darwin", "unknown"] as unknown[]).includes(seen?.os)).toBe(true); @@ -135,6 +145,14 @@ test("enroll posts the route-shaped body, persists config at 0600, exits 0", asy const pub = JSON.parse(String(seen?.publicKey)); expect(pub).toMatchObject({ kty: "EC", crv: "P-256" }); expect("d" in pub).toBe(false); + // The relay signing key posted (spec 2026-10-08 §4.2) is the PUBLIC half of + // the persisted sibling file: the private half never leaves the machine, + // and what rides the wire is exactly the string identity generation stored. + const signing = JSON.parse(String(seen?.signingPublicKey)); + expect(signing).toMatchObject({ kty: "EC", crv: "P-256" }); + expect("d" in signing).toBe(false); + const signingFile = JSON.parse(readFileSync(join(dataDir, "node-signing-identity.json"), "utf8")); + expect(seen?.signingPublicKey).toBe(signingFile.publicJwk); const cfg = await loadConfig(); // The link pair is FRESH per enroll, so it is asserted by pairing rather diff --git a/apps/node/agent/src/__tests__/identity.test.ts b/apps/node/agent/src/__tests__/identity.test.ts index 04427d8b3..2fbcf5eef 100644 --- a/apps/node/agent/src/__tests__/identity.test.ts +++ b/apps/node/agent/src/__tests__/identity.test.ts @@ -11,7 +11,8 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { identityPath, loadOrCreateIdentity } from "../identity.js"; +import { importJWK } from "jose"; +import { identityPath, loadOrCreateIdentity, signingIdentityPath } from "../identity.js"; function freshDir(): string { return mkdtempSync(join(tmpdir(), "subshell-id-")); @@ -72,3 +73,52 @@ test("valid JSON that is not a usable identity object routes to quarantine too", await expect(loadOrCreateIdentity(dir)).rejects.toThrow(/refusing to overwrite/i); expect(readdirSync(dir).some((f) => f.endsWith(".corrupt-parse"))).toBe(true); }); + +// ── The M2 relay signing keypair (spec 2026-10-08 §4.1): an ES256 sibling of +// the ECDH pair with the SAME persistence discipline - 0600 file, ENOENT-only +// regeneration, private half never leaves the machine. + +test("first load also mints an ES256 signing keypair at 0600 in the sibling file", async () => { + const dir = freshDir(); + const id = await loadOrCreateIdentity(dir); + const pub = JSON.parse(id.signingPublicJwk); + expect(pub).toMatchObject({ kty: "EC", crv: "P-256" }); + expect(typeof pub.x).toBe("string"); + expect(typeof pub.y).toBe("string"); + expect("d" in pub).toBe(false); + expect(typeof JSON.parse(id.signingPrivateJwk).d).toBe("string"); + // A real curve point, importable for ES256 verification; not a curve point + // is exactly what the enroll route will refuse on this string. + expect(await importJWK(pub, "ES256")).toBeDefined(); + // Sibling file, not the same file, and a genuinely different key. + expect(signingIdentityPath(dir)).not.toBe(identityPath(dir)); + expect(id.signingPublicJwk).not.toBe(id.publicJwk); + expect(statSync(signingIdentityPath(dir)).mode & 0o777).toBe(0o600); +}); + +test("second load reuses the exact same signing key (persisted, no silent rotation)", async () => { + const dir = freshDir(); + const first = await loadOrCreateIdentity(dir); + const second = await loadOrCreateIdentity(dir); + expect(second.signingPublicJwk).toBe(first.signingPublicJwk); + expect(second.signingPrivateJwk).toBe(first.signingPrivateJwk); +}); + +test("corrupt signing file quarantines only itself; the ECDH identity survives", async () => { + const dir = freshDir(); + const first = await loadOrCreateIdentity(dir); + writeFileSync(signingIdentityPath(dir), "{ definitely not json"); + await expect(loadOrCreateIdentity(dir)).rejects.toThrow(/refusing to overwrite/i); + // The signing file moved aside; the encryption identity is untouched. + expect(existsSync(signingIdentityPath(dir))).toBe(false); + expect(existsSync(identityPath(dir))).toBe(true); + const quarantined = readdirSync(dir).filter((f) => f.includes("corrupt")); + expect(quarantined.length).toBe(1); + expect(quarantined[0]).toInclude("node-signing-identity"); + // Only after quarantine may a fresh signing key be minted, and the ECDH + // pair rides through unchanged - one file's corruption rotates one key. + const next = await loadOrCreateIdentity(dir); + expect(next.signingPublicJwk).not.toBe(first.signingPublicJwk); + expect(next.publicJwk).toBe(first.publicJwk); + expect(statSync(signingIdentityPath(dir)).mode & 0o777).toBe(0o600); +}); diff --git a/apps/node/agent/src/enroll.ts b/apps/node/agent/src/enroll.ts index ebb874683..80693caff 100644 --- a/apps/node/agent/src/enroll.ts +++ b/apps/node/agent/src/enroll.ts @@ -113,6 +113,9 @@ export async function runEnroll(opts: EnrollOptions): Promise { hostname: hostname(), agentVersion: NODE_VERSION, publicKey: identity.publicJwk, + // The ES256 relay signing half (spec 2026-10-08 §4.2): the public JWK + // only, registered beside the encryption key in the same node: record. + signingPublicKey: identity.signingPublicJwk, encryptPublicKey: link.publicKey, }; diff --git a/apps/node/agent/src/identity.ts b/apps/node/agent/src/identity.ts index e87dbf143..c6b13e52f 100644 --- a/apps/node/agent/src/identity.ts +++ b/apps/node/agent/src/identity.ts @@ -1,34 +1,68 @@ /** - * Port of backend mcp/identity-store.ts (spec 2026-08-31, task 12) — the same - * fail-closed rules and quarantine wording, simplified to ONE keypair per data - * dir (a node has no principal-per-file; it IS the principal). Deliberately a - * copy: the dependency direction is agent → packages, never agent → backend. + * Port of backend mcp/identity-store.ts (spec 2026-08-31, task 12): the same + * fail-closed rules and quarantine wording, simplified to one keypair FILE per + * role (a node has no principal-per-file; it IS the principal). Deliberately a + * copy: the dependency direction is agent -> packages, never agent -> backend. + * + * Two keypairs, one file each, from protocol 18 / spec 2026-10-08 §4.1: the + * ECDH-ES encryption pair in `identity.json` (sealed delivery) and the ES256 + * signing pair in the sibling `node-signing-identity.json` (SSH relay payloads, + * M2). The signing pair is generated at the same moment and posture as the + * encryption pair and follows the exact same discipline below. * * Fail-closed: ONLY a genuinely missing file (ENOENT) generates a fresh key. * Any other read/parse failure on a PRESENT file means key material exists we - * cannot read — the file is moved aside (best-effort) and the call throws, + * cannot read - the file is moved aside (best-effort) and the call throws, * never silently rotating over the old key and orphaning sealed history. */ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { exportJWK, generateKeyPair } from "jose"; -/** The node's persisted keypair (JWKs as JSON strings, mirroring the backend store). */ +/** + * The node's persisted keypairs. JWKs as JSON strings, mirroring the backend + * store; each pair lives in its own file, so one file's corruption can only + * ever rotate its own key. + */ export interface NodeIdentity { - /** Public JWK JSON (P-256 / ECDH-ES) — sent at enroll, registered for sealed delivery. */ + /** Public JWK JSON (P-256 / ECDH-ES) - sent at enroll, registered for sealed delivery. */ + publicJwk: string; + /** Private JWK JSON - never leaves this file. */ + privateJwk: string; + /** + * Public JWK JSON (P-256 / ES256) - the machine's signing identity for the + * SSH agent relay (spec 2026-10-08 §4.1); sent at enroll, registered beside + * the encryption key in the same `node:` identities record (§4.2). + */ + signingPublicJwk: string; + /** Signing private JWK JSON - never leaves this machine; signs relay payloads only. */ + signingPrivateJwk: string; +} + +/** The on-disk shape of one keypair file; both files share it. */ +interface KeypairFile { publicJwk: string; - /** Private JWK JSON — never leaves this file. */ privateJwk: string; } -/** Absolute path of the identity file inside a data dir. */ +/** Absolute path of the encryption identity file inside a data dir. */ export function identityPath(dataDir: string): string { return join(dataDir, "identity.json"); } -/** Loads the node's keypair, generating and persisting one on first run. */ +/** Absolute path of the ES256 signing identity file inside a data dir. */ +export function signingIdentityPath(dataDir: string): string { + return join(dataDir, "node-signing-identity.json"); +} + +/** + * Loads the node's keypairs (encryption + signing), generating and persisting + * any missing one on first run. An existing ECDH file is never rewritten by + * this call, so a pre-M2 node upgrades into a fresh signing pair without its + * encryption identity moving. + */ export async function loadOrCreateIdentity(dataDir: string): Promise { - // The dir will hold the private key material, so a dir WE create gets 0700 — + // The dir will hold the private key material, so a dir WE create gets 0700 - // unconditionally chmod'd, because mkdir's `mode` option is masked by the // umask and cannot guarantee it. A dir that already exists is left alone: // whoever created it chose its mode (shared mount, pre-seeded permissions, @@ -37,15 +71,30 @@ export async function loadOrCreateIdentity(dataDir: string): Promise { try { const parsed: unknown = JSON.parse(readFileSync(file, "utf8")); - // Valid JSON that is not a usable identity is corruption of a PRESENT file, - // not a first run — the SyntaxError routes it into quarantine below. - if (!isIdentity(parsed)) throw new SyntaxError("identity file is valid JSON but not an identity object"); + // Valid JSON that is not a usable keypair is corruption of a PRESENT file, + // not a first run - the SyntaxError routes it into quarantine below. + if (!isKeypairFile(parsed)) throw new SyntaxError("identity file is valid JSON but not an identity object"); return parsed; } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") return await writeFresh(file); + if ((err as NodeJS.ErrnoException).code === "ENOENT") return await writeFresh(file, alg); const reason = err instanceof SyntaxError ? "parse" : ((err as NodeJS.ErrnoException).code ?? "read"); let aside = `${file}.corrupt-${reason}`; if (existsSync(aside)) aside = `${aside}-${Date.now()}`; @@ -63,23 +112,24 @@ export async function loadOrCreateIdentity(dataDir: string): Promise { +async function writeFresh(file: string, alg: "ECDH-ES" | "ES256"): Promise { // Bun's crypto.subtle lacks ECDH generateKey; jose falls back to node:crypto - // internally (same path @internal/mcp-core's crypto.ts documents). - const { publicKey, privateKey } = await generateKeyPair("ECDH-ES", { crv: "P-256", extractable: true }); - const identity: NodeIdentity = { + // internally (same path @internal/mcp-core's crypto.ts documents). ES256 + // rides the same generator - both are P-256, the algorithm is the purpose. + const { publicKey, privateKey } = await generateKeyPair(alg, { crv: "P-256", extractable: true }); + const keypair: KeypairFile = { publicJwk: JSON.stringify(await exportJWK(publicKey)), privateJwk: JSON.stringify(await exportJWK(privateKey)), }; - writeFileSync(file, JSON.stringify(identity, null, 2), { mode: 0o600 }); - return identity; + writeFileSync(file, JSON.stringify(keypair, null, 2), { mode: 0o600 }); + return keypair; } diff --git a/apps/node/agent/src/index.ts b/apps/node/agent/src/index.ts index 0ac2f878a..7b512bdcd 100644 --- a/apps/node/agent/src/index.ts +++ b/apps/node/agent/src/index.ts @@ -17,7 +17,7 @@ export { wsUrlFor, } from "./daemon.js"; export { type EnrollOptions, type EnrollResult, mapOs, type NodeOs, runEnroll } from "./enroll.js"; -export { identityPath, loadOrCreateIdentity, type NodeIdentity } from "./identity.js"; +export { identityPath, loadOrCreateIdentity, type NodeIdentity, signingIdentityPath } from "./identity.js"; export { buildInventoryEvent, type InventoryEvent } from "./inventory.js"; export { clearLock, type DaemonLock, isPidAlive, lockPath, readLock, writeLock } from "./lock.js"; export { runNodeMcp } from "./mcp/main.js"; diff --git a/apps/server/api/src/api/identities.route.ts b/apps/server/api/src/api/identities.route.ts index 0a655dd56..b5e656270 100644 --- a/apps/server/api/src/api/identities.route.ts +++ b/apps/server/api/src/api/identities.route.ts @@ -42,9 +42,13 @@ export const identityRoutes = new Elysia({ prefix: "/api/identities" }) // Must be a P-256 PUBLIC key jose can IMPORT — a merely parseable // object would poison seal() for every co-member (see public-jwk.ts). await assertImportablePublicJwk(parsed); + // Pane/user principals have no relay signing identity: the slot is + // stated null so the machine-only column (spec 2026-10-08 §4.2) is + // never filled through this door. return await new IdentitiesRepository(db).register({ principalId: principal, publicKey: body.publicKey, + signingPublicKey: null, displayName: body.displayName ?? null, }); }, diff --git a/apps/server/api/src/api/nodes/__tests__/enroll-route.test.ts b/apps/server/api/src/api/nodes/__tests__/enroll-route.test.ts index f91c99709..092b34451 100644 --- a/apps/server/api/src/api/nodes/__tests__/enroll-route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/enroll-route.test.ts @@ -28,6 +28,15 @@ async function jwkString(withD = false): Promise { return JSON.stringify(await exportJWK(withD ? privateKey : publicKey)); } +/** + * Fresh ES256 signing keypair as a JWK string (the M2 relay identity, spec + * 2026-10-08 §4.1); `withD` exports the PRIVATE half, which enroll must refuse. + */ +async function signingJwkString(withD = false): Promise { + const { publicKey, privateKey } = await generateKeyPair("ES256", { crv: "P-256", extractable: true }); + return JSON.stringify(await exportJWK(withD ? privateKey : publicKey)); +} + /** * Snapshot of the constants module taken before any `mock.module` swap, so the * wsUrl subpath test can restore the real values for the suites that share this @@ -53,6 +62,7 @@ describe("/api/nodes/enroll", () => { const createdNodeIds: string[] = []; const createdSetupKeyIds: string[] = []; let goodJwk: string; + let goodSigningJwk: string; beforeAll(async () => { await setupAuthTables(); @@ -64,6 +74,7 @@ describe("/api/nodes/enroll", () => { }); await signIn(aliceEmail, pw); // exercise the real auth stack; enroll itself needs no cookie goodJwk = await jwkString(); + goodSigningJwk = await signingJwkString(); }); afterAll(async () => { @@ -105,6 +116,10 @@ describe("/api/nodes/enroll", () => { hostname: "host-a", agentVersion: "0.1.0", publicKey: goodJwk, + // The M2 relay signing key (spec 2026-10-08 §4.2): a post-M2 agent + // carries it; the pre-M2 posture is `signingPublicKey: undefined`, + // which JSON.stringify drops from the body. + signingPublicKey: goodSigningJwk, ...over, }; } @@ -129,9 +144,11 @@ describe("/api/nodes/enroll", () => { expect(node?.agentVersion).toBe("0.1.0"); expect(node?.apiKeyId).toBeTruthy(); - // The identity row carries the exact validated JWK string under node:. + // The identity row carries the exact validated JWK string under node:, + // and the M2 signing slot (spec 2026-10-08 §4.2) rides the SAME record. const identity = await identities.findByPrincipal(`node:${body.nodeId}`); expect(identity?.publicKey).toBe(goodJwk); + expect(identity?.signingPublicKey).toBe(goodSigningJwk); expect(identity?.displayName).toBe("mini-one"); // The node key is a real `subshell_` bearer; the setup key cannot redeem again. @@ -194,6 +211,7 @@ describe("/api/nodes/enroll", () => { timer: setTimeout(() => {}, 30000), }); const freshJwk = await jwkString(); + const freshSigningJwk = await signingJwkString(); const link = await generateLinkKeyPair(); const freshEncryptPublicKey = link.publicKey; const res = await enroll( @@ -201,6 +219,7 @@ describe("/api/nodes/enroll", () => { name: "ignored replacement name", hostname: "replacement-host", publicKey: freshJwk, + signingPublicKey: freshSigningJwk, encryptPublicKey: freshEncryptPublicKey, }), ); @@ -222,7 +241,11 @@ describe("/api/nodes/enroll", () => { expect(current.hostname).toBe("replacement-host"); expect(current.publicKey).toBe(freshJwk); expect(current.encryptPublicKey).toBe(freshEncryptPublicKey); - expect((await identities.findByPrincipal(`node:${original.nodeId}`))?.publicKey).toBe(freshJwk); + // Both identity halves swap together: re-registration replaces the whole + // machine identity, signing slot included (spec 2026-10-08 §4.2). + const recoveredIdentity = await identities.findByPrincipal(`node:${original.nodeId}`); + expect(recoveredIdentity?.publicKey).toBe(freshJwk); + expect(recoveredIdentity?.signingPublicKey).toBe(freshSigningJwk); expect(await db.selectFrom("nodeShares").selectAll().where("nodeId", "=", original.nodeId).execute()).toHaveLength( 1, ); @@ -495,6 +518,73 @@ describe("/api/nodes/enroll", () => { expect(await repo.peekValid(setupKey)).toBe(true); }); + // ── Relay signing identity (spec 2026-10-08 §4.1/§4.2): enroll carries the + // machine's ES256 public key into the SAME `node:` identity record, and a + // bad one burns nothing. + + it("a valid signingPublicKey registers beside the encryption key", async () => { + const setupKey = await makeKey(); + const res = await enroll(bodyFor(setupKey, { name: "relay-id" })); + expect(res.status).toBe(201); + const { nodeId } = (await res.json()) as { nodeId: string }; + createdNodeIds.push(nodeId); + const identity = await identities.findByPrincipal(`node:${nodeId}`); + expect(identity?.signingPublicKey).toBe(goodSigningJwk); + // The relay half lives only in the identities record, never on the node row. + expect(await nodes.findById(nodeId)).not.toHaveProperty("signingPublicKey"); + }); + + it("an absent signingPublicKey (pre-M2 agent) still enrolls and registers NULL", async () => { + // The field is optional so an old binary enrolls unchanged; §4.3's + // bootstrap delivers the signing key over the node link later. Until then + // the slot is NULL, exactly the shape every pre-M2 row carries. + const setupKey = await makeKey(); + const res = await enroll(bodyFor(setupKey, { name: "pre-m2-one", signingPublicKey: undefined })); + expect(res.status).toBe(201); + const { nodeId } = (await res.json()) as { nodeId: string }; + createdNodeIds.push(nodeId); + const identity = await identities.findByPrincipal(`node:${nodeId}`); + expect(identity?.publicKey).toBe(goodJwk); + expect(identity?.signingPublicKey).toBeNull(); + }); + + it("malformed signingPublicKey fails BEFORE consume — key stays redeemable", async () => { + // THE ordering test for the new field (same position the publicKey checks + // hold): a body that cannot be stored must not spend the single-use key. + // The fixture stays above the schema's minLength so it genuinely reaches + // the route's own decode branches. + const setupKey = await makeKey(); + + const notJson = await enroll(bodyFor(setupKey, { signingPublicKey: "this-is-not-json-at-all" })); + expect(notJson.status).toBe(400); + expect(((await notJson.json()) as { message: string }).message).toInclude("signingPublicKey"); + expect(await repo.peekValid(setupKey)).toBe(true); + + // Well-formed, right shape, coordinates off the curve: only the real + // import proves a curve point (the publicKey discipline, measured). + const offCurve = await enroll( + bodyFor(setupKey, { + signingPublicKey: JSON.stringify({ kty: "EC", crv: "P-256", x: "A".repeat(43), y: "B".repeat(43) }), + }), + ); + expect(offCurve.status).toBe(400); + expect(((await offCurve.json()) as { message: string }).message).toInclude("signingPublicKey"); + expect(await repo.peekValid(setupKey)).toBe(true); + + // The very same key still redeems with a good signing JWK. + const ok = await enroll(bodyFor(setupKey)); + expect(ok.status).toBe(201); + createdNodeIds.push(((await ok.json()) as { nodeId: string }).nodeId); + }); + + it("private signing JWK (carries `d`) → 400 + key unconsumed", async () => { + const setupKey = await makeKey(); + const res = await enroll(bodyFor(setupKey, { signingPublicKey: await signingJwkString(true) })); + expect(res.status).toBe(400); + expect(((await res.json()) as { message: string }).message).toInclude("signingPublicKey"); + expect(await repo.peekValid(setupKey)).toBe(true); + }); + it("os outside linux|darwin|unknown → 400 + key unconsumed", async () => { const setupKey = await makeKey(); const res = await enroll(bodyFor(setupKey, { os: "windows" })); diff --git a/apps/server/api/src/api/nodes/enroll.route.ts b/apps/server/api/src/api/nodes/enroll.route.ts index f286c5345..e1013c1c6 100644 --- a/apps/server/api/src/api/nodes/enroll.route.ts +++ b/apps/server/api/src/api/nodes/enroll.route.ts @@ -3,7 +3,7 @@ import { NODE_NAME_MAX_UNITS, normalizeNodeName } from "@internal/subshell-proto import { ensureSodium } from "@internal/subshell-protocol/node-link-crypto"; import { Elysia, t } from "elysia"; import { HttpError } from "@/api/auth-guard.js"; -import { assertImportablePublicJwk } from "@/api/public-jwk.js"; +import { assertImportablePublicJwk, assertImportableSigningJwk } from "@/api/public-jwk.js"; import type { CreatedApiKey, NodeKeyMetadata } from "@/auth/apikey-store.js"; import { getAuth } from "@/auth.js"; import { APP_BASE_URL } from "@/constants.js"; @@ -50,6 +50,18 @@ const EnrollBodySchema = t.Object({ maxLength: 2048, description: "JSON-serialized P-256 ECDH-ES PUBLIC JWK (no private component) for sealed delivery to this node", }), + // Optional so a pre-M2 agent still enrolls (spec 2026-10-08 §4.3): the + // identities slot stays NULL until the §4.3 bootstrap delivers the key over + // the node link. A PRESENT value is decoded and imported below (like + // `publicKey` above), BEFORE the setup key is spent. + signingPublicKey: t.Optional( + t.String({ + minLength: 16, + maxLength: 2048, + description: + "JSON-serialized P-256 ES256 PUBLIC JWK (no private component): the machine's SSH relay signing key, registered in the same node: identity record", + }), + ), // Optional so a pre-v14 one-liner still enrolls (spec 2026-09-24 §5): the row // then stores NULL and the node registers its static on first connect. A // PRESENT value is decoded and length-checked below — like `publicKey` above, @@ -201,6 +213,38 @@ export const enrollRoute = new Elysia().use(apiModels).post( ); } + // The relay signing key (spec 2026-10-08 §4.1/§4.2), validated in the SAME + // pre-consume position as publicKey above: a malformed value must not burn + // the single-use setup key. An absent field is the pre-M2 posture (NULL + // slot, filled later by the §4.3 bootstrap), never a refusal. + let signingPublicKey: string | null = null; + if (body.signingPublicKey !== undefined) { + let signingParsed: unknown; + try { + signingParsed = JSON.parse(body.signingPublicKey); + } catch { + return status( + 400, + apiErrorBody({ + code: BackendErrorCodes.INPUT_VALIDATION_ERROR, + message: "signingPublicKey must be a JSON JWK", + }), + ); + } + try { + await assertImportableSigningJwk(signingParsed); + } catch (err) { + return status( + 400, + apiErrorBody({ + code: BackendErrorCodes.INPUT_VALIDATION_ERROR, + message: err instanceof HttpError ? err.message : "signingPublicKey must be a valid P-256 public JWK", + }), + ); + } + signingPublicKey = body.signingPublicKey; + } + // The link-encryption static (spec 2026-09-24 §3), validated in the SAME // pre-consume position as publicKey above — a malformed body must not burn // the single-use key. Shape is schema-checked (43-44 chars); the decode @@ -269,6 +313,9 @@ export const enrollRoute = new Elysia().use(apiModels).post( agentVersion: body.agentVersion, }, encryptPublicKey, + // Both identity halves swap together on re-registration (§4.2); the + // validated value, or NULL for a pre-M2 body. + signingPublicKey, ); return status(201, { ...result, controlPublicKey, controlEncryptPublicKey, wsUrl }); } catch (err) { @@ -304,9 +351,12 @@ export const enrollRoute = new Elysia().use(apiModels).post( encryptPublicKey, }); nodeRowCreated = true; + // The signing key lands in the SAME node: record (§4.2): enroll is the + // trust event the key inherits, and node deletion cascades both halves. await new IdentitiesRepository(db).register({ principalId: `node:${nodeId}`, publicKey: body.publicKey, + signingPublicKey, displayName: name, }); // Node key: long-lived (no expiresIn — revocation is delete-node, diff --git a/apps/server/api/src/api/public-jwk.ts b/apps/server/api/src/api/public-jwk.ts index f0bcfe32b..accc298b0 100644 --- a/apps/server/api/src/api/public-jwk.ts +++ b/apps/server/api/src/api/public-jwk.ts @@ -7,19 +7,14 @@ type JwkInput = Parameters[0]; /** Base64url charset; coordinate LENGTH is proven by the real import below. */ const BASE64URL = /^[A-Za-z0-9_-]+$/; -const INVALID = "publicKey must be a valid P-256 public JWK"; - /** - * Proves a parsed JSON value is a PUBLIC P-256 key that jose can actually - * import for ECDH-ES — the same operation `seal()` performs for every roster - * key at post time. Registration only ever saw "parses as a JSON object", so - * a garbage key that passed would make EVERY later `post_channel` throw - * for EVERY member of every channel the registrant joined (channel-wide DoS). - * Structural checks are not enough either: only the import round-trip catches - * coordinates that are well-formed base64url but not a curve point. - * @throws HttpError 400 with a single generic message on any failure. + * Shared shape gate for both P-256 public-key registrations: an EC/P-256 + * object with base64url coordinates and NO private component - a public-key + * store that accepted `d` would be storing a private key it was handed. The + * refusal names the caller's field and nothing else (one generic message, so + * the answer cannot be probed for WHY the key was refused). */ -export async function assertImportablePublicJwk(parsed: unknown): Promise { +function assertP256PublicJwkShape(parsed: unknown, label: string): Record { const jwk = parsed as Record | null; const structurallyOk = typeof parsed === "object" && @@ -35,10 +30,44 @@ export async function assertImportablePublicJwk(parsed: unknown): Promise BASE64URL.test(jwk.y) && // Public-key store: the private component must never be registered. !("d" in jwk); - if (!structurallyOk) throw new HttpError(400, INVALID); + if (!structurallyOk) throw new HttpError(400, `${label} must be a valid P-256 public JWK`); + return jwk; +} + +/** + * Proves a parsed JSON value is a PUBLIC P-256 key that jose can actually + * import for ECDH-ES — the same operation `seal()` performs for every roster + * key at post time. Registration only ever saw "parses as a JSON object", so + * a garbage key that passed would make EVERY later `post_channel` throw + * for EVERY member of every channel the registrant joined (channel-wide DoS). + * Structural checks are not enough either: only the import round-trip catches + * coordinates that are well-formed base64url but not a curve point. + * @throws HttpError 400 with a single generic message on any failure. + */ +export async function assertImportablePublicJwk(parsed: unknown): Promise { + const jwk = assertP256PublicJwkShape(parsed, "publicKey"); try { await importJWK(jwk as unknown as JwkInput, "ECDH-ES"); } catch { - throw new HttpError(400, INVALID); + throw new HttpError(400, "publicKey must be a valid P-256 public JWK"); + } +} + +/** + * Proves a parsed JSON value is a PUBLIC P-256 key that jose can import for + * ES256 verification - the operation the SSH relay performs with a machine's + * signing key (spec 2026-10-08 §4.1). Same discipline as + * {@link assertImportablePublicJwk}: shape first, then the real import, which + * is the only check that refuses well-formed-looking coordinates that are not + * a curve point. A garbage registration here would poison every later + * signature check against this machine, so nothing that fails it is stored. + * @throws HttpError 400 with a single generic message on any failure. + */ +export async function assertImportableSigningJwk(parsed: unknown): Promise { + const jwk = assertP256PublicJwkShape(parsed, "signingPublicKey"); + try { + await importJWK(jwk as unknown as JwkInput, "ES256"); + } catch { + throw new HttpError(400, "signingPublicKey must be a valid P-256 public JWK"); } } diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 6acba4c3c..27bf37c74 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,21 +392,21 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. - // WHY 0046+0047+0048 together: this era's three migrations are the tail of - // the registered ledger, so the prefix runs contiguously through 0045 and - // boot re-applies all three. Advancing LATEST_BACKUP_MIGRATION (0048) means + // WHY 0047+0048+0049 together: this era's three migrations are the tail of + // the registered ledger, so the prefix runs contiguously through 0046 and + // boot re-applies all three. Advancing LATEST_BACKUP_MIGRATION (0049) means // the strip set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "DROP TABLE backup_recovery; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; DELETE FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "CREATE TABLE backup_recovery(user_id TEXT PRIMARY KEY REFERENCES user(id)); ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; UPDATE migration_restore_probe SET value='newer destination';", + "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; UPDATE migration_restore_probe SET value='newer destination';", ); for (const row of migrationRows) db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity')", ) .all(), ).toEqual([]); @@ -430,18 +430,15 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0046-backup-recovery','0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity') ORDER BY name", ) .all(), ).toEqual([ - { name: "0046-backup-recovery" }, { name: "0047-node-ssh-enabled" }, { name: "0048-ssh-launch-and-saved-hosts" }, + { name: "0049-ssh-relay-identity" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); - expect(migrated.query("SELECT name FROM sqlite_schema WHERE name='backup_recovery'").get()).toEqual({ - name: "backup_recovery", - }); expect( migrated .query("SELECT name FROM pragma_table_info('nodes') WHERE name IN ('ssh_enabled','ssh_enabled_at') ORDER BY name") @@ -453,6 +450,12 @@ test("real entry: available older upgrade snapshot restores and migrates before expect(migrated.query("SELECT name FROM pragma_table_info('subshells') WHERE name='ssh'").get()).toEqual({ name: "ssh", }); + expect( + migrated.query("SELECT name FROM pragma_table_info('identities') WHERE name='signing_public_key'").get(), + ).toEqual({ name: "signing_public_key" }); + expect(migrated.query("SELECT name FROM pragma_table_info('nodes') WHERE name='ssh_fingerprint'").get()).toEqual({ + name: "ssh_fingerprint", + }); migrated.close(); expect(readFileSync(join(instance.configDir, "config.env"), "utf8")).toBe(originalConfig); const deadline = Date.now() + 15_000; diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index e47050718..415466e44 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -48,6 +48,7 @@ import * as nodeReregistrationMigration from "@/db/migrations/0045-node-reregist import * as backupRecoveryMigration from "@/db/migrations/0046-backup-recovery.js"; import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as sshRelayIdentityMigration from "@/db/migrations/0049-ssh-relay-identity.js"; /** * Runs all pending Kysely migrations against the app database. @@ -140,6 +141,10 @@ export async function runMigrations(): Promise { // The SSH launcher tier: the per-owner destination ledger and the // pane's approved snapshot in `subshells.ssh` (spec 2026-10-07 §7). "0048-ssh-launch-and-saved-hosts": sshLaunchMigration, + // The relay identity tier: the machine's ES256 signing slot in the + // identities record and the trust-card fingerprint mirror on the + // nodes row (spec 2026-10-08 §4, §9). + "0049-ssh-relay-identity": sshRelayIdentityMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts b/apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts new file mode 100644 index 000000000..5418ccc88 --- /dev/null +++ b/apps/server/api/src/db/migrations/0049-ssh-relay-identity.ts @@ -0,0 +1,26 @@ +import type { Kysely } from "kysely"; + +/** + * The relay identity tier (spec 2026-10-08 §4.2, §4.5, §9). + * + * `identities.signing_public_key` is the per-machine ES256 signing slot that + * rides beside the ECDH-ES encryption key in the SAME `node:` record: enroll + * fills it, deletion cascades it exactly as the encryption key already does. + * `nodes.ssh_fingerprint` is the durable mirror of the last runtime-report + * ssh-fingerprint block, so the trust card survives an offline node or a + * plane restart; it is populated by the report-handling task, not this one. + * + * Both columns are NULL for every pre-M2 row: the signing slot is filled by a + * re-enroll or the `ssh_register_identity` bootstrap (§4.3), and the mirror by + * the next runtime report (§4.5). An upgrade therefore changes nothing any + * existing behavior reads. + */ +export async function up(db: Kysely): Promise { + await db.schema.alterTable("identities").addColumn("signing_public_key", "text").execute(); + await db.schema.alterTable("nodes").addColumn("ssh_fingerprint", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("nodes").dropColumn("ssh_fingerprint").execute(); + await db.schema.alterTable("identities").dropColumn("signing_public_key").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0049-ssh-relay-identity.test.ts b/apps/server/api/src/db/migrations/__tests__/0049-ssh-relay-identity.test.ts new file mode 100644 index 000000000..2825996f8 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0049-ssh-relay-identity.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as initMigration from "@/db/migrations/0001-init.js"; +import * as channelsMigration from "@/db/migrations/0009-channels.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as relayIdentityMigration from "@/db/migrations/0049-ssh-relay-identity.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + identities: Record; + nodes: Record; +} + +/** + * The relay identity tier's storage (spec 2026-10-08 §4.2, §4.5, §9): + * `identities.signing_public_key`, the machine's ES256 signing slot beside the + * ECDH-ES encryption key, and `nodes.ssh_fingerprint`, the durable mirror of + * the runtime fingerprint block so the trust card survives an offline node. + * Both are nullable by design: a pre-M2 row simply carries nothing yet. + */ +describe("migration 0049-ssh-relay-identity", () => { + let dbFile: string; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0049-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + // The FK target, minimally shaped (same posture as the 0048 test); 0009 + // additionally adds a column to the `sessions` table 0001 creates. + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await initMigration.up(db); + await channelsMigration.up(db); + await nodesMigration.up(db); + // Written BEFORE the columns exist - the upgrade case: a pre-M2 principal + // and a pre-M2 node row both read NULL for both new slots. + await db + .insertInto("identities") + .values({ + principal_id: "node:pre-m2", + public_key: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB"}', + display_name: "old box", + }) + .execute(); + await db + .insertInto("nodes") + .values({ + id: "node-pre-m2", + owner_user_id: "u1", + name: "old box", + kind: "agent", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }) + .execute(); + await relayIdentityMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds signing_public_key to identities; a pre-M2 registration reads NULL", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('identities')`.execute(db); + expect(cols.rows.map((c) => c.name)).toContain("signing_public_key"); + const r = await sql<{ signing_public_key: string | null }>` + SELECT signing_public_key FROM identities WHERE principal_id = 'node:pre-m2' + `.execute(db); + expect(r.rows[0].signing_public_key).toBeNull(); + // The signing slot stores the registered JWK text and reads back; the + // encryption key beside it is untouched (same record, §4.2). + const signing = '{"kty":"EC","crv":"P-256","x":"CCC","y":"DDD"}'; + await sql`UPDATE identities SET signing_public_key = ${signing} WHERE principal_id = 'node:pre-m2'`.execute(db); + const back = await sql<{ signing_public_key: string; public_key: string }>` + SELECT signing_public_key, public_key FROM identities WHERE principal_id = 'node:pre-m2' + `.execute(db); + expect(back.rows[0].signing_public_key).toBe(signing); + expect(back.rows[0].public_key).toBe('{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB"}'); + }); + + it("adds ssh_fingerprint to nodes; a pre-M2 row reads NULL and a mirror round-trips", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + expect(cols.rows.map((c) => c.name)).toContain("ssh_fingerprint"); + const r = await sql<{ ssh_fingerprint: string | null }>` + SELECT ssh_fingerprint FROM nodes WHERE id = 'node-pre-m2' + `.execute(db); + expect(r.rows[0].ssh_fingerprint).toBeNull(); + // The mirror block is JSON text (the §4.5 trust card data), written by a + // later task when the agent reports. + const block = JSON.stringify({ signing: "SHA256:aaa", encryption: "SHA256:bbb", reportedAt: "now" }); + await sql`UPDATE nodes SET ssh_fingerprint = ${block} WHERE id = 'node-pre-m2'`.execute(db); + const back = await sql<{ ssh_fingerprint: string }>` + SELECT ssh_fingerprint FROM nodes WHERE id = 'node-pre-m2' + `.execute(db); + expect(back.rows[0].ssh_fingerprint).toBe(block); + }); + + it("neither column is NOT NULL: a fresh insert without them succeeds", async () => { + await db + .insertInto("nodes") + .values({ + id: "node-post-m2", + owner_user_id: "u1", + name: "new box", + kind: "agent", + created_at: "2026-10-08T00:00:00.000Z", + updated_at: "2026-10-08T00:00:00.000Z", + }) + .execute(); + const r = await sql<{ ssh_fingerprint: string | null }>` + SELECT ssh_fingerprint FROM nodes WHERE id = 'node-post-m2' + `.execute(db); + expect(r.rows[0].ssh_fingerprint).toBeNull(); + }); + + it("down drops both columns and leaves the rows", async () => { + await relayIdentityMigration.down(db); + const identityCols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('identities')`.execute(db); + expect(identityCols.rows.map((c) => c.name)).not.toContain("signing_public_key"); + const nodeCols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + expect(nodeCols.rows.map((c) => c.name)).not.toContain("ssh_fingerprint"); + const left = await sql<{ id: string }>`SELECT id FROM nodes ORDER BY id`.execute(db); + expect(left.rows.map((r) => r.id)).toEqual(["node-post-m2", "node-pre-m2"]); + }); +}); diff --git a/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts b/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts index 658c70a0e..1b2e95b42 100644 --- a/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts @@ -65,11 +65,29 @@ describe("IdentitiesRepository", () => { beforeEach(wipe); it("registers and upserts (rotate) by principal", async () => { - const first = await repo.register({ principalId: "sess:a", publicKey: "JWK-1", displayName: "Alpha" }); + const first = await repo.register({ + principalId: "sess:a", + publicKey: "JWK-1", + signingPublicKey: null, + displayName: "Alpha", + }); expect(first.publicKey).toBe("JWK-1"); - const rotated = await repo.register({ principalId: "sess:a", publicKey: "JWK-2", displayName: null }); + expect(first.signingPublicKey).toBeNull(); + const rotated = await repo.register({ + principalId: "sess:a", + publicKey: "JWK-2", + signingPublicKey: "ES-JWK", + displayName: null, + }); expect(rotated.publicKey).toBe("JWK-2"); - expect(await repo.findByPrincipal("sess:a")).toMatchObject({ publicKey: "JWK-2", displayName: null }); + // The signing slot rotates with the record in the one write (spec + // 2026-10-08 §4.2): an insert and an upsert both carry it. + expect(rotated.signingPublicKey).toBe("ES-JWK"); + expect(await repo.findByPrincipal("sess:a")).toMatchObject({ + publicKey: "JWK-2", + signingPublicKey: "ES-JWK", + displayName: null, + }); expect(await repo.findByPrincipal("sess:ghost")).toBeUndefined(); }); }); diff --git a/apps/server/api/src/db/repositories/identities.repository.ts b/apps/server/api/src/db/repositories/identities.repository.ts index 1779f874b..ab227773b 100644 --- a/apps/server/api/src/db/repositories/identities.repository.ts +++ b/apps/server/api/src/db/repositories/identities.repository.ts @@ -8,10 +8,17 @@ import type { IdentityTable } from "@/db/types/identities.db-types.js"; * unreadable to that principal by design (documented in the spec). */ export class IdentitiesRepository extends BaseRepository { - /** Inserts or rotates the principal's keypair registration. */ + /** + * Inserts or rotates the principal's keypair registration. The + * `signingPublicKey` slot (the machine's ES256 relay half, spec + * 2026-10-08 §4.2) rotates WITH the encryption key in the same record, so a + * caller always states it explicitly: null where the principal has no + * signing identity (panes, users) or the agent has not reported one. + */ async register(input: { principalId: string; publicKey: string; + signingPublicKey: string | null; displayName: string | null; }): Promise { await this.db @@ -19,12 +26,14 @@ export class IdentitiesRepository extends BaseRepository { .values({ principalId: input.principalId, publicKey: input.publicKey, + signingPublicKey: input.signingPublicKey, displayName: input.displayName, registeredAt: sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`, }) .onConflict((oc) => oc.column("principalId").doUpdateSet({ publicKey: input.publicKey, + signingPublicKey: input.signingPublicKey, displayName: input.displayName, registeredAt: sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`, }), diff --git a/apps/server/api/src/db/types/identities.db-types.ts b/apps/server/api/src/db/types/identities.db-types.ts index 3c7816890..fb4ff990f 100644 --- a/apps/server/api/src/db/types/identities.db-types.ts +++ b/apps/server/api/src/db/types/identities.db-types.ts @@ -8,6 +8,14 @@ export interface IdentityTable { principalId: string; /** JSON-serialized JWK (P-256 / ECDH-ES public key) */ publicKey: string; + /** + * JSON-serialized JWK (P-256 / ES256 public key): the machine's signing + * identity for the SSH agent relay (spec 2026-10-08 §4.2). Only `node:` + * principals ever carry one - enroll fills it and node deletion cascades + * it exactly as `publicKey` does; a null reads as "never reported" and is + * what every pre-M2 row and every pane/user holds. + */ + signingPublicKey: string | null; /** Convenience label (e.g. subshell name); not authoritative */ displayName: string | null; /** ISO 8601 timestamp of (re-)registration (DB default); re-registering rotates */ diff --git a/apps/server/api/src/db/types/nodes.db-types.ts b/apps/server/api/src/db/types/nodes.db-types.ts index 80c215583..74400a0a8 100644 --- a/apps/server/api/src/db/types/nodes.db-types.ts +++ b/apps/server/api/src/db/types/nodes.db-types.ts @@ -101,6 +101,14 @@ export interface NodeTable { * maintenance. */ sshEnabledAt: string | null; + /** + * JSON mirror of the ssh-fingerprint block from this node's last runtime + * report (spec 2026-10-08 §4.5), so the trust card survives an offline node + * or a plane restart. NULL until a post-M2 agent reports. Serialized only + * under the same owner-or-`edit` gate as the live runtime field, never + * `view` and never `local`. Populated by the report-handling task. + */ + sshFingerprint: string | null; /** ISO 8601 creation time */ createdAt: string; /** ISO 8601 last update time */ diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 284d9d864..8b1e25885 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0048-ssh-launch-and-saved-hosts"; +export const LATEST_BACKUP_MIGRATION = "0049-ssh-relay-identity"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts index 4b9a256d2..a8cb3413a 100644 --- a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts @@ -175,6 +175,8 @@ function nodeRow(over: Partial = {}): NodeTable { maintenanceSource: null, sshEnabled: 0, sshEnabledAt: null, + // The 0049 mirror slot: NULL until the runtime-report task fills it. + sshFingerprint: null, createdAt: "2026-09-01T00:00:00.000Z", updatedAt: "2026-09-01T00:00:00.000Z", ...over, diff --git a/apps/server/api/src/services/nodes/reregister-node.ts b/apps/server/api/src/services/nodes/reregister-node.ts index e4fb95fa1..d09a82c6f 100644 --- a/apps/server/api/src/services/nodes/reregister-node.ts +++ b/apps/server/api/src/services/nodes/reregister-node.ts @@ -21,6 +21,14 @@ export async function reregisterNode( agentVersion: string; }, encryptPublicKey: string | null, + /** + * The ES256 relay signing half (spec 2026-10-08 §4.2): re-registration is a + * fresh enrollment-grade trust event, so it REPLACES the slot rather than + * preserving it. The machine's on-disk keypairs normally survive the + * re-registration, so the replacement is normally the same bytes; null is + * the pre-M2 posture until the §4.3 bootstrap reports. + */ + signingPublicKey: string | null, ): Promise<{ nodeId: string; nodeKey: string; name: string }> { const nodeId = key.targetNodeId; if (!nodeId) throw new Error("Re-registration needs an existing node"); @@ -59,9 +67,12 @@ export async function reregisterNode( .executeTakeFirst(); if (!updated) throw new Error("Node credentials changed; issue a new re-registration key and retry"); name = updated.name; + // Both identity halves rotate together, inside the same transaction that + // swaps the credentials (§4.2). await new IdentitiesRepository(tx).register({ principalId: `node:${nodeId}`, publicKey: machine.publicKey, + signingPublicKey, displayName: name, }); // Auth uses a separate handle to the SAME SQLite file. Revoke through From 0429c4adf297c4b7816bd2a1df10b9a292adb68d Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 11:46:42 -0700 Subject: [PATCH 15/92] test+docs(ssh-relay): address Task 1 review (identities nullability test, protocol-18 wording, em dash) --- apps/node/agent/src/identity.ts | 2 +- apps/server/api/src/api/public-jwk.ts | 2 +- .../__tests__/0049-ssh-relay-identity.test.ts | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/node/agent/src/identity.ts b/apps/node/agent/src/identity.ts index c6b13e52f..720213e1b 100644 --- a/apps/node/agent/src/identity.ts +++ b/apps/node/agent/src/identity.ts @@ -4,7 +4,7 @@ * role (a node has no principal-per-file; it IS the principal). Deliberately a * copy: the dependency direction is agent -> packages, never agent -> backend. * - * Two keypairs, one file each, from protocol 18 / spec 2026-10-08 §4.1: the + * Two keypairs, one file each, for the protocol-18 SSH relay (spec 2026-10-08 §4.1): the * ECDH-ES encryption pair in `identity.json` (sealed delivery) and the ES256 * signing pair in the sibling `node-signing-identity.json` (SSH relay payloads, * M2). The signing pair is generated at the same moment and posture as the diff --git a/apps/server/api/src/api/public-jwk.ts b/apps/server/api/src/api/public-jwk.ts index accc298b0..8fc2421e6 100644 --- a/apps/server/api/src/api/public-jwk.ts +++ b/apps/server/api/src/api/public-jwk.ts @@ -36,7 +36,7 @@ function assertP256PublicJwkShape(parsed: unknown, label: string): Record { }); it("neither column is NOT NULL: a fresh insert without them succeeds", async () => { + // The identities side of the claim: a post-migration registration that + // OMITS the signing slot must land, with the column NULL, not fail NOT NULL. + await db + .insertInto("identities") + .values({ + principal_id: "node:fresh-null", + public_key: '{"kty":"EC","crv":"P-256","x":"EEE","y":"FFF"}', + display_name: "fresh box", + }) + .execute(); + const ri = await sql<{ signing_public_key: string | null }>` + SELECT signing_public_key FROM identities WHERE principal_id = 'node:fresh-null' + `.execute(db); + expect(ri.rows[0].signing_public_key).toBeNull(); await db .insertInto("nodes") .values({ From ad6f1fb2fb1d451527730691b0fdb39a5397112f Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 11:52:24 -0700 Subject: [PATCH 16/92] feat(ssh-relay): always-strict node machine pin store (both keys, no channel escape) --- .../src/__tests__/machine-pin-store.test.ts | 158 ++++++++++++++++ apps/node/agent/src/machine-pin-store.ts | 161 ++++++++++++++++ .../src/__tests__/ssh-pin-store.test.ts | 108 +++++++++++ packages/subshell-protocol/src/index.ts | 3 + .../subshell-protocol/src/ssh-pin-store.ts | 175 ++++++++++++++++++ 5 files changed, 605 insertions(+) create mode 100644 apps/node/agent/src/__tests__/machine-pin-store.test.ts create mode 100644 apps/node/agent/src/machine-pin-store.ts create mode 100644 packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts create mode 100644 packages/subshell-protocol/src/ssh-pin-store.ts diff --git a/apps/node/agent/src/__tests__/machine-pin-store.test.ts b/apps/node/agent/src/__tests__/machine-pin-store.test.ts new file mode 100644 index 000000000..d03f8c876 --- /dev/null +++ b/apps/node/agent/src/__tests__/machine-pin-store.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type MachinePin, MachinePinStore, machinePinPath } from "../machine-pin-store.js"; + +/** + * Relay pins are raw PUBLIC JWK strings; byte equality runs on these exact + * strings (spec 2026-10-08 §4.4), so the fixtures are just distinct opaque + * texts. Fingerprints are a display concern (ssh-pin-store.ts), not a store + * concern. + */ +const keyA: MachinePin = { + signing: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB"}', + encryption: '{"kty":"EC","crv":"P-256","x":"CCC","y":"DDD"}', +}; +const keyB: MachinePin = { + signing: '{"kty":"EC","crv":"P-256","x":"EEE","y":"FFF"}', + encryption: '{"kty":"EC","crv":"P-256","x":"GGG","y":"HHH"}', +}; + +function freshDir(): string { + return mkdtempSync(join(tmpdir(), "subshell-mpin-")); +} + +test("pins land in ssh-machine-pins.json, a file DISTINCT from the pane channels' peers.json", () => { + const dir = freshDir(); + // The channel TOFU store (mcp-core pin-store.ts) lives at /peers.json. + // The relay store must never read or write it, so it is a different name. + expect(machinePinPath(dir)).toBe(join(dir, "ssh-machine-pins.json")); + expect(machinePinPath(dir)).not.toBe(join(dir, "peers.json")); + + const peersFile = join(dir, "peers.json"); + writeFileSync(peersFile, '{"principal:other":"{"untouched":true}"}', { mode: 0o600 }); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + store.check("node-1", keyA); + // The channel pin file rides through byte-identical: the relay store has its + // own path and never borrows the channels'. + expect(readFileSync(peersFile, "utf8")).toBe('{"principal:other":"{"untouched":true}"}'); +}); + +test("the pin file is 0600 after write, and a pre-existing looser mode is repaired", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + expect(statSync(machinePinPath(dir)).mode & 0o777).toBe(0o600); + // A hand-chmod or a umask surprise never survives the next write: chmodSync + // after writeFileSync is load-bearing, since O_CREAT's mode is masked by the + // umask and ignored for an existing file. + chmodSync(machinePinPath(dir), 0o644); + store.pin("node-2", keyB); + expect(statSync(machinePinPath(dir)).mode & 0o777).toBe(0o600); +}); + +test("get returns the raw pinned strings verbatim; an unknown peer has no pin", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + expect(store.get("node-1")).toBe(null); + store.pin("node-1", keyA); + // Byte-for-byte round trip: the stored material is exactly what was pinned, + // not a re-serialization. + expect(store.get("node-1")).toEqual(keyA); +}); + +test("check is byte equality on both halves: ok for the same bytes, changed for either half moving", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + expect(store.check("node-1", keyA)).toBe("ok"); + expect(store.check("node-1", { ...keyA, signing: keyB.signing })).toBe("changed"); + expect(store.check("node-1", { ...keyA, encryption: keyB.encryption })).toBe("changed"); +}); + +test("check compares the RAW strings, not fingerprints: a reordered serialization of the same key is 'changed'", () => { + // The store's comparison rule mirrors pin-store.ts: byte equality on the + // stored material (§4.4). KEY display hashes DER, but a candidate that + // re-serializes the same key is NOT the plane delivering the registered + // bytes, so strictness says "changed" here; the equality the store enforces + // is on the raw strings. + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + const reordered = JSON.parse(keyA.signing); + const reorderedSigning = JSON.stringify({ y: reordered.y, x: reordered.x, crv: reordered.crv, kty: reordered.kty }); + expect(store.check("node-1", { signing: reorderedSigning, encryption: keyA.encryption })).toBe("changed"); +}); + +test("check on an unpinned peer is 'changed': the store fails closed, it never seals-to or verifies-to an unpinned stranger", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + expect(store.check("never-seen", keyA)).toBe("changed"); +}); + +test("pins persist across store instances and multiple peers coexist; re-pin replaces one entry", () => { + const dir = freshDir(); + new MachinePinStore(dir).pin("node-1", keyA); + new MachinePinStore(dir).pin("node-2", keyB); + const reopened = new MachinePinStore(dir); + expect(reopened.get("node-1")).toEqual(keyA); + expect(reopened.get("node-2")).toEqual(keyB); + // §4.5's re-pair replaces the stored pin for that peer and only that peer. + reopened.pin("node-1", keyB); + expect(reopened.get("node-1")).toEqual(keyB); + expect(reopened.get("node-2")).toEqual(keyB); + expect(reopened.check("node-1", keyA)).toBe("changed"); +}); + +test("a corrupt pin file is quarantined and throws; it is never read as an empty pin set", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + writeFileSync(machinePinPath(dir), "{ definitely not json"); + // Fail closed on FIRST read: an unreadable pin set must not silently become + // "no pins" (the pin-store.ts / identity.ts doctrine). The unreadable file + // is moved aside, its bytes preserved for the operator. + expect(() => store.get("node-1")).toThrow(); + const quarantined = readdirSync(dir).filter((f) => f.includes("corrupt")); + expect(quarantined.length).toBe(1); + expect(readFileSync(join(dir, quarantined[0]), "utf8")).toContain("definitely not json"); + // Only after quarantine does the store read empty; a re-pin works again. + expect(store.get("node-1")).toBe(null); + store.pin("node-1", keyA); + expect(store.check("node-1", keyA)).toBe("ok"); + // check() fail-closes the same way: a junk file is never an "empty pin set", + // and a wrong-shape entry is junk too. + writeFileSync(machinePinPath(dir), "[]"); + expect(() => store.check("node-1", keyA)).toThrow(); +}); + +test("SUBSHELL_CHANNEL_PIN has NO effect on this store: it is always strict", () => { + // The channel escape hatch (SUBSHELL_CHANNEL_PIN=trust) is pinned OUT of the + // relay path: setting it must not soften a byte-equality verdict nor skip + // writing the file (spec 2026-10-08 §4.4: "never its escape hatch"). + const previous = process.env.SUBSHELL_CHANNEL_PIN; + process.env.SUBSHELL_CHANNEL_PIN = "trust"; + try { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + expect(store.check("node-1", keyB)).toBe("changed"); + expect(statSync(machinePinPath(dir)).mode & 0o777).toBe(0o600); + } finally { + if (previous === undefined) delete process.env.SUBSHELL_CHANNEL_PIN; + else process.env.SUBSHELL_CHANNEL_PIN = previous; + } +}); + +test("the source never consults SUBSHELL_CHANNEL_PIN (code, not prose, is scanned)", () => { + // The header comment NAMES the env var to say the store never reads it; the + // load-bearing fact is that no CODE expression touches it. Comments stripped + // first, then even the word may not appear. + const source = readFileSync(new URL("../machine-pin-store.ts", import.meta.url), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + expect(source).not.toMatch(/SUBSHELL_CHANNEL_PIN/); + expect(source).not.toMatch(/process\.env/); +}); diff --git a/apps/node/agent/src/machine-pin-store.ts b/apps/node/agent/src/machine-pin-store.ts new file mode 100644 index 000000000..777bb79ef --- /dev/null +++ b/apps/node/agent/src/machine-pin-store.ts @@ -0,0 +1,161 @@ +/** + * The node-side MACHINE pin store for the SSH agent relay (spec 2026-10-08 + * §4.4). Each side pins BOTH of a peer's public keys - the ES256 signing key + * (§4.1) and the ECDH-ES encryption key sealing uses - as the raw public JWK + * strings the plane brokered at first pairing, and thereafter enforces + * byte equality on each, mirroring `pin-store.ts`'s comparison rule. + * + * Always strict, deliberately: this store NEVER reads `SUBSHELL_CHANNEL_PIN`. + * The channel escape hatch ("trust") relaxes the pane channels' peer TOFU in + * `packages/mcp-core/src/pin-store.ts`; reusing it here would let one env var + * silently unpin every machine relationship, which §4.4 forbids ("never its + * escape hatch"). Pinning the encryption half is what makes the plane's + * blindness (§5.5) real: a plane that could substitute a peer's encryption + * key would read every relay seal, and this byte-equality block refuses it. + * + * The file is `/ssh-machine-pins.json` (0600) - a DISTINCT file + * from the pane channels' `peers.json`, which lives under the MCP data dir + * and is written by code this store must not touch. `dataDir` is the agent's + * configured data dir (the same value `identity.ts` stores its keypairs in), + * supplied by the caller from `loadConfig()`; nothing here re-derives it. + * + * Corruption is fail-closed like the sibling stores: a PRESENT-but-unreadable + * file is moved aside and throws - an unreadable pin set never silently + * becomes "no pins". + */ +import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +/** A peer's pinned public halves, stored as the exact raw JWK strings. */ +export interface MachinePin { + /** Public JWK JSON (P-256 / ES256) - the peer's machine signing identity. */ + signing: string; + /** Public JWK JSON (P-256 / ECDH-ES) - the key relay seals to. */ + encryption: string; +} + +/** Result of comparing a candidate against the stored pin. */ +export type MachinePinVerdict = "ok" | "changed"; + +/** The store's file name inside the node data dir (never `peers.json`). */ +const PIN_FILE_NAME = "ssh-machine-pins.json"; + +/** Absolute path of the machine pin file inside a node data dir. */ +export function machinePinPath(dataDir: string): string { + return join(dataDir, PIN_FILE_NAME); +} + +/** Reads a `MachinePin` out of parsed JSON, or null when the shape is junk. */ +function isMachinePin(value: unknown): value is MachinePin { + if (typeof value !== "object" || value === null) return false; + const entry = value as Record; + return typeof entry.signing === "string" && typeof entry.encryption === "string"; +} + +/** + * Byte-equality pins for relay peers, one JSON map per node data dir. Every + * method reads the file fresh, so a re-pair written by another process is + * visible without restarting the agent. + */ +export class MachinePinStore { + /** The node data dir holding `ssh-machine-pins.json` (config `dataDir`). */ + readonly dataDir: string; + + constructor(dataDir: string) { + this.dataDir = dataDir; + } + + /** + * The stored pin for a peer, or `null` when the peer has never been pinned. + * @throws Error when the pin file exists but cannot be read or parsed. + */ + get(nodeId: string): MachinePin | null { + return this.loadAll()[nodeId] ?? null; + } + + /** + * Store (or REPLACE) a peer's pin - the write of a first pairing and of + * §4.5's re-pair alike. Other peers' entries ride through untouched. + */ + pin(nodeId: string, pin: MachinePin): void { + const all = this.loadAll(); + all[nodeId] = { signing: pin.signing, encryption: pin.encryption }; + this.saveAll(all); + } + + /** + * Compare a candidate against the stored pin, BYTE-FOR-BYTE on both raw + * public strings (fingerprints are display, not trust). Any difference in + * either half is `"changed"`, and so is a peer that has no pin at all: the + * store fails closed, it never blesses a stranger. + * @throws Error on an unreadable pin file (fail closed, never "changed"). + */ + check(nodeId: string, candidate: MachinePin): MachinePinVerdict { + const pinned = this.get(nodeId); + if (pinned === null) return "changed"; + return pinned.signing === candidate.signing && pinned.encryption === candidate.encryption ? "ok" : "changed"; + } + + /** + * Read the whole map. ENOENT (first run) is the one legitimate empty set; + * any other failure quarantines the file and throws - the pin set is never + * silently reset (the pin-store.ts doctrine). + */ + private loadAll(): Record { + const file = machinePinPath(this.dataDir); + let raw: string; + try { + raw = readFileSync(file, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw new Error(`machine pin file ${file} is unreadable: ${err instanceof Error ? err.message : String(err)}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw this.quarantine(file, err); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw this.quarantine(file, new Error("top level is not a JSON object")); + } + const all: Record = {}; + for (const [peerId, value] of Object.entries(parsed as Record)) { + if (!isMachinePin(value)) { + throw this.quarantine(file, new Error(`entry '${peerId}' is not a {signing, encryption} pin object`)); + } + all[peerId] = { signing: value.signing, encryption: value.encryption }; + } + return all; + } + + /** Write the whole map, keys sorted for stable diffs, then force 0600. */ + private saveAll(all: Record): void { + const file = machinePinPath(this.dataDir); + mkdirSync(this.dataDir, { recursive: true }); + const sorted: Record = {}; + for (const peerId of Object.keys(all).sort()) { + sorted[peerId] = all[peerId]; + } + // The mode option covers O_CREAT only, is masked by the umask, and is + // ignored for a file that already exists - chmod after write is the part + // that actually guarantees 0600 (same posture as identity.ts). + writeFileSync(file, JSON.stringify(sorted, null, 2), { mode: 0o600 }); + chmodSync(file, 0o600); + } + + /** Moves an unparseable pin file aside and throws (fails the read closed). */ + private quarantine(file: string, cause: unknown): never { + let aside = `${file}.corrupt-${Date.now()}`; + try { + renameSync(file, aside); + } catch { + aside = `${file} (could not move aside)`; + } + throw new Error( + `machine pin file is corrupt (${cause instanceof Error ? cause.message : String(cause)}); ` + + `refusing to treat the pin set as empty. The file (${aside}) was NOT reset; ` + + "restore it by hand or re-pair each peer after verifying out-of-band.", + ); + } +} diff --git a/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts b/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts new file mode 100644 index 000000000..0460b2b78 --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from "bun:test"; +import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { base64UrlNoPad, base64UrlToBytes, bytesOfJwk, fingerprintJwk } from "../ssh-pin-store.js"; + +/** + * A fresh P-256 public JWK, exactly the shape the relay carries: the ES256 + * signing key and the ECDH-ES encryption key are both EC P-256, and only the + * PUBLIC half is ever fingerprinted (spec 2026-10-08 §4.5). node:crypto + * generates it because Bun's WebCrypto lacks EC key generation entirely (the + * gap the pure module under test is built to sidestep); the module itself + * imports no node builtin. + */ +function freshPublicJwk(): Record { + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const jwk = publicKey.export({ format: "jwk" }) as Record; + expect("d" in jwk).toBe(false); + return jwk; +} + +test("fingerprintJwk renders OpenSSH notation: SHA256: over 43 base64url chars, no padding", async () => { + const jwk = freshPublicJwk(); + const fp = await fingerprintJwk(JSON.stringify(jwk)); + expect(fp.startsWith("SHA256:")).toBe(true); + // SHA-256 is 32 bytes: 43 base64url characters with the '=' padding dropped, + // and no '+' or '/' anywhere in the alphabet. + expect(fp.slice("SHA256:".length)).toMatch(/^[A-Za-z0-9_-]{43}$/); +}); + +test("two JWK serializations of the SAME key (different member order) share one fingerprint", async () => { + // THE load-bearing property: the preimage is the DER SubjectPublicKeyInfo, + // never a JSON serialization, so key order in the JWK text is irrelevant + // (spec 2026-10-08 §4.5: "never a JSON-JWK serialization (which is not + // canonical)"). + const jwk = freshPublicJwk(); + const a = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }); + const b = JSON.stringify({ y: jwk.y, x: jwk.x, kty: jwk.kty, crv: jwk.crv, ext: true, key_ops: ["verify"] }); + expect(a).not.toBe(b); + expect(await fingerprintJwk(a)).toBe(await fingerprintJwk(b)); +}); + +test("two different keys get two different fingerprints", async () => { + const one = freshPublicJwk(); + const two = freshPublicJwk(); + expect(await fingerprintJwk(JSON.stringify(one))).not.toBe(await fingerprintJwk(JSON.stringify(two))); +}); + +test("bytesOfJwk is the DER SPKI, byte-identical to node:crypto's independent encoder", () => { + // The hand-built fixed-head encoding must match a real X.509 encoder + // exactly, or the fingerprint is a private dialect, not the SubjectPublicKeyInfo. + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const der = publicKey.export({ type: "spki", format: "der" }); + const jwk = publicKey.export({ format: "jwk" }) as Record; + expect(bytesOfJwk(jwk)).toEqual(new Uint8Array(der)); + expect(bytesOfJwk(JSON.stringify(jwk))).toEqual(new Uint8Array(der)); +}); + +test("the fingerprint is SHA-256 over that DER, verified against an independent node:crypto path", async () => { + // node:crypto is used HERE (a test file) only to double-check the module's + // own path with a different encoder and hasher; the module stays node-free. + const jwk = freshPublicJwk(); + const der = createPublicKey({ key: jwk as never, format: "jwk" }).export({ type: "spki", format: "der" }); + const expected = `SHA256:${createHash("sha256").update(der).digest("base64url")}`; + expect(await fingerprintJwk(JSON.stringify(jwk))).toBe(expected); +}); + +test("fingerprintJwk accepts the JWK as an object as well as a string", async () => { + const jwk = freshPublicJwk(); + expect(await fingerprintJwk(jwk)).toBe(await fingerprintJwk(JSON.stringify(jwk))); +}); + +test("private material and junk are refused, never fingerprinted", async () => { + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const privateJwk = privateKey.export({ format: "jwk" }) as Record; + expect("d" in privateJwk).toBe(true); + expect(() => bytesOfJwk(privateJwk)).toThrow(/private/i); + await expect(fingerprintJwk(JSON.stringify(privateJwk))).rejects.toThrow(/private/i); + expect(() => bytesOfJwk("{ not json")).toThrow(); + expect(() => bytesOfJwk(JSON.stringify({ kty: "oct", k: "aaa" }))).toThrow(); + expect(() => bytesOfJwk(JSON.stringify({ kty: "EC", crv: "P-384", x: "AAA", y: "BBB" }))).toThrow(/P-256/); + expect(() => bytesOfJwk(JSON.stringify({ kty: "EC", crv: "P-256", x: "!!!" }))).toThrow(); +}); + +test("base64url round trip is pure and padding-free", () => { + const bytes = Uint8Array.from([0x00, 0xff, 0x10, 0x23, 0x45]); + const encoded = base64UrlNoPad(bytes); + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); + expect(base64UrlToBytes(encoded)).toEqual(bytes); + expect(() => base64UrlToBytes("====")).toThrow(); + // The alphabet is the URL-safe one: 0xFB 0xFF encodes as '-' '_' where + // standard base64 would put '+' '/'. + expect(base64UrlNoPad(Uint8Array.from([0xfb, 0xff]))).toBe("-_8"); + expect(base64UrlToBytes("-_8")).toEqual(Uint8Array.from([0xfb, 0xff])); +}); + +test("ssh-pin-store.ts is Metro-safe: no node: builtin import in CODE", () => { + // This package's barrel reaches apps/client/mobile through Metro, which + // cannot resolve `node:*` (see the NOTE in src/index.ts). Comments carry the + // words "node:" and "Buffer" as PROSE, so the scanned text is the code with + // comments stripped. + const source = readFileSync(new URL("../ssh-pin-store.ts", import.meta.url), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + expect(source).not.toMatch(/from\s+["']node:/); + expect(source).not.toMatch(/require\(\s*["']node:/); + expect(source).not.toMatch(/\bBuffer\b/); + expect(source).not.toMatch(/await\s+import\(/); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 31bf6ab96..e2fc65ada 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -257,6 +257,9 @@ export { SSH_PATH_MAX_CHARS, SSH_PROBE_DEADLINE_MS, } from "./ssh-limits.js"; +// PURE module (WebCrypto globals only, no node: builtins): safe in the +// Metro-consumed barrel; pinned by __tests__/ssh-pin-store.test.ts. +export { base64UrlNoPad, bytesOfJwk, fingerprintJwk, type PublicJwkInput } from "./ssh-pin-store.js"; export type { NodeSshAliasListResult, NodeSshResolveOutcomeWire, diff --git a/packages/subshell-protocol/src/ssh-pin-store.ts b/packages/subshell-protocol/src/ssh-pin-store.ts new file mode 100644 index 000000000..f70d273df --- /dev/null +++ b/packages/subshell-protocol/src/ssh-pin-store.ts @@ -0,0 +1,175 @@ +/** + * Fingerprint helpers for the SSH agent relay's machine pins and agent keys + * (docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md §4.5). + * + * A fingerprint is `SHA256:` + base64url(SHA-256(DER SubjectPublicKeyInfo)) + * with the padding stripped - the OpenSSH display notation. The preimage is + * pinned to the DER encoding, NEVER a JSON-JWK serialization: JWK member + * order is not canonical, so a JSON hash would make the trust card's honest + * comparison (§4.6) ill-posed. Both machine keys (ES256 signing, ECDH-ES + * encryption) are EC P-256, which is why one fixed DER structure covers the + * pair: a P-256 public key's SPKI is the constant 27-byte AlgorithmIdentifier + * header below plus the 64-byte uncompressed point X||Y, so the DER is BUILT + * from the coordinates rather than asked of a key import. That is also why + * this module does not go through `crypto.subtle.importKey`: Bun's WebCrypto + * does not implement EC import at all (the same gap identity.ts papers over + * with jose's node:crypto fallback), and this package runs there. `digest` is + * a WebCrypto global in Bun and the browser both, so the hash stays on the + * standard primitive. + * + * PURE and Metro-safe: no `node:` builtin import anywhere (pinned by test); + * it rides the package barrel consumed by apps/client/mobile. The node-side + * pin STORE (byte equality on the raw strings) is a separate concern and + * lives in apps/node/agent/src/machine-pin-store.ts. + */ + +/** The base64url alphabet (RFC 4648 §5): `-` and `_` in place of `+` and `/`. */ +const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +/** The reverse lookup for the alphabet above; -1 marks a non-alphabet byte. */ +const BASE64URL_VALUES: number[] = (() => { + const values = new Array(128).fill(-1); + for (let i = 0; i < BASE64URL_ALPHABET.length; i++) { + values[BASE64URL_ALPHABET.charCodeAt(i)] = i; + } + return values; +})(); + +/** + * The constant head of every EC P-256 public key's DER SubjectPublicKeyInfo: + * SEQUENCE { AlgorithmIdentifier(id-ecPublicKey, prime256v1), BIT STRING(66) }, + * ending at the `04` that opens the uncompressed point. 27 bytes; the point's + * X and Y follow. Independent encoders (node:crypto, browsers) produce + * byte-identical output - pinned by test. + */ +const SPKI_P256_HEAD = Uint8Array.of( + 0x30, + 0x59, + 0x30, + 0x13, + 0x06, + 0x07, + 0x2a, + 0x86, + 0x48, + 0xce, + 0x3d, + 0x02, + 0x01, + 0x06, + 0x08, + 0x2a, + 0x86, + 0x48, + 0xce, + 0x3d, + 0x03, + 0x01, + 0x07, + 0x03, + 0x42, + 0x00, + 0x04, +); + +/** A public JWK as the wire carries it: a JSON string, or its parsed shape. */ +export type PublicJwkInput = string | Record; + +/** + * Encodes bytes as base64url WITHOUT `=` padding. A small pure loop over the + * bytes - deliberately not Buffer (a `node:` builtin Metro cannot resolve) + * and not `btoa` (which wants a binary string glued together first). + */ +export function base64UrlNoPad(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const remaining = bytes.length - i; + const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); + out += BASE64URL_ALPHABET[(n >> 18) & 63]; + out += BASE64URL_ALPHABET[(n >> 12) & 63]; + if (remaining > 1) out += BASE64URL_ALPHABET[(n >> 6) & 63]; + if (remaining > 2) out += BASE64URL_ALPHABET[n & 63]; + } + return out; +} + +/** + * Decodes unpadded base64url text into bytes (the inverse of + * {@link base64UrlNoPad}). Non-alphabet characters, stray `=` padding, and + * impossible trailing-bit spellings throw - a coordinate is not trimmable + * material, so junk is refused rather than repaired. + */ +export function base64UrlToBytes(text: string): Uint8Array { + if (/[^A-Za-z0-9_-]/.test(text)) { + throw new TypeError(`ssh-pin-store: not base64url text: ${JSON.stringify(text.slice(0, 12))}`); + } + const out = new Uint8Array(Math.floor((text.length * 6) / 8)); + let bits = 0; + let value = 0; + let at = 0; + for (const ch of text) { + value = (value << 6) | BASE64URL_VALUES[ch.charCodeAt(0)]; + bits += 6; + if (bits >= 8) { + out[at++] = (value >>> (bits - 8)) & 0xff; + bits -= 8; + } + } + if (bits >= 4) { + // 4 or 5 leftover bits mean an encoding whose last character carried data + // beyond the byte stream - not canonical, so not accepted. + throw new TypeError("ssh-pin-store: base64url text has non-canonical trailing bits"); + } + return out; +} + +/** + * The canonical fingerprint preimage of a PUBLIC EC P-256 JWK: its DER + * SubjectPublicKeyInfo bytes. JWK member order cannot change the result, + * because the coordinates are read by NAME and re-encoded from the fixed + * P-256 structure, never hashed from the text. + * + * @throws TypeError on private material (a `d` member), a curve other than + * P-256 (the check is curve-pinned by NAME, not text-trusting), a wrong coordinate + * width, or non-base64url text - a private half must never acquire a + * "public" fingerprint. + */ +export function bytesOfJwk(publicJwk: PublicJwkInput): Uint8Array { + const parsed: unknown = typeof publicJwk === "string" ? JSON.parse(publicJwk) : publicJwk; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new TypeError("ssh-pin-store: fingerprint input is not a JWK object"); + } + const jwk = parsed as Record; + if (jwk.d !== undefined) { + throw new TypeError("ssh-pin-store: refusing to fingerprint private key material"); + } + if (jwk.kty !== "EC" || jwk.crv !== "P-256") { + throw new TypeError( + `ssh-pin-store: fingerprint input is not an EC P-256 public key (kty=${String(jwk.kty)} crv=${String(jwk.crv)})`, + ); + } + if (typeof jwk.x !== "string" || typeof jwk.y !== "string") { + throw new TypeError("ssh-pin-store: EC public JWK is missing its x/y coordinates"); + } + const x = base64UrlToBytes(jwk.x); + const y = base64UrlToBytes(jwk.y); + if (x.length !== 32 || y.length !== 32) { + throw new TypeError(`ssh-pin-store: P-256 coordinates must be 32 bytes (got ${x.length}/${y.length})`); + } + const der = new Uint8Array(SPKI_P256_HEAD.length + 64); + der.set(SPKI_P256_HEAD); + der.set(x, SPKI_P256_HEAD.length); + der.set(y, SPKI_P256_HEAD.length + 32); + return der; +} + +/** + * The `SHA256:`-prefixed fingerprint of a public EC P-256 JWK (string or + * object): SHA-256 over {@link bytesOfJwk}, base64url without padding - the + * same notation OpenSSH prints for host keys, so an operator comparing the + * trust card (§8) against `ssh-keygen -lf` output reads one alphabet. + */ +export async function fingerprintJwk(publicJwk: PublicJwkInput): Promise { + const hash = await crypto.subtle.digest("SHA-256", bytesOfJwk(publicJwk)); + return `SHA256:${base64UrlNoPad(new Uint8Array(hash))}`; +} From 9b856424dacd887f6b26be13db531e5037ac5f39 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 11:57:25 -0700 Subject: [PATCH 17/92] fix(ssh-relay): Task 2 review - base64url round-trip, prototype-safe pin map, atomic write, comment fixes --- .../src/__tests__/machine-pin-store.test.ts | 17 ++++++++++ apps/node/agent/src/machine-pin-store.ts | 31 ++++++++++++++---- .../src/__tests__/ssh-pin-store.test.ts | 32 +++++++++++++++++++ .../subshell-protocol/src/ssh-pin-store.ts | 17 ++++++---- 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/apps/node/agent/src/__tests__/machine-pin-store.test.ts b/apps/node/agent/src/__tests__/machine-pin-store.test.ts index d03f8c876..434acecd6 100644 --- a/apps/node/agent/src/__tests__/machine-pin-store.test.ts +++ b/apps/node/agent/src/__tests__/machine-pin-store.test.ts @@ -63,6 +63,23 @@ test("get returns the raw pinned strings verbatim; an unknown peer has no pin", expect(store.get("node-1")).toEqual(keyA); }); +test("inherited Object members are never pins: get is null for __proto__/constructor/toString on an empty store", () => { + // The maps are Object.create(null)-built: a plain `{}` would hand back + // Object.prototype or a method for these ids, breaking the "null when + // never pinned" contract Tasks 6/8 branch on. + const store = new MachinePinStore(freshDir()); + expect(store.get("__proto__")).toBe(null); + expect(store.get("constructor")).toBe(null); + expect(store.get("toString")).toBe(null); + expect(store.check("toString", keyA)).toBe("changed"); + // A peer id that shadows an Object member is still ordinary data: pinning + // and reading it round-trips through the JSON file. + store.pin("toString", keyA); + expect(store.get("toString")).toEqual(keyA); + expect(store.check("toString", keyA)).toBe("ok"); + expect(new MachinePinStore(store.dataDir).get("toString")).toEqual(keyA); +}); + test("check is byte equality on both halves: ok for the same bytes, changed for either half moving", () => { const dir = freshDir(); const store = new MachinePinStore(dir); diff --git a/apps/node/agent/src/machine-pin-store.ts b/apps/node/agent/src/machine-pin-store.ts index 777bb79ef..b3893564b 100644 --- a/apps/node/agent/src/machine-pin-store.ts +++ b/apps/node/agent/src/machine-pin-store.ts @@ -52,6 +52,16 @@ function isMachinePin(value: unknown): value is MachinePin { return typeof entry.signing === "string" && typeof entry.encryption === "string"; } +/** + * A fresh peer-keyed map with NO prototype. A plain `{}` would let + * `get("__proto__")` or `get("toString")` read back inherited members + * instead of "never pinned", breaking the null contract Tasks 6 and 8 + * branch on; prototype-free, those ids are ordinary missing keys. + */ +function emptyPinMap(): Record { + return Object.create(null) as Record; +} + /** * Byte-equality pins for relay peers, one JSON map per node data dir. Every * method reads the file fresh, so a re-pair written by another process is @@ -107,7 +117,7 @@ export class MachinePinStore { try { raw = readFileSync(file, "utf8"); } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + if ((err as NodeJS.ErrnoException).code === "ENOENT") return emptyPinMap(); throw new Error(`machine pin file ${file} is unreadable: ${err instanceof Error ? err.message : String(err)}`); } let parsed: unknown; @@ -119,7 +129,7 @@ export class MachinePinStore { if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw this.quarantine(file, new Error("top level is not a JSON object")); } - const all: Record = {}; + const all = emptyPinMap(); for (const [peerId, value] of Object.entries(parsed as Record)) { if (!isMachinePin(value)) { throw this.quarantine(file, new Error(`entry '${peerId}' is not a {signing, encryption} pin object`)); @@ -129,18 +139,25 @@ export class MachinePinStore { return all; } - /** Write the whole map, keys sorted for stable diffs, then force 0600. */ + /** + * Write the whole map, keys sorted for stable diffs: a full tmp-then-rename + * (rename is atomic within a directory, so a crash mid-write leaves the + * previous file intact, never a truncated pin set), then force 0600. + */ private saveAll(all: Record): void { const file = machinePinPath(this.dataDir); mkdirSync(this.dataDir, { recursive: true }); - const sorted: Record = {}; + const sorted = emptyPinMap(); for (const peerId of Object.keys(all).sort()) { sorted[peerId] = all[peerId]; } // The mode option covers O_CREAT only, is masked by the umask, and is - // ignored for a file that already exists - chmod after write is the part - // that actually guarantees 0600 (same posture as identity.ts). - writeFileSync(file, JSON.stringify(sorted, null, 2), { mode: 0o600 }); + // ignored for a file that already exists - chmod after the rename is the + // part that actually guarantees 0600 on the final file (same posture as + // identity.ts). + const tmp = `${file}.tmp`; + writeFileSync(tmp, JSON.stringify(sorted, null, 2), { mode: 0o600 }); + renameSync(tmp, file); chmodSync(file, 0o600); } diff --git a/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts b/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts index 0460b2b78..5d7925fb6 100644 --- a/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts +++ b/packages/subshell-protocol/src/__tests__/ssh-pin-store.test.ts @@ -81,6 +81,34 @@ test("private material and junk are refused, never fingerprinted", async () => { expect(() => bytesOfJwk(JSON.stringify({ kty: "EC", crv: "P-256", x: "!!!" }))).toThrow(); }); +test("base64UrlToBytes inverts base64UrlNoPad at every byte length 0..8", () => { + // Canonical encodings must decode, not just the convenient ones: byte + // lengths 1, 4, and 7 land 4 leftover bits, which earlier guards misread + // as non-canonical and threw on. + for (let len = 0; len <= 8; len++) { + const bytes = Uint8Array.from({ length: len }, (_, i) => (i * 37 + 11) & 0xff); + expect(base64UrlToBytes(base64UrlNoPad(bytes))).toEqual(bytes); + } +}); + +test("base64UrlToBytes refuses non-canonical trailing bits", () => { + // "QQ" is the canonical encoding of the single byte 0x41 and must decode; + // "AB" claims the same byte with nonzero padding bits, which + // base64UrlNoPad never emits, so it must throw. + expect(base64UrlToBytes("QQ")).toEqual(Uint8Array.from([0x41])); + expect(() => base64UrlToBytes("AB")).toThrow(/non-canonical trailing bits/); + // One character past a full group is an impossible spelling: six leftover + // bits, whatever their value. + expect(() => base64UrlToBytes("A")).toThrow(/non-canonical trailing bits/); + // The coordinate width itself: 32 zero bytes encode to 43 characters whose + // last carries two zero pad bits; bytesOfJwk's length check alone would + // accept the flipped spelling, so the decoder must not. + const canonical = base64UrlNoPad(new Uint8Array(32)); + expect(canonical.length).toBe(43); + expect(canonical.endsWith("A")).toBe(true); + expect(() => base64UrlToBytes(`${canonical.slice(0, 42)}B`)).toThrow(/non-canonical trailing bits/); +}); + test("base64url round trip is pure and padding-free", () => { const bytes = Uint8Array.from([0x00, 0xff, 0x10, 0x23, 0x45]); const encoded = base64UrlNoPad(bytes); @@ -102,6 +130,10 @@ test("ssh-pin-store.ts is Metro-safe: no node: builtin import in CODE", () => { .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/^\s*\/\/.*$/gm, ""); expect(source).not.toMatch(/from\s+["']node:/); + // The `from` line above cannot see a bare side-effect import or an + // unawaited dynamic one; this pattern covers `import "node:fs"` and + // `import("node:fs")` (with or without await). + expect(source).not.toMatch(/import\s*\(?\s*["']node:/); expect(source).not.toMatch(/require\(\s*["']node:/); expect(source).not.toMatch(/\bBuffer\b/); expect(source).not.toMatch(/await\s+import\(/); diff --git a/packages/subshell-protocol/src/ssh-pin-store.ts b/packages/subshell-protocol/src/ssh-pin-store.ts index f70d273df..2236b612d 100644 --- a/packages/subshell-protocol/src/ssh-pin-store.ts +++ b/packages/subshell-protocol/src/ssh-pin-store.ts @@ -115,9 +115,12 @@ export function base64UrlToBytes(text: string): Uint8Array { bits -= 8; } } - if (bits >= 4) { - // 4 or 5 leftover bits mean an encoding whose last character carried data - // beyond the byte stream - not canonical, so not accepted. + // The leftover `bits` (always even: 0, 2, or 4 for canonical widths) are the + // trailing partial group, and they live in `value`'s low bits. A canonical + // encoding pads them with zeros, so any set bit is a non-canonical spelling. + // Six leftover bits mean a text one character past a full group: no byte + // string encodes to that length, an impossible spelling either way. + if (bits === 6 || (value & ((1 << bits) - 1)) !== 0) { throw new TypeError("ssh-pin-store: base64url text has non-canonical trailing bits"); } return out; @@ -165,9 +168,11 @@ export function bytesOfJwk(publicJwk: PublicJwkInput): Uint8Array { /** * The `SHA256:`-prefixed fingerprint of a public EC P-256 JWK (string or - * object): SHA-256 over {@link bytesOfJwk}, base64url without padding - the - * same notation OpenSSH prints for host keys, so an operator comparing the - * trust card (§8) against `ssh-keygen -lf` output reads one alphabet. + * object): SHA-256 over {@link bytesOfJwk}, base64url without padding. Only + * the scheme is OpenSSH's notation (SHA-256, the `SHA256:` prefix); the + * encoding follows this spec's base64url rule. OpenSSH prints standard + * base64 (`+` and `/`) over a different preimage, so this string never + * matches `ssh-keygen -lf` output character for character. */ export async function fingerprintJwk(publicJwk: PublicJwkInput): Promise { const hash = await crypto.subtle.digest("SHA-256", bytesOfJwk(publicJwk)); From ab95cd90fa90037f3f4bc1d15314cb444c336a80 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:06:20 -0700 Subject: [PATCH 18/92] feat(ssh-relay): ssh_register_identity delivers signing keys for pre-M2 nodes --- .claude/rules/security-context.md | 2 +- .../__tests__/commands-ssh-identity.test.ts | 101 ++++++++ apps/node/agent/src/commands/index.ts | 10 +- apps/node/agent/src/commands/ssh-identity.ts | 33 +++ .../db/repositories/identities.repository.ts | 19 ++ .../__tests__/ssh-identity-register.test.ts | 216 ++++++++++++++++++ .../api/src/services/nodes/node-ws-handler.ts | 9 + .../api/src/services/nodes/ssh-identity.ts | 139 +++++++++++ apps/server/api/src/services/nodes/ssh-rpc.ts | 30 ++- docs/security.md | 5 + .../src/__tests__/node-frames.test.ts | 24 ++ .../src/__tests__/node-results-ssh.test.ts | 27 ++- packages/subshell-protocol/src/index.ts | 3 + packages/subshell-protocol/src/node-frames.ts | 10 +- .../subshell-protocol/src/node-results.ts | 22 +- packages/subshell-protocol/src/ssh-frames.ts | 37 ++- packages/subshell-protocol/src/ssh-results.ts | 13 ++ 17 files changed, 678 insertions(+), 22 deletions(-) create mode 100644 apps/node/agent/src/__tests__/commands-ssh-identity.test.ts create mode 100644 apps/node/agent/src/commands/ssh-identity.ts create mode 100644 apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts create mode 100644 apps/server/api/src/services/nodes/ssh-identity.ts diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 29215d351..78cee0db1 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. ## Deliberate non-validation & accepted risks diff --git a/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts b/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts new file mode 100644 index 000000000..9d0c5c4fb --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts @@ -0,0 +1,101 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseNodeSshIdentity } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { loadOrCreateIdentity, signingIdentityPath } from "../identity.js"; + +/** + * `ssh_register_identity` (spec 2026-10-08 §4.3): the machine answers its + * SIGNING PUBLIC JWK and nothing else. Driven through `dispatchCommand` + * exactly as the daemon drives it (the commands-ssh.test.ts posture). + * + * What this file owns: + * - the answer IS `identity.signingPublicJwk`, byte for byte, stable across + * asks (a re-report after rotation must not read as a mismatch); + * - the private half NEVER rides out: no `d` in the answer's JWK, and the + * serialized result never contains the stored private JWK; + * - NO ssh-enabled gate: registration is the machine's own act, not an SSH + * act, and a pre-M2 node hits it while its mirror still says off; + * - a corrupt signing file fails closed through dispatch's whole-switch wrap + * (identity.ts quarantines and throws; the plane learns `ok:false`). + */ + +let base: string; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-ssh-id-"))); +}); + +afterAll(() => { + rmSync(base, { recursive: true, force: true }); +}); + +function makeCtx(dataDir: string): CommandContext { + return { config: { dataDir } } as unknown as CommandContext; +} + +function freshDir(tag: string): string { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + return dir; +} + +describe("ssh_register_identity", () => { + it("answers the signing PUBLIC jwk byte-for-byte, stable across asks", async () => { + const dir = freshDir("basic"); + const identity = await loadOrCreateIdentity(dir); + const first = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(first.ok).toBe(true); + const parsed = parseNodeSshIdentity(first.ok ? first.data : null); + expect(parsed?.signingPublicKey).toBe(identity.signingPublicJwk); + // The §4.3 guard compares BYTES: a second ask must answer the same string. + const second = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(second.ok && second.data).toEqual(first.ok ? first.data : null); + }); + + it("the private half never leaves: no d member in the answer, no private jwk in the frame", async () => { + const dir = freshDir("private"); + const identity = await loadOrCreateIdentity(dir); + const result = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(result.ok).toBe(true); + const answer = parseNodeSshIdentity(result.ok ? result.data : null); + if (!answer) throw new Error("identity answer did not validate"); + const jwk = JSON.parse(answer.signingPublicKey) as Record; + expect("d" in jwk).toBe(false); + expect(jwk.kty).toBe("EC"); + expect(jwk.crv).toBe("P-256"); + // The whole wire frame, serialized, must not carry the stored private JWK. + expect(JSON.stringify(result)).not.toContain(identity.signingPrivateJwk); + }); + + it("ignores the ssh-enabled gate: an un-mirrored (fail-closed OFF) machine still registers", async () => { + // Deliberately NO ssh-enabled.json here: the mirror is absent, which the + // gate reads as OFF. Registration precedes the gate; a node that never + // turned SSH on must still be able to file its identity (§4.3). + const dir = freshDir("gate-off"); + expect(readdirSync(dir).length).toBe(0); + const result = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(result.ok).toBe(true); + // The pair the loader minted on this ask is exactly what the answer names. + const identity = await loadOrCreateIdentity(dir); + const parsed = parseNodeSshIdentity(result.ok ? result.data : null); + expect(parsed?.signingPublicKey).toBe(identity.signingPublicJwk); + }); + + it("a corrupt signing file fails closed: quarantine throw surfaces as ok:false", async () => { + const dir = freshDir("corrupt"); + writeFileSync(signingIdentityPath(dir), "not a keypair", { mode: 0o600 }); + // The handler may create the encryption pair, but the signing pair's + // quarantine throw must reach dispatch's whole-switch wrap as a refusal. + const result = await dispatchCommand(makeCtx(dir), { type: "ssh_register_identity" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("refusing to overwrite existing key material"); + // The corrupt file was moved aside, not answered over. + const quarantined = readdirSync(dir).filter((f) => f.includes("corrupt-")); + expect(quarantined.length).toBe(1); + expect(readdirSync(dir)).not.toContain("node-signing-identity.json"); + }); +}); diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 55342ee8c..1dbeb746e 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -29,6 +29,7 @@ import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; +import { execSshRegisterIdentity } from "./ssh-identity.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; import { execTransferWrite } from "./transfer-write.js"; import { execTreeManifest } from "./tree-manifest.js"; @@ -51,9 +52,12 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * commands `archive_create`, `file_read`, `transfer_write`, `archive_extract` * and `tree_manifest` (spec 2026-10-01 §4, protocol 15), and * `set_ssh_enabled` (spec 2026-10-07 §4.3, protocol 16 — the SSH gate's plane- - * to-machine write), and the two SSH read arms `ssh_discover_aliases` and + * to-machine write), the two SSH read arms `ssh_discover_aliases` and * `ssh_resolve_config` (spec 2026-10-07 §5, protocol 17 — both refuse on the - * local gate mirror before any lookup or spawn). Any + * local gate mirror before any lookup or spawn), and the M2 §4.3 bootstrap + * `ssh_register_identity` (spec 2026-10-08 §4.3 - the machine's own signing + * PUBLIC key, ungated like `set_ssh_enabled`: registration is not an SSH act). + * Any * unknown type still answers `unsupported` — the integration * contract that lets the backend and agent tracks move independently. * @@ -134,6 +138,8 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return await execSshDiscoverAliases(ctx); case "ssh_resolve_config": return await execSshResolveConfig(ctx, cmd); + case "ssh_register_identity": + return await execSshRegisterIdentity(ctx); case "set_server_url": return await execSetServerUrl(ctx, cmd); case "update": diff --git a/apps/node/agent/src/commands/ssh-identity.ts b/apps/node/agent/src/commands/ssh-identity.ts new file mode 100644 index 000000000..4290a55de --- /dev/null +++ b/apps/node/agent/src/commands/ssh-identity.ts @@ -0,0 +1,33 @@ +import { type JsonValue, parseNodeSshIdentity } from "@internal/subshell-protocol"; +import { loadOrCreateIdentity } from "../identity.js"; +import type { CommandContext, CommandResult } from "./context.js"; + +/** + * `ssh_register_identity` (spec 2026-10-08 §4.3): the machine reports its + * ES256 relay SIGNING public key so a node that enrolled before M2 fills the + * plane's `signingPublicKey` slot over the already-authenticated, app-layer- + * encrypted node link. + * + * The answer is run through the frozen wire validator on the way out (the + * `ssh-aliases.ts` posture), and only `signingPublicJwk` is read: the private + * half never enters a result frame. The bytes are STABLE by construction - + * `loadOrCreateIdentity` never rotates a present file, and a corrupt one + * quarantines and throws (dispatch answers `ok:false`, the plane stores + * nothing) rather than letting a silent rotation ride out as a "re-report" + * the plane's §4.3 own-registration guard would refuse. + * + * NO ssh-enabled gate check, unlike the two config arms: this is the + * machine's own registration, not an SSH act. `set_ssh_enabled` is the + * precedent for a command the gate cannot govern, and a pre-M2 node reaches + * this BEFORE anyone has ever enabled SSH on it. + * + * @param ctx - the per-daemon execution context (data dir) + * @returns `{ ok: true, data: { signingPublicKey } }` with the PUBLIC JWK only + */ +export async function execSshRegisterIdentity(ctx: CommandContext): Promise { + const identity = await loadOrCreateIdentity(ctx.config.dataDir); + const validated = parseNodeSshIdentity({ signingPublicKey: identity.signingPublicJwk }); + if (validated === null) return { ok: false, error: "malformed signing identity" }; + // The seam cast: JSON-safe by construction, contract owned by `node-results.ts`. + return { ok: true, data: validated as unknown as JsonValue }; +} diff --git a/apps/server/api/src/db/repositories/identities.repository.ts b/apps/server/api/src/db/repositories/identities.repository.ts index ab227773b..7e77ef604 100644 --- a/apps/server/api/src/db/repositories/identities.repository.ts +++ b/apps/server/api/src/db/repositories/identities.repository.ts @@ -42,6 +42,25 @@ export class IdentitiesRepository extends BaseRepository { return this.findByPrincipal(input.principalId) as Promise; } + /** + * Fill the principal's EMPTY signing slot (spec 2026-10-08 §4.3): a + * compare-and-write that refuses to land once the slot holds ANY value, so + * the anti-silent-rotation guard is structural in SQL rather than a + * read-then-write the caller could race. The encryption `publicKey` and + * `registeredAt` do not move - filling the signing half is not a rotation. + * @returns true when THIS call wrote the bytes (false: the slot was, or + * concurrently became, non-empty, or the row does not exist) + */ + async fillSigningPublicKey(principalId: string, signingPublicKey: string): Promise { + const res = await this.db + .updateTable("identities") + .set({ signingPublicKey }) + .where("principalId", "=", principalId) + .where("signingPublicKey", "is", null) + .executeTakeFirst(); + return Number(res.numUpdatedRows) > 0; + } + /** The principal's current identity, if registered. */ async findByPrincipal(principalId: string): Promise { return this.db.selectFrom("identities").selectAll().where("principalId", "=", principalId).executeTakeFirst(); diff --git a/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts b/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts new file mode 100644 index 000000000..64c946b9c --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts @@ -0,0 +1,216 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { exportJWK, generateKeyPair } from "jose"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { bootstrapSshIdentityOnReady, deliverSigningKey } from "@/services/nodes/ssh-identity.js"; +import { attachScriptedNode, type ScriptedNode } from "@/test-helpers/scripted-node.js"; + +/** + * `deliverSigningKey` + the `ready`-time bootstrap (spec 2026-10-08 §4.3): + * the §4.3 delivery stores a first report, is idempotent on a re-report of + * the SAME bytes, and REFUSES to overwrite a slot holding a DIFFERENT key + * (the own-registration guard: a silent rotation must never be repairable + * by the machine that reported it). The property this suite exists to pin + * is that refusal, and that nothing the store writes is ever a private key: + * a JWK carrying `d` is refused exactly like junk. + * + * Posture copied from ssh-enabled.test.ts: real migrated DB, real rows, + * scripted node on the real registry for the RPC leg - no RPC mocks, the + * `ssh_register_identity` frame travels the real `sendCommand` chain and is + * gated by the real `parseNodeCommandBody`. + */ + +const nodes = new NodesRepository(db); +const identities = new IdentitiesRepository(db); +const nodeIds: string[] = []; +const emails: string[] = []; +const scripted: ScriptedNode[] = []; +let ownerId: string; +let KEY_A: string; +let KEY_B: string; +/** A real ES256 PRIVATE JWK: the store must refuse it exactly like garbage. */ +let PRIVATE_A: string; +/** A plausible ECDH-ES public JWK for the identity row's encryption half. */ +const ENC_JWK = + '{"kty":"EC","crv":"P-256","x":"BBBBBBBBBBBBBBBBBBBBBBBB-BBBBBBBBBBBBBBBBBBB","y":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}'; + +async function mkUser(): Promise { + const email = `ssh-id-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("ssh-id-pass-1"), + role: "user", + }); +} + +/** An enrolled agent node whose identity record carries an EMPTY signing slot. */ +async function mkNodeWithEmptySlot(): Promise { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ id, ownerUserId: ownerId, name: `ssh-id-${id.slice(0, 8)}`, kind: "agent", status: "online" }); + await identities.register({ + principalId: `node:${id}`, + publicKey: ENC_JWK, + signingPublicKey: null, + displayName: null, + }); + return id; +} + +async function slotOf(nodeId: string): Promise { + const row = await identities.findByPrincipal(`node:${nodeId}`); + return row?.signingPublicKey ?? null; +} + +/** Audit rows for one target's registration, newest last. */ +async function auditFor(targetId: string): Promise<{ actorUserId: string | null; meta: string | null }[]> { + const rows = await db + .selectFrom("auditEvents") + .select(["actorUserId", "metadataJson"]) + .where("targetId", "=", targetId) + .where("action", "=", "node.ssh_identity.register") + .orderBy("createdAt", "asc") + .execute(); + return rows.map((r) => ({ actorUserId: r.actorUserId, meta: r.metadataJson })); +} + +beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(); + const a = await generateKeyPair("ES256", { crv: "P-256", extractable: true }); + KEY_A = JSON.stringify(await exportJWK(a.publicKey)); + PRIVATE_A = JSON.stringify(await exportJWK(a.privateKey)); + const b = await generateKeyPair("ES256", { crv: "P-256", extractable: true }); + KEY_B = JSON.stringify(await exportJWK(b.publicKey)); +}); + +afterAll(async () => { + for (const s of scripted) s.detach(); + resetNodeRegistryForTests(); + for (const id of nodeIds) { + await db.deleteFrom("identities").where("principalId", "=", `node:${id}`).execute(); + await nodes.deleteById(id); + } + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +describe("deliverSigningKey (spec 2026-10-08 §4.3)", () => { + it("stores a FIRST report into the empty slot and audits it (ids and via only)", async () => { + const id = await mkNodeWithEmptySlot(); + expect(await deliverSigningKey(id, KEY_A)).toBe(KEY_A); + expect(await slotOf(id)).toBe(KEY_A); + const rows = await auditFor(id); + expect(rows).toHaveLength(1); + expect(rows[0].actorUserId).toBeNull(); + expect(JSON.parse(rows[0].meta ?? "null")).toEqual({ via: "link" }); + // The key VALUE never enters the audit row (Global Constraint: no key + // material in any audit row; the metadata names the delivery, nothing else). + expect(rows[0].meta).not.toContain(KEY_A); + expect(rows[0].meta).not.toContain("-----"); + }); + + it("is idempotent on a re-report of the SAME bytes: success, no second write", async () => { + const id = await mkNodeWithEmptySlot(); + expect(await deliverSigningKey(id, KEY_A)).toBe(KEY_A); + expect(await deliverSigningKey(id, KEY_A)).toBe(KEY_A); + expect(await slotOf(id)).toBe(KEY_A); + expect(await auditFor(id)).toHaveLength(1); // the re-report wrote nothing new + }); + + it("REFUSES a DIFFERENT key: returns the existing, overwrites nothing, audits nothing", async () => { + const id = await mkNodeWithEmptySlot(); + await deliverSigningKey(id, KEY_A); + // The §4.3 own-registration guard: a machine that reports a key other + // than the one on file gets the EXISTING answer back and changes nothing. + expect(await deliverSigningKey(id, KEY_B)).toBe(KEY_A); + expect(await slotOf(id)).toBe(KEY_A); + expect(await auditFor(id)).toHaveLength(1); + }); + + it("refuses malformed input WITHOUT storing it (and never a private JWK)", async () => { + const id = await mkNodeWithEmptySlot(); + expect(await deliverSigningKey(id, "not json at all")).toBeNull(); + expect(await deliverSigningKey(id, '"just a string"')).toBeNull(); + expect(await deliverSigningKey(id, PRIVATE_A)).toBeNull(); // has `d` + expect(await slotOf(id)).toBeNull(); + expect(await auditFor(id)).toHaveLength(0); + }); + + it("refuses a node with NO identity row: nothing to fill, no row invented", async () => { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ + id, + ownerUserId: ownerId, + name: `ssh-orphan-${id.slice(0, 8)}`, + kind: "agent", + status: "online", + }); + expect(await deliverSigningKey(id, KEY_A)).toBeNull(); + expect(await identities.findByPrincipal(`node:${id}`)).toBeUndefined(); + expect(await auditFor(id)).toHaveLength(0); + }); +}); + +describe("bootstrapSshIdentityOnReady (the ready-time wire-up)", () => { + it("sends ssh_register_identity ONCE on an empty slot and files the answer", async () => { + const id = await mkNodeWithEmptySlot(); + const node = attachScriptedNode(id, { ssh_register_identity: () => ({ signingPublicKey: KEY_A }) }); + scripted.push(node); + await bootstrapSshIdentityOnReady(id); + expect(node.countOf("ssh_register_identity")).toBe(1); + expect(await slotOf(id)).toBe(KEY_A); + expect(await auditFor(id)).toHaveLength(1); + }); + + it("sends NOTHING when the slot is already filled (the no-spam gate)", async () => { + const id = await mkNodeWithEmptySlot(); + await deliverSigningKey(id, KEY_A); + const node = attachScriptedNode(id, { ssh_register_identity: () => ({ signingPublicKey: KEY_A }) }); + scripted.push(node); + await bootstrapSshIdentityOnReady(id); + expect(node.countOf("ssh_register_identity")).toBe(0); + expect(await slotOf(id)).toBe(KEY_A); + expect(await auditFor(id)).toHaveLength(1); // only the direct delivery above + }); + + it("sends NOTHING for a node with no identity row", async () => { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ + id, + ownerUserId: ownerId, + name: `ssh-none-${id.slice(0, 8)}`, + kind: "agent", + status: "online", + }); + const node = attachScriptedNode(id, { ssh_register_identity: () => ({ signingPublicKey: KEY_A }) }); + scripted.push(node); + await bootstrapSshIdentityOnReady(id); + expect(node.countOf("ssh_register_identity")).toBe(0); + expect(await identities.findByPrincipal(`node:${id}`)).toBeUndefined(); + }); + + it("survives a malformed machine answer: no throw, nothing stored", async () => { + const id = await mkNodeWithEmptySlot(); + const node = attachScriptedNode(id, { ssh_register_identity: () => ({ signingPublicKey: "garbage{" }) }); + scripted.push(node); + await bootstrapSshIdentityOnReady(id); + expect(node.countOf("ssh_register_identity")).toBe(1); // the ask did go out + expect(await slotOf(id)).toBeNull(); + expect(await auditFor(id)).toHaveLength(0); + }); + + it("swallows an unreachable machine quietly (offline is routine at ready)", async () => { + const id = await mkNodeWithEmptySlot(); // no scripted node attached: sendCommand answers offline + await expect(bootstrapSshIdentityOnReady(id)).resolves.toBeUndefined(); + expect(await slotOf(id)).toBeNull(); + }); +}); diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index 0c4b31dc9..ae200f60a 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -29,6 +29,7 @@ import { } from "@/services/nodes/link-session.js"; import { loadNodeEncryptionKeys, nodeEncryptionPublicKey } from "@/services/nodes/node-encryption-keys.js"; import { announceNodePresence, projectNodeOffline } from "@/services/nodes/node-presence-announce.js"; +import { bootstrapSshIdentityOnReady } from "@/services/nodes/ssh-identity.js"; import { logger } from "@/utils/logger.js"; import { refireInputHoldsForNode } from "@/ws/input-hold.js"; import { dispatchOutput, getNodeLifecycleHooks } from "./node-events.js"; @@ -980,6 +981,14 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: // cheap; everything slow lives in the push's own best-effort swallow. if (hooks) await hooks.onSshEnabled(nodeId, event.sshEnabled); else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile ssh_enabled`); + // The §4.3 signing bootstrap (spec 2026-10-08): an agent whose identity + // record has no signing key is ASKED ONCE, and the answer files itself. + // Fire-and-forget for the same reason the detect kick above is: the + // machine's answer arrives as a LATER `result` frame on this socket's + // own queue, and awaiting it inside this frame would deadlock the + // chain. The no-spam gate (filled slot / absent record = silent skip) + // lives in the call itself. + void bootstrapSshIdentityOnReady(nodeId); return; } case "heartbeat": diff --git a/apps/server/api/src/services/nodes/ssh-identity.ts b/apps/server/api/src/services/nodes/ssh-identity.ts new file mode 100644 index 000000000..3d7d35377 --- /dev/null +++ b/apps/server/api/src/services/nodes/ssh-identity.ts @@ -0,0 +1,139 @@ +import { assertImportableSigningJwk } from "@/api/public-jwk.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { audit } from "@/services/audit.js"; +import { sshRegisterIdentity } from "@/services/nodes/ssh-rpc.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The §4.3 signing-key bootstrap (spec 2026-10-08): nodes that enrolled + * before M2 hold a signing keypair on disk but have never reported its + * public half. On `ready`, a machine whose `signingPublicKey` slot is EMPTY + * is asked once ({@link sshRegisterIdentity}) over its already-authenticated + * link, and the answer lands through {@link deliverSigningKey}. + * + * **The guard that makes this safe is the anti-silent-rotation rule.** A + * report that DIFFERS from a key already on file is refused, NOT stored: + * the machine that rotated its identity unilaterally must never repair its + * own history by overwriting the record the relay verifies against (the + * peer-side repair path is §4.5, a separate deliberate owner act). Same + * bytes, or a first fill, are the only writes this seam can ever cause - + * plus {@link IdentitiesRepository.fillSigningPublicKey}'s CAS, which makes + * even a raced double-report answerable: whoever lands first owns the slot. + * + * No key VALUE ever enters a log line or an audit row (Global Constraint); + * the audit names the node and `via: "link"`, and the warnings name the node + * and the shape of the problem. + */ + +/** + * File one machine's reported signing public key into its `node:` identity + * record. The whole guard table (spec 2026-10-08 §4.3): + * + * | slot holds | report | → | + * |---|---|---| + * | nothing | importable public JWK | store it, audit `node.ssh_identity.register` (`via: "link"`), return it | + * | the SAME bytes | same bytes | no-op success, return it (idempotent: every re-report of a stable key is quiet) | + * | a DIFFERENT key | anything else | REFUSE: return the existing bytes, overwrite nothing | + * | anything | unparseable / non-importable / private-carrying JWK | refuse BEFORE any write, return null (the enroll lesson: nothing that fails the import gate is stored) | + * + * A node with no identity row at all gets `null`: the row is enroll's + * artifact (its encryption half is the seal key); this seam never invents + * one. + * + * @param nodeId - the node the report is about (the socket's authenticated + * identity at every call site; never a frame's self-claim) + * @param signingPublicKey - the machine's JSON-serialized public JWK, as the + * agent answered it (wire-validated string; importability checked HERE) + * @returns the key now on file (stored, or the existing one the report was + * refused against), or null when nothing is on file and nothing was stored + */ +export async function deliverSigningKey(nodeId: string, signingPublicKey: string): Promise { + const { repos } = getRequestlessContext(); + // Validate BEFORE touching the row: a malformed report is refused exactly + // like the enroll route refuses it, and the refusal never reaches the DB. + // assertImportableSigningJwk carries the public-store rule (EC/P-256 shape, + // real curve-point import for ES256, and NO `d` - a "public" key that is + // secretly a private one is refused, not stored). + let parsedKey: unknown; + try { + parsedKey = JSON.parse(signingPublicKey); + } catch { + parsedKey = null; + } + try { + await assertImportableSigningJwk(parsedKey); + } catch { + // The refuse line names the machine and the shape, never the payload. + logger.warn(`ssh identity: node ${nodeId} reported an unusable signing key; nothing stored`); + return null; + } + + const principalId = `node:${nodeId}`; + const row = await repos.identities.findByPrincipal(principalId); + if (!row) { + logger.warn(`ssh identity: node ${nodeId} has no identity record; refusing to invent one`); + return null; + } + if (row.signingPublicKey !== null) { + // The slot is owned. Same bytes: the quiet idempotent yes. Different + // bytes: the §4.3 own-registration refusal - return the EXISTING key and + // change nothing, whatever the caller expected. + if (row.signingPublicKey !== signingPublicKey) { + logger.warn( + `ssh identity: node ${nodeId} reported a signing key DIFFERENT from the one on file; ` + + "keeping the existing key (a genuine change needs a re-enroll plus per-peer re-pairs, spec §4.2/§4.5)", + ); + } + return row.signingPublicKey; + } + + // The CAS write: only this call's UPDATE can flip the empty slot, so two + // racy reports (two `ready` frames) cannot double-store or clobber. + const stored = await repos.identities.fillSigningPublicKey(principalId, signingPublicKey); + if (!stored) { + const after = await repos.identities.findByPrincipal(principalId); + return after?.signingPublicKey ?? null; + } + await audit({ + actorUserId: null, // the link delivered it; no human asked + action: "node.ssh_identity.register", + targetType: "node", + targetId: nodeId, + // ids/via only, never the key value (docs/security.md §10). + metadataJson: JSON.stringify({ via: "link" }), + }); + return signingPublicKey; +} + +/** + * The `ready`-time bootstrap body (spec 2026-10-08 §4.3): ask a node that has + * never delivered its signing key, once, and file the answer. + * + * The NO-SPAM gate is the first read: a slot that already holds a key (the + * overwhelming steady state) answers without a command, so an agent that + * reconnects hourly costs nothing after its first contact. A node with NO + * identity row is skipped too - there is no slot to fill and no write this + * seam may make. + * + * Swallows its own failures (offline/timeout/unsupported are routine at + * `ready`; a machine that did not answer simply asks again on the next + * contact), so the fire-and-forget call site can be a bare statement. + * + * @param nodeId - the socket's authenticated identity, never a frame's claim + */ +export async function bootstrapSshIdentityOnReady(nodeId: string): Promise { + if (nodeId === LOCAL_NODE_ID) return; // no socket to ask over + try { + const { repos } = getRequestlessContext(); + const row = await repos.identities.findByPrincipal(`node:${nodeId}`); + if (!row || row.signingPublicKey !== null) return; // no slot, or already filled + const reported = await sshRegisterIdentity(nodeId); + await deliverSigningKey(nodeId, reported); + } catch (err: unknown) { + // `debug`, not warn: an offline mid-boot or an agent that has not + // upgraded into the command is the ordinary case, and the next `ready` + // retries by construction. + logger.withError(err).debug(`ssh identity: §4.3 bootstrap for node ${nodeId} did not land; retrying next ready`); + } +} diff --git a/apps/server/api/src/services/nodes/ssh-rpc.ts b/apps/server/api/src/services/nodes/ssh-rpc.ts index 9156e5396..5e8f63264 100644 --- a/apps/server/api/src/services/nodes/ssh-rpc.ts +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -2,13 +2,15 @@ import { type NodeSshAliasListResult, type NodeSshResolveOutcomeWire, parseNodeSshAliasList, + parseNodeSshIdentity, parseNodeSshResolveOutcome, } from "@internal/subshell-protocol"; import { NodeRpcError, sendCommand } from "@/services/nodes/node-rpc.js"; /** - * The two ssh discovery/resolve commands as plane-side RPC wrappers - * (spec 2026-10-07 §5). Each is `sendCommand` plus ITS frozen result parser: + * The ssh commands as plane-side RPC wrappers (spec 2026-10-07 §5 for the + * discovery/resolve pair; spec 2026-10-08 §4.3 for the identity bootstrap). + * Each is `sendCommand` plus ITS frozen result parser: * the value these return is a validated plain object (or the function throws), * so no caller can branch on a machine answer it has not parsed. * @@ -96,3 +98,27 @@ export async function sshResolve(nodeId: string, alias: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_register_identity" }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshIdentity(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed signing identity`, nodeId); + } + return parsed.signingPublicKey; +} diff --git a/docs/security.md b/docs/security.md index 39bbdfede..d65262b28 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2277,6 +2277,11 @@ Audit events are written, grouped by family: - **Nodes**: `node.enroll`, `node.delete`, `node.rename`, `node.key_rotate`, `node.reregister_key`, `node.reregister`, `node.allowed_dirs.update`, `node.config.update`, `node.maintenance.update`, `node.ssh_enabled.update`, + `node.ssh_identity.register` (spec 2026-10-08 §4.3: the signing-key + bootstrap lands when the link's answer fills the EMPTY slot, `via: "link"` + and nothing else in the metadata - never the key value; the idempotent + re-report of the same bytes and the refused different-bytes report write + no row), `node.logging.update`, `node.update`, `node.update.unknown`, `node.shares_set`, `node.local_share_changed`, and `node.service` (with `{ verb: "restart", forced }` metadata; the restart diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 6e3f3e1b0..75e683051 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -24,6 +24,7 @@ import { parseNodeRuntimeReport, partPathOf, } from "../node-frames.js"; +import { SSH_COMMAND_TYPES } from "../ssh-frames.js"; import { SSH_CONFIG_FILE_MAX_BYTES } from "../ssh-limits.js"; import { MIN_NODE_VERSION } from "../versions.js"; @@ -923,6 +924,29 @@ describe("ssh command arms and the launch ssh block", () => { expect(parseNodeCommandBody({ type: "ssh_resolve_config", alias: "a b" })).toBeNull(); }); + it("the ssh_register_identity arm is its type alone (spec 2026-10-08 §4.3)", () => { + expect(parseNodeCommandBody({ type: "ssh_register_identity" })).toEqual({ type: "ssh_register_identity" }); + // Nothing beyond the type travels: the machine answers about itself, so + // a plane-sent field could only be an injection attempt at its own slot. + expect(parseNodeCommandBody({ type: "ssh_register_identity", signingPublicKey: "x" })).toEqual({ + type: "ssh_register_identity", + }); + expect(parseNodeCommandBody({ type: "ssh_register_identity" })).not.toBeNull(); + // The family's census: exactly these three types, and the count is the + // tripwire - a fourth arm must show up here before it ships. + expect([...SSH_COMMAND_TYPES].sort()).toEqual([ + "ssh_discover_aliases", + "ssh_register_identity", + "ssh_resolve_config", + ]); + expect(SSH_COMMAND_TYPES).toHaveLength(3); + // Every census type is a type the dispatcher actually narrows. + for (const t of SSH_COMMAND_TYPES) { + const body = t === "ssh_resolve_config" ? { type: t, alias: "box-a" } : { type: t }; + expect(parseNodeCommandBody(body)).not.toBeNull(); + } + }); + it("launch accepts an ssh block and refuses malformed ones", () => { const good = { ...launchCmd, ssh: { configPath: "/d/ssh/s1/config", fileContent: "Host *\n" } }; expect(parseNodeCommandBody(good)).toMatchObject({ diff --git a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts index b4a013a1f..f6f46859a 100644 --- a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { parseNodeSshAliasList, parseNodeSshResolveOutcome } from "../node-results.js"; +import { parseNodeSshAliasList, parseNodeSshIdentity, parseNodeSshResolveOutcome } from "../node-results.js"; import { makeAliasList, makeResolveOk, makeResolveRefused } from "./fixtures/ssh-fixtures.js"; +/** A well-formed public-JWK STRING: the validator's whole job is that it is JSON and an object. */ +const SIGNING_JWK = + '{"kty":"EC","crv":"P-256","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","y":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}'; + describe("parseNodeSshAliasList", () => { test("narrows a well-formed answer", () => { expect(parseNodeSshAliasList(makeAliasList({ aliases: ["box-a", "box-b"] }))).toEqual({ @@ -44,3 +48,24 @@ describe("parseNodeSshResolveOutcome", () => { expect(parseNodeSshResolveOutcome({ accepted: false, code: "not_a_code", settings: [] })).toBeNull(); }); }); + +describe("parseNodeSshIdentity", () => { + test("narrows a well-formed signing-public-key answer", () => { + expect(parseNodeSshIdentity({ signingPublicKey: SIGNING_JWK })).toEqual({ signingPublicKey: SIGNING_JWK }); + }); + test("refuses a non-string or empty field", () => { + expect(parseNodeSshIdentity({ signingPublicKey: 7 })).toBeNull(); + expect(parseNodeSshIdentity({ signingPublicKey: "" })).toBeNull(); + expect(parseNodeSshIdentity({})).toBeNull(); + expect(parseNodeSshIdentity(null)).toBeNull(); + expect(parseNodeSshIdentity(SIGNING_JWK)).toBeNull(); // the answer is an OBJECT, not a bare string + }); + test("refuses a value that is not well-formed JSON of object shape", () => { + // The grammar stops at "JSON that parses to an object"; ES256 importability + // is the server's gate (assertImportableSigningJwk), never this one's. + expect(parseNodeSshIdentity({ signingPublicKey: "not json at all" })).toBeNull(); + expect(parseNodeSshIdentity({ signingPublicKey: '"a string that parses"' })).toBeNull(); + expect(parseNodeSshIdentity({ signingPublicKey: "[1,2]" })).toBeNull(); + expect(parseNodeSshIdentity({ signingPublicKey: "null" })).toBeNull(); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index e2fc65ada..1c3724026 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -145,6 +145,7 @@ export { parseNodeProbeEntries, parseNodePromptDeliver, parseNodeSshAliasList, + parseNodeSshIdentity, parseNodeSshResolveOutcome, parseNodeStatDirResult, parseNodeTreeManifestPage, @@ -247,6 +248,7 @@ export { SSH_COMMAND_TYPES, type SshDiscoverAliasesCommand, type SshNodeCommandBody, + type SshRegisterIdentityCommand, type SshResolveConfigCommand, } from "./ssh-frames.js"; export { @@ -262,6 +264,7 @@ export { export { base64UrlNoPad, bytesOfJwk, fingerprintJwk, type PublicJwkInput } from "./ssh-pin-store.js"; export type { NodeSshAliasListResult, + NodeSshIdentityResult, NodeSshResolveOutcomeWire, } from "./ssh-results.js"; export { MAX_UPLOAD_BYTES } from "./uploads.js"; diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index f1d2c2031..cf5175460 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1124,10 +1124,9 @@ export type NodeCommandBody = */ maxBytes: number; } - // The SSH discovery/resolve reads: two commands folded in as one union - // member so the SSH grammar lives in `ssh-frames.ts` beside the commands it - // narrows; their answers are validated by the two ssh result parsers - // appended to `node-results.ts` in the same tier. + // The SSH commands: three members folded in as one union member so the SSH + // grammar lives in `ssh-frames.ts` beside the commands it narrows; their + // answers are validated by the ssh result parsers in `node-results.ts`. | SshNodeCommandBody; /** @@ -1739,7 +1738,8 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { : { type: "tree_manifest", root: value.root, maxBytes: value.maxBytes as number }; case "ssh_discover_aliases": case "ssh_resolve_config": - // Delegation, not a second parser: the SSH grammar (both arms, one + case "ssh_register_identity": + // Delegation, not a second parser: the SSH grammar (all arms, one // file) lives in ssh-frames.ts beside the commands it narrows. return parseSshNodeCommandBody(value); default: diff --git a/packages/subshell-protocol/src/node-results.ts b/packages/subshell-protocol/src/node-results.ts index de2d1ea29..c8a30440a 100644 --- a/packages/subshell-protocol/src/node-results.ts +++ b/packages/subshell-protocol/src/node-results.ts @@ -24,7 +24,7 @@ import { MAX_ARCHIVE_BYTES, MAX_MANIFEST_PAGE_ENTRIES } from "./node-frames.js"; import { parseSshConnectionSnapshot } from "./ssh-config.js"; import { isSshErrorCode } from "./ssh-errors.js"; import { SSH_MAX_DISCOVERED_ALIASES } from "./ssh-limits.js"; -import type { NodeSshAliasListResult, NodeSshResolveOutcomeWire } from "./ssh-results.js"; +import type { NodeSshAliasListResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire } from "./ssh-results.js"; function isNonEmptyStr(value: unknown): value is string { return isStr(value) && value.length > 0; @@ -584,3 +584,23 @@ export function parseNodeSshResolveOutcome(data: unknown): NodeSshResolveOutcome if (!isSshErrorCode(data.code) || !isStrArray(data.settings)) return null; return { accepted: false, code: data.code, settings: [...(data.settings as string[])] }; } + +/** + * Validates and narrows an `ssh_register_identity` command's `result{data}` + * (spec 2026-10-08 §4.3). Deliberately shallow: the grammar proves the field + * is a non-empty string carrying JSON that parses to an object. ES256 + * importability (and the no-private-component rule) is the server's gate at + * the store, not the wire's; a machine answering its OWN key has nothing to + * inject beyond its own identity. + * @param data - the `data` member of a successful result frame + * @returns the narrowed answer, or null when malformed + */ +export function parseNodeSshIdentity(data: unknown): NodeSshIdentityResult | null { + if (!isRecord(data) || !isNonEmptyStr(data.signingPublicKey)) return null; + try { + if (!isRecord(JSON.parse(data.signingPublicKey) as unknown)) return null; + } catch { + return null; + } + return { signingPublicKey: data.signingPublicKey }; +} diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index da22d140a..96d1c4bd5 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -1,17 +1,20 @@ /** - * The two surviving SSH node commands (design 2026-10-05 §7): the bounded - * config DISCOVERY and the single-alias RESOLUTION that feed the wizard's - * review step. They ride the existing signed/encrypted command transport - * unchanged - signing proves WHO ordered it, this file freezes WHAT was - * ordered. + * The SSH node commands: the bounded config DISCOVERY and the single-alias + * RESOLUTION that feed the wizard's review step (design 2026-10-05 §7), and + * the M2 §4.3 IDENTITY bootstrap a pre-M2 node answers once. They ride the + * existing signed/encrypted command transport unchanged - signing proves WHO + * ordered it, this file freezes WHAT was ordered. * * The rest of the family the earlier command table named (`ssh_test_ * connection`, the run start/status/read/cancel quartet, `ssh_terminal_ * launch`, `ssh_input_control`) retired with that destination-execution * product, which never shipped; their `type` arms are therefore deleted, not - * refused. The brokered-session commands are NOT defined on this branch — they - * arrive with the sealed agent-relay milestone (M2); no file names them here yet - * (`ssh-limits.ts` carries their limits ahead of the frames, as it declares). + * refused. The sealed agent-relay milestone (M2) adds ONE command here, + * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node's signing key reaches + * the plane inside a signed command on the existing node link). The + * brokered-session commands are NOT defined yet - they land with the relay + * frames themselves (`ssh-limits.ts` carries their limits ahead of the frames, + * as it declares). * * The commands are TRANSPORT-AGNOSTIC plain objects: the server-hosted * `local` node executes the same runtime in-process against these exact @@ -48,11 +51,21 @@ export interface SshResolveConfigCommand { alias: string; } +/** + * The machine reports its OWN ES256 relay signing public key (spec + * 2026-10-08 §4.3): the once-over-the-link bootstrap for a node that enrolled + * before the key existed. No input beyond the type - the answer is the + * machine's own identity, there is nothing for the plane to ask about. + */ +export interface SshRegisterIdentityCommand { + type: "ssh_register_identity"; +} + /** Every SSH-family command body, as one union the {@link NodeCommandBody} union folds in. */ -export type SshNodeCommandBody = SshDiscoverAliasesCommand | SshResolveConfigCommand; +export type SshNodeCommandBody = SshDiscoverAliasesCommand | SshResolveConfigCommand | SshRegisterIdentityCommand; /** Every SSH command `type`, for census tests and dispatch tables. */ -export const SSH_COMMAND_TYPES = ["ssh_discover_aliases", "ssh_resolve_config"] as const; +export const SSH_COMMAND_TYPES = ["ssh_discover_aliases", "ssh_resolve_config", "ssh_register_identity"] as const; /* ------------------------------------------------------------------ */ /* arm validators (delegated from parseNodeCommandBody) */ @@ -86,6 +99,10 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu return { type: "ssh_discover_aliases" }; case "ssh_resolve_config": return isAliasName(value.alias) ? { type: "ssh_resolve_config", alias: value.alias } : null; + case "ssh_register_identity": + // The whole command is its type: the machine answers about ITSELF, and + // the answer's shape is `node-results.ts`'s business, not this grammar's. + return { type: "ssh_register_identity" }; default: return null; } diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts index 0a246172f..f86027ea5 100644 --- a/packages/subshell-protocol/src/ssh-results.ts +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -58,3 +58,16 @@ export type NodeSshResolveOutcomeWire = /** Config keywords that blocked acceptance; empty when the code names the whole cause. */ settings: string[]; }; + +/** + * `ssh_register_identity` answer (spec 2026-10-08 §4.3): the machine's OWN + * ES256 signing public key, for a node that enrolled before the key existed. + * The grammar here only proves the field is a non-empty string carrying + * well-formed JSON; the ES256/P-256 importability check (and the refusal of + * any JWK holding a private component) is the SERVER's gate, beside the one + * the enroll path runs. + */ +export interface NodeSshIdentityResult { + /** JSON-serialized public JWK (P-256 / ES256) - never the private half. */ + signingPublicKey: string; +} From 80658cde2d370d28100ca0afaec4a7959bf845cc Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:09:55 -0700 Subject: [PATCH 19/92] test(ssh-relay): cover the CAS-loser branch; fix dead JSDoc link (Task 3 review) --- .../__tests__/ssh-identity-register.test.ts | 29 +++++++++++++++++++ .../api/src/services/nodes/ssh-identity.ts | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts b/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts index 64c946b9c..fe41ffba1 100644 --- a/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts @@ -157,6 +157,35 @@ describe("deliverSigningKey (spec 2026-10-08 §4.3)", () => { expect(await identities.findByPrincipal(`node:${id}`)).toBeUndefined(); expect(await auditFor(id)).toHaveLength(0); }); + + it("a raced double-report lands exactly once (the CAS-loser branch)", async () => { + const id = await mkNodeWithEmptySlot(); + // Two concurrent deliveries against the EMPTY slot: their reads both see + // null (each chain yields at the same awaits, and bun:sqlite runs the + // query synchronously), so both reach the CAS. Exactly one UPDATE flips + // the slot; the loser re-reads and returns the winner's bytes instead of + // auditing a second row. + const results = await Promise.all([deliverSigningKey(id, KEY_B), deliverSigningKey(id, KEY_B)]); + expect(results).toEqual([KEY_B, KEY_B]); + expect(await slotOf(id)).toBe(KEY_B); + expect(await auditFor(id)).toHaveLength(1); // the loser wrote no audit row + }); +}); + +describe("IdentitiesRepository.fillSigningPublicKey (the CAS in SQL)", () => { + it("refuses every write once the slot holds a value, same bytes or different", async () => { + const id = await mkNodeWithEmptySlot(); + const principalId = `node:${id}`; + // The first fill lands: this is the write the empty slot exists for. + expect(await identities.fillSigningPublicKey(principalId, KEY_A)).toBe(true); + // Same bytes: the UPDATE still matches nothing (the `signingPublicKey is + // null` predicate fails), so even re-reporting the stored key answers + // false. Different bytes: refused the same way. + expect(await identities.fillSigningPublicKey(principalId, KEY_A)).toBe(false); + expect(await identities.fillSigningPublicKey(principalId, KEY_B)).toBe(false); + // The stored bytes never moved. + expect(await slotOf(id)).toBe(KEY_A); + }); }); describe("bootstrapSshIdentityOnReady (the ready-time wire-up)", () => { diff --git a/apps/server/api/src/services/nodes/ssh-identity.ts b/apps/server/api/src/services/nodes/ssh-identity.ts index 3d7d35377..0e78f3302 100644 --- a/apps/server/api/src/services/nodes/ssh-identity.ts +++ b/apps/server/api/src/services/nodes/ssh-identity.ts @@ -18,7 +18,7 @@ import { logger } from "@/utils/logger.js"; * own history by overwriting the record the relay verifies against (the * peer-side repair path is §4.5, a separate deliberate owner act). Same * bytes, or a first fill, are the only writes this seam can ever cause - - * plus {@link IdentitiesRepository.fillSigningPublicKey}'s CAS, which makes + * plus the identities repository's `fillSigningPublicKey` CAS, which makes * even a raced double-report answerable: whoever lands first owns the slot. * * No key VALUE ever enters a log line or an audit row (Global Constraint); From c134a334e170df06504e2af6f9d798147008483a Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:18:31 -0700 Subject: [PATCH 20/92] feat(ssh-relay): protocol 18, relay open/close commands + blind relay frame --- .../agent/src/__tests__/commands-ssh.test.ts | 6 +- .../nodes/__tests__/node-ws-handler.test.ts | 66 ++++++++ .../nodes/__tests__/relay-frames.test.ts | 46 ++++++ .../api/src/services/nodes/node-ws-handler.ts | 12 ++ .../api/src/services/nodes/relay-frames.ts | 79 +++++++++ .../src/__tests__/node-frames.test.ts | 121 +++++++++++++- .../src/__tests__/relay-frames.test.ts | 89 ++++++++++ packages/subshell-protocol/src/index.ts | 11 ++ packages/subshell-protocol/src/node-frames.ts | 118 +++++++++++++- packages/subshell-protocol/src/ssh-frames.ts | 152 ++++++++++++++++-- packages/subshell-protocol/src/ssh-limits.ts | 46 ++++++ 11 files changed, 718 insertions(+), 28 deletions(-) create mode 100644 apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts create mode 100644 apps/server/api/src/services/nodes/relay-frames.ts create mode 100644 packages/subshell-protocol/src/__tests__/relay-frames.test.ts diff --git a/apps/node/agent/src/__tests__/commands-ssh.test.ts b/apps/node/agent/src/__tests__/commands-ssh.test.ts index ecd061c2e..51ffda5c3 100644 --- a/apps/node/agent/src/__tests__/commands-ssh.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh.test.ts @@ -163,7 +163,7 @@ describe("ssh_discover_aliases / ssh_resolve_config arms (gate ON)", () => { }); }); -/** The bump this tier owns: literal-16 pins everywhere must now read 17. */ -it("NODE_PROTOCOL_VERSION is 17 (the ssh launch block + the two read arms)", () => { - expect(NODE_PROTOCOL_VERSION).toBe(17); +/** The bump M2's relay tier owns: the relay link frame + the open/close commands (spec 2026-10-08 §5.1). */ +it("NODE_PROTOCOL_VERSION is 18 (relay frames + ssh_relay_open/close on the wire)", () => { + expect(NODE_PROTOCOL_VERSION).toBe(18); }); diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index ff41e1bfa..f1ac2b1a6 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -7,6 +7,8 @@ import { NODE_MAX_FRAME_BYTES, NODE_PROTOCOL_VERSION, type NodeEvent, + type RelayFrame, + SSH_RELAY_FRAME_MAX_BYTES, } from "@internal/subshell-protocol"; import { createClientSession, @@ -48,6 +50,7 @@ import { type NodeWsDeps, type NodeWsSocket, } from "../node-ws-handler.js"; +import { relayFramesSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; import { beginUpdate, readView, resetForTests, updateSwapped } from "../update-tracker.js"; /* ---------------------------- fakes ----------------------------- */ @@ -1748,6 +1751,69 @@ describe("handleNodeMessage through the link machine (spec 2026-09-24 §4/§5/§ expect(big.closed[0]?.code).toBe(NODE_CLOSE_TOO_BIG); }); + it("(g2) a tier-17 binding is refused, and no relay frame EVER reaches the seam on that socket (spec 2026-10-08 §11)", async () => { + // THE bump gate: relay frames are a new link frame kind, so an agent + // that ships no relay grammar must not pair at all. The exact-match + // check in the binding is what refuses it - BEFORE establishment, which + // is the only door a relay frame has (the machine hands plaintext only + // after `established`, and the switch never runs on an unestablished + // socket). The relay frame here is a VALID shape; it fails on the + // version, which is the point. + resetRelayFramesForTests(); + const f = await handshakeFixture(); + const { h, ws, client } = f; + await handleNodeMessage(h.deps, ws, kxText(f)); + await handleNodeMessage( + h.deps, + ws, + client.session.sealFrame( + JSON.stringify({ nodeId: "n1", nodeKey: LIVE, protocolVersion: NODE_PROTOCOL_VERSION - 1 }), + ), + ); + expect(ws.closed[0]?.code).toBe(NODE_CLOSE_HANDSHAKE_REQUIRED); // 4410 + expect(ws.data.linkPhase).not.toBe("established"); + // What the pre-18 agent tries anyway - first as plaintext junk, then as + // a WELL-FORMED sealed relay frame its own session can still encrypt. + const relayFrame = { type: "relay", ref: "r-1", seq: 0, direction: "B2A", blob: "QUJD" }; + await handleNodeMessage(h.deps, ws, JSON.stringify(relayFrame)); + await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify(relayFrame))); + expect(relayFramesSeenFor("n1")).toBeUndefined(); // refused at the version match, never routed + }); + + it("(g3) an established tier-18 link routes a sealed relay frame verbatim to the seam; over-cap is dropped", async () => { + resetRelayFramesForTests(); + const f = await handshakeFixture(); + const { h, ws, client } = f; + await handleNodeMessage(h.deps, ws, kxText(f)); + await handleNodeMessage(h.deps, ws, bindingBytes(f)); // established at 18 + const relayFrame: RelayFrame = { type: "relay", ref: "r-1", seq: 3, direction: "A2B", blob: "QUJD" }; + await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify(relayFrame))); + // The seam saw EXACTLY the frame, blob included - and nothing else did: + // the plane is a blind router (§5.5), the stub only records. + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); + // The over-cap blob is refused by the grammar upstream of the seam + // (§5.1: cap is law); the socket survives, the frame does not arrive. + const over = "A".repeat(Math.ceil(SSH_RELAY_FRAME_MAX_BYTES / 3) * 4 + 4); + await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify({ ...relayFrame, blob: over }))); + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); // unchanged + expect(ws.closed).toHaveLength(0); // a dropped frame is not fatal (spec §3.1 posture) + }); + + it("(g4) a HELD socket's relay frame is dropped by the held-gate, not routed", async () => { + resetRelayFramesForTests(); + const h = makeHarness(); + const ws = fakeSocket("n1"); // unclassified fake: straight to the gates/switch + handleNodeOpen(OPEN_DEPS, ws); // the hold needs the connection it attaches + await handleNodeMessage(h.deps, ws, JSON.stringify(readyFrame({ protocolVersion: 999 }))); + expect(getHeld("n1")).toBeDefined(); + await handleNodeMessage( + h.deps, + ws, + JSON.stringify({ type: "relay", ref: "r-1", seq: 0, direction: "B2A", blob: "QUJD" }), + ); + expect(relayFramesSeenFor("n1")).toBeUndefined(); // held speaks exactly one thing: `result` + }); + it("(h) the held `update` path traverses the machine untouched: plaintext out, plaintext result in, RPC completes", async () => { // spec 2026-09-24 §5 regression lock ("held update path unregressed"). A // LEGACY below-floor agent is held by the EXISTING floor gate — the diff --git a/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts new file mode 100644 index 000000000..50ef338eb --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "bun:test"; +import type { RelayFrame } from "@internal/subshell-protocol"; +import { onRelayFrame, relayFramesSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; + +/** + * The Task-4 seam (spec 2026-10-08 §5.1): validation happened upstream + * (parseRelayFrame via the handler's parse), the held/supersede guards + * upstream of that, and the version gate upstream of everything. What this + * file pins is the stub's CONTRACT that Task 8's broker inherits: route in, + * recorded verbatim out, blob never touched. The end-to-end routing (which + * socket a frame reaches only after a brokered open) is Task 8's suite. + */ + +const frame: RelayFrame = { + type: "relay", + ref: "r-4f2a", + seq: 0, + direction: "B2A", + blob: "QUJDk5+ToQ==", +}; + +describe("onRelayFrame (the pre-broker stub)", () => { + it("records a routed frame per node, verbatim and blob-intact", () => { + resetRelayFramesForTests(); + onRelayFrame("n-a", frame); + expect(relayFramesSeenFor("n-a")).toEqual({ frames: 1, last: frame }); + // The stub is not a reader: the stored frame is the SAME shape that went + // in, base64 kept as the opaque string §5.5 demands, decoded nowhere. + expect(relayFramesSeenFor("n-a")?.last.blob).toBe(frame.blob); + }); + + it("counts per node and keeps only the newest frame", () => { + resetRelayFramesForTests(); + onRelayFrame("n-b", frame); + onRelayFrame("n-b", { ...frame, seq: 1, direction: "A2B" }); + expect(relayFramesSeenFor("n-b")).toEqual({ frames: 2, last: { ...frame, seq: 1, direction: "A2B" } }); + // Per-node isolation: A's traffic is not B's record. + expect(relayFramesSeenFor("n-a")).toBeUndefined(); + }); + + it("reset clears the registry (the --parallel fresh-registry posture)", () => { + onRelayFrame("n-c", frame); + resetRelayFramesForTests(); + expect(relayFramesSeenFor("n-c")).toBeUndefined(); + }); +}); diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index ae200f60a..fe3e853a2 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -47,6 +47,7 @@ import { releaseHeld, } from "./node-registry.js"; import { binaryPayload, failConnPendings, resolveResult } from "./node-rpc.js"; +import { onRelayFrame } from "./relay-frames.js"; import { recordNodeDisconnect, recordNodeReady } from "./update-tracker.js"; /** @@ -1073,6 +1074,17 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: case "error": logger.withMetadata({ nodeId, code: event.code }).warn(`node reported error: ${event.message}`); return; + case "relay": { + // The sealed agent-relay leg (spec 2026-10-08 §5.1). Everything a + // router must know about this frame is already decided upstream: the + // grammar validated shape and the SSH_RELAY_FRAME_MAX_BYTES cap, the + // exact-match handshake refused any pre-18 agent BEFORE this socket + // could carry a frame, and the held/supersede guards above ran. The + // blob stays opaque right through here (§5.5) - Task 8's broker pairs + // refs and shuttles; this is the routing seam, not the broker. + onRelayFrame(nodeId, event); + return; + } } } diff --git a/apps/server/api/src/services/nodes/relay-frames.ts b/apps/server/api/src/services/nodes/relay-frames.ts new file mode 100644 index 000000000..83bb51e4e --- /dev/null +++ b/apps/server/api/src/services/nodes/relay-frames.ts @@ -0,0 +1,79 @@ +import type { RelayFrame } from "@internal/subshell-protocol"; + +/** + * The plane-side recognition seam for `relay` link frames (spec 2026-10-08 + * §5.1/§5.3) - the single named point between "a decrypted, grammar-validated + * relay frame arrived on an authenticated node socket" and whoever brokers + * it. `node-ws-handler.ts` routes here from its event switch; nothing else + * calls in, and nothing here calls outward. + * + * **This is deliberately a stub.** Task 8 (the plane relay broker) replaces + * the body of {@link onRelayFrame} with the real broker. What is ALREADY + * decided before a frame reaches this file, and must not be re-decided here: + * the socket is authenticated (the upgrade chain), the link is encrypted and + * version-matched at NODE_PROTOCOL_VERSION (the handshake - which is why a + * pre-18 agent never gets a relay frame routed at all), the frame is + * well-formed and within SSH_RELAY_FRAME_MAX_BYTES (`parseRelayFrame`, via + * `parseNodeEvent`), the node is not held, and the socket is not superseded + * (both guards run upstream in the handler). + * + * What Task 8's broker owns, and NOTHING here does: + * - pair the two live sockets by the frame's routing `ref` against the relay + * sessions their `ssh_relay_open` commands established (§5.1); + * - forward `blob` to the peer socket BLIND - the plane reads the ref and + * nothing else (§5.5; an unopened byte copy is the whole job); + * - enforce SSH_RELAY_MAX_PER_NODE at session-open time (§5.3's loud refusal); + * - tear down on every §5.6 cut (grace elapsed, child exit, lifetime expiry, + * A dropping, grant revoke) with the named-reason close and the + * `node.ssh_relay.close` audit. + * The stub records what arrived per node so the wiring is testable today + * without the broker being real, and so the day it lands, the tests that + * assert "a routed frame reaches the seam" do not move. + */ + +/** What the stub holds for one node until Task 8's broker replaces it. */ +export interface RelayFrameStubRecord { + /** How many relay frames have been routed from this node. */ + frames: number; + /** The most recent frame, kept verbatim - blob included, still opaque. */ + last: RelayFrame; +} + +const seenByNode = new Map(); + +/** + * Route one validated relay frame from `nodeId`. Today: record it. Task 8: + * replace with the blind pair-and-forward of §5.1/§5.3/§5.5. + * @param nodeId - the node whose authenticated socket produced the frame + * @param frame - the grammar-validated frame (`parseRelayFrame` narrowed it) + */ +export function onRelayFrame(nodeId: string, frame: RelayFrame): void { + const record = seenByNode.get(nodeId); + if (record) { + record.frames += 1; + record.last = frame; + } else { + seenByNode.set(nodeId, { frames: 1, last: frame }); + } + // TODO(Task 8): pair by `frame.ref` against the relay session this + // pairing's ssh_relay_open established, forward the blob unopened to the + // peer's socket, and close-with-named-reason on every §5.6 cut. A frame + // with no brokered session behind it is refused there, with a reason. +} + +/** + * What the stub recorded for one node (the frame count and the last frame, + * verbatim). + * @internal test-only; Task 8's broker replaces this module's surface + */ +export function relayFramesSeenFor(nodeId: string): RelayFrameStubRecord | undefined { + return seenByNode.get(nodeId); +} + +/** + * Drop every stub record. + * @internal test-only; per-file isolation under `bun test --parallel` + */ +export function resetRelayFramesForTests(): void { + seenByNode.clear(); +} diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 75e683051..d08c3fa7a 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -24,8 +24,8 @@ import { parseNodeRuntimeReport, partPathOf, } from "../node-frames.js"; -import { SSH_COMMAND_TYPES } from "../ssh-frames.js"; -import { SSH_CONFIG_FILE_MAX_BYTES } from "../ssh-limits.js"; +import { SSH_COMMAND_TYPES, type SshRelayOpenCommand } from "../ssh-frames.js"; +import { SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_GRANT_FINGERPRINTS } from "../ssh-limits.js"; import { MIN_NODE_VERSION } from "../versions.js"; const launchCmd = { @@ -45,6 +45,25 @@ const launchCmd = { resolve: { binaryName: "claude" }, }; +/** + * A complete relay-open pairing (spec 2026-10-08 §5.1): every field the + * brokered open carries is REQUIRED on the wire, so one fixture serves the + * accept paths and the field-by-field refusals below. + */ +const relayOpenCmd: SshRelayOpenCommand = { + type: "ssh_relay_open", + relayId: "relay-9c31", + ref: "r-4f2a", + role: "A", + aNodeId: "node-a", + bNodeId: "node-b", + peerSigningPublicKey: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB"}', + peerEncryptPublicKey: "SGVsbG9Xb3JsZEhlcmVJc1RoaXJ0eXR3b0J5dGVzMTI=", + grantId: "grant-77", + fingerprints: ["SHA256:AAAA", "SHA256:BBBB"], + lifetimeMs: 30_000, +}; + describe("parseNodeCommandBody", () => { it("accepts a well-formed launch and preserves fields", () => { const cmd = parseNodeCommandBody(structuredClone(launchCmd)); @@ -323,7 +342,12 @@ describe("parseNodeCommandBody", () => { // `ssh` block and the `ssh_discover_aliases` / `ssh_resolve_config` arms. // A lagging agent ignores the launch block and spawns a bare `ssh` with no // `-F` config, so it is HELD (update-only) until crossed, not approximated. - expect(NODE_PROTOCOL_VERSION).toBe(17); + // 18 is the sealed agent relay (spec 2026-10-08 §5.1): the `relay` link + // frame (a new frame kind on the established link) and the `ssh_relay_open` + // / `ssh_relay_close` signed commands. A tier-17 agent understands no relay + // frame, and the exact-match gate refuses it BEFORE any relay command + // (spec §11: never half-relaying). + expect(NODE_PROTOCOL_VERSION).toBe(18); }); it("accepts set_allowed_dirs and rejects a missing or non-array dirs", () => { @@ -932,17 +956,26 @@ describe("ssh command arms and the launch ssh block", () => { type: "ssh_register_identity", }); expect(parseNodeCommandBody({ type: "ssh_register_identity" })).not.toBeNull(); - // The family's census: exactly these three types, and the count is the - // tripwire - a fourth arm must show up here before it ships. + // The family's census: exactly these five types, and the count is the + // tripwire - a further arm must show up here before it ships. expect([...SSH_COMMAND_TYPES].sort()).toEqual([ "ssh_discover_aliases", "ssh_register_identity", + "ssh_relay_close", + "ssh_relay_open", "ssh_resolve_config", ]); - expect(SSH_COMMAND_TYPES).toHaveLength(3); + expect(SSH_COMMAND_TYPES).toHaveLength(5); // Every census type is a type the dispatcher actually narrows. for (const t of SSH_COMMAND_TYPES) { - const body = t === "ssh_resolve_config" ? { type: t, alias: "box-a" } : { type: t }; + const body = + t === "ssh_resolve_config" + ? { type: t, alias: "box-a" } + : t === "ssh_relay_open" + ? relayOpenCmd + : t === "ssh_relay_close" + ? { type: t, ref: "r-1", reason: "lifetime_expired" } + : { type: t }; expect(parseNodeCommandBody(body)).not.toBeNull(); } }); @@ -970,6 +1003,80 @@ describe("ssh command arms and the launch ssh block", () => { }); }); +describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => { + it("narrows a complete open pairing, every field carried through", () => { + expect(parseNodeCommandBody(structuredClone(relayOpenCmd))).toEqual(structuredClone(relayOpenCmd)); + expect(parseNodeCommandBody(JSON.stringify(relayOpenCmd))).toEqual(relayOpenCmd); + // Role B is the same grammar; the plane decides which machine gets which. + expect(parseNodeCommandBody({ ...relayOpenCmd, role: "B" })).toMatchObject({ role: "B" }); + // An empty fingerprint set parses: scoping (serve nothing) is the + // responder's rule (§5.4), not a reason to refuse the frame. + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: [] })).toMatchObject({ fingerprints: [] }); + }); + + it("refuses an open missing any half of the pairing", () => { + // The command IS the pairing; a partial one names no complete session, + // so every field is required (same posture as `update`'s three). + for (const drop of [ + "relayId", + "ref", + "role", + "aNodeId", + "bNodeId", + "peerSigningPublicKey", + "peerEncryptPublicKey", + "grantId", + "fingerprints", + "lifetimeMs", + ] as const) { + const partial = structuredClone(relayOpenCmd) as unknown as Record; + delete partial[drop]; + expect(parseNodeCommandBody(partial)).toBeNull(); + } + }); + + it("refuses malformed open fields", () => { + expect(parseNodeCommandBody({ ...relayOpenCmd, role: "C" })).toBeNull(); // only A or B + expect(parseNodeCommandBody({ ...relayOpenCmd, relayId: "" })).toBeNull(); // ids are non-empty + expect(parseNodeCommandBody({ ...relayOpenCmd, grantId: 42 })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: "SHA256:AAAA" })).toBeNull(); // array, not string + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: ["ok", 7] })).toBeNull(); + // The grant's selection is capped by SSH_MAX_GRANT_FINGERPRINTS, and the + // refusal is hard - never a silent truncation (§5.4). + expect( + parseNodeCommandBody({ + ...relayOpenCmd, + fingerprints: Array.from({ length: SSH_MAX_GRANT_FINGERPRINTS + 1 }, (_, i) => `SHA256:${i}`), + }), + ).toBeNull(); + expect( + parseNodeCommandBody({ + ...relayOpenCmd, + fingerprints: Array.from({ length: SSH_MAX_GRANT_FINGERPRINTS }, (_, i) => `SHA256:${i}`), + }), + ).not.toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, lifetimeMs: 0 })).toBeNull(); // positive or refuse + expect(parseNodeCommandBody({ ...relayOpenCmd, lifetimeMs: 1.5 })).toBeNull(); + // The encryption half is a base64 key (same spelling the link pins carry); + // junk at the grammar is malformed, not "the pin will sort it out". + expect(parseNodeCommandBody({ ...relayOpenCmd, peerEncryptPublicKey: "!!!" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, peerEncryptPublicKey: "" })).toBeNull(); + }); + + it("narrows the close: routing ref and named reason, nothing else", () => { + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a", reason: "grant_revoked" })).toEqual({ + type: "ssh_relay_close", + ref: "r-4f2a", + reason: "grant_revoked", + }); + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a" })).toBeNull(); // reason required + expect(parseNodeCommandBody({ type: "ssh_relay_close", reason: "lifetime_expired" })).toBeNull(); // ref required + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "", reason: "child_exit" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r", reason: "" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r", reason: 3 })).toBeNull(); + }); +}); + describe("update command (protocol 10, spec 2026-09-15 §5.1)", () => { it("pins the wire shape as a LITERAL, because this one is frozen across protocol bumps", () => { // Every other command travels between two ends that agreed on diff --git a/packages/subshell-protocol/src/__tests__/relay-frames.test.ts b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts new file mode 100644 index 000000000..91ee2ee02 --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "bun:test"; +import { NODE_PROTOCOL_VERSION, parseNodeEvent, parseRelayFrame } from "../node-frames.js"; +import { SSH_RELAY_FRAME_MAX_BYTES } from "../ssh-limits.js"; + +/** + * The sealed agent-relay link frame (spec 2026-10-08 §5.1): one sealed + * envelope per frame, keyed by the opaque routing ref, direction-tagged, + * blob-capped. The plane is a BLIND router - the grammar is the only thing + * that touches the frame's SHAPE, and it never decodes `blob`. + */ + +/** Strict base64 that decodes to EXACTLY n raw bytes (padded last group). */ +function b64OfRawLength(n: number): string { + const full = Math.floor(n / 3); + const rem = n % 3; + const body = "A".repeat(full * 4); + if (rem === 0) return body; + return rem === 1 ? `${body}AA==` : `${body}AAA=`; +} + +const good = { + type: "relay", + ref: "r-4f2a", + seq: 0, + direction: "B2A", + blob: b64OfRawLength(1024), +} as const; + +describe("NODE_PROTOCOL_VERSION 18 (spec 2026-10-08 §5.1)", () => { + it("18 is the relay tier: relay frames + the open/close commands", () => { + // The bump is load-bearing: a tier-17 agent (M1) understands no relay + // frame, and the exact-match gate is what refuses it BEFORE any frame is + // accepted. Never half-relay. + expect(NODE_PROTOCOL_VERSION).toBe(18); + }); +}); + +describe("parseRelayFrame", () => { + it("accepts a valid frame and preserves every field", () => { + expect(parseRelayFrame(good)).toEqual({ ...good }); + expect(parseRelayFrame(JSON.stringify(good))).toEqual({ ...good }); + expect(parseRelayFrame({ ...good, direction: "A2B", seq: 7 })).toMatchObject({ direction: "A2B", seq: 7 }); + }); + + it("accepts a blob at exactly the cap and refuses one over it", () => { + // The cap is on the RAW payload the base64 decodes to, measured exactly + // (padding groups included) - a padded group that reads one byte longer + // than a full one at the same spelling must not smuggle past it. + const atCap = { ...good, blob: b64OfRawLength(SSH_RELAY_FRAME_MAX_BYTES) }; + expect(parseRelayFrame(atCap)).not.toBeNull(); + expect(parseRelayFrame({ ...good, blob: b64OfRawLength(SSH_RELAY_FRAME_MAX_BYTES + 1) })).toBeNull(); + expect(parseRelayFrame({ ...good, blob: "A".repeat(atCap.blob.length + 4) })).toBeNull(); + // One padded extra group appended: one raw byte over, and the char + // count reads almost like the at-cap frame - the raw measurement is what + // refuses it (§5.1's cap is law, not a char-count approximation). + expect(parseRelayFrame({ ...good, blob: `${atCap.blob}AB==` })).toBeNull(); + // One byte UNDER the cap, padded last group: still legal. + expect(parseRelayFrame({ ...good, blob: b64OfRawLength(SSH_RELAY_FRAME_MAX_BYTES - 1) })).not.toBeNull(); + }); + + it("refuses malformed shapes", () => { + expect(parseRelayFrame({ ...good, type: "not-relay" })).toBeNull(); + expect(parseRelayFrame({ ref: "r", seq: 0, direction: "B2A", blob: "AA==" })).toBeNull(); // no type + expect(parseRelayFrame({ ...good, ref: "" })).toBeNull(); // empty routing ref + expect(parseRelayFrame({ ...good })).not.toBeNull(); // (sanity for the line above) + expect(parseRelayFrame({ ...good, seq: -1 })).toBeNull(); // seq is a monotonic counter + expect(parseRelayFrame({ ...good, seq: 1.5 })).toBeNull(); + expect(parseRelayFrame({ ...good, direction: "A2A" })).toBeNull(); // only the two spellings + expect(parseRelayFrame({ ...good, blob: "not base64 !!!" })).toBeNull(); + expect(parseRelayFrame({ ...good, blob: 42 })).toBeNull(); + expect(parseRelayFrame("nope")).toBeNull(); + expect(parseRelayFrame("null")).toBeNull(); // parses, and null is not a frame + expect(parseRelayFrame(JSON.stringify([good]))).toBeNull(); // an array is not a record either + }); + + it("the relay kind joins the node-event narrowing (the plane's one inbound parse)", () => { + // Every relay frame the PLANE sees arrives on the agent-to-plane inbound + // path, which dispatches through parseNodeEvent; the kind must come back + // from there, not fall to the unrecognized-drop. + const event = parseNodeEvent({ ...good }); + expect(event?.type).toBe("relay"); + if (event?.type === "relay") { + expect(event.ref).toBe("r-4f2a"); + expect(event.direction).toBe("B2A"); + } + // A malformed relay frame stays NULL there, exactly as parseRelayFrame refuses it. + expect(parseNodeEvent({ ...good, direction: "sideways" })).toBeNull(); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 1c3724026..c3ccb2c85 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -111,7 +111,10 @@ export { parseNodeMaintenance, parseNodeRuntimeReport, parseNodeSshEnabled, + parseRelayFrame, partPathOf, + type RelayDirection, + type RelayFrame, } from "./node-frames.js"; export { type DetectResultWire, @@ -249,15 +252,23 @@ export { type SshDiscoverAliasesCommand, type SshNodeCommandBody, type SshRegisterIdentityCommand, + type SshRelayCloseCommand, + type SshRelayOpenCommand, + type SshRelayRole, type SshResolveConfigCommand, } from "./ssh-frames.js"; export { SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_DISCOVERED_ALIASES, + SSH_MAX_GRANT_FINGERPRINTS, SSH_MAX_PROXY_HOPS, SSH_NAME_MAX_CHARS, SSH_PATH_MAX_CHARS, SSH_PROBE_DEADLINE_MS, + SSH_RELAY_FRAME_MAX_BYTES, + SSH_RELAY_LIFETIME_MS, + SSH_RELAY_MAX_PER_NODE, + SSH_RELAY_TEARDOWN_GRACE_MS, } from "./ssh-limits.js"; // PURE module (WebCrypto globals only, no node: builtins): safe in the // Metro-consumed barrel; pinned by __tests__/ssh-pin-store.test.ts. diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index cf5175460..1d71a8f99 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1,7 +1,7 @@ import { BASE64_RE, isBool, isInt, isNum, isRecord, isStr, isStrArray, isStringMap } from "./guards.js"; import type { JsonValue } from "./json.js"; import { parseSshNodeCommandBody, type SshNodeCommandBody } from "./ssh-frames.js"; -import { SSH_CONFIG_FILE_MAX_BYTES, SSH_PATH_MAX_CHARS } from "./ssh-limits.js"; +import { SSH_CONFIG_FILE_MAX_BYTES, SSH_PATH_MAX_CHARS, SSH_RELAY_FRAME_MAX_BYTES } from "./ssh-limits.js"; /** * Node ↔ control-plane wire contract (spec 2026-08-31 §3). @@ -150,15 +150,25 @@ import { SSH_CONFIG_FILE_MAX_BYTES, SSH_PATH_MAX_CHARS } from "./ssh-limits.js"; * mirror simply says no. A capability gate cannot live with that ambiguity * (spec §4.3), so the number is what refuses the old agent, not the frame. * - * **16 -> 17 (this tier):** the ssh launch block and the two discovery/resolve - * commands. A lagging agent is HELD (update-only) until crossed, per the + * **16 -> 17:** the ssh launch block and the two discovery/resolve commands. + * A lagging agent is HELD (update-only) until crossed, per the * capability-gate doctrine in docs/superpowers/specs/2026-10-07-ssh-anywhere-design.md * §4.3; the desktop update path is the crossing. The ambiguity the bump * refuses: a tier-1 agent that ignores the launch frame's `ssh` member would * spawn a bare `ssh` pane with no `-F` config (§4.3's gate doctrine refuses to * live with that). + * + * **17 -> 18 (this tier):** the sealed agent relay + * (docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md §5.1). The + * `relay` link frame - a NEW frame kind on the established link, carrying one + * sealed envelope keyed by the opaque routing ref, with no per-message + * command signing - plus the `ssh_relay_open` / `ssh_relay_close` signed + * commands that broker a session. This one cannot ride a silent tier: a + * tier-17 agent understands no `relay` frame at all, and the exact-match + * gate is what refuses pairing BEFORE any relay command is sent (§5.1's own + * bump argument, §11's never-half-relay rule). Server first, as always. */ -export const NODE_PROTOCOL_VERSION = 17; +export const NODE_PROTOCOL_VERSION = 18; /** * The FIRST protocol whose agents verify the publisher signature on an @@ -1274,6 +1284,87 @@ export type PluginReportWire = { restartRequired?: boolean; }; +/* ------------------------------------------------------------------ */ +/* sealed agent relay link frame (spec 2026-10-08 §5.1) */ +/* ------------------------------------------------------------------ */ + +/** Which way one relay envelope travels inside its session (spec §5.1). */ +export type RelayDirection = "B2A" | "A2B"; + +/** + * The `relay` link frame: ONE sealed envelope per frame, on the established + * (protocol-18) link, keyed by the opaque routing ref (§5.1). It is NOT a + * command and NOT signed per message: the link already authenticates the + * machine, and origin rides the inner ES256 signature the endpoints exchange + * (§5.6), which the plane never sees. The plane's only roles are open, + * route, close - so `blob` is opaque BY CONTRACT: this grammar validates the + * SHAPE and the size and decodes nothing in between. `seq` is the frame's + * transport slot (the anti-replay `seq` lives SIGNED inside the envelope, + * §5.6); here it only needs to be a non-negative integer both ends can log. + * + * The frame travels BOTH directions of the link. On the plane's inbound path + * it arrives like every other agent frame (it joins {@link NodeEvent} for + * that); the plane-to-node leg serializes the same object down the + * established link and the node parses it with {@link parseRelayFrame} + * directly. The routing ref pairs the two legs; the broker (Task 8) owns + * that pairing, never this grammar. + */ +export interface RelayFrame { + type: "relay"; + /** The opaque routing ref the relay-open command established. */ + ref: string; + /** Per-frame counter on THIS leg; monotonic, non-negative. */ + seq: number; + /** The envelope's logical direction inside the session, both spellings. */ + direction: RelayDirection; + /** The sealed envelope, strict base64, raw payload at most SSH_RELAY_FRAME_MAX_BYTES. */ + blob: string; +} + +/** + * Exact raw length a strict-base64 string carries: 3 bytes per whole group + * minus the pad. BASE64_RE already forces the length to a multiple of 4, so + * the trailing-`=` count alone closes the last group. Measuring the cap on + * the RAW payload (not the inflated string) is what makes the bound honest: + * a char-length cap would let a padded group carry one byte past + * {@link SSH_RELAY_FRAME_MAX_BYTES} while reading exactly as long. + */ +function base64RawLength(b64: string): number { + let pad = 0; + for (let i = b64.length - 1; i >= 0 && b64[i] === "="; i -= 1) pad += 1; + return (b64.length / 4) * 3 - pad; +} + +/** + * Validates and narrows a relay link frame (raw JSON text or the + * already-parsed object, same intake as {@link parseNodeEvent}). A present- + * but-undecodable blob is malformed, not deferred; an over-cap blob is + * REFUSED here because the cap is law (§5.1: over-cap frame refused, session + * closed with a named reason - the close is the broker's, the refusal is + * this function's). The blob's CONTENTS are never inspected: blindness is + * the point (§5.5). + * + * @param raw - candidate frame as received + * @returns the narrowed frame, or null when malformed / not a relay frame + */ +export function parseRelayFrame(raw: string | object): RelayFrame | null { + let value: unknown = raw; + if (typeof raw === "string") { + try { + value = JSON.parse(raw); + } catch { + return null; + } + } + if (!isRecord(value) || value.type !== "relay") return null; + if (!isStr(value.ref) || value.ref.length === 0) return null; + if (!isInt(value.seq) || (value.seq as number) < 0) return null; + if (value.direction !== "B2A" && value.direction !== "A2B") return null; + if (!isStr(value.blob) || value.blob.length === 0 || !BASE64_RE.test(value.blob)) return null; + if (base64RawLength(value.blob) > SSH_RELAY_FRAME_MAX_BYTES) return null; + return { type: "relay", ref: value.ref, seq: value.seq as number, direction: value.direction, blob: value.blob }; +} + export type NodeEvent = | { type: "ready"; @@ -1407,7 +1498,17 @@ export type NodeEvent = | { type: "output"; subshellId: string; subId: string; fromByte: number; toByte: number; data_b64: string } | { type: "exit"; subshellId: string; exitCode: number | null; at: string } | { type: "subshells_report"; subshells: { subshellId: string; alive: boolean; exitCode: number | null }[] } - | { type: "error"; code: string; message: string }; + | { type: "error"; code: string; message: string } + /** + * The sealed agent-relay leg (spec 2026-10-08 §5.1). Folded in for the + * PLANE's inbound path: every relay frame the plane reads arrives on an + * agent socket, where `parseNodeEvent` is the one kind dispatcher and the + * held-gate/supersede-probe that follow are exactly the guards a shuttle + * frame should pass through. On the plane-to-node leg the node parses the + * same shape with {@link parseRelayFrame} directly; "event" here names the + * inbound-to-plane position, not a claim that the node volunteered it. + */ + | RelayFrame; /* ------------------------------------------------------------------ */ /* validators (hand-rolled, parseClientFrame style — spec §3) */ @@ -1739,6 +1840,8 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { case "ssh_discover_aliases": case "ssh_resolve_config": case "ssh_register_identity": + case "ssh_relay_open": + case "ssh_relay_close": // Delegation, not a second parser: the SSH grammar (all arms, one // file) lives in ssh-frames.ts beside the commands it narrows. return parseSshNodeCommandBody(value); @@ -1862,6 +1965,11 @@ export function parseNodeEvent(raw: string | object): NodeEvent | null { return isStr(value.code) && isStr(value.message) ? { type: "error", code: value.code, message: value.message } : null; + case "relay": + // Delegation, like the SSH commands: the relay frame's whole contract + // (ref, seq, direction, capped opaque blob) lives in parseRelayFrame + // beside the type it narrows. + return parseRelayFrame(value); default: return null; } diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index 96d1c4bd5..3e19810c1 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -9,12 +9,14 @@ * connection`, the run start/status/read/cancel quartet, `ssh_terminal_ * launch`, `ssh_input_control`) retired with that destination-execution * product, which never shipped; their `type` arms are therefore deleted, not - * refused. The sealed agent-relay milestone (M2) adds ONE command here, - * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node's signing key reaches - * the plane inside a signed command on the existing node link). The - * brokered-session commands are NOT defined yet - they land with the relay - * frames themselves (`ssh-limits.ts` carries their limits ahead of the frames, - * as it declares). + * refused. The sealed agent-relay milestone (M2) adds three commands here: + * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node’s signing key reaches + * the plane inside a signed command on the existing node link), and the + * brokered pair `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane’s OPEN + * hands one machine the whole pairing, the CLOSE names why it ends). What + * rides the link AFTER an open - the sealed blobs themselves - is not a + * command at all: it is the `relay` link frame in `node-frames.ts`, which + * carries no per-message signing because the link already authenticates. * * The commands are TRANSPORT-AGNOSTIC plain objects: the server-hosted * `local` node executes the same runtime in-process against these exact @@ -23,12 +25,13 @@ * * Hand-rolled in the `node-frames.ts` style; imports no `node:` builtin, so * it rides the Metro-safe barrel with the rest of the grammar. The wiring - * landed with the launcher tier: `parseNodeCommandBody` dispatches these two - * arms here, and `node-results.ts` validates their answers. + * landed with the launcher tier: `parseNodeCommandBody` dispatches every + * `ssh_*` arm here, and `node-results.ts` validates the answers that carry + * data (the relay pair answer with a plain ack - no envelope to validate). */ -import { isRecord, isStr } from "./guards.js"; -import { SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; +import { BASE64_RE, isInt, isRecord, isStr, isStrArray } from "./guards.js"; +import { SSH_MAX_GRANT_FINGERPRINTS, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; /* ------------------------------------------------------------------ */ /* command bodies */ @@ -61,11 +64,83 @@ export interface SshRegisterIdentityCommand { type: "ssh_register_identity"; } +/** + * Which side of a relay pairing a machine is (spec 2026-10-08 §2): A is the + * key home whose agent signs, B is the connecting machine that holds none. + */ +export type SshRelayRole = "A" | "B"; + +/** + * The brokered OPEN (spec §5.1): the plane hands one machine the WHOLE + * pairing in this one signed command - relay-session id, opaque routing ref, + * both node ids with their roles, the peer's registered signing AND + * encryption public keys to pin (§4.4), the grant id with its selected + * key-fingerprint set (§5.4, delivered so the responder enforces it without + * a REST call it cannot make), and the lifetime. Every field is REQUIRED: + * a partial pairing names no session the endpoint could honor, so the + * grammar refuses it rather than let an executor guess a half. + * + * The peer keys travel as their registered spellings: the ES256 signing half + * as a JSON-serialized public JWK (what §4.2/§4.3 store and report), the + * ECDH-ES encryption half as canonical base64 (what the link pins). The + * byte-equality enforcement is the receiver's machine pin store (§4.4), + * not this grammar's. + */ +export interface SshRelayOpenCommand { + type: "ssh_relay_open"; + /** The relay-session id the plane minted (audit + §5.6's signature covers it). */ + relayId: string; + /** Opaque routing ref - the plane's blind pairing key for later relay frames. */ + ref: string; + /** THIS machine's role in the pairing; the executor branches on it. */ + role: SshRelayRole; + /** The key home's node id (names A whichever machine reads this). */ + aNodeId: string; + /** The connecting machine's node id (names B likewise). */ + bNodeId: string; + /** The peer's registered ES256 signing public key (JSON public JWK) to pin. */ + peerSigningPublicKey: string; + /** The peer's registered ECDH-ES encryption public key (canonical base64) to pin. */ + peerEncryptPublicKey: string; + /** The live grant this session runs under (revoke cuts the session, §6.3). */ + grantId: string; + /** The grant's selected key fingerprints, at most SSH_MAX_GRANT_FINGERPRINTS. */ + fingerprints: string[]; + /** Session ceiling in ms; SSH_RELAY_LIFETIME_MS is what the plane sends. */ + lifetimeMs: number; +} + +/** + * The brokered TEARDOWN (spec §5.1/§5.6): stop the session named by the + * routing ref and say why, in the open. The reason is a named cause + * (lifetime expiry, grant revoke, A drop, child exit, over-cap frame...) + * because §5.1's "closed with a named reason" is the whole failure-mode + * posture; an unnamed close is the silence this family refuses. + */ +export interface SshRelayCloseCommand { + type: "ssh_relay_close"; + /** The routing ref of the session to tear down. */ + ref: string; + /** The named reason this session ends; relayed to the endpoint's log. */ + reason: string; +} + /** Every SSH-family command body, as one union the {@link NodeCommandBody} union folds in. */ -export type SshNodeCommandBody = SshDiscoverAliasesCommand | SshResolveConfigCommand | SshRegisterIdentityCommand; +export type SshNodeCommandBody = + | SshDiscoverAliasesCommand + | SshResolveConfigCommand + | SshRegisterIdentityCommand + | SshRelayOpenCommand + | SshRelayCloseCommand; /** Every SSH command `type`, for census tests and dispatch tables. */ -export const SSH_COMMAND_TYPES = ["ssh_discover_aliases", "ssh_resolve_config", "ssh_register_identity"] as const; +export const SSH_COMMAND_TYPES = [ + "ssh_discover_aliases", + "ssh_resolve_config", + "ssh_register_identity", + "ssh_relay_open", + "ssh_relay_close", +] as const; /* ------------------------------------------------------------------ */ /* arm validators (delegated from parseNodeCommandBody) */ @@ -82,9 +157,26 @@ function isAliasName(value: unknown): value is string { ); } +/** Identifiers on this grammar are non-empty strings; shape only, existence is what the command asks. */ +function isIdStr(value: unknown): value is string { + return isStr(value) && value.length > 0; +} + +/** + * The grant's selected fingerprint set: strings, every one non-empty (an + * empty entry matches no key yet would ride a grant as a silent never-serve + * line), and at most {@link SSH_MAX_GRANT_FINGERPRINTS} - over-cap is a + * refusal at the grammar so the truncation nobody may silently perform is + * impossible by construction (§5.4). An EMPTY set parses: §5.4's + * "names no fingerprint, serves nothing" is a decision, not a defect. + */ +function isGrantFingerprints(value: unknown): value is string[] { + return isStrArray(value) && value.every((f) => f.length > 0) && value.length <= SSH_MAX_GRANT_FINGERPRINTS; +} + /** * Validates and narrows any `ssh_*` command body. `parseNodeCommandBody` - * routes its two `type` arms here, so the SSH grammar lives in ONE file + * routes every `ssh_` arm here, so the SSH grammar lives in ONE file * beside the commands it narrows. The contract this upholds is * the same as the node commands' today: a NON-null return is safe to switch * on by `type`. @@ -103,6 +195,40 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu // The whole command is its type: the machine answers about ITSELF, and // the answer's shape is `node-results.ts`'s business, not this grammar's. return { type: "ssh_register_identity" }; + case "ssh_relay_open": { + // EVERY pairing field is required (§5.1): this command IS the pairing, + // and an executor guessing a missing half would be guessing trust + // (whose key to pin) or safety (which fingerprints to serve). The + // role is only "A" or "B" - there is no third machine in this design. + if (value.role !== "A" && value.role !== "B") return null; + if ( + !isIdStr(value.relayId) || + !isIdStr(value.ref) || + !isIdStr(value.aNodeId) || + !isIdStr(value.bNodeId) || + !isIdStr(value.grantId) + ) { + return null; + } + // The signing half is a JSON public JWK - the grammar proves it is a + // non-empty string; importability and the private-part refusal are the + // server's gate, beside the one enroll and `ssh_register_identity` run. + if (!isIdStr(value.peerSigningPublicKey)) return null; + // The encryption half is a base64 key (the link pin's own spelling); a + // present-but-undecodable one is malformed, not "the pin sorts it out". + if (!isIdStr(value.peerEncryptPublicKey) || !BASE64_RE.test(value.peerEncryptPublicKey)) return null; + if (!isGrantFingerprints(value.fingerprints)) return null; + // Positive whole milliseconds; the VALUE's lawfulness (SSH_RELAY_LIFETIME_MS + // as the ceiling the broker sends) is the broker's, this is the shape. + if (!isInt(value.lifetimeMs) || (value.lifetimeMs as number) <= 0) return null; + return value as unknown as SshRelayOpenCommand; + } + case "ssh_relay_close": + // Ref + named reason, and nothing else may ride it: the close selects + // a session, it does not amend one (§5.1/§5.6). + return isIdStr(value.ref) && isIdStr(value.reason) + ? { type: "ssh_relay_close", ref: value.ref, reason: value.reason } + : null; default: return null; } diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts index 20803a982..e27b738fc 100644 --- a/packages/subshell-protocol/src/ssh-limits.ts +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -131,6 +131,52 @@ export const SSH_REQ_ID_MAX_CHARS = 64; */ export const SSH_SESSION_LOG_WINDOW_BYTES = 128 * 1024; +/* ------------------------------------------------------------------ */ +/* sealed agent relay (spec 2026-10-08 §5.1/§5.3/§5.4/§5.6) */ +/* ------------------------------------------------------------------ */ + +/** + * Largest RAW payload one sealed relay frame may carry (spec 2026-10-08 + * §5.1). 128 KiB is the channel-envelope scale the spec names the cap + * bounded to: base64 inflates 4/3, so a full blob spells about 174,764 + * characters inside the frame, well under the node link's own + * NODE_MAX_FRAME_BYTES ceiling - the same arithmetic that sized + * {@link SSH_SESSION_LOG_WINDOW_BYTES}. An over-cap frame is refused and + * the session closed with a named reason; the cap is law, not a hint. + */ +export const SSH_RELAY_FRAME_MAX_BYTES = 131_072; + +/** + * Concurrent relay sessions one node may hold on the plane's broker + * (spec §5.3). Exceeding the cap is a LOUD refusal, never a queue. + */ +export const SSH_RELAY_MAX_PER_NODE = 8; + +/** + * Hard ceiling on a relay session's life (spec §5.6). Modeled on + * {@link SSH_SESSION_OPEN_DEADLINE_MS}: the relay exists only for the + * handshake window, and the lifetime is the last-resort cut beneath the + * earlier ones (grace elapsed, child exit, A drop, grant revoke). + */ +export const SSH_RELAY_LIFETIME_MS = 30_000; + +/** + * How long B's proxy keeps the relay open after the `ssh` child is alive + * with no agent error before tearing it down (spec §5.6: B cannot see + * ssh's handshake to D, so a quiet grace stands in for the signal, and + * child exit cuts immediately). Same 5 s family as {@link SSH_CANCEL_GRACE_MS}. + */ +export const SSH_RELAY_TEARDOWN_GRACE_MS = 5_000; + +/** + * Key fingerprints one grant may select (spec §5.4). Deliberately + * DISTINCT from {@link SSH_MAX_IDENTITY_REFS}, which bounds snapshot + * identity PATHS, not grant selections; an approval selecting more is a + * hard refusal, never a silent truncation. Public data end to end: + * `SHA256:` base64 fingerprints, never key material. + */ +export const SSH_MAX_GRANT_FINGERPRINTS = 8; + /** * Largest raw stdout slice one brokered `session_frame` event may carry * (design §3's pump, restated as a number: the node-frames `session_frame` From 84def3deaadc88329bff3af95d031e989bf3dc7e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:28:35 -0700 Subject: [PATCH 21/92] fix(ssh-relay): Task 4 review - signing-key shape gate, observable over-cap, typed close reasons --- .../nodes/__tests__/node-ws-handler.test.ts | 45 ++++++- .../nodes/__tests__/relay-frames.test.ts | 34 ++++- .../api/src/services/nodes/node-ws-handler.ts | 15 ++- .../api/src/services/nodes/relay-frames.ts | 50 +++++++- .../src/__tests__/node-frames.test.ts | 79 ++++++++++-- .../src/__tests__/relay-frames.test.ts | 35 +++++- packages/subshell-protocol/src/index.ts | 4 + packages/subshell-protocol/src/node-frames.ts | 38 ++++++ packages/subshell-protocol/src/ssh-frames.ts | 116 ++++++++++++++---- 9 files changed, 375 insertions(+), 41 deletions(-) diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index f1ac2b1a6..be5cb0218 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -50,7 +50,7 @@ import { type NodeWsDeps, type NodeWsSocket, } from "../node-ws-handler.js"; -import { relayFramesSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; +import { relayFramesSeenFor, relayOverCapsSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; import { beginUpdate, readView, resetForTests, updateSwapped } from "../update-tracker.js"; /* ---------------------------- fakes ----------------------------- */ @@ -1780,7 +1780,7 @@ describe("handleNodeMessage through the link machine (spec 2026-09-24 §4/§5/§ expect(relayFramesSeenFor("n1")).toBeUndefined(); // refused at the version match, never routed }); - it("(g3) an established tier-18 link routes a sealed relay frame verbatim to the seam; over-cap is dropped", async () => { + it("(g3) an established tier-18 link routes a sealed relay frame verbatim to the seam; over-cap is refused by name", async () => { resetRelayFramesForTests(); const f = await handshakeFixture(); const { h, ws, client } = f; @@ -1791,12 +1791,45 @@ describe("handleNodeMessage through the link machine (spec 2026-09-24 §4/§5/§ // The seam saw EXACTLY the frame, blob included - and nothing else did: // the plane is a blind router (§5.5), the stub only records. expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); - // The over-cap blob is refused by the grammar upstream of the seam - // (§5.1: cap is law); the socket survives, the frame does not arrive. + // The over-cap blob is refused by the grammar upstream of the ROUTER seam + // (§5.1: cap is law); the socket survives, the frame is not forwarded. + // But §5.1's named-reason close needs the refusal to be OBSERVABLE, and + // the handler's over-cap probe is what routes it - ref only, no blob. const over = "A".repeat(Math.ceil(SSH_RELAY_FRAME_MAX_BYTES / 3) * 4 + 4); await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify({ ...relayFrame, blob: over }))); - expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); // unchanged - expect(ws.closed).toHaveLength(0); // a dropped frame is not fatal (spec §3.1 posture) + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); // router seam: NOT the normal route + expect(relayOverCapsSeenFor("n1")).toEqual({ overCaps: 1, lastRef: "r-1" }); // over-cap seam, ref carried + // The blob never crossed: the record has no byte slot, and the only + // bytes the seam could know are the ref (§5.5 blindness end to end). + expect(JSON.stringify(relayOverCapsSeenFor("n1"))).not.toContain("AAAA"); + expect(ws.closed).toHaveLength(0); // a refused frame is not fatal (spec §3.1 posture) + }); + + it("(g3b) an over-cap relay frame lands on the over-cap seam, NOT the router seam; junk lands on neither (Task 4 review)", async () => { + // The plain-path twin of g3's encrypted route (the fake socket skips the + // link machine), pinning the branch the handler added to its + // parseNodeEvent-null path: shape probe first, then the drop. + resetRelayFramesForTests(); + const h = makeHarness(); + const ws = fakeSocket("n1"); + const over = "A".repeat(Math.ceil(SSH_RELAY_FRAME_MAX_BYTES / 3) * 4 + 4); + const overFrame = { type: "relay", ref: "r-9", seq: 1, direction: "B2A", blob: over }; + await handleNodeMessage(h.deps, ws, JSON.stringify(overFrame)); + expect(relayOverCapsSeenFor("n1")).toEqual({ overCaps: 1, lastRef: "r-9" }); + expect(relayFramesSeenFor("n1")).toBeUndefined(); // never routed, blob never forwarded + // A second refusal increments the same node's count with the newer ref. + await handleNodeMessage(h.deps, ws, JSON.stringify({ ...overFrame, ref: "r-10" })); + expect(relayOverCapsSeenFor("n1")).toEqual({ overCaps: 2, lastRef: "r-10" }); + // A MALFORMED over-cap frame is junk, not an over-cap: it stays on the + // silent-drop path the probe deliberately does not widen. + await handleNodeMessage(h.deps, ws, JSON.stringify({ ...overFrame, seq: -5 })); + expect(relayOverCapsSeenFor("n1")).toMatchObject({ overCaps: 2 }); + // And a well-formed UNDER-cap frame goes the other way: routed, not refused. + const goodFrame: RelayFrame = { type: "relay", ref: "r-11", seq: 0, direction: "A2B", blob: "QUJD" }; + await handleNodeMessage(h.deps, ws, JSON.stringify(goodFrame)); + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: goodFrame }); + expect(relayOverCapsSeenFor("n1")).toMatchObject({ overCaps: 2 }); // still just the two + expect(ws.closed).toHaveLength(0); }); it("(g4) a HELD socket's relay frame is dropped by the held-gate, not routed", async () => { diff --git a/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts index 50ef338eb..fe971f092 100644 --- a/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "bun:test"; import type { RelayFrame } from "@internal/subshell-protocol"; -import { onRelayFrame, relayFramesSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; +import { + onRelayFrame, + onRelayFrameOverCap, + relayFramesSeenFor, + relayOverCapsSeenFor, + resetRelayFramesForTests, +} from "../relay-frames.js"; /** * The Task-4 seam (spec 2026-10-08 §5.1): validation happened upstream @@ -44,3 +50,29 @@ describe("onRelayFrame (the pre-broker stub)", () => { expect(relayFramesSeenFor("n-c")).toBeUndefined(); }); }); + +describe("onRelayFrameOverCap (the pre-broker over-cap stub, Task 4 review)", () => { + it("counts refusals per node and keeps ONLY the ref; the blob never comes", () => { + resetRelayFramesForTests(); + onRelayFrameOverCap("n-d", "r-1"); + expect(relayOverCapsSeenFor("n-d")).toEqual({ overCaps: 1, lastRef: "r-1" }); + onRelayFrameOverCap("n-d", "r-2"); + expect(relayOverCapsSeenFor("n-d")).toEqual({ overCaps: 2, lastRef: "r-2" }); + // Per-node isolation, same posture as the routed stub. + expect(relayOverCapsSeenFor("n-e")).toBeUndefined(); + // The record has no blob slot at all: an over-cap frame is refused + // unread, and the stub could not store the bytes even if it wanted to. + expect(Object.keys(relayOverCapsSeenFor("n-d") ?? {})).toEqual(["overCaps", "lastRef"]); + }); + + it("the two seams are independent, and reset clears both", () => { + resetRelayFramesForTests(); + onRelayFrameOverCap("n-f", "r-9"); + expect(relayFramesSeenFor("n-f")).toBeUndefined(); // a refusal is not a routed frame + onRelayFrame("n-f", frame); + expect(relayOverCapsSeenFor("n-f")).toEqual({ overCaps: 1, lastRef: "r-9" }); // a route is not a refusal + resetRelayFramesForTests(); + expect(relayOverCapsSeenFor("n-f")).toBeUndefined(); + expect(relayFramesSeenFor("n-f")).toBeUndefined(); + }); +}); diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index fe3e853a2..a8cb5911f 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -7,6 +7,7 @@ import { type NodeEvent, nodeVersionSupported, parseNodeEvent, + relayFrameRefIfOverCap, } from "@internal/subshell-protocol"; import type { LinkSession } from "@internal/subshell-protocol/node-link-crypto"; import { HttpError } from "@/api/auth-guard.js"; @@ -47,7 +48,7 @@ import { releaseHeld, } from "./node-registry.js"; import { binaryPayload, failConnPendings, resolveResult } from "./node-rpc.js"; -import { onRelayFrame } from "./relay-frames.js"; +import { onRelayFrame, onRelayFrameOverCap } from "./relay-frames.js"; import { recordNodeDisconnect, recordNodeReady } from "./update-tracker.js"; /** @@ -746,6 +747,18 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: const event = parseNodeEvent(resolved); if (!event) { + // A WELL-FORMED relay frame whose blob exceeds SSH_RELAY_FRAME_MAX_BYTES + // is refused by the strict grammar (parseRelayFrame stays strict so the + // NodeEvent union cannot carry an unlawful frame), but §5.1 refuses to + // answer it with silence: "closed with a named reason" needs the refusal + // to be OBSERVABLE. Probe the shape before logging "unrecognized" - the + // probe reads shape and the blob's raw length only, never the bytes + // (§5.5) - and route the named refusal to the relay seam. + const overCapRef = relayFrameRefIfOverCap(resolved); + if (overCapRef !== null) { + onRelayFrameOverCap(nodeId, overCapRef); + return; + } logger.debug(`node ws: dropped unrecognized frame from ${nodeId}`); return; } diff --git a/apps/server/api/src/services/nodes/relay-frames.ts b/apps/server/api/src/services/nodes/relay-frames.ts index 83bb51e4e..48d636c56 100644 --- a/apps/server/api/src/services/nodes/relay-frames.ts +++ b/apps/server/api/src/services/nodes/relay-frames.ts @@ -26,6 +26,10 @@ import type { RelayFrame } from "@internal/subshell-protocol"; * - tear down on every §5.6 cut (grace elapsed, child exit, lifetime expiry, * A dropping, grant revoke) with the named-reason close and the * `node.ssh_relay.close` audit. + * A second entry point, {@link onRelayFrameOverCap}, carries the refusal + * this grammar performs but cannot express in a `NodeEvent` (an over-cap + * blob) so §5.1's named-reason close has something to be ABOUT. + * * The stub records what arrived per node so the wiring is testable today * without the broker being real, and so the day it lands, the tests that * assert "a routed frame reaches the seam" do not move. @@ -39,7 +43,16 @@ export interface RelayFrameStubRecord { last: RelayFrame; } +/** What the over-cap stub holds for one node until Task 8 replaces it. */ +export interface RelayOverCapStubRecord { + /** How many over-cap relay frames have been refused from this node. */ + overCaps: number; + /** The routing ref of the most recent refusal. NEVER the blob: an over-cap frame is refused unread (§5.1, §5.5). */ + lastRef: string; +} + const seenByNode = new Map(); +const overCapByNode = new Map(); /** * Route one validated relay frame from `nodeId`. Today: record it. Task 8: @@ -61,6 +74,31 @@ export function onRelayFrame(nodeId: string, frame: RelayFrame): void { // with no brokered session behind it is refused there, with a reason. } +/** + * Refuse an over-cap relay frame BY NAME (spec 2026-10-08 §5.1). This is the + * observability half of the refusal the strict grammar already performed: + * `parseRelayFrame` answers an over-cap blob with null (so the `NodeEvent` + * union stays safe) and `node-ws-handler.ts` probes the shape with + * `relayFrameRefIfOverCap` and routes the named case here instead of the + * silent "unrecognized" drop. The blob never comes along - the probe reads + * its raw length and nothing else (§5.5). + * @param nodeId - the node whose authenticated socket produced the frame + * @param ref - the routing ref the well-formed candidate carried + */ +export function onRelayFrameOverCap(nodeId: string, ref: string): void { + const record = overCapByNode.get(nodeId); + if (record) { + record.overCaps += 1; + record.lastRef = ref; + } else { + overCapByNode.set(nodeId, { overCaps: 1, lastRef: ref }); + } + // TODO(Task 8): this is where §5.1's named-reason close lands: look the + // session up by `ref`, cut BOTH ends with ssh_relay_close reason + // "over-cap" (SSH_RELAY_CLOSE_REASONS), and audit `node.ssh_relay.close`. + // Until the broker exists, the record only proves the refusal is visible. +} + /** * What the stub recorded for one node (the frame count and the last frame, * verbatim). @@ -71,9 +109,19 @@ export function relayFramesSeenFor(nodeId: string): RelayFrameStubRecord | undef } /** - * Drop every stub record. + * What the over-cap stub recorded for one node (the refusal count and the + * last refused ref; never a blob). + * @internal test-only; Task 8's broker replaces this module's surface + */ +export function relayOverCapsSeenFor(nodeId: string): RelayOverCapStubRecord | undefined { + return overCapByNode.get(nodeId); +} + +/** + * Drop every stub record (routed frames and over-cap refusals alike). * @internal test-only; per-file isolation under `bun test --parallel` */ export function resetRelayFramesForTests(): void { seenByNode.clear(); + overCapByNode.clear(); } diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index d08c3fa7a..1081f734f 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -24,7 +24,7 @@ import { parseNodeRuntimeReport, partPathOf, } from "../node-frames.js"; -import { SSH_COMMAND_TYPES, type SshRelayOpenCommand } from "../ssh-frames.js"; +import { SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, type SshRelayOpenCommand } from "../ssh-frames.js"; import { SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_GRANT_FINGERPRINTS } from "../ssh-limits.js"; import { MIN_NODE_VERSION } from "../versions.js"; @@ -974,7 +974,7 @@ describe("ssh command arms and the launch ssh block", () => { : t === "ssh_relay_open" ? relayOpenCmd : t === "ssh_relay_close" - ? { type: t, ref: "r-1", reason: "lifetime_expired" } + ? { type: t, ref: "r-1", reason: "lifetime-expiry" } : { type: t }; expect(parseNodeCommandBody(body)).not.toBeNull(); } @@ -1063,17 +1063,82 @@ describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => expect(parseNodeCommandBody({ ...relayOpenCmd, peerEncryptPublicKey: "" })).toBeNull(); }); - it("narrows the close: routing ref and named reason, nothing else", () => { - expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a", reason: "grant_revoked" })).toEqual({ + it("refuses a peer signing key whose JSON is not a plain public-JWK record (Task 4 review)", () => { + // The shape gate mirrors parseNodeSshIdentity: a non-empty string is not + // enough; it must PARSE to a plain record. A bare word and a serialized + // PRIVATE JWK are the two shapes the old isIdStr-only check let through, + // so both are named here. Deep private-material refusal (importability, + // curve, coordinates) stays `bytesOfJwk` on the node - Task 6. + expect(parseNodeCommandBody({ ...relayOpenCmd, peerSigningPublicKey: "not json" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, peerSigningPublicKey: "hello" })).toBeNull(); + // A real private-JWK spelling: plain record, carries the `d` member. + expect( + parseNodeCommandBody({ + ...relayOpenCmd, + peerSigningPublicKey: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB","d":"pr1v4t3"}', + }), + ).toBeNull(); + // JSON scalars and arrays are not records. + expect(parseNodeCommandBody({ ...relayOpenCmd, peerSigningPublicKey: "null" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, peerSigningPublicKey: "[1,2]" })).toBeNull(); + // The public shape (the fixture) still parses, and a public JWK with + // EXTRA public members is still a record. + expect( + parseNodeCommandBody({ + ...relayOpenCmd, + peerSigningPublicKey: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB","use":"sig"}', + }), + ).not.toBeNull(); + }); + + it("rebuilds the open from validated fields; stray wire members do not ride", () => { + const parsed = parseNodeCommandBody({ ...relayOpenCmd, sneaky: { should: "not pass" } }); + expect(parsed).not.toBeNull(); + expect(parsed).not.toHaveProperty("sneaky"); + expect(parsed).toEqual(relayOpenCmd); + }); + + it("refuses fingerprint entries outside the SHA256 display form", () => { + // The tight form is what stops control chars (and plain junk) reaching a + // future audit row through a field that promises to be a fingerprint. + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: ["ok"] })).toBeNull(); // no prefix + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: ["SHA256:"] })).toBeNull(); // empty digest + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: ["SHA256:AAA\tBBB"] })).toBeNull(); // control char + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: [`SHA256:${"A".repeat(129)}`] })).toBeNull(); // over the bound + expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: [`SHA256:${"a-_0".repeat(32)}`] })).not.toBeNull(); // alphabet, at bound + }); + + it("narrows the close: routing ref and a NAMED reason from the typed union, nothing else", () => { + // The census members are the ONLY accepted spellings; each round-trips. + for (const reason of SSH_RELAY_CLOSE_REASONS) { + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a", reason })).toEqual({ + type: "ssh_relay_close", + ref: "r-4f2a", + reason, + }); + } + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a", reason: "grant-revoked" })).toEqual({ type: "ssh_relay_close", ref: "r-4f2a", - reason: "grant_revoked", + reason: "grant-revoked", }); + // An unknown reason is a malformed close: §5.1's "closed with a named + // reason" cannot hold if the grammar accepts any string. + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a", reason: "because-i-said-so" })).toBeNull(); + // The old underscore spellings are not the names either. + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a", reason: "grant_revoked" })).toBeNull(); expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-4f2a" })).toBeNull(); // reason required - expect(parseNodeCommandBody({ type: "ssh_relay_close", reason: "lifetime_expired" })).toBeNull(); // ref required - expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "", reason: "child_exit" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_relay_close", reason: "lifetime-expiry" })).toBeNull(); // ref required + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "", reason: "child-exit" })).toBeNull(); expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r", reason: "" })).toBeNull(); expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r", reason: 3 })).toBeNull(); + // A stray member on a valid close does not ride (the open arm's rebuild + // style; the close always rebuilt, and it still drops the extra). + expect(parseNodeCommandBody({ type: "ssh_relay_close", ref: "r-1", reason: "a-dropped", extra: 1 })).toEqual({ + type: "ssh_relay_close", + ref: "r-1", + reason: "a-dropped", + }); }); }); diff --git a/packages/subshell-protocol/src/__tests__/relay-frames.test.ts b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts index 91ee2ee02..f61b33ebb 100644 --- a/packages/subshell-protocol/src/__tests__/relay-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { NODE_PROTOCOL_VERSION, parseNodeEvent, parseRelayFrame } from "../node-frames.js"; +import { + NODE_PROTOCOL_VERSION, + parseNodeEvent, + parseRelayFrame, + relayFrameOverCap, + relayFrameRefIfOverCap, +} from "../node-frames.js"; import { SSH_RELAY_FRAME_MAX_BYTES } from "../ssh-limits.js"; /** @@ -73,6 +79,33 @@ describe("parseRelayFrame", () => { expect(parseRelayFrame(JSON.stringify([good]))).toBeNull(); // an array is not a record either }); + it("parseRelayFrame stays STRICT about over-cap, and the over-cap probe names it (Task 4 review)", () => { + // The strict parse answers an over-cap blob with the same null as junk, + // which keeps the NodeEvent union safe but is invisible to the future + // broker (§5.1 owes the refusal a named close). relayFrameOverCap is the + // tiebreaker: true ONLY for a well-formed candidate whose decoded blob + // is over the cap; the blob is measured, never decoded. + const over = { ...good, blob: b64OfRawLength(SSH_RELAY_FRAME_MAX_BYTES + 1) }; + expect(parseRelayFrame(over)).toBeNull(); // strict, unchanged + expect(relayFrameOverCap(over)).toBe(true); + expect(relayFrameRefIfOverCap(over)).toBe("r-4f2a"); // the ref a named close needs + expect(relayFrameOverCap(JSON.stringify(over))).toBe(true); // raw-text intake + // At the cap is lawful, not over-cap. + expect(relayFrameOverCap({ ...good, blob: b64OfRawLength(SSH_RELAY_FRAME_MAX_BYTES) })).toBe(false); + expect(relayFrameRefIfOverCap({ ...good, blob: b64OfRawLength(SSH_RELAY_FRAME_MAX_BYTES) })).toBeNull(); + // A malformed shape is NOT an over-cap, even when its blob is huge: the + // probe follows the same shape checks as the strict parse, in order. + expect(relayFrameOverCap({ ...over, direction: "sideways" })).toBe(false); + expect(relayFrameOverCap({ ...over, seq: -1 })).toBe(false); + expect(relayFrameOverCap({ ...over, ref: "" })).toBe(false); + expect(relayFrameOverCap({ ...over, blob: "!!!" })).toBe(false); + // Junk answers false/false, never an error. + expect(relayFrameOverCap("nope")).toBe(false); + expect(relayFrameRefIfOverCap("nope")).toBeNull(); + expect(relayFrameOverCap(null)).toBe(false); + expect(relayFrameOverCap(good)).toBe(false); + }); + it("the relay kind joins the node-event narrowing (the plane's one inbound parse)", () => { // Every relay frame the PLANE sees arrives on the agent-to-plane inbound // path, which dispatches through parseNodeEvent; the kind must come back diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index c3ccb2c85..b2f24c4ff 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -115,6 +115,8 @@ export { partPathOf, type RelayDirection, type RelayFrame, + relayFrameOverCap, + relayFrameRefIfOverCap, } from "./node-frames.js"; export { type DetectResultWire, @@ -249,10 +251,12 @@ export { export { parseSshNodeCommandBody, SSH_COMMAND_TYPES, + SSH_RELAY_CLOSE_REASONS, type SshDiscoverAliasesCommand, type SshNodeCommandBody, type SshRegisterIdentityCommand, type SshRelayCloseCommand, + type SshRelayCloseReason, type SshRelayOpenCommand, type SshRelayRole, type SshResolveConfigCommand, diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 1d71a8f99..47ef83d1d 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1365,6 +1365,44 @@ export function parseRelayFrame(raw: string | object): RelayFrame | null { return { type: "relay", ref: value.ref, seq: value.seq as number, direction: value.direction, blob: value.blob }; } +/** + * The routing ref of a WELL-FORMED `relay` candidate (the same shape checks + * {@link parseRelayFrame} runs, in the same order) whose decoded blob + * EXCEEDS SSH_RELAY_FRAME_MAX_BYTES - i.e. exactly the one refusal reason + * `parseRelayFrame` answers with a plain null, which §5.1 says must end the + * session with a NAMED reason instead. The strict parse stays strict (the + * `NodeEvent` union must not carry an over-cap frame); this probe exists so + * the caller that just got a null back can tell "junk, drop quietly" from + * "over-cap, name the refusal". Only shape + the raw length of the blob are + * examined; the bytes themselves are never decoded or copied (§5.5's + * blindness). Junk of any kind answers null, never an error. + * + * @param value - candidate frame, raw JSON text or the already-parsed object + * @returns the routing ref when the candidate is relay-shaped AND over-cap, + * null for anything else (junk, malformed relay, under-cap) + */ +export function relayFrameRefIfOverCap(value: unknown): string | null { + let parsed: unknown = value; + if (typeof value === "string") { + try { + parsed = JSON.parse(value); + } catch { + return null; + } + } + if (!isRecord(parsed) || parsed.type !== "relay") return null; + if (!isStr(parsed.ref) || parsed.ref.length === 0) return null; + if (!isInt(parsed.seq) || (parsed.seq as number) < 0) return null; + if (parsed.direction !== "B2A" && parsed.direction !== "A2B") return null; + if (!isStr(parsed.blob) || parsed.blob.length === 0 || !BASE64_RE.test(parsed.blob)) return null; + return base64RawLength(parsed.blob) > SSH_RELAY_FRAME_MAX_BYTES ? parsed.ref : null; +} + +/** Whether `value` is a well-formed `relay` candidate carrying an over-cap blob. */ +export function relayFrameOverCap(value: unknown): boolean { + return relayFrameRefIfOverCap(value) !== null; +} + export type NodeEvent = | { type: "ready"; diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index 3e19810c1..4348f161d 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -10,9 +10,9 @@ * launch`, `ssh_input_control`) retired with that destination-execution * product, which never shipped; their `type` arms are therefore deleted, not * refused. The sealed agent-relay milestone (M2) adds three commands here: - * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node’s signing key reaches + * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node's signing key reaches * the plane inside a signed command on the existing node link), and the - * brokered pair `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane’s OPEN + * brokered pair `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane's OPEN * hands one machine the whole pairing, the CLOSE names why it ends). What * rides the link AFTER an open - the sealed blobs themselves - is not a * command at all: it is the `relay` link frame in `node-frames.ts`, which @@ -110,19 +110,43 @@ export interface SshRelayOpenCommand { lifetimeMs: number; } +/** + * Every reason a relay session may be closed with (spec 2026-10-08 §5.1/§5.6): + * §5.1's "closed with a named reason" is only kept if the grammar knows every + * name, so a close carrying anything else is malformed, not a fresh idea. + * - `handshake-grace`: the pairing's handshake window elapsed unanswered. + * - `child-exit`: the A-side agent child died; the session ends with it. + * - `lifetime-expiry`: SSH_RELAY_LIFETIME_MS ran out. + * - `a-dropped`: A's link dropped and no re-pair restored it. + * - `grant-revoked`: the grant underneath the session was revoked (§6.3). + * - `over-cap`: a frame over SSH_RELAY_FRAME_MAX_BYTES arrived (§5.1: cap + * is law; the refusal closes the session by name rather than silently). + */ +export const SSH_RELAY_CLOSE_REASONS = [ + "handshake-grace", + "child-exit", + "lifetime-expiry", + "a-dropped", + "grant-revoked", + "over-cap", +] as const; + +/** One named close reason; the runtime census is {@link SSH_RELAY_CLOSE_REASONS}. */ +export type SshRelayCloseReason = (typeof SSH_RELAY_CLOSE_REASONS)[number]; + /** * The brokered TEARDOWN (spec §5.1/§5.6): stop the session named by the - * routing ref and say why, in the open. The reason is a named cause - * (lifetime expiry, grant revoke, A drop, child exit, over-cap frame...) - * because §5.1's "closed with a named reason" is the whole failure-mode - * posture; an unnamed close is the silence this family refuses. + * routing ref and say why, in the open. The reason is a NAMED cause drawn + * from {@link SSH_RELAY_CLOSE_REASONS} because §5.1's "closed with a named + * reason" is the whole failure-mode posture; an unnamed close is the silence + * this family refuses. */ export interface SshRelayCloseCommand { type: "ssh_relay_close"; /** The routing ref of the session to tear down. */ ref: string; /** The named reason this session ends; relayed to the endpoint's log. */ - reason: string; + reason: SshRelayCloseReason; } /** Every SSH-family command body, as one union the {@link NodeCommandBody} union folds in. */ @@ -163,15 +187,28 @@ function isIdStr(value: unknown): value is string { } /** - * The grant's selected fingerprint set: strings, every one non-empty (an - * empty entry matches no key yet would ride a grant as a silent never-serve - * line), and at most {@link SSH_MAX_GRANT_FINGERPRINTS} - over-cap is a - * refusal at the grammar so the truncation nobody may silently perform is - * impossible by construction (§5.4). An EMPTY set parses: §5.4's - * "names no fingerprint, serves nothing" is a decision, not a defect. + * The OpenSSH display notation the grant stores: the `SHA256:` prefix over + * base64url digest text (43 chars in practice; bounded generously at 128). + * Shape, not truth - whether the digest matches a key is the responder's + * §5.4 enforcement. The alphabet rule is also what stops control characters + * reaching a future audit row through a field that promises to be a + * fingerprint. + */ +const GRANT_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9_-]{1,128}$/; + +/** + * The grant's selected fingerprint set: strings, every one in the `SHA256:` + * display form (an empty or free-text entry matches no key yet would ride a + * grant as a silent never-serve line), and at most + * {@link SSH_MAX_GRANT_FINGERPRINTS} - over-cap is a refusal at the grammar + * so the truncation nobody may silently perform is impossible by construction + * (§5.4). An EMPTY set parses: §5.4's "names no fingerprint, serves nothing" + * is a decision, not a defect. */ function isGrantFingerprints(value: unknown): value is string[] { - return isStrArray(value) && value.every((f) => f.length > 0) && value.length <= SSH_MAX_GRANT_FINGERPRINTS; + return ( + isStrArray(value) && value.every((f) => GRANT_FINGERPRINT_RE.test(f)) && value.length <= SSH_MAX_GRANT_FINGERPRINTS + ); } /** @@ -210,10 +247,23 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu ) { return null; } - // The signing half is a JSON public JWK - the grammar proves it is a - // non-empty string; importability and the private-part refusal are the - // server's gate, beside the one enroll and `ssh_register_identity` run. + // The signing half is a JSON public JWK. The shape gate mirrors + // `parseNodeSshIdentity` (node-results.ts) for the enroll answer: a + // non-empty string AND `JSON.parse` yielding a plain record - and it + // also refuses the shallow private-material tell (a top-level `d` + // member), so "hello", a JSON scalar/array, and a serialized PRIVATE + // JWK all read as malformed here. Deep private-material and key-validity + // refusal stays at `bytesOfJwk` on the node (Task 6), which is beside + // the one import; this gate keeps obvious nonsense out of the signed + // command's narrowed body. if (!isIdStr(value.peerSigningPublicKey)) return null; + let peerSigningJwk: unknown; + try { + peerSigningJwk = JSON.parse(value.peerSigningPublicKey); + } catch { + return null; + } + if (!isRecord(peerSigningJwk) || "d" in peerSigningJwk) return null; // The encryption half is a base64 key (the link pin's own spelling); a // present-but-undecodable one is malformed, not "the pin sorts it out". if (!isIdStr(value.peerEncryptPublicKey) || !BASE64_RE.test(value.peerEncryptPublicKey)) return null; @@ -221,14 +271,32 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu // Positive whole milliseconds; the VALUE's lawfulness (SSH_RELAY_LIFETIME_MS // as the ceiling the broker sends) is the broker's, this is the shape. if (!isInt(value.lifetimeMs) || (value.lifetimeMs as number) <= 0) return null; - return value as unknown as SshRelayOpenCommand; + // Rebuilt from the validated fields, never a cast of the candidate: a + // stray member on the wire drops here, exactly as the close arm drops. + return { + type: "ssh_relay_open", + relayId: value.relayId, + ref: value.ref, + role: value.role, + aNodeId: value.aNodeId, + bNodeId: value.bNodeId, + peerSigningPublicKey: value.peerSigningPublicKey, + peerEncryptPublicKey: value.peerEncryptPublicKey, + grantId: value.grantId, + fingerprints: [...value.fingerprints], + lifetimeMs: value.lifetimeMs as number, + }; + } + case "ssh_relay_close": { + // Ref + a reason from the NAMED set, and nothing else may ride it: the + // close selects a session, it does not amend one (§5.1/§5.6). Membership + // in SSH_RELAY_CLOSE_REASONS is the check - §5.1's "closed with a named + // reason" is worthless as a free string, so an unknown reason is a + // malformed close refused at the grammar. + if (!isIdStr(value.ref) || !isStr(value.reason)) return null; + if (!(SSH_RELAY_CLOSE_REASONS as readonly string[]).includes(value.reason)) return null; + return { type: "ssh_relay_close", ref: value.ref, reason: value.reason as SshRelayCloseReason }; } - case "ssh_relay_close": - // Ref + named reason, and nothing else may ride it: the close selects - // a session, it does not amend one (§5.1/§5.6). - return isIdStr(value.ref) && isIdStr(value.reason) - ? { type: "ssh_relay_close", ref: value.ref, reason: value.reason } - : null; default: return null; } From b03e73225b24d47242ee04c480cbd6416c4b79e5 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:34:28 -0700 Subject: [PATCH 22/92] feat(ssh-relay): sealed envelope codec with signed canonical payload + seq gate --- .../src/__tests__/ssh-relay.test.ts | 566 ++++++++++++++++++ packages/subshell-protocol/src/index.ts | 25 + packages/subshell-protocol/src/ssh-relay.ts | 544 +++++++++++++++++ 3 files changed, 1135 insertions(+) create mode 100644 packages/subshell-protocol/src/__tests__/ssh-relay.test.ts create mode 100644 packages/subshell-protocol/src/ssh-relay.ts diff --git a/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts b/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts new file mode 100644 index 000000000..74af5424d --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts @@ -0,0 +1,566 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { exportJWK, flattenedDecrypt, GeneralEncrypt, generateKeyPair, importJWK, SignJWT } from "jose"; +import { BASE64_RE } from "../guards.js"; +import { SSH_RELAY_FRAME_MAX_BYTES } from "../ssh-limits.js"; +import { base64UrlNoPad, base64UrlToBytes } from "../ssh-pin-store.js"; +import { + bindNonces, + canonicalizeRelayMessage, + newNonce, + openRelayEnvelope, + RELAY_SIG_ISSUER, + type RelayInnerMessage, + type RelayOwnIdentity, + type RelaySealFn, + type SealRelayEnvelopeInput, + SeqGate, + sealRelayEnvelope, + signRelayEnvelope, + verifyRelayEnvelope, +} from "../ssh-relay.js"; + +/** + * The relay envelope codec (spec 2026-10-08 §5.6): canonicalization binds the + * fields, the ES256 signature carries origin, the seal carries confidentiality, + * and the SeqGate carries anti-replay. The seal/open pair is INJECTED (the + * production one is `packages/mcp-core/src/crypto.ts`; this package must not + * import it, or the workspace cycle would break the build order and Metro). + * The doubles below mirror crypto.ts's jose recipe verbatim so the codec is + * exercised against the real wire shape: GeneralEncrypt ECDH-ES+A256KW / + * A256GCM with a plaintext recipient `kid`, and the flattened-slot open. + */ + +/* ---------------------------- key material ---------------------------- */ + +async function es256Jwks(): Promise<{ privateJwk: JsonWebKey; publicJwk: JsonWebKey }> { + const { privateKey, publicKey } = await generateKeyPair("ES256", { extractable: true }); + return { + privateJwk: (await exportJWK(privateKey)) as unknown as JsonWebKey, + publicJwk: (await exportJWK(publicKey)) as unknown as JsonWebKey, + }; +} + +async function ecdhStrings(): Promise<{ publicJwk: string; privateJwk: string }> { + // Same recipe as mcp-core's generateKeypair: P-256 ECDH-ES, JWK JSON strings. + const { publicKey, privateKey } = await generateKeyPair("ECDH-ES", { crv: "P-256", extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +/* ------------------------- seal/open doubles -------------------------- */ + +const sealImpl: RelaySealFn = async (text, recipients) => { + if (recipients.length === 0) throw new Error("seal requires at least one recipient"); + const ge = new GeneralEncrypt(new TextEncoder().encode(text)); + ge.setProtectedHeader({ alg: "ECDH-ES+A256KW", enc: "A256GCM" }); + for (const r of recipients) { + ge.addRecipient(await importJWK(JSON.parse(r.publicJwk), "ECDH-ES")).setUnprotectedHeader({ kid: r.principalId }); + } + const envelope = await ge.encrypt(); + return { envelope: JSON.stringify(envelope), recipientIds: recipients.map((r) => r.principalId) }; +}; + +const openImpl = async (envelope: string, own: RelayOwnIdentity): Promise => { + const env = JSON.parse(envelope) as { + protected?: string; + iv: string; + ciphertext: string; + tag: string; + recipients: { header?: { kid?: string }; encrypted_key: string }[]; + }; + const mine = (env.recipients ?? []).find((r) => r.header?.kid === own.principalId); + if (!mine) throw new Error(`envelope has no recipient slot for principal: ${own.principalId}`); + const { plaintext } = await flattenedDecrypt( + { + protected: env.protected, + header: mine.header, + encrypted_key: mine.encrypted_key, + iv: env.iv, + tag: env.tag, + ciphertext: env.ciphertext, + }, + await importJWK(JSON.parse(own.privateJwk), "ECDH-ES"), + ); + return new TextDecoder().decode(plaintext); +}; + +/* ----------------------------- a message ------------------------------ */ + +const NB = base64UrlNoPad(Uint8Array.from({ length: 16 }, (_, i) => i)); +const NA = base64UrlNoPad(Uint8Array.from({ length: 16 }, (_, i) => i + 16)); +const AGENT = base64UrlNoPad(Uint8Array.from([0x00, 0x00, 0x00, 0x0d])); + +function relayMessage(overrides: Partial = {}): RelayInnerMessage { + return { + relaySessionId: "relay-7d21", + routingRef: "rt-04a1", + direction: "B2A", + seq: 0, + nB: NB, + agentBytesB64: AGENT, + ...overrides, + }; +} + +/* ------------------------------ canonicalize -------------------------- */ + +describe("canonicalizeRelayMessage", () => { + it("is insertion-order independent (same logical message, two field orders)", () => { + const ordered = { + agentBytesB64: AGENT, + direction: "B2A", + nA: NA, + nB: NB, + relaySessionId: "relay-7d21", + routingRef: "rt-04a1", + seq: 3, + } satisfies RelayInnerMessage; + const shuffled = { + seq: 3, + routingRef: "rt-04a1", + nB: NB, + relaySessionId: "relay-7d21", + direction: "B2A", + agentBytesB64: AGENT, + nA: NA, + } satisfies RelayInnerMessage; + expect(canonicalizeRelayMessage(ordered)).toBe(canonicalizeRelayMessage(shuffled)); + // And the same again through a JSON round trip of the object itself. + expect(canonicalizeRelayMessage(JSON.parse(JSON.stringify(shuffled)))).toBe(canonicalizeRelayMessage(ordered)); + }); + + it("emits sorted-key JSON (the canonical spelling is fixed, not incidental)", () => { + const s = canonicalizeRelayMessage(relayMessage({ nA: NA, seq: 42 })); + expect(Object.keys(JSON.parse(s) as Record)).toEqual([ + "agentBytesB64", + "direction", + "nA", + "nB", + "relaySessionId", + "routingRef", + "seq", + ]); + // Fixed number formatting: an integer spells as itself, no exponent form. + expect(s).toContain('"seq":42'); + }); + + it("is change-sensitive on EVERY bound field", () => { + const base = canonicalizeRelayMessage(relayMessage()); + const variants: RelayInnerMessage[] = [ + relayMessage({ relaySessionId: "relay-7d22" }), + relayMessage({ routingRef: "rt-04a2" }), + relayMessage({ direction: "A2B" }), + relayMessage({ seq: 1 }), + relayMessage({ nB: NA }), // swapped nonce value + relayMessage({ nA: NA }), // nonce added (was absent) + relayMessage({ agentBytesB64: base64UrlNoPad(Uint8Array.from([0x00, 0x00, 0x00, 0x0b])) }), + relayMessage({ agentBytesB64: "" }), // empty is its own message + ]; + const strings = variants.map((v) => canonicalizeRelayMessage(v)); + for (const s of strings) expect(s).not.toBe(base); + // Pairwise distinct too - two mutations never collide. + const set = new Set([base, ...strings]); + expect(set.size).toBe(strings.length + 1); + }); + + it("binds nonce PRESENCE: nB-only, both, and neither are three different strings", () => { + const nBOnly = canonicalizeRelayMessage(relayMessage()); + const both = canonicalizeRelayMessage(relayMessage({ nA: NA })); + const neither = canonicalizeRelayMessage(relayMessage({ nB: undefined })); + expect(new Set([nBOnly, both, neither]).size).toBe(3); + expect(Object.keys(JSON.parse(neither) as Record)).not.toContain("nA"); + }); + + it("refuses malformed messages rather than canonicalizing junk into a signature", () => { + expect(() => canonicalizeRelayMessage(relayMessage({ direction: "C2D" as "B2A" }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ seq: 1.5 }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ seq: -1 }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ seq: Number.NaN }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ seq: 2 ** 53 }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ relaySessionId: "" }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ routingRef: "" }))).toThrow(); + // Standard-base64 spellings are NOT base64url: refused, not repaired. + expect(() => canonicalizeRelayMessage(relayMessage({ agentBytesB64: "ab+cd/ef==" }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ nB: NB.slice(0, 21) }))).toThrow(); // 15.75 bytes + expect(() => canonicalizeRelayMessage(relayMessage({ nA: `${NB}=` }))).toThrow(); // stray padding + }); +}); + +/* ------------------------------ bindNonces ---------------------------- */ + +describe("bindNonces", () => { + it("passes the present nonces through and omits the absent", () => { + expect(bindNonces()).toEqual({}); + expect(bindNonces(undefined, NB)).toEqual({ nB: NB }); + expect(bindNonces(NA, NB)).toEqual({ nA: NA, nB: NB }); + }); + it("refuses anything that is not a 128-bit base64url nonce", () => { + expect(() => bindNonces("short")).toThrow(); // too short to be 16 bytes + expect(() => bindNonces(`${NA}A`)).toThrow(); // 23 chars: no byte string is 16 and spells 23 + expect(() => bindNonces(undefined, `${NA.slice(0, 20)}+/`)).toThrow(); // standard-base64 chars + }); +}); + +/* -------------------------------- nonces ------------------------------ */ + +describe("newNonce", () => { + it("mints distinct 128-bit base64url nonces", () => { + const seen = new Set(); + for (let i = 0; i < 64; i++) { + const n = newNonce(); + expect(n).toMatch(/^[A-Za-z0-9_-]+$/); + expect(base64UrlToBytes(n)).toHaveLength(16); + seen.add(n); + } + expect(seen.size).toBe(64); + }); +}); + +/* --------------------------- sign and verify -------------------------- */ + +describe("signRelayEnvelope / verifyRelayEnvelope", () => { + it("a message signed by A verifies against A's pinned public key and yields the message back", async () => { + const a = await es256Jwks(); + const m = relayMessage(); + const jws = await signRelayEnvelope({ privateJwk: a.privateJwk, message: m }); + expect(jws.split(".")).toHaveLength(3); // compact JWS + const got = await verifyRelayEnvelope({ + jws, + publicJwk: a.publicJwk, + expect: { routingRef: m.routingRef, direction: m.direction, seq: m.seq }, + }); + expect(got).toEqual(m); + }); + + it("FAILS against a different pinned key (a swapped machine key is a refused origin)", async () => { + const a = await es256Jwks(); + const b = await es256Jwks(); + const jws = await signRelayEnvelope({ privateJwk: a.privateJwk, message: relayMessage() }); + await expect( + verifyRelayEnvelope({ + jws, + publicJwk: b.publicJwk, + expect: { routingRef: "rt-04a1", direction: "B2A", seq: 0 }, + }), + ).rejects.toThrow(); + }); + + it("flipping a bound field AFTER signing fails verify (signature covers the canonical string)", async () => { + const a = await es256Jwks(); + const jws = await signRelayEnvelope({ privateJwk: a.privateJwk, message: relayMessage() }); + const [header, payload, signature] = jws.split("."); + // Flip the last payload character to another alphabet character: the + // segment still base64url-decodes, but the canonical string inside the + // signed bytes changes, so the signature no longer covers the message. + const last = payload[payload.length - 1]; + const tamperedPayload = `${payload.slice(0, -1)}${last === "A" ? "B" : "A"}`; + const tampered = `${header}.${tamperedPayload}.${signature}`; + await expect( + verifyRelayEnvelope({ + jws: tampered, + publicJwk: a.publicJwk, + expect: { routingRef: "rt-04a1", direction: "B2A", seq: 0 }, + }), + ).rejects.toThrow(); + // And a wrapper claiming the UNtampered routing triple fails the bind: + // verify must refuse a JWS whose canonical fields differ from the wrapper's. + const m2 = relayMessage({ seq: 5 }); + const jws2 = await signRelayEnvelope({ privateJwk: a.privateJwk, message: m2 }); + await expect( + verifyRelayEnvelope({ + jws: jws2, + publicJwk: a.publicJwk, + expect: { routingRef: "rt-04a1", direction: "B2A", seq: 0 }, + }), + ).rejects.toThrow(); + }); + + it("requires the relay issuer claim and the ES256 alg (header/claim swaps refused)", async () => { + const a = await es256Jwks(); + const m = relayMessage(); + // A well-signed JWS WITHOUT our iss claim: right crypto, wrong document. + const key = (await importJWK( + { ...(a.privateJwk as object), kty: "EC", crv: "P-256" } as never, + "ES256", + )) as CryptoKey; + const noIss = await new SignJWT({ msg: canonicalizeRelayMessage(m) }) + .setProtectedHeader({ alg: "ES256", typ: "JWT" }) + .sign(key); + await expect( + verifyRelayEnvelope({ + jws: noIss, + publicJwk: a.publicJwk, + expect: { routingRef: m.routingRef, direction: m.direction, seq: m.seq }, + }), + ).rejects.toThrow(); + // A valid EdDSA signature over the same claims, presented to the ES256 pin: + // the alg allow-list refuses it before the key is even consulted. + const ed = await generateKeyPair("EdDSA", { extractable: true }); + const edPrivate = ed.privateKey as unknown as CryptoKey; + const swappedAlg = await new SignJWT({ iss: RELAY_SIG_ISSUER, msg: canonicalizeRelayMessage(m) }) + .setProtectedHeader({ alg: "EdDSA", typ: "JWT" }) + .sign(edPrivate); + await expect( + verifyRelayEnvelope({ + jws: swappedAlg, + publicJwk: a.publicJwk, + expect: { routingRef: m.routingRef, direction: m.direction, seq: m.seq }, + }), + ).rejects.toThrow(); + }); + + it("refuses malformed JWS input without throwing anything uncatchable", async () => { + const a = await es256Jwks(); + for (const junk of ["", "not.a.jws", "a.b.c", `${"x".repeat(8)}.${"y".repeat(8)}.${"z".repeat(8)}`]) { + await expect( + verifyRelayEnvelope({ + jws: junk, + publicJwk: a.publicJwk, + expect: { routingRef: "r", direction: "B2A", seq: 0 }, + }), + ).rejects.toThrow(); + } + }); +}); + +/* -------------------------------- SeqGate ----------------------------- */ + +describe("SeqGate", () => { + it("accepts 0,1,2 per direction and rejects an equal or lower seq", () => { + const gate = new SeqGate(); + expect(gate.accept("B2A", 0)).toBe(true); // the first accepted value may be 0 + expect(gate.accept("B2A", 1)).toBe(true); + expect(gate.accept("B2A", 2)).toBe(true); + expect(gate.accept("B2A", 2)).toBe(false); // resend + expect(gate.accept("B2A", 1)).toBe(false); // rewind + expect(gate.accept("B2A", 0)).toBe(false); + }); + + it("gaps are legal (the gate is monotonic, not contiguous)", () => { + const gate = new SeqGate(); + expect(gate.accept("A2B", 0)).toBe(true); + expect(gate.accept("A2B", 7)).toBe(true); + expect(gate.accept("A2B", 6)).toBe(false); + }); + + it("tracks the two directions independently", () => { + const gate = new SeqGate(); + expect(gate.accept("B2A", 0)).toBe(true); + expect(gate.accept("B2A", 1)).toBe(true); + expect(gate.accept("A2B", 0)).toBe(true); // A2B starts fresh at 0 though B2A is at 1 + expect(gate.accept("A2B", 1)).toBe(true); + expect(gate.accept("B2A", 0)).toBe(false); // B2A's own history still refuses + expect(gate.accept("A2B", 2)).toBe(true); + }); + + it("rejects non-integers and negatives outright", () => { + const gate = new SeqGate(); + expect(gate.accept("B2A", -1)).toBe(false); + expect(gate.accept("B2A", 0.5)).toBe(false); + expect(gate.accept("B2A", Number.NaN)).toBe(false); + expect(gate.accept("B2A", Number.POSITIVE_INFINITY)).toBe(false); + expect(gate.accept("B2A", 0)).toBe(true); // and the gate still works afterwards + }); +}); + +/* --------------------------- sealed envelope -------------------------- */ + +describe("sealRelayEnvelope / openRelayEnvelope", () => { + it("round-trips: open yields the JWS + routing fields, and it verifies to the original message", async () => { + const signer = await es256Jwks(); // B's signing key + const recipient = await ecdhStrings(); // A's pinned encryption key + const m = relayMessage({ nA: NA, seq: 2 }); // both nonces, a later message + const blob = await sealRelayEnvelope({ + message: m, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: sealImpl, + }); + expect(blob).toMatch(BASE64_RE); // what RelayFrame.blob requires + const opened = await openRelayEnvelope({ + blob, + own: { principalId: "node:A", ...recipient }, + open: openImpl, + expect: { ref: m.routingRef, direction: m.direction }, + }); + expect(opened.routingRef).toBe(m.routingRef); + expect(opened.direction).toBe(m.direction); + expect(opened.seq).toBe(m.seq); + const got = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: signer.publicJwk, + expect: { routingRef: opened.routingRef, direction: opened.direction, seq: opened.seq }, + }); + expect(got).toEqual(m); + }); + + it("opening with the WRONG recipient key throws (same label, foreign keypair)", async () => { + const signer = await es256Jwks(); + const right = await ecdhStrings(); + const wrong = await ecdhStrings(); + const m = relayMessage(); + const blob = await sealRelayEnvelope({ + message: m, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: right.publicJwk }, + seal: sealImpl, + }); + await expect( + openRelayEnvelope({ + blob, + own: { principalId: "node:A", publicJwk: wrong.publicJwk, privateJwk: wrong.privateJwk }, + open: openImpl, + }), + ).rejects.toThrow(); + }); + + it("opening under the WRONG principal label throws even with the right key (no slot)", async () => { + const signer = await es256Jwks(); + const recipient = await ecdhStrings(); + const m = relayMessage(); + const blob = await sealRelayEnvelope({ + message: m, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: sealImpl, + }); + await expect( + openRelayEnvelope({ blob, own: { principalId: "node:B", ...recipient }, open: openImpl }), + ).rejects.toThrow(); + }); + + it("a tampered sealed blob throws", async () => { + const signer = await es256Jwks(); + const recipient = await ecdhStrings(); + const m = relayMessage(); + const blob = await sealRelayEnvelope({ + message: m, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: sealImpl, + }); + // Flip one alphabet character mid-blob (past the headers, inside the + // ciphertext region): base64 stays valid, the AEAD tag check must refuse. + const at = Math.floor(blob.length / 2); + const c = blob[at] === "A" ? "B" : "A"; + const tampered = `${blob.slice(0, at)}${c}${blob.slice(at + 1)}`; + await expect( + openRelayEnvelope({ blob: tampered, own: { principalId: "node:A", ...recipient }, open: openImpl }), + ).rejects.toThrow(); + }); + + it("refuses a blob that is not base64, and base64 that is not an envelope", async () => { + const recipient = await ecdhStrings(); + const own = { principalId: "node:A", ...recipient }; + await expect(openRelayEnvelope({ blob: "not base64!", own, open: openImpl })).rejects.toThrow(); + await expect(openRelayEnvelope({ blob: "", own, open: openImpl })).rejects.toThrow(); + await expect(openRelayEnvelope({ blob: base64OfText("hello"), own, open: openImpl })).rejects.toThrow(); + }); + + it("the sender refuses an envelope over SSH_RELAY_FRAME_MAX_BYTES (cap is law, both directions)", async () => { + const signer = await es256Jwks(); + const recipient = await ecdhStrings(); + const fat = relayMessage({ agentBytesB64: "A".repeat(SSH_RELAY_FRAME_MAX_BYTES) }); + await expect( + sealRelayEnvelope({ + message: fat, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: sealImpl, + }), + ).rejects.toThrow(/cap|SSH_RELAY_FRAME_MAX_BYTES|too large|over/i); + }); + + it("open cross-checks the wrapper against the frame it rode (ref/direction swap refused)", async () => { + const signer = await es256Jwks(); + const recipient = await ecdhStrings(); + const m = relayMessage(); + const blob = await sealRelayEnvelope({ + message: m, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: sealImpl, + }); + await expect( + openRelayEnvelope({ + blob, + own: { principalId: "node:A", ...recipient }, + open: openImpl, + expect: { ref: "rt-EVIL", direction: "B2A" }, + }), + ).rejects.toThrow(); + await expect( + openRelayEnvelope({ + blob, + own: { principalId: "node:A", ...recipient }, + open: openImpl, + expect: { ref: m.routingRef, direction: "A2B" }, + }), + ).rejects.toThrow(); + }); + + it("the sealed wire blob carries no plaintext of the agent bytes or the JWS", async () => { + const signer = await es256Jwks(); + const recipient = await ecdhStrings(); + const m = relayMessage(); + const blob = await sealRelayEnvelope({ + message: m, + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: sealImpl, + }); + expect(blob).not.toContain(m.agentBytesB64); + expect(atob(blob)).not.toContain("routingRef"); + }); + + it("sealRelayEnvelope refuses a malformed message before any crypto runs", async () => { + const signer = await es256Jwks(); + const recipient = await ecdhStrings(); + let sealCalls = 0; + const input: SealRelayEnvelopeInput = { + message: relayMessage({ seq: -1 }), + privateJwk: signer.privateJwk, + recipient: { principalId: "node:A", publicJwk: recipient.publicJwk }, + seal: async () => { + sealCalls += 1; + return { envelope: "", recipientIds: [] }; + }, + }; + await expect(sealRelayEnvelope(input)).rejects.toThrow(/non-negative safe integer/); + expect(sealCalls).toBe(0); + }); +}); + +/* ------------------------------ Metro purity -------------------------- */ + +describe("ssh-relay.ts is Metro-safe", () => { + it("has no node: builtin import in CODE", () => { + // Same pin as ssh-pin-store.test.ts: comments carry "node:" as prose, so + // the scanned text is the code with comments stripped. + const source = readFileSync(new URL("../ssh-relay.ts", import.meta.url), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + expect(source).not.toMatch(/from\s+["']node:/); + expect(source).not.toMatch(/import\s*\(?\s*["']node:/); + expect(source).not.toMatch(/require\(\s*["']node:/); + expect(source).not.toMatch(/\bBuffer\b/); + expect(source).not.toMatch(/await\s+import\(/); + }); +}); + +/** Standard base64 of text, for the junk-blob test (kept out of the codec). */ +function base64OfText(text: string): string { + const bytes = new TextEncoder().encode(text); + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); + out += alphabet[(n >> 18) & 63] + alphabet[(n >> 12) & 63]; + out += i + 1 < bytes.length ? alphabet[(n >> 6) & 63] : "="; + out += i + 2 < bytes.length ? alphabet[n & 63] : "="; + } + return out; +} diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index b2f24c4ff..ec9d76f11 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -277,6 +277,31 @@ export { // PURE module (WebCrypto globals only, no node: builtins): safe in the // Metro-consumed barrel; pinned by __tests__/ssh-pin-store.test.ts. export { base64UrlNoPad, bytesOfJwk, fingerprintJwk, type PublicJwkInput } from "./ssh-pin-store.js"; +// PURE module (jose + WebCrypto globals, no node: builtins; seal/open are +// injected, never imported - see the file header): safe in the Metro barrel; +// pinned by __tests__/ssh-relay.test.ts. +export { + bindNonces, + canonicalizeRelayMessage, + newNonce, + type OpenRelayEnvelopeInput, + openRelayEnvelope, + RELAY_SIG_ISSUER, + type RelayBinding, + type RelayEnvelopeOpen, + type RelayInnerMessage, + type RelayOpenFn, + type RelayOwnIdentity, + type RelaySealFn, + type RelaySealRecipient, + type SealRelayEnvelopeInput, + SeqGate, + type SignRelayEnvelopeInput, + sealRelayEnvelope, + signRelayEnvelope, + type VerifyRelayEnvelopeInput, + verifyRelayEnvelope, +} from "./ssh-relay.js"; export type { NodeSshAliasListResult, NodeSshIdentityResult, diff --git a/packages/subshell-protocol/src/ssh-relay.ts b/packages/subshell-protocol/src/ssh-relay.ts new file mode 100644 index 000000000..72ddf8dce --- /dev/null +++ b/packages/subshell-protocol/src/ssh-relay.ts @@ -0,0 +1,544 @@ +import { compactVerify, importJWK, type JWK, type JWTPayload, SignJWT } from "jose"; +import { BASE64_RE, isRecord, isStr } from "./guards.js"; +import type { RelayDirection } from "./node-frames.js"; +import { SSH_RELAY_FRAME_MAX_BYTES } from "./ssh-limits.js"; +import { base64UrlNoPad, base64UrlToBytes } from "./ssh-pin-store.js"; + +/** + * The sealed agent relay's envelope codec (spec 2026-10-08 §5.6). One ssh-agent + * message crosses the relay as a PURE data flow: + * + * message -> canonicalize (fixed sorted-key JSON, binds every field) + * -> sign (ES256 compact JWS over the canonical string; + * ORIGIN - §5.6: confidentiality is not authenticity) + * -> wrap ({ jws, routingRef, direction, seq } - the fields + * the receiver reads FIRST to locate the session, + * sealed so the plane never sees them either) + * -> seal (the injected mcp-core recipe: ECDH-ES+A256KW / + * A256GCM to the recipient's PINNED encryption key) + * -> base64 (the strict padded spelling RelayFrame.blob wants) + * + * `open` yields the JWS + routing fields, `verifyRelayEnvelope` binds the rest, + * and the receiver's {@link SeqGate} refuses resends and rewinds. The agent + * payload never leaves the signed-and-sealed inner: the plane routes blobs. + * + * seal/open are INJECTED, not imported: the production pair lives in + * `packages/mcp-core/src/crypto.ts`, which depends on this package - importing + * it back would be a workspace cycle (and Metro cannot see mcp-core at all). + * {@link RelaySealFn}/{@link RelayOpenFn} are mcp-core's exact signatures, so + * Tasks 6/7 pass `seal`/`open` straight through. The pin byte-equality check + * that produces those keys is the node-side MachinePinStore (§4.4), the + * caller's side of honesty; this codec never touches storage. + * + * PURE and Metro-safe: no `node:` builtin import (pinned by test); all crypto + * goes through jose (Bun's WebCrypto lacks EC importKey/exportKey - the same + * gap node-signing.ts papers over, so key import mirrors THAT file) and the + * WebCrypto globals `crypto.getRandomValues` / `crypto.subtle`. + */ + +/** `iss` claim every relay signature carries; the codec refuses any other. */ +export const RELAY_SIG_ISSUER = "subshell-ssh-relay"; + +/** Session nonces are 128 bits, base64url-spelled (spec §5.6). */ +const NONCE_BYTES = 16; + +/** The canonical message's field census, in the emission order (§5.6). */ +const MESSAGE_FIELDS = ["agentBytesB64", "direction", "nA", "nB", "relaySessionId", "routingRef", "seq"] as const; + +/** + * The signed inner message (spec §5.6): the signature covers a canonicalized + * encoding of EXACTLY these fields, so tampering any one of them - or moving + * an envelope to another session, direction, or slot - fails verification. + */ +export interface RelayInnerMessage { + /** The relay-session id the plane minted (closes cross-session replay) */ + relaySessionId: string; + /** The opaque routing ref this session's frames ride (binds envelope to lane) */ + routingRef: string; + /** Which way this message travels inside its session */ + direction: RelayDirection; + /** The SIGNED anti-replay seq: strictly increasing per direction, first may be 0 */ + seq: number; + /** B's endpoint nonce; present once B has sent (always in B2A, both in A2B) */ + nB?: string; + /** A's endpoint nonce; absent in B's very first message (A has not spoken yet) */ + nA?: string; + /** The agent payload, canonical base64url (no padding) - opaque to the codec */ + agentBytesB64: string; +} + +/** The sealed inner the wire carries: the JWS plus the locate-first fields. */ +export interface RelayEnvelopeOpen { + /** The compact JWS whose canonical payload binds everything else */ + jws: string; + /** Wrapper copy of the routing ref - cross-checked against the signature */ + routingRef: string; + /** Wrapper copy of the direction - cross-checked against the signature */ + direction: RelayDirection; + /** Wrapper copy of the SIGNED seq - the value the SeqGate judges */ + seq: number; +} + +/** A recipient to seal to: label + PUBLIC encryption JWK as a JSON string. */ +export interface RelaySealRecipient { + /** The address label sealed as the JWE recipient `kid` (e.g. `node:`) */ + principalId: string; + /** Public JWK JSON of the recipient's PINNED ECDH-ES encryption key */ + publicJwk: string; +} + +/** The opener's own identity: label + BOTH encryption JWK halves as strings. */ +export interface RelayOwnIdentity { + /** The principal label whose recipient slot to open (must match the `kid`) */ + principalId: string; + /** Public JWK JSON (kept for symmetry with mcp-core's IdentityKeyPair) */ + publicJwk: string; + /** Private JWK JSON - stays in the local process, like every private half */ + privateJwk: string; +} + +/** mcp-core `crypto.ts` seal's signature (structurally assignable to it). */ +export type RelaySealFn = ( + text: string, + recipients: RelaySealRecipient[], +) => Promise<{ envelope: string; recipientIds: string[] }>; + +/** mcp-core `crypto.ts` open's signature (throws on foreign/tampered input). */ +export type RelayOpenFn = (envelope: string, own: RelayOwnIdentity) => Promise; + +/** Inputs to {@link signRelayEnvelope}. */ +export interface SignRelayEnvelopeInput { + /** The sender's ES256 signing private JWK (the §4.1 per-machine key) */ + privateJwk: JsonWebKey; + /** The message to canonicalize and sign */ + message: RelayInnerMessage; +} + +/** The routing triple a verifier cross-checks the signature against. */ +export interface RelayBinding { + routingRef: string; + direction: RelayDirection; + seq: number; +} + +/** Inputs to {@link verifyRelayEnvelope}. */ +export interface VerifyRelayEnvelopeInput { + /** The compact JWS from the opened wrapper */ + jws: string; + /** The sender's PINNED ES256 signing public key (the §4.4 machine pin) */ + publicJwk: JsonWebKey; + /** + * The wrapper's locate-first fields. Every provided member MUST equal what + * the signature says, so a sealed-but-hostile wrapper cannot pair one + * session's ref with another session's signed bytes. + */ + expect?: Partial; +} + +/** Inputs to {@link sealRelayEnvelope}. */ +export interface SealRelayEnvelopeInput { + /** The message to canonicalize, sign, wrap, and seal */ + message: RelayInnerMessage; + /** The sender's ES256 signing private JWK */ + privateJwk: JsonWebKey; + /** The recipient's PINNED encryption key and its address label */ + recipient: RelaySealRecipient; + /** The sealing primitive (mcp-core's `seal`; see the file header) */ + seal: RelaySealFn; +} + +/** Inputs to {@link openRelayEnvelope}. */ +export interface OpenRelayEnvelopeInput { + /** The RelayFrame blob: strict padded base64 of the sealed envelope */ + blob: string; + /** THIS machine's encryption identity (the slot `kid` to open) */ + own: RelayOwnIdentity; + /** The opening primitive (mcp-core's `open`; see the file header) */ + open: RelayOpenFn; + /** + * The frame's own routing facts. When given, the wrapper must agree with + * them - a frame relayed under ref X cannot deliver an envelope addressed + * through ref Y, checked before any key material is consulted. + */ + expect?: { ref: string; direction: RelayDirection }; +} + +/** The EC (P-256) JWK shape jose's static types demand (mirrors node-signing). */ +type EcJwk = JWK & { kty: "EC"; crv: "P-256" }; + +/** Force a stored JWK into the EC/P-256 shape jose's types demand. */ +function asEcJwk(jwk: JsonWebKey): EcJwk { + return { ...(jwk as unknown as Record), kty: "EC", crv: "P-256" } as EcJwk; +} + +async function importPrivateES256(jwk: JsonWebKey): Promise { + return (await importJWK({ ...asEcJwk(jwk), d: jwk.d ?? "" }, "ES256")) as CryptoKey; +} + +async function importPublicES256(jwk: JsonWebKey): Promise { + return (await importJWK(asEcJwk(jwk), "ES256")) as CryptoKey; +} + +/* ------------------------------------------------------------------ */ +/* base64: the STANDARD (padded) spelling the wire's blob field uses */ +/* ------------------------------------------------------------------ */ + +const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +const BASE64_VALUES: number[] = (() => { + const values = new Array(128).fill(-1); + for (let i = 0; i < BASE64_ALPHABET.length; i++) { + values[BASE64_ALPHABET.charCodeAt(i)] = i; + } + return values; +})(); + +/** Encodes bytes as standard base64 WITH `=` padding (the blob's grammar). */ +function base64FromBytes(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const remaining = bytes.length - i; + const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); + out += BASE64_ALPHABET[(n >> 18) & 63]; + out += BASE64_ALPHABET[(n >> 12) & 63]; + out += remaining > 1 ? BASE64_ALPHABET[(n >> 6) & 63] : "="; + out += remaining > 2 ? BASE64_ALPHABET[n & 63] : "="; + } + return out; +} + +/** + * Decodes standard padded base64 to bytes. Callers pre-screen with + * BASE64_RE; this still refuses mid-text padding, non-alphabet bytes, and + * non-canonical trailing bits - junk is refused, never repaired. + */ +function base64ToBytes(text: string): Uint8Array { + if (text.length % 4 !== 0) throw new TypeError("ssh-relay: blob length is not a padded base64 group"); + let pad = 0; + while (pad < 2 && text[text.length - 1 - pad] === "=") pad += 1; + const body = text.slice(0, text.length - pad); + const out = new Uint8Array(Math.floor((body.length * 6) / 8)); + let bits = 0; + let value = 0; + let at = 0; + for (const ch of body) { + const v = BASE64_VALUES[ch.charCodeAt(0)]; + if (v < 0) throw new TypeError("ssh-relay: blob carries a non-base64 character"); + value = (value << 6) | v; + bits += 6; + if (bits >= 8) { + out[at++] = (value >>> (bits - 8)) & 0xff; + bits -= 8; + } + } + if (bits === 6 || (value & ((1 << bits) - 1)) !== 0) { + throw new TypeError("ssh-relay: blob has non-canonical trailing bits"); + } + return out; +} + +/* ------------------------------------------------------------------ */ +/* canonicalization (§5.6) */ +/* ------------------------------------------------------------------ */ + +/** Refuse anything that is not a canonical 128-bit base64url nonce. */ +function requireNonce(label: string, nonce: unknown): string { + if (!isStr(nonce)) throw new TypeError(`ssh-relay: ${label} is not a base64url string`); + let bytes: Uint8Array; + try { + bytes = base64UrlToBytes(nonce); + } catch { + throw new TypeError(`ssh-relay: ${label} is not canonical base64url`); + } + if (bytes.length !== NONCE_BYTES) { + throw new TypeError(`ssh-relay: ${label} must be ${NONCE_BYTES} bytes (got ${bytes.length})`); + } + return nonce; +} + +/** + * The nonce pair as the canonical message spells it (spec §5.6): `nB` alone + * in B's first message, both once each side has seen the peer's, never `nA` + * without `nB` (B speaks first - though the codec only binds what is present, + * the endpoints own that protocol fact). Validates through {@link newNonce}'s + * shape: 16 bytes, canonical base64url. + */ +export function bindNonces(nA?: string, nB?: string): { nA?: string; nB?: string } { + const bound: { nA?: string; nB?: string } = {}; + if (nA !== undefined) bound.nA = requireNonce("nA", nA); + if (nB !== undefined) bound.nB = requireNonce("nB", nB); + return bound; +} + +/** Validate a loose record as the closed inner-message grammar, or throw. */ +function assertRelayInnerMessage(raw: Record): RelayInnerMessage { + for (const key of Object.keys(raw)) { + if (!(MESSAGE_FIELDS as readonly string[]).includes(key) && raw[key] !== undefined) { + throw new TypeError(`ssh-relay: unknown relay message field: ${key}`); + } + } + if (!isStr(raw.relaySessionId) || raw.relaySessionId.length === 0) { + throw new TypeError("ssh-relay: relaySessionId must be a non-empty string"); + } + if (!isStr(raw.routingRef) || raw.routingRef.length === 0) { + throw new TypeError("ssh-relay: routingRef must be a non-empty string"); + } + if (raw.direction !== "B2A" && raw.direction !== "A2B") { + throw new TypeError("ssh-relay: direction must be B2A or A2B"); + } + if (!Number.isSafeInteger(raw.seq) || (raw.seq as number) < 0) { + throw new TypeError("ssh-relay: seq must be a non-negative safe integer"); + } + if (!isStr(raw.agentBytesB64)) { + throw new TypeError("ssh-relay: agentBytesB64 must be a base64url string"); + } + try { + base64UrlToBytes(raw.agentBytesB64); // "" (empty payload) is canonical and legal + } catch { + throw new TypeError("ssh-relay: agentBytesB64 is not canonical base64url"); + } + const message: RelayInnerMessage = { + agentBytesB64: raw.agentBytesB64, + direction: raw.direction, + relaySessionId: raw.relaySessionId, + routingRef: raw.routingRef, + seq: raw.seq as number, + }; + const { nA, nB } = bindNonces(raw.nA as string | undefined, raw.nB as string | undefined); + if (nA !== undefined) message.nA = nA; + if (nB !== undefined) message.nB = nB; + return message; +} + +/** + * The deterministic canonical encoding the signature covers: JSON with the + * fixed (alphabetically sorted) field order above, integers in plain spelling, + * optional nonces emitted only when present. JS key insertion order cannot + * reach it (fields are read by NAME into a fresh object), and changing ANY + * field changes the string, so the signature binds every one. + */ +export function canonicalizeRelayMessage(m: RelayInnerMessage): string { + const message = assertRelayInnerMessage(m as unknown as Record); + const canonical: Record = {}; + for (const field of MESSAGE_FIELDS) { + const value = message[field as keyof RelayInnerMessage]; + if (value !== undefined) canonical[field] = value; + } + return JSON.stringify(canonical); +} + +/** Re-derive the message from its canonical string; throws on anything else. */ +function parseCanonicalRelayMessage(canonical: string): RelayInnerMessage { + let raw: unknown; + try { + raw = JSON.parse(canonical); + } catch { + throw new TypeError("ssh-relay: canonical payload is not JSON"); + } + if (!isRecord(raw)) throw new TypeError("ssh-relay: canonical payload is not an object"); + const message = assertRelayInnerMessage(raw); + // The re-derivation must reproduce the verified bytes EXACTLY: an encoding + // that differs only in spelling (key order, spacing) never came from here. + if (canonicalizeRelayMessage(message) !== canonical) { + throw new TypeError("ssh-relay: canonical payload is not in canonical spelling"); + } + return message; +} + +/* ------------------------------------------------------------------ */ +/* origin: the ES256 signature (§5.6) */ +/* ------------------------------------------------------------------ */ + +/** + * Sign a relay message into a compact JWS: the canonical string rides as the + * single `msg` claim under our issuer. There is deliberately NO `exp` - the + * session's lifetime is the endpoints'/broker's cut (§5.6), and per-message + * expiry would make node clock skew an origin failure; replay is the seq + * gate's job, forgery is this signature's. + * + * @throws TypeError when the message is malformed (before any crypto runs) + */ +export async function signRelayEnvelope(input: SignRelayEnvelopeInput): Promise { + const key = await importPrivateES256(input.privateJwk); + return new SignJWT({ msg: canonicalizeRelayMessage(input.message) }) + .setProtectedHeader({ alg: "ES256", typ: "JWT" }) + .setIssuer(RELAY_SIG_ISSUER) + .sign(key); +} + +/** + * Verify a relay signature against the peer's PINNED signing key and return + * the message the canonical string spells. Every failure THROWS - callers + * treat any throw as origin refusal (the machine pin, not this codec, is what + * made `publicJwk` trustworthy). jose v6's `compactVerify` checks crypto and + * format only, so the `iss` claim is evaluated here, and `algorithms` pins + * ES256 so a header swap can never downgrade the check (node-signing's rule, + * verbatim). When `expect` is given, the wrapper's routing fields must match + * what the signature says - a mismatch is a refused binding, not a soft hint. + */ +export async function verifyRelayEnvelope(input: VerifyRelayEnvelopeInput): Promise { + let payload: JWTPayload; + try { + const key = await importPublicES256(input.publicJwk); + const verified = await compactVerify(input.jws, key, { algorithms: ["ES256"] }); + payload = JSON.parse(new TextDecoder().decode(verified.payload)) as JWTPayload; + } catch { + throw new TypeError("ssh-relay: relay envelope signature is invalid"); + } + if (payload.iss !== RELAY_SIG_ISSUER || typeof payload.msg !== "string") { + throw new TypeError("ssh-relay: relay envelope claims are invalid"); + } + const message = parseCanonicalRelayMessage(payload.msg); + const expect = input.expect; + if (expect) { + if (expect.routingRef !== undefined && expect.routingRef !== message.routingRef) { + throw new TypeError("ssh-relay: routingRef does not match the signature"); + } + if (expect.direction !== undefined && expect.direction !== message.direction) { + throw new TypeError("ssh-relay: direction does not match the signature"); + } + if (expect.seq !== undefined && expect.seq !== message.seq) { + throw new TypeError("ssh-relay: seq does not match the signature"); + } + } + return message; +} + +/* ------------------------------------------------------------------ */ +/* the sealed envelope: wrap + seal, open + bind */ +/* ------------------------------------------------------------------ */ + +/** + * Produce the wire blob for one relay message: canonicalize, sign, wrap as + * `{ jws, routingRef, direction, seq }`, and seal the wrapper to the + * recipient's PINNED encryption key. The returned string is strict padded + * base64 of the General JWE JSON - exactly what `RelayFrame.blob` carries - + * and is refused here (not on the wire) when it would exceed + * SSH_RELAY_FRAME_MAX_BYTES: the cap is law on both sides of the link. + * + * @throws TypeError on a malformed message, recipient label/key, or over-cap blob + */ +export async function sealRelayEnvelope(input: SealRelayEnvelopeInput): Promise { + const message = assertRelayInnerMessage(input.message as unknown as Record); + if (!isStr(input.recipient?.principalId) || input.recipient.principalId.length === 0) { + throw new TypeError("ssh-relay: recipient principalId must be a non-empty string"); + } + if (!isStr(input.recipient.publicJwk) || input.recipient.publicJwk.length === 0) { + throw new TypeError("ssh-relay: recipient publicJwk must be a non-empty string"); + } + const jws = await signRelayEnvelope({ privateJwk: input.privateJwk, message }); + const wrapper = JSON.stringify({ + jws, + routingRef: message.routingRef, + direction: message.direction, + seq: message.seq, + }); + const { envelope } = await input.seal(wrapper, [ + { principalId: input.recipient.principalId, publicJwk: input.recipient.publicJwk }, + ]); + const bytes = new TextEncoder().encode(envelope); + if (bytes.length > SSH_RELAY_FRAME_MAX_BYTES) { + throw new TypeError( + `ssh-relay: sealed envelope is ${bytes.length} bytes, over the SSH_RELAY_FRAME_MAX_BYTES cap of ${SSH_RELAY_FRAME_MAX_BYTES}`, + ); + } + return base64FromBytes(bytes); +} + +/** + * Open a relay frame's blob with THIS machine's encryption key and return the + * JWS + routing fields. Throws when the blob is not base64, holds no slot for + * `own.principalId`, is not addressed to us cryptographically, or is tampered + * (the injected `open` fails all of those); the wrapper's shape is validated + * before it is handed back. This does NOT verify origin - that is + * {@link verifyRelayEnvelope} with the sender's pinned signing key, then the + * caller's {@link SeqGate}. Anyone holding the recipient's PUBLIC encryption + * key can craft a seal the recipient opens (§5.6's first line), so an opened + * wrapper is unsigned noise until the signature says otherwise. + */ +export async function openRelayEnvelope(input: OpenRelayEnvelopeInput): Promise { + if (!isStr(input.blob) || input.blob.length === 0 || !BASE64_RE.test(input.blob)) { + throw new TypeError("ssh-relay: blob is not strict padded base64"); + } + const envelopeText = new TextDecoder().decode(base64ToBytes(input.blob)); + const wrapperText = await input.open(envelopeText, input.own); + let wrapper: unknown; + try { + wrapper = JSON.parse(wrapperText); + } catch { + throw new TypeError("ssh-relay: opened envelope holds no JSON wrapper"); + } + if (!isRecord(wrapper)) throw new TypeError("ssh-relay: opened envelope wrapper is not an object"); + const { jws, routingRef, direction, seq, ...rest } = wrapper; + if (Object.keys(rest).length > 0) { + throw new TypeError("ssh-relay: relay wrapper carries unknown fields"); + } + if (!isStr(jws) || jws.length === 0) throw new TypeError("ssh-relay: wrapper jws is not a non-empty string"); + if (!isStr(routingRef) || routingRef.length === 0) { + throw new TypeError("ssh-relay: wrapper routingRef is not a non-empty string"); + } + if (direction !== "B2A" && direction !== "A2B") { + throw new TypeError("ssh-relay: wrapper direction is not B2A or A2B"); + } + if (!Number.isSafeInteger(seq) || (seq as number) < 0) { + throw new TypeError("ssh-relay: wrapper seq is not a non-negative safe integer"); + } + if (input.expect) { + if (input.expect.ref !== routingRef) { + throw new TypeError("ssh-relay: wrapper routingRef does not match the frame's ref"); + } + if (input.expect.direction !== direction) { + throw new TypeError("ssh-relay: wrapper direction does not match the frame's"); + } + } + return { jws, routingRef, direction, seq: seq as number }; +} + +/* ------------------------------------------------------------------ */ +/* replay: the per-direction seq gate (§5.6) */ +/* ------------------------------------------------------------------ */ + +/** + * The per-direction anti-replay judge for ONE relay session (spec §5.6): the + * receiver keeps the highest accepted `seq` for each direction and accepts + * only a strictly greater one, so a multi-key handshake (several + * SIGN_REQUESTs, increasing seq) completes while a byte-identical resend or + * any rewind is refused - even though the plane routes every envelope and can + * resend any of them. The two directions are independent: B2A and A2B are + * different senders, and one's stream must not throttle the other. Built per + * session; seq restarts with the session (the signature binds the session id, + * so a stale envelope cannot ride a fresh session's gate). + */ +export class SeqGate { + #last: Record = { B2A: -1, A2B: -1 }; + + /** + * True iff `seq` advances this direction (first accepted value may be 0; + * gaps are legal, regressions and resends are not). Malformed seq (negative, + * fractional, non-finite) or an unknown direction answers false and changes + * nothing - a hostile frame must never move the watermark. + */ + accept(direction: RelayDirection, seq: number): boolean { + if (direction !== "B2A" && direction !== "A2B") return false; + if (!Number.isSafeInteger(seq) || seq < 0) return false; + if (seq <= this.#last[direction]) return false; + this.#last[direction] = seq; + return true; + } +} + +/* ------------------------------------------------------------------ */ +/* endpoint nonces (§5.6) */ +/* ------------------------------------------------------------------ */ + +/** + * A fresh 128-bit session nonce, canonical base64url (22 characters). Each + * endpoint mints ONE at session start - B carries `nB` in its first sealed + * request, A carries `nA` in its first sealed response - and thereafter signs + * the PEER's value into every message, so an envelope from a different pairing + * never validates, byte-identical payload or not. WebCrypto's + * `crypto.getRandomValues` is a global in Bun, node, and the browser alike. + */ +export function newNonce(): string { + return base64UrlNoPad(crypto.getRandomValues(new Uint8Array(NONCE_BYTES))); +} From 1059f82ec808d691eb7e20d2de99dbc9d0097b91 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:42:06 -0700 Subject: [PATCH 23/92] fix(ssh-relay): Task 5 review - structural nonce binding, required expect, base64 hardening + split --- .../src/__tests__/ssh-base64-std.test.ts | 94 ++++++++++++ .../src/__tests__/ssh-relay.test.ts | 88 ++++++++++- packages/subshell-protocol/src/index.ts | 2 + packages/subshell-protocol/src/node-frames.ts | 17 ++- .../subshell-protocol/src/ssh-base64-std.ts | 68 +++++++++ packages/subshell-protocol/src/ssh-relay.ts | 144 ++++++++---------- 6 files changed, 323 insertions(+), 90 deletions(-) create mode 100644 packages/subshell-protocol/src/__tests__/ssh-base64-std.test.ts create mode 100644 packages/subshell-protocol/src/ssh-base64-std.ts diff --git a/packages/subshell-protocol/src/__tests__/ssh-base64-std.test.ts b/packages/subshell-protocol/src/__tests__/ssh-base64-std.test.ts new file mode 100644 index 000000000..620d217c7 --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/ssh-base64-std.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { BASE64_RE } from "../guards.js"; +import { base64FromBytes, base64ToBytes } from "../ssh-base64-std.js"; + +/** + * The standard padded base64 codec split out of ssh-relay.ts (Task 5 review + * M-5). The pins below are the contract the relay wire depends on: what this + * admits must stay exactly what BASE64_RE admits (Task 4's `parseRelayFrame` + * measures the raw length of such strings for the frame cap), and everything + * else is refused by name, never repaired. + */ + +function bytesOf(text: string): Uint8Array { + return new TextEncoder().encode(text); +} + +describe("base64FromBytes", () => { + it("encodes the known vectors, padded", () => { + expect(base64FromBytes(new Uint8Array(0))).toBe(""); + expect(base64FromBytes(bytesOf("A"))).toBe("QQ=="); + expect(base64FromBytes(bytesOf("AB"))).toBe("QUI="); + expect(base64FromBytes(bytesOf("ABC"))).toBe("QUJD"); + expect(base64FromBytes(Uint8Array.from([0x00, 0x01, 0x02]))).toBe("AAEC"); + }); + + it("agrees with the platform encoder and BASE64_RE for every input length", () => { + for (let len = 0; len <= 20; len++) { + const bytes = Uint8Array.from({ length: len }, (_, i) => (i * 37) & 0xff); + const s = base64FromBytes(bytes); + expect(s).toMatch(BASE64_RE); // the blob grammar a relay frame must satisfy + // The same bytes through the Web-exposed encoder spell identically. + expect(s).toBe(btoa(String.fromCharCode(...Array.from(bytes)))); + } + }); +}); + +describe("base64ToBytes", () => { + it("round-trips what base64FromBytes emits", () => { + for (let len = 0; len <= 20; len++) { + const bytes = Uint8Array.from({ length: len }, (_, i) => (i * 91 + 7) & 0xff); + expect(Array.from(base64ToBytes(base64FromBytes(bytes)))).toEqual(Array.from(bytes)); + } + }); + + it("decodes the known vectors", () => { + expect(Array.from(base64ToBytes("QQ=="))).toEqual([0x41]); + expect(Array.from(base64ToBytes("QUI="))).toEqual([0x41, 0x42]); + expect(Array.from(base64ToBytes("QUJD"))).toEqual([0x41, 0x42, 0x43]); + expect(Array.from(base64ToBytes(""))).toEqual([]); + }); + + it("refuses non-group lengths, mid-text padding, and non-canonical trailing bits", () => { + expect(() => base64ToBytes("QQQ")).toThrow(/padded base64 group/); + expect(() => base64ToBytes("Q=Q=")).toThrow(/non-base64 character/); // "=" is not alphabet + expect(() => base64ToBytes("=AAA")).toThrow(/non-base64 character/); + expect(() => base64ToBytes("AB==")).toThrow(/non-canonical trailing bits/); // 10 bits for 1 byte + }); + + it("refuses ASCII junk by name", () => { + expect(() => base64ToBytes("A!BC")).toThrow(/non-base64 character/); + expect(() => base64ToBytes("A BC")).toThrow(/non-base64 character/); // a space (0x20) + expect(() => base64ToBytes("AB-_")).toThrow(/non-base64 character/); // base64URL is not standard + }); + + it("refuses a code point at or above U+0080 instead of corrupting (M-2)", () => { + // The table spans codes 0..127 only: before the guard these read as + // `undefined`, sailed past the `v < 0` screen, and poisoned `value` with + // NaN. Each string below is a padded 4-char group whose body holds one + // high code point, and each must throw. + expect(() => base64ToBytes("Q\u00e9BC")).toThrow(/non-base64 character/); // U+00E9 + expect(() => base64ToBytes("Q\u3042BC")).toThrow(/non-base64 character/); // U+3042 + expect(() => base64ToBytes("Q\u2028BC")).toThrow(/non-base64 character/); // U+2028 + }); + + it("refuses the U+0080 boundary itself inside a group", () => { + expect(() => base64ToBytes("Q\u0080BC")).toThrow(/non-base64 character/); + }); +}); + +describe("ssh-base64-std.ts is Metro-safe", () => { + it("has no node: builtin import in CODE", () => { + // Same pin as ssh-relay.test.ts: comments carry "node:" as prose, so + // the scanned text is the code with comments stripped. + const source = readFileSync(new URL("../ssh-base64-std.ts", import.meta.url), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + expect(source).not.toMatch(/from\s+["']node:/); + expect(source).not.toMatch(/import\s*\(?\s*["']node:/); + expect(source).not.toMatch(/require\(\s*["']node:/); + expect(source).not.toMatch(/\bBuffer\b/); + expect(source).not.toMatch(/await\s+import\(/); + }); +}); diff --git a/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts b/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts index 74af5424d..a47bb7b3c 100644 --- a/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts +++ b/packages/subshell-protocol/src/__tests__/ssh-relay.test.ts @@ -178,6 +178,7 @@ describe("canonicalizeRelayMessage", () => { expect(() => canonicalizeRelayMessage(relayMessage({ direction: "C2D" as "B2A" }))).toThrow(); expect(() => canonicalizeRelayMessage(relayMessage({ seq: 1.5 }))).toThrow(); expect(() => canonicalizeRelayMessage(relayMessage({ seq: -1 }))).toThrow(); + expect(() => canonicalizeRelayMessage(relayMessage({ seq: -0 }))).toThrow(); // -0 JSON-spells as 0: refused, not coerced (M-3) expect(() => canonicalizeRelayMessage(relayMessage({ seq: Number.NaN }))).toThrow(); expect(() => canonicalizeRelayMessage(relayMessage({ seq: 2 ** 53 }))).toThrow(); expect(() => canonicalizeRelayMessage(relayMessage({ relaySessionId: "" }))).toThrow(); @@ -312,6 +313,53 @@ describe("signRelayEnvelope / verifyRelayEnvelope", () => { ).rejects.toThrow(); }); + it("enforces the bound nonces (I-1: the endpoint-minted replay defense, enforced when supplied)", async () => { + const a = await es256Jwks(); + const m = relayMessage({ nA: NA }); // both nonces present + const jws = await signRelayEnvelope({ privateJwk: a.privateJwk, message: m }); + const base = { routingRef: m.routingRef, direction: m.direction, seq: m.seq }; + // Matching bindings pass, exactly like the routing triple does. + const got = await verifyRelayEnvelope({ + jws, + publicJwk: a.publicJwk, + expect: { ...base, nA: NA, nB: NB, relaySessionId: m.relaySessionId }, + }); + expect(got).toEqual(m); + // A different value for a bound nonce fails, in either spelling. + await expect(verifyRelayEnvelope({ jws, publicJwk: a.publicJwk, expect: { ...base, nB: NA } })).rejects.toThrow( + /nB does not match/, + ); + await expect(verifyRelayEnvelope({ jws, publicJwk: a.publicJwk, expect: { ...base, nA: NB } })).rejects.toThrow( + /nA does not match/, + ); + // A bound nonce the signed message does not carry is a mismatch too: + // B's first message signed without nA cannot satisfy a caller who bound one. + const mNoNA = relayMessage(); + const jwsNoNA = await signRelayEnvelope({ privateJwk: a.privateJwk, message: mNoNA }); + await expect( + verifyRelayEnvelope({ jws: jwsNoNA, publicJwk: a.publicJwk, expect: { ...base, nA: NA } }), + ).rejects.toThrow(/nA does not match/); + // An absent binding member enforces nothing (the caller may not hold it yet). + const loose = await verifyRelayEnvelope({ jws, publicJwk: a.publicJwk, expect: base }); + expect(loose).toEqual(m); + }); + + it("enforces the bound relaySessionId (a reused-ref envelope from another session is refused)", async () => { + const a = await es256Jwks(); + const m = relayMessage(); + const jws = await signRelayEnvelope({ privateJwk: a.privateJwk, message: m }); + const base = { routingRef: m.routingRef, direction: m.direction, seq: m.seq }; + await expect( + verifyRelayEnvelope({ jws, publicJwk: a.publicJwk, expect: { ...base, relaySessionId: "relay-EVIL" } }), + ).rejects.toThrow(/relaySessionId does not match/); + const got = await verifyRelayEnvelope({ + jws, + publicJwk: a.publicJwk, + expect: { ...base, relaySessionId: m.relaySessionId }, + }); + expect(got).toEqual(m); + }); + it("refuses malformed JWS input without throwing anything uncatchable", async () => { const a = await es256Jwks(); for (const junk of ["", "not.a.jws", "a.b.c", `${"x".repeat(8)}.${"y".repeat(8)}.${"z".repeat(8)}`]) { @@ -413,6 +461,7 @@ describe("sealRelayEnvelope / openRelayEnvelope", () => { blob, own: { principalId: "node:A", publicJwk: wrong.publicJwk, privateJwk: wrong.privateJwk }, open: openImpl, + expect: { ref: m.routingRef, direction: m.direction }, }), ).rejects.toThrow(); }); @@ -428,7 +477,12 @@ describe("sealRelayEnvelope / openRelayEnvelope", () => { seal: sealImpl, }); await expect( - openRelayEnvelope({ blob, own: { principalId: "node:B", ...recipient }, open: openImpl }), + openRelayEnvelope({ + blob, + own: { principalId: "node:B", ...recipient }, + open: openImpl, + expect: { ref: m.routingRef, direction: m.direction }, + }), ).rejects.toThrow(); }); @@ -448,16 +502,24 @@ describe("sealRelayEnvelope / openRelayEnvelope", () => { const c = blob[at] === "A" ? "B" : "A"; const tampered = `${blob.slice(0, at)}${c}${blob.slice(at + 1)}`; await expect( - openRelayEnvelope({ blob: tampered, own: { principalId: "node:A", ...recipient }, open: openImpl }), + openRelayEnvelope({ + blob: tampered, + own: { principalId: "node:A", ...recipient }, + open: openImpl, + expect: { ref: m.routingRef, direction: m.direction }, + }), ).rejects.toThrow(); }); it("refuses a blob that is not base64, and base64 that is not an envelope", async () => { const recipient = await ecdhStrings(); const own = { principalId: "node:A", ...recipient }; - await expect(openRelayEnvelope({ blob: "not base64!", own, open: openImpl })).rejects.toThrow(); - await expect(openRelayEnvelope({ blob: "", own, open: openImpl })).rejects.toThrow(); - await expect(openRelayEnvelope({ blob: base64OfText("hello"), own, open: openImpl })).rejects.toThrow(); + const binding = { ref: "rt-04a1", direction: "B2A" as const }; + await expect(openRelayEnvelope({ blob: "not base64!", own, open: openImpl, expect: binding })).rejects.toThrow(); + await expect(openRelayEnvelope({ blob: "", own, open: openImpl, expect: binding })).rejects.toThrow(); + await expect( + openRelayEnvelope({ blob: base64OfText("hello"), own, open: openImpl, expect: binding }), + ).rejects.toThrow(); }); it("the sender refuses an envelope over SSH_RELAY_FRAME_MAX_BYTES (cap is law, both directions)", async () => { @@ -532,6 +594,22 @@ describe("sealRelayEnvelope / openRelayEnvelope", () => { await expect(sealRelayEnvelope(input)).rejects.toThrow(/non-negative safe integer/); expect(sealCalls).toBe(0); }); + + it("open refuses a wrapper whose seq is spelled -0 (a hostile sender can write it; JSON.parse yields -0)", async () => { + const recipient = await ecdhStrings(); + // sealImpl takes arbitrary text: exactly what a hostile sender seals. + const { envelope } = await sealImpl('{"jws":"a.b.c","routingRef":"rt-04a1","direction":"B2A","seq":-0}', [ + { principalId: "node:A", publicJwk: recipient.publicJwk }, + ]); + await expect( + openRelayEnvelope({ + blob: base64OfText(envelope), + own: { principalId: "node:A", ...recipient }, + open: openImpl, + expect: { ref: "rt-04a1", direction: "B2A" }, + }), + ).rejects.toThrow(/non-negative safe integer/); + }); }); /* ------------------------------ Metro purity -------------------------- */ diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index ec9d76f11..8a4d1d276 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -72,6 +72,7 @@ export { type HarnessSessionWire, isNodeServiceVerb, isNodeSubshellId, + isRelayDirection, type LaunchResolveWire, MAX_ARCHIVE_BYTES, MAX_ARCHIVE_ENTRIES, @@ -113,6 +114,7 @@ export { parseNodeSshEnabled, parseRelayFrame, partPathOf, + RELAY_DIRECTIONS, type RelayDirection, type RelayFrame, relayFrameOverCap, diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 47ef83d1d..d5bf04075 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1288,8 +1288,19 @@ export type PluginReportWire = { /* sealed agent relay link frame (spec 2026-10-08 §5.1) */ /* ------------------------------------------------------------------ */ +/** + * The relay directions as a runtime list beside the type: one definition, and + * everything that iterates or validates reads these (code-style rule). + */ +export const RELAY_DIRECTIONS = ["B2A", "A2B"] as const; + /** Which way one relay envelope travels inside its session (spec §5.1). */ -export type RelayDirection = "B2A" | "A2B"; +export type RelayDirection = (typeof RELAY_DIRECTIONS)[number]; + +/** Whether `value` is one of the two relay directions (narrows to the type). */ +export function isRelayDirection(value: unknown): value is RelayDirection { + return (RELAY_DIRECTIONS as readonly unknown[]).includes(value); +} /** * The `relay` link frame: ONE sealed envelope per frame, on the established @@ -1359,7 +1370,7 @@ export function parseRelayFrame(raw: string | object): RelayFrame | null { if (!isRecord(value) || value.type !== "relay") return null; if (!isStr(value.ref) || value.ref.length === 0) return null; if (!isInt(value.seq) || (value.seq as number) < 0) return null; - if (value.direction !== "B2A" && value.direction !== "A2B") return null; + if (!isRelayDirection(value.direction)) return null; if (!isStr(value.blob) || value.blob.length === 0 || !BASE64_RE.test(value.blob)) return null; if (base64RawLength(value.blob) > SSH_RELAY_FRAME_MAX_BYTES) return null; return { type: "relay", ref: value.ref, seq: value.seq as number, direction: value.direction, blob: value.blob }; @@ -1393,7 +1404,7 @@ export function relayFrameRefIfOverCap(value: unknown): string | null { if (!isRecord(parsed) || parsed.type !== "relay") return null; if (!isStr(parsed.ref) || parsed.ref.length === 0) return null; if (!isInt(parsed.seq) || (parsed.seq as number) < 0) return null; - if (parsed.direction !== "B2A" && parsed.direction !== "A2B") return null; + if (!isRelayDirection(parsed.direction)) return null; if (!isStr(parsed.blob) || parsed.blob.length === 0 || !BASE64_RE.test(parsed.blob)) return null; return base64RawLength(parsed.blob) > SSH_RELAY_FRAME_MAX_BYTES ? parsed.ref : null; } diff --git a/packages/subshell-protocol/src/ssh-base64-std.ts b/packages/subshell-protocol/src/ssh-base64-std.ts new file mode 100644 index 000000000..0a51003a5 --- /dev/null +++ b/packages/subshell-protocol/src/ssh-base64-std.ts @@ -0,0 +1,68 @@ +/** + * The STANDARD (padded) base64 spelling the relay wire's `blob` field uses, + * as a pure module: no `node:` builtin, no `Buffer` (pinned by test, same + * regime as ssh-relay.ts, which imports this file through the Metro-consumed + * barrel). These are the exact helpers that lived inline in ssh-relay.ts; + * Task 5 review M-5 split them out to keep that file inside the size + * guideline. No logic change: the acceptance set stays BASE64_RE-compatible + * (Task 4's `parseRelayFrame` cap rule measures the raw payload of strings + * this grammar admits), and junk is refused, never repaired. + */ + +const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +const BASE64_VALUES: number[] = (() => { + const values = new Array(128).fill(-1); + for (let i = 0; i < BASE64_ALPHABET.length; i++) { + values[BASE64_ALPHABET.charCodeAt(i)] = i; + } + return values; +})(); + +/** Encodes bytes as standard base64 WITH `=` padding (the blob's grammar). */ +export function base64FromBytes(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const remaining = bytes.length - i; + const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); + out += BASE64_ALPHABET[(n >> 18) & 63]; + out += BASE64_ALPHABET[(n >> 12) & 63]; + out += remaining > 1 ? BASE64_ALPHABET[(n >> 6) & 63] : "="; + out += remaining > 2 ? BASE64_ALPHABET[n & 63] : "="; + } + return out; +} + +/** + * Decodes standard padded base64 to bytes. Callers pre-screen with + * BASE64_RE; this still refuses mid-text padding, non-alphabet bytes, and + * non-canonical trailing bits - junk is refused, never repaired. A code point + * at or above U+0080 is NOT in the table's range: it is refused by name + * (Task 5 review M-2 - reading past the table yields `undefined`, which + * `v < 0` would wave through and silently corrupt into NaN-poisoned bytes). + */ +export function base64ToBytes(text: string): Uint8Array { + if (text.length % 4 !== 0) throw new TypeError("ssh-relay: blob length is not a padded base64 group"); + let pad = 0; + while (pad < 2 && text[text.length - 1 - pad] === "=") pad += 1; + const body = text.slice(0, text.length - pad); + const out = new Uint8Array(Math.floor((body.length * 6) / 8)); + let bits = 0; + let value = 0; + let at = 0; + for (const ch of body) { + const code = ch.charCodeAt(0); + const v = code < 128 ? BASE64_VALUES[code] : -1; + if (v < 0) throw new TypeError("ssh-relay: blob carries a non-base64 character"); + value = (value << 6) | v; + bits += 6; + if (bits >= 8) { + out[at++] = (value >>> (bits - 8)) & 0xff; + bits -= 8; + } + } + if (bits === 6 || (value & ((1 << bits) - 1)) !== 0) { + throw new TypeError("ssh-relay: blob has non-canonical trailing bits"); + } + return out; +} diff --git a/packages/subshell-protocol/src/ssh-relay.ts b/packages/subshell-protocol/src/ssh-relay.ts index 72ddf8dce..bbd562f02 100644 --- a/packages/subshell-protocol/src/ssh-relay.ts +++ b/packages/subshell-protocol/src/ssh-relay.ts @@ -1,6 +1,7 @@ import { compactVerify, importJWK, type JWK, type JWTPayload, SignJWT } from "jose"; import { BASE64_RE, isRecord, isStr } from "./guards.js"; -import type { RelayDirection } from "./node-frames.js"; +import { isRelayDirection, type RelayDirection } from "./node-frames.js"; +import { base64FromBytes, base64ToBytes } from "./ssh-base64-std.js"; import { SSH_RELAY_FRAME_MAX_BYTES } from "./ssh-limits.js"; import { base64UrlNoPad, base64UrlToBytes } from "./ssh-pin-store.js"; @@ -16,7 +17,8 @@ import { base64UrlNoPad, base64UrlToBytes } from "./ssh-pin-store.js"; * sealed so the plane never sees them either) * -> seal (the injected mcp-core recipe: ECDH-ES+A256KW / * A256GCM to the recipient's PINNED encryption key) - * -> base64 (the strict padded spelling RelayFrame.blob wants) + * -> base64 (the strict padded spelling RelayFrame.blob wants; + * that codec lives in ssh-base64-std.ts) * * `open` yields the JWS + routing fields, `verifyRelayEnvelope` binds the rest, * and the receiver's {@link SeqGate} refuses resends and rewinds. The agent @@ -114,11 +116,26 @@ export interface SignRelayEnvelopeInput { message: RelayInnerMessage; } -/** The routing triple a verifier cross-checks the signature against. */ +/** + * The facts a verifier cross-checks the signature against: the routing + * triple the wrapper locates its session by, plus the session facts only the + * ENDPOINT knows (I-1, Task 5 review): when the caller holds a session, it + * supplies what it recorded and {@link verifyRelayEnvelope} enforces every + * present member. The codec owns none of these facts itself. + */ export interface RelayBinding { + /** The opaque routing ref the session's frames ride */ routingRef: string; + /** Which way the message travels inside the session */ direction: RelayDirection; + /** The signed anti-replay seq */ seq: number; + /** B's endpoint nonce as this endpoint recorded it for this relay-open */ + nB?: string; + /** A's endpoint nonce as this endpoint recorded it for this relay-open */ + nA?: string; + /** The relay-session id from the `ssh_relay_open` command */ + relaySessionId?: string; } /** Inputs to {@link verifyRelayEnvelope}. */ @@ -128,9 +145,12 @@ export interface VerifyRelayEnvelopeInput { /** The sender's PINNED ES256 signing public key (the §4.4 machine pin) */ publicJwk: JsonWebKey; /** - * The wrapper's locate-first fields. Every provided member MUST equal what - * the signature says, so a sealed-but-hostile wrapper cannot pair one - * session's ref with another session's signed bytes. + * The wrapper's locate-first fields and, once the caller holds session + * state, the nonces and relay-session id it recorded (see + * {@link RelayBinding}). Every provided member MUST equal what the + * signature says, so a sealed-but-hostile wrapper cannot pair one + * session's ref with another session's signed bytes, and an envelope + * carrying another pairing's nonce is refused the same way. */ expect?: Partial; } @@ -156,11 +176,15 @@ export interface OpenRelayEnvelopeInput { /** The opening primitive (mcp-core's `open`; see the file header) */ open: RelayOpenFn; /** - * The frame's own routing facts. When given, the wrapper must agree with - * them - a frame relayed under ref X cannot deliver an envelope addressed - * through ref Y, checked before any key material is consulted. + * The frame's own routing facts - REQUIRED (Task 5 review M-1: every real + * call site holds the frame). The wrapper must agree with them: a frame + * relayed under ref X cannot deliver an envelope addressed through ref Y, + * checked before any key material is consulted. The SIGNED anti-replay seq + * is deliberately absent: the frame's transport seq is a different number + * (Task 4's grammar), and the wrapper's is sealed inside and only known + * after opening - bind it with {@link verifyRelayEnvelope}'s `expect`. */ - expect?: { ref: string; direction: RelayDirection }; + expect: { ref: string; direction: RelayDirection }; } /** The EC (P-256) JWK shape jose's static types demand (mirrors node-signing). */ @@ -179,64 +203,6 @@ async function importPublicES256(jwk: JsonWebKey): Promise { return (await importJWK(asEcJwk(jwk), "ES256")) as CryptoKey; } -/* ------------------------------------------------------------------ */ -/* base64: the STANDARD (padded) spelling the wire's blob field uses */ -/* ------------------------------------------------------------------ */ - -const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -const BASE64_VALUES: number[] = (() => { - const values = new Array(128).fill(-1); - for (let i = 0; i < BASE64_ALPHABET.length; i++) { - values[BASE64_ALPHABET.charCodeAt(i)] = i; - } - return values; -})(); - -/** Encodes bytes as standard base64 WITH `=` padding (the blob's grammar). */ -function base64FromBytes(bytes: Uint8Array): string { - let out = ""; - for (let i = 0; i < bytes.length; i += 3) { - const remaining = bytes.length - i; - const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); - out += BASE64_ALPHABET[(n >> 18) & 63]; - out += BASE64_ALPHABET[(n >> 12) & 63]; - out += remaining > 1 ? BASE64_ALPHABET[(n >> 6) & 63] : "="; - out += remaining > 2 ? BASE64_ALPHABET[n & 63] : "="; - } - return out; -} - -/** - * Decodes standard padded base64 to bytes. Callers pre-screen with - * BASE64_RE; this still refuses mid-text padding, non-alphabet bytes, and - * non-canonical trailing bits - junk is refused, never repaired. - */ -function base64ToBytes(text: string): Uint8Array { - if (text.length % 4 !== 0) throw new TypeError("ssh-relay: blob length is not a padded base64 group"); - let pad = 0; - while (pad < 2 && text[text.length - 1 - pad] === "=") pad += 1; - const body = text.slice(0, text.length - pad); - const out = new Uint8Array(Math.floor((body.length * 6) / 8)); - let bits = 0; - let value = 0; - let at = 0; - for (const ch of body) { - const v = BASE64_VALUES[ch.charCodeAt(0)]; - if (v < 0) throw new TypeError("ssh-relay: blob carries a non-base64 character"); - value = (value << 6) | v; - bits += 6; - if (bits >= 8) { - out[at++] = (value >>> (bits - 8)) & 0xff; - bits -= 8; - } - } - if (bits === 6 || (value & ((1 << bits) - 1)) !== 0) { - throw new TypeError("ssh-relay: blob has non-canonical trailing bits"); - } - return out; -} - /* ------------------------------------------------------------------ */ /* canonicalization (§5.6) */ /* ------------------------------------------------------------------ */ @@ -283,10 +249,12 @@ function assertRelayInnerMessage(raw: Record): RelayInnerMessag if (!isStr(raw.routingRef) || raw.routingRef.length === 0) { throw new TypeError("ssh-relay: routingRef must be a non-empty string"); } - if (raw.direction !== "B2A" && raw.direction !== "A2B") { + if (!isRelayDirection(raw.direction)) { throw new TypeError("ssh-relay: direction must be B2A or A2B"); } - if (!Number.isSafeInteger(raw.seq) || (raw.seq as number) < 0) { + // `-0` passes the safe-integer and `< 0` screens yet JSON-spells as `0`, so + // it would smuggle a value that === 0 into every later comparison (M-3). + if (!Number.isSafeInteger(raw.seq) || (raw.seq as number) < 0 || Object.is(raw.seq, -0)) { throw new TypeError("ssh-relay: seq must be a non-negative safe integer"); } if (!isStr(raw.agentBytesB64)) { @@ -373,8 +341,10 @@ export async function signRelayEnvelope(input: SignRelayEnvelopeInput): Promise< * made `publicJwk` trustworthy). jose v6's `compactVerify` checks crypto and * format only, so the `iss` claim is evaluated here, and `algorithms` pins * ES256 so a header swap can never downgrade the check (node-signing's rule, - * verbatim). When `expect` is given, the wrapper's routing fields must match - * what the signature says - a mismatch is a refused binding, not a soft hint. + * verbatim). When `expect` is given, EVERY provided member (the routing + * triple, and - once the caller holds session state - the recorded nonces and + * relay-session id) must match what the signature says; a mismatch is a + * refused binding, not a soft hint. */ export async function verifyRelayEnvelope(input: VerifyRelayEnvelopeInput): Promise { let payload: JWTPayload; @@ -400,6 +370,18 @@ export async function verifyRelayEnvelope(input: VerifyRelayEnvelopeInput): Prom if (expect.seq !== undefined && expect.seq !== message.seq) { throw new TypeError("ssh-relay: seq does not match the signature"); } + if (expect.relaySessionId !== undefined && expect.relaySessionId !== message.relaySessionId) { + throw new TypeError("ssh-relay: relaySessionId does not match the signature"); + } + // I-1: the endpoint-minted nonces. The plane cannot mint these, so a + // resent envelope from another pairing is refused HERE once the caller + // binds the values it recorded for this relay-open. + if (expect.nB !== undefined && expect.nB !== message.nB) { + throw new TypeError("ssh-relay: nB does not match the signature"); + } + if (expect.nA !== undefined && expect.nA !== message.nA) { + throw new TypeError("ssh-relay: nA does not match the signature"); + } } return message; } @@ -477,19 +459,17 @@ export async function openRelayEnvelope(input: OpenRelayEnvelopeInput): Promise< if (!isStr(routingRef) || routingRef.length === 0) { throw new TypeError("ssh-relay: wrapper routingRef is not a non-empty string"); } - if (direction !== "B2A" && direction !== "A2B") { + if (!isRelayDirection(direction)) { throw new TypeError("ssh-relay: wrapper direction is not B2A or A2B"); } - if (!Number.isSafeInteger(seq) || (seq as number) < 0) { + if (!Number.isSafeInteger(seq) || (seq as number) < 0 || Object.is(seq, -0)) { throw new TypeError("ssh-relay: wrapper seq is not a non-negative safe integer"); } - if (input.expect) { - if (input.expect.ref !== routingRef) { - throw new TypeError("ssh-relay: wrapper routingRef does not match the frame's ref"); - } - if (input.expect.direction !== direction) { - throw new TypeError("ssh-relay: wrapper direction does not match the frame's"); - } + if (input.expect.ref !== routingRef) { + throw new TypeError("ssh-relay: wrapper routingRef does not match the frame's ref"); + } + if (input.expect.direction !== direction) { + throw new TypeError("ssh-relay: wrapper direction does not match the frame's"); } return { jws, routingRef, direction, seq: seq as number }; } @@ -519,7 +499,7 @@ export class SeqGate { * nothing - a hostile frame must never move the watermark. */ accept(direction: RelayDirection, seq: number): boolean { - if (direction !== "B2A" && direction !== "A2B") return false; + if (!isRelayDirection(direction)) return false; if (!Number.isSafeInteger(seq) || seq < 0) return false; if (seq <= this.#last[direction]) return false; this.#last[direction] = seq; From d86bc56764fc11bc457e9f850508eede50117f1b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:51:37 -0700 Subject: [PATCH 24/92] feat(ssh-relay): B-side ssh-agent proxy socket over sealed relay frames --- apps/node/agent/src/__tests__/daemon.test.ts | 42 ++ .../agent/src/__tests__/relay-proxy.test.ts | 537 ++++++++++++++++++ apps/node/agent/src/commands/ssh-relay.ts | 229 ++++++++ apps/node/agent/src/daemon.ts | 45 +- apps/node/agent/src/relay-proxy.ts | 455 +++++++++++++++ 5 files changed, 1304 insertions(+), 4 deletions(-) create mode 100644 apps/node/agent/src/__tests__/relay-proxy.test.ts create mode 100644 apps/node/agent/src/commands/ssh-relay.ts create mode 100644 apps/node/agent/src/relay-proxy.ts diff --git a/apps/node/agent/src/__tests__/daemon.test.ts b/apps/node/agent/src/__tests__/daemon.test.ts index 9b8577ee2..504c6f29d 100644 --- a/apps/node/agent/src/__tests__/daemon.test.ts +++ b/apps/node/agent/src/__tests__/daemon.test.ts @@ -26,6 +26,7 @@ import { type NodeEvent, type NodeRuntimeReport, parseNodeEvent, + type RelayFrame, signCommand, } from "@internal/subshell-protocol"; import { @@ -37,6 +38,7 @@ import { parseLinkBinding, } from "@internal/subshell-protocol/node-link-crypto"; import { run as runCli } from "../cli.js"; +import { RelaySessions } from "../commands/ssh-relay.js"; import { TAIL_POLL_MS } from "../commands/tail.js"; import { configPath, type NodeConfig, saveConfig } from "../config.js"; import { @@ -467,6 +469,7 @@ async function startDaemon( | "runtime" | "retentionMs" | "retentionPass" + | "relaySessions" > > & { config?: Partial; @@ -2580,3 +2583,42 @@ describe("pane-log retention wiring", () => { } }); }); + +/* ------------------------------------------------------------------ */ +/* relay inbound routing (spec 2026-10-08 §5.1): frames are not commands */ +/* ------------------------------------------------------------------ */ + +test("an inbound relay frame reaches the relay seam and never the JWS-command path", async () => { + // The registry is INJECTED: this pins the daemon's routing decision, and the + // seam is the one Task 7's A-side responder registers into (a single + // onInboundRelayFrame dispatch, routed by the ref the relay-open paired). + const relay = new RelaySessions(); + const seen: RelayFrame[] = []; + relay.register("r-1", { onRelayFrame: (f) => seen.push(f), close: () => {} }); + const h = await startDaemon({ relaySessions: relay }); + await waitFor(h, (e) => e.type === "inventory", "connect inventory push"); + + h.plane.sendToAgent(JSON.stringify({ type: "relay", ref: "r-1", seq: 0, direction: "A2B", blob: "AAAA" })); + await waitUntil(() => seen.length === 1, "the frame routed to its session handler"); + await sleep(120); + // NOT a command: the JWS path answers every non-command text frame with a + // `verify: malformed` error - a relay frame must draw NOTHING from it. + expect(eventsAs(h, "error").length).toBe(0); + expect(count(h, (e) => e.type === "result")).toBe(0); + + // A malformed relay candidate is relay-TYPED noise: dropped, still never a + // command refusal the plane would read as a command anomaly. + h.plane.sendToAgent(JSON.stringify({ type: "relay", ref: "r-1", seq: 0, direction: "A2B", blob: "!!!!" })); + h.plane.sendToAgent(JSON.stringify({ type: "relay", ref: "", seq: 0, direction: "A2B", blob: "AAAA" })); + // A frame for an unregistered ref is dropped by the registry, not re-routed. + h.plane.sendToAgent(JSON.stringify({ type: "relay", ref: "r-none", seq: 0, direction: "A2B", blob: "AAAA" })); + await sleep(120); + expect(seen.length).toBe(1); + expect(eventsAs(h, "error").length).toBe(0); + + // And the command path is UNTOUCHED for what is not relay-shaped: the old + // non-JWS text frame still draws the ordinary malformed answer. + h.plane.sendToAgent(JSON.stringify({ jws: 42 })); + await waitFor(h, (e) => e.type === "error" && e.code === "verify", "non-relay junk still answers verify/malformed"); + expect(seen.length).toBe(1); +}); diff --git a/apps/node/agent/src/__tests__/relay-proxy.test.ts b/apps/node/agent/src/__tests__/relay-proxy.test.ts new file mode 100644 index 000000000..f0355872e --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-proxy.test.ts @@ -0,0 +1,537 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, statSync } from "node:fs"; +import { createConnection, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { + base64UrlNoPad, + newNonce, + openRelayEnvelope, + parseRelayFrame, + type RelayFrame, + SSH_RELAY_FRAME_MAX_BYTES, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import { exportJWK, generateKeyPair } from "jose"; +import { RelaySessions } from "../commands/ssh-relay.js"; +import { type AgentProxyHandle, buildAgentSocketPath, startAgentProxy } from "../relay-proxy.js"; + +/** + * The B-side ssh-agent proxy (spec 2026-10-08 §5.2/§5.6) over the Task-5 codec. + * seal/open are the REAL mcp-core pair (the production pair, injected as the + * codec's own doctrine demands), and the A-side test keys are generated the + * same way the protocol tests do it, so the envelope the proxy produces and + * consumes is the shipped wire shape end to end. + */ + +/** The ssh-agent wire type bytes (OpenSSH `agent-proto.h`). */ +const SSH2_AGENTC_REQUEST_IDENTITIES = 17; +const SSH2_AGENTC_SIGN_REQUEST = 13; +const SSH2_AGENT_IDENTITIES = 18; + +interface JwkPair { + publicJwk: string; + privateJwk: string; +} + +async function es256Pair(): Promise { + const { publicKey, privateKey } = await generateKeyPair("ES256", { extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +async function ecdhPair(): Promise { + const { publicKey, privateKey } = await generateKeyPair("ECDH-ES", { crv: "P-256", extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +// One cast of keys for the whole file: generation is the slow part, and the +// pins are per-proxy inputs anyway. A = key home, B = this machine. +const aKeysReady: Promise<{ signing: JwkPair; encryption: JwkPair }> = Promise.all([es256Pair(), ecdhPair()]).then( + ([signing, encryption]) => ({ signing, encryption }), +); +const bKeysReady: Promise<{ signing: JwkPair; encryption: JwkPair }> = Promise.all([es256Pair(), ecdhPair()]).then( + ([signing, encryption]) => ({ signing, encryption }), +); +/** An ES256 pair nobody pinned: replies signed with it are forgeries. */ +const evilKeysReady: Promise = es256Pair(); + +/** The one frame grammar the tests accept: 4-byte BE length + payload. */ +function agentFrame(payload: number[]): Buffer { + const body = Buffer.from(payload); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + return Buffer.concat([header, body]); +} + +async function sleep(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)); +} + +async function waitUntil(cond: () => boolean, what: string, timeoutMs = 3000): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > timeoutMs) throw new Error(`timed out waiting for ${what}`); + await sleep(5); + } +} + +function connectSocket(socketPath: string): Socket { + const conn = createConnection({ path: socketPath }); + conn.on("error", () => { + /* the tests read failures through data/close events, not error throws */ + }); + return conn; +} + +/** Collect everything a socket received, and whether it was closed. */ +function spy(conn: Socket): { chunks: Buffer[]; closed: () => boolean } { + const chunks: Buffer[] = []; + let closed = false; + conn.on("data", (d) => chunks.push(Buffer.from(d))); + conn.on("close", () => { + closed = true; + }); + return { chunks, closed: () => closed }; +} + +interface ProxyFixture { + proxy: AgentProxyHandle; + frames: RelayFrame[]; + dataDir: string; + relayId: string; + ref: string; + bNodeId: string; + cleanup(): void; +} + +async function startFixture(paneId = "pane-1"): Promise { + const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-proxy-")); + const frames: RelayFrame[] = []; + const proxy = await startAgentProxy({ + dataDir, + paneId, + relayId: "relay-1", + ref: "r-1", + peerNodeId: "a-node", + selfNodeId: "b-node", + peerSigningJwk: aKeys.signing.publicJwk, + peerEncryptionJwk: aKeys.encryption.publicJwk, + ownEncryptionPublicJwk: bKeys.encryption.publicJwk, + ownEncryptionPrivateJwk: bKeys.encryption.privateJwk, + ownSigningPrivateJwk: bKeys.signing.privateJwk, + seal: mcpSeal, + open: mcpOpen, + sendRelayFrame: (frame) => { + frames.push(frame); + }, + }); + return { + proxy, + frames, + dataDir, + relayId: "relay-1", + ref: "r-1", + bNodeId: "b-node", + cleanup: () => proxy.close(), + }; +} + +/** + * Open one outbound B2A frame's blob AS A (the seal is addressed to A's + * encryption key), and verify its signature against B's public signing key: + * the mirror of the checklist the proxy runs on replies. + */ +async function openAndVerifyB2A( + fixture: ProxyFixture, + frame: RelayFrame, +): Promise<{ agentBytes: Buffer; nB: string; nA?: string; seq: number }> { + const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); + const opened = await openRelayEnvelope({ + blob: frame.blob, + own: { + principalId: "node:a-node", + publicJwk: aKeys.encryption.publicJwk, + privateJwk: aKeys.encryption.privateJwk, + }, + open: mcpOpen, + expect: { ref: frame.ref, direction: frame.direction }, + }); + const message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: JSON.parse(bKeys.signing.publicJwk) as JsonWebKey, + expect: { routingRef: fixture.ref, direction: "B2A", seq: opened.seq }, + }); + return { + agentBytes: Buffer.from(message.agentBytesB64, "base64url"), + nB: message.nB ?? "", + nA: message.nA, + seq: opened.seq, + }; +} + +/** Seal one A2B reply exactly as A's responder will (Task 7): A's signing key, B's pinned encryption key. */ +async function sealReply( + fixture: ProxyFixture, + opts: { + seq: number; + nB: string; + nA?: string; + agentBytes: number[]; + signerPrivateJwk?: string; + relaySessionId?: string; + }, +): Promise { + const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); + const blob = await sealRelayEnvelope({ + message: { + relaySessionId: opts.relaySessionId ?? fixture.relayId, + routingRef: fixture.ref, + direction: "A2B", + seq: opts.seq, + nB: opts.nB, + ...(opts.nA === undefined ? {} : { nA: opts.nA }), + agentBytesB64: base64UrlNoPad(new Uint8Array(opts.agentBytes)), + }, + privateJwk: JSON.parse(opts.signerPrivateJwk ?? aKeys.signing.privateJwk) as JsonWebKey, + recipient: { principalId: `node:${fixture.bNodeId}`, publicJwk: bKeys.encryption.publicJwk }, + seal: mcpSeal, + }); + return { type: "relay", ref: fixture.ref, seq: opts.seq, direction: "A2B", blob }; +} + +/* ------------------------------------------------------------------ */ +/* outbound: each agent request becomes exactly one sealed B2A frame */ +/* ------------------------------------------------------------------ */ + +test("a REQUEST_IDENTITIES on the socket emits exactly one B2A relay frame, seq 0, carrying the agent bytes", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const w = await new Promise((res, rej) => { + conn.on("connect", () => res()); + conn.on("error", rej); + }); + void w; + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "one outbound relay frame"); + await sleep(30); + expect(f.frames.length).toBe(1); // exactly one + + const frame = f.frames[0]; + expect(parseRelayFrame(JSON.stringify(frame))).not.toBeNull(); // it is wire-legal + expect(frame.type).toBe("relay"); + expect(frame.ref).toBe("r-1"); + expect(frame.direction).toBe("B2A"); + expect(frame.seq).toBe(0); // the transport slot + + const opened = await openAndVerifyB2A(f, frame); + expect(opened.seq).toBe(0); // the SIGNED anti-replay seq agrees for the first request + expect(opened.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES])); + expect(opened.nB).toMatch(/^[A-Za-z0-9_-]{22}$/); // a 128-bit base64url endpoint nonce + expect(opened.nA).toBeUndefined(); // B speaks first; nA has not been minted yet + conn.destroy(); + } finally { + f.cleanup(); + } +}); + +test("a second request gets seq 1 and carries the recorded nA onward", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "first request frame"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + const nA = newNonce(); + const reply = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [SSH2_AGENT_IDENTITIES, 0, 0, 0, 0] }); + f.proxy.deliverInboundRelayFrame(reply); + await waitUntil(() => spyC.chunks.length === 1, "reply bytes written back"); + + conn.write(agentFrame([SSH2_AGENTC_SIGN_REQUEST, 0, 1, 2, 3])); + await waitUntil(() => f.frames.length === 2, "second request frame"); + const second = await openAndVerifyB2A(f, f.frames[1]); + expect(f.frames[1].seq).toBe(1); // transport seq advanced + expect(second.seq).toBe(1); // signed anti-replay seq advanced + expect(second.nB).toBe(first.nB); // B keeps its own nonce + expect(second.nA).toBe(nA); // and signs A's onward (§5.6: recorded peer value) + expect(second.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_SIGN_REQUEST, 0, 1, 2, 3])); + } finally { + f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* inbound: a verified reply is written back; a refused one never is */ +/* ------------------------------------------------------------------ */ + +test("a correctly sealed+signed A reply (stub A keypair pinned) writes the agent bytes back on the socket", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + const replyBytes = [SSH2_AGENT_IDENTITIES, 0, 0, 0, 2, 0, 0, 0, 1, 65]; + const reply = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: replyBytes }); + f.proxy.deliverInboundRelayFrame(reply); + await waitUntil(() => spyC.chunks.length >= 1, "reply written back"); + + const written = Buffer.concat(spyC.chunks); + const len = written.readUInt32BE(0); + expect(len).toBe(replyBytes.length); + expect(written.subarray(4)).toEqual(Buffer.from(replyBytes)); + } finally { + f.cleanup(); + } +}); + +test("a reply signed by the WRONG key is refused: nothing written, the pending connection fails", async () => { + const f = await startFixture(); + const evil = await evilKeysReady; + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + const forged = await sealReply(f, { + seq: 0, + nB: first.nB, + nA: newNonce(), + agentBytes: [SSH2_AGENT_IDENTITIES], + signerPrivateJwk: evil.privateJwk, // not the pinned key: an origin failure + }); + f.proxy.deliverInboundRelayFrame(forged); + await waitUntil(() => spyC.closed(), "connection failed"); + expect(spyC.chunks.length).toBe(0); // NEVER a forged reply on the wire to ssh + } finally { + f.cleanup(); + } +}); + +test("a reply with a mismatched relay-session id or nB nonce is refused and not written", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + // Cross-session replay: right keys, another session's id. + const wrongSession = await sealReply(f, { + seq: 0, + nB: first.nB, + nA: newNonce(), + agentBytes: [SSH2_AGENT_IDENTITIES], + relaySessionId: "relay-OTHER", + }); + f.proxy.deliverInboundRelayFrame(wrongSession); + await waitUntil(() => spyC.closed(), "session-id mismatch fails the pending connection"); + expect(spyC.chunks.length).toBe(0); + + // Foreign nonce: this machine never minted it. + const conn2 = connectSocket(f.proxy.socketPath); + const spy2 = spy(conn2); + await new Promise((res) => conn2.on("connect", () => res())); + conn2.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 2, "second request frame out"); + const wrongNonce = await sealReply(f, { + seq: 0, + nB: newNonce(), + nA: newNonce(), + agentBytes: [SSH2_AGENT_IDENTITIES], + }); + f.proxy.deliverInboundRelayFrame(wrongNonce); + await waitUntil(() => spy2.closed(), "nonce mismatch fails the pending connection"); + expect(spy2.chunks.length).toBe(0); + } finally { + f.cleanup(); + } +}); + +test("a repeated seq is refused by the SeqGate and not written; a valid next-seq reply still is", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + const nA = newNonce(); + const good = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [1, 2, 3] }); + f.proxy.deliverInboundRelayFrame(good); + await waitUntil(() => spyC.chunks.length === 1, "first reply written"); + + // The plane can resend ANY envelope it routed (§5.6): a byte-identical + // resend and a rewind are both refused, and a refusal costs nothing on + // the live stream - the connection stays open for the next request. + f.proxy.deliverInboundRelayFrame(good); // resend, seq 0 again + const rewind = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [9, 9, 9] }); + f.proxy.deliverInboundRelayFrame(rewind); + const lower = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [8, 8, 8] }); + f.proxy.deliverInboundRelayFrame(lower); + await sleep(50); + expect(spyC.chunks.length).toBe(1); // nothing further was written + expect(spyC.closed()).toBe(false); // a replay refusal is not a connection failure + + // The stream continues: seq 1 over seq 0 is accepted. + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 2, "second request frame out"); + const next = await sealReply(f, { seq: 1, nB: first.nB, nA, agentBytes: [4, 5, 6] }); + f.proxy.deliverInboundRelayFrame(next); + await waitUntil(() => spyC.chunks.length === 2, "next-seq reply written"); + expect(spyC.chunks[1].subarray(4)).toEqual(Buffer.from([4, 5, 6])); + } finally { + f.cleanup(); + } +}); + +test("an openable but unsigned-sealed blob (plane-crafted noise, wrong recipient slot) is dropped without killing the pending request", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + + // Not even valid base64-of-a-JWE: the open fails before any trust question. + f.proxy.deliverInboundRelayFrame({ type: "relay", ref: "r-1", seq: 0, direction: "A2B", blob: "AAAAAAAAAAAAAAAA" }); + await sleep(30); + expect(spyC.chunks.length).toBe(0); + expect(spyC.closed()).toBe(false); // noise from the routing plane costs the pane nothing + + // And the session still works: the real reply lands. + const first = await openAndVerifyB2A(f, f.frames[0]); + const good = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: [7] }); + f.proxy.deliverInboundRelayFrame(good); + await waitUntil(() => spyC.chunks.length === 1, "valid reply still written"); + } finally { + f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the socket file and its directory */ +/* ------------------------------------------------------------------ */ + +test("the socket lives at /ssh//agent.sock, mode 0600, not in the pane log dir", async () => { + const f = await startFixture("pane-42"); + try { + const expected = join(f.dataDir, "ssh", "pane-42", "agent.sock"); + expect(f.proxy.socketPath).toBe(expected); + expect(buildAgentSocketPath(f.dataDir, "pane-42")).toBe(expected); + // The M1 pane CONFIG lands in the same dir (spec §5.2 "beside the rendered + // config"); the pane LOG dir is /subshells - a different tree. + expect(f.proxy.socketPath).not.toContain(join(f.dataDir, "subshells")); + const st = statSync(f.proxy.socketPath); + expect(st.mode & 0o777).toBe(0o600); + const dir = statSync(join(f.dataDir, "ssh", "pane-42")); + expect(dir.mode & 0o777).toBe(0o700); + } finally { + f.cleanup(); + } +}); + +test("close() unbinds: the socket file is gone and a fresh connect fails", async () => { + const f = await startFixture(); + const path = f.proxy.socketPath; + f.proxy.close(); + await waitUntil(() => { + try { + statSync(path); + return false; + } catch { + return true; + } + }, "socket file unlinked"); + const conn = createConnection({ path }); + let failed = false; + conn.on("error", () => { + failed = true; + }); + await waitUntil(() => failed, "connect refused after close"); + // Idempotent: a second close must not throw. + f.proxy.close(); +}); + +test("an over-cap request length prefix fails the connection without emitting a frame", async () => { + const f = await startFixture(); + try { + const conn = connectSocket(f.proxy.socketPath); + const spyC = spy(conn); + await new Promise((res) => conn.on("connect", () => res())); + const header = Buffer.alloc(4); + header.writeUInt32BE(SSH_RELAY_FRAME_MAX_BYTES + 1, 0); + conn.write(header); + await waitUntil(() => spyC.closed(), "over-cap request drops the connection"); + expect(f.frames.length).toBe(0); + } finally { + f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the registry seam the daemon routes into (Task 7 reuses this one) */ +/* ------------------------------------------------------------------ */ + +test("RelaySessions dispatches by ref to the owning role: registered, unknown, duplicate, throwing", () => { + const relay = new RelaySessions(); + const seenA: RelayFrame[] = []; + const seenB: RelayFrame[] = []; + let closedWith = ""; + const frame = (ref: string): RelayFrame => ({ type: "relay", ref, seq: 0, direction: "A2B", blob: "AAAA" }); + + expect( + relay.register("r-1", { + onRelayFrame: (f) => seenA.push(f), + close: (reason) => { + closedWith = reason; + }, + }), + ).toBe(true); + expect(relay.register("r-1", { onRelayFrame: (f) => seenB.push(f), close: () => {} })).toBe(false); // ref already owned + expect( + relay.register("r-2", { + onRelayFrame: () => { + throw new Error("handler boom"); + }, + close: () => {}, + }), + ).toBe(true); + + relay.onInboundRelayFrame(frame("r-1")); + expect(seenA.length).toBe(1); + expect(seenB.length).toBe(0); + + relay.onInboundRelayFrame(frame("unknown-ref")); // never throws into the frame chain + relay.onInboundRelayFrame(frame("r-2")); // a throwing owner is contained, not rethrown + + relay.closeAll("grant-revoked"); + expect(closedWith).toBe("grant-revoked"); + expect(relay.size).toBe(0); + relay.onInboundRelayFrame(frame("r-1")); // after closeAll nothing routes, nothing throws + expect(seenA.length).toBe(1); +}); diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts new file mode 100644 index 000000000..0bee80776 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -0,0 +1,229 @@ +import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { bytesOfJwk, type RelayFrame, type SshRelayOpenCommand } from "@internal/subshell-protocol"; +import { loadOrCreateIdentity } from "../identity.js"; +import { log } from "../log.js"; +import { MachinePinStore } from "../machine-pin-store.js"; +import { startAgentProxy } from "../relay-proxy.js"; + +/** + * The node-side relay session registry (spec 2026-10-08 §5.1/§5.2) and the B + * branch of the brokered pairing. + * + * The daemon's inbound route hands EVERY `relay` link frame to this module's + * single dispatcher ({@link RelaySessions.onInboundRelayFrame}); the JWS + * command path never sees one. Which local role owns a `ref` is the only + * question routing asks: today the B-side agent proxy registers itself + * ({@link openBRelaySession}); Task 7's A-side responder registers into the + * SAME registry for the other role, and Task 8's `ssh_relay_open` executor + * decides which branch a command drives by the command's own `role` field. + * That is the whole seam - one dispatch, one map, any number of roles. + * + * §5.1's pairing doctrine needs the pairing itself: the plane's + * `ssh_relay_open` command carries the relay-session id, the routing ref, both + * node ids with roles, and the peer's registered signing + encryption keys. + * The B branch enforces §4.4's byte-equality pin (first pairing writes the + * pin; a pin that moved is a hard block naming the peer and §4.5's recovery, + * never a silent overwrite) and only then binds the pane's agent socket. + * + * WHAT THE WIRE DOES NOT CARRY YET (handed to Task 8): the frozen + * `ssh_relay_open` grammar names no pane. §5.2 puts the socket in the PANE's + * ssh dir (`/ssh//agent.sock`), so {@link openBRelaySession} + * takes the `paneId` as an explicit executor input - whoever delivers the + * command must supply it (a grammar addition or a launch-side correlation; + * the proxy refuses an id outside the path-composition shape either way, so + * guessing is structurally impossible). No executor arm is registered on this + * pass: `ssh_relay_open`/`ssh_relay_close` keep answering `unsupported` from + * the dispatch switch until Task 8's delivery wires them. + */ + +/** One local owner of a relay session's inbound frames (a B proxy, or Task 7's A responder). */ +export interface RelaySessionHandler { + /** Deliver one grammar-parsed inbound frame routed to this session's ref. */ + onRelayFrame(frame: RelayFrame): void; + /** End the session with §5.6's named reason (the `ssh_relay_close` word, or a local cut). */ + close(reason: string): void; +} + +/** + * The per-daemon map from routing ref to its local owner. ONE instance lives + * for the daemon's life (relay sessions outlive a link reconnect - the plane + * re-pumps frames on the fresh socket), and {@link + * RelaySessions.onInboundRelayFrame} is THE inbound relay dispatch every role + * shares (Task 7). Frames for an unknown ref are dropped with a log line: a + * stale ref (session already closed around the plane's own cut) is routine, + * and the refusal the plane deserves is Task 8's broker's, not this map's. + */ +export class RelaySessions { + #byRef = new Map(); + #say: (line: string) => void; + + constructor(logLine?: (line: string) => void) { + this.#say = logLine ?? ((line: string): void => log(line)); + } + + /** Sessions currently owned (census/test surface). */ + get size(): number { + return this.#byRef.size; + } + + /** + * Take ownership of a ref. False when the ref is ALREADY owned - two live + * sessions on one routing ref is a broker bug, and the second opener's + * resources belong to ITSELF to release (its caller sees the false). + */ + register(ref: string, handler: RelaySessionHandler): boolean { + if (this.#byRef.has(ref)) return false; + this.#byRef.set(ref, handler); + return true; + } + + /** Release ownership WITHOUT closing (the caller owns its own teardown). */ + unregister(ref: string): void { + this.#byRef.delete(ref); + } + + /** Whether this machine currently owns `ref`. */ + has(ref: string): boolean { + return this.#byRef.has(ref); + } + + /** + * THE single inbound relay dispatch (the Task-7-reused seam): route one + * parsed frame to whichever local role owns its ref. A throwing owner is + * contained - one poisoned session cannot take the frame chain down. + */ + onInboundRelayFrame(frame: RelayFrame): void { + const handler = this.#byRef.get(frame.ref); + if (!handler) { + // Refs are opaque ids, not secrets and not paths: naming one is how the + // operator can correlate this line with the plane's broker log. + this.#say(`relay frame for unknown session ref ${frame.ref}: dropped`); + return; + } + try { + handler.onRelayFrame(frame); + } catch (err) { + this.#say(`relay session ${frame.ref}: inbound handling threw: ${String(err)}`); + } + } + + /** + * Close the session named by `ref` (what Task 8's `ssh_relay_close` + * executor will call). True when a session was owned and closed, false for + * an unknown ref. + */ + close(ref: string, reason: string): boolean { + const handler = this.#byRef.get(ref); + if (!handler) return false; + this.#byRef.delete(ref); + try { + handler.close(reason); + } catch (err) { + this.#say(`relay session ${ref}: close threw: ${String(err)}`); + } + return true; + } + + /** Close everything (daemon shutdown / a link that will never return). */ + closeAll(reason: string): void { + for (const ref of [...this.#byRef.keys()]) this.close(ref, reason); + } +} + +/** Everything {@link openBRelaySession} needs: the daemon's seams + the command's pairing + the pane. */ +export interface BRelaySessionArgs { + /** The daemon's registry this session joins. */ + relay: RelaySessions; + /** The node's data dir (config.dataDir). */ + dataDir: string; + /** This machine's node id (config.nodeId) - the open-as principal. */ + selfNodeId: string; + /** The pane whose ssh connects through the socket (see the module header's Task-8 note). */ + paneId: string; + /** The verified `ssh_relay_open` body; its `role` MUST be "B". */ + cmd: SshRelayOpenCommand; + /** The link pump: one sealed B2A frame onto the current socket (drops are the daemon's). */ + sendRelayFrame(frame: RelayFrame): void; + /** Line sink (defaults to the agent logger); never sees keys or agent bytes. */ + log?: (line: string) => void; +} + +/** + * Decode the command's base64 spelling of the peer's registered ECDH-ES public + * key into the raw public JWK string the pin store holds (the §4.2 registration + * spelling; the grammar's `BASE64_RE` gate already proved the outer layer). + */ +function decodePeerEncryptionJwk(b64: string): string { + const jwk = Buffer.from(b64, "base64").toString("utf8"); + // The pin's encryption half must be a PUBLIC P-256 JWK - the same deep + // refusal `bytesOfJwk` gives the signing half (private material, foreign + // curve, junk all throw; the grammar's shallow `d` gate stops at top level). + bytesOfJwk(jwk); + return jwk; +} + +/** + * Open the B side of a brokered relay pairing: enforce §4.4's machine pin on + * A (first pairing pins both halves, a moved pin is a hard block), then bind + * the pane's agent proxy socket and register its ref. Throws a named refusal + * (the dispatch wrapper turns it into `ok:false`) on: role other than B, an + * unusable peer key, a MOVED pin (naming the peer and §4.5's re-pair), or a + * ref already owned. Resolves with the composed socket path - the value the + * launch's scoped `SSH_AUTH_SOCK` must match byte for byte (§5.2). + * + * The gate (ssh-enabled mirror) is the CALLER's first check, like every SSH + * arm's: this function is the pairing's mechanics, not the policy. + */ +export async function openBRelaySession(args: BRelaySessionArgs): Promise<{ socketPath: string }> { + const { cmd } = args; + if (cmd.role !== "B") throw new Error("relay open refused: this machine is not the B side of the pairing"); + + // §4.4: both halves, byte-equal, in the node-side store (its own file, never + // honoring SUBSHELL_CHANNEL_PIN - MachinePinStore's own doctrine). + const candidate = { + signing: cmd.peerSigningPublicKey, + encryption: decodePeerEncryptionJwk(cmd.peerEncryptPublicKey), + }; + bytesOfJwk(candidate.signing); // deep public-only validity on the signing half too + const pins = new MachinePinStore(args.dataDir); + const existing = pins.get(cmd.aNodeId); + if (existing === null) { + pins.pin(cmd.aNodeId, candidate); // first pairing: the plane-delivered keys become the pin (§4.4/§4.6's accepted window) + } else if (pins.check(cmd.aNodeId, candidate) !== "ok") { + // Hard block naming the peer and pointing at recovery - §4.5: re-pair is + // an operator act; nothing here may "trust anyway". + throw new Error( + `relay open refused: machine pin for ${cmd.aNodeId} has MOVED (re-pair per §4.5 after out-of-band verification)`, + ); + } + + const identity = await loadOrCreateIdentity(args.dataDir); + const proxy = await startAgentProxy({ + dataDir: args.dataDir, + paneId: args.paneId, + relayId: cmd.relayId, + ref: cmd.ref, + peerNodeId: cmd.aNodeId, + selfNodeId: args.selfNodeId, + peerSigningJwk: candidate.signing, + peerEncryptionJwk: candidate.encryption, + ownEncryptionPublicJwk: identity.publicJwk, + ownEncryptionPrivateJwk: identity.privateJwk, + ownSigningPrivateJwk: identity.signingPrivateJwk, + seal: mcpSeal, + open: mcpOpen, + sendRelayFrame: args.sendRelayFrame, + ...(args.log === undefined ? {} : { log: args.log }), + }); + + if ( + !args.relay.register(cmd.ref, { + onRelayFrame: (f) => proxy.deliverInboundRelayFrame(f), + close: () => proxy.close(), + }) + ) { + proxy.close(); + throw new Error("relay open refused: routing ref already owned by a live session"); + } + return { socketPath: proxy.socketPath }; +} diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index a2e9fc255..02aa1cf7b 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -11,6 +11,8 @@ import { type NodeMaintenanceWire, type NodeRuntimeReport, type NodeSshEnabledWire, + parseRelayFrame, + relayFrameRefIfOverCap, SeqTracker, verifyCommand, } from "@internal/subshell-protocol"; @@ -24,6 +26,7 @@ import { seedMaintenanceMemo, seedSshEnabledMemo, } from "./commands/report.js"; +import { RelaySessions } from "./commands/ssh-relay.js"; import { stopAllTails } from "./commands/tail.js"; import { sweepStaleTransfers } from "./commands/transfer-sweep.js"; import { cleanupStaleUploads } from "./commands/write-file.js"; @@ -205,6 +208,13 @@ export interface DaemonDeps { * "plaintext mode": spec §6 forbids a v14 socket ever running unsealed. */ link?: (args: LinkNegotiatorArgs) => LinkNegotiator; + /** + * The relay-session registry the inbound `relay` frames route into (spec + * 2026-10-08 §5.1/§5.2; default: a fresh {@link RelaySessions}). + * @internal test seam: the daemon test injects a capturing registry to pin + * the routing decision; production runs on the one this call constructs. + */ + relaySessions?: RelaySessions; } interface WsClose { @@ -451,6 +461,13 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // a jti that has run once NEVER runs twice. const idempotent = new Map(); + // The relay-session registry (spec 2026-10-08 §5.1/§5.2): PER-DAEMON, like + // the idempotence map: a relay session belongs to the pairing, not to the + // socket it first rode, and the plane re-pumps frames on the fresh link. + // Inbound `relay` frames route through it (see `onFrame`); B proxies (and + // from Task 7, the A responder) register into it by routing ref. + const relaySessions = deps.relaySessions ?? new RelaySessions(); + // PER-PROCESS executor context (spec §3.4/§7): built once, survives every // reconnect. `ws` is a STABLE wrapper routing to the CURRENT socket — // executors hold `ctx` across reconnects. The serial chain does NOT keep @@ -826,14 +843,34 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom return null; }; - /** One ADMITTED frame: jws extraction → verify → execute (spec §4). */ + /** One ADMITTED frame: relay routing (spec 2026-10-08 §5.1) OR jws → verify → execute (§4). */ const onFrame = async (ws: WsLike, data: string): Promise => { - let jws: unknown; + let parsed: unknown; try { - jws = (JSON.parse(data) as { jws?: unknown }).jws; + parsed = JSON.parse(data); } catch { - jws = undefined; + parsed = undefined; + } + // The `relay` link frame is NOT a command (§5.1: no per-message signing, + // the link authenticates the machine, origin rides the inner ES256 + // signature): it must never draw the command grammar's `verify: malformed` + // answer, which the plane reads as a command anomaly. Recognized by its + // `type` BEFORE the JWS path; a relay-shaped-but-malformed candidate is + // relay noise (dropped with a line; over-cap ones name their ref, the + // session's named close itself is the broker's), and every non-relay frame + // falls through to the untouched command path below. + if (typeof parsed === "object" && parsed !== null && (parsed as { type?: unknown }).type === "relay") { + const frame = parseRelayFrame(parsed as object); + if (frame !== null) relaySessions.onInboundRelayFrame(frame); + else { + const overCapRef = relayFrameRefIfOverCap(parsed); + if (overCapRef !== null) + log(`relay frame over the size cap for ref ${overCapRef}: refused (§5.1; the named close is the broker's)`); + else log("malformed relay frame dropped"); + } + return; } + const jws = (parsed as { jws?: unknown } | undefined)?.jws; if (typeof jws !== "string") { send(ws, { type: "error", code: "verify", message: "malformed" }); return; diff --git a/apps/node/agent/src/relay-proxy.ts b/apps/node/agent/src/relay-proxy.ts new file mode 100644 index 000000000..4df897d8d --- /dev/null +++ b/apps/node/agent/src/relay-proxy.ts @@ -0,0 +1,455 @@ +import { chmodSync, mkdirSync, unlinkSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { dirname, join } from "node:path"; +import { buildSshConfigPath } from "@internal/pane-runtime"; +import { + base64UrlNoPad, + bytesOfJwk, + newNonce, + openRelayEnvelope, + type RelayFrame, + type RelayOpenFn, + type RelaySealFn, + SeqGate, + SSH_RELAY_FRAME_MAX_BYTES, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import { log } from "./log.js"; + +/** + * B-side ssh-agent proxy socket (spec 2026-10-08 §5.2): the pane's `ssh` + * connects here through its scoped `SSH_AUTH_SOCK`, and every agent request it + * writes is shipped to A as ONE sealed relay envelope - framed on the local + * wire (4-byte big-endian length + payload, the ssh-agent protocol), sealed by + * the Task-5 codec to A's PINNED encryption key, signed with B's own §4.1 + * signing key, and handed to the link pump as a `relay` frame. A's replies + * (inbound A2B frames) are opened, run through the full Task-5 caller + * checklist - verify against A's pinned signing key, cross-check the recorded + * session id and both nonces, then the per-session {@link SeqGate} - and the + * returned agent bytes are written back with the same framing. Nothing that + * fails verification reaches the socket: the pane would rather hang than + * consume a forged reply. + * + * The socket lives at `/ssh//agent.sock`, mode 0600, in the + * per-session ssh dir M1 established for the rendered config - the path is + * derived through {@link buildSshConfigPath} itself, so the two machines' + * byte-equal derivation and the exit sweep keep finding one tree. The proxy + * exists only for the handshake window (§5.6); `close()` unbinds it and the + * caller (relay close / pane teardown) drives the timing. + * + * Correlation: a relay frame carries no request id (the plane routes blind by + * ref), so the proxy keeps AT MOST ONE outstanding request - later requests + * queue locally, unsealed, until the reply lands. ssh itself serializes + * agent requests per connection; the queue makes that true across the relay + * too, which is what makes replies safely FIFO. + * + * Key handling mirrors the caller checklist in `task-5-report` verbatim: the + * wrapper is opened with B's own encryption key and the FRAME's ref/direction + * (`expect` required, M-1); the signature is verified against A's pinned + * signing key with `relaySessionId`, `nB`, and (once recorded) `nA` all bound + * into `expect` (I-1); the signed `seq` passes through the session's gate. + * seal/open are INJECTED (the protocol package cannot import mcp-core; the + * agent imports it directly and passes `seal`/`open` through). + */ + +/** One agent message's maximum payload: the relay frame cap (§5.1: cap is law). */ +const MAX_AGENT_MESSAGE_BYTES = SSH_RELAY_FRAME_MAX_BYTES; + +/** Inputs to {@link startAgentProxy}: session facts, pinned keys, injected crypto, the pump. */ +export interface AgentProxyArgs { + /** The node's data dir; the pane's ssh dir hangs under `/ssh//`. */ + dataDir: string; + /** The pane whose `ssh` connects here; the per-session ssh dir is named by it. */ + paneId: string; + /** The relay-session id the plane minted (§5.6: signed into every envelope). */ + relayId: string; + /** The opaque routing ref this session's frames ride (also the registry key). */ + ref: string; + /** A's node id: the seal-to principal `node:` and the pinned-key holder. */ + peerNodeId: string; + /** This machine's node id: the open-as principal `node:` (Task-5 convention). */ + selfNodeId: string; + /** A's PINNED ES256 signing public JWK (JSON string) - the origin key. */ + peerSigningJwk: string; + /** A's PINNED ECDH-ES encryption public JWK (JSON string) - the seal-to key. */ + peerEncryptionJwk: string; + /** B's own encryption keypair (both JWK JSON strings) - opens replies addressed to B. */ + ownEncryptionPublicJwk: string; + ownEncryptionPrivateJwk: string; + /** B's own ES256 signing PRIVATE JWK (JSON string) - signs every B2A envelope. */ + ownSigningPrivateJwk: string; + /** The sealing primitive - mcp-core's `seal` in production (injected, Task-5 doctrine). */ + seal: RelaySealFn; + /** The opening primitive - mcp-core's `open`. */ + open: RelayOpenFn; + /** Hand one sealed B2A frame to the link pump (the daemon's `send`, via the ctx seam). */ + sendRelayFrame(frame: RelayFrame): void; + /** Line sink; defaults to the agent logger. Never receives keys, nonces, or agent bytes. */ + log?: (line: string) => void; +} + +/** The running proxy: the composed socket path plus the two lifecycle verbs. */ +export interface AgentProxyHandle { + /** Absolute path of the listening socket (0600, inside the pane's ssh dir). */ + readonly socketPath: string; + /** + * Feed one INBOUND relay frame parsed off the link to this session. Only + * A2B frames for this session's ref are honored; anything else is dropped + * with a log line (defense in depth - the registry already routed by ref). + * Never throws: a refused reply is a refusal, not a crash. + */ + deliverInboundRelayFrame(frame: RelayFrame): void; + /** Tear down: fail every live connection, stop listening, UNLINK the socket. Idempotent. */ + close(): void; +} + +/** + * The agent proxy socket path for a pane: the directory {@link + * buildSshConfigPath} already owns for that pane's rendered config, plus + * `agent.sock`. The helper's own guards run (absolute dataDir, an id inside + * the path-composition shape) - it THROWS on anything else, so a caller can + * never compose a socket outside `/ssh//`. + */ +export function buildAgentSocketPath(dataDir: string, paneId: string): string { + return join(dirname(buildSshConfigPath(dataDir, paneId)), "agent.sock"); +} + +/** One framed agent message: 4-byte big-endian length, then the payload. */ +function framing(payload: Buffer): Buffer { + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length, 0); + return Buffer.concat([header, payload]); +} + +/** A live client connection plus its receive buffer and (while outstanding) its relay slot. */ +interface ConnState { + socket: Socket; + buffer: Buffer; +} + +/** + * Start the B-side proxy: bind the socket, then speak the ssh-agent wire until + * `close()`. Rejects (without leaving a bound socket behind) when the pinned + * keys are not usable PUBLIC EC P-256 JWKs - the deep private-material and + * key-validity refusal §4.4 assigns to this side (`bytesOfJwk`). + */ +export async function startAgentProxy(args: AgentProxyArgs): Promise { + const say = args.log ?? ((line: string): void => log(line)); + const socketPath = buildAgentSocketPath(args.dataDir, args.paneId); + + // Pin sanity BEFORE binding: both peer halves must be public P-256 JWKs + // (bytesOfJwk throws on a `d` member, a foreign curve, or junk). Parsing + // once keeps the per-frame verify free of JSON work. + const peerSigning = JSON.parse(args.peerSigningJwk) as JsonWebKey; + try { + bytesOfJwk(args.peerSigningJwk); + bytesOfJwk(args.peerEncryptionJwk); + } catch (err) { + throw new Error(`relay proxy: pinned peer key rejected: ${err instanceof Error ? err.message : String(err)}`); + } + const ownSigningPrivate = JSON.parse(args.ownSigningPrivateJwk) as JsonWebKey; + const ownEncryption = { + principalId: `node:${args.selfNodeId}`, + publicJwk: args.ownEncryptionPublicJwk, + privateJwk: args.ownEncryptionPrivateJwk, + }; + const recipient = { principalId: `node:${args.peerNodeId}`, publicJwk: args.peerEncryptionJwk }; + + // The pane's ssh dir, the same 0700 posture the launch write enforces; the + // dir may already hold the rendered config (it will, once Task 8 sequences + // open-and-launch) and holds agent.sock beside it. + const dir = dirname(socketPath); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + // A stale socket file from a crashed session would make `listen` fail with + // EADDRINUSE; the pane's ssh dir is this machine's alone, so the unlinked + // name can only have been a previous proxy for the same pane. + try { + unlinkSync(socketPath); + } catch { + /* the clean case: no stale name */ + } + + // Session state (§5.6): B's own nonce is minted once; A's is recorded on the + // first verified reply and signed onward after that. The gate is FRESH per + // relay-open, and the two seqs (signed anti-replay, frame transport) start + // at 0 and advance together on B's leg. + const nB = newNonce(); + let nA: string | undefined; + const gate = new SeqGate(); + let signedSeq = 0; + let transportSeq = 0; + + const conns = new Set(); + /** The ONE outstanding request (the FIFO head at the relay), if any. */ + let outstanding: { conn: ConnState } | null = null; + /** True while a seal is in flight: no slot has been taken, and no other request starts. */ + let sealedInFlight = false; + const queue: { conn: ConnState; payload: Buffer }[] = []; + let closed = false; + + /** Destroy a connection's socket; its close handler does the bookkeeping. */ + const failConn = (conn: ConnState): void => { + conns.delete(conn); + try { + conn.socket.destroy(); + } catch { + /* already gone */ + } + }; + + /** Nothing outstanding anymore: let the next queued request go (if any). */ + const releaseOutstanding = (): void => { + outstanding = null; + void pumpQueue(); + }; + + /** A refused-forge frame fails every request that could be waiting on it. */ + const poisonSession = (why: string): void => { + say(`relay session ${args.ref}: ${why}; refusing the frame and failing pending agent connections`); + const waiting = outstanding?.conn; + if (waiting) failConn(waiting); + for (const q of queue.splice(0)) failConn(q.conn); + // A seal still in flight resolves into a dead connection: sendRequest's + // liveness re-check drops it and drains the (now empty) queue itself. + releaseOutstanding(); + }; + + /** Seal one agent request and hand the frame to the pump; then open the slot. */ + const sendRequest = async (conn: ConnState, payload: Buffer): Promise => { + const agentBytesB64 = base64UrlNoPad(new Uint8Array(payload)); + const message = { + relaySessionId: args.relayId, + routingRef: args.ref, + direction: "B2A" as const, + seq: signedSeq, + nB, + ...(nA === undefined ? {} : { nA }), + agentBytesB64, + }; + let blob: string; + try { + blob = await sealRelayEnvelope({ message, privateJwk: ownSigningPrivate, recipient, seal: args.seal }); + } catch (err) { + // Malformed key material or an over-cap payload: the request is dead. + // Cost it its connection, never the session (no seq was consumed). + say( + `relay session ${args.ref}: could not seal an agent request: ${err instanceof Error ? err.message : String(err)}`, + ); + sealedInFlight = false; + failConn(conn); + void pumpQueue(); + return; + } + if (closed) return; + if (!conns.has(conn)) { + // The connection died mid-seal: its reply would have nowhere to go, so + // the slot is not taken and the queue drains straight away. + sealedInFlight = false; + void pumpQueue(); + return; + } + signedSeq += 1; + try { + args.sendRelayFrame({ + type: "relay", + ref: args.ref, + seq: transportSeq, + direction: "B2A", + blob, + }); + } catch (err) { + say(`relay session ${args.ref}: the link pump refused a request: ${String(err)}`); + } + transportSeq += 1; + sealedInFlight = false; + // The slot HOLDS the frame's reply: further requests queue until it lands + // (or the connection dies and its close handler frees the slot). + outstanding = { conn }; + void pumpQueue(); + }; + + /** Start the next queued request if the relay slot is free. */ + const pumpQueue = async (): Promise => { + if (closed || sealedInFlight || outstanding) return; + const next = queue.shift(); + if (!next) return; + sealedInFlight = true; + void sendRequest(next.conn, next.payload); + }; + + /** One complete inbound agent message from one connection. */ + const handleAgentRequest = (conn: ConnState, payload: Buffer): void => { + if (closed) return; + if (!outstanding && !sealedInFlight) { + sealedInFlight = true; + void sendRequest(conn, payload); + return; + } + queue.push({ conn, payload }); // serializes behind the outstanding request (§5.6 FIFO) + }; + + const onConnection = (socket: Socket): void => { + const conn: ConnState = { socket, buffer: Buffer.alloc(0) }; + conns.add(conn); + socket.on("data", (chunk: Buffer) => { + conn.buffer = conn.buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([conn.buffer, chunk]); + for (;;) { + if (conn.buffer.length < 4) return; + const length = conn.buffer.readUInt32BE(0); + if (length > MAX_AGENT_MESSAGE_BYTES) { + // A length prefix past the wire cap is noise or an attack: the + // payload is never buffered, and the connection is over. + say(`relay session ${args.ref}: agent message over the cap (${length} bytes); connection failed`); + failConn(conn); + return; + } + if (conn.buffer.length < 4 + length) return; + const payload = conn.buffer.subarray(4, 4 + length); + conn.buffer = conn.buffer.subarray(4 + length); + handleAgentRequest(conn, Buffer.from(payload)); + if (!conns.has(conn)) return; // the handler failed this connection mid-loop + } + }); + const forget = (): void => { + if (!conns.delete(conn)) return; + for (let i = queue.length - 1; i >= 0; i -= 1) { + if (queue[i].conn === conn) queue.splice(i, 1); + } + // A mid-seal death needs no bookkeeping here: sendRequest re-checks + // liveness before taking the slot, and drops the sealed frame if the + // connection is gone. + if (outstanding?.conn === conn && !sealedInFlight) releaseOutstanding(); + }; + socket.on("close", forget); + socket.on("error", () => { + /* close follows; never throw out of the socket layer */ + }); + }; + + const server: Server = createServer(onConnection); + await new Promise((resolve, reject) => { + const onError = (err: Error): void => { + server.removeListener("listening", onListening); + reject(err); + }; + const onListening = (): void => { + server.removeListener("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(socketPath); + }); + // The socket is the pane user's door onto the relay: 0600, enforced the way + // every 0600 file in this agent is - umask cannot be reasoned with, chmod + // after the fact can. + chmodSync(socketPath, 0o600); + + /** Open + verify one inbound reply, then write it back. Runs on the chain. */ + const deliverReply = async (frame: RelayFrame): Promise => { + if (closed) return; + let opened; + try { + // The frame's OWN routing facts bind the wrapper (M-1): a relayed blob + // addressed through another ref or direction never reaches a key. + opened = await openRelayEnvelope({ + blob: frame.blob, + own: ownEncryption, + open: args.open, + expect: { ref: frame.ref, direction: frame.direction }, + }); + } catch { + // Unopenable noise from the routing plane (which holds B's public key + // and can craft seals all by itself, §5.6). Cost it a log line; the + // pane's pending request stays alive. + say(`relay session ${args.ref}: refused an inbound frame (envelope did not open)`); + return; + } + let message; + try { + // The Task-5 caller checklist, every binding the endpoint holds (I-1): + // origin against A's pinned signing key, this session's relayId, the + // recorded nonces, and the frame-agreed routing triple. An openable + // wrapper with a foreign signature or another session's facts is a + // FORGERY against the pin, not noise: refuse it and fail the + // connections that could consume anything built on this exchange. + message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: peerSigning, + expect: { + routingRef: args.ref, + direction: "A2B", + seq: opened.seq, + relaySessionId: args.relayId, + nB, + ...(nA === undefined ? {} : { nA }), + }, + }); + } catch { + poisonSession("reply failed origin/binding verification"); + return; + } + if (!gate.accept("A2B", message.seq)) { + // The plane routes - and can resend - everything (§5.6). A resend is + // refused, not punished: no bytes to the socket, no harm to the stream. + say(`relay session ${args.ref}: refused a replayed/rewound reply (seq ${message.seq})`); + return; + } + if (nA === undefined) nA = message.nA; // record A's nonce on the first verified reply + const payload = Buffer.from(message.agentBytesB64, "base64url"); + const waiting = outstanding; + if (!waiting) { + say(`relay session ${args.ref}: a reply with no outstanding request was dropped`); + return; + } + outstanding = null; + try { + waiting.conn.socket.write(framing(payload)); + } catch { + /* the close handler already forgot it */ + } + void pumpQueue(); + }; + + // Replies are processed in ARRIVAL order, one chain deep: the SeqGate is + // per-session and must never race two opens out of order. + let deliverChain: Promise = Promise.resolve(); + + return { + socketPath, + deliverInboundRelayFrame(frame: RelayFrame): void { + if (closed) return; + if (frame.ref !== args.ref || frame.direction !== "A2B") { + // Misrouting is a registry bug, not a threat; a B2A frame is B's own + // echo. Either way it never reaches the reply path. + say(`relay session ${args.ref}: dropped a non-A2B or foreign-ref inbound frame`); + return; + } + deliverChain = deliverChain + .then(() => deliverReply(frame)) + .catch((err: unknown) => { + say(`relay session ${args.ref}: inbound handling failed: ${String(err)}`); + }); + }, + close(): void { + if (closed) return; + closed = true; + for (const conn of [...conns]) failConn(conn); + for (const q of queue.splice(0)) failConn(q.conn); + try { + server.close(); + } catch { + /* already closed */ + } + // The listen name goes with the session (§5.2: unbinds at teardown); an + // unlink failure leaves a stale name the NEXT proxy start unlinks. + try { + unlinkSync(socketPath); + } catch { + /* never bound, or already gone */ + } + }, + }; +} From 7d8b2b2861e08f88022c5060fabdf0ffac8922c3 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 12:52:39 -0700 Subject: [PATCH 25/92] docs(ssh-relay): plan Task 7 agent codes corrected to 13/15 (real agent-proto.h values) --- docs/superpowers/plans/2026-10-08-ssh-relay-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md index 67fb185b9..f5501bbda 100644 --- a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md +++ b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md @@ -180,7 +180,7 @@ Every task implicitly includes these; values are copied from `docs/superpowers/s - [ ] **Step 1: Failing test.** A valid `REQUEST_IDENTITIES` forwarded to a stub agent returns a roster filtered to the grant's fingerprints; a `SIGN_REQUEST` whose blob hash is outside the set is refused (null) and never forwarded; `ADD_IDENTITY`/`REMOVE_ALL_IDENTITIES`/`EXTENSION` refused; a wrong-origin (bad B signature) request refused. - [ ] **Step 2: FAIL run.** -- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (13) and `SSH2_AGENTC_SIGN_REQUEST` (11) by numeric type (confirm against the target OpenSSH's `agent-proto.h`), refuse all else by default-deny; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). +- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (13) and `SSH2_AGENTC_SIGN_REQUEST` (15) by numeric type; DO NOT trust any remembered code - define the two constants by name and confirm the numeric values against the target OpenSSH `agent-proto.h` (SSH2_AGENTC_ADD_IDENTITY is 17, a common misread); refuse all else by default-deny; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). - [ ] **Step 4: PASS** + a test that the responder runs only for a brokered session naming this A and refuses an unbrokered one. - [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): A-side responder enforces origin, method allow-list, and grant fingerprint set"`. From 083ad869b93b06f0d63cbb1cbb7effe0eaa41996 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:01:34 -0700 Subject: [PATCH 26/92] fix(ssh-relay): Task 6 review - burn slot on conn death, deliver-or-fail send, validate before parse, correct agent-code comment --- .../agent/src/__tests__/relay-proxy.test.ts | 179 +++++++++++++++++- apps/node/agent/src/commands/ssh-relay.ts | 8 +- apps/node/agent/src/relay-proxy.ts | 100 ++++++++-- 3 files changed, 259 insertions(+), 28 deletions(-) diff --git a/apps/node/agent/src/__tests__/relay-proxy.test.ts b/apps/node/agent/src/__tests__/relay-proxy.test.ts index f0355872e..3c4121a30 100644 --- a/apps/node/agent/src/__tests__/relay-proxy.test.ts +++ b/apps/node/agent/src/__tests__/relay-proxy.test.ts @@ -26,10 +26,18 @@ import { type AgentProxyHandle, buildAgentSocketPath, startAgentProxy } from ".. * consumes is the shipped wire shape end to end. */ -/** The ssh-agent wire type bytes (OpenSSH `agent-proto.h`). */ -const SSH2_AGENTC_REQUEST_IDENTITIES = 17; -const SSH2_AGENTC_SIGN_REQUEST = 13; -const SSH2_AGENT_IDENTITIES = 18; +/** + * The ssh-agent wire type bytes as OpenSSH `agent-proto.h` defines them: + * SSH2_AGENTC_REQUEST_IDENTITIES is 13 and SSH2_AGENTC_SIGN_REQUEST is 15 + * (NOT the RFC 9987 renumbering 11/13; 17/18 are ADD/REMOVE IDENTITY, the + * common misread the first draft of this file carried). These are opaque + * fixture payloads only: the B proxy forwards agent bytes byte-agnostically + * and never parses a type byte - the numeric type gate is A's responder's + * default-deny (Task 7). + */ +const SSH2_AGENTC_REQUEST_IDENTITIES = 13; +const SSH2_AGENTC_SIGN_REQUEST = 15; +const SSH2_AGENT_IDENTITIES_ANSWER = 12; interface JwkPair { publicJwk: string; @@ -112,7 +120,11 @@ interface ProxyFixture { cleanup(): void; } -async function startFixture(paneId = "pane-1"): Promise { +async function startFixture( + paneId = "pane-1", + /** Overrides the pump entirely (a throwing pump tests deliver-or-throw); default collects. */ + send?: (frame: RelayFrame) => void, +): Promise { const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-proxy-")); const frames: RelayFrame[] = []; @@ -131,6 +143,10 @@ async function startFixture(paneId = "pane-1"): Promise { seal: mcpSeal, open: mcpOpen, sendRelayFrame: (frame) => { + if (send) { + send(frame); + return; + } frames.push(frame); }, }); @@ -256,7 +272,12 @@ test("a second request gets seq 1 and carries the recorded nA onward", async () const first = await openAndVerifyB2A(f, f.frames[0]); const nA = newNonce(); - const reply = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [SSH2_AGENT_IDENTITIES, 0, 0, 0, 0] }); + const reply = await sealReply(f, { + seq: 0, + nB: first.nB, + nA, + agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER, 0, 0, 0, 0], + }); f.proxy.deliverInboundRelayFrame(reply); await waitUntil(() => spyC.chunks.length === 1, "reply bytes written back"); @@ -287,7 +308,7 @@ test("a correctly sealed+signed A reply (stub A keypair pinned) writes the agent await waitUntil(() => f.frames.length === 1, "request frame out"); const first = await openAndVerifyB2A(f, f.frames[0]); - const replyBytes = [SSH2_AGENT_IDENTITIES, 0, 0, 0, 2, 0, 0, 0, 1, 65]; + const replyBytes = [SSH2_AGENT_IDENTITIES_ANSWER, 0, 0, 0, 2, 0, 0, 0, 1, 65]; const reply = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: replyBytes }); f.proxy.deliverInboundRelayFrame(reply); await waitUntil(() => spyC.chunks.length >= 1, "reply written back"); @@ -316,7 +337,7 @@ test("a reply signed by the WRONG key is refused: nothing written, the pending c seq: 0, nB: first.nB, nA: newNonce(), - agentBytes: [SSH2_AGENT_IDENTITIES], + agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER], signerPrivateJwk: evil.privateJwk, // not the pinned key: an origin failure }); f.proxy.deliverInboundRelayFrame(forged); @@ -342,7 +363,7 @@ test("a reply with a mismatched relay-session id or nB nonce is refused and not seq: 0, nB: first.nB, nA: newNonce(), - agentBytes: [SSH2_AGENT_IDENTITIES], + agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER], relaySessionId: "relay-OTHER", }); f.proxy.deliverInboundRelayFrame(wrongSession); @@ -359,7 +380,7 @@ test("a reply with a mismatched relay-session id or nB nonce is refused and not seq: 0, nB: newNonce(), nA: newNonce(), - agentBytes: [SSH2_AGENT_IDENTITIES], + agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER], }); f.proxy.deliverInboundRelayFrame(wrongNonce); await waitUntil(() => spy2.closed(), "nonce mismatch fails the pending connection"); @@ -433,6 +454,108 @@ test("an openable but unsigned-sealed blob (plane-crafted noise, wrong recipient } }); +/* ------------------------------------------------------------------ */ +/* FIFO integrity: a reply to a dead connection burns the slot */ +/* ------------------------------------------------------------------ */ + +test("a reply to a dead connection is absorbed: never written to the next connection's socket", async () => { + const f = await startFixture(); + try { + const x = connectSocket(f.proxy.socketPath); + const spyX = spy(x); + await new Promise((res) => x.on("connect", () => res())); + x.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + await waitUntil(() => f.frames.length === 1, "X's request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + // Y queues behind X's outstanding slot. + const y = connectSocket(f.proxy.socketPath); + const spyY = spy(y); + await new Promise((res) => y.on("connect", () => res())); + y.write(agentFrame([SSH2_AGENTC_SIGN_REQUEST, 1, 2, 3])); + await sleep(30); + expect(f.frames.length).toBe(1); // queued unsealed: the slot holds X's request + + // X dies with its reply still owed. The slot BURNS: it keeps holding the + // dead request, so the queue must NOT promote yet (an immediate release + // would put Y's request on the wire while A's answer to X is still in + // flight, and that answer would then land on Y's socket). + x.destroy(); + await waitUntil(() => spyX.closed(), "X's socket closed"); + await sleep(30); + expect(f.frames.length).toBe(1); // burned slot: no early promote + + // A's genuine reply to X arrives: fresh, gate-passing seq 0, correctly + // signed. It must be verified and DISCARDED, never delivered to Y. + const nA = newNonce(); + const replyToX = await sealReply(f, { seq: 0, nB: first.nB, nA, agentBytes: [99, 98, 97] }); + f.proxy.deliverInboundRelayFrame(replyToX); + await waitUntil(() => f.frames.length === 2, "the absorb releases the slot and Y's queued request goes out"); + expect(spyY.chunks.length).toBe(0); // NEVER X's agent answer inside Y's stream + + // Y's request left as seq 1 (X lawfully consumed 0), carrying the recorded nA. + const second = await openAndVerifyB2A(f, f.frames[1]); + expect(f.frames[1].seq).toBe(1); + expect(second.seq).toBe(1); + expect(second.nA).toBe(nA); + expect(second.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_SIGN_REQUEST, 1, 2, 3])); + + // A's reply to Y (seq 1) arrives: THAT is what Y receives, in order. + const replyToY = await sealReply(f, { seq: 1, nB: first.nB, nA, agentBytes: [1, 2, 3] }); + f.proxy.deliverInboundRelayFrame(replyToY); + await waitUntil(() => spyY.chunks.length === 1, "Y's own reply written to Y"); + expect(spyY.chunks[0].subarray(4)).toEqual(Buffer.from([1, 2, 3])); + expect(spyY.closed()).toBe(false); + } finally { + f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* deliver-or-throw: a refused send fails the request, never hangs it */ +/* ------------------------------------------------------------------ */ + +test("a sendRelayFrame that throws fails the connection and drains the queue; no phantom slot, no consumed seq", async () => { + const sent: RelayFrame[] = []; + let attempts = 0; + const f = await startFixture("pane-throw", (frame) => { + attempts += 1; + if (attempts === 1) throw new Error("link down: the pump dropped the send"); + sent.push(frame); + }); + try { + const a = connectSocket(f.proxy.socketPath); + const spyA = spy(a); + await new Promise((res) => a.on("connect", () => res())); + // req1 takes the slot straight away; req2 queues behind the in-flight + // seal (arrives in the same read while sealedInFlight holds). + a.write(Buffer.concat([agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES]), agentFrame([SSH2_AGENTC_SIGN_REQUEST, 9])])); + await waitUntil(() => spyA.closed(), "the connection fails on the refused send instead of hanging"); + expect(attempts).toBe(1); // only req1 reached the pump; req2 died at the liveness gate + expect(sent.length).toBe(0); // nothing ever reached A + + // The throw is NON-DELIVERY: no slot was claimed, so the session is free + // for the next request, and the failed send consumed no seq (A never saw + // the request; the retry lawfully re-uses 0). + const b = connectSocket(f.proxy.socketPath); + const spyB = spy(b); + await new Promise((res) => b.on("connect", () => res())); + b.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES, 4, 5])); + await waitUntil(() => sent.length === 1, "the next request goes out after the failed send"); + expect(sent[0].seq).toBe(0); + const first = await openAndVerifyB2A(f, sent[0]); + expect(first.seq).toBe(0); + expect(first.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES, 4, 5])); + + const reply = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: [7, 7] }); + f.proxy.deliverInboundRelayFrame(reply); + await waitUntil(() => spyB.chunks.length === 1, "the live connection's reply is written"); + expect(spyB.chunks[0].subarray(4)).toEqual(Buffer.from([7, 7])); + } finally { + f.cleanup(); + } +}); + /* ------------------------------------------------------------------ */ /* the socket file and its directory */ /* ------------------------------------------------------------------ */ @@ -477,6 +600,42 @@ test("close() unbinds: the socket file is gone and a fresh connect fails", async f.proxy.close(); }); +test("a malformed pinned peer JWK surfaces the named refusal, not a raw SyntaxError", async () => { + const [aKeys, bKeys] = await Promise.all([aKeysReady, bKeysReady]); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-proxy-")); + const base = { + dataDir, + paneId: "pane-bad", + relayId: "relay-1", + ref: "r-1", + peerNodeId: "a-node", + selfNodeId: "b-node", + peerSigningJwk: aKeys.signing.publicJwk, + peerEncryptionJwk: aKeys.encryption.publicJwk, + ownEncryptionPublicJwk: bKeys.encryption.publicJwk, + ownEncryptionPrivateJwk: bKeys.encryption.privateJwk, + ownSigningPrivateJwk: bKeys.signing.privateJwk, + seal: mcpSeal, + open: mcpOpen, + sendRelayFrame: (): void => {}, + }; + // Not JSON at all: bytesOfJwk's guard runs FIRST and turns the parse + // failure into the documented refusal (the parse now happens only after + // the guard, so a raw SyntaxError can never escape startAgentProxy). + await expect(startAgentProxy({ ...base, peerSigningJwk: "{not json" })).rejects.toThrow( + /relay proxy: pinned peer key rejected/, + ); + await expect(startAgentProxy({ ...base, peerEncryptionJwk: "!!!" })).rejects.toThrow( + /relay proxy: pinned peer key rejected/, + ); + // Deep private material in a pin half keeps the same named refusal. + await expect(startAgentProxy({ ...base, peerSigningJwk: aKeys.signing.privateJwk })).rejects.toThrow( + /relay proxy: pinned peer key rejected/, + ); + // The refusal precedes any binding: no socket name exists. + expect(() => statSync(buildAgentSocketPath(dataDir, "pane-bad"))).toThrow(); +}); + test("an over-cap request length prefix fails the connection without emitting a frame", async () => { const f = await startFixture(); try { diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index 0bee80776..0e54b1d35 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -142,7 +142,13 @@ export interface BRelaySessionArgs { paneId: string; /** The verified `ssh_relay_open` body; its `role` MUST be "B". */ cmd: SshRelayOpenCommand; - /** The link pump: one sealed B2A frame onto the current socket (drops are the daemon's). */ + /** + * The link pump: one sealed B2A frame onto the current socket. Forwarded to + * the proxy unchanged, so it carries the proxy's DELIVER-OR-THROW contract: + * a dropped send must THROW here (Task-8 glue obligation), never log and + * return - the proxy fails the request's connection on a throw rather than + * parking a phantom reply that never comes. + */ sendRelayFrame(frame: RelayFrame): void; /** Line sink (defaults to the agent logger); never sees keys or agent bytes. */ log?: (line: string) => void; diff --git a/apps/node/agent/src/relay-proxy.ts b/apps/node/agent/src/relay-proxy.ts index 4df897d8d..5e0fe15d9 100644 --- a/apps/node/agent/src/relay-proxy.ts +++ b/apps/node/agent/src/relay-proxy.ts @@ -44,6 +44,14 @@ import { log } from "./log.js"; * agent requests per connection; the queue makes that true across the relay * too, which is what makes replies safely FIFO. * + * When a connection dies with its request ALREADY delivered, the slot BURNS + * rather than freeing: it keeps holding the dead request's seq until that + * request's reply arrives, and that reply - verified through the same + * checklist as any - is decoded and DISCARDED without touching a socket. + * Only after the absorb does the slot release and the queue promote, so a + * dead request's honest answer can never be written into a live connection's + * ssh stream. + * * Key handling mirrors the caller checklist in `task-5-report` verbatim: the * wrapper is opened with B's own encryption key and the FRAME's ref/direction * (`expect` required, M-1); the signature is verified against A's pinned @@ -83,7 +91,15 @@ export interface AgentProxyArgs { seal: RelaySealFn; /** The opening primitive - mcp-core's `open`. */ open: RelayOpenFn; - /** Hand one sealed B2A frame to the link pump (the daemon's `send`, via the ctx seam). */ + /** + * Hand one sealed B2A frame to the link pump (the daemon's `send`, via the + * ctx seam). DELIVER-OR-THROW: the proxy treats a THROW from this callback + * as NON-DELIVERY - the request's connection is failed, no slot is taken, + * and the queue advances (the seal-failure posture). The Task-8 glue MUST + * therefore throw on a dropped send: a pump that logs-and-returns while no + * socket lives would otherwise leave a phantom request whose reply never + * comes, hanging the session until an external teardown. + */ sendRelayFrame(frame: RelayFrame): void; /** Line sink; defaults to the agent logger. Never receives keys, nonces, or agent bytes. */ log?: (line: string) => void; @@ -122,12 +138,28 @@ function framing(payload: Buffer): Buffer { return Buffer.concat([header, payload]); } -/** A live client connection plus its receive buffer and (while outstanding) its relay slot. */ +/** A live client connection plus its receive buffer. */ interface ConnState { socket: Socket; buffer: Buffer; } +/** + * The ONE outstanding request (the FIFO head at the relay) plus its burn + * state. While `awaitingAbsorb` is false, `conn` owns the reply when it + * lands. Once that connection has DIED after its frame was delivered, the + * slot burns: the next gate-passed A2B reply belongs to the dead request, is + * verified through the full checklist and then discarded without touching any + * socket, and only after that does the slot release and the queue promote. + */ +interface OutstandingSlot { + conn: ConnState; + /** The signed seq the delivered request carried (burn bookkeeping, log line). */ + signedSeq: number; + /** True once the slot's connection died with the reply still owed to it. */ + awaitingAbsorb: boolean; +} + /** * Start the B-side proxy: bind the socket, then speak the ssh-agent wire until * `close()`. Rejects (without leaving a bound socket behind) when the pinned @@ -139,15 +171,17 @@ export async function startAgentProxy(args: AgentProxyArgs): Promise(); /** The ONE outstanding request (the FIFO head at the relay), if any. */ - let outstanding: { conn: ConnState } | null = null; + let outstanding: OutstandingSlot | null = null; /** True while a seal is in flight: no slot has been taken, and no other request starts. */ let sealedInFlight = false; const queue: { conn: ConnState; payload: Buffer }[] = []; @@ -219,11 +253,12 @@ export async function startAgentProxy(args: AgentProxyArgs): Promise => { const agentBytesB64 = base64UrlNoPad(new Uint8Array(payload)); + const reqSeq = signedSeq; // the anti-replay seq this request signs into its envelope const message = { relaySessionId: args.relayId, routingRef: args.ref, direction: "B2A" as const, - seq: signedSeq, + seq: reqSeq, nB, ...(nA === undefined ? {} : { nA }), agentBytesB64, @@ -250,7 +285,6 @@ export async function startAgentProxy(args: AgentProxyArgs): Promise { @@ -347,7 +400,8 @@ export async function startAgentProxy(args: AgentProxyArgs): Promise => { if (closed) return; let opened; @@ -399,14 +453,26 @@ export async function startAgentProxy(args: AgentProxyArgs): Promise Date: Thu, 8 Oct 2026 13:02:12 -0700 Subject: [PATCH 27/92] docs(ssh-relay): plan T7 - OpenSSH agent-proto.h numbering (13/15), not RFC 9987 (11/13); e2e is arbiter --- docs/superpowers/plans/2026-10-08-ssh-relay-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md index f5501bbda..05e43a6f4 100644 --- a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md +++ b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md @@ -180,7 +180,7 @@ Every task implicitly includes these; values are copied from `docs/superpowers/s - [ ] **Step 1: Failing test.** A valid `REQUEST_IDENTITIES` forwarded to a stub agent returns a roster filtered to the grant's fingerprints; a `SIGN_REQUEST` whose blob hash is outside the set is refused (null) and never forwarded; `ADD_IDENTITY`/`REMOVE_ALL_IDENTITIES`/`EXTENSION` refused; a wrong-origin (bad B signature) request refused. - [ ] **Step 2: FAIL run.** -- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (13) and `SSH2_AGENTC_SIGN_REQUEST` (15) by numeric type; DO NOT trust any remembered code - define the two constants by name and confirm the numeric values against the target OpenSSH `agent-proto.h` (SSH2_AGENTC_ADD_IDENTITY is 17, a common misread); refuse all else by default-deny; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). +- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (13) and `SSH2_AGENTC_SIGN_REQUEST` (15) by numeric type; DO NOT trust any remembered code - define the two constants by name and confirm the numeric values against the target OpenSSH `agent-proto.h` (SSH2_AGENTC_ADD_IDENTITY is 17, a common misread). Use OpenSSH's numbering (identities 13, sign 15); RFC 9987 renumbers these to 11/13 - do NOT use RFC 9987 here, the wire and the agent are OpenSSH; the real-agent e2e (Task 16) is the arbiter. Refuse all else by default-deny; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). - [ ] **Step 4: PASS** + a test that the responder runs only for a brokered session naming this A and refuses an unbrokered one. - [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): A-side responder enforces origin, method allow-list, and grant fingerprint set"`. From 6beb4f6663ea78dcc147a764c78c341ec53d21bd Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:09:24 -0700 Subject: [PATCH 28/92] feat(ssh-relay): A-side responder enforces origin, method allow-list, and grant fingerprint set --- .../src/__tests__/relay-responder.test.ts | 864 ++++++++++++++++++ apps/node/agent/src/commands/ssh-relay.ts | 147 ++- apps/node/agent/src/relay-responder.ts | 599 ++++++++++++ 3 files changed, 1599 insertions(+), 11 deletions(-) create mode 100644 apps/node/agent/src/__tests__/relay-responder.test.ts create mode 100644 apps/node/agent/src/relay-responder.ts diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts new file mode 100644 index 000000000..49ac52ddb --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -0,0 +1,864 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { + base64UrlNoPad, + newNonce, + openRelayEnvelope, + parseRelayFrame, + type RelayFrame, + type SshRelayOpenCommand, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import { exportJWK, generateKeyPair } from "jose"; +import { openARelaySession, openBRelaySession, RelaySessions } from "../commands/ssh-relay.js"; +import { loadOrCreateIdentity } from "../identity.js"; +import { + filterIdentitiesAnswer, + fingerprintAgentBlob, + liveAgentSocketPath, + SSH2_AGENT_FAILURE, + SSH2_AGENT_IDENTITIES_ANSWER, + SSH2_AGENT_SIGN_RESPONSE, + SSH2_AGENTC_REQUEST_IDENTITIES, + SSH2_AGENTC_SIGN_REQUEST, + startRelayResponder, +} from "../relay-responder.js"; + +/** + * The A-side responder (spec 2026-10-08 §5.4/§5.6): inbound B2A frames are + * opened, origin-verified against B's PINNED signing key with the full Task-5 + * caller checklist, default-denied by agent method, scoped to the grant's + * fingerprint set against A's live agent (a stub Unix socket here), and the + * reply is sealed+signed back as an A2B frame. seal/open are the REAL + * mcp-core pair, the keys are generated exactly as the protocol and proxy + * tests do, and the test side computes fingerprints independently (node:crypto + * over the wire blob) so the scheme is pinned, not echoed. + */ + +/* ------------------------------------------------------------------ */ +/* the agent wire (OpenSSH `agent-proto.h` values) */ +/* ------------------------------------------------------------------ */ + +/** + * Non-allow-listed request types used as refusals witnesses, named by their + * OpenSSH `agent-proto.h` values: 17 ADD_IDENTITY, 18 REMOVE_IDENTITY, + * 19 REMOVE_ALL_IDENTITIES, 22 LOCK, 23 UNLOCK, 27 EXTENSION, and the + * SSH1-era range starting at 1. (The two FORWARDED types are the production + * constants imported above.) + */ +const SSH2_AGENTC_ADD_IDENTITY = 17; +const SSH2_AGENTC_REMOVE_IDENTITY = 18; +const SSH2_AGENTC_REMOVE_ALL_IDENTITIES = 19; +const SSH2_AGENTC_LOCK = 22; +const SSH2_AGENTC_UNLOCK = 23; +const SSH2_AGENTC_EXTENSION = 27; +const SSH1_AGENTC_REQUEST_RSA_IDENTITIES = 1; + +/** An independent fingerprint oracle for the tests: SHA-256, base64url no pad. */ +function fp(wire: Uint8Array): string { + return `SHA256:${createHash("sha256").update(wire).digest("base64url")}`; +} + +function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +/** An SSH string on the agent wire: 4-byte BE length + bytes. */ +function sshStr(bytes: Buffer | string): Buffer { + const b = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes; + return Buffer.concat([be32(b.length), b]); +} + +function buildIdentitiesAnswer(entries: { blob: Buffer; comment: string }[]): Buffer { + return Buffer.concat([ + Buffer.from([SSH2_AGENT_IDENTITIES_ANSWER]), + be32(entries.length), + ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), + ]); +} + +function buildSignRequest(keyBlob: Buffer, flags = 0): Buffer { + return Buffer.concat([Buffer.from([SSH2_AGENTC_SIGN_REQUEST]), sshStr(keyBlob), be32(flags)]); +} + +/** Decode an IDENTITIES_ANSWER the test way (independent of the production parser). */ +function parseAnswer(answer: Buffer): { type: number; count: number; comments: string[] } { + const type = answer[0]; + const count = answer.readUInt32BE(1); + const comments: string[] = []; + let off = 5; + for (let i = 0; i < count; i += 1) { + const blobLen = answer.readUInt32BE(off); + off += 4 + blobLen; + const cLen = answer.readUInt32BE(off); + comments.push(answer.subarray(off + 4, off + 4 + cLen).toString("utf8")); + off += 4 + cLen; + } + return { type, count, comments }; +} + +function framed(payload: Buffer): Buffer { + return Buffer.concat([be32(payload.length), payload]); +} + +/* ------------------------------------------------------------------ */ +/* the stub live agent: a Unix socket that answers the agent wire */ +/* ------------------------------------------------------------------ */ + +interface StubAgent { + path: string; + /** Every request payload the stub RECEIVED: the "never forwarded" witness. */ + received: Buffer[]; + close(): Promise; +} + +async function startStubAgent(roster: { blob: Buffer; comment: string }[]): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-stub-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + for (;;) { + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + const request = Buffer.from(buffer.subarray(4, 4 + len)); + buffer = buffer.subarray(4 + len); + received.push(request); + const type = request[0]; + if (type === SSH2_AGENTC_REQUEST_IDENTITIES) { + conn.write(framed(buildIdentitiesAnswer(roster))); + } else if (type === SSH2_AGENTC_SIGN_REQUEST) { + // The stub signs ANYTHING it is asked: "the stub never received it" + // is the witness that the responder refused before forwarding. + conn.write(framed(Buffer.concat([Buffer.from([SSH2_AGENT_SIGN_RESPONSE]), sshStr(Buffer.from("SIG"))]))); + } else { + conn.write(framed(Buffer.from([SSH2_AGENT_FAILURE]))); + } + } + }); + conn.on("error", () => { + /* the responder closes per request; the stub never throws */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { path, received, close: () => new Promise((r) => server.close(() => r())) }; +} + +/* ------------------------------------------------------------------ */ +/* machine identities */ +/* ------------------------------------------------------------------ */ + +interface JwkPair { + publicJwk: string; + privateJwk: string; +} + +async function es256Pair(): Promise { + const { publicKey, privateKey } = await generateKeyPair("ES256", { extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +async function ecdhPair(): Promise { + const { publicKey, privateKey } = await generateKeyPair("ECDH-ES", { crv: "P-256", extractable: true }); + return { + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), + }; +} + +async function machineKeys(): Promise<{ signing: JwkPair; encryption: JwkPair }> { + const [signing, encryption] = await Promise.all([es256Pair(), ecdhPair()]); + return { signing, encryption }; +} + +// B = the connecting machine (test-generated keys); A = this machine, whose +// identity the responder loads from the data dir through identity.ts itself. +const bKeysReady = machineKeys(); +/** An ES256 pair nobody pinned: requests signed with it are forgeries. */ +const evilKeysReady: Promise = es256Pair(); + +/* ------------------------------------------------------------------ */ +/* the fixture: a brokered A session over RelaySessions + the stub */ +/* ------------------------------------------------------------------ */ + +const KEY_IN = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-IN"))]); +const KEY_OUT = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-OUT"))]); +const ROSTER = [ + { blob: KEY_IN, comment: "granted key" }, + { blob: KEY_OUT, comment: "ungranted key" }, +]; + +interface Fixture { + relay: RelaySessions; + frames: RelayFrame[]; + stub: StubAgent; + dataDir: string; + aPublic: { signing: string; encryption: string }; + aPrivate: { signing: string; encryption: string }; + bKeys: { signing: JwkPair; encryption: JwkPair }; + cleanup(): Promise; +} + +/** Open the A side with the shipped command shape through the real registration path. */ +async function openFixture( + overrides: Partial = {}, + opts: { resolveAgentSocket?: () => string | null; send?: (frame: RelayFrame) => void } = {}, +): Promise { + const bKeys = await bKeysReady; + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-responder-")); + // The responder loads A's identity from the data dir; mint it first so the + // test side knows A's keys (loadOrCreateIdentity is idempotent per dir). + const aIdentity = await loadOrCreateIdentity(dataDir); + const stub = await startStubAgent(ROSTER); + const relay = new RelaySessions(); + const frames: RelayFrame[] = []; + const cmd: SshRelayOpenCommand = { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-1", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(bKeys.encryption.publicJwk, "utf8").toString("base64"), + grantId: "grant-1", + fingerprints: [fp(KEY_IN)], + lifetimeMs: 30_000, + ...overrides, + }; + await openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd, + sendRelayFrame: + opts.send ?? + ((frame: RelayFrame): void => { + frames.push(frame); + }), + resolveAgentSocket: opts.resolveAgentSocket ?? ((): string => stub.path), + }); + return { + relay, + frames, + stub, + dataDir, + aPublic: { signing: aIdentity.signingPublicJwk, encryption: aIdentity.publicJwk }, + aPrivate: { signing: aIdentity.signingPrivateJwk, encryption: aIdentity.privateJwk }, + bKeys, + cleanup: async () => { + relay.closeAll("lifetime-expiry"); + await stub.close(); + }, + }; +} + +/** Seal one B2A request exactly as B's proxy does (Task 6 wire posture). */ +async function sealRequest( + f: Fixture, + opts: { + agentBytes: Buffer; + seq: number; + nB: string; + nA?: string; + signerPrivateJwk?: string; + relaySessionId?: string; + ref?: string; + direction?: "B2A" | "A2B"; + }, +): Promise { + const blob = await sealRelayEnvelope({ + message: { + relaySessionId: opts.relaySessionId ?? "relay-1", + routingRef: opts.ref ?? "r-1", + direction: opts.direction ?? "B2A", + seq: opts.seq, + nB: opts.nB, + ...(opts.nA === undefined ? {} : { nA: opts.nA }), + agentBytesB64: base64UrlNoPad(new Uint8Array(opts.agentBytes)), + }, + privateJwk: JSON.parse(opts.signerPrivateJwk ?? f.bKeys.signing.privateJwk) as JsonWebKey, + recipient: { principalId: "node:a-node", publicJwk: f.aPublic.encryption }, + seal: mcpSeal, + }); + const direction = opts.direction ?? "B2A"; + return { type: "relay", ref: opts.ref ?? "r-1", seq: opts.seq, direction, blob }; +} + +/** Open + verify one A2B frame AS B: the mirror of A's inbound checklist. */ +async function decodeA2B( + f: Fixture, + frame: RelayFrame, + expect: { nB: string; nA?: string }, +): Promise<{ agentBytes: Buffer; nA?: string; seq: number }> { + const opened = await openRelayEnvelope({ + blob: frame.blob, + own: { + principalId: "node:b-node", + publicJwk: f.bKeys.encryption.publicJwk, + privateJwk: f.bKeys.encryption.privateJwk, + }, + open: mcpOpen, + expect: { ref: frame.ref, direction: frame.direction }, + }); + const message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: JSON.parse(f.aPublic.signing) as JsonWebKey, + expect: { + routingRef: frame.ref, + direction: "A2B", + seq: opened.seq, + relaySessionId: "relay-1", + nB: expect.nB, + ...(expect.nA === undefined ? {} : { nA: expect.nA }), + }, + }); + return { + agentBytes: Buffer.from(message.agentBytesB64, "base64url"), + nA: message.nA, + seq: opened.seq, + }; +} + +async function sleep(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)); +} + +async function waitUntil(cond: () => boolean, what: string, timeoutMs = 3000): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > timeoutMs) throw new Error(`timed out waiting for ${what}`); + await sleep(5); + } +} + +/* ------------------------------------------------------------------ */ +/* REQUEST_IDENTITIES: forwarded, then filtered to the grant set */ +/* ------------------------------------------------------------------ */ + +test("a REQUEST_IDENTITIES (13) from pinned B gets an IDENTITIES_ANSWER filtered to the grant fingerprints, and the session's second request rides the recorded nA", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const req1 = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + expect(parseRelayFrame(JSON.stringify(req1))).not.toBeNull(); + f.relay.onInboundRelayFrame(req1); + await waitUntil(() => f.frames.length === 1, "one A2B reply frame"); + + const frame1 = f.frames[0]; + expect(frame1.type).toBe("relay"); + expect(frame1.ref).toBe("r-1"); + expect(frame1.direction).toBe("A2B"); + expect(frame1.seq).toBe(0); + const reply1 = await decodeA2B(f, frame1, { nB }); + expect(reply1.nA).toMatch(/^[A-Za-z0-9_-]{22}$/); // A minted its endpoint nonce (§5.6) + expect(reply1.seq).toBe(0); // the signed seq of the first reply + + const answer = parseAnswer(reply1.agentBytes); + expect(answer.type).toBe(SSH2_AGENT_IDENTITIES_ANSWER); + expect(answer.count).toBe(1); // the stub roster had 2; only the granted fingerprint survives + expect(answer.comments).toEqual(["granted key"]); + expect(f.stub.received.length).toBe(1); // the agent saw exactly one request + expect(f.stub.received[0]).toEqual(Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES])); + + // The second request carries A's recorded nonce; A's expect binds it onward. + const req2 = await sealRequest(f, { agentBytes: buildSignRequest(KEY_IN), seq: 1, nB, nA: reply1.nA }); + f.relay.onInboundRelayFrame(req2); + await waitUntil(() => f.frames.length === 2, "the second A2B reply frame"); + const reply2 = await decodeA2B(f, f.frames[1], { nB, nA: reply1.nA }); + expect(f.frames[1].seq).toBe(1); // transport seq advanced + expect(reply2.seq).toBe(1); // signed anti-replay seq advanced + expect(reply2.nA).toBe(reply1.nA); // every A2B carries the SAME nA (Task-6 handoff) + expect(reply2.agentBytes).toEqual( + Buffer.concat([Buffer.from([SSH2_AGENT_SIGN_RESPONSE]), sshStr(Buffer.from("SIG"))]), + ); + expect(f.stub.received.length).toBe(2); + expect(f.stub.received[1]).toEqual(buildSignRequest(KEY_IN)); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* SIGN_REQUEST: in-set forwarded, out-of-set refused before the agent */ +/* ------------------------------------------------------------------ */ + +test("a SIGN_REQUEST (15) for an OUT-of-set blob is refused with SSH2_AGENT_FAILURE and never forwarded to A's agent", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: buildSignRequest(KEY_OUT), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "a refusal reply frame"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + await sleep(30); + expect(f.stub.received.length).toBe(0); // the stub agent never saw it (§5.4: refused at the responder) + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* method allow-list: default-deny on everything else */ +/* ------------------------------------------------------------------ */ + +test("disallowed request types (ADD 17, REMOVE 18, REMOVE_ALL 19, LOCK 22, UNLOCK 23, EXTENSION 27, SSH1 1) are refused and never forwarded", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const disallowed = [ + SSH2_AGENTC_ADD_IDENTITY, + SSH2_AGENTC_REMOVE_IDENTITY, + SSH2_AGENTC_REMOVE_ALL_IDENTITIES, + SSH2_AGENTC_LOCK, + SSH2_AGENTC_UNLOCK, + SSH2_AGENTC_EXTENSION, + SSH1_AGENTC_REQUEST_RSA_IDENTITIES, + ]; + let nA: string | undefined; + for (const [i, type] of disallowed.entries()) { + const req = await sealRequest(f, { + agentBytes: Buffer.from([type, 1, 2, 3]), + seq: i, + nB, + ...(nA === undefined ? {} : { nA }), + }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === i + 1, `a refusal for disallowed type ${type}`); + const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); + expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + expect(f.frames[i].seq).toBe(i); + nA = reply.nA; // later requests must carry the recorded nonce onward (real B's state) + } + await sleep(30); + expect(f.stub.received.length).toBe(0); // nothing reached A's agent + } finally { + await f.cleanup(); + } +}); + +test("a malformed SIGN_REQUEST (truncated wire) is refused and not forwarded", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const truncated = Buffer.concat([Buffer.from([SSH2_AGENTC_SIGN_REQUEST]), sshStr(KEY_IN).subarray(0, 2)]); + const req = await sealRequest(f, { agentBytes: truncated, seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "a refusal reply frame"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + await sleep(30); + expect(f.stub.received.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the empty grant: serve nothing, never the full list */ +/* ------------------------------------------------------------------ */ + +test("a grant naming no fingerprint yields an empty (count 0) IDENTITIES_ANSWER, never the roster", async () => { + const f = await openFixture({ fingerprints: [] }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the filtered reply frame"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + const answer = parseAnswer(reply.agentBytes); + expect(answer.type).toBe(SSH2_AGENT_IDENTITIES_ANSWER); + expect(answer.count).toBe(0); + expect(reply.agentBytes.length).toBe(5); // type + zero count, nothing else + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* origin and replay: a refusal sends nothing and forwards nothing */ +/* ------------------------------------------------------------------ */ + +test("a request signed by a key that is not B's pin is refused: no reply frame, nothing forwarded", async () => { + const f = await openFixture(); + const evil = await evilKeysReady; + try { + const req = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 0, + nB: newNonce(), + signerPrivateJwk: evil.privateJwk, + }); + f.relay.onInboundRelayFrame(req); + await sleep(50); + expect(f.frames.length).toBe(0); + expect(f.stub.received.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +test("a request bound to a foreign relay-session id or a foreign nB nonce is refused", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + // Cross-session replay: right keys, another session's id. + const wrongSession = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 0, + nB, + relaySessionId: "relay-EVIL", + }); + f.relay.onInboundRelayFrame(wrongSession); + await sleep(50); + expect(f.frames.length).toBe(0); + + // Land the genuine first request so nB is recorded... + const good = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + f.relay.onInboundRelayFrame(good); + await waitUntil(() => f.frames.length === 1, "the genuine reply"); + const first = await decodeA2B(f, f.frames[0], { nB }); + + // ...then a second-seq request carrying a nonce this session never saw. + const foreignNonce = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 1, + nB: newNonce(), + nA: first.nA, + }); + f.relay.onInboundRelayFrame(foreignNonce); + await sleep(50); + expect(f.frames.length).toBe(1); // no reply to the foreign nonce + expect(f.stub.received.length).toBe(1); // and nothing further reached the agent + } finally { + await f.cleanup(); + } +}); + +test("a replayed or non-increasing-seq request is refused: no reply frame, nothing forwarded; the session continues", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const good = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + f.relay.onInboundRelayFrame(good); + await waitUntil(() => f.frames.length === 1, "the first genuine reply"); + const first = await decodeA2B(f, f.frames[0], { nB }); + + // Byte-identical resend of the first frame (the plane can resend anything). + f.relay.onInboundRelayFrame(good); + // A freshly signed request that rewinds the seq (the signature binds seq). + const rewind = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 0, + nB, + nA: first.nA, + }); + f.relay.onInboundRelayFrame(rewind); + await sleep(50); + expect(f.frames.length).toBe(1); + expect(f.stub.received.length).toBe(1); + + // The session still works after refusals: seq 1 advances. + const next = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 1, + nB, + nA: first.nA, + }); + f.relay.onInboundRelayFrame(next); + await waitUntil(() => f.frames.length === 2, "the next-seq reply still lands"); + } finally { + await f.cleanup(); + } +}); + +test("a frame on another ref or with the A2B direction never reaches the responder path", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + // Another ref: the registry itself drops it (the unbrokered-session case). + const foreignRef = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 0, + nB, + ref: "r-EVIL", + }); + f.relay.onInboundRelayFrame(foreignRef); + // The wrong direction for A's side (B's own echo posture). + const echo = await sealRequest(f, { + agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + seq: 0, + nB, + direction: "A2B", + }); + f.relay.onInboundRelayFrame(echo); + await sleep(50); + expect(f.frames.length).toBe(0); + expect(f.stub.received.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* registration: openARelaySession's guards + the relay-open carriage */ +/* ------------------------------------------------------------------ */ + +type OpenCmdBase = Omit; + +test("openARelaySession refuses a command that does not name this machine in its role, and the B branch guards its own", async () => { + const bKeys = await bKeysReady; + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-responder-")); + const encB64 = Buffer.from(bKeys.encryption.publicJwk, "utf8").toString("base64"); + const base: OpenCmdBase = { + type: "ssh_relay_open", + relayId: "relay-1", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + grantId: "grant-1", + fingerprints: [], + lifetimeMs: 30_000, + }; + // role B: the A branch is not for this command. + await expect( + openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: { + ...base, + role: "B", + ref: "r-9", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: encB64, + }, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/not the A side/); + // role A but aNodeId names someone else: not OUR brokered session. + await expect( + openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: { + ...base, + aNodeId: "someone-else", + ref: "r-9", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: encB64, + }, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/not the key home/); + // The mirrored guard on the B branch: bNodeId must name the receiver too. + await expect( + openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-guard", + cmd: { + ...base, + role: "B", + bNodeId: "someone-else", + ref: "r-8", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: encB64, + }, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/not the connecting side/); + expect(relay.size).toBe(0); +}); + +test("openARelaySession enforces §4.4 byte-equality on B's pin and the duplicate-ref rule", async () => { + const f = await openFixture(); // first pairing pinned b-node with bKeys + try { + const otherB = await machineKeys(); + const base: OpenCmdBase = { + type: "ssh_relay_open", + relayId: "relay-2", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + grantId: "grant-1", + fingerprints: [], + lifetimeMs: 30_000, + }; + // A moved signing half under the SAME peer id: a hard block, never a silent overwrite. + await expect( + openARelaySession({ + relay: f.relay, + dataDir: f.dataDir, + selfNodeId: "a-node", + cmd: { + ...base, + ref: "r-2", + peerSigningPublicKey: otherB.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(otherB.encryption.publicJwk, "utf8").toString("base64"), + }, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/pin for b-node has MOVED/); + // Same pinned pairing, already-owned ref: refused, registry untouched. + await expect( + openARelaySession({ + relay: f.relay, + dataDir: f.dataDir, + selfNodeId: "a-node", + cmd: { + ...base, + relayId: "relay-3", + ref: "r-1", + peerSigningPublicKey: f.bKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(f.bKeys.encryption.publicJwk, "utf8").toString("base64"), + }, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/routing ref already owned/); + expect(f.relay.size).toBe(1); + } finally { + await f.cleanup(); + } +}); + +test("a pinned peer key that is not a usable public P-256 JWK rejects the session before anything binds", async () => { + const bKeys = await bKeysReady; + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-responder-")); + // A PRIVATE signing JWK as the pin: bytesOfJwk's deep refusal, not a raw parse error. + await expect( + openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-1", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: bKeys.signing.privateJwk, + peerEncryptPublicKey: Buffer.from(bKeys.encryption.publicJwk, "utf8").toString("base64"), + grantId: "grant-1", + fingerprints: [], + lifetimeMs: 30_000, + }, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/relay open refused/); + expect(relay.size).toBe(0); +}); + +test("relay.close tears the responder down: later frames produce nothing", async () => { + const f = await openFixture(); + try { + expect(f.relay.has("r-1")).toBe(true); + expect(f.relay.close("r-1", "grant-revoked")).toBe(true); + expect(f.relay.has("r-1")).toBe(false); + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); // the registry: unknown ref now, dropped + await sleep(30); + expect(f.frames.length).toBe(0); + expect(f.stub.received.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the live agent socket seam */ +/* ------------------------------------------------------------------ */ + +test("liveAgentSocketPath honors only an absolute SSH_AUTH_SOCK (the ssh-resolve rule)", () => { + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "/run/user/1000/ssh-agent.sock" })).toBe("/run/user/1000/ssh-agent.sock"); + expect(liveAgentSocketPath({})).toBeNull(); + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "" })).toBeNull(); + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "relative/path.sock" })).toBeNull(); +}); + +test("with no live agent socket the responder answers SSH2_AGENT_FAILURE and touches nothing", async () => { + const f = await openFixture({}, { resolveAgentSocket: () => null }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the no-agent refusal reply"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + expect(f.stub.received.length).toBe(0); // the stub socket was never connected + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the filtering/parser helpers, pinned directly */ +/* ------------------------------------------------------------------ */ + +test("startRelayResponder rejects a pinned peer key that is not public P-256 material (the deep bytesOfJwk gate)", () => { + expect(() => + startRelayResponder({ + relayId: "relay-1", + ref: "r-1", + selfNodeId: "a-node", + peerNodeId: "b-node", + peerSigningJwk: "not even json", + peerEncryptionJwk: "not even json", + ownEncryptionPublicJwk: "{}", + ownEncryptionPrivateJwk: "{}", + ownSigningPrivateJwk: "{}", + fingerprints: [], + seal: mcpSeal, + open: mcpOpen, + sendRelayFrame: () => {}, + }), + ).toThrow(/relay responder: pinned peer key rejected/); +}); + +test("fingerprintAgentBlob spells SHA256 over the wire blob in the grant grammar's base64url form", () => { + const s = fingerprintAgentBlob(Buffer.from("hello")); + expect(s).toMatch(/^SHA256:[A-Za-z0-9_-]{43}$/); + expect(s).toBe(fp(Buffer.from("hello"))); +}); + +test("filterIdentitiesAnswer keeps only granted entries (count fixed, bytes preserved) and refuses malformed answers", () => { + const allowed = new Set([fp(KEY_OUT)]); // grant the SECOND entry: order must not matter + const filtered = filterIdentitiesAnswer(buildIdentitiesAnswer(ROSTER), allowed); + const answer = parseAnswer(filtered); + expect(answer.count).toBe(1); + expect(answer.comments).toEqual(["ungranted key"]); + // The kept entry's wire bytes survive byte-identical: type + count + str(blob) + str(comment). + expect(filtered).toEqual( + Buffer.concat([Buffer.from([SSH2_AGENT_IDENTITIES_ANSWER]), be32(1), sshStr(KEY_OUT), sshStr("ungranted key")]), + ); + expect(filterIdentitiesAnswer(buildIdentitiesAnswer([]), new Set()).length).toBe(5); + // Malformed: declared entries the buffer does not carry, and a wrong type byte. + const truncated = buildIdentitiesAnswer(ROSTER).subarray(0, 20); + expect(() => filterIdentitiesAnswer(truncated, new Set())).toThrow(); + expect(() => filterIdentitiesAnswer(Buffer.from([SSH2_AGENT_SIGN_RESPONSE, 0, 0, 0, 0]), new Set())).toThrow(); +}); diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index 0e54b1d35..eb2841966 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -4,6 +4,7 @@ import { loadOrCreateIdentity } from "../identity.js"; import { log } from "../log.js"; import { MachinePinStore } from "../machine-pin-store.js"; import { startAgentProxy } from "../relay-proxy.js"; +import { liveAgentSocketPath, startRelayResponder } from "../relay-responder.js"; /** * The node-side relay session registry (spec 2026-10-08 §5.1/§5.2) and the B @@ -12,11 +13,11 @@ import { startAgentProxy } from "../relay-proxy.js"; * The daemon's inbound route hands EVERY `relay` link frame to this module's * single dispatcher ({@link RelaySessions.onInboundRelayFrame}); the JWS * command path never sees one. Which local role owns a `ref` is the only - * question routing asks: today the B-side agent proxy registers itself - * ({@link openBRelaySession}); Task 7's A-side responder registers into the - * SAME registry for the other role, and Task 8's `ssh_relay_open` executor - * decides which branch a command drives by the command's own `role` field. - * That is the whole seam - one dispatch, one map, any number of roles. + * question routing asks: the B-side agent proxy registers itself + * ({@link openBRelaySession}) and the A-side responder registers into the + * SAME registry ({@link openARelaySession}); Task 8's `ssh_relay_open` + * executor decides which branch a command drives by the command's own `role` + * field. That is the whole seam - one dispatch, one map, both roles. * * §5.1's pairing doctrine needs the pairing itself: the plane's * `ssh_relay_open` command carries the relay-session id, the routing ref, both @@ -183,14 +184,27 @@ function decodePeerEncryptionJwk(b64: string): string { export async function openBRelaySession(args: BRelaySessionArgs): Promise<{ socketPath: string }> { const { cmd } = args; if (cmd.role !== "B") throw new Error("relay open refused: this machine is not the B side of the pairing"); + // The role says B; the pairing must ALSO name THIS machine as B (§5.4's + // "a session naming this machine" reads both branches alike - a command + // about someone else's pairing is refused before any pin is consulted). + if (cmd.bNodeId !== args.selfNodeId) { + throw new Error("relay open refused: this machine is not the connecting side named by the pairing"); + } // §4.4: both halves, byte-equal, in the node-side store (its own file, never - // honoring SUBSHELL_CHANNEL_PIN - MachinePinStore's own doctrine). - const candidate = { - signing: cmd.peerSigningPublicKey, - encryption: decodePeerEncryptionJwk(cmd.peerEncryptPublicKey), - }; - bytesOfJwk(candidate.signing); // deep public-only validity on the signing half too + // honoring SUBSHELL_CHANNEL_PIN - MachinePinStore's own doctrine). Every key + // gate lands in the SAME named refusal (the raw bytesOfJwk text would reach + // the dispatch answer otherwise): decode, deep-check, reject before any pin. + let candidate: { signing: string; encryption: string }; + try { + candidate = { + signing: cmd.peerSigningPublicKey, + encryption: decodePeerEncryptionJwk(cmd.peerEncryptPublicKey), + }; + bytesOfJwk(candidate.signing); // deep public-only validity on the signing half too + } catch (err) { + throw new Error(`relay open refused: peer pin rejected: ${err instanceof Error ? err.message : String(err)}`); + } const pins = new MachinePinStore(args.dataDir); const existing = pins.get(cmd.aNodeId); if (existing === null) { @@ -233,3 +247,114 @@ export async function openBRelaySession(args: BRelaySessionArgs): Promise<{ sock } return { socketPath: proxy.socketPath }; } + +/** Everything {@link openARelaySession} needs: the daemon's seams + the command's pairing. */ +export interface ARelaySessionArgs { + /** The daemon's registry this session joins. */ + relay: RelaySessions; + /** The node's data dir (config.dataDir): holds A's identity and B's pin. */ + dataDir: string; + /** This machine's node id (config.nodeId) - must equal `cmd.aNodeId`, the open-as principal. */ + selfNodeId: string; + /** The verified `ssh_relay_open` body; its `role` MUST be "A" and its `aNodeId` MUST be this machine. */ + cmd: SshRelayOpenCommand; + /** + * The link pump: one sealed A2B frame onto the current socket. The + * responder forwards it unchanged, so it carries the DELIVER-OR-THROW + * contract (the Task-8 glue obligation): a dropped send must THROW, and A + * consumes no seq for a reply that never left. + */ + sendRelayFrame(frame: RelayFrame): void; + /** + * Resolve A's live agent socket per request; defaults to + * `liveAgentSocketPath` (the connecting account's absolute `SSH_AUTH_SOCK`, + * the ssh-resolve rule). The test seam for a stub agent. + */ + resolveAgentSocket?: () => string | null; + /** Line sink (defaults to the agent logger); never sees keys, fingerprints, or agent bytes. */ + log?: (line: string) => void; +} + +/** + * Open the A side of a brokered relay pairing: enforce §4.4's machine pin on + * B (first pairing writes both halves, a moved pin is a hard block naming the + * peer and §4.5's re-pair), then start {@link startRelayResponder} for the + * command's grant (its selected fingerprint set is carried by the command + * itself, §5.1 - the responder enforces it without a REST call it cannot + * make) and register the ref. Throws a named refusal (the dispatch wrapper + * turns it into `ok:false`) on: role other than A, a pairing that names a + * different machine as A, an unusable peer key, a MOVED pin, or a ref already + * owned. Resolves with the session id - nothing about the machine is in the + * answer, and no path exists to leak. + * + * The gate (ssh-enabled mirror) is the CALLER's first check, like every SSH + * arm's: this function is the pairing's mechanics, not the policy. §5.4's + * roster command (`ssh_agent_identities`, Task 11) is a separate signed + * command, not this session: the responder serves ONLY brokered traffic. + */ +export async function openARelaySession(args: ARelaySessionArgs): Promise<{ relayId: string }> { + const { cmd } = args; + if (cmd.role !== "A") throw new Error("relay open refused: this machine is not the A side of the pairing"); + // §5.4: "a session naming this A" - the role alone is not enough when the + // plane brokers many machines' pairings through one daemon; the pairing + // must name THIS machine as the key home before any pin is consulted. + if (cmd.aNodeId !== args.selfNodeId) { + throw new Error(`relay open refused: this machine is not the key home ${cmd.aNodeId} named by the pairing`); + } + + // §4.4: both halves, byte-equal, in the node-side store (its own file, never + // honoring SUBSHELL_CHANNEL_PIN - MachinePinStore's own doctrine). The peer + // here is B: A pins whoever the command names as the connecting machine. + // Same named-refusal wrapper as the B branch. + let candidate: { signing: string; encryption: string }; + try { + candidate = { + signing: cmd.peerSigningPublicKey, + encryption: decodePeerEncryptionJwk(cmd.peerEncryptPublicKey), + }; + bytesOfJwk(candidate.signing); // deep public-only validity on the signing half too + } catch (err) { + throw new Error(`relay open refused: peer pin rejected: ${err instanceof Error ? err.message : String(err)}`); + } + const pins = new MachinePinStore(args.dataDir); + const existing = pins.get(cmd.bNodeId); + if (existing === null) { + pins.pin(cmd.bNodeId, candidate); // first pairing: the plane-delivered keys become the pin (§4.4/§4.6's accepted window) + } else if (pins.check(cmd.bNodeId, candidate) !== "ok") { + // Hard block naming the peer and pointing at recovery - §4.5: re-pair is + // an operator act; nothing here may "trust anyway". + throw new Error( + `relay open refused: machine pin for ${cmd.bNodeId} has MOVED (re-pair per §4.5 after out-of-band verification)`, + ); + } + + const identity = await loadOrCreateIdentity(args.dataDir); + const responder = startRelayResponder({ + relayId: cmd.relayId, + ref: cmd.ref, + selfNodeId: args.selfNodeId, + peerNodeId: cmd.bNodeId, + peerSigningJwk: candidate.signing, + peerEncryptionJwk: candidate.encryption, + ownEncryptionPublicJwk: identity.publicJwk, + ownEncryptionPrivateJwk: identity.privateJwk, + ownSigningPrivateJwk: identity.signingPrivateJwk, + fingerprints: cmd.fingerprints, + seal: mcpSeal, + open: mcpOpen, + sendRelayFrame: args.sendRelayFrame, + resolveAgentSocket: args.resolveAgentSocket ?? liveAgentSocketPath, + ...(args.log === undefined ? {} : { log: args.log }), + }); + + if ( + !args.relay.register(cmd.ref, { + onRelayFrame: (f) => responder.deliverInboundRelayFrame(f), + close: (reason) => responder.close(reason), + }) + ) { + responder.close(); + throw new Error("relay open refused: routing ref already owned by a live session"); + } + return { relayId: cmd.relayId }; +} diff --git a/apps/node/agent/src/relay-responder.ts b/apps/node/agent/src/relay-responder.ts new file mode 100644 index 000000000..b41f31a60 --- /dev/null +++ b/apps/node/agent/src/relay-responder.ts @@ -0,0 +1,599 @@ +import { createHash } from "node:crypto"; +import { createConnection, type Socket } from "node:net"; +import { + base64UrlNoPad, + bytesOfJwk, + newNonce, + openRelayEnvelope, + type RelayFrame, + type RelayOpenFn, + type RelaySealFn, + SeqGate, + SSH_RELAY_FRAME_MAX_BYTES, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import { log } from "./log.js"; + +/** + * A-side responder of the sealed agent relay (spec 2026-10-08 §5.4/§5.6): the + * key home's gate. One inbound B2A relay frame carries one ssh-agent request + * sealed from B; this module opens it with A's own encryption key, verifies + * origin against B's PINNED signing key with the full Task-5 caller checklist + * (frame-bound open, structurally bound relaySessionId / nB / nA, per-session + * {@link SeqGate}), default-denies the agent method, enforces the grant's + * fingerprint set against A's LIVE agent socket, and seals+signs the (possibly + * filtered or refused) agent answer back as an A2B frame. + * + * The two rules of §5.4, in the order they run: + * + * 1. **Method allow-list, default-deny.** Exactly the two request types SSH + * publickey auth uses are forwarded, matched by numeric type byte on the + * wire; every other type (identity mutations, LOCK/UNLOCK, EXTENSION, the + * SSH1-era range, anything unknown) is refused WITHOUT touching A's agent. + * A granted window therefore cannot mutate or lock A's agent against its + * other panes, by default-deny rather than a name list that could miss a + * code. + * 2. **Identity scoping.** The IDENTITIES_ANSWER is rebuilt with only the + * entries whose `SHA256:` fingerprint is in the grant's selected set, and a + * SIGN_REQUEST whose key blob hashes outside the set is refused before it + * is ever forwarded. A grant naming no fingerprint serves nothing. + * + * **Agent-only (§3 item 4):** the only path to a key is A's running agent + * socket (`SSH_AUTH_SOCK`, the connecting account's own, the same + * absolute-path rule `ssh-resolve.ts` established for the launch side). No + * private key file is opened here, and nothing about key material is ever + * logged. + * + * Refusal semantics mirror Task 6's proxy: an unopenable envelope is + * plane-crafted noise and costs a log line (§5.6 first line: the plane holds + * A's public key and can seal at will); an envelope that opens but fails + * verification against the pin is a FORGERY and is refused with NO reply sent + * (answering a forgery would hand the attacker a signature oracle and spend a + * real seq against B's stream); a SeqGate refusal is refused silently-ish, + * never punished, because the plane routes and can resend anything. A refused + * REQUEST still gets the session's normal answer only in the agent layer: + * disallowed or out-of-scope agent requests are answered with + * `SSH2_AGENT_FAILURE` (B's ssh sees a clean "agent refused", never a hang), + * while refused FRAMES (origin/replay) never produce any A2B at all. + * + * Nonce protocol (§5.6, A's side of the mirror): A mints `nA` once at session + * start and carries it in every A2B alongside the recorded `nB`. B carries + * `nB` in every request, so `nB` is recorded from the first verified request + * and bound thereafter; B cannot carry `nA` until A's first reply reaches it, + * and B's proxy never seals a second request before that reply lands + * (Task 6's single-outstanding FIFO), so `nA` is unbound only for the session's + * first verified request and hard-bound from the second onward. + * + * seal/open are INJECTED exactly as at the B side: the protocol package cannot + * import mcp-core; the caller (commands/ssh-relay.ts, production glue) passes + * mcp-core's pair straight through. + */ + +/* ------------------------------------------------------------------ */ +/* the ssh-agent wire (OpenSSH `agent-proto.h` values) */ +/* ------------------------------------------------------------------ */ + +/** + * `SSH2_AGENTC_REQUEST_IDENTITIES` - ask for the public-key list. The value + * is from OpenSSH's `agent-proto.h` (NOT the RFC 9987 renumbering, where this + * is 11: the wire and the real agent are OpenSSH, and the real-agent e2e is + * the arbiter). One of the TWO forwarded request types. + */ +export const SSH2_AGENTC_REQUEST_IDENTITIES = 13; + +/** + * `SSH2_AGENTC_SIGN_REQUEST` - sign with a named blob. The value is from + * OpenSSH's `agent-proto.h` (RFC 9987 calls this 13 - not this wire). One of + * the TWO forwarded request types. + */ +export const SSH2_AGENTC_SIGN_REQUEST = 15; + +/** `SSH2_AGENT_IDENTITIES_ANSWER` (agent-proto.h): the roster reply this module filters. */ +export const SSH2_AGENT_IDENTITIES_ANSWER = 2; + +/** `SSH2_AGENT_SIGN_RESPONSE` (agent-proto.h): the signature reply, forwarded verbatim (it is opaque). */ +export const SSH2_AGENT_SIGN_RESPONSE = 14; + +/** `SSH2_AGENT_FAILURE` (agent-proto.h): the uniform answer to any refused request. */ +export const SSH2_AGENT_FAILURE = 5; + +/** One agent message's maximum payload: the relay frame cap (§5.1: cap is law). */ +const MAX_AGENT_MESSAGE_BYTES = SSH_RELAY_FRAME_MAX_BYTES; + +/** Local I/O budget for one round trip to A's agent (a local Unix socket call; ssh's own is unbounded, ours is not). */ +const AGENT_REQUEST_TIMEOUT_MS = 10_000; + +/** The single-byte refusal every refused request is answered with. */ +const FAILURE = Buffer.from([SSH2_AGENT_FAILURE]); + +/* ------------------------------------------------------------------ */ +/* agent-wire framing + parsing (4-byte BE length + payload, shared */ +/* shape with the B proxy; the TYPE parsing is A-only per §5.4) */ +/* ------------------------------------------------------------------ */ + +/** Frame one agent message for the wire: 4-byte big-endian length, then the payload. */ +function framing(payload: Buffer): Buffer { + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length, 0); + return Buffer.concat([header, payload]); +} + +/** A cursor over an agent payload; every read past the end throws (never a silent partial parse). */ +class ByteReader { + #buf: Buffer; + #off = 0; + + constructor(buf: Buffer) { + this.#buf = buf; + } + + u32(): number { + if (this.#off + 4 > this.#buf.length) throw new Error("agent wire: truncated uint32"); + const v = this.#buf.readUInt32BE(this.#off); + this.#off += 4; + return v; + } + + bytes(n: number): Buffer { + if (n > this.#buf.length - this.#off) throw new Error("agent wire: truncated byte run"); + const v = this.#buf.subarray(this.#off, this.#off + n); + this.#off += n; + return v; + } + + /** An SSH string: uint32 length + bytes. */ + string(): Buffer { + return this.bytes(this.u32()); + } + + done(): boolean { + return this.#off === this.#buf.length; + } +} + +/** Serialize an SSH string for the rebuilt answer: uint32 length + bytes. */ +function sshString(bytes: Buffer): Buffer { + const header = Buffer.alloc(4); + header.writeUInt32BE(bytes.length, 0); + return Buffer.concat([header, bytes]); +} + +/** + * The OpenSSH display fingerprint of one agent key blob: SHA-256 over the + * RAW SSH wire encoding (the agent-answer blob bytes), spelled `SHA256:` + + * base64url without padding - the same scheme `fingerprintJwk` established + * (§4.5: "Agent keys hash their agent wire encoding"; §5.4), which is also + * the only spelling the grant grammar's `GRANT_FINGERPRINT_RE` accepts. + */ +export function fingerprintAgentBlob(blob: Uint8Array): string { + return `SHA256:${createHash("sha256").update(blob).digest("base64url")}`; +} + +/** + * Rebuild an IDENTITIES_ANSWER keeping ONLY the entries whose blob + * fingerprints into `allowed`; fixes the count; kept entries ride byte- + * identically. Throws on a malformed answer (type byte, declared entries the + * buffer does not carry, trailing bytes) - an unparseable answer is refused + * as a failure, NEVER forwarded unfiltered, because a partial parse of the + * roster is exactly what §5.4 exists to prevent. + */ +export function filterIdentitiesAnswer(answer: Buffer, allowed: ReadonlySet): Buffer { + if (answer.length < 5 || answer[0] !== SSH2_AGENT_IDENTITIES_ANSWER) { + throw new Error("relay responder: expected an SSH2_AGENT_IDENTITIES_ANSWER"); + } + const reader = new ByteReader(answer.subarray(1)); + const count = reader.u32(); + const kept: Buffer[] = []; // one item per KEPT ENTRY (both fields pre-concatenated, so the rebuilt count is the entry count) + for (let i = 0; i < count; i += 1) { + const blob = reader.string(); + const comment = reader.string(); + if (allowed.has(fingerprintAgentBlob(blob))) kept.push(Buffer.concat([sshString(blob), sshString(comment)])); + } + if (!reader.done()) throw new Error("relay responder: IDENTITIES_ANSWER carries trailing bytes"); + return Buffer.concat([Buffer.from([SSH2_AGENT_IDENTITIES_ANSWER]), sshStringCount(kept.length), ...kept]); +} + +/** The count field of a rebuilt answer (an unframed uint32, unlike an SSH string). */ +function sshStringCount(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +/** + * Split a SIGN_REQUEST into its key blob (the parse throws on anything short + * of the full grammar: string blob + uint32 flags + nothing else). The flags + * ride through to the agent unexamined: §5.4 scopes by BLOB, not by flag. + */ +export function parseSignRequest(payload: Buffer): { keyBlob: Buffer; flags: number } { + const reader = new ByteReader(payload.subarray(1)); + const keyBlob = reader.string(); + const flags = reader.u32(); + if (keyBlob.length === 0) throw new Error("relay responder: SIGN_REQUEST names an empty key blob"); + if (!reader.done()) throw new Error("relay responder: SIGN_REQUEST carries trailing bytes"); + return { keyBlob, flags }; +} + +/* ------------------------------------------------------------------ */ +/* A's live agent socket */ +/* ------------------------------------------------------------------ */ + +/** + * The connecting account's live agent socket: an absolute `SSH_AUTH_SOCK`, + * else null - the same trust rule `ssh-resolve.ts` runs for the launch side + * ("the env sock is trusted because it is the connecting account's own + * setup"). A null answer is the honest no-agent case: the responder refuses + * with SSH2_AGENT_FAILURE and never reads a key file (agent-only, §5.4). + */ +export function liveAgentSocketPath(env: NodeJS.ProcessEnv = process.env): string | null { + const sock = env.SSH_AUTH_SOCK; + return typeof sock === "string" && sock.startsWith("/") && sock.length > 1 ? sock : null; +} + +/** + * One request/response round trip to A's agent over the Unix socket: + * connect, write one framed message, read one framed answer, close. Each + * request rides its own connection (ssh's agent protocol permits it; no + * session state lives on the socket). Rejects on connect failure, timeout, + * an empty or over-cap answer, or a close before a complete answer. + */ +function requestLiveAgent(socketPath: string, payload: Buffer): Promise { + return new Promise((resolve, reject) => { + let conn: Socket; + try { + conn = createConnection({ path: socketPath }); + } catch (err) { + reject(new Error(`relay responder: cannot reach the agent socket: ${String(err)}`)); + return; + } + let buffer = Buffer.alloc(0); + let settled = false; + const fail = (why: string): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + conn.destroy(); + } catch { + /* already gone */ + } + reject(new Error(`relay responder: ${why}`)); + }; + const timer = setTimeout(() => fail("agent request timed out"), AGENT_REQUEST_TIMEOUT_MS); + conn.on("error", (err: Error) => fail(`agent socket error: ${err.message}`)); + conn.on("connect", () => { + conn.write(framing(payload)); + }); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (len === 0 || len > MAX_AGENT_MESSAGE_BYTES) { + fail(`agent answer length ${len} is empty or over the cap`); + return; + } + if (buffer.length < 4 + len) return; + const answer = Buffer.from(buffer.subarray(4, 4 + len)); + settled = true; + clearTimeout(timer); + try { + conn.end(); + } catch { + /* the destroy below handles it */ + } + conn.destroy(); + resolve(answer); + }); + conn.on("close", () => fail("the agent socket closed before a complete answer")); + }); +} + +/* ------------------------------------------------------------------ */ +/* the responder */ +/* ------------------------------------------------------------------ */ + +/** Inputs to {@link startRelayResponder}: session facts, B's pins, A's identity, the pump, the seams. */ +export interface RelayResponderArgs { + /** The relay-session id the plane minted (§5.6: signed into every envelope). */ + relayId: string; + /** The opaque routing ref this session's frames ride (also the registry key). */ + ref: string; + /** This machine's node id: the open-as principal `node:`. */ + selfNodeId: string; + /** B's node id: the seal-to principal `node:` and the pinned-key holder. */ + peerNodeId: string; + /** B's PINNED ES256 signing public JWK (JSON string) - the origin key. */ + peerSigningJwk: string; + /** B's PINNED ECDH-ES encryption public JWK (JSON string) - the seal-to key. */ + peerEncryptionJwk: string; + /** A's own encryption keypair (both JWK JSON strings) - opens B's requests. */ + ownEncryptionPublicJwk: string; + ownEncryptionPrivateJwk: string; + /** A's own ES256 signing PRIVATE JWK - signs every A2B reply. */ + ownSigningPrivateJwk: string; + /** The grant's selected fingerprints (§5.4); empty names nothing, serves nothing. */ + fingerprints: readonly string[]; + /** The sealing primitive - mcp-core's `seal` in production (injected, Task-5 doctrine). */ + seal: RelaySealFn; + /** The opening primitive - mcp-core's `open`. */ + open: RelayOpenFn; + /** + * Hand one sealed A2B frame to the link pump (the daemon's `send`, via the + * ctx seam). DELIVER-OR-THROW (the Task-6 contract, restated for A): a + * dropped send must THROW here. A throw is NON-DELIVERY: no seq is + * consumed, and B's proxy (still holding its outstanding slot) stalls until + * the relay's external teardown - Task 8's broker closes the session on + * its timers. + */ + sendRelayFrame(frame: RelayFrame): void; + /** + * Resolve A's live agent socket per request (default {@link + * liveAgentSocketPath}). A test seam and the operator override land here; + * returning null is the honest no-agent case. + */ + resolveAgentSocket?: () => string | null; + /** Line sink; defaults to the agent logger. Never receives keys, fingerprints, nonces, or agent bytes. */ + log?: (line: string) => void; +} + +/** The running responder: the two lifecycle verbs the registry adapter uses. */ +export interface RelayResponderHandle { + /** + * Feed one INBOUND relay frame parsed off the link to this session. Only + * B2A frames for this session's ref are honored; anything else is dropped + * with a log line (defense in depth - the registry already routed by ref). + * Never throws: a refused frame is a refusal, not a crash. + */ + deliverInboundRelayFrame(frame: RelayFrame): void; + /** End the session (§5.6's named close word from the registry, or a local cut). Idempotent. */ + close(reason?: string): void; +} + +/** + * Start the A-side responder for ONE brokered session: validate B's pins + * (deep public-only P-256 via {@link bytesOfJwk}, the same gate the B proxy + * runs), mint A's session nonce, and return the handler that + * `commands/ssh-relay.ts` registers into {@link RelaySessions}. Throws a + * named refusal when either pinned half is not usable public material. + */ +export function startRelayResponder(args: RelayResponderArgs): RelayResponderHandle { + const say = args.log ?? ((line: string): void => log(line)); + + // Pin sanity BEFORE anything binds (the B side's MINOR-3 order): bytesOfJwk + // parses the string itself, so junk, private material, and a foreign curve + // all land in the guard and surface as the named refusal. Parsing once + // AFTER the guard keeps the per-frame verify free of JSON work. + try { + bytesOfJwk(args.peerSigningJwk); + bytesOfJwk(args.peerEncryptionJwk); + } catch (err) { + throw new Error(`relay responder: pinned peer key rejected: ${err instanceof Error ? err.message : String(err)}`); + } + const peerSigning = JSON.parse(args.peerSigningJwk) as JsonWebKey; + const ownSigningPrivate = JSON.parse(args.ownSigningPrivateJwk) as JsonWebKey; + const ownEncryption = { + principalId: `node:${args.selfNodeId}`, + publicJwk: args.ownEncryptionPublicJwk, + privateJwk: args.ownEncryptionPrivateJwk, + }; + const recipient = { principalId: `node:${args.peerNodeId}`, publicJwk: args.peerEncryptionJwk }; + const allowed = new Set(args.fingerprints); + const resolveAgent = args.resolveAgentSocket ?? liveAgentSocketPath; + + // Session state (§5.6): A's nonce is minted once and every A2B carries it; + // B's is recorded from the first verified request. `nASeenBinding` marks + // that the first verified request has passed, from which point every + // inbound message MUST carry A's nonce (B learns it from A's first reply, + // and B's single-outstanding FIFO means B's next request was sealed after + // that reply landed - a request without it is a replay or a forgery). + const nA = newNonce(); + let nB: string | undefined; + let bindNA = false; + const gate = new SeqGate(); + let signedSeq = 0; + let transportSeq = 0; + let closed = false; + + /** The agent-layer decision for one decoded request: the payload to answer B with. */ + const respondToAgent = async (request: Buffer): Promise => { + if (request.length === 0) return FAILURE; // no type byte: nothing allow-listed, nothing forwarded + const type = request[0]; + const agentPath = resolveAgent(); + if (type !== SSH2_AGENTC_REQUEST_IDENTITIES && type !== SSH2_AGENTC_SIGN_REQUEST) { + // §5.4's default-deny: by the REFUSED set's behavior, not a lookup. Log the + // numeric type (not a secret) so the operator can see what B asked for. + say(`relay session ${args.ref}: refused disallowed agent request type ${type}; not forwarded`); + return FAILURE; + } + if (type === SSH2_AGENTC_SIGN_REQUEST) { + let keyBlob: Buffer; + try { + ({ keyBlob } = parseSignRequest(request)); + } catch { + say(`relay session ${args.ref}: refused a malformed SIGN_REQUEST; not forwarded`); + return FAILURE; + } + // §5.4's signing gate: checked HERE, before anything touches the agent. + if (!allowed.has(fingerprintAgentBlob(keyBlob))) { + say(`relay session ${args.ref}: refused a SIGN_REQUEST outside the grant set; not forwarded`); + return FAILURE; + } + if (agentPath === null) { + say(`relay session ${args.ref}: no live agent socket (SSH_AUTH_SOCK unset): refusing the request`); + return FAILURE; + } + let response: Buffer; + try { + response = await requestLiveAgent(agentPath, request); + } catch (err) { + say(`relay session ${args.ref}: the agent request failed: ${String(err)}`); + return FAILURE; + } + // The signature is opaque and the blob was already scoped, but the + // response TYPE still gates: only SIGN_RESPONSE and the agent's own + // FAILURE ride onward; anything unexpected becomes a failure. + if (response.length < 1 || (response[0] !== SSH2_AGENT_SIGN_RESPONSE && response[0] !== SSH2_AGENT_FAILURE)) { + say(`relay session ${args.ref}: the agent answered a sign request with an unexpected type; refusing`); + return FAILURE; + } + return response; + } + // SSH2_AGENTC_REQUEST_IDENTITIES: the roster goes to the agent, comes + // back, and is FILTERED here - the grant set is enforced at A, the only + // machine that can see A's whole roster. + if (agentPath === null) { + say(`relay session ${args.ref}: no live agent socket (SSH_AUTH_SOCK unset): refusing the request`); + return FAILURE; + } + let answer: Buffer; + try { + answer = await requestLiveAgent(agentPath, request); + } catch (err) { + say(`relay session ${args.ref}: the agent request failed: ${String(err)}`); + return FAILURE; + } + if (answer.length < 1) return FAILURE; + if (answer[0] === SSH2_AGENT_FAILURE) return FAILURE; // A's agent itself refused: pass the refusal + if (answer[0] !== SSH2_AGENT_IDENTITIES_ANSWER) { + say(`relay session ${args.ref}: the agent answered identities with an unexpected type; refusing`); + return FAILURE; + } + try { + return filterIdentitiesAnswer(answer, allowed); + } catch { + // An unparseable roster NEVER rides on: partial parsing is how an + // ungranted identity could leak. The pane sees a clean failure. + say(`relay session ${args.ref}: the agent's IDENTITIES_ANSWER did not parse; refusing it`); + return FAILURE; + } + }; + + /** The full inbound pipeline for one frame: open, verify, gate, answer. */ + const handleFrame = async (frame: RelayFrame): Promise => { + if (closed) return; + let opened; + try { + // Checklist step 1: the FRAME's own routing facts bind the wrapper + // (M-1) - a relayed blob addressed through another ref or direction + // never reaches a key. + opened = await openRelayEnvelope({ + blob: frame.blob, + own: ownEncryption, + open: args.open, + expect: { ref: frame.ref, direction: frame.direction }, + }); + } catch { + // Unopenable noise from the routing plane (which holds A's public key + // and can craft seals all by itself, §5.6). Cost it a log line. + say(`relay session ${args.ref}: refused an inbound frame (envelope did not open)`); + return; + } + let message; + try { + // Checklist steps 2+3+4 in one call (I-1): origin against B's PINNED + // signing key, this session's relayId, and the nonces this endpoint + // holds. An openable wrapper with a foreign signature or another + // session's facts is a FORGERY, not noise: it is refused and NOTHING + // is sent back - answering a forgery would sign real A2B envelopes + // against attacker-chosen state. + message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: peerSigning, + expect: { + routingRef: args.ref, + direction: "B2A", + seq: opened.seq, + relaySessionId: args.relayId, + ...(nB === undefined ? {} : { nB }), + ...(bindNA ? { nA } : {}), + }, + }); + } catch { + say(`relay session ${args.ref}: refused a forgery (origin/binding verification failed); nothing sent`); + return; + } + // §5.6's nonce discipline, A's side: B carries nB in EVERY request, and + // carries nA from the first request sealed after A's first reply landed. + // A request before the flip that already carries nA cannot be genuine + // (A's nA has not been delivered), and one after the flip without it is + // refused by the binding itself. + if (message.nB === undefined || (!bindNA && message.nA !== undefined)) { + say(`relay session ${args.ref}: refused a request with a malformed nonce pair; nothing sent`); + return; + } + if (!gate.accept("B2A", message.seq)) { + // The plane routes - and can resend - everything (§5.6). A resend is + // refused, not punished: no agent traffic, no reply, seq unconsumed. + say(`relay session ${args.ref}: refused a replayed/rewound request (seq ${message.seq})`); + return; + } + if (nB === undefined) nB = message.nB; // record B's nonce from the first verified request + bindNA = true; // every later request must carry A's nonce back (see above) + + const request = Buffer.from(message.agentBytesB64, "base64url"); + const reply = await respondToAgent(request); + if (closed) return; + const replySeq = signedSeq; + let blob: string; + try { + blob = await sealRelayEnvelope({ + message: { + relaySessionId: args.relayId, + routingRef: args.ref, + direction: "A2B", + seq: replySeq, + nB: message.nB, // the SAME value the request carried, signed onward + nA, + agentBytesB64: base64UrlNoPad(new Uint8Array(reply)), + }, + privateJwk: ownSigningPrivate, + recipient, + seal: args.seal, + }); + } catch (err) { + // Malformed key material or an over-cap reply: nothing was sent, so no + // seq is consumed. B's proxy stalls on its outstanding slot until the + // relay's external teardown (Task 8's broker closes on its timers). + say(`relay session ${args.ref}: could not seal a reply: ${err instanceof Error ? err.message : String(err)}`); + return; + } + try { + args.sendRelayFrame({ type: "relay", ref: args.ref, seq: transportSeq, direction: "A2B", blob }); + } catch (err) { + // DELIVER-OR-THROW: a thrown pump means B never received the answer. + // No seq is consumed (B's next request lawfully re-uses the numbers); + // the session stalls to external teardown, the same posture as a drop. + say(`relay session ${args.ref}: the link pump refused a reply: ${String(err)}`); + return; + } + signedSeq += 1; + transportSeq += 1; + }; + + // Frames are processed in ARRIVAL order, one chain deep: the SeqGate is + // per-session and must never race two opens out of order (Task 6's rule). + let deliverChain: Promise = Promise.resolve(); + + return { + deliverInboundRelayFrame(frame: RelayFrame): void { + if (closed) return; + if (frame.ref !== args.ref || frame.direction !== "B2A") { + // Misrouting is a registry bug, not a threat; an A2B frame is A's own + // echo. Either way it never reaches the request path. + say(`relay session ${args.ref}: dropped a non-B2A or foreign-ref inbound frame`); + return; + } + deliverChain = deliverChain + .then(() => handleFrame(frame)) + .catch((err: unknown) => { + say(`relay session ${args.ref}: inbound handling failed: ${String(err)}`); + }); + }, + close(reason?: string): void { + if (closed) return; + closed = true; + say(`relay session ${args.ref}: A-side responder closed${reason ? ` (${reason})` : ""}`); + }, + }; +} From 757a895a46d53c5b90b0944933708e81a021bafb Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:20:24 -0700 Subject: [PATCH 29/92] docs(ssh-relay): agent numbering is probed, not baked (measured OpenSSH 10.2 = 11/13, collides with classic 13/15) --- docs/superpowers/plans/2026-10-08-ssh-relay-plan.md | 6 +++++- docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md index 05e43a6f4..89d456702 100644 --- a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md +++ b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md @@ -180,7 +180,11 @@ Every task implicitly includes these; values are copied from `docs/superpowers/s - [ ] **Step 1: Failing test.** A valid `REQUEST_IDENTITIES` forwarded to a stub agent returns a roster filtered to the grant's fingerprints; a `SIGN_REQUEST` whose blob hash is outside the set is refused (null) and never forwarded; `ADD_IDENTITY`/`REMOVE_ALL_IDENTITIES`/`EXTENSION` refused; a wrong-origin (bad B signature) request refused. - [ ] **Step 2: FAIL run.** -- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request; forward ONLY `SSH2_AGENTC_REQUEST_IDENTITIES` (13) and `SSH2_AGENTC_SIGN_REQUEST` (15) by numeric type; DO NOT trust any remembered code - define the two constants by name and confirm the numeric values against the target OpenSSH `agent-proto.h` (SSH2_AGENTC_ADD_IDENTITY is 17, a common misread). Use OpenSSH's numbering (identities 13, sign 15); RFC 9987 renumbers these to 11/13 - do NOT use RFC 9987 here, the wire and the agent are OpenSSH; the real-agent e2e (Task 16) is the arbiter. Refuse all else by default-deny; on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, refuse if the blob is outside the set; talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). +- [ ] **Step 3: Implement `relay-responder.ts`.** Verify B's signature against the machine pin (reject otherwise); `SeqGate` accept; decode the inner agent request. + + **Agent numbering is probed, not hard-coded (operator ruling 2026-10-08).** The agent request/response codepoints are NOT a single fixed set the responder may bake in: OpenSSH's classic numbering (identities request 13 / answer 14 / sign request 15 / sign response 16) and the RFC 9987 numbering OpenSSH 10.x ships (identities 11 / answer 12 / sign 13 / sign response 14) COLLIDE - codepoint 13 is *identities* under classic but *sign* under RFC, and 14 is *answer* under classic but *sign response* under RFC. Measured on this fleet's `OpenSSH_10.2p1`: request 11 answers 12; request 13 and 15 both answer FAILURE(5). Therefore the responder MUST, once per relay-session open, **probe A's live agent** to learn which numbering it speaks, then classify and scope every request in that discovered scheme. Probe by sending a well-formed identities request in one scheme and reading the response code: a valid IDENTITIES_ANSWER (12 or 14) names the scheme; a FAILURE(5) to the candidate that would be *sign* under the other scheme confirms the choice. Cache the resolved scheme on the A-side session; never re-interpret a byte without it. + + Forward ONLY the two read methods the scheme names: the identities request and the sign request. Do NOT trust any remembered numeric value - derive the forward-decision from the probed scheme, and default-deny ANY request type the scheme does not name as identities or sign (ADD_IDENTITY, REMOVE_IDENTITY, REMOVE_ALL_IDENTITIES, EXPLICIT_* / EXTENSION, and any unrecognized byte). **Scoping runs BEFORE any forward, for whichever codepoint the probed scheme treats as sign:** on identities, drop entries whose `SHA256:` (over the agent wire encoding) is not in the grant set; on sign, resolve the target key by its blob/agent reference and refuse (null) when that fingerprint is outside the grant set - never forward a sign request whose key is ungranted. Accept the RFC 9987 extended sign grammar (a sign request carries a trailing `string algorithms` list after the blob) as well as the classic (blob-only) form; parse both, refuse a malformed body, and refuse rather than forward an ambiguous byte-13 that the scheme has not confirmed as sign. Talk to A's live agent socket (agent-only, never read key files); seal+sign the reply (direction A2B, A's `nA`, next `seq`). The real-agent e2e (Task 16) is the arbiter that the probe picks the right scheme for a live `OpenSSH_10.x` agent. - [ ] **Step 4: PASS** + a test that the responder runs only for a brokered session naming this A and refuses an unbrokered one. - [ ] **Step 5: Commit** `git commit -m "feat(ssh-relay): A-side responder enforces origin, method allow-list, and grant fingerprint set"`. diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 6f2936b75..9ef4d77ef 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -93,6 +93,8 @@ A's responder accepts an **agent-protocol** request only when the plane brokered **Method allow-list, default-deny.** Forward exactly the two request types SSH publickey auth uses, `SSH2_AGENTC_REQUEST_IDENTITIES` and `SSH2_AGENTC_SIGN_REQUEST` (by symbolic type, matched on the wire); **refuse every other type**: all identity mutations (`ADD_IDENTITY`, `ADD_ID_CONSTRAINED`, smartcard add/remove, `REMOVE_IDENTITY`, `REMOVE_ALL_IDENTITIES`), `LOCK`/`UNLOCK`, `EXTENSION`/`EXTENSION_RESPONSE`, every SSH1-era code, and any unknown type. A granted window therefore cannot mutate or lock A's agent against its other panes, by default-deny rather than a name list that could miss a code. +**The numeric scheme is probed, not baked (ruling 2026-10-08).** "By symbolic type, matched on the wire" cannot mean a fixed codepoint: OpenSSH's classic agent numbering (identities 13 / answer 14 / sign 15 / response 16) and the RFC 9987 numbering OpenSSH 10.x ships (identities 11 / answer 12 / sign 13 / response 14) collide at 13 and 14, so byte 13 is *identities* on one agent and *sign* on the other. The responder probes A's live agent once per relay-session open - a well-formed identities request in one scheme, whose answer code (a valid IDENTITIES_ANSWER, or FAILURE to the candidate that the other scheme reads as sign) names the scheme - caches that scheme on the session, and classifies and scopes every later request in it. This is load-bearing for the scoping rule below it: the fingerprint check must run against whichever codepoint A's agent actually treats as `SIGN_REQUEST`, so an ungranted sign never reaches the agent. A request the resolved scheme does not name as identities or sign is refused, never forwarded, and a byte whose scheme is not yet resolved is refused; the responder never forwards an ambiguous codepoint under a guessed interpretation. + **Identity scoping, cross-machine.** A's `REQUEST_IDENTITIES` answer is served **filtered to the key fingerprints the grant names**, not A's whole roster and never by matching the snapshot's identity *paths* (those are resolved on B and are not portable to A's agent, which carries blobs plus comments). At the first-use approval (§6.2) the operator selects which of A's public agent identities may serve the grant; their fingerprints are stored on the grant (public data, no secret), capped by a new `SSH_MAX_GRANT_FINGERPRINTS` (kept distinct from `SSH_MAX_IDENTITY_REFS`, which bounds snapshot paths, not grant selections); an approval selecting more than the cap is a hard red error on the grants screen, never a silent truncation. A grant naming no fingerprint serves nothing (which reads as "no matching key in A's agent," parent §12). The same set gates signing: a `SIGN_REQUEST` arrives with a key blob, and the responder refuses a `SIGN_REQUEST` whose key blob hashes (SHA-256 over the agent wire encoding, `SHA256:` base64) outside the grant's selected fingerprint set, checked at the responder and never forwarded to A's agent, so a granted window signs only the keys the operator picked regardless of which blob B supplies. **Roster enumeration for approval.** So the operator has something to choose, the plane sends A a signed `ssh_agent_identities` node-link command (like `detect`, answered with no grant outstanding and no relay session) that asks A's agent for its public identities as `SHA256:` fingerprints plus comments, blobs withheld. It answers only what is asked and writes no audit row; it fails closed if A is offline (§11). Each fetch hands the plane A's whole public roster and labels, new plane-side metadata (§10). From a83f91fed5810f6aebfca1b5dac24dadc856f8b1 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:30:09 -0700 Subject: [PATCH 30/92] feat(ssh-relay): probe A's agent numbering; scope sign before forward --- .../src/__tests__/relay-agent-scheme.test.ts | 285 ++++++++ .../src/__tests__/relay-agent-socket.test.ts | 129 ++++ .../agent/src/__tests__/relay-proxy.test.ts | 14 +- .../src/__tests__/relay-responder.test.ts | 617 +++++++++++++----- apps/node/agent/src/commands/ssh-relay.ts | 53 +- apps/node/agent/src/relay-agent-scheme.ts | 273 ++++++++ apps/node/agent/src/relay-agent-socket.ts | 99 +++ apps/node/agent/src/relay-responder.ts | 414 ++++-------- 8 files changed, 1408 insertions(+), 476 deletions(-) create mode 100644 apps/node/agent/src/__tests__/relay-agent-scheme.test.ts create mode 100644 apps/node/agent/src/__tests__/relay-agent-socket.test.ts create mode 100644 apps/node/agent/src/relay-agent-scheme.ts create mode 100644 apps/node/agent/src/relay-agent-socket.ts diff --git a/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts b/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts new file mode 100644 index 000000000..acefbb416 --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts @@ -0,0 +1,285 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + type AgentScheme, + CLASSIC_SCHEME, + filterIdentitiesAnswer, + fingerprintAgentBlob, + OPENSSH_10X_SCHEME, + parseIdentitiesAnswer, + parseSignRequest, + probeAgentScheme, + SSH2_AGENT_FAILURE, +} from "../relay-agent-scheme.js"; + +/** + * Byte-literal fixtures for the two ssh-agent numbering schemes the A-side + * responder probes between (spec 2026-10-08 §5.4, ruling 2026-10-08): the + * RFC 9987 numbering OpenSSH 10.x ships (identities 11 / answer 12 / sign 13 / + * sign response 14, sign carrying the extended grammar) and OpenSSH's classic + * numbering (13 / 14 / 15 / 16). Every codepoint here is written as a plain + * number - the constants are asserted AGAINST the literals, never derived + * from the production module, so numeric drift cannot hide. The FAILURE(5) + * answers mirror the live measurement on this fleet's OpenSSH_10.2p1: a byte + * 13 (a truncated sign under 10.x) answers FAILURE, and a byte 11 answers 12. + */ + +/* ---------------- the agent wire, spelled by hand ---------------- */ + +function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +/** An SSH string on the agent wire: 4-byte BE length + bytes. */ +function sshStr(bytes: Buffer | string): Buffer { + const b = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes; + return Buffer.concat([be32(b.length), b]); +} + +/** An independent fingerprint oracle: SHA-256 over the wire blob, base64url no pad. */ +function fp(wire: Uint8Array): string { + return `SHA256:${createHash("sha256").update(wire).digest("base64url")}`; +} + +const KEY_A = Buffer.concat([sshStr("ssh-ed25519"), sshStr(Buffer.from("KEY-A"))]); +const KEY_B = Buffer.concat([sshStr("ssh-ed25519"), sshStr(Buffer.from("KEY-B"))]); + +/** An IDENTITIES_ANSWER spelled in ONE scheme's answer byte, from raw numbers. */ +function answerWith(answerByte: number, entries: { blob: Buffer; comment: string }[]): Buffer { + return Buffer.concat([ + Buffer.from([answerByte]), + be32(entries.length), + ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), + ]); +} + +/* ---------------- the scheme data: measured literals ---------------- */ + +test("the schemes carry the measured codepoints and collide exactly where the ruling says", () => { + // RFC 9987 numbering, shipped by OpenSSH 10.x (measured OpenSSH_10.2p1: + // request 11 answers 12; request 13 and 15 answer FAILURE 5). + expect([OPENSSH_10X_SCHEME.identities, OPENSSH_10X_SCHEME.answer]).toEqual([11, 12]); + expect([OPENSSH_10X_SCHEME.sign, OPENSSH_10X_SCHEME.signResponse]).toEqual([13, 14]); + expect(OPENSSH_10X_SCHEME.extendedSign).toBe(true); + // OpenSSH's classic numbering: identities 13 / answer 14 / sign 15 / response 16. + expect([CLASSIC_SCHEME.identities, CLASSIC_SCHEME.answer]).toEqual([13, 14]); + expect([CLASSIC_SCHEME.sign, CLASSIC_SCHEME.signResponse]).toEqual([15, 16]); + expect(CLASSIC_SCHEME.extendedSign).toBe(false); + // SSH2_AGENT_FAILURE is 5 in both (the measurement's third probe). + expect(SSH2_AGENT_FAILURE).toBe(5); + // The collisions the probe exists to resolve: 13 is classic identities but + // 10.x sign; 14 is classic answer but 10.x sign response. + expect(CLASSIC_SCHEME.identities).toBe(OPENSSH_10X_SCHEME.sign); + expect(CLASSIC_SCHEME.answer).toBe(OPENSSH_10X_SCHEME.signResponse); +}); + +/* ---------------- fingerprint spelling (the grant grammar) ---------------- */ + +test("fingerprintAgentBlob spells SHA256 over the wire blob in the grant grammar's base64url form", () => { + const s = fingerprintAgentBlob(Buffer.from("hello")); + expect(s).toMatch(/^SHA256:[A-Za-z0-9_-]{43}$/); + expect(s).toBe(fp(Buffer.from("hello"))); +}); + +/* ---------------- IDENTITIES_ANSWER parsing and filtering ---------------- */ + +test("parseIdentitiesAnswer accepts only the resolved scheme's answer byte", () => { + const tenX = answerWith(12, [{ blob: KEY_A, comment: "a" }]); + const classic = answerWith(14, [{ blob: KEY_A, comment: "a" }]); + expect(parseIdentitiesAnswer(tenX, OPENSSH_10X_SCHEME).map((e) => e.comment.toString())).toEqual(["a"]); + expect(parseIdentitiesAnswer(classic, CLASSIC_SCHEME).map((e) => e.comment.toString())).toEqual(["a"]); + // Byte 14 is the ANSWER under classic and the SIGN RESPONSE under 10.x: + // a resolver must never accept it as a roster in the wrong scheme. + expect(() => parseIdentitiesAnswer(classic, OPENSSH_10X_SCHEME)).toThrow(); + expect(() => parseIdentitiesAnswer(tenX, CLASSIC_SCHEME)).toThrow(); +}); + +test("parseIdentitiesAnswer refuses truncated entries and trailing bytes", () => { + const full = answerWith(12, [{ blob: KEY_A, comment: "a" }]); + expect(() => parseIdentitiesAnswer(full.subarray(0, full.length - 1), OPENSSH_10X_SCHEME)).toThrow(); + expect(() => parseIdentitiesAnswer(Buffer.concat([full, Buffer.from([0])]), OPENSSH_10X_SCHEME)).toThrow(); + // A declared count the buffer does not carry. + const overcounted = Buffer.concat([Buffer.from([12]), be32(2), sshStr(KEY_A), sshStr("a")]); + expect(() => parseIdentitiesAnswer(overcounted, OPENSSH_10X_SCHEME)).toThrow(); + // The shortest legal answer: type + count 0. + expect(parseIdentitiesAnswer(Buffer.from([12, 0, 0, 0, 0]), OPENSSH_10X_SCHEME)).toEqual([]); +}); + +test("filterIdentitiesAnswer keeps granted entries byte-identical under BOTH schemes' answer byte", () => { + const roster = [ + { blob: KEY_A, comment: "granted" }, + { blob: KEY_B, comment: "not granted" }, + ]; + const allowed = new Set([fp(KEY_B)]); // grant the SECOND entry: order must not matter + for (const [scheme, answerByte] of [ + [OPENSSH_10X_SCHEME, 12], + [CLASSIC_SCHEME, 14], + ] as [AgentScheme, number][]) { + const filtered = filterIdentitiesAnswer(answerWith(answerByte, roster), allowed, scheme); + // The rebuilt answer carries THIS scheme's answer byte - not a foreign + // one, and never the first pass's invented 2. + expect([...filtered]).toEqual([answerByte, 0, 0, 0, 1, ...sshStr(KEY_B), ...sshStr("not granted")]); + expect(parseIdentitiesAnswer(filtered, scheme)).toEqual([{ blob: KEY_B, comment: Buffer.from("not granted") }]); + } + // An empty grant serves the count-zero answer, five bytes, nothing else. + expect([...filterIdentitiesAnswer(answerWith(12, roster), new Set(), OPENSSH_10X_SCHEME)]).toEqual([12, 0, 0, 0, 0]); + // A malformed answer throws (the responder must refuse it, never forward). + expect(() => filterIdentitiesAnswer(Buffer.from([12, 0, 0]), new Set(), OPENSSH_10X_SCHEME)).toThrow(); + expect(() => filterIdentitiesAnswer(answerWith(14, roster), new Set([fp(KEY_A)]), OPENSSH_10X_SCHEME)).toThrow(); +}); + +/* ---------------- SIGN_REQUEST grammar: classic and extended ---------------- */ + +test("parseSignRequest accepts the classic body (blob, data, flags) in the classic scheme", () => { + const body = Buffer.concat([Buffer.from([15]), sshStr(KEY_A), sshStr("DATA"), be32(0)]); + const parsed = parseSignRequest(body, CLASSIC_SCHEME); + expect(parsed.keyBlob).toEqual(KEY_A); + expect(parsed.data).toEqual(Buffer.from("DATA")); + expect(parsed.flags).toBe(0); + expect(parsed.algorithms).toBeUndefined(); +}); + +test("parseSignRequest accepts the extended 10.x body (trailing algorithms string) and the bare form", () => { + const bare = Buffer.concat([Buffer.from([13]), sshStr(KEY_A), sshStr("DATA"), be32(1)]); + expect(parseSignRequest(bare, OPENSSH_10X_SCHEME).algorithms).toBeUndefined(); + const extended = Buffer.concat([ + Buffer.from([13]), + sshStr(KEY_A), + sshStr("DATA"), + be32(0), + sshStr("ssh-ed25519,rsa-sha2-512"), + ]); + expect(parseSignRequest(extended, OPENSSH_10X_SCHEME).algorithms).toEqual(Buffer.from("ssh-ed25519,rsa-sha2-512")); +}); + +test("parseSignRequest refuses every malformed body in both schemes", () => { + const bare = (scheme: AgentScheme, tail: Buffer): Buffer => + Buffer.concat([Buffer.from([scheme.sign]), sshStr(KEY_A), tail]); + // Blob-only (no data, no flags): a body the wire never legally carries. + expect(() => parseSignRequest(bare(CLASSIC_SCHEME, Buffer.alloc(0)), CLASSIC_SCHEME)).toThrow(); + expect(() => parseSignRequest(bare(OPENSSH_10X_SCHEME, Buffer.alloc(0)), OPENSSH_10X_SCHEME)).toThrow(); + // The first pass's invented blob+flags shape: still not the wire grammar. + expect(() => parseSignRequest(bare(CLASSIC_SCHEME, be32(0)), CLASSIC_SCHEME)).toThrow(); + // Truncated string lengths, missing flags, trailing garbage. + expect(() => + parseSignRequest(Buffer.concat([Buffer.from([15]), Buffer.from([0, 0, 0, 9]), KEY_A]), CLASSIC_SCHEME), + ).toThrow(); + expect(() => + parseSignRequest(Buffer.concat([Buffer.from([15]), sshStr(KEY_A), sshStr("DATA")]), CLASSIC_SCHEME), + ).toThrow(); + expect(() => + parseSignRequest( + Buffer.concat([Buffer.from([15]), sshStr(KEY_A), sshStr("DATA"), be32(0), Buffer.from([0, 0])]), + CLASSIC_SCHEME, + ), + ).toThrow(); + // A trailing algorithms string is legal only under the extended scheme, + // and it must itself be well-formed there. + const trailing = Buffer.concat([Buffer.from([13]), sshStr(KEY_A), sshStr("DATA"), be32(0), sshStr("algos")]); + expect(() => parseSignRequest(trailing, CLASSIC_SCHEME)).toThrow(); + expect(() => + parseSignRequest( + Buffer.concat([Buffer.from([13]), sshStr(KEY_A), sshStr("DATA"), be32(0), Buffer.from([0, 0, 0, 99])]), + OPENSSH_10X_SCHEME, + ), + ).toThrow(); + // An empty key blob names nothing to scope: refused. + expect(() => + parseSignRequest( + Buffer.concat([Buffer.from([13]), sshStr(Buffer.alloc(0)), sshStr("DATA"), be32(0)]), + OPENSSH_10X_SCHEME, + ), + ).toThrow(); +}); + +/* ---------------- the probe: resolving the live agent's numbering ---------------- */ + +/** Records every payload a fake agent received and answers per a script. */ +function scriptedAgent(answer: (payload: Buffer) => Buffer | "throw"): { + sent: Buffer[]; + requestAgent: (socketPath: string, payload: Buffer) => Promise; +} { + const sent: Buffer[] = []; + return { + sent, + requestAgent: async (_socketPath: string, payload: Buffer): Promise => { + sent.push(Buffer.from(payload)); + const a = answer(payload); + if (a === "throw") throw new Error("connect refused"); + return a; + }, + }; +} + +const EMPTY_ANSWER_12 = Buffer.from([12, 0, 0, 0, 0]); +const EMPTY_ANSWER_14 = Buffer.from([14, 0, 0, 0, 0]); + +test("the probe resolves openssh-10x from the 11-to-12 answer after the classic candidate answers FAILURE", async () => { + // The measured OpenSSH_10.2p1 pair: byte 13 (a truncated sign) answers 5, + // byte 11 (identities) answers a valid 12. + const agent = scriptedAgent((p) => { + if (p.equals(Buffer.from([13]))) return Buffer.from([5]); + if (p.equals(Buffer.from([11]))) return EMPTY_ANSWER_12; + return "throw"; + }); + const scheme = await probeAgentScheme("/fake/agent.sock", agent.requestAgent); + expect(scheme).toBe(OPENSSH_10X_SCHEME); + expect(agent.sent).toEqual([Buffer.from([13]), Buffer.from([11])]); +}); + +test("the probe resolves classic from the 13-to-14 answer in a single probe", async () => { + const agent = scriptedAgent((p) => (p.equals(Buffer.from([13])) ? EMPTY_ANSWER_14 : Buffer.from([5]))); + const scheme = await probeAgentScheme("/fake/agent.sock", agent.requestAgent); + expect(scheme).toBe(CLASSIC_SCHEME); + // The classic agent answered the first candidate: no second probe is sent. + expect(agent.sent).toEqual([Buffer.from([13])]); +}); + +test("an agent that answers FAILURE to both candidates resolves to nothing, never a guessed scheme", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + expect(await probeAgentScheme("/fake/agent.sock", agent.requestAgent)).toBeNull(); + expect(agent.sent).toEqual([Buffer.from([13]), Buffer.from([11])]); +}); + +test("a probe answer must PARSE as an IDENTITIES_ANSWER: a bare type byte confirms nothing", async () => { + // Type 14 with a truncated body is not a valid classic answer (and is what + // a 10.x SIGN_RESPONSE byte would look like); the probe must move on and + // confirm 10.x positively. + const agent = scriptedAgent((p) => { + if (p.equals(Buffer.from([13]))) return Buffer.from([14, 0, 0, 0, 2, 0, 0, 0, 1, 65]); + if (p.equals(Buffer.from([11]))) return EMPTY_ANSWER_12; + return Buffer.from([5]); + }); + expect(await probeAgentScheme("/fake/agent.sock", agent.requestAgent)).toBe(OPENSSH_10X_SCHEME); + expect(agent.sent.length).toBe(2); +}); + +test("an unknown answer byte and a transport failure both move the probe on and resolve to nothing", async () => { + const junk = scriptedAgent(() => Buffer.from([99])); + expect(await probeAgentScheme("/fake/agent.sock", junk.requestAgent)).toBeNull(); + expect(junk.sent.length).toBe(2); + const dead = scriptedAgent(() => "throw"); + expect(await probeAgentScheme("/fake/agent.sock", dead.requestAgent)).toBeNull(); + expect(dead.sent.length).toBe(2); // every candidate was tried before giving up +}); + +test("the probe never sends anything but one-byte identities requests (a probe must never reach a signature)", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + await probeAgentScheme("/fake/agent.sock", agent.requestAgent); + for (const payload of agent.sent) { + expect(payload.length).toBe(1); // never a sign body: a truncated sign can only ever FAILURE + expect([11, 13]).toContain(payload[0]); // only the two candidates' identities bytes + } +}); + +test("probeAgentScheme passes the socket path it was given to every candidate", async () => { + const seen: string[] = []; + const scheme = await probeAgentScheme("/run/user/1000/agent.sock", async (path, payload) => { + seen.push(path); + return payload.equals(Buffer.from([13])) ? EMPTY_ANSWER_14 : Buffer.from([5]); + }); + expect(scheme).toBe(CLASSIC_SCHEME); + expect(seen).toEqual(["/run/user/1000/agent.sock"]); +}); diff --git a/apps/node/agent/src/__tests__/relay-agent-socket.test.ts b/apps/node/agent/src/__tests__/relay-agent-socket.test.ts new file mode 100644 index 000000000..8cdb0247d --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-agent-socket.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SSH_RELAY_FRAME_MAX_BYTES } from "@internal/subshell-protocol"; +import { liveAgentSocketPath, requestLiveAgent } from "../relay-agent-socket.js"; + +/** + * The one-shot agent socket round trip the responder and the numbering probe + * share: connect, write ONE framed message, read ONE framed answer, close. + * These fixtures answer raw bytes, so the framing contract is pinned against + * the real socket layer, not a mock. + */ + +function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +function framed(payload: Buffer): Buffer { + return Buffer.concat([be32(payload.length), payload]); +} + +interface EchoServer { + path: string; + received: Buffer[]; + close(): Promise; +} + +/** A Unix socket that answers every complete framed request with `answerBytes`. */ +async function startEchoAgent(onRequest: (request: Buffer, conn: Socket) => void): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-agent-socket-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + const request = Buffer.from(buffer.subarray(4, 4 + len)); + buffer = buffer.subarray(4 + len); + received.push(request); + onRequest(request, conn); + }); + conn.on("error", () => { + /* the client closes per request; never throw out of the socket layer */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { path, received, close: () => new Promise((r) => server.close(() => r())) }; +} + +/* ---------------- liveAgentSocketPath (the ssh-resolve rule) ---------------- */ + +test("liveAgentSocketPath honors only an absolute SSH_AUTH_SOCK (the ssh-resolve rule)", () => { + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "/run/user/1000/ssh-agent.sock" })).toBe("/run/user/1000/ssh-agent.sock"); + expect(liveAgentSocketPath({})).toBeNull(); + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "" })).toBeNull(); + expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "relative/path.sock" })).toBeNull(); +}); + +/* ---------------- requestLiveAgent (the round trip) ---------------- */ + +test("requestLiveAgent writes exactly one framed request and resolves the framed answer", async () => { + const server = await startEchoAgent((_request, conn) => { + conn.write(framed(Buffer.from([12, 0, 0, 0, 0]))); + }); + try { + const request = Buffer.from([11]); + const answer = await requestLiveAgent(server.path, request); + expect([...answer]).toEqual([12, 0, 0, 0, 0]); + expect(server.received.length).toBe(1); + expect(server.received[0]).toEqual(request); // one message, no framing residue, byte-exact + } finally { + await server.close(); + } +}); + +test("requestLiveAgent rejects an answer whose length header is empty or over the relay cap", async () => { + const overCap = await startEchoAgent((_request, conn) => { + conn.write(Buffer.concat([be32(SSH_RELAY_FRAME_MAX_BYTES + 1), Buffer.from([0])])); + }); + const zeroLen = await startEchoAgent((_request, conn) => { + conn.write(be32(0)); + }); + try { + await expect(requestLiveAgent(overCap.path, Buffer.from([11]))).rejects.toThrow(/cap/); + await expect(requestLiveAgent(zeroLen.path, Buffer.from([11]))).rejects.toThrow(/cap/); + } finally { + await overCap.close(); + await zeroLen.close(); + } +}); + +test("requestLiveAgent rejects when the agent closes before a complete answer", async () => { + const server = await startEchoAgent((_request, conn) => { + conn.write(Buffer.from([0, 0, 0, 5, 12])); // a header promising more than follows + conn.end(); + }); + try { + await expect(requestLiveAgent(server.path, Buffer.from([11]))).rejects.toThrow(/closed before/); + } finally { + await server.close(); + } +}); + +test("requestLiveAgent rejects on its local timeout when the agent never answers", async () => { + const server = await startEchoAgent(() => { + /* swallow: the agent thinks about it forever */ + }); + try { + await expect(requestLiveAgent(server.path, Buffer.from([11]), 80)).rejects.toThrow(/timed out/); + } finally { + await server.close(); + } +}); + +test("requestLiveAgent rejects when nothing listens on the socket", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-agent-socket-")); + await expect(requestLiveAgent(join(dir, "nobody-home.sock"), Buffer.from([11]))).rejects.toThrow(); +}); diff --git a/apps/node/agent/src/__tests__/relay-proxy.test.ts b/apps/node/agent/src/__tests__/relay-proxy.test.ts index 3c4121a30..b73df9b06 100644 --- a/apps/node/agent/src/__tests__/relay-proxy.test.ts +++ b/apps/node/agent/src/__tests__/relay-proxy.test.ts @@ -27,13 +27,13 @@ import { type AgentProxyHandle, buildAgentSocketPath, startAgentProxy } from ".. */ /** - * The ssh-agent wire type bytes as OpenSSH `agent-proto.h` defines them: - * SSH2_AGENTC_REQUEST_IDENTITIES is 13 and SSH2_AGENTC_SIGN_REQUEST is 15 - * (NOT the RFC 9987 renumbering 11/13; 17/18 are ADD/REMOVE IDENTITY, the - * common misread the first draft of this file carried). These are opaque - * fixture payloads only: the B proxy forwards agent bytes byte-agnostically - * and never parses a type byte - the numeric type gate is A's responder's - * default-deny (Task 7). + * ssh-agent wire type bytes used as FIXTURE PAYLOADS only: the B proxy + * forwards agent bytes byte-agnostically and never parses a type byte, so the + * numbers here carry no protocol meaning (the classic 13/15 pair from + * `agent-proto.h` is what a pre-10.x agent speaks; OpenSSH 10.x ships the RFC + * 9987 renumbering 11/13 - see the ruling of 2026-10-08, which is why A's + * responder PROBEs the numbering and the numeric type gate is A's responder's + * default-deny, Task 7, never this side). */ const SSH2_AGENTC_REQUEST_IDENTITIES = 13; const SSH2_AGENTC_SIGN_REQUEST = 15; diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts index 49ac52ddb..b712677be 100644 --- a/apps/node/agent/src/__tests__/relay-responder.test.ts +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -18,48 +18,27 @@ import { import { exportJWK, generateKeyPair } from "jose"; import { openARelaySession, openBRelaySession, RelaySessions } from "../commands/ssh-relay.js"; import { loadOrCreateIdentity } from "../identity.js"; -import { - filterIdentitiesAnswer, - fingerprintAgentBlob, - liveAgentSocketPath, - SSH2_AGENT_FAILURE, - SSH2_AGENT_IDENTITIES_ANSWER, - SSH2_AGENT_SIGN_RESPONSE, - SSH2_AGENTC_REQUEST_IDENTITIES, - SSH2_AGENTC_SIGN_REQUEST, - startRelayResponder, -} from "../relay-responder.js"; +import { type AgentScheme, CLASSIC_SCHEME, OPENSSH_10X_SCHEME } from "../relay-agent-scheme.js"; +import { startRelayResponder } from "../relay-responder.js"; /** - * The A-side responder (spec 2026-10-08 §5.4/§5.6): inbound B2A frames are - * opened, origin-verified against B's PINNED signing key with the full Task-5 - * caller checklist, default-denied by agent method, scoped to the grant's - * fingerprint set against A's live agent (a stub Unix socket here), and the - * reply is sealed+signed back as an A2B frame. seal/open are the REAL - * mcp-core pair, the keys are generated exactly as the protocol and proxy - * tests do, and the test side computes fingerprints independently (node:crypto - * over the wire blob) so the scheme is pinned, not echoed. + * The A-side responder (spec 2026-10-08 §5.4/§5.6) under the PROBED numbering + * ruling (ruling 2026-10-08): the session probes A's live agent once at + * relay-open to learn which codepoint scheme it speaks, caches that scheme on + * the session, then classifies and scopes every request in it. The stub + * agents below answer with real-agent byte literals for BOTH schemes - the + * fixtures are written against the measured OpenSSH_10.2p1 behavior (a byte + * 13 truncated sign answers FAILURE 5; a byte 11 answers a valid 12) and + * OpenSSH's classic behavior (13 answers 14; 15 signs with 16), never against + * constants the production module exports, so numeric drift cannot hide. + * seal/open are the REAL mcp-core pair; the test side computes fingerprints + * independently (node:crypto over the wire blob). */ /* ------------------------------------------------------------------ */ -/* the agent wire (OpenSSH `agent-proto.h` values) */ +/* the agent wire, spelled by hand */ /* ------------------------------------------------------------------ */ -/** - * Non-allow-listed request types used as refusals witnesses, named by their - * OpenSSH `agent-proto.h` values: 17 ADD_IDENTITY, 18 REMOVE_IDENTITY, - * 19 REMOVE_ALL_IDENTITIES, 22 LOCK, 23 UNLOCK, 27 EXTENSION, and the - * SSH1-era range starting at 1. (The two FORWARDED types are the production - * constants imported above.) - */ -const SSH2_AGENTC_ADD_IDENTITY = 17; -const SSH2_AGENTC_REMOVE_IDENTITY = 18; -const SSH2_AGENTC_REMOVE_ALL_IDENTITIES = 19; -const SSH2_AGENTC_LOCK = 22; -const SSH2_AGENTC_UNLOCK = 23; -const SSH2_AGENTC_EXTENSION = 27; -const SSH1_AGENTC_REQUEST_RSA_IDENTITIES = 1; - /** An independent fingerprint oracle for the tests: SHA-256, base64url no pad. */ function fp(wire: Uint8Array): string { return `SHA256:${createHash("sha256").update(wire).digest("base64url")}`; @@ -77,16 +56,50 @@ function sshStr(bytes: Buffer | string): Buffer { return Buffer.concat([be32(b.length), b]); } -function buildIdentitiesAnswer(entries: { blob: Buffer; comment: string }[]): Buffer { +function buildIdentitiesAnswer(scheme: AgentScheme, entries: { blob: Buffer; comment: string }[]): Buffer { return Buffer.concat([ - Buffer.from([SSH2_AGENT_IDENTITIES_ANSWER]), + Buffer.from([scheme.answer]), be32(entries.length), ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), ]); } -function buildSignRequest(keyBlob: Buffer, flags = 0): Buffer { - return Buffer.concat([Buffer.from([SSH2_AGENTC_SIGN_REQUEST]), sshStr(keyBlob), be32(flags)]); +/** A classic-scheme SIGN_REQUEST body: blob, data, flags (the classic wire grammar). */ +function buildSignRequestClassic(keyBlob: Buffer, data = Buffer.from("DATA"), flags = 0): Buffer { + return Buffer.concat([Buffer.from([15]), sshStr(keyBlob), sshStr(data), be32(flags)]); +} + +/** A 10.x-scheme SIGN_REQUEST body: blob, data, flags, optional trailing algorithms string. */ +function buildSignRequestTenX( + keyBlob: Buffer, + opts: { data?: Buffer; flags?: number; algorithms?: string } = {}, +): Buffer { + const parts = [Buffer.from([13]), sshStr(keyBlob), sshStr(opts.data ?? Buffer.from("DATA")), be32(opts.flags ?? 0)]; + if (opts.algorithms !== undefined) parts.push(sshStr(opts.algorithms)); + return Buffer.concat(parts); +} + +/** The stub's independent sign-body validity check (mirrors the real agent's parser). */ +function isWellFormedSignBody(p: Buffer, scheme: AgentScheme): boolean { + let off = 1; + const str = (): Buffer | null => { + if (off + 4 > p.length) return null; + const n = p.readUInt32BE(off); + off += 4; + if (off + n > p.length) return null; + const v = Buffer.from(p.subarray(off, off + n)); + off += n; + return v; + }; + const blob = str(); + if (blob === null || blob.length === 0) return false; + if (str() === null) return false; // data + if (off + 4 > p.length) return false; // flags + off += 4; + if (off === p.length) return true; + if (!scheme.extendedSign) return false; // trailing bytes need the extended grammar + if (str() === null) return false; // algorithms string must be whole + return off === p.length; } /** Decode an IDENTITIES_ANSWER the test way (independent of the production parser). */ @@ -110,20 +123,43 @@ function framed(payload: Buffer): Buffer { } /* ------------------------------------------------------------------ */ -/* the stub live agent: a Unix socket that answers the agent wire */ +/* the stub live agents: Unix sockets speaking one scheme's bytes */ /* ------------------------------------------------------------------ */ interface StubAgent { path: string; - /** Every request payload the stub RECEIVED: the "never forwarded" witness. */ + /** Every request payload the stub RECEIVED, in order (the probes included). */ received: Buffer[]; + /** + * Install an answer mutator AFTER the open-time probe (late binding): the + * probe must always see a clean agent or the scheme would never resolve; + * only later replies get corrupted to drive the response gates. + */ + setMutate(mutate: (kind: "identities" | "sign", answer: Buffer) => Buffer): void; close(): Promise; } -async function startStubAgent(roster: { blob: Buffer; comment: string }[]): Promise { +interface RosterEntry { + blob: Buffer; + comment: string; +} + +/** + * A stub that behaves like a live agent of the given scheme: a one-byte + * identities request answers the full roster in that scheme's answer byte; a + * WELL-FORMED sign body answers SIGN_RESPONSE (it signs anything it is asked: + * "the forwarded list never contains it" is the witness that the responder + * refused before forwarding); anything else - including the probe's one-byte + * classic candidate hitting a 10.x agent, where byte 13 is a truncated sign - + * answers FAILURE(5), exactly the OpenSSH_10.2p1 measurement. {@link + * StubAgent.setMutate} can later corrupt would-be-valid answers (foreign type + * bytes, truncated rosters) to drive the responder's response gates. + */ +async function startStubAgent(roster: RosterEntry[], scheme: AgentScheme): Promise { const dir = mkdtempSync(join(tmpdir(), "subshell-relay-stub-")); const path = join(dir, "agent.sock"); const received: Buffer[] = []; + const state: { mutate?: (kind: "identities" | "sign", answer: Buffer) => Buffer } = {}; const server: Server = createServer((conn: Socket) => { let buffer = Buffer.alloc(0); conn.on("data", (chunk: Buffer) => { @@ -136,14 +172,14 @@ async function startStubAgent(roster: { blob: Buffer; comment: string }[]): Prom buffer = buffer.subarray(4 + len); received.push(request); const type = request[0]; - if (type === SSH2_AGENTC_REQUEST_IDENTITIES) { - conn.write(framed(buildIdentitiesAnswer(roster))); - } else if (type === SSH2_AGENTC_SIGN_REQUEST) { - // The stub signs ANYTHING it is asked: "the stub never received it" - // is the witness that the responder refused before forwarding. - conn.write(framed(Buffer.concat([Buffer.from([SSH2_AGENT_SIGN_RESPONSE]), sshStr(Buffer.from("SIG"))]))); + if (request.length === 1 && type === scheme.identities) { + const answer = buildIdentitiesAnswer(scheme, roster); + conn.write(framed(state.mutate ? state.mutate("identities", answer) : answer)); + } else if (type === scheme.sign && isWellFormedSignBody(request, scheme)) { + const answer = Buffer.concat([Buffer.from([scheme.signResponse]), sshStr(Buffer.from("SIG"))]); + conn.write(framed(state.mutate ? state.mutate("sign", answer) : answer)); } else { - conn.write(framed(Buffer.from([SSH2_AGENT_FAILURE]))); + conn.write(framed(Buffer.from([5]))); } } }); @@ -156,7 +192,49 @@ async function startStubAgent(roster: { blob: Buffer; comment: string }[]): Prom server.once("listening", () => resolve()); server.listen(path); }); - return { path, received, close: () => new Promise((r) => server.close(() => r())) }; + return { + path, + received, + setMutate: (mutate) => { + state.mutate = mutate; + }, + close: () => new Promise((r) => server.close(() => r())), + }; +} + +/** A stub answering FAILURE(5) to EVERY request: it speaks neither scheme. */ +async function startUnprobeableAgent(): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-stub-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + received.push(Buffer.from(buffer.subarray(4, 4 + len))); + buffer = buffer.subarray(4 + len); + conn.write(framed(Buffer.from([5]))); + }); + conn.on("error", () => { + /* per-request closes; never throw */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { + path, + received, + setMutate: () => { + /* this stub never produces a valid answer to corrupt */ + }, + close: () => new Promise((r) => server.close(() => r())), + }; } /* ------------------------------------------------------------------ */ @@ -196,12 +274,12 @@ const bKeysReady = machineKeys(); const evilKeysReady: Promise = es256Pair(); /* ------------------------------------------------------------------ */ -/* the fixture: a brokered A session over RelaySessions + the stub */ +/* the fixture: a brokered A session over RelaySessions + a stub agent */ /* ------------------------------------------------------------------ */ const KEY_IN = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-IN"))]); const KEY_OUT = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-OUT"))]); -const ROSTER = [ +const ROSTER: RosterEntry[] = [ { blob: KEY_IN, comment: "granted key" }, { blob: KEY_OUT, comment: "ungranted key" }, ]; @@ -210,24 +288,41 @@ interface Fixture { relay: RelaySessions; frames: RelayFrame[]; stub: StubAgent; + /** Messages the stub received AFTER the open-time probe finished. */ + forwarded(): Buffer[]; dataDir: string; aPublic: { signing: string; encryption: string }; aPrivate: { signing: string; encryption: string }; bKeys: { signing: JwkPair; encryption: JwkPair }; + /** The scheme the stub agent speaks and the probe is expected to resolve. */ + scheme: AgentScheme; cleanup(): Promise; } -/** Open the A side with the shipped command shape through the real registration path. */ +/** + * Open the A side with the shipped command shape through the real registration + * path. The numbering probe runs INSIDE openARelaySession, so by the time this + * resolves the stub has already fielded the probe bytes; everything received + * after that point is what the responder forwarded for B's requests. + */ async function openFixture( overrides: Partial = {}, - opts: { resolveAgentSocket?: () => string | null; send?: (frame: RelayFrame) => void } = {}, + opts: { + resolveAgentSocket?: () => string | null; + send?: (frame: RelayFrame) => void; + scheme?: AgentScheme; + roster?: RosterEntry[]; + unprobeable?: boolean; + mutateAnswer?: (kind: "identities" | "sign", answer: Buffer) => Buffer; + } = {}, ): Promise { + const scheme = opts.scheme ?? OPENSSH_10X_SCHEME; const bKeys = await bKeysReady; const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-responder-")); // The responder loads A's identity from the data dir; mint it first so the // test side knows A's keys (loadOrCreateIdentity is idempotent per dir). const aIdentity = await loadOrCreateIdentity(dataDir); - const stub = await startStubAgent(ROSTER); + const stub = opts.unprobeable ? await startUnprobeableAgent() : await startStubAgent(opts.roster ?? ROSTER, scheme); const relay = new RelaySessions(); const frames: RelayFrame[] = []; const cmd: SshRelayOpenCommand = { @@ -256,14 +351,18 @@ async function openFixture( }), resolveAgentSocket: opts.resolveAgentSocket ?? ((): string => stub.path), }); + const probeCount = stub.received.length; // whatever the open-time probe asked + if (opts.mutateAnswer) stub.setMutate(opts.mutateAnswer); // corrupt only post-probe answers return { relay, frames, stub, + forwarded: (): Buffer[] => stub.received.slice(probeCount), dataDir, aPublic: { signing: aIdentity.signingPublicJwk, encryption: aIdentity.publicJwk }, aPrivate: { signing: aIdentity.signingPrivateJwk, encryption: aIdentity.privateJwk }, bKeys, + scheme, cleanup: async () => { relay.closeAll("lifetime-expiry"); await stub.close(); @@ -351,14 +450,82 @@ async function waitUntil(cond: () => boolean, what: string, timeoutMs = 3000): P } /* ------------------------------------------------------------------ */ -/* REQUEST_IDENTITIES: forwarded, then filtered to the grant set */ +/* the open-time probe (the 2026-10-08 numbering ruling) */ /* ------------------------------------------------------------------ */ -test("a REQUEST_IDENTITIES (13) from pinned B gets an IDENTITIES_ANSWER filtered to the grant fingerprints, and the session's second request rides the recorded nA", async () => { +test("opening a session on a 10.x agent probes classic-then-10.x, resolves on the 11-to-12 answer, and forwards nothing", async () => { + const f = await openFixture(); // the 10.x stub is the fleet default (measured OpenSSH_10.2p1) + try { + // Byte 13 first: the 10.x stub reads it as a truncated sign and answers + // FAILURE(5), the measured behavior. Byte 11 then answers a valid + // IDENTITIES_ANSWER(12): the scheme resolves positively on the 11-to-12 pair. + expect(f.stub.received).toEqual([Buffer.from([13]), Buffer.from([11])]); + // The probe's roster is A's own view of the world: it never rides to B. + expect(f.frames.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +test("opening a session on a classic agent resolves on the 13-to-14 answer in a single probe", async () => { + const f = await openFixture({}, { scheme: CLASSIC_SCHEME }); + try { + expect(f.stub.received).toEqual([Buffer.from([13])]); + expect(f.frames.length).toBe(0); + } finally { + await f.cleanup(); + } +}); + +test("the probe never sends anything but one-byte identities requests (it can never ask for a signature)", async () => { const f = await openFixture(); try { + for (const payload of f.stub.received) { + expect(payload.length).toBe(1); + expect([11, 13]).toContain(payload[0]); + } + } finally { + await f.cleanup(); + } +}); + +test("an agent the probe cannot resolve refuses every request and forwards nothing beyond the probes", async () => { + const f = await openFixture({}, { unprobeable: true }); // FAILURE(5) to everything + try { + expect(f.stub.received).toEqual([Buffer.from([13]), Buffer.from([11])]); // both candidates tried const nB = newNonce(); - const req1 = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + // Every byte SOME scheme would honor must be refused while the scheme is + // unresolved: never forwarded under a guessed interpretation. + const requests = [ + Buffer.from([11]), + buildSignRequestTenX(KEY_IN), + Buffer.from([13]), + buildSignRequestClassic(KEY_IN), + ]; + let nA: string | undefined; + for (const [i, agentBytes] of requests.entries()) { + const req = await sealRequest(f, { agentBytes, seq: i, nB, ...(nA === undefined ? {} : { nA }) }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === i + 1, `refusal ${i}`); + const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); + expect([...reply.agentBytes]).toEqual([5]); + nA = reply.nA; // later requests must carry the recorded nonce onward (real B's state) + } + expect(f.forwarded()).toEqual([]); // nothing beyond the two one-byte probes + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the 10.x scheme: identities 11/answer 12, sign 13/response 14 */ +/* ------------------------------------------------------------------ */ + +test("under the 10.x scheme a REQUEST_IDENTITIES (11) from pinned B gets an IDENTITIES_ANSWER (12) filtered to the grant, and the session's second request rides the recorded nA", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const req1 = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); expect(parseRelayFrame(JSON.stringify(req1))).not.toBeNull(); f.relay.onInboundRelayFrame(req1); await waitUntil(() => f.frames.length === 1, "one A2B reply frame"); @@ -373,67 +540,62 @@ test("a REQUEST_IDENTITIES (13) from pinned B gets an IDENTITIES_ANSWER filtered expect(reply1.seq).toBe(0); // the signed seq of the first reply const answer = parseAnswer(reply1.agentBytes); - expect(answer.type).toBe(SSH2_AGENT_IDENTITIES_ANSWER); + expect(answer.type).toBe(12); // the 10.x answer byte, never an invented 2 (C-1) expect(answer.count).toBe(1); // the stub roster had 2; only the granted fingerprint survives expect(answer.comments).toEqual(["granted key"]); - expect(f.stub.received.length).toBe(1); // the agent saw exactly one request - expect(f.stub.received[0]).toEqual(Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES])); + expect(f.forwarded()[0]).toEqual(Buffer.from([11])); // the agent saw the identities request // The second request carries A's recorded nonce; A's expect binds it onward. - const req2 = await sealRequest(f, { agentBytes: buildSignRequest(KEY_IN), seq: 1, nB, nA: reply1.nA }); + // The 10.x extended grammar: blob, data, flags, trailing algorithms string. + const signReq = buildSignRequestTenX(KEY_IN, { algorithms: "ssh-ed25519" }); + const req2 = await sealRequest(f, { agentBytes: signReq, seq: 1, nB, nA: reply1.nA }); f.relay.onInboundRelayFrame(req2); await waitUntil(() => f.frames.length === 2, "the second A2B reply frame"); const reply2 = await decodeA2B(f, f.frames[1], { nB, nA: reply1.nA }); expect(f.frames[1].seq).toBe(1); // transport seq advanced expect(reply2.seq).toBe(1); // signed anti-replay seq advanced expect(reply2.nA).toBe(reply1.nA); // every A2B carries the SAME nA (Task-6 handoff) - expect(reply2.agentBytes).toEqual( - Buffer.concat([Buffer.from([SSH2_AGENT_SIGN_RESPONSE]), sshStr(Buffer.from("SIG"))]), - ); - expect(f.stub.received.length).toBe(2); - expect(f.stub.received[1]).toEqual(buildSignRequest(KEY_IN)); + expect([...reply2.agentBytes]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); // SIGN_RESPONSE 14 + expect(f.forwarded()[1]).toEqual(signReq); // byte-identical forward, extended grammar included } finally { await f.cleanup(); } }); -/* ------------------------------------------------------------------ */ -/* SIGN_REQUEST: in-set forwarded, out-of-set refused before the agent */ -/* ------------------------------------------------------------------ */ - -test("a SIGN_REQUEST (15) for an OUT-of-set blob is refused with SSH2_AGENT_FAILURE and never forwarded to A's agent", async () => { +test("C-2 regression: a crafted byte-13 SIGN_REQUEST under the 10.x scheme with an OUT-of-grant blob is refused and never forwarded", async () => { + // Byte 13 is SIGN under 10.x. Before the fix the responder treated it as + // the classic identities request and forwarded it raw, unscoped, so A's + // agent would have signed an attacker-chosen blob. Now the probe resolves + // 10.x and the fingerprint scope must reject it BEFORE any forward. const f = await openFixture(); try { const nB = newNonce(); - const req = await sealRequest(f, { agentBytes: buildSignRequest(KEY_OUT), seq: 0, nB }); + const crafted = buildSignRequestTenX(KEY_OUT); // KEY_OUT is not in the grant + const req = await sealRequest(f, { agentBytes: crafted, seq: 0, nB }); f.relay.onInboundRelayFrame(req); await waitUntil(() => f.frames.length === 1, "a refusal reply frame"); const reply = await decodeA2B(f, f.frames[0], { nB }); - expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + expect([...reply.agentBytes]).toEqual([5]); // SSH2_AGENT_FAILURE await sleep(30); - expect(f.stub.received.length).toBe(0); // the stub agent never saw it (§5.4: refused at the responder) + expect(f.forwarded()).toEqual([]); // §5.4: checked at the responder, never forwarded + // And the byte-13 traffic the stub saw is only the one-byte probe, never + // the crafted body. + for (const seen of f.stub.received) { + if (seen[0] === 13) expect(seen.length).toBe(1); + } } finally { await f.cleanup(); } }); -/* ------------------------------------------------------------------ */ -/* method allow-list: default-deny on everything else */ -/* ------------------------------------------------------------------ */ - -test("disallowed request types (ADD 17, REMOVE 18, REMOVE_ALL 19, LOCK 22, UNLOCK 23, EXTENSION 27, SSH1 1) are refused and never forwarded", async () => { +test("under the 10.x scheme every codepoint the scheme does not name is refused and never forwarded (default-deny)", async () => { const f = await openFixture(); try { const nB = newNonce(); - const disallowed = [ - SSH2_AGENTC_ADD_IDENTITY, - SSH2_AGENTC_REMOVE_IDENTITY, - SSH2_AGENTC_REMOVE_ALL_IDENTITIES, - SSH2_AGENTC_LOCK, - SSH2_AGENTC_UNLOCK, - SSH2_AGENTC_EXTENSION, - SSH1_AGENTC_REQUEST_RSA_IDENTITIES, - ]; + // 15/16 are the CLASSIC sign request/response bytes; 14 is 10.x's own + // response byte aimed at us as a request; the rest are agent-proto.h + // mutations/locks/SSH1 codes. NONE is 10.x identities (11) or sign (13). + const disallowed = [15, 16, 14, 17, 18, 19, 22, 23, 27, 1, 200]; let nA: string | undefined; for (const [i, type] of disallowed.entries()) { const req = await sealRequest(f, { @@ -445,50 +607,209 @@ test("disallowed request types (ADD 17, REMOVE 18, REMOVE_ALL 19, LOCK 22, UNLOC f.relay.onInboundRelayFrame(req); await waitUntil(() => f.frames.length === i + 1, `a refusal for disallowed type ${type}`); const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); - expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + expect([...reply.agentBytes]).toEqual([5]); expect(f.frames[i].seq).toBe(i); nA = reply.nA; // later requests must carry the recorded nonce onward (real B's state) } await sleep(30); - expect(f.stub.received.length).toBe(0); // nothing reached A's agent + expect(f.forwarded()).toEqual([]); // nothing reached A's agent } finally { await f.cleanup(); } }); -test("a malformed SIGN_REQUEST (truncated wire) is refused and not forwarded", async () => { +test("under the 10.x scheme malformed sign bodies are refused and never forwarded", async () => { const f = await openFixture(); try { const nB = newNonce(); - const truncated = Buffer.concat([Buffer.from([SSH2_AGENTC_SIGN_REQUEST]), sshStr(KEY_IN).subarray(0, 2)]); - const req = await sealRequest(f, { agentBytes: truncated, seq: 0, nB }); + let nA: string | undefined; + const malformed: Buffer[] = [ + Buffer.from([13, 0, 0, 0, 2]), // truncated blob length + Buffer.concat([Buffer.from([13]), sshStr(KEY_IN)]), // blob-only: no data, no flags + Buffer.concat([Buffer.from([13]), sshStr(KEY_IN), be32(0)]), // blob+flags: not the wire grammar + Buffer.concat([Buffer.from([13]), sshStr(KEY_IN), sshStr("DATA")]), // missing flags + Buffer.concat([Buffer.from([13]), sshStr(Buffer.alloc(0)), sshStr("DATA"), be32(0)]), // empty blob + Buffer.concat([Buffer.from([13]), sshStr(KEY_IN), sshStr("DATA"), be32(0), Buffer.from([0, 0])]), // junk tail + Buffer.concat([ + Buffer.from([13]), + sshStr(KEY_IN), + sshStr("DATA"), + be32(0), + Buffer.from([0, 0, 0, 99]), // algorithms string longer than the buffer + ]), + ]; + for (const [i, agentBytes] of malformed.entries()) { + const req = await sealRequest(f, { agentBytes, seq: i, nB, ...(nA === undefined ? {} : { nA }) }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === i + 1, `a refusal for malformed body ${i}`); + const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); + expect([...reply.agentBytes]).toEqual([5]); + nA = reply.nA; + } + await sleep(30); + expect(f.forwarded()).toEqual([]); + } finally { + await f.cleanup(); + } +}); + +test("under the 10.x scheme a REQUEST_IDENTITIES (11) must be exactly one byte (extra bytes are refused, not forwarded)", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11, 0, 1, 2, 3]), seq: 0, nB }); f.relay.onInboundRelayFrame(req); - await waitUntil(() => f.frames.length === 1, "a refusal reply frame"); + await waitUntil(() => f.frames.length === 1, "the refusal"); const reply = await decodeA2B(f, f.frames[0], { nB }); - expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + expect([...reply.agentBytes]).toEqual([5]); await sleep(30); - expect(f.stub.received.length).toBe(0); + expect(f.forwarded()).toEqual([]); + } finally { + await f.cleanup(); + } +}); + +test("under the 10.x scheme a grant naming no fingerprint serves the count-zero answer (12), never the roster", async () => { + const f = await openFixture({ fingerprints: [] }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the filtered reply frame"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + // The full two-key roster is on the stub; the answer is five bytes: 12 + count 0. + expect([...reply.agentBytes]).toEqual([12, 0, 0, 0, 0]); } finally { await f.cleanup(); } }); /* ------------------------------------------------------------------ */ -/* the empty grant: serve nothing, never the full list */ +/* the classic scheme: identities 13/answer 14, sign 15/response 16 */ /* ------------------------------------------------------------------ */ -test("a grant naming no fingerprint yields an empty (count 0) IDENTITIES_ANSWER, never the roster", async () => { - const f = await openFixture({ fingerprints: [] }); +test("under the classic scheme a REQUEST_IDENTITIES (13) gets an IDENTITIES_ANSWER (14) filtered to the grant", async () => { + const f = await openFixture({}, { scheme: CLASSIC_SCHEME }); try { const nB = newNonce(); - const req = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + const req = await sealRequest(f, { agentBytes: Buffer.from([13]), seq: 0, nB }); f.relay.onInboundRelayFrame(req); - await waitUntil(() => f.frames.length === 1, "the filtered reply frame"); + await waitUntil(() => f.frames.length === 1, "one A2B reply frame"); const reply = await decodeA2B(f, f.frames[0], { nB }); const answer = parseAnswer(reply.agentBytes); - expect(answer.type).toBe(SSH2_AGENT_IDENTITIES_ANSWER); - expect(answer.count).toBe(0); - expect(reply.agentBytes.length).toBe(5); // type + zero count, nothing else + expect(answer.type).toBe(14); // the classic answer byte + expect(answer.count).toBe(1); + expect(answer.comments).toEqual(["granted key"]); + expect(f.forwarded()[0]).toEqual(Buffer.from([13])); // byte 13 IS identities here + } finally { + await f.cleanup(); + } +}); + +test("under the classic scheme a SIGN_REQUEST (15) is scoped by fingerprint and a granted one rides byte-identical", async () => { + const f = await openFixture({}, { scheme: CLASSIC_SCHEME }); + try { + const nB = newNonce(); + const outOfSet = buildSignRequestClassic(KEY_OUT); + const bad = await sealRequest(f, { agentBytes: outOfSet, seq: 0, nB }); + f.relay.onInboundRelayFrame(bad); + await waitUntil(() => f.frames.length === 1, "the out-of-set refusal"); + const refusal = await decodeA2B(f, f.frames[0], { nB }); + expect([...refusal.agentBytes]).toEqual([5]); + expect(f.forwarded()).toEqual([]); // out-of-set never reached the agent + + const inSet = buildSignRequestClassic(KEY_IN); + const good = await sealRequest(f, { agentBytes: inSet, seq: 1, nB, nA: refusal.nA }); + f.relay.onInboundRelayFrame(good); + await waitUntil(() => f.frames.length === 2, "the signature reply"); + const reply = await decodeA2B(f, f.frames[1], { nB, nA: refusal.nA }); + expect([...reply.agentBytes]).toEqual([16, 0, 0, 0, 3, ...Buffer.from("SIG")]); // SIGN_RESPONSE 16 + expect(f.forwarded()[0]).toEqual(inSet); + } finally { + await f.cleanup(); + } +}); + +test("under the classic scheme the 10.x bytes and non-grammatical bodies are refused and never forwarded", async () => { + const f = await openFixture({}, { scheme: CLASSIC_SCHEME }); + try { + const nB = newNonce(); + let nA: string | undefined; + const cases = [ + Buffer.from([11]), // 10.x identities: a classic agent has no such request + Buffer.from([12]), // 10.x's answer byte aimed at us as a request + Buffer.from([13, 9]), // classic identities must be exactly one byte + buildSignRequestClassic(KEY_IN).subarray(0, 5), // truncated before data + buildSignRequestTenX(KEY_IN), // byte 13 with a full body: 13 must be one byte here + Buffer.concat([buildSignRequestClassic(KEY_IN), sshStr("ssh-ed25519")]), // trailing algorithms: the extended grammar belongs to 10.x + ]; + for (const [i, agentBytes] of cases.entries()) { + const req = await sealRequest(f, { agentBytes, seq: i, nB, ...(nA === undefined ? {} : { nA }) }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === i + 1, `a refusal for case ${i}`); + const reply = await decodeA2B(f, f.frames[i], { nB, ...(nA === undefined ? {} : { nA }) }); + expect([...reply.agentBytes]).toEqual([5]); + nA = reply.nA; + } + await sleep(30); + expect(f.forwarded()).toEqual([]); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* agent answers that misbehave (the response gates) */ +/* ------------------------------------------------------------------ */ + +test("an agent answering identities with a FOREIGN type byte becomes a refusal, never a forward-through", async () => { + // The 10.x session asks [11]; the stub answers a well-formed roster spelled + // with the classic 14 (the collision byte). The resolved scheme's answer + // gate must refuse it: 14 is a SIGN_RESPONSE here, not a roster. + const f = await openFixture({}, { mutateAnswer: (_kind, answer) => Buffer.from([14, ...answer.subarray(1)]) }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); + } finally { + await f.cleanup(); + } +}); + +test("an unparseable roster never rides on: partial parsing is exactly what §5.4 exists to prevent", async () => { + // A valid 12 header whose declared entries the buffer does not carry. + const f = await openFixture({}, { mutateAnswer: (_kind, answer) => answer.subarray(0, answer.length - 4) }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); + } finally { + await f.cleanup(); + } +}); + +test("an agent answering a granted sign with a FOREIGN response byte becomes a refusal (the signature never rides)", async () => { + // The 10.x session forwards byte 13; the stub answers type 16 (the classic + // SIGN_RESPONSE) with a signature: under the resolved 10.x scheme that is + // not a legal answer, and the bytes must never reach B. + const f = await openFixture( + {}, + { mutateAnswer: (kind, answer) => (kind === "sign" ? Buffer.from([16, ...answer.subarray(1)]) : answer) }, + ); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: buildSignRequestTenX(KEY_IN), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); + expect(f.forwarded().length).toBe(1); // the scoped request DID ride; its answer did not } finally { await f.cleanup(); } @@ -503,7 +824,7 @@ test("a request signed by a key that is not B's pin is refused: no reply frame, const evil = await evilKeysReady; try { const req = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + agentBytes: Buffer.from([11]), seq: 0, nB: newNonce(), signerPrivateJwk: evil.privateJwk, @@ -511,7 +832,7 @@ test("a request signed by a key that is not B's pin is refused: no reply frame, f.relay.onInboundRelayFrame(req); await sleep(50); expect(f.frames.length).toBe(0); - expect(f.stub.received.length).toBe(0); + expect(f.forwarded()).toEqual([]); } finally { await f.cleanup(); } @@ -523,7 +844,7 @@ test("a request bound to a foreign relay-session id or a foreign nB nonce is ref const nB = newNonce(); // Cross-session replay: right keys, another session's id. const wrongSession = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + agentBytes: Buffer.from([11]), seq: 0, nB, relaySessionId: "relay-EVIL", @@ -533,14 +854,14 @@ test("a request bound to a foreign relay-session id or a foreign nB nonce is ref expect(f.frames.length).toBe(0); // Land the genuine first request so nB is recorded... - const good = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + const good = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); f.relay.onInboundRelayFrame(good); await waitUntil(() => f.frames.length === 1, "the genuine reply"); const first = await decodeA2B(f, f.frames[0], { nB }); // ...then a second-seq request carrying a nonce this session never saw. const foreignNonce = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + agentBytes: Buffer.from([11]), seq: 1, nB: newNonce(), nA: first.nA, @@ -548,7 +869,7 @@ test("a request bound to a foreign relay-session id or a foreign nB nonce is ref f.relay.onInboundRelayFrame(foreignNonce); await sleep(50); expect(f.frames.length).toBe(1); // no reply to the foreign nonce - expect(f.stub.received.length).toBe(1); // and nothing further reached the agent + expect(f.forwarded().length).toBe(1); // and nothing further reached the agent } finally { await f.cleanup(); } @@ -558,7 +879,7 @@ test("a replayed or non-increasing-seq request is refused: no reply frame, nothi const f = await openFixture(); try { const nB = newNonce(); - const good = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + const good = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); f.relay.onInboundRelayFrame(good); await waitUntil(() => f.frames.length === 1, "the first genuine reply"); const first = await decodeA2B(f, f.frames[0], { nB }); @@ -566,24 +887,14 @@ test("a replayed or non-increasing-seq request is refused: no reply frame, nothi // Byte-identical resend of the first frame (the plane can resend anything). f.relay.onInboundRelayFrame(good); // A freshly signed request that rewinds the seq (the signature binds seq). - const rewind = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), - seq: 0, - nB, - nA: first.nA, - }); + const rewind = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB, nA: first.nA }); f.relay.onInboundRelayFrame(rewind); await sleep(50); expect(f.frames.length).toBe(1); - expect(f.stub.received.length).toBe(1); + expect(f.forwarded().length).toBe(1); // The session still works after refusals: seq 1 advances. - const next = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), - seq: 1, - nB, - nA: first.nA, - }); + const next = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 1, nB, nA: first.nA }); f.relay.onInboundRelayFrame(next); await waitUntil(() => f.frames.length === 2, "the next-seq reply still lands"); } finally { @@ -597,7 +908,7 @@ test("a frame on another ref or with the A2B direction never reaches the respond const nB = newNonce(); // Another ref: the registry itself drops it (the unbrokered-session case). const foreignRef = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + agentBytes: Buffer.from([11]), seq: 0, nB, ref: "r-EVIL", @@ -605,7 +916,7 @@ test("a frame on another ref or with the A2B direction never reaches the respond f.relay.onInboundRelayFrame(foreignRef); // The wrong direction for A's side (B's own echo posture). const echo = await sealRequest(f, { - agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), + agentBytes: Buffer.from([11]), seq: 0, nB, direction: "A2B", @@ -613,7 +924,7 @@ test("a frame on another ref or with the A2B direction never reaches the respond f.relay.onInboundRelayFrame(echo); await sleep(50); expect(f.frames.length).toBe(0); - expect(f.stub.received.length).toBe(0); + expect(f.forwarded()).toEqual([]); } finally { await f.cleanup(); } @@ -654,6 +965,7 @@ test("openARelaySession refuses a command that does not name this machine in its peerEncryptPublicKey: encB64, }, sendRelayFrame: () => {}, + resolveAgentSocket: () => null, }), ).rejects.toThrow(/not the A side/); // role A but aNodeId names someone else: not OUR brokered session. @@ -670,6 +982,7 @@ test("openARelaySession refuses a command that does not name this machine in its peerEncryptPublicKey: encB64, }, sendRelayFrame: () => {}, + resolveAgentSocket: () => null, }), ).rejects.toThrow(/not the key home/); // The mirrored guard on the B branch: bNodeId must name the receiver too. @@ -720,6 +1033,7 @@ test("openARelaySession enforces §4.4 byte-equality on B's pin and the duplicat peerEncryptPublicKey: Buffer.from(otherB.encryption.publicJwk, "utf8").toString("base64"), }, sendRelayFrame: () => {}, + resolveAgentSocket: () => null, }), ).rejects.toThrow(/pin for b-node has MOVED/); // Same pinned pairing, already-owned ref: refused, registry untouched. @@ -736,6 +1050,7 @@ test("openARelaySession enforces §4.4 byte-equality on B's pin and the duplicat peerEncryptPublicKey: Buffer.from(f.bKeys.encryption.publicJwk, "utf8").toString("base64"), }, sendRelayFrame: () => {}, + resolveAgentSocket: () => null, }), ).rejects.toThrow(/routing ref already owned/); expect(f.relay.size).toBe(1); @@ -768,6 +1083,7 @@ test("a pinned peer key that is not a usable public P-256 JWK rejects the sessio lifetimeMs: 30_000, }, sendRelayFrame: () => {}, + resolveAgentSocket: () => null, }), ).rejects.toThrow(/relay open refused/); expect(relay.size).toBe(0); @@ -780,11 +1096,11 @@ test("relay.close tears the responder down: later frames produce nothing", async expect(f.relay.close("r-1", "grant-revoked")).toBe(true); expect(f.relay.has("r-1")).toBe(false); const nB = newNonce(); - const req = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); f.relay.onInboundRelayFrame(req); // the registry: unknown ref now, dropped await sleep(30); expect(f.frames.length).toBe(0); - expect(f.stub.received.length).toBe(0); + expect(f.forwarded()).toEqual([]); } finally { await f.cleanup(); } @@ -794,22 +1110,17 @@ test("relay.close tears the responder down: later frames produce nothing", async /* the live agent socket seam */ /* ------------------------------------------------------------------ */ -test("liveAgentSocketPath honors only an absolute SSH_AUTH_SOCK (the ssh-resolve rule)", () => { - expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "/run/user/1000/ssh-agent.sock" })).toBe("/run/user/1000/ssh-agent.sock"); - expect(liveAgentSocketPath({})).toBeNull(); - expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "" })).toBeNull(); - expect(liveAgentSocketPath({ SSH_AUTH_SOCK: "relative/path.sock" })).toBeNull(); -}); - -test("with no live agent socket the responder answers SSH2_AGENT_FAILURE and touches nothing", async () => { +test("with no live agent socket the session opens with no scheme and answers SSH2_AGENT_FAILURE, touching nothing", async () => { const f = await openFixture({}, { resolveAgentSocket: () => null }); try { + // Nothing connected at open either: the probe only runs when a socket exists. + expect(f.stub.received.length).toBe(0); const nB = newNonce(); - const req = await sealRequest(f, { agentBytes: Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]), seq: 0, nB }); + const req = await sealRequest(f, { agentBytes: Buffer.from([11]), seq: 0, nB }); f.relay.onInboundRelayFrame(req); await waitUntil(() => f.frames.length === 1, "the no-agent refusal reply"); const reply = await decodeA2B(f, f.frames[0], { nB }); - expect([...reply.agentBytes]).toEqual([SSH2_AGENT_FAILURE]); + expect([...reply.agentBytes]).toEqual([5]); expect(f.stub.received.length).toBe(0); // the stub socket was never connected } finally { await f.cleanup(); @@ -817,7 +1128,7 @@ test("with no live agent socket the responder answers SSH2_AGENT_FAILURE and tou }); /* ------------------------------------------------------------------ */ -/* the filtering/parser helpers, pinned directly */ +/* startRelayResponder's own gates */ /* ------------------------------------------------------------------ */ test("startRelayResponder rejects a pinned peer key that is not public P-256 material (the deep bytesOfJwk gate)", () => { @@ -833,32 +1144,10 @@ test("startRelayResponder rejects a pinned peer key that is not public P-256 mat ownEncryptionPrivateJwk: "{}", ownSigningPrivateJwk: "{}", fingerprints: [], + agentScheme: OPENSSH_10X_SCHEME, seal: mcpSeal, open: mcpOpen, sendRelayFrame: () => {}, }), ).toThrow(/relay responder: pinned peer key rejected/); }); - -test("fingerprintAgentBlob spells SHA256 over the wire blob in the grant grammar's base64url form", () => { - const s = fingerprintAgentBlob(Buffer.from("hello")); - expect(s).toMatch(/^SHA256:[A-Za-z0-9_-]{43}$/); - expect(s).toBe(fp(Buffer.from("hello"))); -}); - -test("filterIdentitiesAnswer keeps only granted entries (count fixed, bytes preserved) and refuses malformed answers", () => { - const allowed = new Set([fp(KEY_OUT)]); // grant the SECOND entry: order must not matter - const filtered = filterIdentitiesAnswer(buildIdentitiesAnswer(ROSTER), allowed); - const answer = parseAnswer(filtered); - expect(answer.count).toBe(1); - expect(answer.comments).toEqual(["ungranted key"]); - // The kept entry's wire bytes survive byte-identical: type + count + str(blob) + str(comment). - expect(filtered).toEqual( - Buffer.concat([Buffer.from([SSH2_AGENT_IDENTITIES_ANSWER]), be32(1), sshStr(KEY_OUT), sshStr("ungranted key")]), - ); - expect(filterIdentitiesAnswer(buildIdentitiesAnswer([]), new Set()).length).toBe(5); - // Malformed: declared entries the buffer does not carry, and a wrong type byte. - const truncated = buildIdentitiesAnswer(ROSTER).subarray(0, 20); - expect(() => filterIdentitiesAnswer(truncated, new Set())).toThrow(); - expect(() => filterIdentitiesAnswer(Buffer.from([SSH2_AGENT_SIGN_RESPONSE, 0, 0, 0, 0]), new Set())).toThrow(); -}); diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index eb2841966..02273ba02 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -3,8 +3,10 @@ import { bytesOfJwk, type RelayFrame, type SshRelayOpenCommand } from "@internal import { loadOrCreateIdentity } from "../identity.js"; import { log } from "../log.js"; import { MachinePinStore } from "../machine-pin-store.js"; +import { type AgentScheme, probeAgentScheme } from "../relay-agent-scheme.js"; +import { liveAgentSocketPath, requestLiveAgent } from "../relay-agent-socket.js"; import { startAgentProxy } from "../relay-proxy.js"; -import { liveAgentSocketPath, startRelayResponder } from "../relay-responder.js"; +import { startRelayResponder } from "../relay-responder.js"; /** * The node-side relay session registry (spec 2026-10-08 §5.1/§5.2) and the B @@ -266,9 +268,10 @@ export interface ARelaySessionArgs { */ sendRelayFrame(frame: RelayFrame): void; /** - * Resolve A's live agent socket per request; defaults to - * `liveAgentSocketPath` (the connecting account's absolute `SSH_AUTH_SOCK`, - * the ssh-resolve rule). The test seam for a stub agent. + * Resolve A's live agent socket; defaults to `liveAgentSocketPath` (the + * connecting account's absolute `SSH_AUTH_SOCK`, the ssh-resolve rule). + * Used ONCE by the open-time numbering probe and again per forwarded + * request. The test seam for a stub agent. */ resolveAgentSocket?: () => string | null; /** Line sink (defaults to the agent logger); never sees keys, fingerprints, or agent bytes. */ @@ -278,14 +281,19 @@ export interface ARelaySessionArgs { /** * Open the A side of a brokered relay pairing: enforce §4.4's machine pin on * B (first pairing writes both halves, a moved pin is a hard block naming the - * peer and §4.5's re-pair), then start {@link startRelayResponder} for the - * command's grant (its selected fingerprint set is carried by the command - * itself, §5.1 - the responder enforces it without a REST call it cannot - * make) and register the ref. Throws a named refusal (the dispatch wrapper - * turns it into `ok:false`) on: role other than A, a pairing that names a - * different machine as A, an unusable peer key, a MOVED pin, or a ref already - * owned. Resolves with the session id - nothing about the machine is in the - * answer, and no path exists to leak. + * peer and §4.5's re-pair), PROBE A's live agent to resolve its numbering + * (the ruling of 2026-10-08: classic 13/15 and OpenSSH-10.x 11/13 collide at + * byte 13, so {@link probeAgentScheme} must name the scheme once, here, before + * any byte is ever classified; an unresolved probe opens a session that + * refuses everything, never one that guesses), then start + * {@link startRelayResponder} for the command's grant (its selected + * fingerprint set is carried by the command itself, §5.1 - the responder + * enforces it without a REST call it cannot make) and register the ref. + * Throws a named refusal (the dispatch wrapper turns it into `ok:false`) on: + * role other than A, a pairing that names a different machine as A, an + * unusable peer key, a MOVED pin, or a ref already owned. A probe failure is + * NOT a throw: the session opens refuse-closed. Resolves with the session id + * - nothing about the machine is in the answer, and no path exists to leak. * * The gate (ssh-enabled mirror) is the CALLER's first check, like every SSH * arm's: this function is the pairing's mechanics, not the policy. §5.4's @@ -328,8 +336,27 @@ export async function openARelaySession(args: ARelaySessionArgs): Promise<{ rela ); } + // The numbering probe (ruling 2026-10-08), at session open and ONCE: the + // responder classifies every later byte only in what this resolves. No + // socket, or an agent that answers neither candidate, is not an error to + // throw around - it is the honest unresolved case, and an unresolved + // session refuses every request rather than forward one under a guess. + const say = args.log ?? ((line: string): void => log(line)); + const resolveAgent = args.resolveAgentSocket ?? liveAgentSocketPath; + const agentSocket = resolveAgent(); + let agentScheme: AgentScheme | null = null; + if (agentSocket !== null) { + agentScheme = await probeAgentScheme(agentSocket, requestLiveAgent); + } + if (agentScheme === null) { + say(`relay session ${cmd.ref}: agent numbering unresolved at open; every request will be refused`); + } else { + say(`relay session ${cmd.ref}: agent numbering resolved: ${agentScheme.name}`); + } + const identity = await loadOrCreateIdentity(args.dataDir); const responder = startRelayResponder({ + agentScheme, relayId: cmd.relayId, ref: cmd.ref, selfNodeId: args.selfNodeId, @@ -343,7 +370,7 @@ export async function openARelaySession(args: ARelaySessionArgs): Promise<{ rela seal: mcpSeal, open: mcpOpen, sendRelayFrame: args.sendRelayFrame, - resolveAgentSocket: args.resolveAgentSocket ?? liveAgentSocketPath, + resolveAgentSocket: resolveAgent, ...(args.log === undefined ? {} : { log: args.log }), }); diff --git a/apps/node/agent/src/relay-agent-scheme.ts b/apps/node/agent/src/relay-agent-scheme.ts new file mode 100644 index 000000000..c98a5e72a --- /dev/null +++ b/apps/node/agent/src/relay-agent-scheme.ts @@ -0,0 +1,273 @@ +import { createHash } from "node:crypto"; + +/** + * The ssh-agent numbering schemes the A-side relay responder probes between + * (spec 2026-10-08 §5.4, ruling 2026-10-08: the numbering is PROBED, not + * baked). OpenSSH's classic numbering (identities 13 / answer 14 / sign 15 / + * sign response 16) and the RFC 9987 numbering OpenSSH 10.x ships (identities + * 11 / answer 12 / sign 13 / sign response 14) COLLIDE: byte 13 is the + * identities request under classic and the sign request under 10.x, and byte + * 14 is the answer under classic and the sign response under 10.x. Measured on + * this fleet's OpenSSH_10.2p1: request 11 answers 12; requests 13 and 15 both + * answer FAILURE(5). Forwarding a codepoint under the wrong interpretation is + * how an unscoped SIGN_REQUEST reaches the agent, so the responder must know + * which scheme A's live agent speaks before it classifies ANY byte. + * + * {@link probeAgentScheme} resolves that once per relay-session open: it sends + * each candidate's well-formed identities request in turn and demands a + * VALID IDENTITIES_ANSWER in that candidate's own answer byte to name the + * scheme. A byte-13 probe against a 10.x agent is a truncated sign request - + * it can only ever answer FAILURE (a probe therefore never risks a signature) + * - and the second candidate (byte 11) then names 10.x positively. No + * resolution ever rests on a guess: an agent that positively answers neither + * candidate resolves to nothing, and the session refuses everything. + * + * The parsing and scoping helpers here are the wire grammar itself: the + * per-scheme IDENTITIES_ANSWER filter, and SIGN_REQUEST bodies in both the + * classic (blob, data, flags) and the extended 10.x (trailing `string + * algorithms`) form. Task 11 (`ssh_agent_identities`) reuses + * {@link fingerprintAgentBlob} and {@link parseIdentitiesAnswer}: the + * fingerprint spelling defined here IS the grant-matching grammar (§5.4). + */ + +/** One agent request/response numbering: the four codepoints plus the body grammar. */ +export interface AgentScheme { + /** Stable short name for log lines and tests; never user data. */ + name: "openssh-10x" | "classic"; + /** SSH2_AGENTC_REQUEST_IDENTITIES: the roster request byte (one byte, no body). */ + identities: number; + /** SSH2_AGENT_IDENTITIES_ANSWER: the roster response byte this session's answers carry. */ + answer: number; + /** SSH2_AGENTC_SIGN_REQUEST: the codepoint whose body MUST be fingerprint-scoped before any forward. */ + sign: number; + /** SSH2_AGENT_SIGN_RESPONSE: the signature reply byte, otherwise opaque. */ + signResponse: number; + /** Whether SIGN_REQUEST bodies may carry the trailing `string algorithms` (RFC 9987 extended grammar). */ + extendedSign: boolean; +} + +/** + * The RFC 9987 numbering OpenSSH 10.x ships (measured OpenSSH_10.2p1: an 11 + * answers 12; a 13 answers 5 because it is a truncated sign; a 15 is unknown + * and answers 5). Sign bodies carry the extended grammar. + */ +export const OPENSSH_10X_SCHEME: AgentScheme = { + name: "openssh-10x", + identities: 11, + answer: 12, + sign: 13, + signResponse: 14, + extendedSign: true, +}; + +/** + * OpenSSH's classic numbering from `agent-proto.h` (identities 13 / answer 14 + * / sign 15 / sign response 16), the scheme pre-10.x agents and every other + * ssh-agent implementation in the field speak. Sign bodies are blob+data+ + * flags only. + */ +export const CLASSIC_SCHEME: AgentScheme = { + name: "classic", + identities: 13, + answer: 14, + sign: 15, + signResponse: 16, + extendedSign: false, +}; + +/** `SSH2_AGENT_FAILURE`: the uniform refusal, the same 5 in BOTH schemes (measured). */ +export const SSH2_AGENT_FAILURE = 5; + +/** The probe order: the classic candidate first, so a classic agent resolves in ONE round trip. */ +const PROBE_CANDIDATES: readonly AgentScheme[] = [CLASSIC_SCHEME, OPENSSH_10X_SCHEME]; + +/** One framed round trip to a live agent socket (the `requestLiveAgent` shape). */ +export type AgentRequestFn = (socketPath: string, payload: Buffer) => Promise; + +/* ------------------------------------------------------------------ */ +/* the wire codec (shared shape with the B proxy; A-side parsing) */ +/* ------------------------------------------------------------------ */ + +/** A cursor over an agent payload; every read past the end throws (never a silent partial parse). */ +class ByteReader { + #buf: Buffer; + #off = 0; + + constructor(buf: Buffer) { + this.#buf = buf; + } + + u32(): number { + if (this.#off + 4 > this.#buf.length) throw new Error("agent wire: truncated uint32"); + const v = this.#buf.readUInt32BE(this.#off); + this.#off += 4; + return v; + } + + bytes(n: number): Buffer { + if (n > this.#buf.length - this.#off) throw new Error("agent wire: truncated byte run"); + const v = this.#buf.subarray(this.#off, this.#off + n); + this.#off += n; + return v; + } + + /** An SSH string: uint32 length + bytes. */ + string(): Buffer { + return this.bytes(this.u32()); + } + + done(): boolean { + return this.#off === this.#buf.length; + } +} + +/** Serialize an SSH string: uint32 length + bytes. */ +function sshString(bytes: Buffer): Buffer { + const header = Buffer.alloc(4); + header.writeUInt32BE(bytes.length, 0); + return Buffer.concat([header, bytes]); +} + +/* ------------------------------------------------------------------ */ +/* the fingerprint grammar (§5.4: the ONLY spelling grants match on) */ +/* ------------------------------------------------------------------ */ + +/** + * The OpenSSH display fingerprint of one agent key blob: SHA-256 over the + * RAW SSH wire encoding (the agent-answer blob bytes), spelled `SHA256:` + + * base64url without padding - the same scheme `fingerprintJwk` established + * (§4.5: "Agent keys hash their agent wire encoding"; §5.4), which is also + * the only spelling the grant grammar's `GRANT_FINGERPRINT_RE` accepts. + */ +export function fingerprintAgentBlob(blob: Uint8Array): string { + return `SHA256:${createHash("sha256").update(blob).digest("base64url")}`; +} + +/* ------------------------------------------------------------------ */ +/* IDENTITIES_ANSWER: parse, then scope */ +/* ------------------------------------------------------------------ */ + +/** One roster entry as the agent answer carries it. */ +export interface AgentIdentity { + /** The public key blob, byte-exact from the wire (its fingerprint scopes it). */ + blob: Buffer; + /** The operator's label; rides through, never parsed. */ + comment: Buffer; +} + +/** + * Parse an IDENTITIES_ANSWER in the RESOLVED scheme: the type byte must be + * THAT scheme's answer codepoint (byte 14 answers an agent that speaks + * classic, and is a foreign SIGN_RESPONSE to one that speaks 10.x), and the + * declared entries must be all there and nothing more. Throws on anything + * else - an unparseable roster is refused by the caller, NEVER forwarded, + * because a partial parse of the roster is exactly what §5.4 exists to + * prevent. + */ +export function parseIdentitiesAnswer(answer: Buffer, scheme: AgentScheme): AgentIdentity[] { + if (answer.length < 5 || answer[0] !== scheme.answer) { + throw new Error(`relay agent wire: expected a ${scheme.name} IDENTITIES_ANSWER (${scheme.answer})`); + } + const reader = new ByteReader(answer.subarray(1)); + const count = reader.u32(); + const entries: AgentIdentity[] = []; + for (let i = 0; i < count; i += 1) { + const blob = reader.string(); + const comment = reader.string(); + entries.push({ blob, comment }); + } + if (!reader.done()) throw new Error("relay agent wire: IDENTITIES_ANSWER carries trailing bytes"); + return entries; +} + +/** + * Rebuild an IDENTITIES_ANSWER keeping ONLY the entries whose blob + * fingerprints into `allowed`; fixes the count; kept entries ride byte- + * identically and are re-headed with the RESOLVED scheme's answer byte. + * Throws on a malformed answer (see {@link parseIdentitiesAnswer}). + */ +export function filterIdentitiesAnswer(answer: Buffer, allowed: ReadonlySet, scheme: AgentScheme): Buffer { + const kept = parseIdentitiesAnswer(answer, scheme) + .filter((entry) => allowed.has(fingerprintAgentBlob(entry.blob))) + .map((entry) => Buffer.concat([sshString(entry.blob), sshString(entry.comment)])); + const count = Buffer.alloc(4); + count.writeUInt32BE(kept.length, 0); + return Buffer.concat([Buffer.from([scheme.answer]), count, ...kept]); +} + +/* ------------------------------------------------------------------ */ +/* SIGN_REQUEST: the body the fingerprint scope reads */ +/* ------------------------------------------------------------------ */ + +/** The parsed fields of a well-formed SIGN_REQUEST body (only `keyBlob` scopes; the rest ride through). */ +export interface SignRequestBody { + /** The target key blob: its {@link fingerprintAgentBlob} decides the forward (it MUST be checked before any). */ + keyBlob: Buffer; + /** The bytes to be signed; unexamined, forwarded inside the untouched request bytes. */ + data: Buffer; + /** Request flags; unexamined (§5.4 scopes by BLOB, not by flag). */ + flags: number; + /** The RFC 9987 extended grammar's trailing `string algorithms`; present only under the extended scheme. */ + algorithms?: Buffer; +} + +/** + * Split a SIGN_REQUEST (full payload, type byte included) into its fields. + * The wire grammar is blob, data, flags - in BOTH schemes - and the extended + * 10.x scheme additionally allows one well-formed trailing `string + * algorithms`. Anything else (missing fields, truncated strings, an empty + * blob, a classic body with trailing bytes, junk after the algorithms string) + * throws: a malformed body is refused, never forwarded. + */ +export function parseSignRequest(payload: Buffer, scheme: AgentScheme): SignRequestBody { + const reader = new ByteReader(payload.subarray(1)); + const keyBlob = reader.string(); + const data = reader.string(); + const flags = reader.u32(); + if (keyBlob.length === 0) throw new Error("relay agent wire: SIGN_REQUEST names an empty key blob"); + if (reader.done()) return { keyBlob, data, flags }; + if (!scheme.extendedSign) throw new Error("relay agent wire: classic SIGN_REQUEST carries trailing bytes"); + const algorithms = reader.string(); + if (!reader.done()) throw new Error("relay agent wire: SIGN_REQUEST carries bytes after the algorithms string"); + return { keyBlob, data, flags, algorithms }; +} + +/* ------------------------------------------------------------------ */ +/* the probe (ruling 2026-10-08: the numbering is learned, never assumed) */ +/* ------------------------------------------------------------------ */ + +/** Whether `answer` is a VALID IDENTITIES_ANSWER in `scheme` (positive confirmation, nothing weaker). */ +function isIdentitiesAnswer(answer: Buffer, scheme: AgentScheme): boolean { + if (answer.length === 0 || answer[0] !== scheme.answer) return false; + try { + parseIdentitiesAnswer(answer, scheme); + return true; + } catch { + return false; + } +} + +/** + * Learn which numbering the live agent speaks: send each candidate's + * one-byte identities request in turn (classic's 13 first, so a classic agent + * resolves in a single round trip) and demand a VALID IDENTITIES_ANSWER in + * that candidate's own answer byte to name it. A 10.x agent reads the byte-13 + * candidate as a truncated sign request and answers FAILURE(5) - the probe + * sends only one-byte requests, so it can never ask for a signature - then + * the byte-11 candidate answers a valid 12 and names the scheme positively. + * An agent that answers neither resolves to NULL: the caller must refuse + * every request, because a byte under a GUESSED scheme is the bug this + * ruling exists to kill. + */ +export async function probeAgentScheme(socketPath: string, requestAgent: AgentRequestFn): Promise { + for (const candidate of PROBE_CANDIDATES) { + let answer: Buffer; + try { + answer = await requestAgent(socketPath, Buffer.from([candidate.identities])); + } catch { + continue; // this candidate got no answer: try the next before giving up + } + if (isIdentitiesAnswer(answer, candidate)) return candidate; + } + return null; +} diff --git a/apps/node/agent/src/relay-agent-socket.ts b/apps/node/agent/src/relay-agent-socket.ts new file mode 100644 index 000000000..24ebd4da8 --- /dev/null +++ b/apps/node/agent/src/relay-agent-socket.ts @@ -0,0 +1,99 @@ +import { createConnection, type Socket } from "node:net"; +import { SSH_RELAY_FRAME_MAX_BYTES } from "@internal/subshell-protocol"; + +/** + * A's live agent socket, spoken to one framed message at a time. This is the + * ONLY path from the relay to a key (§3 item 4 / §5.4, "agent-only"): the + * responder and the numbering probe connect to the running agent through + * `SSH_AUTH_SOCK` and nothing else - no private key file is ever opened here, + * and nothing about key material reaches a log line. + * + * The trust rule is the one {@link liveAgentSocketPath} shares with the launch + * side (`ssh-resolve.ts`): the env sock is trusted because it is the + * connecting account's own setup, and only an absolute path qualifies. Each + * request rides its own one-shot connection (ssh's agent protocol permits it; + * no session state lives on the socket), under the relay frame cap and a + * local timeout - ssh's own agent budget is unbounded, ours may not be. + */ + +/** One agent message's maximum payload: the relay frame cap (§5.1: cap is law). */ +const MAX_AGENT_MESSAGE_BYTES = SSH_RELAY_FRAME_MAX_BYTES; + +/** Local I/O budget for one round trip to A's agent (a local Unix socket call). */ +const AGENT_REQUEST_TIMEOUT_MS = 10_000; + +/** + * The connecting account's live agent socket: an absolute `SSH_AUTH_SOCK`, + * else null. A null answer is the honest no-agent case: the relay refuses + * with SSH2_AGENT_FAILURE and never reads a key file (agent-only, §5.4). + */ +export function liveAgentSocketPath(env: NodeJS.ProcessEnv = process.env): string | null { + const sock = env.SSH_AUTH_SOCK; + return typeof sock === "string" && sock.startsWith("/") && sock.length > 1 ? sock : null; +} + +/** Frame one agent message for the wire: 4-byte big-endian length, then the payload. */ +function framing(payload: Buffer): Buffer { + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length, 0); + return Buffer.concat([header, payload]); +} + +/** + * One request/response round trip to A's agent over the Unix socket: + * connect, write one framed message, read one framed answer, close. Rejects + * on connect failure, timeout, an empty or over-cap answer, or a close before + * a complete answer. `timeoutMs` is exposed for tests; production always uses + * the module default. The answer's TYPE is not examined here - classifying + * it is the caller's job in the RESOLVED scheme (relay-agent-scheme.ts). + */ +export function requestLiveAgent(socketPath: string, payload: Buffer, timeoutMs?: number): Promise { + return new Promise((resolve, reject) => { + let conn: Socket; + try { + conn = createConnection({ path: socketPath }); + } catch (err) { + reject(new Error(`relay agent socket: cannot reach the agent socket: ${String(err)}`)); + return; + } + let buffer = Buffer.alloc(0); + let settled = false; + const fail = (why: string): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + conn.destroy(); + } catch { + /* already gone */ + } + reject(new Error(`relay agent socket: ${why}`)); + }; + const timer = setTimeout(() => fail("agent request timed out"), timeoutMs ?? AGENT_REQUEST_TIMEOUT_MS); + conn.on("error", (err: Error) => fail(`agent socket error: ${err.message}`)); + conn.on("connect", () => { + conn.write(framing(payload)); + }); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (len === 0 || len > MAX_AGENT_MESSAGE_BYTES) { + fail(`agent answer length ${len} is empty or over the cap`); + return; + } + if (buffer.length < 4 + len) return; + const answer = Buffer.from(buffer.subarray(4, 4 + len)); + settled = true; + clearTimeout(timer); + try { + conn.end(); + } catch { + /* the destroy below handles it */ + } + conn.destroy(); + resolve(answer); + }); + conn.on("close", () => fail("the agent socket closed before a complete answer")); + }); +} diff --git a/apps/node/agent/src/relay-responder.ts b/apps/node/agent/src/relay-responder.ts index b41f31a60..ca578b0fe 100644 --- a/apps/node/agent/src/relay-responder.ts +++ b/apps/node/agent/src/relay-responder.ts @@ -1,5 +1,3 @@ -import { createHash } from "node:crypto"; -import { createConnection, type Socket } from "node:net"; import { base64UrlNoPad, bytesOfJwk, @@ -9,11 +7,18 @@ import { type RelayOpenFn, type RelaySealFn, SeqGate, - SSH_RELAY_FRAME_MAX_BYTES, sealRelayEnvelope, verifyRelayEnvelope, } from "@internal/subshell-protocol"; import { log } from "./log.js"; +import { + type AgentScheme, + filterIdentitiesAnswer, + fingerprintAgentBlob, + parseSignRequest, + SSH2_AGENT_FAILURE, +} from "./relay-agent-scheme.js"; +import { liveAgentSocketPath, requestLiveAgent } from "./relay-agent-socket.js"; /** * A-side responder of the sealed agent relay (spec 2026-10-08 §5.4/§5.6): the @@ -21,29 +26,38 @@ import { log } from "./log.js"; * sealed from B; this module opens it with A's own encryption key, verifies * origin against B's PINNED signing key with the full Task-5 caller checklist * (frame-bound open, structurally bound relaySessionId / nB / nA, per-session - * {@link SeqGate}), default-denies the agent method, enforces the grant's - * fingerprint set against A's LIVE agent socket, and seals+signs the (possibly - * filtered or refused) agent answer back as an A2B frame. + * {@link SeqGate}), default-denies the agent method **in the probed scheme**, + * enforces the grant's fingerprint set against A's LIVE agent socket, and + * seals+signs the (possibly filtered or refused) agent answer back as an A2B + * frame. The agent wire itself (numbering, parsing, scoping helpers) lives in + * `relay-agent-scheme.ts`; the socket round trip in `relay-agent-socket.ts`. * - * The two rules of §5.4, in the order they run: + * The three rules of §5.4, in the order they run: * - * 1. **Method allow-list, default-deny.** Exactly the two request types SSH - * publickey auth uses are forwarded, matched by numeric type byte on the - * wire; every other type (identity mutations, LOCK/UNLOCK, EXTENSION, the - * SSH1-era range, anything unknown) is refused WITHOUT touching A's agent. - * A granted window therefore cannot mutate or lock A's agent against its - * other panes, by default-deny rather than a name list that could miss a - * code. - * 2. **Identity scoping.** The IDENTITIES_ANSWER is rebuilt with only the - * entries whose `SHA256:` fingerprint is in the grant's selected set, and a - * SIGN_REQUEST whose key blob hashes outside the set is refused before it - * is ever forwarded. A grant naming no fingerprint serves nothing. + * 1. **The scheme is probed, never assumed (ruling 2026-10-08).** Classic + * (identities 13 / sign 15) and RFC 9987 / OpenSSH-10.x (identities 11 / sign + * 13) numbering COLLIDE at byte 13: forwarding it raw as identities against a + * 10.x agent forwards a SIGN_REQUEST unscoped, and the key home signs an + * attacker-chosen blob. The session is therefore opened with a resolved + * `AgentScheme` (the probe lives in `relay-agent-scheme.ts`, run by + * `openARelaySession`); a session whose probe resolved NOTHING classifies + * nothing and forwards nothing - every request is refused. + * 2. **Method allow-list, default-deny in the resolved scheme.** Exactly the + * two request types SSH publickey auth uses - the identities and sign + * codepoints THAT scheme names - are ever forwarded; every other byte + * (identity mutations, LOCK/UNLOCK, EXTENSION, the SSH1-era range, the OTHER + * scheme's codes, anything unknown) dies here without touching A's agent. + * 3. **Identity scoping BEFORE any forward.** The identities answer is + * rebuilt with only the entries whose `SHA256:` fingerprint is in the grant; + * a sign request is parsed, its key blob fingerprinted, and a blob outside + * the set is refused at the responder - the bytes NEVER reach A's agent, + * whichever codepoint the scheme treats as sign (§5.4's load-bearing rule). + * A granted window therefore signs and sees only the keys the operator picked. * * **Agent-only (§3 item 4):** the only path to a key is A's running agent - * socket (`SSH_AUTH_SOCK`, the connecting account's own, the same - * absolute-path rule `ssh-resolve.ts` established for the launch side). No - * private key file is opened here, and nothing about key material is ever - * logged. + * socket (`SSH_AUTH_SOCK`, the connecting account's own, the absolute-path + * rule `relay-agent-socket.ts` shares with `ssh-resolve.ts`). No private key + * file is opened anywhere, and nothing about key material is ever logged. * * Refusal semantics mirror Task 6's proxy: an unopenable envelope is * plane-crafted noise and costs a log line (§5.6 first line: the plane holds @@ -54,7 +68,7 @@ import { log } from "./log.js"; * never punished, because the plane routes and can resend anything. A refused * REQUEST still gets the session's normal answer only in the agent layer: * disallowed or out-of-scope agent requests are answered with - * `SSH2_AGENT_FAILURE` (B's ssh sees a clean "agent refused", never a hang), + * SSH2_AGENT_FAILURE (B's ssh sees a clean "agent refused", never a hang), * while refused FRAMES (origin/replay) never produce any A2B at all. * * Nonce protocol (§5.6, A's side of the mirror): A mints `nA` once at session @@ -70,230 +84,14 @@ import { log } from "./log.js"; * mcp-core's pair straight through. */ -/* ------------------------------------------------------------------ */ -/* the ssh-agent wire (OpenSSH `agent-proto.h` values) */ -/* ------------------------------------------------------------------ */ - -/** - * `SSH2_AGENTC_REQUEST_IDENTITIES` - ask for the public-key list. The value - * is from OpenSSH's `agent-proto.h` (NOT the RFC 9987 renumbering, where this - * is 11: the wire and the real agent are OpenSSH, and the real-agent e2e is - * the arbiter). One of the TWO forwarded request types. - */ -export const SSH2_AGENTC_REQUEST_IDENTITIES = 13; - -/** - * `SSH2_AGENTC_SIGN_REQUEST` - sign with a named blob. The value is from - * OpenSSH's `agent-proto.h` (RFC 9987 calls this 13 - not this wire). One of - * the TWO forwarded request types. - */ -export const SSH2_AGENTC_SIGN_REQUEST = 15; - -/** `SSH2_AGENT_IDENTITIES_ANSWER` (agent-proto.h): the roster reply this module filters. */ -export const SSH2_AGENT_IDENTITIES_ANSWER = 2; - -/** `SSH2_AGENT_SIGN_RESPONSE` (agent-proto.h): the signature reply, forwarded verbatim (it is opaque). */ -export const SSH2_AGENT_SIGN_RESPONSE = 14; - -/** `SSH2_AGENT_FAILURE` (agent-proto.h): the uniform answer to any refused request. */ -export const SSH2_AGENT_FAILURE = 5; - -/** One agent message's maximum payload: the relay frame cap (§5.1: cap is law). */ -const MAX_AGENT_MESSAGE_BYTES = SSH_RELAY_FRAME_MAX_BYTES; - -/** Local I/O budget for one round trip to A's agent (a local Unix socket call; ssh's own is unbounded, ours is not). */ -const AGENT_REQUEST_TIMEOUT_MS = 10_000; - -/** The single-byte refusal every refused request is answered with. */ +/** The single-byte refusal every refused request is answered with (5 in both schemes). */ const FAILURE = Buffer.from([SSH2_AGENT_FAILURE]); -/* ------------------------------------------------------------------ */ -/* agent-wire framing + parsing (4-byte BE length + payload, shared */ -/* shape with the B proxy; the TYPE parsing is A-only per §5.4) */ -/* ------------------------------------------------------------------ */ - -/** Frame one agent message for the wire: 4-byte big-endian length, then the payload. */ -function framing(payload: Buffer): Buffer { - const header = Buffer.alloc(4); - header.writeUInt32BE(payload.length, 0); - return Buffer.concat([header, payload]); -} - -/** A cursor over an agent payload; every read past the end throws (never a silent partial parse). */ -class ByteReader { - #buf: Buffer; - #off = 0; - - constructor(buf: Buffer) { - this.#buf = buf; - } - - u32(): number { - if (this.#off + 4 > this.#buf.length) throw new Error("agent wire: truncated uint32"); - const v = this.#buf.readUInt32BE(this.#off); - this.#off += 4; - return v; - } - - bytes(n: number): Buffer { - if (n > this.#buf.length - this.#off) throw new Error("agent wire: truncated byte run"); - const v = this.#buf.subarray(this.#off, this.#off + n); - this.#off += n; - return v; - } - - /** An SSH string: uint32 length + bytes. */ - string(): Buffer { - return this.bytes(this.u32()); - } - - done(): boolean { - return this.#off === this.#buf.length; - } -} - -/** Serialize an SSH string for the rebuilt answer: uint32 length + bytes. */ -function sshString(bytes: Buffer): Buffer { - const header = Buffer.alloc(4); - header.writeUInt32BE(bytes.length, 0); - return Buffer.concat([header, bytes]); -} - -/** - * The OpenSSH display fingerprint of one agent key blob: SHA-256 over the - * RAW SSH wire encoding (the agent-answer blob bytes), spelled `SHA256:` + - * base64url without padding - the same scheme `fingerprintJwk` established - * (§4.5: "Agent keys hash their agent wire encoding"; §5.4), which is also - * the only spelling the grant grammar's `GRANT_FINGERPRINT_RE` accepts. - */ -export function fingerprintAgentBlob(blob: Uint8Array): string { - return `SHA256:${createHash("sha256").update(blob).digest("base64url")}`; -} - -/** - * Rebuild an IDENTITIES_ANSWER keeping ONLY the entries whose blob - * fingerprints into `allowed`; fixes the count; kept entries ride byte- - * identically. Throws on a malformed answer (type byte, declared entries the - * buffer does not carry, trailing bytes) - an unparseable answer is refused - * as a failure, NEVER forwarded unfiltered, because a partial parse of the - * roster is exactly what §5.4 exists to prevent. - */ -export function filterIdentitiesAnswer(answer: Buffer, allowed: ReadonlySet): Buffer { - if (answer.length < 5 || answer[0] !== SSH2_AGENT_IDENTITIES_ANSWER) { - throw new Error("relay responder: expected an SSH2_AGENT_IDENTITIES_ANSWER"); - } - const reader = new ByteReader(answer.subarray(1)); - const count = reader.u32(); - const kept: Buffer[] = []; // one item per KEPT ENTRY (both fields pre-concatenated, so the rebuilt count is the entry count) - for (let i = 0; i < count; i += 1) { - const blob = reader.string(); - const comment = reader.string(); - if (allowed.has(fingerprintAgentBlob(blob))) kept.push(Buffer.concat([sshString(blob), sshString(comment)])); - } - if (!reader.done()) throw new Error("relay responder: IDENTITIES_ANSWER carries trailing bytes"); - return Buffer.concat([Buffer.from([SSH2_AGENT_IDENTITIES_ANSWER]), sshStringCount(kept.length), ...kept]); -} - -/** The count field of a rebuilt answer (an unframed uint32, unlike an SSH string). */ -function sshStringCount(n: number): Buffer { - const b = Buffer.alloc(4); - b.writeUInt32BE(n, 0); - return b; -} - -/** - * Split a SIGN_REQUEST into its key blob (the parse throws on anything short - * of the full grammar: string blob + uint32 flags + nothing else). The flags - * ride through to the agent unexamined: §5.4 scopes by BLOB, not by flag. - */ -export function parseSignRequest(payload: Buffer): { keyBlob: Buffer; flags: number } { - const reader = new ByteReader(payload.subarray(1)); - const keyBlob = reader.string(); - const flags = reader.u32(); - if (keyBlob.length === 0) throw new Error("relay responder: SIGN_REQUEST names an empty key blob"); - if (!reader.done()) throw new Error("relay responder: SIGN_REQUEST carries trailing bytes"); - return { keyBlob, flags }; -} - -/* ------------------------------------------------------------------ */ -/* A's live agent socket */ -/* ------------------------------------------------------------------ */ - -/** - * The connecting account's live agent socket: an absolute `SSH_AUTH_SOCK`, - * else null - the same trust rule `ssh-resolve.ts` runs for the launch side - * ("the env sock is trusted because it is the connecting account's own - * setup"). A null answer is the honest no-agent case: the responder refuses - * with SSH2_AGENT_FAILURE and never reads a key file (agent-only, §5.4). - */ -export function liveAgentSocketPath(env: NodeJS.ProcessEnv = process.env): string | null { - const sock = env.SSH_AUTH_SOCK; - return typeof sock === "string" && sock.startsWith("/") && sock.length > 1 ? sock : null; -} - -/** - * One request/response round trip to A's agent over the Unix socket: - * connect, write one framed message, read one framed answer, close. Each - * request rides its own connection (ssh's agent protocol permits it; no - * session state lives on the socket). Rejects on connect failure, timeout, - * an empty or over-cap answer, or a close before a complete answer. - */ -function requestLiveAgent(socketPath: string, payload: Buffer): Promise { - return new Promise((resolve, reject) => { - let conn: Socket; - try { - conn = createConnection({ path: socketPath }); - } catch (err) { - reject(new Error(`relay responder: cannot reach the agent socket: ${String(err)}`)); - return; - } - let buffer = Buffer.alloc(0); - let settled = false; - const fail = (why: string): void => { - if (settled) return; - settled = true; - clearTimeout(timer); - try { - conn.destroy(); - } catch { - /* already gone */ - } - reject(new Error(`relay responder: ${why}`)); - }; - const timer = setTimeout(() => fail("agent request timed out"), AGENT_REQUEST_TIMEOUT_MS); - conn.on("error", (err: Error) => fail(`agent socket error: ${err.message}`)); - conn.on("connect", () => { - conn.write(framing(payload)); - }); - conn.on("data", (chunk: Buffer) => { - buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); - if (buffer.length < 4) return; - const len = buffer.readUInt32BE(0); - if (len === 0 || len > MAX_AGENT_MESSAGE_BYTES) { - fail(`agent answer length ${len} is empty or over the cap`); - return; - } - if (buffer.length < 4 + len) return; - const answer = Buffer.from(buffer.subarray(4, 4 + len)); - settled = true; - clearTimeout(timer); - try { - conn.end(); - } catch { - /* the destroy below handles it */ - } - conn.destroy(); - resolve(answer); - }); - conn.on("close", () => fail("the agent socket closed before a complete answer")); - }); -} - /* ------------------------------------------------------------------ */ /* the responder */ /* ------------------------------------------------------------------ */ -/** Inputs to {@link startRelayResponder}: session facts, B's pins, A's identity, the pump, the seams. */ +/** Inputs to {@link startRelayResponder}: session facts, B's pins, A's identity, the probed scheme, the seams. */ export interface RelayResponderArgs { /** The relay-session id the plane minted (§5.6: signed into every envelope). */ relayId: string; @@ -314,6 +112,14 @@ export interface RelayResponderArgs { ownSigningPrivateJwk: string; /** The grant's selected fingerprints (§5.4); empty names nothing, serves nothing. */ fingerprints: readonly string[]; + /** + * The numbering probe resolved against A's live agent at session open + * ({@link probeAgentScheme} in relay-agent-scheme.ts, run by + * openARelaySession). Null means unresolved: this session classifies no + * byte and forwards nothing, because a byte under a GUESSED scheme is the + * CRITICAL the 2026-10-08 ruling exists to prevent. + */ + agentScheme: AgentScheme | null; /** The sealing primitive - mcp-core's `seal` in production (injected, Task-5 doctrine). */ seal: RelaySealFn; /** The opening primitive - mcp-core's `open`. */ @@ -328,9 +134,11 @@ export interface RelayResponderArgs { */ sendRelayFrame(frame: RelayFrame): void; /** - * Resolve A's live agent socket per request (default {@link - * liveAgentSocketPath}). A test seam and the operator override land here; - * returning null is the honest no-agent case. + * Resolve A's live agent socket per request (default `liveAgentSocketPath` + * from relay-agent-socket.ts). A test seam and the operator override land + * here; returning null is the honest no-agent case. The scheme is cached + * once at open; a later agent restart under a DIFFERENT numbering fails the + * response-type gate, never a silent re-interpretation. */ resolveAgentSocket?: () => string | null; /** Line sink; defaults to the agent logger. Never receives keys, fingerprints, nonces, or agent bytes. */ @@ -354,8 +162,8 @@ export interface RelayResponderHandle { * Start the A-side responder for ONE brokered session: validate B's pins * (deep public-only P-256 via {@link bytesOfJwk}, the same gate the B proxy * runs), mint A's session nonce, and return the handler that - * `commands/ssh-relay.ts` registers into {@link RelaySessions}. Throws a - * named refusal when either pinned half is not usable public material. + * `commands/ssh-relay.ts` registers into `RelaySessions`. Throws a named + * refusal when either pinned half is not usable public material. */ export function startRelayResponder(args: RelayResponderArgs): RelayResponderHandle { const say = args.log ?? ((line: string): void => log(line)); @@ -380,13 +188,14 @@ export function startRelayResponder(args: RelayResponderArgs): RelayResponderHan const recipient = { principalId: `node:${args.peerNodeId}`, publicJwk: args.peerEncryptionJwk }; const allowed = new Set(args.fingerprints); const resolveAgent = args.resolveAgentSocket ?? liveAgentSocketPath; + const scheme = args.agentScheme; // cached at session open; NEVER re-derived per byte // Session state (§5.6): A's nonce is minted once and every A2B carries it; - // B's is recorded from the first verified request. `nASeenBinding` marks - // that the first verified request has passed, from which point every - // inbound message MUST carry A's nonce (B learns it from A's first reply, - // and B's single-outstanding FIFO means B's next request was sealed after - // that reply landed - a request without it is a replay or a forgery). + // B's is recorded from the first verified request. `bindNA` marks that the + // first verified request has passed, from which point every inbound message + // MUST carry A's nonce (B learns it from A's first reply, and B's + // single-outstanding FIFO means B's next request was sealed after that + // reply landed - a request without it is a replay or a forgery). const nA = newNonce(); let nB: string | undefined; let bindNA = false; @@ -397,28 +206,36 @@ export function startRelayResponder(args: RelayResponderArgs): RelayResponderHan /** The agent-layer decision for one decoded request: the payload to answer B with. */ const respondToAgent = async (request: Buffer): Promise => { - if (request.length === 0) return FAILURE; // no type byte: nothing allow-listed, nothing forwarded - const type = request[0]; - const agentPath = resolveAgent(); - if (type !== SSH2_AGENTC_REQUEST_IDENTITIES && type !== SSH2_AGENTC_SIGN_REQUEST) { - // §5.4's default-deny: by the REFUSED set's behavior, not a lookup. Log the - // numeric type (not a secret) so the operator can see what B asked for. - say(`relay session ${args.ref}: refused disallowed agent request type ${type}; not forwarded`); + if (request.length === 0) { + say(`relay session ${args.ref}: refused an agent request with no type byte; not forwarded`); return FAILURE; } - if (type === SSH2_AGENTC_SIGN_REQUEST) { + if (scheme === null) { + // The numbering probe resolved nothing at open: there is no safe + // reading of ANY byte, and forwarding under a guess is exactly the + // C-2 bug this ruling killed. Refuse, always. + say(`relay session ${args.ref}: refused a request with no resolved agent scheme; not forwarded`); + return FAILURE; + } + const type = request[0]; + + if (type === scheme.sign) { let keyBlob: Buffer; try { - ({ keyBlob } = parseSignRequest(request)); + ({ keyBlob } = parseSignRequest(request, scheme)); } catch { - say(`relay session ${args.ref}: refused a malformed SIGN_REQUEST; not forwarded`); + say(`relay session ${args.ref}: refused a malformed SIGN_REQUEST body; not forwarded`); return FAILURE; } - // §5.4's signing gate: checked HERE, before anything touches the agent. + // §5.4's signing gate, BEFORE anything else touches the agent: a sign + // whose blob is outside the grant dies here whichever codepoint the + // probed scheme treats as sign (classic 15 or 10.x 13 - the very byte + // the first pass forwarded raw). if (!allowed.has(fingerprintAgentBlob(keyBlob))) { say(`relay session ${args.ref}: refused a SIGN_REQUEST outside the grant set; not forwarded`); return FAILURE; } + const agentPath = resolveAgent(); if (agentPath === null) { say(`relay session ${args.ref}: no live agent socket (SSH_AUTH_SOCK unset): refusing the request`); return FAILURE; @@ -431,42 +248,55 @@ export function startRelayResponder(args: RelayResponderArgs): RelayResponderHan return FAILURE; } // The signature is opaque and the blob was already scoped, but the - // response TYPE still gates: only SIGN_RESPONSE and the agent's own - // FAILURE ride onward; anything unexpected becomes a failure. - if (response.length < 1 || (response[0] !== SSH2_AGENT_SIGN_RESPONSE && response[0] !== SSH2_AGENT_FAILURE)) { + // response TYPE still gates in the resolved scheme: only that scheme's + // SIGN_RESPONSE and the agent's own FAILURE ride onward; anything + // else (a foreign scheme's bytes included) becomes a failure. + if (response.length < 1 || (response[0] !== scheme.signResponse && response[0] !== SSH2_AGENT_FAILURE)) { say(`relay session ${args.ref}: the agent answered a sign request with an unexpected type; refusing`); return FAILURE; } return response; } - // SSH2_AGENTC_REQUEST_IDENTITIES: the roster goes to the agent, comes - // back, and is FILTERED here - the grant set is enforced at A, the only - // machine that can see A's whole roster. - if (agentPath === null) { - say(`relay session ${args.ref}: no live agent socket (SSH_AUTH_SOCK unset): refusing the request`); - return FAILURE; - } - let answer: Buffer; - try { - answer = await requestLiveAgent(agentPath, request); - } catch (err) { - say(`relay session ${args.ref}: the agent request failed: ${String(err)}`); - return FAILURE; - } - if (answer.length < 1) return FAILURE; - if (answer[0] === SSH2_AGENT_FAILURE) return FAILURE; // A's agent itself refused: pass the refusal - if (answer[0] !== SSH2_AGENT_IDENTITIES_ANSWER) { - say(`relay session ${args.ref}: the agent answered identities with an unexpected type; refusing`); - return FAILURE; - } - try { - return filterIdentitiesAnswer(answer, allowed); - } catch { - // An unparseable roster NEVER rides on: partial parsing is how an - // ungranted identity could leak. The pane sees a clean failure. - say(`relay session ${args.ref}: the agent's IDENTITIES_ANSWER did not parse; refusing it`); - return FAILURE; + + if (type === scheme.identities) { + if (request.length !== 1) { + say(`relay session ${args.ref}: refused a malformed REQUEST_IDENTITIES; not forwarded`); + return FAILURE; + } + const agentPath = resolveAgent(); + if (agentPath === null) { + say(`relay session ${args.ref}: no live agent socket (SSH_AUTH_SOCK unset): refusing the request`); + return FAILURE; + } + let answer: Buffer; + try { + answer = await requestLiveAgent(agentPath, request); + } catch (err) { + say(`relay session ${args.ref}: the agent request failed: ${String(err)}`); + return FAILURE; + } + if (answer.length < 1) return FAILURE; + if (answer[0] === SSH2_AGENT_FAILURE) return FAILURE; // A's agent itself refused: pass the refusal + if (answer[0] !== scheme.answer) { + say(`relay session ${args.ref}: the agent answered identities with an unexpected type; refusing`); + return FAILURE; + } + try { + return filterIdentitiesAnswer(answer, allowed, scheme); + } catch { + // An unparseable roster NEVER rides on: partial parsing is how an + // ungranted identity could leak. The pane sees a clean failure. + say(`relay session ${args.ref}: the agent's IDENTITIES_ANSWER did not parse; refusing it`); + return FAILURE; + } } + + // §5.4's default-deny in the resolved scheme: the OTHER scheme's codes, + // the answer/response bytes aimed at us as requests, identity mutations, + // LOCK/UNLOCK, EXTENSION, the SSH1-era range, anything unknown. Log the + // numeric type (not a secret) so the operator can see what B asked for. + say(`relay session ${args.ref}: refused disallowed agent request type ${type}; not forwarded`); + return FAILURE; }; /** The full inbound pipeline for one frame: open, verify, gate, answer. */ From 27ee9c98448ba09e44c3b3b17af14eb758ee41fc Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:36:00 -0700 Subject: [PATCH 31/92] fix(ssh-relay): strictly parse SIGN_RESPONSE body; drop chimeric proxy fixture names --- .../src/__tests__/relay-agent-scheme.test.ts | 40 +++++++++ .../agent/src/__tests__/relay-proxy.test.ts | 64 +++++++-------- .../src/__tests__/relay-responder.test.ts | 82 +++++++++++++++++++ apps/node/agent/src/relay-agent-scheme.ts | 22 +++++ apps/node/agent/src/relay-responder.ts | 23 ++++-- 5 files changed, 190 insertions(+), 41 deletions(-) diff --git a/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts b/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts index acefbb416..d8b97a988 100644 --- a/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts +++ b/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts @@ -8,6 +8,7 @@ import { OPENSSH_10X_SCHEME, parseIdentitiesAnswer, parseSignRequest, + parseSignResponse, probeAgentScheme, SSH2_AGENT_FAILURE, } from "../relay-agent-scheme.js"; @@ -194,6 +195,45 @@ test("parseSignRequest refuses every malformed body in both schemes", () => { ).toThrow(); }); +/* ---------------- SIGN_RESPONSE: the body the relay gate accepts ---------------- */ + +test("parseSignResponse accepts exactly the type byte plus one string in each scheme", () => { + // The collision this strictness exists for, pinned: classic answer 14 == + // 10.x signResponse 14. The type byte alone cannot tell a signature from a + // foreign scheme's roster; only the body parse can. + expect(CLASSIC_SCHEME.answer).toBe(14); + expect(OPENSSH_10X_SCHEME.signResponse).toBe(14); + expect(parseSignResponse(Buffer.concat([Buffer.from([14]), sshStr(Buffer.from("SIG"))]), OPENSSH_10X_SCHEME)).toEqual( + Buffer.from("SIG"), + ); + expect(parseSignResponse(Buffer.concat([Buffer.from([16]), sshStr(Buffer.from("SIG"))]), CLASSIC_SCHEME)).toEqual( + Buffer.from("SIG"), + ); +}); + +test("parseSignResponse refuses a roster-shaped body, extra bytes, truncation, and a foreign type byte", () => { + // A multi-entry roster spelled under the SIGN_RESPONSE byte (the exact + // shape a mid-session classic swap answers a forwarded 13 with): refused. + const roster = Buffer.concat([Buffer.from([14]), be32(2), sshStr(KEY_A), sshStr("a"), sshStr(KEY_B), sshStr("b")]); + expect(() => parseSignResponse(roster, OPENSSH_10X_SCHEME)).toThrow(); + expect(() => + parseSignResponse(Buffer.concat([Buffer.from([16]), be32(1), sshStr(KEY_A), sshStr("a")]), CLASSIC_SCHEME), + ).toThrow(); + // Bytes after the one signature string: not the wire shape. + expect(() => + parseSignResponse( + Buffer.concat([Buffer.from([14]), sshStr(Buffer.from("SIG")), Buffer.from([0])]), + OPENSSH_10X_SCHEME, + ), + ).toThrow(); + // Empty body and a truncated length prefix. + expect(() => parseSignResponse(Buffer.from([14]), OPENSSH_10X_SCHEME)).toThrow(); + expect(() => parseSignResponse(Buffer.from([14, 0, 0, 0]), OPENSSH_10X_SCHEME)).toThrow(); + // A foreign scheme's response byte is refused in the resolved scheme. + expect(() => parseSignResponse(Buffer.concat([Buffer.from([16]), sshStr("S")]), OPENSSH_10X_SCHEME)).toThrow(); + expect(() => parseSignResponse(Buffer.concat([Buffer.from([14]), sshStr("S")]), CLASSIC_SCHEME)).toThrow(); +}); + /* ---------------- the probe: resolving the live agent's numbering ---------------- */ /** Records every payload a fake agent received and answers per a script. */ diff --git a/apps/node/agent/src/__tests__/relay-proxy.test.ts b/apps/node/agent/src/__tests__/relay-proxy.test.ts index b73df9b06..8262d93c7 100644 --- a/apps/node/agent/src/__tests__/relay-proxy.test.ts +++ b/apps/node/agent/src/__tests__/relay-proxy.test.ts @@ -27,17 +27,15 @@ import { type AgentProxyHandle, buildAgentSocketPath, startAgentProxy } from ".. */ /** - * ssh-agent wire type bytes used as FIXTURE PAYLOADS only: the B proxy - * forwards agent bytes byte-agnostically and never parses a type byte, so the - * numbers here carry no protocol meaning (the classic 13/15 pair from - * `agent-proto.h` is what a pre-10.x agent speaks; OpenSSH 10.x ships the RFC - * 9987 renumbering 11/13 - see the ruling of 2026-10-08, which is why A's - * responder PROBEs the numbering and the numeric type gate is A's responder's - * default-deny, Task 7, never this side). + * Opaque FIXTURE PAYLOADS only: the B proxy forwards agent bytes + * byte-agnostically and never parses a type byte, so these numbers carry no + * protocol meaning at this side. These tests exercise transport framing (the + * FIFO, seq, nonces, socket lifecycle); what a real agent makes of a byte is + * A's responder's business, and its scheme semantics are pinned there. */ -const SSH2_AGENTC_REQUEST_IDENTITIES = 13; -const SSH2_AGENTC_SIGN_REQUEST = 15; -const SSH2_AGENT_IDENTITIES_ANSWER = 12; +const SAMPLE_AGENT_REQUEST_A = 13; +const SAMPLE_AGENT_REQUEST_B = 15; +const SAMPLE_AGENT_ANSWER_A = 12; interface JwkPair { publicJwk: string; @@ -237,7 +235,7 @@ test("a REQUEST_IDENTITIES on the socket emits exactly one B2A relay frame, seq conn.on("error", rej); }); void w; - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "one outbound relay frame"); await sleep(30); expect(f.frames.length).toBe(1); // exactly one @@ -251,7 +249,7 @@ test("a REQUEST_IDENTITIES on the socket emits exactly one B2A relay frame, seq const opened = await openAndVerifyB2A(f, frame); expect(opened.seq).toBe(0); // the SIGNED anti-replay seq agrees for the first request - expect(opened.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES])); + expect(opened.agentBytes).toEqual(Buffer.from([SAMPLE_AGENT_REQUEST_A])); expect(opened.nB).toMatch(/^[A-Za-z0-9_-]{22}$/); // a 128-bit base64url endpoint nonce expect(opened.nA).toBeUndefined(); // B speaks first; nA has not been minted yet conn.destroy(); @@ -267,7 +265,7 @@ test("a second request gets seq 1 and carries the recorded nA onward", async () const spyC = spy(conn); await new Promise((res) => conn.on("connect", () => res())); - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "first request frame"); const first = await openAndVerifyB2A(f, f.frames[0]); @@ -276,19 +274,19 @@ test("a second request gets seq 1 and carries the recorded nA onward", async () seq: 0, nB: first.nB, nA, - agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER, 0, 0, 0, 0], + agentBytes: [SAMPLE_AGENT_ANSWER_A, 0, 0, 0, 0], }); f.proxy.deliverInboundRelayFrame(reply); await waitUntil(() => spyC.chunks.length === 1, "reply bytes written back"); - conn.write(agentFrame([SSH2_AGENTC_SIGN_REQUEST, 0, 1, 2, 3])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_B, 0, 1, 2, 3])); await waitUntil(() => f.frames.length === 2, "second request frame"); const second = await openAndVerifyB2A(f, f.frames[1]); expect(f.frames[1].seq).toBe(1); // transport seq advanced expect(second.seq).toBe(1); // signed anti-replay seq advanced expect(second.nB).toBe(first.nB); // B keeps its own nonce expect(second.nA).toBe(nA); // and signs A's onward (§5.6: recorded peer value) - expect(second.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_SIGN_REQUEST, 0, 1, 2, 3])); + expect(second.agentBytes).toEqual(Buffer.from([SAMPLE_AGENT_REQUEST_B, 0, 1, 2, 3])); } finally { f.cleanup(); } @@ -304,11 +302,11 @@ test("a correctly sealed+signed A reply (stub A keypair pinned) writes the agent const conn = connectSocket(f.proxy.socketPath); const spyC = spy(conn); await new Promise((res) => conn.on("connect", () => res())); - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "request frame out"); const first = await openAndVerifyB2A(f, f.frames[0]); - const replyBytes = [SSH2_AGENT_IDENTITIES_ANSWER, 0, 0, 0, 2, 0, 0, 0, 1, 65]; + const replyBytes = [SAMPLE_AGENT_ANSWER_A, 0, 0, 0, 2, 0, 0, 0, 1, 65]; const reply = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: replyBytes }); f.proxy.deliverInboundRelayFrame(reply); await waitUntil(() => spyC.chunks.length >= 1, "reply written back"); @@ -329,7 +327,7 @@ test("a reply signed by the WRONG key is refused: nothing written, the pending c const conn = connectSocket(f.proxy.socketPath); const spyC = spy(conn); await new Promise((res) => conn.on("connect", () => res())); - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "request frame out"); const first = await openAndVerifyB2A(f, f.frames[0]); @@ -337,7 +335,7 @@ test("a reply signed by the WRONG key is refused: nothing written, the pending c seq: 0, nB: first.nB, nA: newNonce(), - agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER], + agentBytes: [SAMPLE_AGENT_ANSWER_A], signerPrivateJwk: evil.privateJwk, // not the pinned key: an origin failure }); f.proxy.deliverInboundRelayFrame(forged); @@ -354,7 +352,7 @@ test("a reply with a mismatched relay-session id or nB nonce is refused and not const conn = connectSocket(f.proxy.socketPath); const spyC = spy(conn); await new Promise((res) => conn.on("connect", () => res())); - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "request frame out"); const first = await openAndVerifyB2A(f, f.frames[0]); @@ -363,7 +361,7 @@ test("a reply with a mismatched relay-session id or nB nonce is refused and not seq: 0, nB: first.nB, nA: newNonce(), - agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER], + agentBytes: [SAMPLE_AGENT_ANSWER_A], relaySessionId: "relay-OTHER", }); f.proxy.deliverInboundRelayFrame(wrongSession); @@ -374,13 +372,13 @@ test("a reply with a mismatched relay-session id or nB nonce is refused and not const conn2 = connectSocket(f.proxy.socketPath); const spy2 = spy(conn2); await new Promise((res) => conn2.on("connect", () => res())); - conn2.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn2.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 2, "second request frame out"); const wrongNonce = await sealReply(f, { seq: 0, nB: newNonce(), nA: newNonce(), - agentBytes: [SSH2_AGENT_IDENTITIES_ANSWER], + agentBytes: [SAMPLE_AGENT_ANSWER_A], }); f.proxy.deliverInboundRelayFrame(wrongNonce); await waitUntil(() => spy2.closed(), "nonce mismatch fails the pending connection"); @@ -396,7 +394,7 @@ test("a repeated seq is refused by the SeqGate and not written; a valid next-seq const conn = connectSocket(f.proxy.socketPath); const spyC = spy(conn); await new Promise((res) => conn.on("connect", () => res())); - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "request frame out"); const first = await openAndVerifyB2A(f, f.frames[0]); @@ -418,7 +416,7 @@ test("a repeated seq is refused by the SeqGate and not written; a valid next-seq expect(spyC.closed()).toBe(false); // a replay refusal is not a connection failure // The stream continues: seq 1 over seq 0 is accepted. - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 2, "second request frame out"); const next = await sealReply(f, { seq: 1, nB: first.nB, nA, agentBytes: [4, 5, 6] }); f.proxy.deliverInboundRelayFrame(next); @@ -435,7 +433,7 @@ test("an openable but unsigned-sealed blob (plane-crafted noise, wrong recipient const conn = connectSocket(f.proxy.socketPath); const spyC = spy(conn); await new Promise((res) => conn.on("connect", () => res())); - conn.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + conn.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "request frame out"); // Not even valid base64-of-a-JWE: the open fails before any trust question. @@ -464,7 +462,7 @@ test("a reply to a dead connection is absorbed: never written to the next connec const x = connectSocket(f.proxy.socketPath); const spyX = spy(x); await new Promise((res) => x.on("connect", () => res())); - x.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES])); + x.write(agentFrame([SAMPLE_AGENT_REQUEST_A])); await waitUntil(() => f.frames.length === 1, "X's request frame out"); const first = await openAndVerifyB2A(f, f.frames[0]); @@ -472,7 +470,7 @@ test("a reply to a dead connection is absorbed: never written to the next connec const y = connectSocket(f.proxy.socketPath); const spyY = spy(y); await new Promise((res) => y.on("connect", () => res())); - y.write(agentFrame([SSH2_AGENTC_SIGN_REQUEST, 1, 2, 3])); + y.write(agentFrame([SAMPLE_AGENT_REQUEST_B, 1, 2, 3])); await sleep(30); expect(f.frames.length).toBe(1); // queued unsealed: the slot holds X's request @@ -498,7 +496,7 @@ test("a reply to a dead connection is absorbed: never written to the next connec expect(f.frames[1].seq).toBe(1); expect(second.seq).toBe(1); expect(second.nA).toBe(nA); - expect(second.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_SIGN_REQUEST, 1, 2, 3])); + expect(second.agentBytes).toEqual(Buffer.from([SAMPLE_AGENT_REQUEST_B, 1, 2, 3])); // A's reply to Y (seq 1) arrives: THAT is what Y receives, in order. const replyToY = await sealReply(f, { seq: 1, nB: first.nB, nA, agentBytes: [1, 2, 3] }); @@ -529,7 +527,7 @@ test("a sendRelayFrame that throws fails the connection and drains the queue; no await new Promise((res) => a.on("connect", () => res())); // req1 takes the slot straight away; req2 queues behind the in-flight // seal (arrives in the same read while sealedInFlight holds). - a.write(Buffer.concat([agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES]), agentFrame([SSH2_AGENTC_SIGN_REQUEST, 9])])); + a.write(Buffer.concat([agentFrame([SAMPLE_AGENT_REQUEST_A]), agentFrame([SAMPLE_AGENT_REQUEST_B, 9])])); await waitUntil(() => spyA.closed(), "the connection fails on the refused send instead of hanging"); expect(attempts).toBe(1); // only req1 reached the pump; req2 died at the liveness gate expect(sent.length).toBe(0); // nothing ever reached A @@ -540,12 +538,12 @@ test("a sendRelayFrame that throws fails the connection and drains the queue; no const b = connectSocket(f.proxy.socketPath); const spyB = spy(b); await new Promise((res) => b.on("connect", () => res())); - b.write(agentFrame([SSH2_AGENTC_REQUEST_IDENTITIES, 4, 5])); + b.write(agentFrame([SAMPLE_AGENT_REQUEST_A, 4, 5])); await waitUntil(() => sent.length === 1, "the next request goes out after the failed send"); expect(sent[0].seq).toBe(0); const first = await openAndVerifyB2A(f, sent[0]); expect(first.seq).toBe(0); - expect(first.agentBytes).toEqual(Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES, 4, 5])); + expect(first.agentBytes).toEqual(Buffer.from([SAMPLE_AGENT_REQUEST_A, 4, 5])); const reply = await sealReply(f, { seq: 0, nB: first.nB, nA: newNonce(), agentBytes: [7, 7] }); f.proxy.deliverInboundRelayFrame(reply); diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts index b712677be..8419edca9 100644 --- a/apps/node/agent/src/__tests__/relay-responder.test.ts +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -794,6 +794,88 @@ test("an unparseable roster never rides on: partial parsing is exactly what §5. } }); +test("regression: a SIGN_RESPONSE of exactly the type byte plus one string rides on in BOTH schemes", async () => { + // The strict body parse accepts the honest shape in each scheme and forwards + // it byte-identical: 10.x's [14, string] and classic's [16, string]. + for (const [scheme, responseByte, signReq] of [ + [OPENSSH_10X_SCHEME, 14, buildSignRequestTenX(KEY_IN)], + [CLASSIC_SCHEME, 16, buildSignRequestClassic(KEY_IN)], + ] as [AgentScheme, number, Buffer][]) { + const f = await openFixture({}, { scheme }); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: signReq, seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, `the ${scheme.name} signature reply`); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([responseByte, 0, 0, 0, 3, ...Buffer.from("SIG")]); + } finally { + await f.cleanup(); + } + } +}); + +test("collision regression: a classic roster answering under byte 14 is refused by a 10.x session's sign gate", async () => { + // classic answer 14 == 10.x signResponse 14. A mid-session swap of A's agent + // to a classic build reads the forwarded byte 13 (10.x sign) as classic + // REQUEST_IDENTITIES and answers its FULL UNFILTERED roster under type 14 - + // exactly the byte a type-only gate would wave through to B as a sign + // response, leaking public blobs plus comments. The strictly parsed body + // must refuse the roster shape; B sees only the clean FAILURE. + const rosterUnder14 = Buffer.concat([ + Buffer.from([14]), + be32(2), + sshStr(KEY_IN), + sshStr("granted key"), + sshStr(KEY_OUT), + sshStr("ungranted key"), + ]); + const f = await openFixture( + {}, + { scheme: OPENSSH_10X_SCHEME, mutateAnswer: (kind, answer) => (kind === "sign" ? rosterUnder14 : answer) }, + ); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: buildSignRequestTenX(KEY_IN), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); // never the roster + expect(f.forwarded().length).toBe(1); // the scoped sign request DID reach the agent + } finally { + await f.cleanup(); + } +}); + +test("regression: a roster-shaped body under classic's SIGN_RESPONSE byte 16 is refused too", async () => { + // Not a real-world collision (classic's roster answer is 14): the point is + // the gate parses the BODY, so a multi-string roster under the response + // byte is refused whichever agent spelled it. + const rosterUnder16 = Buffer.concat([ + Buffer.from([16]), + be32(2), + sshStr(KEY_IN), + sshStr("granted key"), + sshStr(KEY_OUT), + sshStr("ungranted key"), + ]); + const f = await openFixture( + {}, + { scheme: CLASSIC_SCHEME, mutateAnswer: (kind, answer) => (kind === "sign" ? rosterUnder16 : answer) }, + ); + try { + const nB = newNonce(); + const req = await sealRequest(f, { agentBytes: buildSignRequestClassic(KEY_IN), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + await waitUntil(() => f.frames.length === 1, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); // never the roster + expect(f.forwarded().length).toBe(1); + } finally { + await f.cleanup(); + } +}); + test("an agent answering a granted sign with a FOREIGN response byte becomes a refusal (the signature never rides)", async () => { // The 10.x session forwards byte 13; the stub answers type 16 (the classic // SIGN_RESPONSE) with a signature: under the resolved 10.x scheme that is diff --git a/apps/node/agent/src/relay-agent-scheme.ts b/apps/node/agent/src/relay-agent-scheme.ts index c98a5e72a..68f25706c 100644 --- a/apps/node/agent/src/relay-agent-scheme.ts +++ b/apps/node/agent/src/relay-agent-scheme.ts @@ -232,6 +232,28 @@ export function parseSignRequest(payload: Buffer, scheme: AgentScheme): SignRequ return { keyBlob, data, flags, algorithms }; } +/* ------------------------------------------------------------------ */ +/* SIGN_RESPONSE: the answer body the relay gate accepts */ +/* ------------------------------------------------------------------ */ + +/** + * Strictly parse a SIGN_RESPONSE (full payload, type byte included): the type + * byte must be THAT scheme's sign-response codepoint and the body must be + * exactly one `string` (the signature blob) with nothing after it. Throws + * otherwise. The type byte ALONE is not enough: the codepoints collide across + * schemes (classic's roster answer is 14, which is 10.x's sign response), so + * only the body tells a signature from a foreign scheme's roster. + */ +export function parseSignResponse(response: Buffer, scheme: AgentScheme): Buffer { + if (response.length < 5 || response[0] !== scheme.signResponse) { + throw new Error(`relay agent wire: expected a ${scheme.name} SIGN_RESPONSE (${scheme.signResponse})`); + } + const reader = new ByteReader(response.subarray(1)); + const signature = reader.string(); + if (!reader.done()) throw new Error("relay agent wire: SIGN_RESPONSE carries bytes after the signature string"); + return signature; +} + /* ------------------------------------------------------------------ */ /* the probe (ruling 2026-10-08: the numbering is learned, never assumed) */ /* ------------------------------------------------------------------ */ diff --git a/apps/node/agent/src/relay-responder.ts b/apps/node/agent/src/relay-responder.ts index ca578b0fe..8cbb4d751 100644 --- a/apps/node/agent/src/relay-responder.ts +++ b/apps/node/agent/src/relay-responder.ts @@ -16,6 +16,7 @@ import { filterIdentitiesAnswer, fingerprintAgentBlob, parseSignRequest, + parseSignResponse, SSH2_AGENT_FAILURE, } from "./relay-agent-scheme.js"; import { liveAgentSocketPath, requestLiveAgent } from "./relay-agent-socket.js"; @@ -136,9 +137,9 @@ export interface RelayResponderArgs { /** * Resolve A's live agent socket per request (default `liveAgentSocketPath` * from relay-agent-socket.ts). A test seam and the operator override land - * here; returning null is the honest no-agent case. The scheme is cached - * once at open; a later agent restart under a DIFFERENT numbering fails the - * response-type gate, never a silent re-interpretation. + * here; returning null is the honest no-agent case. The cached scheme is + * not re-probed mid-session: the strictly-parsed response body, not merely + * the type byte, is what rejects a foreign-scheme answer. */ resolveAgentSocket?: () => string | null; /** Line sink; defaults to the agent logger. Never receives keys, fingerprints, nonces, or agent bytes. */ @@ -248,11 +249,17 @@ export function startRelayResponder(args: RelayResponderArgs): RelayResponderHan return FAILURE; } // The signature is opaque and the blob was already scoped, but the - // response TYPE still gates in the resolved scheme: only that scheme's - // SIGN_RESPONSE and the agent's own FAILURE ride onward; anything - // else (a foreign scheme's bytes included) becomes a failure. - if (response.length < 1 || (response[0] !== scheme.signResponse && response[0] !== SSH2_AGENT_FAILURE)) { - say(`relay session ${args.ref}: the agent answered a sign request with an unexpected type; refusing`); + // answer is still gated in the resolved scheme, by TYPE AND BODY: + // the type bytes collide across schemes (classic's roster answer is + // 14, which is 10.x's sign response), so only the strictly parsed + // body - exactly one signature string - tells a signature from a + // foreign scheme's roster. That scheme's well-formed SIGN_RESPONSE + // and the agent's own FAILURE ride onward; anything else fails. + if (response.length >= 1 && response[0] === SSH2_AGENT_FAILURE) return response; + try { + parseSignResponse(response, scheme); + } catch { + say(`relay session ${args.ref}: the agent's sign answer was not a well-formed SIGN_RESPONSE; refusing`); return FAILURE; } return response; From 8d2f18266b19415e78d68e25b1db9b8f552fdbb6 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:36:14 -0700 Subject: [PATCH 32/92] docs(ssh-relay): record the shipped Task 7 seam shape in the plan --- docs/superpowers/plans/2026-10-08-ssh-relay-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md index 89d456702..daef5f16b 100644 --- a/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md +++ b/docs/superpowers/plans/2026-10-08-ssh-relay-plan.md @@ -176,7 +176,7 @@ Every task implicitly includes these; values are copied from `docs/superpowers/s - Test: `apps/node/agent/src/__tests__/relay-responder.test.ts` **Interfaces:** -- Consumes: `MachinePinStore`, `ssh-relay.ts`, the relay open command (grant id + selected fingerprint set + peer keys). Produces: `handleRelayRequest(frame, session): Promise` that verifies origin, default-denies agent methods, filters identities, and forwards to A's real agent socket. +- Consumes: `MachinePinStore`, `ssh-relay.ts`, the relay open command (grant id + selected fingerprint set + peer keys). Produces (the shipped seam): `startRelayResponder(...)` + `openARelaySession(...)` + `deliverInboundRelayFrame(frame)` returning `{ onRelayFrame, close }` registered into `RelaySessions`. It probes A's agent scheme at open, verifies origin against the machine pin, `SeqGate`s, default-denies agent methods, and filters identity/sign to the grant's fingerprint set (scope-before-forward in the resolved scheme). Two refusal tiers: an agent-layer FAILURE reply (a refused method or an ungranted key) vs frame-level silence (an origin/seq/shape failure drops the frame with no reply). `relay-responder.ts` is split with `relay-agent-scheme.ts` (schemes/probe/grammar) and `relay-agent-socket.ts` (transport). - [ ] **Step 1: Failing test.** A valid `REQUEST_IDENTITIES` forwarded to a stub agent returns a roster filtered to the grant's fingerprints; a `SIGN_REQUEST` whose blob hash is outside the set is refused (null) and never forwarded; `ADD_IDENTITY`/`REMOVE_ALL_IDENTITIES`/`EXTENSION` refused; a wrong-origin (bad B signature) request refused. - [ ] **Step 2: FAIL run.** From af7adf101c4f0309187a7aa427540a0d7f77b981 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:45:50 -0700 Subject: [PATCH 33/92] feat(ssh-relay): paneId in the relay-open grammar, shared agent-socket path, canonical peer-key spelling Task 8 (b): the brokered open now names the pane its pairing serves, validated with the same ^[a-zA-Z0-9_-]{1,64}$ guard the node's socket bind runs, so a paneId that could only fail the path composition is refused before signing. The encryption-half spelling is fixed as base64 of the UTF-8 JSON public JWK (h), and buildAgentSocketPath moves to pane-runtime beside buildSshConfigPath so the node bind and the plane's byte-check derive one path. --- packages/pane-runtime/src/index.ts | 1 + .../src/ssh/__tests__/ssh-render.test.ts | 31 ++++++++- packages/pane-runtime/src/ssh/ssh-render.ts | 19 ++++++ .../src/__tests__/node-frames.test.ts | 53 ++++++++++++++- packages/subshell-protocol/src/index.ts | 2 + packages/subshell-protocol/src/ssh-frames.ts | 68 +++++++++++++++---- 6 files changed, 158 insertions(+), 16 deletions(-) diff --git a/packages/pane-runtime/src/index.ts b/packages/pane-runtime/src/index.ts index 59034617f..bbbeaaf48 100644 --- a/packages/pane-runtime/src/index.ts +++ b/packages/pane-runtime/src/index.ts @@ -150,6 +150,7 @@ export { walkSshConfig, } from "./ssh/ssh-discover.js"; export { + buildAgentSocketPath, buildSshConfigPath, renderSshConfigContents, sshDestinationToken, diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts index be8634104..01adceec4 100644 --- a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "bun:test"; import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { buildSshConfigPath, renderSshConfigContents, sshDestinationToken, sshOptionTokens } from "../ssh-render.js"; +import { dirname, join } from "node:path"; +import { + buildAgentSocketPath, + buildSshConfigPath, + renderSshConfigContents, + sshDestinationToken, + sshOptionTokens, +} from "../ssh-render.js"; import { makeSnapshot } from "./helpers.js"; /** @@ -235,6 +241,27 @@ describe("buildSshConfigPath", () => { }); }); +describe("buildAgentSocketPath", () => { + // Spec 2026-10-08 §5.2: the relay proxy socket lives in the SAME per-pane + // ssh dir the rendered config owns, so the pane's dir holds `config` and + // `agent.sock` side by side. It lives here because BOTH ends derive it - + // the node binds this exact path and the plane byte-checks the path the + // B open answers with - and one derivation is the only thing keeping the + // byte-check meaningful. + it("composes the socket beside the config, one dir", () => { + expect(buildAgentSocketPath("/data", "s1")).toBe("/data/ssh/s1/agent.sock"); + expect(dirname(buildAgentSocketPath("/data", "s1"))).toBe(dirname(buildSshConfigPath("/data", "s1"))); + }); + + it("runs the same guards as the config path", () => { + expect(() => buildAgentSocketPath("data", "s1")).toThrow(/absolute/); + expect(() => buildAgentSocketPath("/d", "")).toThrow(/paneId/); + expect(() => buildAgentSocketPath("/d", "../escape")).toThrow(/paneId/); + expect(() => buildAgentSocketPath("/d", "a".repeat(65))).toThrow(/paneId/); + expect(buildAgentSocketPath("/d", "a".repeat(64))).toBe(`/d/ssh/${"a".repeat(64)}/agent.sock`); + }); +}); + describe("Metro purity", () => { it("ssh-render.ts imports no node: builtin (the pane-runtime barrel rule)", () => { const source = readFileSync(join(import.meta.dir, "..", "ssh-render.ts"), "utf8"); diff --git a/packages/pane-runtime/src/ssh/ssh-render.ts b/packages/pane-runtime/src/ssh/ssh-render.ts index f0087d197..99bf2fa9f 100644 --- a/packages/pane-runtime/src/ssh/ssh-render.ts +++ b/packages/pane-runtime/src/ssh/ssh-render.ts @@ -215,3 +215,22 @@ export function buildSshConfigPath(dataDir: string, subshellId: string): string if (!/^[a-zA-Z0-9_-]{1,64}$/.test(subshellId)) throw new Error("ssh config path: invalid subshellId"); return `${dataDir}/ssh/${subshellId}/config`; } + +/** + * The relay agent-proxy socket path for a pane (spec 2026-10-08 §5.2): the + * SAME per-pane ssh dir the rendered config owns, with `agent.sock` beside + * `config`. It lives beside {@link buildSshConfigPath} for the same reason + * the config path does: BOTH ends derive it from (dataDir, paneId) and the + * byte-equality is the check - the node's B executor binds exactly this path, + * answers the open with it, and the plane's broker re-derives it (its own + * dataDir record for the node + the command's paneId) and refuses a single- + * byte mismatch before exporting anything as the pane's scoped + * `SSH_AUTH_SOCK`. A machine answer naming any other path therefore points + * the pane's ssh at nothing the plane agreed to. Same guards, same refusals + * (throws) as the config path. + */ +export function buildAgentSocketPath(dataDir: string, paneId: string): string { + if (!dataDir.startsWith("/")) throw new Error("ssh agent socket path: dataDir must be absolute POSIX"); + if (!/^[a-zA-Z0-9_-]{1,64}$/.test(paneId)) throw new Error("ssh agent socket path: invalid paneId"); + return `${dataDir}/ssh/${paneId}/agent.sock`; +} diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 1081f734f..7a1feb1bc 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -24,7 +24,13 @@ import { parseNodeRuntimeReport, partPathOf, } from "../node-frames.js"; -import { SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, type SshRelayOpenCommand } from "../ssh-frames.js"; +import { + isSshGrantFingerprints, + isSshPaneId, + SSH_COMMAND_TYPES, + SSH_RELAY_CLOSE_REASONS, + type SshRelayOpenCommand, +} from "../ssh-frames.js"; import { SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_GRANT_FINGERPRINTS } from "../ssh-limits.js"; import { MIN_NODE_VERSION } from "../versions.js"; @@ -62,6 +68,7 @@ const relayOpenCmd: SshRelayOpenCommand = { grantId: "grant-77", fingerprints: ["SHA256:AAAA", "SHA256:BBBB"], lifetimeMs: 30_000, + paneId: "11111111-2222-4333-8444-555555555555", }; describe("parseNodeCommandBody", () => { @@ -1028,6 +1035,7 @@ describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => "grantId", "fingerprints", "lifetimeMs", + "paneId", ] as const) { const partial = structuredClone(relayOpenCmd) as unknown as Record; delete partial[drop]; @@ -1108,6 +1116,49 @@ describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => expect(parseNodeCommandBody({ ...relayOpenCmd, fingerprints: [`SHA256:${"a-_0".repeat(32)}`] })).not.toBeNull(); // alphabet, at bound }); + it("validates the paneId against the path-composition shape (Task 8 (b))", () => { + // The B-side proxy socket is `/ssh//agent.sock` and + // pane-runtime's own guard refuses anything outside + // `^[a-zA-Z0-9_-]{1,64}$`; the grammar names the SAME shape so a command + // that could only fail the node's guard fails here first, before the + // plane signs it. A uuid (the normal subshell id) is in the class. + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "pane-42" })).toMatchObject({ paneId: "pane-42" }); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "A9_z-x" })).toMatchObject({ paneId: "A9_z-x" }); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "x".repeat(64) })).not.toBeNull(); // at bound + // Every refusal below is a shape the socket-path guard could not take: + // traversal, absolute paths, empty, over-bound, and whitespace/control. + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "x".repeat(65) })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "../escape" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "a/b" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "/abs" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "a.b" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "a b" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: "a\nb" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, paneId: 42 })).toBeNull(); + // The exported predicate is the ONE spelling of the rule; the parse arm + // and the plane's openRelay consult it, so a drift here is a drift there. + expect(isSshPaneId("pane-42")).toBe(true); + expect(isSshPaneId("x".repeat(64))).toBe(true); + expect(isSshPaneId("x".repeat(65))).toBe(false); + expect(isSshPaneId("a/b")).toBe(false); + expect(isSshPaneId("")).toBe(false); + expect(isSshPaneId(7)).toBe(false); + expect(isSshPaneId(null)).toBe(false); + }); + + it("exposes the grant fingerprint-set predicate for the plane's pre-signing check", () => { + // The plane validates a grant's selection with the SAME rule its own + // parser enforces on receipt; one definition, two directions. + expect(isSshGrantFingerprints([])).toBe(true); // §5.4: empty names nothing, serves nothing + expect(isSshGrantFingerprints(["SHA256:AAAA"])).toBe(true); + expect(isSshGrantFingerprints(["SHA256:AAAA", "not-a-fingerprint"])).toBe(false); + expect( + isSshGrantFingerprints(Array.from({ length: SSH_MAX_GRANT_FINGERPRINTS + 1 }, (_, i) => `SHA256:${i}`)), + ).toBe(false); + expect(isSshGrantFingerprints("SHA256:AAAA")).toBe(false); + }); + it("narrows the close: routing ref and a NAMED reason from the typed union, nothing else", () => { // The census members are the ONLY accepted spellings; each round-trips. for (const reason of SSH_RELAY_CLOSE_REASONS) { diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 8a4d1d276..32f30ed35 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -251,6 +251,8 @@ export { type SshErrorCode, } from "./ssh-errors.js"; export { + isSshGrantFingerprints, + isSshPaneId, parseSshNodeCommandBody, SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index 4348f161d..c2731f96a 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -76,15 +76,29 @@ export type SshRelayRole = "A" | "B"; * both node ids with their roles, the peer's registered signing AND * encryption public keys to pin (§4.4), the grant id with its selected * key-fingerprint set (§5.4, delivered so the responder enforces it without - * a REST call it cannot make), and the lifetime. Every field is REQUIRED: - * a partial pairing names no session the endpoint could honor, so the - * grammar refuses it rather than let an executor guess a half. + * a REST call it cannot make), the lifetime, and the pane the pairing serves. + * Every field is REQUIRED: a partial pairing names no session the endpoint + * could honor, so the grammar refuses it rather than let an executor guess a + * half. * - * The peer keys travel as their registered spellings: the ES256 signing half - * as a JSON-serialized public JWK (what §4.2/§4.3 store and report), the - * ECDH-ES encryption half as canonical base64 (what the link pins). The - * byte-equality enforcement is the receiver's machine pin store (§4.4), + * The peer keys travel as their ONE canonical spellings - the same strings + * §4.2/§4.3 store, transported byte-identically so the pin store's + * byte-equality rule has something to match: + * - `peerSigningPublicKey`: the peer's ES256 signing PUBLIC JWK, JSON- + * serialized (the registration/enroll reporting spelling). + * - `peerEncryptPublicKey`: base64 (standard, padded, `BASE64_RE`) of the + * UTF-8 bytes of the peer's ECDH-ES encryption PUBLIC JWK, JSON-serialized + * (what the receiver decodes back to the exact string its machine pin + * store holds, and what `seal` consumes). + * The byte-equality enforcement is the receiver's machine pin store (§4.4), * not this grammar's. + * + * `paneId` (Task 8 (b)): the B-side proxy socket is + * `/ssh//agent.sock`, and pane-runtime's path-composition + * guard (`buildSshConfigPath`/`buildAgentSocketPath`) accepts only ids + * matching {@link isSshPaneId}; the grammar names the SAME shape so a command + * that could only ever fail the node's socket-path guard is refused here, + * before the plane signs it. */ export interface SshRelayOpenCommand { type: "ssh_relay_open"; @@ -100,7 +114,7 @@ export interface SshRelayOpenCommand { bNodeId: string; /** The peer's registered ES256 signing public key (JSON public JWK) to pin. */ peerSigningPublicKey: string; - /** The peer's registered ECDH-ES encryption public key (canonical base64) to pin. */ + /** Base64 of the UTF-8 bytes of the peer's JSON-serialized encryption public JWK, to pin. */ peerEncryptPublicKey: string; /** The live grant this session runs under (revoke cuts the session, §6.3). */ grantId: string; @@ -108,6 +122,8 @@ export interface SshRelayOpenCommand { fingerprints: string[]; /** Session ceiling in ms; SSH_RELAY_LIFETIME_MS is what the plane sends. */ lifetimeMs: number; + /** The pane (subshell id) the pairing serves: names B's proxy socket path (§5.2). */ + paneId: string; } /** @@ -196,6 +212,25 @@ function isIdStr(value: unknown): value is string { */ const GRANT_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9_-]{1,128}$/; +/** + * The pane id the relay-open command names: exactly the shape pane-runtime's + * socket-path composition accepts (`buildSshConfigPath`'s own guard - the + * grammar and the path guard cannot drift apart, because the plane's + * `openRelay` validates with THIS predicate before signing, both nodes' + * parsers validate it on receipt, and the node's bind still runs the path + * guard itself as the last station. + */ +const SSH_PANE_ID_RE = /^[a-zA-Z0-9_-]{1,64}$/; + +/** + * Whether `value` is a pane (subshell) id inside the socket-path composition + * shape. Exported so the PLANE validates the id it is about to sign into a + * relay-open with the same rule its parser enforces on receipt. + */ +export function isSshPaneId(value: unknown): value is string { + return isStr(value) && SSH_PANE_ID_RE.test(value); +} + /** * The grant's selected fingerprint set: strings, every one in the `SHA256:` * display form (an empty or free-text entry matches no key yet would ride a @@ -203,9 +238,10 @@ const GRANT_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9_-]{1,128}$/; * {@link SSH_MAX_GRANT_FINGERPRINTS} - over-cap is a refusal at the grammar * so the truncation nobody may silently perform is impossible by construction * (§5.4). An EMPTY set parses: §5.4's "names no fingerprint, serves nothing" - * is a decision, not a defect. + * is a decision, not a defect. Exported for the plane's pre-signing check + * (one definition of the grant-selection law, both directions). */ -function isGrantFingerprints(value: unknown): value is string[] { +export function isSshGrantFingerprints(value: unknown): value is string[] { return ( isStrArray(value) && value.every((f) => GRANT_FINGERPRINT_RE.test(f)) && value.length <= SSH_MAX_GRANT_FINGERPRINTS ); @@ -264,13 +300,18 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu return null; } if (!isRecord(peerSigningJwk) || "d" in peerSigningJwk) return null; - // The encryption half is a base64 key (the link pin's own spelling); a - // present-but-undecodable one is malformed, not "the pin sorts it out". + // The encryption half is base64 of the UTF-8 JSON public JWK (the one + // canonical spelling, see the command's doc); a present-but-undecodable + // one is malformed, not "the pin sorts it out". if (!isIdStr(value.peerEncryptPublicKey) || !BASE64_RE.test(value.peerEncryptPublicKey)) return null; - if (!isGrantFingerprints(value.fingerprints)) return null; + if (!isSshGrantFingerprints(value.fingerprints)) return null; // Positive whole milliseconds; the VALUE's lawfulness (SSH_RELAY_LIFETIME_MS // as the ceiling the broker sends) is the broker's, this is the shape. if (!isInt(value.lifetimeMs) || (value.lifetimeMs as number) <= 0) return null; + // Task 8 (b): the pane the pairing serves. The B proxy socket is named + // by it, so it must be a path-composability id - the SAME guard the + // node's socket bind runs, applied before anything is signed. + if (!isSshPaneId(value.paneId)) return null; // Rebuilt from the validated fields, never a cast of the candidate: a // stray member on the wire drops here, exactly as the close arm drops. return { @@ -285,6 +326,7 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu grantId: value.grantId, fingerprints: [...value.fingerprints], lifetimeMs: value.lifetimeMs as number, + paneId: value.paneId, }; } case "ssh_relay_close": { From 96317bdfb93424a9372c8725f05d73920704e4f5 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:50:32 -0700 Subject: [PATCH 34/92] feat(ssh-relay): plane relay broker with lifetime, quota, blind routing, audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pairing machine (spec 2026-10-08 §5.3): openRelay gates both roles on the plane's own ssh_enabled row and the agent kind (fail closed), refuses the 9th concurrent session on either node before touching anything, mints the opaque ref, audits node.ssh_relay.open, and delivers the two signed ssh_relay_open commands (role branches, peer keys, fingerprint set, lifetime, paneId). Routing is blind (§5.5): routeRelayFrame looks the ref up and hands the SAME frame object to the peer's pump; no record holds a blob. §5.6's cuts fire through an injectable scheduler: lifetime-expiry, handshake-grace without both sides, a-dropped (A socket death or a refused send - the phantom-hang cut), grant-revoked, child-exit, over-cap; each tells BOTH sides with the named reason and audits. B's answered socket path is byte-checked against the shared pane-runtime derivation and leaves only as the one-key scoped SSH_AUTH_SOCK pane env. Every seam (command transport, pump, rows, audit, clock) is injected; 27 tests, no DB. --- .../__tests__/ssh-relay.service.test.ts | 640 +++++++++++++++++ .../api/src/services/ssh-relay.service.ts | 673 ++++++++++++++++++ 2 files changed, 1313 insertions(+) create mode 100644 apps/server/api/src/services/__tests__/ssh-relay.service.test.ts create mode 100644 apps/server/api/src/services/ssh-relay.service.ts diff --git a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts new file mode 100644 index 000000000..1f8c0f81b --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts @@ -0,0 +1,640 @@ +import { describe, expect, it } from "bun:test"; +import { buildAgentSocketPath } from "@internal/pane-runtime"; +import type { + NodeCommandBody, + RelayFrame, + SshRelayCloseReason, + SshRelayOpenCommand, +} from "@internal/subshell-protocol"; +import { + NODE_PROTOCOL_VERSION, + SSH_RELAY_LIFETIME_MS, + SSH_RELAY_MAX_PER_NODE, + SSH_RELAY_TEARDOWN_GRACE_MS, +} from "@internal/subshell-protocol"; +import type { NodeSocket } from "@/services/nodes/node-registry.js"; +import { attachConnection, getLive, resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { + closeRelayForPaneExit, + createRelayBroker, + type RelayBroker, + RelaySendError, + sendRelayFrameOverNodeSocket, + setRelayBrokerForTests, + sshRelayPaneEnv, + sweepRelayForPane, +} from "@/services/ssh-relay.service.js"; + +/** + * The plane relay broker (spec 2026-10-08 §5.1/§5.3/§5.5/§5.6, Task 8). The + * whole file runs against injected seams: no DB, no socket, no real clock. + * The acceptance gates get their own named test: + * (a) both opens are delivered as signed commands with the role branches and + * the close reaches both sides; (b) paneId carriage + refusal; + * (c) the per-node send glue throws on a dropped send and a frame routed to + * a dead peer tears the session down; (d) `ssh_enabled` and `local` refuse + * before anything is brokered; (e) SSH_AUTH_SOCK leaves ONLY as the scoped + * one-key pane env, byte-matching the B answer's socket path; (f) every + * §5.6 cut fires its named reason through injectable timers; (g) the quota + * is a loud refusal touching nothing; (h) the peer encryption key is carried + * as base64 of the UTF-8 JSON public JWK, and the plane is BLIND (§5.5): + * routing is a byte-for-byte copy and no session record ever holds a blob. + */ + +const PANE = "11111111-2222-4333-8444-555555555555"; +const A_SIGNING = '{"kty":"EC","crv":"P-256","x":"AX","y":"AY","use":"sig"}'; +const A_ENCRYPT = '{"kty":"EC","crv":"P-256","x":"EX","y":"EY","use":"enc"}'; +const B_SIGNING = '{"kty":"EC","crv":"P-256","x":"BX","y":"BY","use":"sig"}'; +const B_ENCRYPT = '{"kty":"EC","crv":"P-256","x":"FX","y":"FY","use":"enc"}'; +const FINGERPRINTS = ["SHA256:AAAA", "SHA256:BBBB"]; + +const b64 = (s: string): string => Buffer.from(s, "utf8").toString("base64"); + +const frame = (over: Partial = {}): RelayFrame => ({ + type: "relay", + ref: "r-unknown", + seq: 0, + direction: "B2A", + blob: "QUJDk5+ToQ==", // opaque; the broker must never decode it + ...over, +}); + +interface FakeTimer { + id: number; + at: number; + fn: () => void; + cancelled: boolean; +} + +function makeHarness( + opts: { + rows?: Record; + dataDir?: string | null; + ack?: (nodeId: string, cmd: NodeCommandBody) => unknown | Promise; + relaySend?: (nodeId: string, frame: RelayFrame) => void; + } = {}, +) { + const clock = { now: 1_730_000_000_000 }; + const timers: FakeTimer[] = []; + let nextTimer = 1; + const commands: { nodeId: string; cmd: NodeCommandBody }[] = []; + const relayFrames: { nodeId: string; frame: RelayFrame }[] = []; + const audits: { + action: string; + actorUserId: string | null; + targetType: string | null; + targetId: string | null; + meta: Record; + }[] = []; + const lines: string[] = []; + const rows = opts.rows ?? { a: { kind: "agent", sshEnabled: 1 }, b: { kind: "agent", sshEnabled: 1 } }; + const defaultAck = (_nodeId: string, cmd: NodeCommandBody): unknown => + cmd.type === "ssh_relay_open" && cmd.role === "B" + ? { role: "B", relayId: cmd.relayId, socketPath: buildAgentSocketPath(opts.dataDir ?? "/data/b", cmd.paneId) } + : cmd.type === "ssh_relay_open" + ? { role: "A", relayId: cmd.relayId } + : { ok: true }; + + const broker: RelayBroker = createRelayBroker({ + async sendCommand(nodeId, cmd) { + commands.push({ nodeId, cmd }); + return (opts.ack ?? defaultAck)(nodeId, cmd); + }, + sendRelayFrame(nodeId, f) { + relayFrames.push({ nodeId, frame: f }); + if (opts.relaySend) return opts.relaySend(nodeId, f); + throw new RelaySendError(nodeId, "fake pump always drops"); + }, + async nodeRow(nodeId) { + const r = rows[nodeId]; + return r === undefined ? null : r; + }, + nodeDataDir: () => (opts.dataDir === undefined ? "/data/b" : opts.dataDir), + async audit(e) { + audits.push({ ...e, meta: JSON.parse(e.metadataJson ?? "{}") as Record }); + }, + nowMs: () => clock.now, + schedule: (fn, ms) => { + const t: FakeTimer = { id: nextTimer, at: clock.now + ms, fn, cancelled: false }; + nextTimer += 1; + timers.push(t); + return t; + }, + cancel: (t) => { + (t as FakeTimer).cancelled = true; + }, + log: (line) => lines.push(line), + }); + + const fireDue = (): number => { + let fired = 0; + for (;;) { + const due = timers.filter((t) => !t.cancelled && t.at <= clock.now).sort((x, y) => x.at - y.at)[0]; + if (!due) return fired; + due.cancelled = true; + due.fn(); + fired += 1; + } + }; + + return { + broker, + commands, + relayFrames, + audits, + lines, + timers, + clock, + advance: async (ms: number) => { + clock.now += ms; + // A macrotask turn drains every microtask chain the fake scheduler's + // callbacks started (allSettleds, awaits in closeRelay). + await new Promise((r) => setTimeout(r, 0)); + fireDue(); + await new Promise((r) => setTimeout(r, 0)); + }, + open: (over: Partial[0]> = {}) => + broker.openRelay({ + grantId: "grant-1", + fingerprints: FINGERPRINTS, + paneId: PANE, + aNode: "a", + bNode: "b", + aPeer: { signingPublicKey: A_SIGNING, encryptionPublicJwk: A_ENCRYPT }, + bPeer: { signingPublicKey: B_SIGNING, encryptionPublicJwk: B_ENCRYPT }, + ...over, + }), + }; +} + +/* ------------------------------------------------------------------ */ +/* openRelay: pairing, carriage, gates, quota */ +/* ------------------------------------------------------------------ */ + +describe("openRelay (spec §5.1/§5.3, Task 8 (a)(b)(d)(g)(h))", () => { + it("pairs A and B with an opaque ref and delivers the whole pairing to BOTH sides", async () => { + const h = makeHarness(); + const r = await h.open(); + // Opaque ids, minted by the plane; the ref is the blind pairing key. + expect(r.ref).toMatch(/^[0-9a-f-]{36}$/); + expect(r.relayId).toMatch(/^[0-9a-f-]{36}$/); + expect(r.ref).not.toBe(r.relayId); + expect(new Date(r.expiresAt).getTime()).toBe(h.clock.now + SSH_RELAY_LIFETIME_MS); + + const toA = h.commands.find((c) => c.nodeId === "a")?.cmd as SshRelayOpenCommand | undefined; + const toB = h.commands.find((c) => c.nodeId === "b")?.cmd as SshRelayOpenCommand | undefined; + expect(toA?.type).toBe("ssh_relay_open"); + expect(toB?.type).toBe("ssh_relay_open"); + // (a) the role branches name each machine what it is; one ref pairs them. + expect(toA?.role).toBe("A"); + expect(toB?.role).toBe("B"); + expect(toA?.ref).toBe(r.ref); + expect(toB?.ref).toBe(r.ref); + expect(toA?.relayId).toBe(r.relayId); + expect(toA?.aNodeId).toBe("a"); + expect(toA?.bNodeId).toBe("b"); + expect(toA?.lifetimeMs).toBe(SSH_RELAY_LIFETIME_MS); + // Each side is told the PEER's keys to pin, never its own. + expect(toA?.peerSigningPublicKey).toBe(B_SIGNING); + expect(toB?.peerSigningPublicKey).toBe(A_SIGNING); + // (h) the encryption half is base64 of the UTF-8 JSON public JWK - the + // exact spelling the node decodes and the pin store holds. + expect(toA?.peerEncryptPublicKey).toBe(b64(B_ENCRYPT)); + expect(Buffer.from(String(toA?.peerEncryptPublicKey), "base64").toString("utf8")).toBe(B_ENCRYPT); + expect(toB?.peerEncryptPublicKey).toBe(b64(A_ENCRYPT)); + // (b) the pane rides the command (the B proxy socket is named by it). + expect(toA?.paneId).toBe(PANE); + expect(toB?.paneId).toBe(PANE); + // (a) the grant's selected fingerprints are delivered for A's responder. + expect(toA?.fingerprints).toEqual(FINGERPRINTS); + expect(toA?.grantId).toBe("grant-1"); + // The open is audited once, ids only. + expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open"]); + expect(h.audits[0]?.meta).toEqual({ + relayId: r.relayId, + ref: r.ref, + grantId: "grant-1", + paneId: PANE, + aNodeId: "a", + bNodeId: "b", + fingerprintCount: FINGERPRINTS.length, + lifetimeMs: SSH_RELAY_LIFETIME_MS, + }); + expect(h.audits[0]?.actorUserId).toBeNull(); + // And the plane records both sides as opened once the acks land. + const info = h.broker.sessionInfo(r.ref); + expect(info).not.toBeNull(); + expect(info?.aOpened).toBe(true); + expect(info?.bOpened).toBe(true); + }); + + it("answers the verified B socket path, byte-matching the shared derivation", async () => { + const h = makeHarness(); + const r = await h.open(); + expect(r.socketPath).toBe(buildAgentSocketPath("/data/b", PANE)); + }); + + it("refuses a B answer whose socket path is not THIS pane's derived path (never trusts a machine claim)", async () => { + const h = makeHarness({ + ack: (_nodeId, cmd) => + cmd.type === "ssh_relay_open" && cmd.role === "B" + ? { role: "B", relayId: cmd.relayId, socketPath: "/attacker/agent.sock" } + : { role: "A", relayId: cmd.relayId }, + }); + await expect(h.open()).rejects.toMatchObject({ code: "bad-socket-path" }); + // The session was torn down and BOTH sides were told, under a named cut. + expect(h.broker.sessionInfo(h.audits[0]?.meta.ref as string)).toBeNull(); + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes.map((c) => c.nodeId).sort()).toEqual(["a", "b"]); + expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open", "node.ssh_relay.close"]); + }); + + it("refuses a missing or non-string B socket answer the same way", async () => { + const h = makeHarness({ + ack: (_nodeId, cmd) => + cmd.type === "ssh_relay_open" && cmd.role === "B" ? { role: "B", relayId: cmd.relayId } : { role: "A" }, + }); + await expect(h.open()).rejects.toMatchObject({ code: "bad-socket-path" }); + }); + + it("refuses a node whose ssh gate is off, is local, or has no row - before any command or audit (acceptance (d))", async () => { + for (const [rows, code] of [ + [{ a: { kind: "agent" as const, sshEnabled: 0 }, b: { kind: "agent" as const, sshEnabled: 1 } }, "node-off"], + [{ a: null, b: { kind: "agent" as const, sshEnabled: 1 } }, "no-node"], + [{ a: { kind: "agent" as const, sshEnabled: 1 }, b: { kind: "local" as const, sshEnabled: 1 } }, "local-node"], + [{ a: { kind: "agent" as const, sshEnabled: null }, b: { kind: "agent" as const, sshEnabled: 1 } }, "node-off"], + ] as const) { + const h = makeHarness({ rows: rows as never }); + await expect(h.open()).rejects.toMatchObject({ code }); + expect(h.commands).toHaveLength(0); + expect(h.audits).toHaveLength(0); + expect(h.broker.activeRelayCount("a")).toBe(0); + } + }); + + it("refuses the same machine as both A and B, and a paneId outside the path-composition shape (acceptance (b))", async () => { + const h = makeHarness(); + await expect(h.open({ aNode: "a", bNode: "a" })).rejects.toMatchObject({ code: "same-node" }); + await expect(h.open({ paneId: "../escape" })).rejects.toMatchObject({ code: "bad-pane-id" }); + await expect(h.open({ paneId: "" })).rejects.toMatchObject({ code: "bad-pane-id" }); + await expect(h.open({ paneId: "x".repeat(65) })).rejects.toMatchObject({ code: "bad-pane-id" }); + await expect(h.open({ fingerprints: ["not-a-fingerprint"] })).rejects.toMatchObject({ code: "bad-fingerprints" }); + expect(h.commands).toHaveLength(0); + expect(h.audits).toHaveLength(0); + }); + + it("enforces SSH_RELAY_MAX_PER_NODE as a loud refusal that touches nothing (acceptance (g))", async () => { + const h = makeHarness({ + rows: { + a: { kind: "agent", sshEnabled: 1 }, + b: { kind: "agent", sshEnabled: 1 }, + "fresh-b": { kind: "agent", sshEnabled: 1 }, + }, + }); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) { + await h.open({ paneId: `pane-${i}`, grantId: `grant-${i}` }); + } + expect(h.broker.activeRelayCount("b")).toBe(SSH_RELAY_MAX_PER_NODE); + const auditsBefore = h.audits.length; + const commandsBefore = h.commands.length; + await expect(h.open({ paneId: "pane-over", grantId: "grant-over" })).rejects.toMatchObject({ + code: "quota", + nodeId: "a", + }); + expect(h.audits).toHaveLength(auditsBefore); // no half-done audit + expect(h.commands).toHaveLength(commandsBefore); // no half-delivered command + // The cap counts each of A and B: a node at the cap cannot join even as A. + await expect( + h.open({ paneId: "pane-as-a", grantId: "grant-as-a", aNode: "b", bNode: "fresh-b" }), + ).rejects.toMatchObject({ code: "quota", nodeId: "b" }); + // A closed slot frees room (the count is LIVE sessions, not history). + const first = h.broker.sessionInfo(h.audits[0]?.meta.ref as string); + expect(first).not.toBeNull(); + await h.broker.closeRelay(first?.ref as string, "lifetime-expiry"); + await expect(h.open({ paneId: "pane-over", grantId: "grant-over" })).resolves.toMatchObject({}); + }); + + it("arms the lifetime and the grace on the INJECTED timers only (no real 30 s waits)", async () => { + const h = makeHarness(); + await h.open(); + const armed = h.timers.map((t) => t.at - 1_730_000_000_000).sort((a, b) => a - b); + expect(armed).toEqual([SSH_RELAY_TEARDOWN_GRACE_MS, SSH_RELAY_LIFETIME_MS]); + }); +}); + +/* ------------------------------------------------------------------ */ +/* routing: blind, by ref, throw-on-drop teardown */ +/* ------------------------------------------------------------------ */ + +describe("routeRelayFrame (spec §5.5, Task 8 (c))", () => { + it("forwards the blob byte-for-byte to the peer and holds nothing itself", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + const f = frame({ ref: r.ref, direction: "B2A", blob: b64(JSON.stringify({ secret: "agent bytes" })) }); + h.broker.routeRelayFrame("b", f); + expect(h.relayFrames).toHaveLength(1); + expect(h.relayFrames[0]?.nodeId).toBe("a"); + // The exact same frame, same strings, same objects: an unopened copy. + expect(h.relayFrames[0]?.frame).toBe(f); + // The plane read the ref and nothing else: the session record has no + // blob slot at any point in its life (§5.5), and never gained one. + const info = h.broker.sessionInfo(r.ref); + expect(Object.keys(info ?? {}).sort()).toEqual( + ["aNode", "aOpened", "bNode", "bOpened", "expiresAt", "grantId", "paneId", "ref", "relayId", "socketPath"].sort(), + ); + // ...and the session view never holds the blob, encoded or decoded. + expect(JSON.stringify(info)).not.toContain(f.blob); + expect(JSON.stringify(info)).not.toContain("agent bytes"); + // Both directions route; A's reply rides to B untouched. + h.broker.routeRelayFrame("a", frame({ ref: r.ref, direction: "A2B", seq: 0 })); + expect(h.relayFrames[1]?.nodeId).toBe("b"); + }); + + it("drops a frame for an unbrokered ref and a frame from a node that is not a party", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + h.broker.routeRelayFrame("b", frame({ ref: "r-not-brokered" })); + h.broker.routeRelayFrame("stranger", frame({ ref: r.ref })); + expect(h.relayFrames).toHaveLength(0); + expect(h.broker.sessionInfo(r.ref)).not.toBeNull(); // a drop is not a teardown + }); + + it("a frame routed to a dead A peer throws through the glue and the session is torn down, not left spinning (acceptance (c))", async () => { + const h = makeHarness(); // the fake pump DROPS (throws) by default + const r = await h.open(); + h.broker.routeRelayFrame("b", frame({ ref: r.ref })); + // The teardown it triggers is async (the close sends, then the audit). + await new Promise((resolve) => setTimeout(resolve, 0)); + // The phantom-hang direction (B's request cannot reach A): the plane cuts + // now, with the named reason, and both sides are told. + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes).toHaveLength(2); + expect(closes[0]?.cmd).toEqual({ type: "ssh_relay_close", ref: r.ref, reason: "a-dropped" }); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open", "node.ssh_relay.close"]); + expect(h.audits[1]?.meta.reason).toBe("a-dropped"); + }); + + it("an undeliverable reply to a dead B loses the frame but not the session (no b-dropped word exists; the caps own it)", async () => { + const h = makeHarness(); + const r = await h.open(); + h.broker.routeRelayFrame("a", frame({ ref: r.ref, direction: "A2B" })); + expect(h.broker.sessionInfo(r.ref)).not.toBeNull(); // A-side cuts must not masquerade as a-dropped + expect(h.commands.filter((c) => c.cmd.type === "ssh_relay_close")).toHaveLength(0); + // The lifetime cap still ends it. + await h.advance(SSH_RELAY_LIFETIME_MS); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + expect(h.audits[1]?.meta.reason).toBe("lifetime-expiry"); + }); +}); + +/* ------------------------------------------------------------------ */ +/* §5.6 teardown cuts, each with its named reason */ +/* ------------------------------------------------------------------ */ + +describe("teardown (spec §5.6, Task 8 (f))", () => { + it("lifetime expiry cuts with lifetime-expiry, tells both sides, audits once", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + await h.advance(SSH_RELAY_LIFETIME_MS); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes.map((c) => c.nodeId).sort()).toEqual(["a", "b"]); + for (const c of closes) expect(c.cmd).toEqual({ type: "ssh_relay_close", ref: r.ref, reason: "lifetime-expiry" }); + expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open", "node.ssh_relay.close"]); + expect(h.audits[1]?.meta).toMatchObject({ ref: r.ref, relayId: r.relayId, reason: "lifetime-expiry" }); + }); + + it("the grace window elapsed without both sides cuts with handshake-grace", async () => { + let releaseB: ((v: unknown) => void) | undefined; + const h = makeHarness({ + ack: (nodeId, cmd) => { + if (nodeId === "b" && cmd.type === "ssh_relay_open") { + return new Promise((resolve) => { + releaseB = resolve; + }); + } + return cmd.type === "ssh_relay_open" ? { role: "A", relayId: cmd.relayId } : { ok: true }; + }, + }); + const opening = h.open(); + await new Promise((r) => setTimeout(r, 0)); + // A answered; B has not, inside the grace: the session survives. + const opened = h.commands.find((c) => c.cmd.type === "ssh_relay_open"); + const ref = opened?.cmd.type === "ssh_relay_open" ? opened.cmd.ref : ""; + expect(ref).not.toBe(""); + await h.advance(SSH_RELAY_TEARDOWN_GRACE_MS); + expect(h.broker.sessionInfo(ref)).toBeNull(); + expect( + h.commands.some( + (c) => c.cmd.type === "ssh_relay_close" && (c.cmd as { reason: string }).reason === "handshake-grace", + ), + ).toBe(true); + // B's late ack lands on a session that no longer exists: it must not + // resurrect it and openRelay refuses loudly (the handshake never completed). + releaseB?.({ role: "B", socketPath: buildAgentSocketPath("/data/b", PANE) }); + await expect(opening).rejects.toMatchObject({ code: "handshake" }); + expect(h.broker.sessionInfo(ref)).toBeNull(); + }); + + it("a refusing node cuts the session under handshake-grace and openRelay throws the named reason", async () => { + const h = makeHarness({ + ack: (_nodeId, cmd) => { + if (cmd.type === "ssh_relay_open" && cmd.role === "B") throw new Error("machine pin for a has MOVED"); + return { role: "A", relayId: cmd.relayId }; + }, + }); + await expect(h.open()).rejects.toMatchObject({ code: "handshake" }); + expect(h.broker.activeRelayCount("a")).toBe(0); + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes).toHaveLength(2); + const firstClose = closes[0]; + expect(firstClose?.cmd.type).toBe("ssh_relay_close"); + if (firstClose?.cmd.type === "ssh_relay_close") expect(firstClose.cmd.reason).toBe("handshake-grace"); + }); + + it("grant revoke closes every session of the grant with grant-revoked (hook entry; the revoke route is T10)", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r1 = await h.open({ paneId: "p1", grantId: "g-target" }); + const r2 = await h.open({ paneId: "p2", grantId: "g-keep" }); + const n = await h.broker.closeForGrant("g-target", "grant-revoked"); + expect(n).toBe(1); + expect(h.broker.sessionInfo(r1.ref)).toBeNull(); + expect(h.broker.sessionInfo(r2.ref)).not.toBeNull(); + const closes = h.commands.filter((c) => c.cmd.type === "ssh_relay_close"); + expect(closes).toHaveLength(2); + for (const c of closes) expect(c.cmd).toEqual({ type: "ssh_relay_close", ref: r1.ref, reason: "grant-revoked" }); + expect(h.audits.filter((a) => a.action === "node.ssh_relay.close")).toHaveLength(1); + }); + + it("A's authenticated socket dropping cuts A's sessions with a-dropped and leaves B-side sessions alone", async () => { + const h = makeHarness({ + relaySend: () => {}, + rows: { + a: { kind: "agent", sshEnabled: 1 }, + b: { kind: "agent", sshEnabled: 1 }, + c: { kind: "agent", sshEnabled: 1 }, + }, + }); + const asA = await h.open({ paneId: "p1" }); + // Same node as B of another pairing: its socket close is NOT a cut + // (§5.6 names only A-dropping; B's loss cuts via child-exit or the caps). + const asB = await h.open({ paneId: "p2", aNode: "c", bNode: "a" }); + const closed = await h.broker.onNodeSocketClosed("a"); + expect(closed).toBe(1); + expect(h.broker.sessionInfo(asA.ref)).toBeNull(); + expect(h.broker.sessionInfo(asB.ref)).not.toBeNull(); + expect( + h.commands.some( + (c) => + c.cmd.type === "ssh_relay_close" && c.nodeId === "b" && (c.cmd as { reason: string }).reason === "a-dropped", + ), + ).toBe(true); + }); + + it("child exit cuts by paneId; the sweep helper is best-effort and takes the row's id", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + const n = await h.broker.closeForPane(PANE, "child-exit"); + expect(n).toBe(1); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + expect( + h.commands.some((c) => c.cmd.type === "ssh_relay_close" && (c.cmd as { reason: string }).reason === "child-exit"), + ).toBe(true); + // An unknown pane is a no-op (the sweep runs on every death transition). + expect(await h.broker.closeForPane("pane-nothing", "child-exit")).toBe(0); + // The row-shaped sweep fires through the module singleton without throwing. + setRelayBrokerForTests(h.broker); + try { + sweepRelayForPane({ id: "pane-nothing" }); + closeRelayForPaneExit("pane-nothing"); + closeRelayForPaneExit(null); + } finally { + setRelayBrokerForTests(null); + } + }); + + it("an over-cap frame closes the session it names, by name, and the blob never comes along", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + expect(await h.broker.refuseOverCap(r.ref)).toBe(true); + expect( + h.commands.some((c) => c.cmd.type === "ssh_relay_close" && (c.cmd as { reason: string }).reason === "over-cap"), + ).toBe(true); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + expect(JSON.stringify(h.audits)).not.toContain("over-cap-blob-bytes"); + expect(await h.broker.refuseOverCap("r-unknown")).toBe(false); + }); + + it("closeRelay is idempotent: the second cut answers false and fires no second audit", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + expect(await h.broker.closeRelay(r.ref, "lifetime-expiry")).toBe(true); + expect(await h.broker.closeRelay(r.ref, "lifetime-expiry")).toBe(false); + expect(h.audits.filter((a) => a.action === "node.ssh_relay.close")).toHaveLength(1); + // An unknown reason never leaves the plane either. + expect(await h.broker.closeRelay("r-none", "not-a-reason" as SshRelayCloseReason)).toBe(false); + }); + + it("teardown keeps the close command's own failure from poisoning anything", async () => { + const h = makeHarness({ + relaySend: () => {}, + ack: (_nodeId, cmd) => { + if (cmd.type === "ssh_relay_close") throw new Error("node offline"); + return cmd.type === "ssh_relay_open" && cmd.role === "B" + ? { socketPath: buildAgentSocketPath("/data/b", cmd.paneId) } + : { ok: true }; + }, + }); + const r = await h.open(); + await expect(h.broker.closeRelay(r.ref, "grant-revoked")).resolves.toBe(true); + expect(h.broker.sessionInfo(r.ref)).toBeNull(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* audit hygiene, pane env, and the real per-node send glue */ +/* ------------------------------------------------------------------ */ + +describe("audit hygiene (Global Constraints)", () => { + it("open/close rows carry ids, hosts and the reason ONLY - never keys, fingerprints, or payloads", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + h.broker.routeRelayFrame("b", frame({ ref: r.ref })); + await h.advance(SSH_RELAY_LIFETIME_MS); + const serialized = JSON.stringify(h.audits); + for (const secret of [A_SIGNING, B_SIGNING, A_ENCRYPT, B_ENCRYPT, "SHA256:AAAA", "QUJDk5", r.socketPath]) { + expect(serialized).not.toContain(secret); + } + // fingerprint COUNT, never the set. + expect(h.audits[0]?.meta.fingerprintCount).toBe(2); + expect(h.audits[0]?.meta.fingerprints).toBeUndefined(); + }); +}); + +describe("SSH_AUTH_SOCK scoping (acceptance (e))", () => { + it("the relay pane env is EXACTLY the one-key scoped exception, nothing else", async () => { + const h = makeHarness({ relaySend: () => {} }); + const r = await h.open(); + expect(sshRelayPaneEnv(r.socketPath)).toEqual({ SSH_AUTH_SOCK: `/data/b/ssh/${PANE}/agent.sock` }); + // A caller cannot smuggle a second variable through the seam. + expect(Object.keys(sshRelayPaneEnv("x"))).toEqual(["SSH_AUTH_SOCK"]); + }); +}); + +describe("sendRelayFrameOverNodeSocket (the plane's throw-on-drop glue, acceptance (c))", () => { + it("throws when the node has no live connection", () => { + resetNodeRegistryForTests(); + expect(() => sendRelayFrameOverNodeSocket("gone", frame())).toThrow(RelaySendError); + }); + + it("throws on a closing or link-less connection and seals onto a live one", () => { + resetNodeRegistryForTests(); + const sent: (string | Buffer)[] = []; + const ws: NodeSocket = { + send: (d) => { + sent.push(d); + }, + close: () => {}, + }; + const conn = attachConnection("n1", ws); + // No link yet (the handshake window, or a hand-built conn): a relay + // frame is NEVER written plaintext. + expect(() => sendRelayFrameOverNodeSocket("n1", frame({ ref: "r-1" }))).toThrow(RelaySendError); + conn.link = { + sealFrame: (s: string) => new TextEncoder().encode(`SEALED:${s}`), + } as never; + sendRelayFrameOverNodeSocket("n1", frame({ ref: "r-1" })); + expect(sent).toHaveLength(1); + expect(Buffer.isBuffer(sent[0])).toBe(true); + // The Buffer carries the SEALED envelope, not the plaintext frame. + expect((sent[0] as Buffer).toString("utf8")).toContain("SEALED:"); + // Closing / superseded conns throw too. + conn.closing = true; + expect(() => sendRelayFrameOverNodeSocket("n1", frame({ ref: "r-1" }))).toThrow(RelaySendError); + resetNodeRegistryForTests(); + expect(getLive("n1")).toBeUndefined(); + }); + + it("a throwing socket send surfaces as the named RelaySendError (never silence)", () => { + resetNodeRegistryForTests(); + const ws: NodeSocket = { + send: () => { + throw new Error("socket write failed"); + }, + close: () => {}, + }; + const conn = attachConnection("n2", ws); + conn.link = { sealFrame: () => new Uint8Array([1]) } as never; + expect(() => sendRelayFrameOverNodeSocket("n2", frame())).toThrow(RelaySendError); + resetNodeRegistryForTests(); + }); +}); + +describe("grammar/version posture", () => { + it("relays a protocol-18-only world: the constant is pinned here as the broker's precondition", () => { + // The handler refuses every relay frame from a pre-18 agent upstream; + // this tripwire documents the dependency at the broker. + expect(NODE_PROTOCOL_VERSION).toBe(18); + }); +}); diff --git a/apps/server/api/src/services/ssh-relay.service.ts b/apps/server/api/src/services/ssh-relay.service.ts new file mode 100644 index 000000000..67204800a --- /dev/null +++ b/apps/server/api/src/services/ssh-relay.service.ts @@ -0,0 +1,673 @@ +import { buildAgentSocketPath } from "@internal/pane-runtime"; +import { + isSshGrantFingerprints, + isSshPaneId, + NODE_MAX_FRAME_BYTES, + type NodeCommandBody, + type RelayFrame, + SSH_RELAY_CLOSE_REASONS, + SSH_RELAY_LIFETIME_MS, + SSH_RELAY_MAX_PER_NODE, + SSH_RELAY_TEARDOWN_GRACE_MS, + type SshNodeCommandBody, + type SshRelayCloseReason, + type SshRelayOpenCommand, +} from "@internal/subshell-protocol"; +import type { NodeKind } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { type AuditEventInput, audit } from "@/services/audit.js"; +import { getLive } from "@/services/nodes/node-registry.js"; +import { binaryPayload, sendCommand } from "@/services/nodes/node-rpc.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The plane relay broker (spec 2026-10-08 §5.3, Task 8): the pairing machine + * for the sealed agent relay. `openRelay` brokers a session between A (the + * key home) and B (the connecting machine) - gate, quota, mint, audit, then + * the two signed `ssh_relay_open` commands carrying the WHOLE pairing: both + * node ids with roles, the peer's registered signing key (JSON public JWK) + * and encryption key (base64 of the UTF-8 JSON public JWK - the one canonical + * spelling, acceptance (h)), the grant's fingerprint set, the lifetime, and + * the pane (acceptance (b)). From then on the plane's only job is §5.5: + * route `relay` frames by their opaque ref, blob byte-for-byte unopened. + * + * **The blindness is the posture.** No session record holds a blob, a key, + * a fingerprint, or a payload; `routeRelayFrame` copies the frame object it + * was handed to the peer's socket and never reads `blob` (§5.5: an unopened + * byte copy is the whole forwarding job). Durably, the plane writes only the + * two audit rows - `node.ssh_relay.open` / `node.ssh_relay.close`, ids, hosts + * and the named reason, never secrets (Global Constraints, docs/security.md + * §10). + * + * **Teardown is by named reason only** (§5.1/§5.6). The grammar's six words + * are the whole vocabulary, and every cut below maps to exactly one: + * lifetime expiry, the handshake grace elapsed without both sides, A's link + * dropping, a grant revoke, the pane's child exiting, and an over-cap frame. + * Each cut sends the named-reason `ssh_relay_close` to BOTH sides and writes + * the close audit. A B-side link loss deliberately names no new reason: the + * grammar has no `b-dropped` word, §5.6 does not name B-dropping as a cut, + * and the task-7 handoff is explicit that phantom state ends at Task 8's + * timers - so an undeliverable reply to a gone B loses the ONE frame and the + * session dies at its lifetime cap, child exit, or revoke, never at a + * misnamed reason. + * + * **The per-node send glue throws on drop** (acceptance (c), mirroring the + * T6/T7 deliver-or-throw contract at the endpoints): {@link + * sendRelayFrameOverNodeSocket} refuses LOUDLY when the peer has no live + * socket, its record is closing or superseded, its encrypted link is not + * established, or the write itself fails. A silent-return pump reintroduces + * the phantom-request hang, so a routed frame whose peer socket is dead + * tears the session down (`a-dropped`) instead of leaving it spinning. + * + * **The machine answer about the socket is never trusted** (acceptance (e)): + * B's open answers with the proxy socket path it bound, and the plane + * re-derives the same path (`buildAgentSocketPath`, the shared pane-runtime + * helper the node itself binds) from ITS record of B's dataDir and the + * command's paneId, refusing a single-byte mismatch before the launch + * exports anything. `SSH_AUTH_SOCK` then leaves through + * {@link sshRelayPaneEnv} as the one-key scoped exception - the exact channel + * `ssh-launch.service.ts`'s `composeSshLaunch` uses for the ssh invocation + * env (`createSubshell`'s `extraPaneEnv` member) - never the whole-pane env. + * + * Both relay roles require a switched-on AGENT node (acceptance (d)): the + * plane's own `ssh_enabled` row must read 1 (default off fails closed, and a + * missing row refuses too) and `kind` must be `agent` - `local` is never an + * A or B. Both checks run before any session exists, any command leaves, or + * any audit row is written, as does the quota (acceptance (g)): a 9th live + * session on either node is a loud `SshRelayRefusal("quota")` that touched + * nothing. + * + * Everything outside the module is a {@link RelayBrokerDeps} seam (the + * `getNodeWsDeps` pattern): the command transport, the per-node relay pump, + * the node rows, the audit sink, and a clock/scheduler so the 30 s lifetime + * and the 5 s grace are testable without waiting. The production wiring is + * the default at the bottom of this file; the singleton is the seam + * `nodes/relay-frames.ts` and the pane-death sweeps route through. + */ + +/** The peer's registered relay keys, as the identities store holds them (public halves only). */ +export interface RelayPeerKeys { + /** The peer's ES256 signing public key: JSON-serialized public JWK (§4.2's reporting spelling). */ + signingPublicKey: string; + /** + * The peer's ECDH-ES encryption public key: JSON-serialized public JWK. + * The signed command carries it as base64 of its UTF-8 bytes (acceptance + * (h)); the pin stores the decoded string byte-equal. + */ + encryptionPublicJwk: string; +} + +/** Why an `openRelay` refused. Each code names its own door; the message says more. */ +export type SshRelayRefusalCode = + | "bad-pane-id" + | "bad-fingerprints" + | "same-node" + | "no-node" + | "local-node" + | "node-off" + | "no-datadir" + | "quota" + | "handshake" + | "bad-socket-path"; + +/** The loud refusal {@link RelayBroker.openRelay} throws. No session exists after one. */ +export class SshRelayRefusal extends Error { + readonly code: SshRelayRefusalCode; + /** The node the refusal is about, when it is node-shaped. */ + readonly nodeId: string | undefined; + + constructor(code: SshRelayRefusalCode, message: string, nodeId?: string) { + super(message); + this.name = "SshRelayRefusal"; + this.code = code; + this.nodeId = nodeId; + } +} + +/** + * The per-node relay pump's refusal (acceptance (c)): a frame that could not + * be delivered. Thrown, never swallowed - the endpoint's deliver-or-throw + * contract and the broker's teardown both depend on the raise. + */ +export class RelaySendError extends Error { + readonly nodeId: string; + + constructor(nodeId: string, message: string) { + super(`relay send to node "${nodeId}" refused: ${message}`); + this.name = "RelaySendError"; + this.nodeId = nodeId; + } +} + +/** The observable view of one brokered session. Never carries a blob, key, or payload (§5.5). */ +export interface RelaySessionInfo { + relayId: string; + ref: string; + grantId: string; + paneId: string; + aNode: string; + bNode: string; + /** Epoch-ms expiry (lifetime cap, §5.6). */ + expiresAt: number; + /** B's verified proxy socket path; null until B's open ack lands. */ + socketPath: string | null; + aOpened: boolean; + bOpened: boolean; +} + +/** Inputs to {@link RelayBroker.openRelay}. A grant's id and selection are T10's; they arrive as inputs here. */ +export interface OpenRelayInput { + /** The live grant this session runs under (revoke cuts the session, §6.3). */ + grantId: string; + /** The grant's selected fingerprints (§5.4); validated with the grammar's own predicate before signing. */ + fingerprints: readonly string[]; + /** The B-side pane the pairing serves (grammar (b)); pre-minted subshell id. */ + paneId: string; + /** The key home's node id. */ + aNode: string; + /** The connecting machine's node id. */ + bNode: string; + /** A's registered public halves, from A's identity row. */ + aPeer: RelayPeerKeys; + /** B's registered public halves, from B's identity row. */ + bPeer: RelayPeerKeys; +} + +/** What a successful `openRelay` answers: the pairing, plus the socket the pane's scoped env must point at. */ +export interface OpenRelayResult { + relayId: string; + /** The opaque routing ref - the plane's blind pairing key (the plan's `sessionRef`). */ + ref: string; + /** ISO 8061 expiry: the lifetime cap (§5.6). */ + expiresAt: string; + /** B's proxy socket, byte-verified against the shared derivation (acceptance (e)). */ + socketPath: string; +} + +/** Opaque timer handle; the scheduler seam's own identity. */ +export type TimerHandle = { readonly brand?: never }; + +/** The broker's world, every seam injectable (production default at the bottom). */ +export interface RelayBrokerDeps { + /** The signed command transport (`node-rpc.sendCommand`). */ + sendCommand(nodeId: string, cmd: NodeCommandBody, opts?: { timeoutMs?: number }): Promise; + /** The per-node relay pump; MUST throw on any non-delivery (acceptance (c)). */ + sendRelayFrame(nodeId: string, frame: RelayFrame): void; + /** The plane's own node row (kind + gate), or null when there is none. */ + nodeRow(nodeId: string): Promise<{ kind: NodeKind; sshEnabled: number | null } | null | undefined>; + /** The node's dataDir from its live `ready` facts, or null (not fully connected). */ + nodeDataDir(nodeId: string): string | null; + /** The audit sink (best-effort by contract, never throws). */ + audit(event: AuditEventInput): Promise; + nowMs(): number; + /** Injected scheduler so the 30 s / 5 s windows drive without waiting. */ + schedule(fn: () => void, ms: number): TimerHandle; + cancel(handle: TimerHandle): void; + log(line: string): void; +} + +/** The broker surface: open, route, the named cuts, and the census views. */ +export interface RelayBroker { + /** Broker A+B for one pane under one grant; throws {@link SshRelayRefusal} loudly. */ + openRelay(input: OpenRelayInput): Promise; + /** Route one grammar-parsed relay frame from `nodeId` BLIND to its peer (§5.5). */ + routeRelayFrame(nodeId: string, frame: RelayFrame): void; + /** The single teardown entry: named reason to BOTH sides + the close audit. Idempotent. */ + closeRelay(ref: string, reason: SshRelayCloseReason): Promise; + /** Grant-revoke hook (§6.3; the revoke route lands in T10). Returns sessions closed. */ + closeForGrant(grantId: string, reason: SshRelayCloseReason): Promise; + /** Pane-death hook (the §5.6 child-exit cut; wired at the sweep sites). Returns sessions closed. */ + closeForPane(paneId: string, reason: SshRelayCloseReason): Promise; + /** §5.1's over-cap refusal: close the named ref with reason `over-cap`. */ + refuseOverCap(ref: string): Promise; + /** The `a-dropped` cut, witnessed by the node socket's death. Returns sessions closed. */ + onNodeSocketClosed(nodeId: string): Promise; + /** Live sessions touching this node in EITHER role (the quota's count). */ + activeRelayCount(nodeId: string): number; + /** The observable view of one ref (test/diagnosis); null when nothing is brokered under it. */ + sessionInfo(ref: string): RelaySessionInfo | null; + /** Drop everything (timer-cancelling; daemon-test isolation). */ + reset(): void; +} + +/** One brokered pairing, in memory only (§5.3: durable life is the audit rows). */ +interface Session { + relayId: string; + ref: string; + grantId: string; + paneId: string; + aNode: string; + bNode: string; + expiresAt: number; + socketPath: string | null; + aOpened: boolean; + bOpened: boolean; + lifetimeTimer: TimerHandle; + graceTimer: TimerHandle; +} + +function base64OfJwk(jwkJson: string): string { + // Acceptance (h): base64 of the UTF-8 bytes of the JSON public JWK - the + // spelling the node decodes back into the byte-equal pin string. + return Buffer.from(jwkJson, "utf8").toString("base64"); +} + +function openCmd(input: OpenRelayInput, role: "A" | "B", ref: string, relayId: string): SshRelayOpenCommand { + const peer = role === "A" ? input.bPeer : input.aPeer; // each side pins the PEER, never itself + return { + type: "ssh_relay_open", + relayId, + ref, + role, + aNodeId: input.aNode, + bNodeId: input.bNode, + peerSigningPublicKey: peer.signingPublicKey, + peerEncryptPublicKey: base64OfJwk(peer.encryptionPublicJwk), + grantId: input.grantId, + fingerprints: [...input.fingerprints], + lifetimeMs: SSH_RELAY_LIFETIME_MS, + paneId: input.paneId, + }; +} + +/** Build a broker over the given seams. The module singleton (bottom) is one of these with the real world. */ +export function createRelayBroker(deps: RelayBrokerDeps): RelayBroker { + /** The ONE map: opaque ref -> session (§5.3). A ref nothing brokered is refused, never guessed. */ + const byRef = new Map(); + + const infoOf = (s: Session): RelaySessionInfo => ({ + relayId: s.relayId, + ref: s.ref, + grantId: s.grantId, + paneId: s.paneId, + aNode: s.aNode, + bNode: s.bNode, + expiresAt: s.expiresAt, + socketPath: s.socketPath, + aOpened: s.aOpened, + bOpened: s.bOpened, + }); + + const closeSends = (s: Session, reason: SshRelayCloseReason): Promise[]> => { + // The named close to BOTH sides, best-effort on the transport (the audit + // records the cut whatever the sockets do; a refused close delivery names + // the side in the log and nothing more). + const cmd: SshNodeCommandBody = { type: "ssh_relay_close", ref: s.ref, reason }; + return Promise.allSettled([ + Promise.resolve().then(() => deps.sendCommand(s.aNode, cmd)), + Promise.resolve().then(() => deps.sendCommand(s.bNode, cmd)), + ]); + }; + + const closeRelay = async (ref: string, reason: SshRelayCloseReason): Promise => { + const s = byRef.get(ref); + if (!s) return false; + // Grammar membership is a code-path guard, not paranoia theater: the + // node's parser refuses an unnamed close, and §5.1's posture is that an + // unnamed close is the silence this family refuses. + if (!(SSH_RELAY_CLOSE_REASONS as readonly string[]).includes(reason)) { + deps.log(`relay broker: close refused for ${ref}: reason "${reason}" is not a named reason`); + return false; + } + // Remove FIRST, arm the cut's sends next: a concurrent route/drop must + // never forward onto a session already being torn down. + byRef.delete(ref); + deps.cancel(s.lifetimeTimer); + deps.cancel(s.graceTimer); + await closeSends(s, reason); + await deps.audit({ + actorUserId: null, // the plane brokered it; the human's act is the launch/grant audit + action: "node.ssh_relay.close", + targetType: "node", + targetId: s.aNode, + // ids, hosts, reason - never keys, fingerprints, sockets, or payloads + // (Global Constraints; docs/security.md §10). + metadataJson: JSON.stringify({ + relayId: s.relayId, + ref: s.ref, + grantId: s.grantId, + paneId: s.paneId, + aNodeId: s.aNode, + bNodeId: s.bNode, + reason, + }), + }); + return true; + }; + + const forEach = (match: (s: Session) => boolean): Session[] => [...byRef.values()].filter(match); + + const openRelay = async (input: OpenRelayInput): Promise => { + // Validation FIRST: a malformed input is refused before a row is read, a + // session exists, or a byte is audited. + if (!isSshPaneId(input.paneId)) { + throw new SshRelayRefusal("bad-pane-id", `relay open refused: paneId "${input.paneId}" is not a path id`); + } + if (!isSshGrantFingerprints(input.fingerprints)) { + throw new SshRelayRefusal( + "bad-fingerprints", + "relay open refused: the grant's fingerprint set is malformed or over SSH_MAX_GRANT_FINGERPRINTS", + ); + } + if (input.aNode === input.bNode) { + // A relay bridges two machines; a one-machine pairing means a caller + // that should not have asked at all. + throw new SshRelayRefusal("same-node", "relay open refused: A and B name the same machine"); + } + // (d) the gate: BOTH sides must be switched-on AGENT nodes, fail closed. + // The plane's row is the authoritative half of the double gate (the node + // re-checks its own mirror on delivery; both refusals are this posture). + for (const nodeId of [input.aNode, input.bNode]) { + const row = await deps.nodeRow(nodeId); + if (!row) throw new SshRelayRefusal("no-node", `relay open refused: node "${nodeId}" has no row`, nodeId); + if (row.kind !== "agent") { + throw new SshRelayRefusal("local-node", `relay open refused: "${nodeId}" is not an agent machine`, nodeId); + } + if (row.sshEnabled !== 1) { + throw new SshRelayRefusal("node-off", `relay open refused: SSH is not enabled on "${nodeId}"`, nodeId); + } + } + // The socket-path byte-check needs B's dataDir; without `ready` facts the + // plane cannot verify B's answer, so it refuses rather than trust it. + const bDataDir = deps.nodeDataDir(input.bNode); + if (bDataDir === null) { + throw new SshRelayRefusal( + "no-datadir", + `relay open refused: "${input.bNode}" has not reported its data dir yet`, + input.bNode, + ); + } + // (g) the quota, before ANY state: a 9th live session on either node is + // a loud refusal that touched nothing - no audit, no command, no record. + for (const nodeId of [input.aNode, input.bNode]) { + const live = forEach((s) => s.aNode === nodeId || s.bNode === nodeId).length; + if (live >= SSH_RELAY_MAX_PER_NODE) { + throw new SshRelayRefusal( + "quota", + `relay open refused: node "${nodeId}" already holds ${live} live relay sessions (max ${SSH_RELAY_MAX_PER_NODE})`, + nodeId, + ); + } + } + + const relayId = crypto.randomUUID(); + const ref = crypto.randomUUID(); // opaque pairing key; entropy is minted, never negotiated + const expiresAt = deps.nowMs() + SSH_RELAY_LIFETIME_MS; + const s: Session = { + relayId, + ref, + grantId: input.grantId, + paneId: input.paneId, + aNode: input.aNode, + bNode: input.bNode, + expiresAt, + socketPath: null, + aOpened: false, + bOpened: false, + lifetimeTimer: deps.schedule(() => { + void closeRelay(ref, "lifetime-expiry").catch((err: unknown) => deps.log(`relay close threw: ${String(err)}`)); + }, SSH_RELAY_LIFETIME_MS), + graceTimer: deps.schedule(() => { + if (s.aOpened && s.bOpened) return; + void closeRelay(ref, "handshake-grace").catch((err: unknown) => deps.log(`relay close threw: ${String(err)}`)); + }, SSH_RELAY_TEARDOWN_GRACE_MS), + }; + byRef.set(ref, s); + await deps.audit({ + actorUserId: null, + action: "node.ssh_relay.open", + targetType: "node", + targetId: input.aNode, + metadataJson: JSON.stringify({ + relayId, + ref, + grantId: input.grantId, + paneId: input.paneId, + aNodeId: input.aNode, + bNodeId: input.bNode, + fingerprintCount: input.fingerprints.length, // the COUNT, never the set + lifetimeMs: SSH_RELAY_LIFETIME_MS, + }), + }); + + // Deliver the pairing. Both opens go out now; the acks complete the + // handshake the grace timer watches. The open command's transport + // deadline is the grace itself - past that window the session is already + // cut, so waiting longer only delays the refusal the human reads. + const expectedSocketPath = buildAgentSocketPath(bDataDir, input.paneId); + const [resA, resB] = await Promise.allSettled([ + deps.sendCommand(input.aNode, openCmd(input, "A", ref, relayId), { timeoutMs: SSH_RELAY_TEARDOWN_GRACE_MS }), + deps.sendCommand(input.bNode, openCmd(input, "B", ref, relayId), { timeoutMs: SSH_RELAY_TEARDOWN_GRACE_MS }), + ]); + if (resA.status === "fulfilled") s.aOpened = true; + if (resB.status === "fulfilled") s.bOpened = true; + + if (resB.status === "fulfilled") { + // (e): B answers the socket it bound. The plane re-derives the path and + // refuses a byte mismatch - a machine answer never names the path the + // pane's ssh gets pointed at. + const ack = resB.value as { socketPath?: unknown }; + if (typeof ack?.socketPath !== "string" || ack.socketPath !== expectedSocketPath) { + await closeRelay(ref, "handshake-grace"); + throw new SshRelayRefusal( + "bad-socket-path", + `relay open refused: "${input.bNode}" answered a socket path the plane did not derive`, + input.bNode, + ); + } + s.socketPath = ack.socketPath; + } + + if (!(s.aOpened && s.bOpened) || !byRef.has(ref)) { + // Either side refused (pin moved, mirror off, offline, timeout) or the + // grace already cut the pairing: the handshake never completed. Close + // under the handshake word (idempotent if the grace already did) - the + // grammar names no "refused-open", and §5.6's window cut is the honest + // bucket for a pairing that did not come up on both ends. + await closeRelay(ref, "handshake-grace"); + const why = + resA.status === "rejected" + ? `A refused or did not answer: ${String(resA.reason)}` + : resB.status === "rejected" + ? `B refused or did not answer: ${String(resB.reason)}` + : "the handshake window closed before both sides answered"; + throw new SshRelayRefusal("handshake", `relay open refused: ${why}`); + } + + // Both sides are live: the grace is no longer owed (the lifetime cap stays + // as §5.6's ceiling). + deps.cancel(s.graceTimer); + return { relayId, ref, expiresAt: new Date(expiresAt).toISOString(), socketPath: s.socketPath as string }; + }; + + const routeRelayFrame = (nodeId: string, frame: RelayFrame): void => { + // §5.5 in one line's worth of reading: look up the ref, find the peer, + // copy the frame. The blob never comes anywhere near this function's + // comprehension - it is passed by reference and that IS the forwarding. + const s = byRef.get(frame.ref); + if (!s) { + // A stale ref (the session was cut around the plane's own close) or a + // stranger pointing at someone's pairing: refused, logged by ref, and + // NOTHING else - refs are opaque ids, not secrets, and naming one is + // how an operator correlates the two machines' logs. + deps.log(`relay frame for unbrokered ref ${frame.ref}: refused`); + return; + } + const fromA = s.aNode === nodeId; + if (!fromA && s.bNode !== nodeId) { + deps.log(`relay frame for ref ${frame.ref} from non-party ${nodeId}: refused`); + return; + } + const peer = fromA ? s.bNode : s.aNode; + try { + deps.sendRelayFrame(peer, frame); + } catch (err) { + deps.log(`relay frame for ref ${frame.ref} could not reach ${peer}: ${String(err)}`); + if (fromA) { + // A's reply cannot reach B: the grammar has no b-dropped word, §5.6 + // does not list B's link as a cut, and T7's handoff is explicit that + // the caps own the end of a B-side stall. Lose this frame; the + // lifetime, child exit, or revoke cuts the session. + return; + } + // B's request cannot reach A: THAT is §5.6's A-dropping (and the + // phantom-request hang acceptance (c) exists to kill). Cut now with the + // named reason; B is still reachable to hear it. + void closeRelay(frame.ref, "a-dropped").catch((err2: unknown) => deps.log(`relay close threw: ${String(err2)}`)); + } + }; + + return { + openRelay, + routeRelayFrame, + closeRelay, + async closeForGrant(grantId, reason) { + let n = 0; + for (const s of forEach((x) => x.grantId === grantId)) if (await closeRelay(s.ref, reason)) n += 1; + return n; + }, + async closeForPane(paneId, reason) { + let n = 0; + for (const s of forEach((x) => x.paneId === paneId)) if (await closeRelay(s.ref, reason)) n += 1; + return n; + }, + refuseOverCap: (ref) => closeRelay(ref, "over-cap"), + async onNodeSocketClosed(nodeId) { + // ONLY the A-side cut (§5.6: "A-drop ... as earlier cuts"). B's link + // may redial with its registry intact; B's real end is the child-exit + // sweep or a cap, not this witness. + let n = 0; + for (const s of forEach((x) => x.aNode === nodeId)) if (await closeRelay(s.ref, "a-dropped")) n += 1; + return n; + }, + activeRelayCount(nodeId) { + return forEach((s) => s.aNode === nodeId || s.bNode === nodeId).length; + }, + sessionInfo(ref) { + const s = byRef.get(ref); + return s ? infoOf(s) : null; + }, + reset() { + for (const s of byRef.values()) { + deps.cancel(s.lifetimeTimer); + deps.cancel(s.graceTimer); + } + byRef.clear(); + }, + }; +} + +/* ------------------------------------------------------------------ */ +/* the real per-node pump (acceptance (c)): deliver, or THROW */ +/* ------------------------------------------------------------------ */ + +/** + * Write one relay frame to `nodeId`'s CURRENT live socket, sealed through + * its established link (a relay frame is never plaintext: the version gate + * guarantees any node that carries one speaks protocol 18, whose sockets are + * handshake-classified). Throws {@link RelaySendError} on every non-delivery: + * no live connection, a closing/superseded record, an unestablished link, or + * a failing write. The broker's teardown and the endpoints' deliver-or-throw + * contract both read the throw; a silent return is the phantom-hang bug. + */ +export function sendRelayFrameOverNodeSocket(nodeId: string, frame: RelayFrame): void { + const conn = getLive(nodeId); + if (!conn) throw new RelaySendError(nodeId, "node has no live connection"); + if (conn.closing) throw new RelaySendError(nodeId, "connection is closing (superseded or revoked)"); + if (!conn.link) throw new RelaySendError(nodeId, "encrypted link is not established"); + const payload = JSON.stringify(frame); + if (Buffer.byteLength(payload) > NODE_MAX_FRAME_BYTES) { + throw new RelaySendError(nodeId, `frame exceeds ${NODE_MAX_FRAME_BYTES} bytes`); + } + try { + conn.ws.send(binaryPayload(conn.link.sealFrame(payload))); + } catch (err) { + throw new RelaySendError(nodeId, `socket send failed: ${err instanceof Error ? err.message : String(err)}`); + } +} + +/* ------------------------------------------------------------------ */ +/* the pane-scoped env (acceptance (e)) + the death sweeps */ +/* ------------------------------------------------------------------ */ + +/** + * The pane env the relay launch exports: EXACTLY the scoped `SSH_AUTH_SOCK` + * exception, the one key the `ssh` invocation gets through `createSubshell`'s + * `extraPaneEnv` member - the same channel `composeSshLaunch` uses for the + * snapshot's agent socket (spec 2026-10-07 §5.2, decision 3). Never merged + * into the whole-pane env (Global Constraints: the agent-capable socket + * belongs to the connecting process, not the pane's shell). + */ +export function sshRelayPaneEnv(socketPath: string): { SSH_AUTH_SOCK: string } { + return { SSH_AUTH_SOCK: socketPath }; +} + +/** + * The §5.6 child-exit cut for one pane, best-effort by shape (the + * `sweepLocalSshDir` doctrine restated for relay sessions): every site where + * a pane stops existing calls it, the broker's map lookup no-ops the + * overwhelmingly common "this pane never relayed" case, and a failure costs + * one log line. The reason is `child-exit` for every stop path: the pane's + * process is gone, which is what the word means to the endpoints. + */ +export function closeRelayForPaneExit(paneId: string | null | undefined): void { + if (!paneId) return; + try { + void getRelayBroker() + .closeForPane(paneId, "child-exit") + .catch((err: unknown) => logger.warn(`relay sweep for pane ${paneId} failed: ${String(err)}`)); + } catch (err) { + logger.withError(err).warn(`relay sweep for pane ${paneId} threw`); + } +} + +/** Row-shaped twin of {@link closeRelayForPaneExit} for the sites that hold a row. */ +export function sweepRelayForPane(row: { id: string } | null | undefined): void { + closeRelayForPaneExit(row?.id); +} + +/* ------------------------------------------------------------------ */ +/* production wiring + module singleton */ +/* ------------------------------------------------------------------ */ + +let defaultDeps: RelayBrokerDeps | undefined; + +function productionDeps(): RelayBrokerDeps { + defaultDeps ??= { + sendCommand: (nodeId, cmd, opts) => sendCommand(nodeId, cmd, opts ?? {}), + sendRelayFrame: sendRelayFrameOverNodeSocket, + async nodeRow(nodeId) { + const row = await getRequestlessContext().repos.nodes.findById(nodeId); + return row ? { kind: row.kind, sshEnabled: row.sshEnabled } : null; + }, + nodeDataDir: (nodeId) => getLive(nodeId)?.agent?.dataDir ?? null, + audit, + nowMs: () => Date.now(), + schedule: (fn, ms) => { + const t = setTimeout(fn, ms); + t.unref?.(); // a brokered relay must never hold the process (or a test runner) open + return t as unknown as TimerHandle; + }, + cancel: (handle) => clearTimeout(handle as ReturnType), + log: (line) => logger.debug(line), + }; + return defaultDeps; +} + +let singleton: RelayBroker | undefined; + +/** The broker the handler routing and the pane-death sweeps use (lazy, like `getNodeWsDeps`). */ +export function getRelayBroker(): RelayBroker { + singleton ??= createRelayBroker(productionDeps()); + return singleton; +} + +/** + * Install (or with null, drop) the module singleton. + * @internal test-only - relay-frames routing and the sweep helpers reach the + * broker through it, and a suite with injected seams must be able to point + * those two entry points at a recorder. + */ +export function setRelayBrokerForTests(broker: RelayBroker | null): void { + singleton = broker ?? undefined; +} From a1f43c385915915ddbb977fa711ccc05bd1d52e2 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:50:35 -0700 Subject: [PATCH 35/92] feat(ssh-relay): route the handler seam and every pane death through the broker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit relay-frames' Task-8 body: onRelayFrame hands the frame object to the broker's blind route and keeps only a blob-free per-node routing summary (count, last ref, last direction - no slot could hold a byte, §5.5); the over-cap seam refuses BY NAME through the broker; a throwing broker is contained so one refused send never costs the socket its frame chain. handleNodeClose's live-socket branch is the a-dropped witness (a superseded close tells the broker nothing). The §5.6 child-exit cut rides every site the local ssh sweep already knows - report, terminate, the maintenance kill, the death transition, the create rollback, the reconcile fast path, and delete - through closeRelayForPaneExit, idempotent and best-effort like the dir sweep beside it. --- .../subshells-exit-ssh-sweep.test.ts | 39 +++++ .../subshells-terminate-ssh-sweep.test.ts | 36 ++++ .../nodes/__tests__/node-ws-handler.test.ts | 54 +++++- .../nodes/__tests__/relay-frames.test.ts | 128 +++++++++++--- .../api/src/services/nodes/node-ws-handler.ts | 10 +- .../api/src/services/nodes/relay-frames.ts | 160 ++++++++++-------- .../src/services/subshell-manager.service.ts | 11 ++ .../api/src/services/subshells.service.ts | 3 + 8 files changed, 343 insertions(+), 98 deletions(-) diff --git a/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts b/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts index 220a8fae6..876417e2f 100644 --- a/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts +++ b/apps/server/api/src/services/__tests__/subshells-exit-ssh-sweep.test.ts @@ -8,6 +8,7 @@ import { runMigrations } from "@/db/migrate.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import { getRequestlessContext } from "@/lib/context.js"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; import { SubshellsService } from "@/services/subshells.service.js"; import { getLogger } from "@/utils/logger.js"; @@ -115,4 +116,42 @@ describe("the exit report sweeps the LOCAL ssh config dir", () => { await expect(service.reportExit(id, 0)).resolves.toBeUndefined(); await expect(service.reportExit(id, 0)).resolves.toBeUndefined(); }); + + it("(M2 §5.6 child-exit) the report also witnesses the pane's death to the relay broker", async () => { + // The relay cut is keyed by the PANE, so it fires for every row flavor + // (local or agent, ssh or not - the broker's map lookup no-ops the rows + // that never brokered anything). A spy broker observes the witness. + const cuts: { paneId: string; reason: string }[] = []; + const spy = { + openRelay: async () => { + throw new Error("unused"); + }, + routeRelayFrame: () => {}, + closeRelay: async () => false, + closeForGrant: async () => 0, + closeForPane: async (paneId: string, reason: string) => { + cuts.push({ paneId, reason }); + return 0; + }, + refuseOverCap: async () => false, + onNodeSocketClosed: async () => 0, + activeRelayCount: () => 0, + sessionInfo: () => null, + reset: () => {}, + }; + setRelayBrokerForTests(spy as unknown as RelayBroker); + try { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-ssh", ssh: '{"host":"example.test"}' }); + await service.reportExit(id, 0); + await new Promise((resolve) => setTimeout(resolve, 0)); // the sweep is fire-and-forget + // The report's own witness AND the manager's shared death transition + // both speak the same word (the dir sweep's redundancy, restated): + // closeForPane is idempotent, and at least one sure thing is the bar. + expect(cuts.length).toBeGreaterThanOrEqual(1); + for (const cut of cuts) expect(cut).toEqual({ paneId: id, reason: "child-exit" }); + } finally { + setRelayBrokerForTests(null); + } + }); }); diff --git a/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts b/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts index c79f85832..d5aaa39d8 100644 --- a/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts +++ b/apps/server/api/src/services/__tests__/subshells-terminate-ssh-sweep.test.ts @@ -9,6 +9,7 @@ import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import { getRequestlessContext } from "@/lib/context.js"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; import { SubshellManagerService } from "@/services/subshell-manager.service.js"; import { SubshellsService } from "@/services/subshells.service.js"; import { getLogger } from "@/utils/logger.js"; @@ -100,6 +101,41 @@ describe("the terminate verb sweeps the LOCAL ssh config dir", () => { expect(existsSync(localDir(id))).toBe(true); rmSync(localDir(id), { recursive: true, force: true }); }); + + it("(M2 §5.6 child-exit) terminate also cuts any relay session brokered for the pane", async () => { + // The kill hand is the sure thing: a brokered B-side relay must die with + // the pane even when the node's dying report never lands. The broker's + // pane lookup no-ops rows that never relayed, so this witness fires for + // every terminate. + const cuts: string[] = []; + const spy = { + openRelay: async () => { + throw new Error("unused"); + }, + routeRelayFrame: () => {}, + closeRelay: async () => false, + closeForGrant: async () => 0, + closeForPane: async (paneId: string, reason: string) => { + cuts.push(`${paneId}:${reason}`); + return 0; + }, + refuseOverCap: async () => false, + onNodeSocketClosed: async () => 0, + activeRelayCount: () => 0, + sessionInfo: () => null, + reset: () => {}, + }; + setRelayBrokerForTests(spy as unknown as RelayBroker); + try { + const id = crypto.randomUUID(); + await seedPaneRow({ id, nodeId: "node-remote-term", ssh: '{"host":"example.test"}' }); + expect(await service.terminateSubshell(OWNER, id, "cookie")).toEqual({ ok: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); // the sweep is fire-and-forget + expect(cuts).toEqual([`${id}:child-exit`]); + } finally { + setRelayBrokerForTests(null); + } + }); }); /** diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index be5cb0218..bcf725ee7 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -22,6 +22,7 @@ import { type NodeReadyReport, NodesRepository } from "@/db/repositories/nodes.r import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import type { NodeKind, NodeTable } from "@/db/types/nodes.db-types.js"; import { subscribeLive } from "@/services/live-bus.js"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; import { resetInputHoldsForTests } from "@/ws/input-hold.js"; import { resetInputWindowsForTests } from "@/ws/input-window.js"; import { handleSubshellMessage } from "@/ws/subshell-ws.js"; @@ -1394,6 +1395,47 @@ describe("handleNodeClose (superseded-close hygiene, spec §5.3)", () => { await handleNodeClose(h.deps, fakeSocket("n1")); expect(h.statuses).toEqual([]); }); + + it("(Task 8, spec 2026-10-08 §5.6) the LIVE socket's death is witnessed by the relay broker; a superseded one is not", async () => { + // The `a-dropped` cut's ONLY trigger is this witness: the handler owns + // "the authenticated link is gone", the broker owns "which sessions that + // kills". A superseded close means the node is NOT gone - its newer + // socket still answers - so the witness must fire once, for the real + // death only. + const seen: string[] = []; + const broker: RelayBroker = { + openRelay: async () => { + throw new Error("unused"); + }, + routeRelayFrame: () => {}, + closeRelay: async () => false, + closeForGrant: async () => 0, + closeForPane: async () => 0, + refuseOverCap: async () => false, + onNodeSocketClosed: async (nodeId) => { + seen.push(nodeId); + return 0; + }, + activeRelayCount: () => 0, + sessionInfo: () => null, + reset: () => {}, + }; + setRelayBrokerForTests(broker); + try { + const h = makeHarness(); + const first = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, first); + const second = fakeSocket("n1"); // newest-wins: the first is superseded + handleNodeOpen(OPEN_DEPS, second); + await handleNodeClose(h.deps, first); + expect(seen).toEqual([]); // a superseded close tells the broker NOTHING + await handleNodeClose(h.deps, second); + expect(seen).toEqual(["n1"]); // the real death is reported exactly once + await new Promise((resolve) => setTimeout(resolve, 0)); // drain the fire-and-forget + } finally { + setRelayBrokerForTests(null); + } + }); }); /** @@ -1788,16 +1830,18 @@ describe("handleNodeMessage through the link machine (spec 2026-09-24 §4/§5/§ await handleNodeMessage(h.deps, ws, bindingBytes(f)); // established at 18 const relayFrame: RelayFrame = { type: "relay", ref: "r-1", seq: 3, direction: "A2B", blob: "QUJD" }; await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify(relayFrame))); - // The seam saw EXACTLY the frame, blob included - and nothing else did: - // the plane is a blind router (§5.5), the stub only records. - expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); + // The frame reached the broker (Task 8's route: pair by ref, copy the + // blob unopened). The seam's own record is a routing SUMMARY - the plane + // is a blind router (§5.5) and the summary has no slot to hold a blob. + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, lastRef: "r-1", lastDirection: "A2B" }); + expect(JSON.stringify(relayFramesSeenFor("n1"))).not.toContain("QUJD"); // The over-cap blob is refused by the grammar upstream of the ROUTER seam // (§5.1: cap is law); the socket survives, the frame is not forwarded. // But §5.1's named-reason close needs the refusal to be OBSERVABLE, and // the handler's over-cap probe is what routes it - ref only, no blob. const over = "A".repeat(Math.ceil(SSH_RELAY_FRAME_MAX_BYTES / 3) * 4 + 4); await handleNodeMessage(h.deps, ws, client.session.sealFrame(JSON.stringify({ ...relayFrame, blob: over }))); - expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: relayFrame }); // router seam: NOT the normal route + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, lastRef: "r-1", lastDirection: "A2B" }); // router seam: NOT the normal route expect(relayOverCapsSeenFor("n1")).toEqual({ overCaps: 1, lastRef: "r-1" }); // over-cap seam, ref carried // The blob never crossed: the record has no byte slot, and the only // bytes the seam could know are the ref (§5.5 blindness end to end). @@ -1827,7 +1871,7 @@ describe("handleNodeMessage through the link machine (spec 2026-09-24 §4/§5/§ // And a well-formed UNDER-cap frame goes the other way: routed, not refused. const goodFrame: RelayFrame = { type: "relay", ref: "r-11", seq: 0, direction: "A2B", blob: "QUJD" }; await handleNodeMessage(h.deps, ws, JSON.stringify(goodFrame)); - expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, last: goodFrame }); + expect(relayFramesSeenFor("n1")).toEqual({ frames: 1, lastRef: "r-11", lastDirection: "A2B" }); expect(relayOverCapsSeenFor("n1")).toMatchObject({ overCaps: 2 }); // still just the two expect(ws.closed).toHaveLength(0); }); diff --git a/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts index fe971f092..2d69dce76 100644 --- a/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; import type { RelayFrame } from "@internal/subshell-protocol"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; import { + onNodeSocketClosed, onRelayFrame, onRelayFrameOverCap, relayFramesSeenFor, @@ -9,12 +11,13 @@ import { } from "../relay-frames.js"; /** - * The Task-4 seam (spec 2026-10-08 §5.1): validation happened upstream - * (parseRelayFrame via the handler's parse), the held/supersede guards - * upstream of that, and the version gate upstream of everything. What this - * file pins is the stub's CONTRACT that Task 8's broker inherits: route in, - * recorded verbatim out, blob never touched. The end-to-end routing (which - * socket a frame reaches only after a brokered open) is Task 8's suite. + * The Task-8 seam (spec 2026-10-08 §5.1/§5.3/§5.5): validation happened + * upstream (parseRelayFrame via the handler's parse), the held/supersede + * guards upstream of that, and the version gate upstream of everything. This + * file pins the SEAM's own contract now that the broker is real: every entry + * point reaches the broker, the per-node record is a routing SUMMARY with no + * blob slot anywhere (§5.5), a throwing broker never escapes the seam, and + * the test reset keeps suites isolated under `bun test --parallel`. */ const frame: RelayFrame = { @@ -25,47 +28,105 @@ const frame: RelayFrame = { blob: "QUJDk5+ToQ==", }; -describe("onRelayFrame (the pre-broker stub)", () => { - it("records a routed frame per node, verbatim and blob-intact", () => { +/** A recording broker: the seam's three verbs, counted, with an optional throw. */ +function spyBroker(overrides: Partial = {}) { + const calls: { route: [string, RelayFrame][]; overCap: string[]; socketClosed: string[] } = { + route: [], + overCap: [], + socketClosed: [], + }; + const noop = (): RelayBroker["closeRelay"] => async () => false; + const broker: RelayBroker = { + openRelay: async () => { + throw new Error("seam test must not open"); + }, + routeRelayFrame: (nodeId, f) => { + calls.route.push([nodeId, f]); + }, + closeRelay: noop(), + closeForGrant: async () => 0, + closeForPane: async () => 0, + refuseOverCap: async (ref) => { + calls.overCap.push(ref); + return false; + }, + onNodeSocketClosed: async (nodeId) => { + calls.socketClosed.push(nodeId); + return 0; + }, + activeRelayCount: () => 0, + sessionInfo: () => null, + reset: () => {}, + ...overrides, + }; + return { broker, calls }; +} + +afterEach(() => { + setRelayBrokerForTests(null); + resetRelayFramesForTests(); +}); + +describe("onRelayFrame (the brokered seam, Task 8)", () => { + it("hands the broker the EXACT frame object and records a blob-free summary", () => { + const { broker, calls } = spyBroker(); + setRelayBrokerForTests(broker); resetRelayFramesForTests(); onRelayFrame("n-a", frame); - expect(relayFramesSeenFor("n-a")).toEqual({ frames: 1, last: frame }); - // The stub is not a reader: the stored frame is the SAME shape that went - // in, base64 kept as the opaque string §5.5 demands, decoded nowhere. - expect(relayFramesSeenFor("n-a")?.last.blob).toBe(frame.blob); + expect(calls.route).toHaveLength(1); + expect(calls.route[0]?.[0]).toBe("n-a"); + expect(calls.route[0]?.[1]).toBe(frame); // by reference: the blind hand-off + const seen = relayFramesSeenFor("n-a"); + expect(seen).toEqual({ frames: 1, lastRef: "r-4f2a", lastDirection: "B2A" }); + // §5.5, structurally: the record has no blob slot at all - the seam + // could not hold the bytes even if a test asked it to. + expect(Object.keys(seen ?? {})).toEqual(["frames", "lastRef", "lastDirection"]); + expect(JSON.stringify(seen)).not.toContain(frame.blob); + expect(JSON.stringify(seen)).not.toContain("QUJD"); }); - it("counts per node and keeps only the newest frame", () => { + it("counts per node and keeps only the newest routing facts", () => { + const { broker } = spyBroker(); + setRelayBrokerForTests(broker); resetRelayFramesForTests(); onRelayFrame("n-b", frame); - onRelayFrame("n-b", { ...frame, seq: 1, direction: "A2B" }); - expect(relayFramesSeenFor("n-b")).toEqual({ frames: 2, last: { ...frame, seq: 1, direction: "A2B" } }); + onRelayFrame("n-b", { ...frame, ref: "r-5", seq: 1, direction: "A2B" }); + expect(relayFramesSeenFor("n-b")).toEqual({ frames: 2, lastRef: "r-5", lastDirection: "A2B" }); // Per-node isolation: A's traffic is not B's record. expect(relayFramesSeenFor("n-a")).toBeUndefined(); }); - it("reset clears the registry (the --parallel fresh-registry posture)", () => { - onRelayFrame("n-c", frame); + it("a throwing broker is CONTAINED (the handler's frame chain survives)", () => { + const { broker } = spyBroker({ + routeRelayFrame: () => { + throw new Error("broker exploded"); + }, + }); + setRelayBrokerForTests(broker); resetRelayFramesForTests(); - expect(relayFramesSeenFor("n-c")).toBeUndefined(); + expect(() => onRelayFrame("n-c", frame)).not.toThrow(); + expect(relayFramesSeenFor("n-c")).toEqual({ frames: 1, lastRef: "r-4f2a", lastDirection: "B2A" }); }); }); -describe("onRelayFrameOverCap (the pre-broker over-cap stub, Task 4 review)", () => { - it("counts refusals per node and keeps ONLY the ref; the blob never comes", () => { +describe("onRelayFrameOverCap (the named refusal, §5.1)", () => { + it("refuses BY NAME through the broker and records ONLY the ref", async () => { + const { broker, calls } = spyBroker(); + setRelayBrokerForTests(broker); resetRelayFramesForTests(); onRelayFrameOverCap("n-d", "r-1"); - expect(relayOverCapsSeenFor("n-d")).toEqual({ overCaps: 1, lastRef: "r-1" }); onRelayFrameOverCap("n-d", "r-2"); + await Promise.resolve(); + expect(calls.overCap).toEqual(["r-1", "r-2"]); expect(relayOverCapsSeenFor("n-d")).toEqual({ overCaps: 2, lastRef: "r-2" }); - // Per-node isolation, same posture as the routed stub. + // Per-node isolation, same posture as the routed summary. expect(relayOverCapsSeenFor("n-e")).toBeUndefined(); - // The record has no blob slot at all: an over-cap frame is refused - // unread, and the stub could not store the bytes even if it wanted to. expect(Object.keys(relayOverCapsSeenFor("n-d") ?? {})).toEqual(["overCaps", "lastRef"]); }); - it("the two seams are independent, and reset clears both", () => { + it("the seams are independent, and reset clears them", () => { + const { broker } = spyBroker(); + setRelayBrokerForTests(broker); resetRelayFramesForTests(); onRelayFrameOverCap("n-f", "r-9"); expect(relayFramesSeenFor("n-f")).toBeUndefined(); // a refusal is not a routed frame @@ -76,3 +137,18 @@ describe("onRelayFrameOverCap (the pre-broker over-cap stub, Task 4 review)", () expect(relayFramesSeenFor("n-f")).toBeUndefined(); }); }); + +describe("onNodeSocketClosed (the a-dropped witness, §5.6)", () => { + it("reports the dead node to the broker and never throws", async () => { + const { broker, calls } = spyBroker(); + setRelayBrokerForTests(broker); + onNodeSocketClosed("n-g"); + await Promise.resolve(); + expect(calls.socketClosed).toEqual(["n-g"]); + const { broker: bad } = spyBroker({ + onNodeSocketClosed: () => Promise.reject(new Error("broker rejected the witness")), + }); + setRelayBrokerForTests(bad); + expect(() => onNodeSocketClosed("n-h")).not.toThrow(); + }); +}); diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index a8cb5911f..1fc2ee210 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -48,7 +48,7 @@ import { releaseHeld, } from "./node-registry.js"; import { binaryPayload, failConnPendings, resolveResult } from "./node-rpc.js"; -import { onRelayFrame, onRelayFrameOverCap } from "./relay-frames.js"; +import { onNodeSocketClosed, onRelayFrame, onRelayFrameOverCap } from "./relay-frames.js"; import { recordNodeDisconnect, recordNodeReady } from "./update-tracker.js"; /** @@ -1166,6 +1166,14 @@ export async function handleNodeClose(deps: NodeWsDeps, ws: NodeWsSocket): Promi // touched any of them — so without this the dashboard keeps rendering // them as healthy until the viewer reconnects. announceNodePresence(nodeId); + // The relay broker's `a-dropped` witness (spec 2026-10-08 §5.6): this is + // the honest "this node's authenticated link is gone" moment - the + // broker cuts sessions where this machine was A and leaves its B-side + // sessions for the redial (or a child-exit / cap cut). The superseded + // branch below deliberately calls nothing: a replacement socket means + // the node is NOT gone. Fire-and-forget by contract (relay-frames owns + // the swallow). + onNodeSocketClosed(nodeId); logger.debug(`node ws: ${nodeId} disconnected → offline`); return; } diff --git a/apps/server/api/src/services/nodes/relay-frames.ts b/apps/server/api/src/services/nodes/relay-frames.ts index 48d636c56..edfd398dc 100644 --- a/apps/server/api/src/services/nodes/relay-frames.ts +++ b/apps/server/api/src/services/nodes/relay-frames.ts @@ -1,87 +1,101 @@ import type { RelayFrame } from "@internal/subshell-protocol"; +import { getRelayBroker } from "@/services/ssh-relay.service.js"; +import { logger } from "@/utils/logger.js"; /** * The plane-side recognition seam for `relay` link frames (spec 2026-10-08 * §5.1/§5.3) - the single named point between "a decrypted, grammar-validated - * relay frame arrived on an authenticated node socket" and whoever brokers - * it. `node-ws-handler.ts` routes here from its event switch; nothing else - * calls in, and nothing here calls outward. + * relay frame arrived on an authenticated node socket" and the broker that + * pairs the sockets. `node-ws-handler.ts` routes here from its event switch + * and from its close teardown; nothing else calls in. * - * **This is deliberately a stub.** Task 8 (the plane relay broker) replaces - * the body of {@link onRelayFrame} with the real broker. What is ALREADY - * decided before a frame reaches this file, and must not be re-decided here: - * the socket is authenticated (the upgrade chain), the link is encrypted and - * version-matched at NODE_PROTOCOL_VERSION (the handshake - which is why a - * pre-18 agent never gets a relay frame routed at all), the frame is - * well-formed and within SSH_RELAY_FRAME_MAX_BYTES (`parseRelayFrame`, via - * `parseNodeEvent`), the node is not held, and the socket is not superseded - * (both guards run upstream in the handler). + * What was ALREADY decided before a frame reaches this file, and is not + * re-decided here: the socket is authenticated (the upgrade chain), the link + * is encrypted and version-matched at NODE_PROTOCOL_VERSION (the handshake - + * which is why a pre-18 agent never gets a relay frame routed at all), the + * frame is well-formed and within SSH_RELAY_FRAME_MAX_BYTES (`parseRelayFrame`, + * via `parseNodeEvent`), the node is not held, and the socket is not + * superseded (all guards run upstream in the handler). * - * What Task 8's broker owns, and NOTHING here does: - * - pair the two live sockets by the frame's routing `ref` against the relay - * sessions their `ssh_relay_open` commands established (§5.1); - * - forward `blob` to the peer socket BLIND - the plane reads the ref and - * nothing else (§5.5; an unopened byte copy is the whole job); - * - enforce SSH_RELAY_MAX_PER_NODE at session-open time (§5.3's loud refusal); - * - tear down on every §5.6 cut (grace elapsed, child exit, lifetime expiry, - * A dropping, grant revoke) with the named-reason close and the - * `node.ssh_relay.close` audit. - * A second entry point, {@link onRelayFrameOverCap}, carries the refusal - * this grammar performs but cannot express in a `NodeEvent` (an over-cap - * blob) so §5.1's named-reason close has something to be ABOUT. + * The Task-8 broker (`services/ssh-relay.service.ts`) owns everything past + * this door: it pairs the two live sockets by the frame's routing `ref`, + * forwards `blob` to the peer BLIND (the plane reads the ref and nothing + * else, §5.5), enforces the quota at open, and cuts on every §5.6 event with + * the named-reason close and the `node.ssh_relay.close` audit. This module + * adds only the per-node routing SUMMARY - frame count, last ref, last + * direction - so the handler's wiring tests can see that a routed frame + * reached the broker without this seam itself holding a byte of any blob + * (§5.5: the summary's record deliberately has no blob slot). * - * The stub records what arrived per node so the wiring is testable today - * without the broker being real, and so the day it lands, the tests that - * assert "a routed frame reaches the seam" do not move. + * {@link onRelayFrameOverCap} is the second entry point: the refusal the + * grammar performed (an over-cap blob answers `parseRelayFrame` with null) + * but cannot express in a `NodeEvent`; the handler's shape probe routes the + * named case here and the broker closes the session it names with reason + * `over-cap`. The blob never comes along - the probe reads its raw length + * and nothing else. + * + * {@link onNodeSocketClosed} is the third: when an authenticated live socket + * dies, the broker cuts the sessions where that node was A (§5.6's + * `a-dropped`); a node's B-side link redialing is not a cut, so the broker + * decides by role itself and this seam just reports the witness. + * + * Routing is FIRE-AND-FORGET by contract: the handler's frame chain must not + * die because a broker send refused - the broker already handled the drop + * (its teardown IS the response to a refused send), and a throw here would + * cost the socket every frame behind this one. */ -/** What the stub holds for one node until Task 8's broker replaces it. */ -export interface RelayFrameStubRecord { +/** What the seam records for one node's routed frames: counters and routing ids, never payloads. */ +export interface RelayFrameRouteRecord { /** How many relay frames have been routed from this node. */ frames: number; - /** The most recent frame, kept verbatim - blob included, still opaque. */ - last: RelayFrame; + /** The routing ref of the most recent routed frame (an opaque id, not a secret). */ + lastRef: string; + /** The direction of the most recent routed frame. */ + lastDirection: RelayFrame["direction"]; } -/** What the over-cap stub holds for one node until Task 8 replaces it. */ -export interface RelayOverCapStubRecord { +/** What the seam holds for one node's over-cap refusals. */ +export interface RelayOverCapRecord { /** How many over-cap relay frames have been refused from this node. */ overCaps: number; /** The routing ref of the most recent refusal. NEVER the blob: an over-cap frame is refused unread (§5.1, §5.5). */ lastRef: string; } -const seenByNode = new Map(); -const overCapByNode = new Map(); +const routedByNode = new Map(); +const overCapByNode = new Map(); /** - * Route one validated relay frame from `nodeId`. Today: record it. Task 8: - * replace with the blind pair-and-forward of §5.1/§5.3/§5.5. + * Route one validated relay frame from `nodeId` to the broker. Records the + * per-node routing summary first (the handler tests' observability), then + * hands the frame object to the broker unchanged. * @param nodeId - the node whose authenticated socket produced the frame * @param frame - the grammar-validated frame (`parseRelayFrame` narrowed it) */ export function onRelayFrame(nodeId: string, frame: RelayFrame): void { - const record = seenByNode.get(nodeId); + const record = routedByNode.get(nodeId); if (record) { record.frames += 1; - record.last = frame; + record.lastRef = frame.ref; + record.lastDirection = frame.direction; } else { - seenByNode.set(nodeId, { frames: 1, last: frame }); + routedByNode.set(nodeId, { frames: 1, lastRef: frame.ref, lastDirection: frame.direction }); + } + try { + // Blind hand-off: the broker pairs by `ref` and copies `blob` to the + // peer's socket unopened (§5.5). A refused send (peer socket gone) is + // the broker's named cut to perform, not an exception to propagate. + getRelayBroker().routeRelayFrame(nodeId, frame); + } catch (err: unknown) { + logger.withError(err).warn(`relay routing for node ${nodeId} threw (contained): ${String(err)}`); } - // TODO(Task 8): pair by `frame.ref` against the relay session this - // pairing's ssh_relay_open established, forward the blob unopened to the - // peer's socket, and close-with-named-reason on every §5.6 cut. A frame - // with no brokered session behind it is refused there, with a reason. } /** - * Refuse an over-cap relay frame BY NAME (spec 2026-10-08 §5.1). This is the - * observability half of the refusal the strict grammar already performed: - * `parseRelayFrame` answers an over-cap blob with null (so the `NodeEvent` - * union stays safe) and `node-ws-handler.ts` probes the shape with - * `relayFrameRefIfOverCap` and routes the named case here instead of the - * silent "unrecognized" drop. The blob never comes along - the probe reads - * its raw length and nothing else (§5.5). + * Refuse an over-cap relay frame BY NAME (spec 2026-10-08 §5.1): the broker + * closes the session named by `ref` with reason `over-cap`, to both sides, + * audited. An unbrokered ref has no session to close and costs a log line. * @param nodeId - the node whose authenticated socket produced the frame * @param ref - the routing ref the well-formed candidate carried */ @@ -93,35 +107,49 @@ export function onRelayFrameOverCap(nodeId: string, ref: string): void { } else { overCapByNode.set(nodeId, { overCaps: 1, lastRef: ref }); } - // TODO(Task 8): this is where §5.1's named-reason close lands: look the - // session up by `ref`, cut BOTH ends with ssh_relay_close reason - // "over-cap" (SSH_RELAY_CLOSE_REASONS), and audit `node.ssh_relay.close`. - // Until the broker exists, the record only proves the refusal is visible. + void getRelayBroker() + .refuseOverCap(ref) + .catch((err: unknown) => logger.withError(err).warn(`relay over-cap close for ${ref} threw: ${String(err)}`)); +} + +/** + * An authenticated LIVE node socket died. Tell the broker: sessions where + * this node was A are cut with `a-dropped` (§5.6); its B-side sessions are + * deliberately not (a B link that redials keeps its registry, and B's real + * end is child-exit or a cap). A superseded socket's close never calls this + * (the handler's per-connection identity owns the distinction) - the newer + * socket's node is not gone. + * @param nodeId - the node whose live connection just went away + */ +export function onNodeSocketClosed(nodeId: string): void { + void getRelayBroker() + .onNodeSocketClosed(nodeId) + .catch((err: unknown) => logger.withError(err).warn(`relay socket-drop cut for ${nodeId} threw: ${String(err)}`)); } /** - * What the stub recorded for one node (the frame count and the last frame, - * verbatim). - * @internal test-only; Task 8's broker replaces this module's surface + * What the seam recorded for one node (the routed-frame summary: count, last + * ref, last direction - never a blob). + * @internal test-only: the handler's routing tests */ -export function relayFramesSeenFor(nodeId: string): RelayFrameStubRecord | undefined { - return seenByNode.get(nodeId); +export function relayFramesSeenFor(nodeId: string): RelayFrameRouteRecord | undefined { + return routedByNode.get(nodeId); } /** - * What the over-cap stub recorded for one node (the refusal count and the - * last refused ref; never a blob). - * @internal test-only; Task 8's broker replaces this module's surface + * What the seam recorded for one node's over-cap refusals (count and last + * ref; never a blob). + * @internal test-only */ -export function relayOverCapsSeenFor(nodeId: string): RelayOverCapStubRecord | undefined { +export function relayOverCapsSeenFor(nodeId: string): RelayOverCapRecord | undefined { return overCapByNode.get(nodeId); } /** - * Drop every stub record (routed frames and over-cap refusals alike). + * Drop every seam record (routed summaries and over-cap refusals alike). * @internal test-only; per-file isolation under `bun test --parallel` */ export function resetRelayFramesForTests(): void { - seenByNode.clear(); + routedByNode.clear(); overCapByNode.clear(); } diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index 1c6b7fa51..8e706a090 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -60,6 +60,7 @@ import { getNotifyService, type NotifyKind } from "@/services/notify.service.js" import { EMPTY_PRESET, parsePreset } from "@/services/preset-definition.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; +import { closeRelayForPaneExit, sweepRelayForPane } from "@/services/ssh-relay.service.js"; import { issueSubshellToken, revokeSubshellToken } from "@/services/subshell-tokens.js"; import { logger } from "@/utils/logger.js"; @@ -568,6 +569,9 @@ export class SubshellManagerService { // no-ops every non-local / non-ssh create; `targetNode` keeps a remote // pane's dir on ITS node's disk untouched. sweepLocalSshDir({ id, nodeId: targetNode, ssh: ssh ? JSON.stringify(ssh.snapshot) : null }); + // A relay session (M2) bound to this pane must die with the create too: + // the B proxy socket would otherwise outlive the row it was named for. + closeRelayForPaneExit(id); await this.#subshells.markTerminated(id, new Date().toISOString()); await this.#revokeTokenOrUnlink(id); publishLive({ kind: "subshell.changed", id }); @@ -1054,6 +1058,7 @@ export class SubshellManagerService { async terminateForMaintenance(row: SubshellTable): Promise { const stopped = await this.terminateSubshell(row.userId, row.id); sweepLocalSshDir(row); // guard: local node ∧ snapshot; agent rows skip (their disk, their watcher) + sweepRelayForPane(row); // relay sessions are per-pane and die with the pane (M2 §5.6 child-exit) if (stopped) void this.#notify(row.id, "maintenance"); } @@ -1111,6 +1116,7 @@ export class SubshellManagerService { const artifacts = row.nodeId === LOCAL_NODE_ID ? [launcher.logPath(id)] : launcher.subshellArtifacts(id, sshPane); await launcher.removeArtifacts(artifacts); sweepLocalSshDir(row); // guard: local node ∧ snapshot; agent rows skip (their disk, their watcher) + sweepRelayForPane(row); // the pane stops existing in every flavor: any brokered relay ends here // And the generated MCP config (no secrets, but nothing to leave behind). try { unlinkSync(subshellMcpConfigPath(id)); @@ -1445,6 +1451,7 @@ export class SubshellManagerService { // fixture-shaped belt that keeps "whichever hand first notices // removes the dir" true for EVERY alive→dead stamp, not most. sweepLocalSshDir(row); + closeRelayForPaneExit(row.id); // the same belt for relay sessions } } continue; @@ -1640,6 +1647,10 @@ export class SubshellManagerService { // reconcile reaches this same line whenever the process is gone, so this is // the deterministic sweep. Guarded on `!restarted` (never clobber a live // re-render) and by the helper itself (local ∧ ssh; every other row no-ops). + // The relay cut runs whatever the restart ladder decided: an agent-capable + // ssh pane never auto-restarts (M1 decision 7), and a restart of anything + // else must not strand the relay session of the process that just died. + closeRelayForPaneExit(fresh.id); if (!restarted) sweepLocalSshDir(fresh); if (!restarted && (fresh.restartOnExit !== 1 || fresh.backoffCount >= 5)) { // Terminal: this subshell will never come back, so its bearer must diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index b345ccae8..3bc22c3dd 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -40,6 +40,7 @@ import { isNodeOfflineError } from "@/services/nodes/remote-launcher.js"; import { getNotifyService } from "@/services/notify.service.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; +import { closeRelayForPaneExit, sweepRelayForPane } from "@/services/ssh-relay.service.js"; import { PROMPT_POLL_MS, PROMPT_SETTLE_TIMEOUT_MS, @@ -739,6 +740,7 @@ export class SubshellsService extends BaseService { return null; }), ); + closeRelayForPaneExit(id); // the pane's own report is the §5.6 child-exit signal await this.#manager.applySelfReportedExit(id, exitCode, new Date().toISOString()); } @@ -1477,6 +1479,7 @@ export class SubshellsService extends BaseService { // it lands, stays the second sure thing (its sweep is idempotent). The // helper's guard no-ops this for every non-local-ssh row. sweepLocalSshDir(row); + sweepRelayForPane(row); // terminate kills the ssh child too: its relay dies with it return { ok: true }; } From ad4b52feffef2f6e38321cba9227b70bb555d317 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:52:40 -0700 Subject: [PATCH 36/92] feat(ssh-relay): node relay executors, deliver-or-throw pump, dispatch arms Acceptance (a) end to end on the machine: ssh_relay_open / ssh_relay_close join the dispatch switch. Open consults the ssh-enabled mirror first and branches on the command's own role - A drives openARelaySession OFF the command chain (the numbering probe can worst-case run two ~10 s agent round trips; the ack answers pending:true immediately and a late MOVED-pin refusal becomes a log line, never a queue that parks input), B awaits openBRelaySession (local and fast, answers socketPath) with the pane from the COMMAND (grammar (b), no launch correlation). Close is ungated: ending a session is teardown, not an SSH act, and reaches RelaySessions.close with the named reason. ctx.relay carries the daemon's registry plus relay-send.ts, whose pump THROWS on every non-delivery (no socket, unestablished link, oversize frame, failing write) - the node-side mirror of the plane's contract and the phantom-hang defense. A context built without the plumbing refuses loudly. --- .../src/__tests__/commands-ssh-relay.test.ts | 238 ++++++++++++++++++ .../agent/src/__tests__/relay-send.test.ts | 74 ++++++ apps/node/agent/src/commands/context.ts | 21 ++ apps/node/agent/src/commands/index.ts | 13 +- .../node/agent/src/commands/ssh-relay-exec.ts | 127 ++++++++++ apps/node/agent/src/commands/ssh-relay.ts | 17 +- apps/node/agent/src/daemon.ts | 11 + apps/node/agent/src/relay-send.ts | 53 ++++ 8 files changed, 542 insertions(+), 12 deletions(-) create mode 100644 apps/node/agent/src/__tests__/commands-ssh-relay.test.ts create mode 100644 apps/node/agent/src/__tests__/relay-send.test.ts create mode 100644 apps/node/agent/src/commands/ssh-relay-exec.ts create mode 100644 apps/node/agent/src/relay-send.ts diff --git a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts new file mode 100644 index 000000000..522e20d5c --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts @@ -0,0 +1,238 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { NodeCommandBody, RelayFrame, SshRelayOpenCommand } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { type ARelaySessionArgs, RelaySessions } from "../commands/ssh-relay.js"; +import { execSshRelayClose, execSshRelayOpen, type RelayOpenSeams } from "../commands/ssh-relay-exec.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; + +/** + * The relay executor arms (spec 2026-10-08 §5.1, Task 8 acceptance (a) and + * the probe-off-pump half of the T7 handoff). Posture copied from + * commands-ssh.test.ts: real temp dirs, the gate mirror written by its own + * writer, refusals asserted by `ok:false` + exact message. + * + * What THIS file owns that nothing else does: + * - the dispatch arms exist: `ssh_relay_open` and `ssh_relay_close` answer + * from the executor, never the switch's `unsupported`. + * - the GATE (mirror first) on open; and that CLOSE is not gated - teardown + * must land even while a machine is switched off. + * - acceptance (a) in both role branches: A drives the openARelaySession + * seam, B drives openBRelaySession with the COMMAND's paneId (grammar (b) + * end to end), B's answer carries the socket path the plane will byte-check. + * - the T7 obligation: the A branch does not await the numbering probe on + * the shared command chain - a stuck agent's openARelaySession must not + * serialize anything behind it (the never-resolving seam is the proof), + * and its late named refusal lands in the log, never as an uncaught throw. + * - the ctx.relay plumbing check (a daemon built without it refuses loudly). + */ + +const STAMP = "2026-10-08T10:00:00.000Z"; +const GATE_REFUSAL = "ssh disabled on this node"; +const PANE = "11111111-2222-4333-8444-555555555555"; + +let base: string; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-relay-"))); +}); + +afterAll(() => { + rmSync(base, { recursive: true, force: true }); +}); + +function dataDir(tag: string, gateOn: boolean): string { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + if (gateOn) writeSshEnabled(dir, { on: true, changedAt: STAMP }); + return dir; +} + +function makeCtx(tag: string, opts: { gateOn?: boolean; nodeId?: string; relay?: boolean } = {}) { + const data = dataDir(tag, opts.gateOn ?? true); + const sends: RelayFrame[] = []; + const relay = + opts.relay === false + ? undefined + : { sessions: new RelaySessions(), sendRelayFrame: (f: RelayFrame) => sends.push(f) }; + const ctx = { config: { dataDir: data, nodeId: opts.nodeId ?? "node-self" }, relay } as unknown as CommandContext; + return { ctx, sends, relay }; +} + +function openCmd(over: Partial = {}): SshRelayOpenCommand { + return { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-1", + role: "A", + aNodeId: "node-self", + bNodeId: "node-peer", + peerSigningPublicKey: '{"kty":"EC","crv":"P-256","x":"AX","y":"AY"}', + peerEncryptPublicKey: Buffer.from('{"kty":"EC","crv":"P-256","x":"BX","y":"BY"}', "utf8").toString("base64"), + grantId: "grant-1", + fingerprints: ["SHA256:AAAA"], + lifetimeMs: 30_000, + paneId: PANE, + ...over, + }; +} + +describe("the dispatch arms exist (acceptance (a))", () => { + it("ssh_relay_open reaches the executor, not the switch's unsupported", async () => { + const { ctx } = makeCtx("dispatch"); + // Role B naming a different machine as B: the executor's own first guard + // answers, which is the proof the arm exists (an unwired switch says + // `unsupported`, never this sentence). + const res = await dispatchCommand(ctx, openCmd({ role: "B", aNodeId: "other", bNodeId: "not-me" })); + expect(res.ok).toBe(false); + expect(res.ok === false && res.error).toContain("not the connecting side"); + }); + + it("ssh_relay_close reaches RelaySessions.close and an unknown ref is an idempotent ok", async () => { + const { ctx, relay } = makeCtx("dispatch-close"); + const res = await dispatchCommand(ctx, { type: "ssh_relay_close", ref: "r-none", reason: "child-exit" }); + expect(res).toEqual({ ok: true, data: { ref: "r-none", closed: false } }); + const closed: string[] = []; + relay?.sessions.register("r-live", { onRelayFrame: () => {}, close: (reason) => closed.push(reason) }); + const res2 = await dispatchCommand(ctx, { + type: "ssh_relay_close", + ref: "r-live", + reason: "a-dropped", + } as NodeCommandBody); + expect(res2).toEqual({ ok: true, data: { ref: "r-live", closed: true } }); + expect(closed).toEqual(["a-dropped"]); // §5.1: the named reason reaches the endpoint + }); + + it("a daemon context WITHOUT the relay plumbing refuses, loudly", async () => { + const { ctx } = makeCtx("no-plumbing", { relay: false }); + const res = await dispatchCommand(ctx, openCmd()); + expect(res.ok).toBe(false); + expect(res.ok === false && res.error).toContain("relay plumbing"); + }); +}); + +describe("the gate (mirror first, spec §4.3)", () => { + it("an off mirror refuses ssh_relay_open before any pairing mechanics", async () => { + const { ctx } = makeCtx("gate-off", { gateOn: false }); + const res = await execSshRelayOpen(ctx, openCmd({ role: "B" })); + expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + }); + + it("ssh_relay_close is NOT gated: teardown answers even while the machine is switched off", async () => { + const { ctx } = makeCtx("gate-off-close", { gateOn: false }); + const res = await execSshRelayClose(ctx, { type: "ssh_relay_close", ref: "r-x", reason: "grant-revoked" }); + expect(res).toEqual({ ok: true, data: { ref: "r-x", closed: false } }); + }); +}); + +describe("the A branch: the probe runs OFF the command chain (T7 handoff)", () => { + it("acks immediately while openARelaySession is still running", async () => { + const { ctx } = makeCtx("a-slow"); + let started: (() => void) | undefined; + const never = new Promise<{ relayId: string }>((resolve) => { + started = () => resolve({ relayId: "relay-1" }); + }); + const seams: RelayOpenSeams = { + openA: async () => never, + openB: async () => { + throw new Error("must not run"); + }, + }; + const exec = execSshRelayOpen(ctx, openCmd({ role: "A" }), seams); + // The proof is timing: if the executor awaited the probe, this race would + // resolve with the timeout instead of the ack. + const winner = await Promise.race([ + exec.then((r) => ({ kind: "ack" as const, r })), + new Promise<{ kind: "timeout" }>((resolve) => setTimeout(() => resolve({ kind: "timeout" }), 25)), + ]); + expect(winner.kind).toBe("ack"); + if (winner.kind === "ack") { + expect(winner.r).toEqual({ ok: true, data: { role: "A", relayId: "relay-1", pending: true } }); + } + started?.(); // let the detached promise settle so nothing leaks a rejection + await never; + }); + + it("a late named refusal from the detached open lands in the log and never throws past the arm", async () => { + const { ctx } = makeCtx("a-refuse"); + const lines: string[] = []; + const seams: RelayOpenSeams = { + openA: async () => { + throw new Error("machine pin for node-peer has MOVED"); + }, + openB: async () => { + throw new Error("must not run"); + }, + log: (line) => lines.push(line), + }; + const res = await execSshRelayOpen(ctx, openCmd({ role: "A" }), seams); + expect(res.ok).toBe(true); // the ack was already earned; the refusal is a LOG fact (§4.5: re-pair is an operator act at the machine) + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(lines.join("\n")).toContain("MOVED"); + expect(lines.join("\n")).toContain("r-1"); + }); +}); + +describe("the B branch: the pane rides the command, the answer carries the socket", () => { + it("openBRelaySession gets the COMMAND's paneId and its socket path answers back", async () => { + const { ctx, relay } = makeCtx("b-ok"); + const seenArgs: ARelaySessionArgs[] = []; + const seams: RelayOpenSeams = { + openA: async () => { + throw new Error("must not run"); + }, + openB: async (args) => { + seenArgs.push(args as unknown as ARelaySessionArgs); + relay?.sessions.register(args.cmd.ref, { onRelayFrame: () => {}, close: () => {} }); + return { socketPath: `${args.dataDir}/ssh/${args.paneId}/agent.sock` }; + }, + }; + const res = await execSshRelayOpen(ctx, openCmd({ role: "B", aNodeId: "node-peer", bNodeId: "node-self" }), seams); + expect(seenArgs).toHaveLength(1); + expect(seenArgs[0]?.paneId).toBe(PANE); // grammar (b): the pane arrives on the WIRE, not a side channel + expect(seenArgs[0]?.selfNodeId).toBe("node-self"); + expect(seenArgs[0]?.cmd.role).toBe("B"); + expect(res).toEqual({ + ok: true, + data: { role: "B", relayId: "relay-1", socketPath: `${ctx.config.dataDir}/${"ssh"}/${PANE}/agent.sock` }, + }); + }); + + it("a throwing open (moved pin, bad keys, owned ref) answers ok:false with the named refusal", async () => { + const { ctx } = makeCtx("b-refuse"); + const seams: RelayOpenSeams = { + openA: async () => { + throw new Error("must not run"); + }, + openB: async () => { + throw new Error("relay open refused: machine pin for node-peer has MOVED (re-pair per §4.5)"); + }, + }; + const res = await execSshRelayOpen(ctx, openCmd({ role: "B", aNodeId: "node-peer", bNodeId: "node-self" }), seams); + expect(res.ok).toBe(false); + expect(res.ok === false && res.error).toContain("MOVED"); + }); +}); + +describe("the daemon seam wiring", () => { + it("the B branch's sendRelayFrame is the ctx pump (deliver-or-throw, forwarded unchanged)", async () => { + const { ctx, sends } = makeCtx("b-pump"); + const seams: RelayOpenSeams = { + openA: async () => { + throw new Error("must not run"); + }, + openB: async (args) => { + // The proxy hands the pump a frame; whatever it does is the pump's + // contract - here the ctx recorder proves identity of the seam. + args.sendRelayFrame({ type: "relay", ref: args.cmd.ref, seq: 0, direction: "B2A", blob: "QUJD" }); + return { socketPath: "/x/agent.sock" }; + }, + }; + await execSshRelayOpen(ctx, openCmd({ role: "B", aNodeId: "node-peer", bNodeId: "node-self" }), seams); + expect(sends).toHaveLength(1); + expect(sends[0]?.ref).toBe("r-1"); + }); +}); diff --git a/apps/node/agent/src/__tests__/relay-send.test.ts b/apps/node/agent/src/__tests__/relay-send.test.ts new file mode 100644 index 000000000..a58df881e --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-send.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test"; +import type { RelayFrame } from "@internal/subshell-protocol"; +import { NODE_MAX_FRAME_BYTES } from "@internal/subshell-protocol"; +import { createDaemonRelaySend, type RelaySendState } from "../relay-send.js"; + +/** + * The daemon's per-socket relay pump (Task 8 acceptance (c), the node-side + * half of the deliver-or-throw contract): the frame that leaves this function + * either hit the CURRENT socket through the ESTABLISHED link or the call + * THREW. The proxy and the responder both act on the throw (fail the request, + * consume no seq) - a silent return is what parked phantom replies in the + * T6 review, so every drop branch here is a named raise. + */ + +const frame: RelayFrame = { type: "relay", ref: "r-1", seq: 0, direction: "B2A", blob: "QUJD" }; + +/** A negotiator-shaped fake link; `up: false` mirrors the not-yet-established window. */ +function fakeLink(up = true): RelaySendState["link"] { + return { + established: () => up, + session: () => (up ? { sealFrame: (text: string) => new TextEncoder().encode(`SEALED:${text}`) } : undefined), + }; +} + +describe("createDaemonRelaySend", () => { + it("seals the JSON frame and writes a Buffer onto the live link", () => { + const sealed: (string | Buffer)[] = []; + const relaySend = createDaemonRelaySend(() => ({ + ws: { send: (d: string | Buffer) => sealed.push(d) }, + link: fakeLink(), + })); + relaySend(frame); + expect(sealed).toHaveLength(1); + const payload = sealed[0]; + expect(Buffer.isBuffer(payload)).toBe(true); // never a bare Uint8Array (the Bun framing fact) + expect((payload as Buffer).toString("utf8")).toBe(`SEALED:${JSON.stringify(frame)}`); + }); + + it("THROWS when there is no socket (between connections), when the link is down, and when the write fails", () => { + // Between connections: the plane re-pumps on the fresh socket, but THIS + // caller must know its frame did not leave. + const noWs = createDaemonRelaySend(() => ({ ws: undefined, link: undefined })); + expect(() => noWs(frame)).toThrow(/no live socket/); + // The open→established window (or after the close): a protocol-18 socket + // has no plaintext path, so a relay frame simply cannot leave. + const noLink = createDaemonRelaySend(() => ({ ws: { send: () => {} }, link: fakeLink(false) })); + expect(() => noLink(frame)).toThrow(/link/); + const noLinkAtAll = createDaemonRelaySend(() => ({ ws: { send: () => {} }, link: undefined })); + expect(() => noLinkAtAll(frame)).toThrow(/link/); + // A throwing write propagates as the named refusal, never silence. + const thrower = createDaemonRelaySend(() => ({ + ws: { + send: () => { + throw new Error("socket write failed"); + }, + }, + link: fakeLink(), + })); + expect(() => thrower(frame)).toThrow(/socket write failed/); + }); + + it("refuses an oversize frame before touching the socket (the cap is law both directions)", () => { + const sealed: (string | Buffer)[] = []; + const giant = createDaemonRelaySend(() => ({ + ws: { send: (d: string | Buffer) => sealed.push(d) }, + link: { + established: () => true, + session: () => ({ sealFrame: (t: string) => new TextEncoder().encode(t) }), + }, + })); + expect(() => giant({ ...frame, blob: "A".repeat(NODE_MAX_FRAME_BYTES) })).toThrow(/exceeds/); + expect(sealed).toHaveLength(0); // no seal, no ratchet step spent on a doomed frame + }); +}); diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts index a3de26868..33e329a2e 100644 --- a/apps/node/agent/src/commands/context.ts +++ b/apps/node/agent/src/commands/context.ts @@ -6,11 +6,13 @@ import type { NodeMaintenanceWire, NodeRuntimeReport, NodeSshEnabledWire, + RelayFrame, } from "@internal/subshell-protocol"; import type { NodeConfig } from "../config.js"; import type { ServiceDeps } from "../service.js"; import type { SubshellMetaStore } from "../subshell-meta.js"; import type { NodeBinaryDeps } from "../update.js"; +import type { RelaySessions } from "./ssh-relay.js"; /** * The command-executor seam (spec 2026-08-31 §7): everything an executor may @@ -109,6 +111,25 @@ export interface CommandContext { binaryDeps?: NodeBinaryDeps; /** Outbound event seam (inventory events now; tail output/exit events later). */ ws: CommandWs; + /** + * The sealed-agent-relay plumbing (spec 2026-10-08 §5.1, Task 8): the + * daemon's routing-ref registry plus the per-socket relay pump, handed to + * the `ssh_relay_open` branches and their sessions. OPTIONAL by type so + * every pre-relay context fake keeps compiling; the real daemon always + * provides it, and the relay arms refuse loudly when a context does not. + */ + relay?: { + /** The per-daemon ref registry; sessions outlive the socket they opened on. */ + sessions: RelaySessions; + /** + * Hand one relay frame to the CURRENT established link. DELIVER-OR-THROW + * (acceptance (c), mirroring the endpoints' T6/T7 contract): a frame that + * cannot leave RAISES - the proxy fails its request and the responder + * consumes no seq, because a silent return parks a phantom reply whose + * answer never comes. + */ + sendRelayFrame(frame: RelayFrame): void; + }; /** * The panes supervised for natural death, keyed by subshellId → * {@link WatcherRegistration} (socket + registration token + unreachable diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 1dbeb746e..82df78cf6 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -30,6 +30,7 @@ import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; import { execSshRegisterIdentity } from "./ssh-identity.js"; +import { execSshRelayClose, execSshRelayOpen } from "./ssh-relay-exec.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; import { execTransferWrite } from "./transfer-write.js"; import { execTreeManifest } from "./tree-manifest.js"; @@ -54,9 +55,13 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * `set_ssh_enabled` (spec 2026-10-07 §4.3, protocol 16 — the SSH gate's plane- * to-machine write), the two SSH read arms `ssh_discover_aliases` and * `ssh_resolve_config` (spec 2026-10-07 §5, protocol 17 — both refuse on the - * local gate mirror before any lookup or spawn), and the M2 §4.3 bootstrap + * local gate mirror before any lookup or spawn), the M2 §4.3 bootstrap * `ssh_register_identity` (spec 2026-10-08 §4.3 - the machine's own signing - * PUBLIC key, ungated like `set_ssh_enabled`: registration is not an SSH act). + * PUBLIC key, ungated like `set_ssh_enabled`: registration is not an SSH act), + * and the brokered relay pair `ssh_relay_open` / `ssh_relay_close` + * (spec 2026-10-08 §5.1, protocol 18 - open consults the gate then drives the + * T6/T7 pairing branches by the command's own role; close is ungated teardown, + * because ending a session is not an SSH act). * Any * unknown type still answers `unsupported` — the integration * contract that lets the backend and agent tracks move independently. @@ -140,6 +145,10 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return await execSshResolveConfig(ctx, cmd); case "ssh_register_identity": return await execSshRegisterIdentity(ctx); + case "ssh_relay_open": + return await execSshRelayOpen(ctx, cmd); + case "ssh_relay_close": + return execSshRelayClose(ctx, cmd); case "set_server_url": return await execSetServerUrl(ctx, cmd); case "update": diff --git a/apps/node/agent/src/commands/ssh-relay-exec.ts b/apps/node/agent/src/commands/ssh-relay-exec.ts new file mode 100644 index 000000000..6df0a3440 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-relay-exec.ts @@ -0,0 +1,127 @@ +import type { JsonValue, SshRelayCloseCommand, SshRelayOpenCommand } from "@internal/subshell-protocol"; +import { log } from "../log.js"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; +import type { CommandContext, CommandResult } from "./context.js"; +import { type ARelaySessionArgs, type BRelaySessionArgs, openARelaySession, openBRelaySession } from "./ssh-relay.js"; +import { SSH_GATE_REFUSAL } from "./ssh-shared.js"; + +/** + * The two relay command arms (spec 2026-10-08 §5.1, Task 8 acceptance (a)): + * the daemon-side executors the plane's signed `ssh_relay_open` / + * `ssh_relay_close` land on. The mechanics they drive are T6/T7's reviewed + * code ({@link openBRelaySession} / {@link openARelaySession} in + * `ssh-relay.ts`); this file owns the DELIVERY decisions: + * + * - **Gate first, open only.** Every SSH arm consults this machine's + * `ssh_enabled` mirror before pairing mechanics (spec §4.3), and this one + * refuses {@link SSH_GATE_REFUSAL} exactly like the discovery arms. The + * CLOSE is deliberately NOT gated: a switch-off mid-session must still be + * able to end the session - teardown is not an SSH act (the + * `set_ssh_enabled` precedent), and refusing it would strand a bound proxy + * socket on a machine that has decided to stop serving SSH. + * - **The A branch answers immediately, the probe runs off the chain** + * (Task 7's handoff, binding): `openARelaySession` awaits the numbering + * probe, which can worst-case run two ~10 s agent round trips against a + * hung agent. The daemon's executor chain serializes every command, so + * awaiting it here would park `input`, `capture`, and every unrelated + * command behind someone else's agent. The ack says what it means - + * `pending: true`, the pairing is DELIVERED, not yet PROBED. A named + * refusal that arrives after the ack (a MOVED pin, an owned ref) is a log + * fact and a dead session the plane's cuts end; §4.5 makes re-pair an + * operator act at the machine, and the answer's absence (never a frame + * from the responder) is what B's side reads as the refusal. + * - **The B branch answers with its socket path.** Binding is local and fast, + * so it runs ON the chain and answers `socketPath` - which the plane + * re-derives from its own facts and byte-checks (the launch-config + * doctrine restated for the agent socket). The pane comes from the COMMAND + * (`cmd.paneId`, grammar (b)), never a launch-side correlation. + * - **The pump is the daemon's** (`ctx.relay.sendRelayFrame`), forwarded to + * both branches unchanged: its deliver-or-throw contract (acceptance (c)) + * is the daemon's `relay-send.ts`, and a context built without the relay + * plumbing refuses loudly instead of half-opening. + * + * The seams (`{@link RelayOpenSeams}`) exist for the probe-off-chain and + * refusal-observability tests; production omits them and gets the real pair. + */ + +/** Injectable pairing seams (test-only; production is the T6/T7 pair). */ +export interface RelayOpenSeams { + openA(args: ARelaySessionArgs): Promise<{ relayId: string }>; + openB(args: BRelaySessionArgs): Promise<{ socketPath: string }>; + /** Line sink for the detached A-branch's outcome (defaults to the agent log). */ + log?: (line: string) => void; +} + +/** Does this machine permit SSH right now? (the one question every SSH arm asks first) */ +function gateOpen(ctx: CommandContext): boolean { + return sshAllowed(readSshEnabled(ctx.config.dataDir)); +} + +/** Execute `ssh_relay_open`: branch on the command's own `role`. */ +export async function execSshRelayOpen( + ctx: CommandContext, + cmd: SshRelayOpenCommand, + seams?: RelayOpenSeams, +): Promise { + if (!gateOpen(ctx)) return { ok: false, error: SSH_GATE_REFUSAL }; + const relay = ctx.relay; + if (!relay) { + // Not a user-facing condition: a daemon that runs commands without the + // relay plumbing would half-open sessions with no pump, so this is the + // impossible-state guard, answered loudly. + return { ok: false, error: "relay plumbing unavailable in this daemon context" }; + } + const say = seams?.log ?? ((line: string): void => log(line)); + if (cmd.role === "A") { + const openA = seams?.openA ?? openARelaySession; + // OFF the chain, deliberately (the module doc names the cost). The guard + // refusals inside openARelaySession (role/self/pin/ref) become log lines + // here, never a second answer to the plane - the ack already left. + void openA({ + relay: relay.sessions, + dataDir: ctx.config.dataDir, + selfNodeId: ctx.config.nodeId, + cmd, + sendRelayFrame: relay.sendRelayFrame, + }).then( + (r) => say(`relay session ${cmd.ref}: A side live (relay ${r.relayId})`), + (err: unknown) => say(`relay session ${cmd.ref}: A open refused after ack: ${String(err)}`), + ); + // The seam cast is the JSON-safety posture the sibling executors use + // (`node-results.ts` owns the answer contract; a relay ack is a plain + // object by the plan's interface note). + return { ok: true, data: { role: "A", relayId: cmd.relayId, pending: true } as unknown as JsonValue }; + } + const openB = seams?.openB ?? openBRelaySession; + try { + const { socketPath } = await openB({ + relay: relay.sessions, + dataDir: ctx.config.dataDir, + selfNodeId: ctx.config.nodeId, + paneId: cmd.paneId, + cmd, + sendRelayFrame: relay.sendRelayFrame, + }); + return { ok: true, data: { role: "B", relayId: cmd.relayId, socketPath } as unknown as JsonValue }; + } catch (err: unknown) { + // Named refusals (MOVED pin, malformed peer key, owned ref): the plane's + // openRelay turns the rejection into its own named refusal. The string + // travels the RESULT channel only - openBRelaySession's refusals name + // ids and the §4.5 remedy, never key material. + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Execute `ssh_relay_close`: stop the session named by the ref and hand the + * named reason to the local owner (§5.6's endpoint line). Ungated by design + * (module doc). An unknown ref answers `closed: false` - a close racing the + * plane's own cut is routine, and a false answer to a redundant close is the + * honest record, not an error. + */ +export function execSshRelayClose(ctx: CommandContext, cmd: SshRelayCloseCommand): CommandResult { + const relay = ctx.relay; + if (!relay) return { ok: false, error: "relay plumbing unavailable in this daemon context" }; + const closed = relay.sessions.close(cmd.ref, cmd.reason); + return { ok: true, data: { ref: cmd.ref, closed } as unknown as JsonValue }; +} diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index 02273ba02..1330e1df2 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -28,15 +28,12 @@ import { startRelayResponder } from "../relay-responder.js"; * pin; a pin that moved is a hard block naming the peer and §4.5's recovery, * never a silent overwrite) and only then binds the pane's agent socket. * - * WHAT THE WIRE DOES NOT CARRY YET (handed to Task 8): the frozen - * `ssh_relay_open` grammar names no pane. §5.2 puts the socket in the PANE's - * ssh dir (`/ssh//agent.sock`), so {@link openBRelaySession} - * takes the `paneId` as an explicit executor input - whoever delivers the - * command must supply it (a grammar addition or a launch-side correlation; - * the proxy refuses an id outside the path-composition shape either way, so - * guessing is structurally impossible). No executor arm is registered on this - * pass: `ssh_relay_open`/`ssh_relay_close` keep answering `unsupported` from - * the dispatch switch until Task 8's delivery wires them. + * THE TASK-8 WIRING (landed): the `ssh_relay_open` grammar carries the pane + * (`cmd.paneId`, acceptance (b)), so {@link openBRelaySession} takes it from + * the command it serves and binds §5.2's `/ssh//agent.sock`; + * the executor arms live in `ssh-relay-exec.ts`, registered on the dispatch + * switch, and the pump they hand the branches is the daemon's + * deliver-or-throw `relay-send.ts` (acceptance (c)). */ /** One local owner of a relay session's inbound frames (a B proxy, or Task 7's A responder). */ @@ -141,7 +138,7 @@ export interface BRelaySessionArgs { dataDir: string; /** This machine's node id (config.nodeId) - the open-as principal. */ selfNodeId: string; - /** The pane whose ssh connects through the socket (see the module header's Task-8 note). */ + /** The pane whose ssh connects through the socket: the command's `paneId` (grammar (b)). */ paneId: string; /** The verified `ssh_relay_open` body; its `role` MUST be "B". */ cmd: SshRelayOpenCommand; diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 02aa1cf7b..a7f14c663 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -40,6 +40,7 @@ import { binaryPayload, createLinkNegotiator, type LinkNegotiator, type LinkNego import { clearLock, writeLock } from "./lock.js"; import { log } from "./log.js"; import { createRetentionPass, PANE_LOG_RETENTION_PASS_MS, resolveLogRetention } from "./pane-log-retention.js"; +import { createDaemonRelaySend } from "./relay-send.js"; import { noteSweepScheduled } from "./retention-settings.js"; import { collectRuntime } from "./runtime.js"; import { selfInvokePrefix } from "./self-invoke.js"; @@ -503,6 +504,16 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom tails: new Map(), uploads: new Map(), runtime, + // The relay plumbing (spec 2026-10-08 §5.1, Task 8): the per-daemon + // registry (sessions survive reconnects - the plane re-pumps on the fresh + // socket) plus the DELIVER-OR-THROW pump. The pump reads the CURRENT + // socket and link through a getter, exactly like the stale-socket guard in + // `send` above: a frame sent after this socket died targets the new link + // if one exists, and throws if none does (acceptance (c)). + relay: { + sessions: relaySessions, + sendRelayFrame: createDaemonRelaySend(() => ({ ws: currentWs, link })), + }, requestRestart: () => { // Deferred, because the daemon is the only sender of `result`: the // executor returns `{ ok: true }`, the frame leaves on this turn, and diff --git a/apps/node/agent/src/relay-send.ts b/apps/node/agent/src/relay-send.ts new file mode 100644 index 000000000..2edc7ac2d --- /dev/null +++ b/apps/node/agent/src/relay-send.ts @@ -0,0 +1,53 @@ +import { NODE_MAX_FRAME_BYTES, type RelayFrame } from "@internal/subshell-protocol"; +import { binaryPayload } from "./link-crypto.js"; + +/** + * The daemon's relay pump, factored out of `daemon.ts` so the + * deliver-or-throw contract is testable without a socket (Task 8 acceptance + * (c), the node-side mirror of the plane's `sendRelayFrameOverNodeSocket`). + * + * `send` in the daemon LOGS a dropped event; a relay frame may not be dropped + * quietly. The proxy parks a phantom request on a send it believes delivered + * (T6 review, restated in T7's handoff), and the responder consumes a signed + * seq for a reply that never left - so every non-delivery here RAISES and the + * endpoints act on the throw (fail the request, consume no seq, stall to the + * plane's timers). State is read through a getter because the pump is handed + * to sessions that OUTLIVE a socket: the registry survives reconnects and the + * plane re-pumps on the fresh link, so the frame targets whatever socket is + * CURRENT at send time, never the socket the open rode in on. + */ + +/** The negotiator slice the pump reads: the per-CONNECTION link, possibly mid-handshake. */ +export interface RelaySendLink { + established(): boolean; + session(): { sealFrame(text: string): Uint8Array } | undefined; +} + +/** The live facts at send time: the CURRENT socket (or none) and its link (or none). */ +export interface RelaySendState { + ws?: { send(data: string | Buffer): unknown } | undefined; + link?: RelaySendLink | undefined; +} + +/** + * Build the pump over a state getter. Throws {@link Error} with a named + * reason on: no live socket (between connections), no established link (a + * protocol-18 socket has no plaintext path at all), an oversize envelope, or + * a failing write. On success the socket received a Buffer of sealed bytes. + */ +export function createDaemonRelaySend(getState: () => RelaySendState): (frame: RelayFrame) => void { + return (frame: RelayFrame): void => { + const { ws, link } = getState(); + if (!ws) throw new Error("relay send refused: no live socket"); + const session = link?.established() ? link.session() : undefined; + if (!session) { + throw new Error("relay send refused: the encrypted link is not established"); + } + const payload = JSON.stringify(frame); + const size = Buffer.byteLength(payload); + if (size > NODE_MAX_FRAME_BYTES) { + throw new Error(`relay send refused: frame ${size}B exceeds the ${NODE_MAX_FRAME_BYTES}B link cap`); + } + ws.send(binaryPayload(session.sealFrame(payload))); + }; +} From 182d23f25dd6612ed610a651352a15745e49c8b7 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 13:54:19 -0700 Subject: [PATCH 37/92] feat(ssh-relay): bound the node relay registry to SSH_RELAY_MAX_PER_NODE Acceptance (g)'s defense-in-depth half: the plane's broker counts live sessions authoritatively at open; RelaySessions.register now also refuses a 9th concurrent ref on this machine with a named throw (the dup-ref false path stands - a routing collision is still the caller's unwind, a full registry is the machine saying no). The responder test fixtures carry the grammar's new paneId; the broker's scheduler seam carries handles as an opaque unknown so any fake timer fits. --- .../src/__tests__/commands-ssh-relay.test.ts | 32 ++++++++++++++++--- .../src/__tests__/relay-responder.test.ts | 4 +++ apps/node/agent/src/commands/ssh-relay.ts | 19 ++++++++++- .../__tests__/ssh-relay.service.test.ts | 4 +-- .../api/src/services/ssh-relay.service.ts | 4 +-- 5 files changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts index 522e20d5c..b4013a5c1 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts @@ -2,10 +2,15 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { NodeCommandBody, RelayFrame, SshRelayOpenCommand } from "@internal/subshell-protocol"; +import { + type NodeCommandBody, + type RelayFrame, + SSH_RELAY_MAX_PER_NODE, + type SshRelayOpenCommand, +} from "@internal/subshell-protocol"; import type { CommandContext } from "../commands/context.js"; import { dispatchCommand } from "../commands/index.js"; -import { type ARelaySessionArgs, RelaySessions } from "../commands/ssh-relay.js"; +import { type BRelaySessionArgs, RelaySessions } from "../commands/ssh-relay.js"; import { execSshRelayClose, execSshRelayOpen, type RelayOpenSeams } from "../commands/ssh-relay-exec.js"; import { writeSshEnabled } from "../ssh-enabled.js"; @@ -179,13 +184,13 @@ describe("the A branch: the probe runs OFF the command chain (T7 handoff)", () = describe("the B branch: the pane rides the command, the answer carries the socket", () => { it("openBRelaySession gets the COMMAND's paneId and its socket path answers back", async () => { const { ctx, relay } = makeCtx("b-ok"); - const seenArgs: ARelaySessionArgs[] = []; + const seenArgs: BRelaySessionArgs[] = []; const seams: RelayOpenSeams = { openA: async () => { throw new Error("must not run"); }, openB: async (args) => { - seenArgs.push(args as unknown as ARelaySessionArgs); + seenArgs.push(args); relay?.sessions.register(args.cmd.ref, { onRelayFrame: () => {}, close: () => {} }); return { socketPath: `${args.dataDir}/ssh/${args.paneId}/agent.sock` }; }, @@ -217,6 +222,25 @@ describe("the B branch: the pane rides the command, the answer carries the socke }); }); +describe("RelaySessions concurrency bound (acceptance (g), node-side defense)", () => { + const handler = { onRelayFrame: () => {}, close: () => {} }; + + it("rejects a 9th concurrent session with a named refusal and keeps the dup-ref false", () => { + const relay = new RelaySessions(); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) expect(relay.register(`r-${i}`, handler)).toBe(true); + // The existing dup-ref contract is UNCHANGED: false, no throw. + expect(relay.register("r-0", handler)).toBe(false); + // A NEW ref over the cap is a loud named refusal (defense-in-depth; the + // plane's quota is authoritative, this is the machine refusing to hold a + // 9th proxy anyway). + expect(() => relay.register(`r-${SSH_RELAY_MAX_PER_NODE}`, handler)).toThrow(/max 8/); + expect(relay.size).toBe(SSH_RELAY_MAX_PER_NODE); + // Releasing a ref frees a slot: the next register lands. + relay.close("r-3", "lifetime-expiry"); + expect(relay.register(`r-${SSH_RELAY_MAX_PER_NODE}`, handler)).toBe(true); + }); +}); + describe("the daemon seam wiring", () => { it("the B branch's sendRelayFrame is the ctx pump (deliver-or-throw, forwarded unchanged)", async () => { const { ctx, sends } = makeCtx("b-pump"); diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts index 8419edca9..0912cec0d 100644 --- a/apps/node/agent/src/__tests__/relay-responder.test.ts +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -337,6 +337,7 @@ async function openFixture( grantId: "grant-1", fingerprints: [fp(KEY_IN)], lifetimeMs: 30_000, + paneId: "pane-a-test", // grammar (b): the command names the pane ...overrides, }; await openARelaySession({ @@ -1032,6 +1033,7 @@ test("openARelaySession refuses a command that does not name this machine in its grantId: "grant-1", fingerprints: [], lifetimeMs: 30_000, + paneId: "pane-a-test", }; // role B: the A branch is not for this command. await expect( @@ -1101,6 +1103,7 @@ test("openARelaySession enforces §4.4 byte-equality on B's pin and the duplicat grantId: "grant-1", fingerprints: [], lifetimeMs: 30_000, + paneId: "pane-a-test", }; // A moved signing half under the SAME peer id: a hard block, never a silent overwrite. await expect( @@ -1163,6 +1166,7 @@ test("a pinned peer key that is not a usable public P-256 JWK rejects the sessio grantId: "grant-1", fingerprints: [], lifetimeMs: 30_000, + paneId: "pane-a-test", }, sendRelayFrame: () => {}, resolveAgentSocket: () => null, diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index 1330e1df2..e2c30ee8d 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -1,5 +1,10 @@ import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; -import { bytesOfJwk, type RelayFrame, type SshRelayOpenCommand } from "@internal/subshell-protocol"; +import { + bytesOfJwk, + type RelayFrame, + SSH_RELAY_MAX_PER_NODE, + type SshRelayOpenCommand, +} from "@internal/subshell-protocol"; import { loadOrCreateIdentity } from "../identity.js"; import { log } from "../log.js"; import { MachinePinStore } from "../machine-pin-store.js"; @@ -70,9 +75,21 @@ export class RelaySessions { * Take ownership of a ref. False when the ref is ALREADY owned - two live * sessions on one routing ref is a broker bug, and the second opener's * resources belong to ITSELF to release (its caller sees the false). + * Throws a named refusal when this machine is already holding + * {@link SSH_RELAY_MAX_PER_NODE} sessions and `ref` is NEW: the plane's + * broker enforces the same cap authoritatively at open, and this is the + * node refusing to hold a 9th proxy socket off a miscounting broker + * (defense-in-depth, Task 8 (g)). The throw path and the dup-ref false + * path are deliberately different: an owned ref is a routing collision + * the CALLER unwinds, a full registry is the machine saying no. */ register(ref: string, handler: RelaySessionHandler): boolean { if (this.#byRef.has(ref)) return false; + if (this.#byRef.size >= SSH_RELAY_MAX_PER_NODE) { + throw new Error( + `relay registry full: ${this.#byRef.size} live sessions on this machine (max ${SSH_RELAY_MAX_PER_NODE})`, + ); + } this.#byRef.set(ref, handler); return true; } diff --git a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts index 1f8c0f81b..c025f0269 100644 --- a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts @@ -239,7 +239,7 @@ describe("openRelay (spec §5.1/§5.3, Task 8 (a)(b)(d)(g)(h))", () => { ack: (_nodeId, cmd) => cmd.type === "ssh_relay_open" && cmd.role === "B" ? { role: "B", relayId: cmd.relayId, socketPath: "/attacker/agent.sock" } - : { role: "A", relayId: cmd.relayId }, + : { role: "A" }, }); await expect(h.open()).rejects.toMatchObject({ code: "bad-socket-path" }); // The session was torn down and BOTH sides were told, under a named cut. @@ -441,7 +441,7 @@ describe("teardown (spec §5.6, Task 8 (f))", () => { const h = makeHarness({ ack: (_nodeId, cmd) => { if (cmd.type === "ssh_relay_open" && cmd.role === "B") throw new Error("machine pin for a has MOVED"); - return { role: "A", relayId: cmd.relayId }; + return { role: "A", relayId: cmd.type === "ssh_relay_open" ? cmd.relayId : "" }; }, }); await expect(h.open()).rejects.toMatchObject({ code: "handshake" }); diff --git a/apps/server/api/src/services/ssh-relay.service.ts b/apps/server/api/src/services/ssh-relay.service.ts index 67204800a..e22a15a63 100644 --- a/apps/server/api/src/services/ssh-relay.service.ts +++ b/apps/server/api/src/services/ssh-relay.service.ts @@ -184,8 +184,8 @@ export interface OpenRelayResult { socketPath: string; } -/** Opaque timer handle; the scheduler seam's own identity. */ -export type TimerHandle = { readonly brand?: never }; +/** Opaque timer handle: the scheduler seam's own identity, carried untouched to `cancel`. */ +export type TimerHandle = unknown; /** The broker's world, every seam injectable (production default at the bottom). */ export interface RelayBrokerDeps { From f579b6a74c28adbdf9fe3ce248d835e22fb0ffa0 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:07:20 -0700 Subject: [PATCH 38/92] fix(ssh-relay): release proxy/responder on cap-throw; tombstone closed refs to stop orphaned late opens Review round on Task 8 found two node-side resource leaks on failure paths, both reachable on a slow/hung-agent node. MAJOR 1: RelaySessions.register throws at the SSH_RELAY_MAX_PER_NODE cap, and the ssh_relay_open executors only cleaned up on the false return - a 9th open leaked its already-bound proxy listener fd and socket name until daemon restart; both call sites now wrap the register in try/catch and release on every refusal shape. MAJOR 2: the A branch acks pending:true before its probe, so a plane close that lands mid-probe found no owned ref, and the late-successful open then registered a responder nothing would ever close - a permanent orphan slot. RelaySessions.close now tombstones an unknown ref for RELAY_CLOSE_TOMBSTONE_MS (2x the session lifetime, swept at every touch, clock/TTL injectable) and register refuses a tombstoned ref, so the late open's own refused path releases its responder/proxy. NIT 2 (doc): the node refusal string also reaches the plane's thrown SshRelayRefusal, so the ids-never-secrets contract is load-bearing for any logger. --- .../src/__tests__/commands-ssh-relay.test.ts | 52 +++++ .../src/__tests__/relay-responder.test.ts | 181 +++++++++++++++++- .../node/agent/src/commands/ssh-relay-exec.ts | 18 +- apps/node/agent/src/commands/ssh-relay.ts | 145 +++++++++++--- 4 files changed, 366 insertions(+), 30 deletions(-) diff --git a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts index b4013a5c1..90e727daa 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts @@ -239,6 +239,58 @@ describe("RelaySessions concurrency bound (acceptance (g), node-side defense)", relay.close("r-3", "lifetime-expiry"); expect(relay.register(`r-${SSH_RELAY_MAX_PER_NODE}`, handler)).toBe(true); }); + + it("a THROWN cap refusal does not create a session and the refused opener can still release itself", () => { + // The call-site contract the executors rely on (fix round T8 MAJOR 1): + // every refusal path of register leaves the registry exactly as it was, + // so the OPENERS cleanup (its try/catch around register) is the whole + // release story - the cap throw must not half-own anything. + const relay = new RelaySessions(); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) relay.register(`r-${i}`, handler); + expect(() => relay.register("r-over", handler)).toThrow(/relay registry full/); + expect(relay.size).toBe(SSH_RELAY_MAX_PER_NODE); + expect(relay.has("r-over")).toBe(false); + }); +}); + +describe("RelaySessions close tombstones (fix round T8 MAJOR 2)", () => { + const handler = { onRelayFrame: () => {}, close: () => {} }; + + it("remembers a close of an UNKNOWN ref, refuses a late register, and expires the tombstone", () => { + // The clock and the TTL are injected: the race window (a detached open + // worst-case ~20 s deep in its probe) is real time the suite cannot pay. + let now = 1_000_000; + const relay = new RelaySessions(undefined, { nowMs: () => now, tombstoneMs: 5_000 }); + // The plane's cut lands BEFORE the open ever registers: nothing is owned, + // and the answer stays the honest `closed: false` the executor answers. + expect(relay.close("r-late", "handshake-grace")).toBe(false); + expect(relay.isTombstoned("r-late")).toBe(true); + // A late SUCCESS must not own a ref the plane already dropped. + expect(relay.register("r-late", handler)).toBe(false); + expect(relay.size).toBe(0); + // A close of an OWNED ref is NOT tombstoned: it took the normal path. + expect(relay.register("r-own", handler)).toBe(true); + expect(relay.close("r-own", "lifetime-expiry")).toBe(true); + expect(relay.isTombstoned("r-own")).toBe(false); + // The set is TTL-bounded, not a growing ledger: after the tombstone + // expires, the ref (or any other) registers normally again. + now += 5_001; + expect(relay.isTombstoned("r-late")).toBe(false); + expect(relay.register("r-late", handler)).toBe(true); + }); + + it("sweeps expired tombstones on every touch so repeated unknown closes stay bounded", () => { + let now = 0; + const lines: string[] = []; + const relay = new RelaySessions((line) => lines.push(line), { nowMs: () => now, tombstoneMs: 100 }); + for (let i = 0; i < 50; i += 1) expect(relay.close(`r-${i}`, "grant-revoked")).toBe(false); + now += 101; // every tombstone is now past its TTL + expect(relay.register("sweep-probe", handler)).toBe(true); // the sweep ran at register + for (let i = 0; i < 50; i += 1) expect(relay.isTombstoned(`r-${i}`)).toBe(false); + // The remembered close is observable in the log (ids and the named + // reason only - the same refusal vocabulary every relay line keeps). + expect(lines.some((l) => l.includes("r-0") && l.includes("grant-revoked"))).toBe(true); + }); }); describe("the daemon seam wiring", () => { diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts index 0912cec0d..f4af31db4 100644 --- a/apps/node/agent/src/__tests__/relay-responder.test.ts +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdtempSync } from "node:fs"; +import { existsSync, mkdtempSync } from "node:fs"; import { createServer, type Server, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -11,6 +11,7 @@ import { openRelayEnvelope, parseRelayFrame, type RelayFrame, + SSH_RELAY_MAX_PER_NODE, type SshRelayOpenCommand, sealRelayEnvelope, verifyRelayEnvelope, @@ -19,6 +20,7 @@ import { exportJWK, generateKeyPair } from "jose"; import { openARelaySession, openBRelaySession, RelaySessions } from "../commands/ssh-relay.js"; import { loadOrCreateIdentity } from "../identity.js"; import { type AgentScheme, CLASSIC_SCHEME, OPENSSH_10X_SCHEME } from "../relay-agent-scheme.js"; +import { buildAgentSocketPath } from "../relay-proxy.js"; import { startRelayResponder } from "../relay-responder.js"; /** @@ -1213,6 +1215,183 @@ test("with no live agent socket the session opens with no scheme and answers SSH } }); +/* ------------------------------------------------------------------ */ +/* refused opens release what they bound (fix round T8 MAJOR 1 + 2) */ +/* ------------------------------------------------------------------ */ + +/** + * A stub agent that answers EVERY request with FAILURE(5) only after + * `delayMs`: the open-time numbering probe stays in flight for the whole + * (delayed) round trip, which is the window the race test drops the plane's + * close into. Same one-shot wire posture as the real round trips. + */ +async function startLaggingAgent(delayMs: number): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-lag-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + received.push(Buffer.from(buffer.subarray(4, 4 + len))); + buffer = buffer.subarray(4 + len); + setTimeout(() => { + if (!conn.destroyed) conn.write(framed(Buffer.from([5]))); + }, delayMs); + }); + conn.on("error", () => { + /* per-request closes; never throw */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { + path, + received, + setMutate: () => { + /* nothing well-formed to corrupt */ + }, + close: () => new Promise((r) => server.close(() => r())), + }; +} + +const REFUSAL_FILLER = { onRelayFrame: () => {}, close: () => {} }; + +/** A command whose peer keys are REAL P-256 material: these tests must reach + * the register step, which sits past every pin gate, so a placeholder JWK + * would refuse in the wrong place. */ +async function refusalCmd(over: Partial): Promise { + const bKeys = await bKeysReady; // here they stand for the PEER's registered keys + return { + type: "ssh_relay_open", + relayId: "relay-refusal", + ref: "r-refusal", + role: "A", + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: bKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(bKeys.encryption.publicJwk, "utf8").toString("base64"), + grantId: "grant-1", + fingerprints: [], + lifetimeMs: 30_000, + paneId: "pane-refusal", + ...over, + }; +} + +test("a B open refused at the node cap releases its bound proxy: no socket file survives (fix MAJOR 1)", async () => { + const relay = new RelaySessions(); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) expect(relay.register(`fill-${i}`, REFUSAL_FILLER)).toBe(true); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-cap-")); + const socketPath = buildAgentSocketPath(dataDir, "pane-cap"); + await expect( + openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-cap", + cmd: await refusalCmd({ role: "B", ref: "r-cap-9", relayId: "relay-cap-9" }), + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/relay registry full/); + // The proxy had already listen()ed when the cap THREW out of register: the + // throw path must run the same release the dup-ref path always ran - the + // listener closed and the socket name unlinked (close() does both, so the + // absent name is the witness that the release ran). + expect(existsSync(socketPath)).toBe(false); + expect(relay.size).toBe(SSH_RELAY_MAX_PER_NODE); + relay.closeAll("lifetime-expiry"); +}); + +test("an A open refused at the node cap closes its responder too (fix MAJOR 1, A twin)", async () => { + const relay = new RelaySessions(); + for (let i = 0; i < SSH_RELAY_MAX_PER_NODE; i += 1) expect(relay.register(`fill-${i}`, REFUSAL_FILLER)).toBe(true); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-acap-")); + const lines: string[] = []; + await expect( + openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: await refusalCmd({ ref: "r-cap-a", relayId: "relay-cap-a" }), + sendRelayFrame: () => {}, + resolveAgentSocket: () => null, // no probe: the cap refusal is the registry's own + log: (line) => lines.push(line), + }), + ).rejects.toThrow(/relay registry full/); + // The responder existed when register threw; the symmetric catch closes it. + expect(lines.some((l) => l.includes("r-cap-a") && l.includes("A-side responder closed"))).toBe(true); + expect(relay.size).toBe(SSH_RELAY_MAX_PER_NODE); + relay.closeAll("lifetime-expiry"); +}); + +test("a close that lands mid-probe wins the race: the late A open is refused and releases its responder (fix MAJOR 2)", async () => { + const lag = await startLaggingAgent(80); // each probe answer is ~80 ms late + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-race-")); + const lines: string[] = []; + try { + // The ack has already left the executor (pending: true); the detached + // open is inside its probe when the plane's cut arrives - worst-case + // ~20 s of agent round trips, here 2 x 80 ms of lagging answers. + const pending = openARelaySession({ + relay, + dataDir, + selfNodeId: "a-node", + cmd: await refusalCmd({ ref: "r-race", relayId: "relay-race" }), + sendRelayFrame: () => {}, + resolveAgentSocket: () => lag.path, + log: (line) => lines.push(line), + }); + await sleep(20); // mid-probe: nothing owns the ref yet + expect(lag.received.length).toBeGreaterThan(0); // the probe really is running + expect(relay.close("r-race", "handshake-grace")).toBe(false); // the plane's ssh_relay_close lands first + await expect(pending).rejects.toThrow(/already closed by the plane/); + // (b) release: the responder's own close line ran (the probe's one-shot + // connections are gone by then; nothing was ever forwarded). + expect(lines.some((l) => l.includes("r-race") && l.includes("A-side responder closed"))).toBe(true); + // (c) no orphan: the refused late register took no slot... + expect(relay.size).toBe(0); + // (d) and the tombstone punishes only THAT ref: a genuinely new session + // registers normally (the TTL-bounded expiry itself is pinned in + // commands-ssh-relay.test.ts with an injected clock). + expect(relay.register("r-fresh", REFUSAL_FILLER)).toBe(true); + expect(relay.isTombstoned("r-race")).toBe(true); + } finally { + relay.closeAll("lifetime-expiry"); + await lag.close(); + } +}); + +test("a late B open for a ref the plane already closed unbinds its proxy (fix MAJOR 2, B twin)", async () => { + const relay = new RelaySessions(); + // The plane's cut lands before the open ever registers. + expect(relay.close("r-late-b", "handshake-grace")).toBe(false); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-btomb-")); + const socketPath = buildAgentSocketPath(dataDir, "pane-late"); + await expect( + openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-late", + cmd: await refusalCmd({ role: "B", ref: "r-late-b", relayId: "relay-late-b" }), + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/already closed by the plane/); + // The proxy bound, the refused register released it: no socket remains and + // no slot was taken. + expect(existsSync(socketPath)).toBe(false); + expect(relay.size).toBe(0); + expect(relay.isTombstoned("r-late-b")).toBe(true); +}); + /* ------------------------------------------------------------------ */ /* startRelayResponder's own gates */ /* ------------------------------------------------------------------ */ diff --git a/apps/node/agent/src/commands/ssh-relay-exec.ts b/apps/node/agent/src/commands/ssh-relay-exec.ts index 6df0a3440..d657503a8 100644 --- a/apps/node/agent/src/commands/ssh-relay-exec.ts +++ b/apps/node/agent/src/commands/ssh-relay-exec.ts @@ -104,10 +104,14 @@ export async function execSshRelayOpen( }); return { ok: true, data: { role: "B", relayId: cmd.relayId, socketPath } as unknown as JsonValue }; } catch (err: unknown) { - // Named refusals (MOVED pin, malformed peer key, owned ref): the plane's - // openRelay turns the rejection into its own named refusal. The string - // travels the RESULT channel only - openBRelaySession's refusals name - // ids and the §4.5 remedy, never key material. + // Named refusals (MOVED pin, malformed peer key, owned ref, registry + // cap, already-closed ref): the plane's openRelay turns the rejection + // into its own named refusal. The string travels the RESULT channel - + // and the plane ALSO interpolates a rejected command's reason into the + // THROWN `SshRelayRefusal("handshake", ...)` that openRelay raises, so + // anything that ever logs either shape, at any level, inherits this as + // load-bearing: node refusals name ids and the §4.5 remedy, never key + // material (NIT, fix round T8). return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } @@ -117,7 +121,11 @@ export async function execSshRelayOpen( * named reason to the local owner (§5.6's endpoint line). Ungated by design * (module doc). An unknown ref answers `closed: false` - a close racing the * plane's own cut is routine, and a false answer to a redundant close is the - * honest record, not an error. + * honest record, not an error. The registry additionally TOMBSTONES an + * unknown ref for a bounded TTL (RelaySessions, fix round T8 MAJOR 2), so + * the close also disarms the one race that answer used to hide: a detached + * A-open that succeeds AFTER this cut can no longer register a responder + * nothing will ever close. */ export function execSshRelayClose(ctx: CommandContext, cmd: SshRelayCloseCommand): CommandResult { const relay = ctx.relay; diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index e2c30ee8d..3d928f453 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -2,6 +2,7 @@ import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; import { bytesOfJwk, type RelayFrame, + SSH_RELAY_LIFETIME_MS, SSH_RELAY_MAX_PER_NODE, type SshRelayOpenCommand, } from "@internal/subshell-protocol"; @@ -49,6 +50,18 @@ export interface RelaySessionHandler { close(reason: string): void; } +/** + * How long a close of a ref this machine never owned is remembered. The race + * it bounds (fix round T8 MAJOR 2): the A branch acks `pending: true` and its + * detached open probes worst-case ~20 s (two 10 s agent round trips), so the + * plane's `ssh_relay_close` can land while the open is still in flight - and + * the open would then REGISTER a responder nothing will ever close, because + * the plane already dropped the ref. A few multiples of the session lifetime + * covers command delivery plus the probe with room; the entry is a marker, + * not a resource, and it EXPIRES (nothing here grows unbounded). + */ +export const RELAY_CLOSE_TOMBSTONE_MS = 2 * SSH_RELAY_LIFETIME_MS; + /** * The per-daemon map from routing ref to its local owner. ONE instance lives * for the daemon's life (relay sessions outlive a link reconnect - the plane @@ -57,13 +70,29 @@ export interface RelaySessionHandler { * shares (Task 7). Frames for an unknown ref are dropped with a log line: a * stale ref (session already closed around the plane's own cut) is routine, * and the refusal the plane deserves is Task 8's broker's, not this map's. + * + * Two refusals beyond ownership, both for the openers' cleanup paths (the + * executors' try/catch around {@link register} releases whatever they just + * bound on EITHER refusal shape): + * - the {@link SSH_RELAY_MAX_PER_NODE} cap THROWS a named refusal (the + * machine saying no to a miscounting broker); + * - a ref closed while unowned is TOMBSTONED for {@link + * RELAY_CLOSE_TOMBSTONE_MS}, and {@link register} answers the dup-path + * false for it (the plane's cut beat a late open; the late open must not + * own a dead ref). */ export class RelaySessions { #byRef = new Map(); + /** Ref closed while unowned -> expiry in ms. TTL-bounded, swept at every touch. */ + #closedRefs = new Map(); #say: (line: string) => void; + #now: () => number; + #tombstoneMs: number; - constructor(logLine?: (line: string) => void) { + constructor(logLine?: (line: string) => void, opts: { nowMs?: () => number; tombstoneMs?: number } = {}) { this.#say = logLine ?? ((line: string): void => log(line)); + this.#now = opts.nowMs ?? Date.now; + this.#tombstoneMs = opts.tombstoneMs ?? RELAY_CLOSE_TOMBSTONE_MS; } /** Sessions currently owned (census/test surface). */ @@ -72,8 +101,10 @@ export class RelaySessions { } /** - * Take ownership of a ref. False when the ref is ALREADY owned - two live - * sessions on one routing ref is a broker bug, and the second opener's + * Take ownership of a ref. False when the ref is ALREADY owned, or when it + * carries a close tombstone (the plane closed this ref before the open + * landed; {@link isTombstoned} lets the refused opener name which) - two + * live sessions on one routing ref is a broker bug, and the second opener's * resources belong to ITSELF to release (its caller sees the false). * Throws a named refusal when this machine is already holding * {@link SSH_RELAY_MAX_PER_NODE} sessions and `ref` is NEW: the plane's @@ -81,10 +112,14 @@ export class RelaySessions { * node refusing to hold a 9th proxy socket off a miscounting broker * (defense-in-depth, Task 8 (g)). The throw path and the dup-ref false * path are deliberately different: an owned ref is a routing collision - * the CALLER unwinds, a full registry is the machine saying no. + * the CALLER unwinds, a full registry is the machine saying no. EITHER + * way, an opener that already bound a resource must release it on both + * shapes (fix round T8 MAJOR 1: the throw path once bypassed that). */ register(ref: string, handler: RelaySessionHandler): boolean { + this.#sweepTombstones(); if (this.#byRef.has(ref)) return false; + if (this.#closedRefs.has(ref)) return false; if (this.#byRef.size >= SSH_RELAY_MAX_PER_NODE) { throw new Error( `relay registry full: ${this.#byRef.size} live sessions on this machine (max ${SSH_RELAY_MAX_PER_NODE})`, @@ -104,6 +139,16 @@ export class RelaySessions { return this.#byRef.has(ref); } + /** + * Whether a close of this ref is currently remembered while the ref is + * unowned: the refused opener's discriminator between "already owned" and + * "the plane already closed it" (fix round T8 MAJOR 2). + */ + isTombstoned(ref: string): boolean { + this.#sweepTombstones(); + return this.#closedRefs.has(ref); + } + /** * THE single inbound relay dispatch (the Task-7-reused seam): route one * parsed frame to whichever local role owns its ref. A throwing owner is @@ -127,11 +172,20 @@ export class RelaySessions { /** * Close the session named by `ref` (what Task 8's `ssh_relay_close` * executor will call). True when a session was owned and closed, false for - * an unknown ref. + * an unknown ref - and an unknown ref is not merely ignored: the close is + * TOMBSTONED for {@link RELAY_CLOSE_TOMBSTONE_MS}, so a detached open that + * succeeds after the plane already cut can no longer own the ref (fix + * round T8 MAJOR 2). A close of an OWNED ref takes the normal path and + * tombstones nothing. */ close(ref: string, reason: string): boolean { + this.#sweepTombstones(); const handler = this.#byRef.get(ref); - if (!handler) return false; + if (!handler) { + this.#closedRefs.set(ref, this.#now() + this.#tombstoneMs); + this.#say(`relay close for ${ref} (${reason}): no local session; remembering the close against late opens`); + return false; + } this.#byRef.delete(ref); try { handler.close(reason); @@ -145,6 +199,19 @@ export class RelaySessions { closeAll(reason: string): void { for (const ref of [...this.#byRef.keys()]) this.close(ref, reason); } + + /** + * Drop every expired tombstone. Called at BOTH mutating touches (register + * and close), so the set is TTL-window-bounded rather than a ledger: a + * burst of closes for unknown refs ages out on the next touch and nothing + * is ever remembered past {@link RELAY_CLOSE_TOMBSTONE_MS}. + */ + #sweepTombstones(): void { + const now = this.#now(); + for (const [ref, expiresAt] of this.#closedRefs) { + if (expiresAt <= now) this.#closedRefs.delete(ref); + } + } } /** Everything {@link openBRelaySession} needs: the daemon's seams + the command's pairing + the pane. */ @@ -190,9 +257,12 @@ function decodePeerEncryptionJwk(b64: string): string { * A (first pairing pins both halves, a moved pin is a hard block), then bind * the pane's agent proxy socket and register its ref. Throws a named refusal * (the dispatch wrapper turns it into `ok:false`) on: role other than B, an - * unusable peer key, a MOVED pin (naming the peer and §4.5's re-pair), or a - * ref already owned. Resolves with the composed socket path - the value the - * launch's scoped `SSH_AUTH_SOCK` must match byte for byte (§5.2). + * unusable peer key, a MOVED pin (naming the peer and §4.5's re-pair), a ref + * already owned, a ref the plane already closed (tombstoned), or the + * node-side registry cap - and EVERY refusal releases the proxy this call + * just bound (fix round T8 MAJOR 1: a thrown cap refusal once bypassed that + * release). Resolves with the composed socket path - the value the launch's + * scoped `SSH_AUTH_SOCK` must match byte for byte (§5.2). * * The gate (ssh-enabled mirror) is the CALLER's first check, like every SSH * arm's: this function is the pairing's mechanics, not the policy. @@ -252,14 +322,26 @@ export async function openBRelaySession(args: BRelaySessionArgs): Promise<{ sock ...(args.log === undefined ? {} : { log: args.log }), }); - if ( - !args.relay.register(cmd.ref, { - onRelayFrame: (f) => proxy.deliverInboundRelayFrame(f), - close: () => proxy.close(), - }) - ) { + try { + if ( + !args.relay.register(cmd.ref, { + onRelayFrame: (f) => proxy.deliverInboundRelayFrame(f), + close: () => proxy.close(), + }) + ) { + throw new Error( + args.relay.isTombstoned(cmd.ref) + ? "relay open refused: routing ref already closed by the plane (late open dropped)" + : "relay open refused: routing ref already owned by a live session", + ); + } + } catch (err) { + // EVERY refused register releases the just-bound proxy: the dup/tombstone + // false path as before, and the THROWN cap refusal too - a refused open + // must never leave a listening fd and a socket name behind (fix round T8 + // MAJOR 1; proxy.close() is idempotent). proxy.close(); - throw new Error("relay open refused: routing ref already owned by a live session"); + throw err; } return { socketPath: proxy.socketPath }; } @@ -305,7 +387,10 @@ export interface ARelaySessionArgs { * enforces it without a REST call it cannot make) and register the ref. * Throws a named refusal (the dispatch wrapper turns it into `ok:false`) on: * role other than A, a pairing that names a different machine as A, an - * unusable peer key, a MOVED pin, or a ref already owned. A probe failure is + * unusable peer key, a MOVED pin, a ref already owned, a ref the plane + * already closed (tombstoned - the race this branch's detached open makes + * real, fix round T8 MAJOR 2), or the node-side registry cap - and EVERY + * refusal closes the responder it just started. A probe failure is * NOT a throw: the session opens refuse-closed. Resolves with the session id * - nothing about the machine is in the answer, and no path exists to leak. * @@ -388,14 +473,26 @@ export async function openARelaySession(args: ARelaySessionArgs): Promise<{ rela ...(args.log === undefined ? {} : { log: args.log }), }); - if ( - !args.relay.register(cmd.ref, { - onRelayFrame: (f) => responder.deliverInboundRelayFrame(f), - close: (reason) => responder.close(reason), - }) - ) { + try { + if ( + !args.relay.register(cmd.ref, { + onRelayFrame: (f) => responder.deliverInboundRelayFrame(f), + close: (reason) => responder.close(reason), + }) + ) { + throw new Error( + args.relay.isTombstoned(cmd.ref) + ? "relay open refused: routing ref already closed by the plane (late open dropped)" + : "relay open refused: routing ref already owned by a live session", + ); + } + } catch (err) { + // Symmetric with the B branch (fix round T8 MAJOR 1): the dup false path + // closed the responder before, and the THROWN cap refusal closes it too. + // The A responder holds no fd, but a refused open must never leave one + // alive and unregistered to classify bytes forever. responder.close(); - throw new Error("relay open refused: routing ref already owned by a live session"); + throw err; } return { relayId: cmd.relayId }; } From 6d10cfb694a26c2f2c1ccc33fb7a7cccb5a125ad Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:21:26 -0700 Subject: [PATCH 39/92] test(ssh-relay): typecheck seam for the agent relay sources the integration test drives The relay integration matrix imports the agent's relay modules IN PLACE, so the server typecheck project must admit files outside src/ (rootDir/outDir move to tsconfig.build.json, which keeps the shipped emit byte-identical, diffed) and carry the one WebCrypto global (JsonWebKey) those sources name: supplied by a test-scoped shim instead of DOM lib, which re-types Blob across seven server files (measured). The build config excludes every __tests__ path, so neither seam can reach the shipped binary. --- .../helpers/agent-relay-globals.d.ts | 37 +++++++++++++++++++ apps/server/api/tsconfig.build.json | 12 ++++-- apps/server/api/tsconfig.json | 10 ++++- 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts diff --git a/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts b/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts new file mode 100644 index 000000000..2052fd47c --- /dev/null +++ b/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts @@ -0,0 +1,37 @@ +/** + * Ambient globals the agent's relay sources reference but this project's + * typecheck lib (ES2023, no DOM) does not declare. + * + * The relay integration test imports the agent's relay modules (under + * apps/node/agent) IN PLACE, so `verify-types` typechecks those sources under + * this app's `lib`. The agent's own tsconfig carries `lib: ["ES2023", "DOM"]`, + * which is where it gets the WebCrypto `JsonWebKey` global; this app + * deliberately does not add DOM (it changes `Blob`/`Response` typing across + * seven server files, measured). Adding only the one name the agent relay + * sources actually use keeps the shim to its smallest honest size. + * + * Lives under `__tests__` so `tsc -p tsconfig.build.json` (which excludes + * `src/**/ __tests__; /**`) never sees it: the shipped server emit is untouched. + */ + +/** The WebCrypto JSON Web Key shape, as lib.dom declares it (agent relay only). */ +interface JsonWebKey { + alg?: string; + crv?: string; + d?: string; + dp?: string; + dq?: string; + e?: string; + ext?: boolean; + k?: string; + key_ops?: string[]; + kty?: string; + n?: string; + oth?: Array<{ d?: string; n?: string; t?: string }>; + p?: string; + q?: string; + qi?: string; + use?: string; + x?: string; + y?: string; +} diff --git a/apps/server/api/tsconfig.build.json b/apps/server/api/tsconfig.build.json index d84e3e347..673468734 100644 --- a/apps/server/api/tsconfig.build.json +++ b/apps/server/api/tsconfig.build.json @@ -1,9 +1,11 @@ { "extends": "./tsconfig.json", "//": [ - "Build-only config. Identical to tsconfig.json except that test files and", - "the cross-suite fixtures under src/test-helpers/ are excluded from emit,", - "so dist/ contains shipping code only.", + "Build-only config. Adds the emit layout (rootDir/outDir, kept out of the", + "shared config below so the relay integration test can import agent", + "sources outside src/ without a TS6059) and excludes test files and the", + "cross-suite fixtures under src/test-helpers/ from emit, so dist/ contains", + "shipping code only.", "", "Two things depended on this: `prod` runs from dist/, so compiled tests were", "being shipped; and a bare `bun test` picked up both src/ and dist/ copies,", @@ -12,5 +14,9 @@ "verify-types deliberately stays on tsconfig.json (the full project) so test", "files are still type-checked — they just aren't emitted." ], + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, "exclude": ["src/**/__tests__/**", "src/test-helpers/**", "src/test-preload.ts"] } diff --git a/apps/server/api/tsconfig.json b/apps/server/api/tsconfig.json index 550f4b7f6..ae790a6da 100644 --- a/apps/server/api/tsconfig.json +++ b/apps/server/api/tsconfig.json @@ -1,9 +1,15 @@ { "extends": "@internal/tsconfig/tsconfig.json", "include": ["src/**/*"], + "//": [ + "No rootDir/outDir here (the typecheck config): the relay integration test", + "under src/**/__tests__ imports the agent's relay modules from", + "apps/node/agent IN PLACE, and an emit layout (explicit or inferred from", + "outDir) makes every such file a TS6059. The build config pins both.", + "The DOM lib (which the agent's own tsconfig carries too) supplies the", + "WebCrypto globals (JsonWebKey) the agent's relay modules reference." + ], "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", "module": "NodeNext", "moduleResolution": "node16", "paths": { From ad0b53cd8e4c807b4c11cf059090fda210fe086c Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:21:29 -0700 Subject: [PATCH 40/92] test(ssh-relay): relay integration matrix, replay inertness, reverse verification Nine whole-stack cases over two real machine identities, the real B proxy socket, the real A responder over a scheme-behavior stub agent, and the real plane broker behind a structurally-blind fake plane (no private key, no opener, JSON text only): granted sign round trip sealed end to end, the method allow-list over the socket, wrong-origin refusal, scope-before-forward after the filtered roster, byte-opacity of the exchange at the plane (wire, audits, session view, broker log), replay inertness inside the window and into a reused routing ref, the multi-key session at strictly increasing signed seqs, non-increasing seq refusal both directions, and forged/garbled A2B refusal at B. Matrix case 10 (roster command) is a named todo for Task 11. The stack fixture rides the T6/T7 scheme-byte posture. --- .../__tests__/helpers/ssh-relay-stack.ts | 580 +++++++++++++++++ .../__tests__/ssh-relay.integration.test.ts | 595 ++++++++++++++++++ 2 files changed, 1175 insertions(+) create mode 100644 apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts diff --git a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts new file mode 100644 index 000000000..e39597172 --- /dev/null +++ b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts @@ -0,0 +1,580 @@ +import { mkdtempSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { + base64UrlNoPad, + type NodeCommandBody, + openRelayEnvelope, + type RelayFrame, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import type { AuditEventInput } from "@/services/audit.js"; +import { + createRelayBroker, + type OpenRelayResult, + type RelayBroker, + type TimerHandle, +} from "@/services/ssh-relay.service.js"; +import { + openARelaySession, + openBRelaySession, + RelaySessions, +} from "../../../../../../node/agent/src/commands/ssh-relay.js"; +import { loadOrCreateIdentity, type NodeIdentity } from "../../../../../../node/agent/src/identity.js"; +import { + type AgentScheme, + fingerprintAgentBlob, + OPENSSH_10X_SCHEME, +} from "../../../../../../node/agent/src/relay-agent-scheme.js"; + +/** + * The whole-stack fixture for `ssh-relay.integration.test.ts` (spec 2026-10-08 + * M2 Task 9): two in-process machine identities (real `loadOrCreateIdentity` + * keypairs in real temp data dirs), a REAL B-side agent proxy, a REAL A-side + * responder fed by a scheme-behavior stub agent, and the REAL plane broker + * (`createRelayBroker`) wired to both through fake transport seams. + * + * **The fake plane is blind by construction.** It is given no private key of + * any machine, no opener, and no codec call: the node-to-plane and + * plane-to-node seams serialize the frame to JSON text and re-parse it (the + * socket is what a plane actually holds), and the broker forwards `blob` by + * reference without reading it. Everything the plane can see is enumerated on + * `Stack.planeVisible()`: wire capture lines, audit rows, session views, and + * its own log lines. + * + * The agent-byte builders and the stub agent below are the T6/T7 fixture + * spellings (measured OpenSSH_10.2p1 behavior, hand-spelled byte literals), + * reused rather than re-invented; the scheme constants come from the agent's + * own `relay-agent-scheme.ts`. + */ + +/* ------------------------------------------------------------------ */ +/* the agent wire, spelled by hand (T7 fixture posture) */ +/* ------------------------------------------------------------------ */ + +/** The roster's granted key: its blob embeds the ASCII marker "KEY-IN". */ +export const KEY_IN = Buffer.concat([ + Buffer.from([0, 0, 0, 7]), + Buffer.from("ssh-ed25519"), + sshStr(Buffer.from("KEY-IN")), +]); +/** The roster's ungranted key: the scope-before-forward witness blob. */ +export const KEY_OUT = Buffer.concat([ + Buffer.from([0, 0, 0, 7]), + Buffer.from("ssh-ed25519"), + sshStr(Buffer.from("KEY-OUT")), +]); + +/** One roster entry as the stub agent carries it. */ +export interface RosterEntry { + blob: Buffer; + comment: string; +} + +/** The stub's default roster: one granted key, one ungranted key. */ +export const ROSTER: RosterEntry[] = [ + { blob: KEY_IN, comment: "granted key" }, + { blob: KEY_OUT, comment: "ungranted key" }, +]; + +/** An independent fingerprint oracle: SHA-256 over the wire blob, base64url. */ +export function fp(wire: Uint8Array): string { + return fingerprintAgentBlob(wire); +} + +/** 4-byte big-endian length. */ +export function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +/** An SSH string on the agent wire: 4-byte BE length + bytes. */ +export function sshStr(bytes: Buffer | string): Buffer { + const b = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes; + return Buffer.concat([be32(b.length), b]); +} + +/** A framed agent message: 4-byte BE length + payload (the ssh-agent wire). */ +export function framed(payload: Buffer): Buffer { + return Buffer.concat([be32(payload.length), payload]); +} + +/** An IDENTITIES_ANSWER in the stub's scheme. */ +export function buildIdentitiesAnswer(scheme: AgentScheme, entries: RosterEntry[]): Buffer { + return Buffer.concat([ + Buffer.from([scheme.answer]), + be32(entries.length), + ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), + ]); +} + +/** A classic-scheme SIGN_REQUEST (15): blob, data, flags. */ +export function buildSignRequestClassic(keyBlob: Buffer, data = Buffer.from("DATA"), flags = 0): Buffer { + return Buffer.concat([Buffer.from([15]), sshStr(keyBlob), sshStr(data), be32(flags)]); +} + +/** A 10.x-scheme SIGN_REQUEST (13): blob, data, flags, optional algorithms. */ +export function buildSignRequestTenX( + keyBlob: Buffer, + opts: { data?: Buffer; flags?: number; algorithms?: string } = {}, +): Buffer { + const parts = [Buffer.from([13]), sshStr(keyBlob), sshStr(opts.data ?? Buffer.from("DATA")), be32(opts.flags ?? 0)]; + if (opts.algorithms !== undefined) parts.push(sshStr(opts.algorithms)); + return Buffer.concat(parts); +} + +/** Decode an IDENTITIES_ANSWER independently of the production parser. */ +export function parseAnswer(answer: Buffer): { type: number; count: number; comments: string[] } { + const type = answer[0]; + const count = answer.readUInt32BE(1); + const comments: string[] = []; + let off = 5; + for (let i = 0; i < count; i += 1) { + const blobLen = answer.readUInt32BE(off); + off += 4 + blobLen; + const cLen = answer.readUInt32BE(off); + comments.push(answer.subarray(off + 4, off + 4 + cLen).toString("utf8")); + off += 4 + cLen; + } + return { type, count, comments }; +} + +/* ------------------------------------------------------------------ */ +/* the stub live agent (T7 fixture, 10.x by default) */ +/* ------------------------------------------------------------------ */ + +/** A scheme-behavior stub agent socket: identities answers, well-formed signs answer SIGN_RESPONSE("SIG"). */ +export interface StubAgent { + path: string; + /** Every request payload the stub received, in order (the open-time probes included). */ + received: Buffer[]; + close(): Promise; +} + +async function startStubAgent(roster: RosterEntry[], scheme: AgentScheme): Promise { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-stack-stub-")); + const path = join(dir, "agent.sock"); + const received: Buffer[] = []; + const server: Server = createServer((conn: Socket) => { + let buffer = Buffer.alloc(0); + conn.on("data", (chunk: Buffer) => { + buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + for (;;) { + if (buffer.length < 4) return; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return; + const request = Buffer.from(buffer.subarray(4, 4 + len)); + buffer = buffer.subarray(4 + len); + received.push(request); + const type = request[0]; + if (request.length === 1 && type === scheme.identities) { + conn.write(framed(buildIdentitiesAnswer(scheme, roster))); + } else if (type === scheme.sign && isWellFormedSignBody(request, scheme)) { + conn.write(framed(Buffer.concat([Buffer.from([scheme.signResponse]), sshStr(Buffer.from("SIG"))]))); + } else { + conn.write(framed(Buffer.from([5]))); + } + } + }); + conn.on("error", () => { + /* the responder closes per request; the stub never throws */ + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => resolve()); + server.listen(path); + }); + return { path, received, close: () => new Promise((r) => server.close(() => r())) }; +} + +/** The stub's own sign-body validity check (mirrors the real agent's parser). */ +function isWellFormedSignBody(p: Buffer, scheme: AgentScheme): boolean { + let off = 1; + const str = (): boolean => { + if (off + 4 > p.length) return false; + const n = p.readUInt32BE(off); + off += 4; + if (off + n > p.length) return false; + off += n; + return true; + }; + if (!str()) return false; // blob + if (off <= 5) return false; // empty blob length means an empty string: refuse + if (!str()) return false; // data + if (off + 4 > p.length) return false; // flags + off += 4; + if (off === p.length) return true; + if (!scheme.extendedSign) return false; + if (!str()) return false; // algorithms + return off === p.length; +} + +/* ------------------------------------------------------------------ */ +/* the stack: broker + A machine + B machine over blind transport */ +/* ------------------------------------------------------------------ */ + +/** The pane the brokered pairing serves: a path-id spelling both grammars accept. */ +export const PANE_ID = "11111111-2222-4333-8444-555555555555"; + +/** One recorded crossing of the plane: the JSON text and the node it landed on. */ +export interface WireEntry { + /** True for plane-to-node deliveries (the fake link's outbound half). */ + outbound: boolean; + /** The node this text reached (or left). */ + nodeId: "a-node" | "b-node"; + /** The JSON text exactly as the fake socket carried it. */ + raw: string; +} + +/** What {@link openStack} builds: the live stack plus everything it observed. */ +export interface Stack { + broker: RelayBroker; + /** The broker's successful open result (ref, relayId, verified socketPath). */ + result: OpenRelayResult; + /** The A machine's real identity (registered public halves live here). */ + aId: NodeIdentity; + /** The B machine's real identity. */ + bId: NodeIdentity; + /** The A machine's data dir (its identity and pin store live here). */ + aDir: string; + /** The B machine's data dir (the proxy socket was bound under here). */ + bDir: string; + /** The A-side node registry (responder under the brokered ref). */ + aRelay: RelaySessions; + /** The B-side node registry (agent proxy under the brokered ref). */ + bRelay: RelaySessions; + /** The stub agent A forwards granted requests to. */ + stub: StubAgent; + /** Requests the stub received AFTER the open-time probe. */ + forwarded(): Buffer[]; + /** Every JSON text that crossed the plane, both directions, in order. */ + wire: WireEntry[]; + /** Audit rows the broker wrote, metadata re-serialized from the JSON string. */ + audits: { action: string; metadata: Record; metadataJson: string }[]; + /** Broker log lines (plane-visible text). */ + brokerLines: string[]; + /** Commands the broker sent (plane-side composites; the open grammar's own view). */ + commands: { nodeId: string; cmd: NodeCommandBody }[]; + /** + * Plane-visible text for the byte-opacity scan: wire captures, audit rows, + * session views, broker log lines. Never the machines' innards. + */ + planeVisible(): string[]; + /** + * Simulated plane outage: while true, inbound B2A frames are captured but + * not routed (A answers nothing until `releaseB2A` runs). + */ + swallowB2A: boolean; + /** Route every swallowed B2A frame now (A's late answers flow). */ + releaseB2A(): void; + /** Deliver one frame INTO the plane as if `nodeId`'s socket carried it. */ + routeFromNode(nodeId: "a-node" | "b-node", frame: RelayFrame): void; + /** + * Run the named close on BOTH node registries WITHOUT the broker's own + * teardown: the machines end the session while the plane's blind lane for + * the ref stays up (still routing anything that re-presents it). The + * ref-reuse replay case drives exactly this resurrection. + */ + closeNodeSessions(ref: string, reason: string): void; + /** Tear everything down (registries, stub socket, broker timers). */ + cleanup(): Promise; +} + +/** Options for {@link openStack}. */ +export interface StackOptions { + /** The grant's fingerprint set (default: the one granted key). */ + fingerprints?: readonly string[]; + /** The stub's roster (default: {@link ROSTER}). */ + roster?: RosterEntry[]; + /** The numbering the stub agent speaks (default: the fleet's measured 10.x). */ + scheme?: AgentScheme; +} + +/** + * Open the whole stack: real broker, real node branches, real identities, a + * stub agent, and the blind fake plane. Resolves after the handshake + * completed (both opens acked, B's socket path byte-verified by the broker + * itself, so a returned stack means the plane's acceptance (e) check passed + * against the proxy's real bound path). + */ +export async function openStack(opts: StackOptions = {}): Promise { + const scheme = opts.scheme ?? OPENSSH_10X_SCHEME; + const fingerprints = opts.fingerprints ?? [fp(KEY_IN)]; + const aDir = mkdtempSync(join(tmpdir(), "subshell-relay-stack-a-")); + const bDir = mkdtempSync(join(tmpdir(), "subshell-relay-stack-b-")); + // The machines' REAL identities: signing + encryption keypairs as identity.ts + // generates and stores them; the broker pairs their registered public halves. + const aId = await loadOrCreateIdentity(aDir); + const bId = await loadOrCreateIdentity(bDir); + const stub = await startStubAgent(opts.roster ?? ROSTER, scheme); + + const aRelay = new RelaySessions(); + const bRelay = new RelaySessions(); + const wire: WireEntry[] = []; + const audits: Stack["audits"] = []; + const brokerLines: string[] = []; + const commands: { nodeId: string; cmd: NodeCommandBody }[] = []; + let swallowedB2A: RelayFrame[] = []; + + /** Plane-to-node: serialize like a socket would, re-parse, hand to the node. */ + const deliverToNode = (nodeId: "a-node" | "b-node", frame: RelayFrame): void => { + const raw = JSON.stringify(frame); + wire.push({ outbound: true, nodeId, raw }); + const parsed = JSON.parse(raw) as RelayFrame; + (nodeId === "a-node" ? aRelay : bRelay).onInboundRelayFrame(parsed); + }; + + /** Node-to-plane: the handler seam; the outage swallows B2A while armed. */ + const routeFromNode = (nodeId: "a-node" | "b-node", frame: RelayFrame): void => { + const raw = JSON.stringify(frame); + wire.push({ outbound: false, nodeId, raw }); + if (nodeId === "b-node" && frame.direction === "B2A" && stack.swallowB2A) { + swallowedB2A.push(JSON.parse(raw) as RelayFrame); + return; + } + broker.routeRelayFrame(nodeId, frame); + }; + + const sendCommand = async (nodeId: string, cmd: NodeCommandBody): Promise => { + commands.push({ nodeId, cmd }); + if (cmd.type === "ssh_relay_open") { + if (cmd.role === "A") { + await openARelaySession({ + relay: aRelay, + dataDir: aDir, + selfNodeId: "a-node", + cmd, + sendRelayFrame: (f) => routeFromNode("a-node", f), + resolveAgentSocket: () => stub.path, + }); + return { relayId: cmd.relayId }; + } + const { socketPath } = await openBRelaySession({ + relay: bRelay, + dataDir: bDir, + selfNodeId: "b-node", + paneId: cmd.paneId, + cmd, + sendRelayFrame: (f) => routeFromNode("b-node", f), + }); + return { socketPath }; + } + if (cmd.type === "ssh_relay_close") { + (nodeId === "a-node" ? aRelay : bRelay).close(cmd.ref, cmd.reason); + return { ok: true }; + } + throw new Error(`fake node ${nodeId}: unexpected command ${String(cmd.type)}`); + }; + + const broker = createRelayBroker({ + sendCommand, + sendRelayFrame: deliverToNode, + async nodeRow(nodeId) { + return nodeId === "a-node" || nodeId === "b-node" ? { kind: "agent", sshEnabled: 1 } : null; + }, + nodeDataDir: (nodeId) => (nodeId === "b-node" ? bDir : null), + async audit(e: AuditEventInput) { + audits.push({ + action: e.action, + metadata: JSON.parse(e.metadataJson ?? "{}") as Record, + metadataJson: e.metadataJson ?? "", + }); + }, + nowMs: () => 1_730_000_000_000, // fixed clock: no timer in the matrix should ever fire + schedule: (): TimerHandle => { + const handle: { fn?: () => void } = {}; + return handle; // never fired; cancel is a no-op + }, + cancel: () => {}, + log: (line) => brokerLines.push(line), + }); + + const result = await broker.openRelay({ + grantId: "grant-1", + fingerprints, + paneId: PANE_ID, + aNode: "a-node", + bNode: "b-node", + aPeer: { signingPublicKey: aId.signingPublicJwk, encryptionPublicJwk: aId.publicJwk }, + bPeer: { signingPublicKey: bId.signingPublicJwk, encryptionPublicJwk: bId.publicJwk }, + }); + + const probeCount = stub.received.length; // whatever the open-time numbering probe asked + + const stack: Stack = { + broker, + result, + aId, + bId, + aDir, + bDir, + aRelay, + bRelay, + stub, + forwarded: () => stub.received.slice(probeCount), + wire, + audits, + brokerLines, + commands, + planeVisible: () => [ + ...wire.map((w) => w.raw), + ...audits.map((a) => a.metadataJson), + ...brokerLines, + JSON.stringify(broker.sessionInfo(result.ref)), + ], + swallowB2A: false, + releaseB2A() { + const pending = swallowedB2A; + swallowedB2A = []; + for (const f of pending) broker.routeRelayFrame("b-node", f); + }, + routeFromNode, + closeNodeSessions(ref, reason) { + // Closes ONLY the node-registry handlers for the ref (owned close, so + // no tombstone). The broker's own session for the ref is left intact: + // the plane routes by ref and knows nothing of relay ids, so a ref + // re-presented by restarted endpoints is a plane-level event the + // broker must keep routing while the ENDPOINTS refuse the old bytes. + aRelay.close(ref, reason); + bRelay.close(ref, reason); + }, + async cleanup() { + bRelay.closeAll("child-exit"); + aRelay.closeAll("child-exit"); + broker.reset(); + await stub.close(); + }, + }; + return stack; +} + +/* ------------------------------------------------------------------ */ +/* wire-side decoders: open + verify one captured frame the peer way */ +/* ------------------------------------------------------------------ */ + +/** What {@link decodeB2A}/{@link decodeA2B} return: the plaintext agent bytes + the bindings. */ +export interface DecodedFrame { + agentBytes: Buffer; + seq: number; + /** B's endpoint nonce: carried by every B2A AND echoed in every A2B (§5.6). */ + nB: string; + /** A's endpoint nonce: absent only from the session's first request. */ + nA?: string; +} + +/** + * Open + verify a B2A frame AS A did: B's signing key is the pinned origin, + * A's real encryption private half opens the envelope. Throws exactly where + * A's responder threw. + */ +export async function decodeB2A(s: Stack, frame: RelayFrame): Promise { + const opened = await openRelayEnvelope({ + blob: frame.blob, + own: { principalId: "node:a-node", publicJwk: s.aId.publicJwk, privateJwk: s.aId.privateJwk }, + open: mcpOpen, + expect: { ref: frame.ref, direction: frame.direction }, + }); + const message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: JSON.parse(s.bId.signingPublicJwk) as JsonWebKey, + expect: { routingRef: frame.ref, direction: "B2A", seq: opened.seq, relaySessionId: s.result.relayId }, + }); + return { + agentBytes: Buffer.from(message.agentBytesB64, "base64url"), + seq: opened.seq, + nB: message.nB ?? "", + ...(message.nA === undefined ? {} : { nA: message.nA }), + }; +} + +/** + * Open + verify an A2B frame AS B did: A's signing key is the pinned origin, + * B's real encryption private half opens the envelope. The witness for the + * matrix's "B can open it with A's pinned encryption key" assertions. + */ +export async function decodeA2B(s: Stack, frame: RelayFrame): Promise { + const opened = await openRelayEnvelope({ + blob: frame.blob, + own: { principalId: "node:b-node", publicJwk: s.bId.publicJwk, privateJwk: s.bId.privateJwk }, + open: mcpOpen, + expect: { ref: frame.ref, direction: frame.direction }, + }); + const message = await verifyRelayEnvelope({ + jws: opened.jws, + publicJwk: JSON.parse(s.aId.signingPublicJwk) as JsonWebKey, + expect: { routingRef: frame.ref, direction: "A2B", seq: opened.seq, relaySessionId: s.result.relayId }, + }); + return { + agentBytes: Buffer.from(message.agentBytesB64, "base64url"), + seq: opened.seq, + nB: message.nB ?? "", + ...(message.nA === undefined ? {} : { nA: message.nA }), + }; +} + +/** Seal one B2A request with the given signer (evil signer = the wrong-origin attack). */ +export async function sealB2A( + s: Stack, + opts: { agentBytes: Buffer; seq: number; nB: string; nA?: string; signerPrivateJwk: string }, +): Promise { + const blob = await sealRelayEnvelope({ + message: { + relaySessionId: s.result.relayId, + routingRef: s.result.ref, + direction: "B2A", + seq: opts.seq, + nB: opts.nB, + ...(opts.nA === undefined ? {} : { nA: opts.nA }), + agentBytesB64: base64UrlNoPad(new Uint8Array(opts.agentBytes)), + }, + privateJwk: JSON.parse(opts.signerPrivateJwk) as JsonWebKey, + recipient: { principalId: "node:a-node", publicJwk: s.aId.publicJwk }, + seal: mcpSeal, + }); + return { type: "relay", ref: s.result.ref, seq: opts.seq, direction: "B2A", blob }; +} + +/** Seal one A2B reply with the given signer (evil signer = the forged-reply attack). */ +export async function sealA2B( + s: Stack, + opts: { agentBytes: Buffer; seq: number; nB: string; nA?: string; signerPrivateJwk: string }, +): Promise { + const blob = await sealRelayEnvelope({ + message: { + relaySessionId: s.result.relayId, + routingRef: s.result.ref, + direction: "A2B", + seq: opts.seq, + nB: opts.nB, + ...(opts.nA === undefined ? {} : { nA: opts.nA }), + agentBytesB64: base64UrlNoPad(new Uint8Array(opts.agentBytes)), + }, + privateJwk: JSON.parse(opts.signerPrivateJwk) as JsonWebKey, + recipient: { principalId: "node:b-node", publicJwk: s.bId.publicJwk }, + seal: mcpSeal, + }); + return { type: "relay", ref: s.result.ref, seq: opts.seq, direction: "A2B", blob }; +} + +/* ------------------------------------------------------------------ */ +/* tiny async helpers */ +/* ------------------------------------------------------------------ */ + +/** Sleep. */ +export async function sleep(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)); +} + +/** Wait until `cond` holds or throw with a named timeout. */ +export async function waitUntil(cond: () => boolean, what: string, timeoutMs = 3000): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > timeoutMs) throw new Error(`timed out waiting for ${what}`); + await sleep(5); + } +} diff --git a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts new file mode 100644 index 000000000..d8fd9cd98 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts @@ -0,0 +1,595 @@ +import { expect, test } from "bun:test"; +import { createConnection, type Socket } from "node:net"; +import { buildAgentSocketPath } from "@internal/pane-runtime"; +import { + base64UrlNoPad, + newNonce, + parseRelayFrame, + type RelayFrame, + SSH_RELAY_LIFETIME_MS, + type SshRelayOpenCommand, +} from "@internal/subshell-protocol"; +import { exportJWK, generateKeyPair } from "jose"; +import { openARelaySession, openBRelaySession } from "../../../../../node/agent/src/commands/ssh-relay.js"; +import { + buildSignRequestTenX, + decodeA2B, + decodeB2A, + fp, + framed, + KEY_IN, + KEY_OUT, + openStack, + PANE_ID, + parseAnswer, + type Stack, + sealA2B, + sealB2A, + sleep, + sshStr, + waitUntil, +} from "./helpers/ssh-relay-stack.js"; + +/** + * The M2 relay integration matrix (spec 2026-10-08, Task 9): the whole stack + * at once. Two REAL machine identities (`loadOrCreateIdentity` in temp data + * dirs), the REAL B proxy socket, the REAL A responder over a stub agent, and + * the REAL plane broker, wired through a fake plane that is blind by + * construction: it holds no private key, imports no opener, and touches only + * JSON text ({type, ref, seq, direction, blob}) - the envelope is base64 + * ciphertext to it, as it is to the shipped broker. + * + * What each case asserts is observable bytes and side effects: the stub + * agent's received list (what A's agent was actually asked), the pane + * socket's received frames (what the pane's ssh actually got), the plane's + * wire capture, and the serialized audit rows. Case 6 (replay inertness), + * case 3 (wrong origin) and case 4 (scope-before-forward) are the crown + * jewels: a replayed envelope must be inert, a foreign signature must not + * reach the key home, and an ungranted blob must never be put in front of + * A's agent, roster answer first or not. + * + * Matrix case 10 (the `ssh_agent_identities` roster command accepted with no + * grant) is DEFERRED to Task 11, which builds that command; the todo at the + * bottom names it. + */ + +/** An ES256 pair pinned by nobody: signatures from it are forgeries. */ +const evilReady: Promise<{ privateJwk: string; publicJwk: string }> = generateKeyPair("ES256", { + extractable: true, +}).then(async ({ publicKey, privateKey }) => ({ + publicJwk: JSON.stringify(await exportJWK(publicKey)), + privateJwk: JSON.stringify(await exportJWK(privateKey)), +})); + +/** The b64-of-UTF8 spelling the open command carries the peer encryption key in. */ +function b64Jwk(jwkJson: string): string { + return Buffer.from(jwkJson, "utf8").toString("base64"); +} + +/** The pane's ssh end: connect, collect framed replies, observe death. */ +function paneAt(socketPath: string): { + conn: Socket; + closed: () => boolean; + payloads: () => Buffer[]; + destroy(): void; +} { + const conn = createConnection({ path: socketPath }); + const chunks: Buffer[] = []; + let closed = false; + conn.on("data", (d) => chunks.push(Buffer.from(d))); + conn.on("close", () => { + closed = true; + }); + conn.on("error", () => { + /* failures are read through the close/data events, never thrown */ + }); + return { + conn, + closed: () => closed, + payloads: (): Buffer[] => { + const buf = Buffer.concat(chunks); + const out: Buffer[] = []; + let off = 0; + while (off + 4 <= buf.length) { + const len = buf.readUInt32BE(off); + if (off + 4 + len > buf.length) break; + out.push(buf.subarray(off + 4, off + 4 + len)); + off += 4 + len; + } + return out; + }, + destroy: () => conn.destroy(), + }; +} + +/** The frames the plane RECEIVED (node-to-plane legs), decoded, in order. */ +function ingress(s: Stack): RelayFrame[] { + return s.wire.filter((w) => !w.outbound).map((w) => JSON.parse(w.raw) as RelayFrame); +} + +/** A2B envelopes A actually PRODUCED (the plane saw them leave A's socket). */ +function producedByA(s: Stack): number { + return s.wire.filter((w) => !w.outbound && w.nodeId === "a-node").length; +} + +/** Change the first base64 character: same length, valid alphabet, broken first byte. */ +function garbleBlob(blob: string): string { + const replacement = blob[0] === "A" ? "B" : "A"; + return replacement + blob.slice(1); +} + +/* ------------------------------------------------------------------ */ +/* 1. the happy path: a granted sign round trips sealed */ +/* ------------------------------------------------------------------ */ + +test("1. a granted SIGN_REQUEST from the pane reaches A's agent and the signature comes back sealed: B opens it with A's pinned encryption key", async () => { + const s = await openStack(); + try { + // The broker's byte-check (acceptance (e)) already ran inside openRelay: + // the returned path IS the proxy's real bound path, equal to the plane's + // own derivation from B's dataDir and the command's pane. + expect(s.result.socketPath).toBe(buildAgentSocketPath(s.bDir, PANE_ID)); + const p = paneAt(s.result.socketPath); + const request = buildSignRequestTenX(KEY_IN, { algorithms: "ssh-ed25519" }); + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 1, "the SIGN_RESPONSE on the pane socket"); + expect([...p.payloads()[0]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); // measured 10.x bytes + expect(s.forwarded()).toEqual([request]); // A's agent saw EXACTLY the scoped request, byte-identical + + const inF = ingress(s); + expect(inF.length).toBe(2); // one B2A, one A2B; nothing else crossed + expect(parseRelayFrame(JSON.stringify(inF[0]))).not.toBeNull(); + expect(inF[0].ref).toBe(s.result.ref); + expect(inF[0].direction).toBe("B2A"); + expect(inF[1].direction).toBe("A2B"); + + // Decode the pair independently, the way each endpoint's checklist does. + const asA = await decodeB2A(s, inF[0]); + expect(asA.agentBytes).toEqual(request); + expect(asA.nB).toMatch(/^[A-Za-z0-9_-]{22}$/); // B minted its endpoint nonce + expect(asA.nA).toBeUndefined(); // B spoke first; A's nonce is not in a request yet + const asB = await decodeA2B(s, inF[1]); // opens with B's own pinned encryption half + expect(asB.agentBytes).toEqual(p.payloads()[0]); // what B can OPEN is what B's pane receives + expect(asB.nA).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(asB.nB).toBe(asA.nB); // the SAME session nonces ride both directions + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 2. the method allow-list end to end */ +/* ------------------------------------------------------------------ */ + +test("2. a non-allow-listed agent method (EXTENSION) is refused at A and never forwarded, over the real socket", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + p.conn.write(framed(Buffer.concat([Buffer.from([27]), sshStr("ssh-agent-extension-op"), Buffer.from([1])]))); + await waitUntil(() => p.payloads().length === 1, "the clean refusal"); + expect([...p.payloads()[0]]).toEqual([5]); // SSH2_AGENT_FAILURE, never a hang + await sleep(30); + expect(s.forwarded()).toEqual([]); // A's agent was never asked + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 3. wrong origin: an unpinned machine key must not reach the key home */ +/* ------------------------------------------------------------------ */ + +test("3. a B2A envelope signed by a machine key that is not B's pin opens at A but verifies nowhere and reaches nothing", async () => { + const s = await openStack(); + const evil = await evilReady; + try { + // The plane (or anyone) holds A's PUBLIC encryption key and can seal at + // will (spec §5.6's first line): the envelope opens. Only the signature + // can name the origin, and the evil key is not B's pin. + const forged = await sealB2A(s, { + agentBytes: Buffer.from([11]), + seq: 0, + nB: newNonce(), + signerPrivateJwk: evil.privateJwk, + }); + s.routeFromNode("b-node", forged); + await sleep(60); + expect(s.forwarded()).toEqual([]); // the key home never saw the request + expect(producedByA(s)).toBe(0); // and a forgery costs no answer: no signature oracle + expect(ingress(s).length).toBe(1); // the only thing the plane routed was the forged B2A + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 4. scope-before-forward, after a correctly filtered roster */ +/* ------------------------------------------------------------------ */ + +test("4. after the filtered roster answer, a SIGN_REQUEST for the UNGRANTED blob is refused at A: A's agent is never asked to sign it", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + p.conn.write(framed(Buffer.from([11]))); + await waitUntil(() => p.payloads().length === 1, "the roster answer"); + const roster = parseAnswer(p.payloads()[0]); + expect(roster.type).toBe(12); + expect(roster.count).toBe(1); // two keys on A's agent, one key for the pane + expect(roster.comments).toEqual(["granted key"]); + + p.conn.write(framed(buildSignRequestTenX(KEY_OUT))); // the blob the roster never showed + await waitUntil(() => p.payloads().length === 2, "the refusal"); + expect([...p.payloads()[1]]).toEqual([5]); + + const fwd = s.forwarded(); + expect(fwd.length).toBe(1); // ONLY the identities request went to A's agent + expect(fwd[0]).toEqual(Buffer.from([11])); + for (const seen of s.stub.received) { + if (seen[0] === 13) expect(seen.length).toBe(1); // byte 13 in the stub's view is only the probe + } + expect(producedByA(s)).toBe(2); // both answers came back, but the second is a refusal, not a signature + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 5. the relayed blob is byte-opaque AT THE PLANE */ +/* ------------------------------------------------------------------ */ + +test("5. a full exchange leaves no plaintext anywhere the plane can see: wire capture, audits, session view, broker log", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + const signRequest = buildSignRequestTenX(KEY_IN, { data: Buffer.from("DATA-SECRET") }); + p.conn.write(framed(Buffer.from([11]))); + await waitUntil(() => p.payloads().length === 1, "the roster"); + p.conn.write(framed(signRequest)); + await waitUntil(() => p.payloads().length === 2, "the signature"); + + const signResponse = p.payloads()[1]; + // Only full multi-field payloads ride the marker list: a one-byte + // request's two-character base64 would "collide" inside random + // ciphertext by chance and tell us nothing. + const payloadB64 = [signRequest, signResponse].flatMap((b) => [ + b.toString("base64"), + base64UrlNoPad(new Uint8Array(b)), + ]); + const markers = [ + "KEY-IN", + "KEY-OUT", + "ssh-ed25519", + "granted key", + "ungranted key", + "DATA-SECRET", // markers containing a hyphen or a space cannot occur inside base64 by chance + ]; + + // Scan every string the plane holds BEFORE the close, then force the + // close audit and scan again (the lifetime audit rides the close). + const sessionView = JSON.stringify(s.broker.sessionInfo(s.result.ref)); + const beforeClose = [...s.planeVisible(), sessionView]; + expect(await s.broker.closeRelay(s.result.ref, "child-exit")).toBe(true); + const afterClose = s.planeVisible(); + + const blobs = ingress(s).map((f) => f.blob); + expect(blobs.length).toBeGreaterThanOrEqual(4); // two requests and two answers crossed + for (const text of [...beforeClose, ...afterClose]) { + for (const marker of markers) expect(text).not.toContain(marker); + for (const form of payloadB64) expect(text).not.toContain(form); + } + for (const blob of blobs) { + // The opaque envelope itself never leaks into a durable structure: + // audits and the session view carry ids and paths, never relay bytes. + for (const a of s.audits) expect(a.metadataJson).not.toContain(blob); + expect(sessionView).not.toContain(blob); + } + + // The audit census is the shipped posture: routing ids, hosts, the + // reason, and the fingerprint COUNT (never the set) - and no fingerprints. + const open = s.audits.find((a) => a.action === "node.ssh_relay.open"); + const close = s.audits.find((a) => a.action === "node.ssh_relay.close"); + expect(open).toBeDefined(); + expect(close).toBeDefined(); + expect(open?.metadata.fingerprintCount).toBe(1); + expect(Object.keys(open?.metadata ?? {}).sort()).toEqual( + ["aNodeId", "bNodeId", "fingerprintCount", "grantId", "lifetimeMs", "paneId", "ref", "relayId"].sort(), + ); + expect(close?.metadata.reason).toBe("child-exit"); + for (const a of s.audits) { + expect(a.metadataJson).not.toContain(fp(KEY_IN)); + expect(a.metadataJson).not.toContain(fp(KEY_OUT)); + } + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 6. replay inertness: inside the window, and into a ref-reuse */ +/* ------------------------------------------------------------------ */ + +test("6. a captured envelope is INERT replayed inside the lifetime and inert replayed into a later session on the reused routing ref", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + const request = buildSignRequestTenX(KEY_IN); + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 1, "the genuine signature"); + const inF = ingress(s); + const b2a = inF[0]; + const a2b = inF[1]; + const decReq = await decodeB2A(s, b2a); + const decRep = await decodeA2B(s, a2b); + + // (a) inside the lifetime window. + // (a1) the byte-identical B2A resend (the plane routes what it already + // routed): refused by the binding flip (the recorded nA must ride) and, + // whatever path refuses it, A asks its agent nothing and answers nothing. + s.routeFromNode("b-node", JSON.parse(JSON.stringify(b2a)) as RelayFrame); + await sleep(60); + expect(s.forwarded().length).toBe(1); + expect(producedByA(s)).toBe(1); + expect(p.payloads().length).toBe(1); + + // (a2) a freshly signed B2A that passes EVERY binding except seq: the + // signed anti-replay value goes backwards, so the SeqGate alone refuses. + const rewind = await sealB2A(s, { + agentBytes: request, + seq: 0, + nB: decReq.nB, + ...(decRep.nA === undefined ? {} : { nA: decRep.nA }), + signerPrivateJwk: s.bId.signingPrivateJwk, + }); + s.routeFromNode("b-node", rewind); + await sleep(60); + expect(s.forwarded().length).toBe(1); + expect(producedByA(s)).toBe(1); + + // (a3) the captured A2B replayed toward B: refused by B's gate; the pane + // stream gains nothing and the connection is not punished for it. + s.routeFromNode("a-node", JSON.parse(JSON.stringify(a2b)) as RelayFrame); + await sleep(60); + expect(p.payloads().length).toBe(1); + expect(p.closed()).toBe(false); + + // (b) the crown jewel: both machines end this pairing and a LATER + // session re-presents the SAME routing ref with a fresh relay id and + // fresh endpoint state. The ref is `crypto.randomUUID()` per pairing, so + // the broker itself would never re-issue it; what the test forces is the + // state the plane is actually in when a ref somehow comes back: it routes + // the ref blindly (it never sees relay ids or nonces), so only the + // ENDPOINTS' bindings can make the old bytes inert. + s.closeNodeSessions(s.result.ref, "child-exit"); + expect(s.aRelay.has(s.result.ref)).toBe(false); + expect(s.bRelay.has(s.result.ref)).toBe(false); + expect(s.broker.sessionInfo(s.result.ref)).not.toBeNull(); // the plane's lane for the ref is still open + const cmd2 = (role: "A" | "B"): SshRelayOpenCommand => ({ + type: "ssh_relay_open", + relayId: "relay-second", + ref: s.result.ref, + role, + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: role === "A" ? s.bId.signingPublicJwk : s.aId.signingPublicJwk, + peerEncryptPublicKey: b64Jwk(role === "A" ? s.bId.publicJwk : s.aId.publicJwk), + grantId: "grant-1", + fingerprints: [fp(KEY_IN)], + lifetimeMs: SSH_RELAY_LIFETIME_MS, + paneId: PANE_ID, + }); + await openARelaySession({ + relay: s.aRelay, + dataDir: s.aDir, + selfNodeId: "a-node", + cmd: cmd2("A"), + sendRelayFrame: (f) => s.routeFromNode("a-node", f), + resolveAgentSocket: () => s.stub.path, + }); + await openBRelaySession({ + relay: s.bRelay, + dataDir: s.bDir, + selfNodeId: "b-node", + paneId: PANE_ID, + cmd: cmd2("B"), + sendRelayFrame: (f) => s.routeFromNode("b-node", f), + }); + const fwdBase = s.forwarded().length; // session 2's open re-probed; baseline moves + const aProducedBase = producedByA(s); + + // (b1) the old relayId, old-nonce, old-seq B2A envelope, replayed into + // the session that shares its ref: A's session-2 responder opens it (the + // key home never moved) and the signature bindings refuse it - fresh + // gate and reused ref notwithstanding, the relay id and endpoint nonces + // are what it dies on. + s.routeFromNode("b-node", JSON.parse(JSON.stringify(b2a)) as RelayFrame); + await sleep(60); + expect(s.forwarded().length).toBe(fwdBase); + expect(producedByA(s)).toBe(aProducedBase); + + // (b2) the old A2B envelope replayed at session 2's idle proxy: nothing + // is written to any pane socket by it. + const p2 = paneAt(s.result.socketPath); + s.routeFromNode("a-node", JSON.parse(JSON.stringify(a2b)) as RelayFrame); + await sleep(60); + expect(p2.payloads().length).toBe(0); + + // (b3) and session 2 itself is healthy: a genuine sign completes on it, + // proving the refusals above refused the FORGERY, not the session. + p2.conn.write(framed(buildSignRequestTenX(KEY_IN))); + await waitUntil(() => p2.payloads().length === 1, "session 2's own signature"); + expect([...p2.payloads()[0]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); + p2.destroy(); + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 7. multi-key session: several granted keys, strictly increasing seq */ +/* ------------------------------------------------------------------ */ + +test("7. a multi-key session: SIGN_REQUESTs for two granted keys at strictly increasing seqs both complete", async () => { + const s = await openStack({ fingerprints: [fp(KEY_IN), fp(KEY_OUT)] }); + try { + const p = paneAt(s.result.socketPath); + const req1 = buildSignRequestTenX(KEY_IN, { data: Buffer.from("DATA-IN") }); + const req2 = buildSignRequestTenX(KEY_OUT, { data: Buffer.from("DATA-OUT") }); + p.conn.write(framed(req1)); + await waitUntil(() => p.payloads().length === 1, "the first signature"); + p.conn.write(framed(req2)); + await waitUntil(() => p.payloads().length === 2, "the second signature"); + expect([...p.payloads()[0]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); + expect([...p.payloads()[1]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); + expect(s.forwarded()).toEqual([req1, req2]); + + const inF = ingress(s); + const b2a = inF.filter((f) => f.direction === "B2A"); + const a2b = inF.filter((f) => f.direction === "A2B"); + expect(b2a.length).toBe(2); + expect(a2b.length).toBe(2); + const signedB2A = await Promise.all(b2a.map((f) => decodeB2A(s, f))); + const signedA2B = await Promise.all(a2b.map((f) => decodeA2B(s, f))); + expect(signedB2A.map((d) => d.seq)).toEqual([0, 1]); // the SIGNED seqs are strictly increasing + expect(signedA2B.map((d) => d.seq)).toEqual([0, 1]); + expect(new Set(signedB2A.map((d) => d.nB)).size).toBe(1); // one session, one pair of nonces + expect(new Set(signedA2B.map((d) => d.nA)).size).toBe(1); + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 8. a resent or non-increasing seq is refused, both directions */ +/* ------------------------------------------------------------------ */ + +test("8. a resent envelope and a non-increasing signed seq are refused in BOTH directions, and the session still serves the next request", async () => { + const s = await openStack(); + try { + const p = paneAt(s.result.socketPath); + const request = buildSignRequestTenX(KEY_IN); + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 1, "the genuine signature"); + const inF = ingress(s); + + // B2A direction, byte-identical resend (the plane can resend any envelope). + s.routeFromNode("b-node", JSON.parse(JSON.stringify(inF[0])) as RelayFrame); + await sleep(50); + expect(s.forwarded().length).toBe(1); + expect(producedByA(s)).toBe(1); + + // B2A direction, a freshly signed envelope whose seq does not advance + // (every other binding correct): the gate alone refuses it. + const decReq = await decodeB2A(s, inF[0]); + const decRep = await decodeA2B(s, inF[1]); + const sameSeq = await sealB2A(s, { + agentBytes: request, + seq: 0, + nB: decReq.nB, + ...(decRep.nA === undefined ? {} : { nA: decRep.nA }), + signerPrivateJwk: s.bId.signingPrivateJwk, + }); + s.routeFromNode("b-node", sameSeq); + await sleep(50); + expect(s.forwarded().length).toBe(1); + expect(producedByA(s)).toBe(1); + + // A2B direction: the captured answer resent toward B; the pane's stream + // gains no duplicate and the connection lives. + s.routeFromNode("a-node", JSON.parse(JSON.stringify(inF[1])) as RelayFrame); + await sleep(50); + expect(p.payloads().length).toBe(1); + expect(p.closed()).toBe(false); + + // Both gates moved forward, so the session continues: request two answers + // at signed seq 1 in each direction. + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 2, "the next genuine signature"); + const secondA2B = ingress(s) + .filter((f) => f.direction === "A2B") + .at(-1); + expect((await decodeA2B(s, secondA2B as RelayFrame)).seq).toBe(1); + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 9. B rejects a forged or garbled A2B: nothing reaches the socket */ +/* ------------------------------------------------------------------ */ + +test("9. a forged A2B signature fails the pane's connection without a byte written, and a garbled envelope is noise that costs the session nothing", async () => { + const s = await openStack(); + const evil = await evilReady; + try { + const p = paneAt(s.result.socketPath); + const request = buildSignRequestTenX(KEY_IN); + p.conn.write(framed(request)); + await waitUntil(() => p.payloads().length === 1, "the genuine first signature"); + const inF = ingress(s); + const decReq = await decodeB2A(s, inF[0]); + const decRep = await decodeA2B(s, inF[1]); + + // Request two, with the plane withholding A's answer so the slot stays + // open: the forged reply races nothing. + s.swallowB2A = true; + p.conn.write(framed(request)); + await waitUntil( + () => ingress(s).filter((f) => f.direction === "B2A").length === 2, + "the second request reaches the plane", + ); + const forged = await sealA2B(s, { + agentBytes: Buffer.concat([Buffer.from([14]), sshStr(Buffer.from("EVIL-SIG"))]), + seq: 1, + nB: decReq.nB, + ...(decRep.nA === undefined ? {} : { nA: decRep.nA }), + signerPrivateJwk: evil.privateJwk, // opens (sealed to B's public half), verifies against nobody's pin + }); + s.routeFromNode("a-node", forged); + await waitUntil(() => p.closed(), "the forged reply fails the waiting connection"); + expect(p.payloads().length).toBe(1); // the refused frame wrote nothing... + for (const pl of p.payloads()) expect(pl.includes(Buffer.from("EVIL-SIG"))).toBe(false); // ...and it was never the forged bytes + + // A's late answer to the dead request 2 is absorbed by the burned slot. + s.swallowB2A = false; + s.releaseB2A(); + await sleep(30); + + // The garble, against a fresh connection: valid base64, broken JWE bytes. + const p3 = paneAt(s.result.socketPath); + s.swallowB2A = true; + p3.conn.write(framed(request)); + await waitUntil( + () => ingress(s).filter((f) => f.direction === "B2A").length === 3, + "the third request reaches the plane", + ); + const garbled: RelayFrame = { ...inF[1], blob: garbleBlob(inF[1].blob) }; // the genuine answer's envelope, one byte broken + s.routeFromNode("a-node", garbled); + await sleep(50); + expect(p3.payloads().length).toBe(0); // an unopenable envelope writes nothing + expect(p3.closed()).toBe(false); // ...and is noise, not a forgery: the session lives + s.swallowB2A = false; + s.releaseB2A(); // A's real answer to request 3 lands: the garble cost the session nothing + await waitUntil(() => p3.payloads().length === 1, "the real answer after the garbled noise"); + expect([...p3.payloads()[0]]).toEqual([14, 0, 0, 0, 3, ...Buffer.from("SIG")]); + p3.destroy(); + } finally { + await s.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 10. the roster command (DEFERRED to Task 11) */ +/* ------------------------------------------------------------------ */ + +// Matrix case 10 - `ssh_agent_identities` accepted with no grant - belongs to +// Task 11: the roster command does not exist yet and this task must not +// build it. The responder-side surface (fingerprintAgentBlob, +// parseIdentitiesAnswer) is already exported for that task to reuse. +const TODO_PLACEHOLDER = (): void => { + /* Task 11 will drive the roster command here. */ +}; + +test.todo( + "10. (Task 11) ssh_agent_identities is accepted with no grant and answers the filtered roster", + TODO_PLACEHOLDER, +); From 29c0a6f513519fb8d130b3a5e925afdfb0a71941 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:32:38 -0700 Subject: [PATCH 41/92] test(ssh-relay): pin openability so relay refusals cannot pass vacuously --- .../helpers/agent-relay-globals.d.ts | 8 +++- .../__tests__/helpers/ssh-relay-stack.ts | 44 ++++++++++++++++--- .../__tests__/ssh-relay.integration.test.ts | 29 ++++++++++-- 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts b/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts index 2052fd47c..2808bf3c0 100644 --- a/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts +++ b/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts @@ -10,8 +10,12 @@ * seven server files, measured). Adding only the one name the agent relay * sources actually use keeps the shim to its smallest honest size. * - * Lives under `__tests__` so `tsc -p tsconfig.build.json` (which excludes - * `src/**/ __tests__; /**`) never sees it: the shipped server emit is untouched. + * Lives under `__tests__` so `tsc -p tsconfig.build.json` (whose exclude + * list covers every `__tests__` tree under src) never sees it: the shipped + * server emit is untouched. A double-star glob cannot be quoted here: the + * comment-closing character pair occurs inside every double-star segment, + * so writing one ends this comment mid-string. That is what mangled the + * earlier wording in this file. */ /** The WebCrypto JSON Web Key shape, as lib.dom declares it (agent relay only). */ diff --git a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts index e39597172..c71552136 100644 --- a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts +++ b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts @@ -7,6 +7,7 @@ import { base64UrlNoPad, type NodeCommandBody, openRelayEnvelope, + type RelayEnvelopeOpen, type RelayFrame, sealRelayEnvelope, verifyRelayEnvelope, @@ -320,6 +321,13 @@ export async function openStack(opts: StackOptions = {}): Promise { const brokerLines: string[] = []; const commands: { nodeId: string; cmd: NodeCommandBody }[] = []; let swallowedB2A: RelayFrame[] = []; + // The outage flag lives in a plain closure variable, NOT on `stack`: + // routeFromNode already serves the open handshake (sendCommand's + // sendRelayFrame seam) while `const stack` is still in its TDZ, and a + // handshake-time B2A reading `stack.swallowB2A` would have thrown a + // ReferenceError. The open's frame silence is protocol behavior the + // fixture must not lean on to stay evaluable. + let swallowArmed = false; /** Plane-to-node: serialize like a socket would, re-parse, hand to the node. */ const deliverToNode = (nodeId: "a-node" | "b-node", frame: RelayFrame): void => { @@ -333,7 +341,7 @@ export async function openStack(opts: StackOptions = {}): Promise { const routeFromNode = (nodeId: "a-node" | "b-node", frame: RelayFrame): void => { const raw = JSON.stringify(frame); wire.push({ outbound: false, nodeId, raw }); - if (nodeId === "b-node" && frame.direction === "B2A" && stack.swallowB2A) { + if (nodeId === "b-node" && frame.direction === "B2A" && swallowArmed) { swallowedB2A.push(JSON.parse(raw) as RelayFrame); return; } @@ -427,7 +435,15 @@ export async function openStack(opts: StackOptions = {}): Promise { ...brokerLines, JSON.stringify(broker.sessionInfo(result.ref)), ], - swallowB2A: false, + // Accessor over the closure flag (see swallowArmed above): the property + // stays a plain boolean on the Stack interface while routeFromNode reads + // only the closure, never the not-yet-initialized stack. + get swallowB2A() { + return swallowArmed; + }, + set swallowB2A(value: boolean) { + swallowArmed = value; + }, releaseB2A() { const pending = swallowedB2A; swallowedB2A = []; @@ -468,17 +484,31 @@ export interface DecodedFrame { } /** - * Open + verify a B2A frame AS A did: B's signing key is the pinned origin, - * A's real encryption private half opens the envelope. Throws exactly where - * A's responder threw. + * OPEN a B2A frame at A WITHOUT verifying anything (the confidentiality half + * only): resolves iff the envelope is sealed to A's real encryption key and + * its wrapper agrees with the frame. This is the openability witness the + * refusal cases need: a downstream refusal (verification, the endpoint's gate) + * is then provably a judgment about origin or replay, not a decryption failure + * of a fixture-mis-sealed envelope - that one rejects HERE instead. */ -export async function decodeB2A(s: Stack, frame: RelayFrame): Promise { - const opened = await openRelayEnvelope({ +export async function openB2A(s: Stack, frame: RelayFrame): Promise { + return openRelayEnvelope({ blob: frame.blob, own: { principalId: "node:a-node", publicJwk: s.aId.publicJwk, privateJwk: s.aId.privateJwk }, open: mcpOpen, expect: { ref: frame.ref, direction: frame.direction }, }); +} + +/** + * Open + verify a B2A frame AS A did: B's signing key is the pinned origin, + * A's real encryption private half opens the envelope. Throws exactly where + * A's responder threw. The seq gate is the responder's own check, deliberately + * absent here: an honestly-sealed low-seq envelope DECODES fine, which is what + * makes the responder's refusal nameable as the gate's. + */ +export async function decodeB2A(s: Stack, frame: RelayFrame): Promise { + const opened = await openB2A(s, frame); const message = await verifyRelayEnvelope({ jws: opened.jws, publicJwk: JSON.parse(s.bId.signingPublicJwk) as JsonWebKey, diff --git a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts index d8fd9cd98..83e6402c8 100644 --- a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts @@ -19,6 +19,7 @@ import { framed, KEY_IN, KEY_OUT, + openB2A, openStack, PANE_ID, parseAnswer, @@ -170,6 +171,7 @@ test("2. a non-allow-listed agent method (EXTENSION) is refused at A and never f expect([...p.payloads()[0]]).toEqual([5]); // SSH2_AGENT_FAILURE, never a hang await sleep(30); expect(s.forwarded()).toEqual([]); // A's agent was never asked + expect(producedByA(s)).toBe(1); // the [5] is A's own synthesized refusal: one A2B left A's socket } finally { await s.cleanup(); } @@ -192,6 +194,14 @@ test("3. a B2A envelope signed by a machine key that is not B's pin opens at A b nB: newNonce(), signerPrivateJwk: evil.privateJwk, }); + // Openability witness: the SAME envelope decrypts at A (openB2A resolves), + // so the refusal below is the SIGNATURE stage, not a decryption failure. + // Had sealB2A mis-sealed this to the wrong recipient, openB2A would reject + // here and the case would fail loudly instead of passing vacuously. + await openB2A(s, forged); + // ...and decodeB2A (open THEN verify against B's pin) rejects naming the + // signature, which a wrong-recipient seal would instead die a DecryptError. + await expect(decodeB2A(s, forged)).rejects.toThrow(/signature is invalid/); s.routeFromNode("b-node", forged); await sleep(60); expect(s.forwarded()).toEqual([]); // the key home never saw the request @@ -340,6 +350,12 @@ test("6. a captured envelope is INERT replayed inside the lifetime and inert rep ...(decRep.nA === undefined ? {} : { nA: decRep.nA }), signerPrivateJwk: s.bId.signingPrivateJwk, }); + // Openability witness: the refused envelope opens AND verifies end to end + // (decodeB2A runs A's open+verify checklist with NO gate - the gate is the + // responder's own next step), so the live refusal below can only be the + // SeqGate's. A sealB2A that sealed to the wrong recipient would throw + // right here, failing this case instead of refusing for the wrong reason. + expect((await decodeB2A(s, rewind)).agentBytes).toEqual(request); s.routeFromNode("b-node", rewind); await sleep(60); expect(s.forwarded().length).toBe(1); @@ -398,9 +414,12 @@ test("6. a captured envelope is INERT replayed inside the lifetime and inert rep // (b1) the old relayId, old-nonce, old-seq B2A envelope, replayed into // the session that shares its ref: A's session-2 responder opens it (the - // key home never moved) and the signature bindings refuse it - fresh - // gate and reused ref notwithstanding, the relay id and endpoint nonces - // are what it dies on. + // key home never moved) and EXACTLY one binding refuses it: the signed + // relaySessionId claim (the bytes carry session 1's id, this responder was + // opened under relay-second). The endpoint nonces refuse nothing at this + // point: session 2 has recorded no nB and has not flipped bindNA, so its + // checklist omits both nonce members; and the fresh gate would have + // ACCEPTED seq 0, had the verification ever passed. s.routeFromNode("b-node", JSON.parse(JSON.stringify(b2a)) as RelayFrame); await sleep(60); expect(s.forwarded().length).toBe(fwdBase); @@ -488,6 +507,10 @@ test("8. a resent envelope and a non-increasing signed seq are refused in BOTH d ...(decRep.nA === undefined ? {} : { nA: decRep.nA }), signerPrivateJwk: s.bId.signingPrivateJwk, }); + // Openability witness (case 6(a2)'s same guard): every check before the + // gate passes for this exact envelope, so the refusal below is the gate's + // alone. A mis-sealed fixture would throw here, not pass vacuously. + expect((await decodeB2A(s, sameSeq)).agentBytes).toEqual(request); s.routeFromNode("b-node", sameSeq); await sleep(50); expect(s.forwarded().length).toBe(1); From 6f1e4ba6defc24b3b415666602525ef38d572597 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:40:51 -0700 Subject: [PATCH 42/92] feat(ssh-grants): migration 0050 - grants, first-use queue, host pins Three tables per the M2 plan's file map (spec 2026-10-08 SS6, SS9): ssh_key_grants (standing authorization with the SHA256: selection as a JSON array), ssh_grant_requests (durable pending queue with a 24 h expiry and the four-state lifecycle), and ssh_host_pins (the T12 TOFU store's shape, one unique pin per owner per resolved destination). Registered in migrate.ts; LATEST_BACKUP_MIGRATION and the backup strip-set fixture move to 0050. --- .../__tests__/backup-cli-subprocess.test.ts | 22 +- apps/server/api/src/db/migrate.ts | 4 + .../api/src/db/migrations/0050-ssh-grants.ts | 92 +++++++ .../__tests__/0050-ssh-grants.test.ts | 238 ++++++++++++++++++ apps/server/api/src/db/types/index.ts | 4 + .../api/src/db/types/ssh-grants.db-types.ts | 122 +++++++++ .../api/src/services/backups/database.ts | 2 +- 7 files changed, 475 insertions(+), 9 deletions(-) create mode 100644 apps/server/api/src/db/migrations/0050-ssh-grants.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0050-ssh-grants.test.ts create mode 100644 apps/server/api/src/db/types/ssh-grants.db-types.ts diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 27bf37c74..e0ed7c3ee 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,21 +392,21 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. - // WHY 0047+0048+0049 together: this era's three migrations are the tail of - // the registered ledger, so the prefix runs contiguously through 0046 and - // boot re-applies all three. Advancing LATEST_BACKUP_MIGRATION (0049) means + // WHY 0047+0048+0049+0050 together: this era's four migrations are the tail + // of the registered ledger, so the prefix runs contiguously through 0046 and + // boot re-applies all four. Advancing LATEST_BACKUP_MIGRATION (0050) means // the strip set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; DROP TABLE ssh_key_grants; DROP TABLE ssh_grant_requests; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; UPDATE migration_restore_probe SET value='newer destination';", + "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; CREATE TABLE ssh_key_grants(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, key_home_node_id TEXT NOT NULL, resolved_selector TEXT NOT NULL, fingerprints TEXT NOT NULL, created_via TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE ssh_grant_requests(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), key_home_node_id TEXT NOT NULL, resolved_selector TEXT NOT NULL, requested_fingerprints TEXT, pane_id TEXT NOT NULL, b_node_id TEXT NOT NULL, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); UPDATE migration_restore_probe SET value='newer destination';", ); for (const row of migrationRows) db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants')", ) .all(), ).toEqual([]); @@ -430,13 +430,14 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants') ORDER BY name", ) .all(), ).toEqual([ { name: "0047-node-ssh-enabled" }, { name: "0048-ssh-launch-and-saved-hosts" }, { name: "0049-ssh-relay-identity" }, + { name: "0050-ssh-grants" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( @@ -456,6 +457,11 @@ test("real entry: available older upgrade snapshot restores and migrates before expect(migrated.query("SELECT name FROM pragma_table_info('nodes') WHERE name='ssh_fingerprint'").get()).toEqual({ name: "ssh_fingerprint", }); + for (const grantTable of ["ssh_key_grants", "ssh_grant_requests", "ssh_host_pins"]) { + expect(migrated.query("SELECT name FROM sqlite_schema WHERE name=?").get(grantTable)).toEqual({ + name: grantTable, + }); + } migrated.close(); expect(readFileSync(join(instance.configDir, "config.env"), "utf8")).toBe(originalConfig); const deadline = Date.now() + 15_000; diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 415466e44..0fed9f7cd 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -49,6 +49,7 @@ import * as backupRecoveryMigration from "@/db/migrations/0046-backup-recovery.j import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import * as sshRelayIdentityMigration from "@/db/migrations/0049-ssh-relay-identity.js"; +import * as sshGrantsMigration from "@/db/migrations/0050-ssh-grants.js"; /** * Runs all pending Kysely migrations against the app database. @@ -145,6 +146,9 @@ export async function runMigrations(): Promise { // identities record and the trust-card fingerprint mirror on the // nodes row (spec 2026-10-08 §4, §9). "0049-ssh-relay-identity": sshRelayIdentityMigration, + // The grant tier: standing key grants, the durable first-use approval + // queue, and the M2 host-pin store (spec 2026-10-08 §6, §9). + "0050-ssh-grants": sshGrantsMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0050-ssh-grants.ts b/apps/server/api/src/db/migrations/0050-ssh-grants.ts new file mode 100644 index 000000000..2bf1fb51d --- /dev/null +++ b/apps/server/api/src/db/migrations/0050-ssh-grants.ts @@ -0,0 +1,92 @@ +import type { Kysely } from "kysely"; + +/** + * The grant tier (spec 2026-10-08 §6, §9): the three tables the authorization + * layer stands on. + * + * - `ssh_key_grants`: the standing authorization - one owner's permission to + * reach destinations matching a selector by signing with the agent on one + * key-home machine. It carries the operator's fingerprint SELECTION (public + * `SHA256:` identifiers as a JSON array, capped at + * `SSH_MAX_GRANT_FINGERPRINTS` by the service, never by a silent truncation) + * and nothing else about the keys: no key material, ever (§6.1). + * - `ssh_grant_requests`: the DURABLE first-use queue (spec §6.2, borrowing + * the signup pending-approvals shape): the pending row survives a plane + * restart so a re-launch finds it, and the expiry sweep marks stale rows + * `expired` without writing any audit event. + * - `ssh_host_pins`: the M2 TOFU host-key store (§9), keyed by the RESOLVED + * destination, one pin per `user@host:port` per owner, independent of + * grants. The read/write/capture LOGIC lands with T12; the schema ships + * here per the plan's file map so later migrations never re-cut it. + * + * Owner is FK-cascaded like the saved-hosts ledger (0048): a removed account + * takes its grants, requests, and pins with it. Node ids carry no FK - + * matching a grant against a vanished node is refused at the gate, not at the + * schema, the same posture `ssh_saved_hosts.node_id` set. + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable("ssh_key_grants") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("owner_user_id", "text", (col) => col.notNull().references("user.id").onDelete("cascade")) + .addColumn("name", "text", (col) => col.notNull()) + .addColumn("key_home_node_id", "text", (col) => col.notNull()) + .addColumn("resolved_selector", "text", (col) => col.notNull()) + .addColumn("fingerprints", "text", (col) => col.notNull()) + .addColumn("created_via", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .addColumn("updated_at", "text", (col) => col.notNull()) + .execute(); + // The launch-time match reads one owner's grants for one key home; the list + // screen reads one owner's grants. Both ride this index. + await db.schema + .createIndex("idx_ssh_key_grants_owner_keyhome") + .on("ssh_key_grants") + .columns(["owner_user_id", "key_home_node_id"]) + .execute(); + + await db.schema + .createTable("ssh_grant_requests") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("owner_user_id", "text", (col) => col.notNull().references("user.id").onDelete("cascade")) + .addColumn("key_home_node_id", "text", (col) => col.notNull()) + .addColumn("resolved_selector", "text", (col) => col.notNull()) + .addColumn("requested_fingerprints", "text") + .addColumn("pane_id", "text", (col) => col.notNull()) + .addColumn("b_node_id", "text", (col) => col.notNull()) + .addColumn("expires_at", "text", (col) => col.notNull()) + .addColumn("status", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .execute(); + // The approvals screen reads one owner's OPEN (pending) queue; the dedup + // read at request time is (owner, key home, selector) newest-first. + await db.schema + .createIndex("idx_ssh_grant_requests_owner_status") + .on("ssh_grant_requests") + .columns(["owner_user_id", "status"]) + .execute(); + + await db.schema + .createTable("ssh_host_pins") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("owner_user_id", "text", (col) => col.notNull().references("user.id").onDelete("cascade")) + .addColumn("destination", "text", (col) => col.notNull()) + .addColumn("host_key", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .addColumn("updated_at", "text", (col) => col.notNull()) + .execute(); + // One pin per owner per resolved destination (§9): the TOFU record, so a + // second capture at a differing key is the §9 hard block, not a second row. + await db.schema + .createIndex("idx_ssh_host_pins_owner_destination") + .unique() + .on("ssh_host_pins") + .columns(["owner_user_id", "destination"]) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("ssh_host_pins").execute(); + await db.schema.dropTable("ssh_grant_requests").execute(); + await db.schema.dropTable("ssh_key_grants").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0050-ssh-grants.test.ts b/apps/server/api/src/db/migrations/__tests__/0050-ssh-grants.test.ts new file mode 100644 index 000000000..37f33e938 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0050-ssh-grants.test.ts @@ -0,0 +1,238 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { CamelCasePlugin, Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as grantsMigration from "@/db/migrations/0050-ssh-grants.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + sshKeyGrants: Record; + sshGrantRequests: Record; + sshHostPins: Record; +} + +/** + * The grant tier's storage (spec 2026-10-08 §6, §9; migration 0050) creates + * ALL THREE tables in one file per the plan's file map: `ssh_key_grants` (the + * standing authorization), `ssh_grant_requests` (the durable first-use queue + * with an expiry deadline and a four-state lifecycle), and `ssh_host_pins` + * (the M2 TOFU store - one unique pin per owner per resolved `user@host:port`; + * its read/write logic is T12's, the SHAPE is this migration's). + * + * The FK target is the minimal `user` table (same posture as the 0048/0049 + * tests); node ids carry NO fk by design (a vanished node is refused at the + * gate, the saved-hosts precedent). + */ +describe("migration 0050-ssh-grants", () => { + let dbFile: string; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0050-${Math.random().toString(36).slice(2)}.db`; + // The app's own camelCase mapping (db/index.ts), so the typed inserts here + // spell names the same way every repository does. + db = new Kysely({ + dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }), + plugins: [new CamelCasePlugin()], + }); + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await sql`INSERT INTO user (id, name, email) VALUES ('u1', 'Owner', 'u1@test')`.execute(db); + await grantsMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("creates ssh_key_grants with the full column set and rejects a null owner", async () => { + const cols = await sql<{ + name: string; + notnull: number; + }>`SELECT name, "notnull" FROM pragma_table_info('ssh_key_grants')`.execute(db); + const byName = new Map(cols.rows.map((c) => [c.name, c.notnull])); + for (const col of [ + "id", + "owner_user_id", + "name", + "key_home_node_id", + "resolved_selector", + "fingerprints", + "created_via", + "created_at", + "updated_at", + ]) { + expect(byName.has(col)).toBe(true); + } + // EVERY grant column is NOT NULL: the service writes the full row, there is + // no partial vocabulary (a grant with no fingerprints is an empty ARRAY, + // not a NULL - "names no fingerprint serves nothing" is data, not absence). + for (const col of [ + "owner_user_id", + "name", + "key_home_node_id", + "resolved_selector", + "fingerprints", + "created_via", + "created_at", + "updated_at", + ]) { + expect(byName.get(col)).toBe(1); + } + await db + .insertInto("sshKeyGrants") + .values({ + id: "g1", + ownerUserId: "u1", + name: "work hosts", + keyHomeNodeId: "node-a", + resolvedSelector: "git.example.test", + fingerprints: JSON.stringify(["SHA256:aaa"]), + createdVia: "first-use", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(); + // The fingerprints column round-trips the JSON array exactly as stored. + const row = await sql<{ fingerprints: string }>`SELECT fingerprints FROM ssh_key_grants WHERE id = 'g1'`.execute( + db, + ); + expect(JSON.parse(row.rows[0].fingerprints)).toEqual(["SHA256:aaa"]); + // NOT NULL enforced at the door: a grant row without its owner is refused. + await expect( + db + .insertInto("sshKeyGrants") + .values({ + id: "g2", + ownerUserId: null as unknown as string, + name: "n", + keyHomeNodeId: "node-a", + resolvedSelector: "h", + fingerprints: "[]", + createdVia: "manual", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(), + ).rejects.toThrow(); + }); + + it("creates ssh_grant_requests: only requested_fingerprints is nullable, and a durable pending row round-trips", async () => { + const cols = await sql<{ + name: string; + notnull: number; + }>`SELECT name, "notnull" FROM pragma_table_info('ssh_grant_requests')`.execute(db); + const byName = new Map(cols.rows.map((c) => [c.name, c.notnull])); + // (`id` is deliberately absent: SQLite's TEXT PRIMARY KEY carries no + // NOT NULL flag, and uniqueness is the actual identity rule here.) + for (const col of [ + "owner_user_id", + "key_home_node_id", + "resolved_selector", + "pane_id", + "b_node_id", + "expires_at", + "status", + "created_at", + ]) { + expect(byName.get(col)).toBe(1); + } + // The one representable absence (spec §6.2: the approver, not the pane, + // selects the keys): a request with no pre-selection stores NULL. + expect(byName.get("requested_fingerprints")).toBe(0); + await db + .insertInto("sshGrantRequests") + .values({ + id: "r1", + ownerUserId: "u1", + keyHomeNodeId: "node-a", + resolvedSelector: "git.example.test", + requestedFingerprints: null, + paneId: "pane-1", + bNodeId: "node-b", + expiresAt: "2026-10-09T00:00:00.000Z", + status: "pending", + createdAt: "2026-10-08T00:00:00.000Z", + }) + .execute(); + // (Raw SQL identifiers stay snake - the plugin rewrites the Kysely + // builders, not raw text - but the RESULT ROW KEYS come back camel, the + // mapping the app's own queries ride everywhere.) + const back = await sql<{ status: string; requestedFingerprints: string | null; paneId: string }>` + SELECT status, requested_fingerprints, pane_id FROM ssh_grant_requests WHERE id = 'r1' + `.execute(db); + expect(back.rows[0].status).toBe("pending"); + expect(back.rows[0].requestedFingerprints).toBeNull(); + // The requester facts ride the row (the approvals screen shows WHO asked): + // the row survives because the table is durable storage, not process state. + expect(back.rows[0].paneId).toBe("pane-1"); + // A terminal status re-uses the same column vocabulary (no CHECK in this + // schema: the SERVICE validates against SSH_GRANT_REQUEST_STATUSES). + await sql`UPDATE ssh_grant_requests SET status = 'approved' WHERE id = 'r1'`.execute(db); + const after = await sql<{ status: string }>`SELECT status FROM ssh_grant_requests WHERE id = 'r1'`.execute(db); + expect(after.rows[0].status).toBe("approved"); + }); + + it("creates ssh_host_pins with the unique (owner, destination) key - the TOFU record shape", async () => { + await db + .insertInto("sshHostPins") + .values({ + id: "p1", + ownerUserId: "u1", + destination: "git@git.example.test:22", + hostKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPin", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(); + // One pin per owner per resolved destination: the second insert at the + // same key is the §9 hard block at the STORAGE level (the capture logic + // that reads this refusal is T12's). + await expect( + db + .insertInto("sshHostPins") + .values({ + id: "p2", + ownerUserId: "u1", + destination: "git@git.example.test:22", + hostKey: "ssh-ed25519 AAAAC3DIFFERENT", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(), + ).rejects.toThrow(); + // Another owner may hold their own pin for the same destination (per-owner + // scoping: one account's first use must not poison or lock another's). + await sql`INSERT INTO user (id, name, email) VALUES ('u2', 'Other', 'u2@test')`.execute(db); + await db + .insertInto("sshHostPins") + .values({ + id: "p3", + ownerUserId: "u2", + destination: "git@git.example.test:22", + hostKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPin", + createdAt: "2026-10-08T00:00:00.000Z", + updatedAt: "2026-10-08T00:00:00.000Z", + }) + .execute(); + const count = await sql<{ n: number }>`SELECT COUNT(*) AS n FROM ssh_host_pins`.execute(db); + expect(count.rows[0].n).toBe(2); + }); + + it("owner deletion cascades every grant tier row (accounts leave no authorization behind)", async () => { + await sql`DELETE FROM user WHERE id = 'u2'`.execute(db); + const orphans = await sql<{ + n: number; + }>`SELECT COUNT(*) AS n FROM ssh_host_pins WHERE owner_user_id = 'u2'`.execute(db); + expect(orphans.rows[0].n).toBe(0); + }); + + it("down drops all three tables", async () => { + await grantsMigration.down(db); + for (const table of ["ssh_key_grants", "ssh_grant_requests", "ssh_host_pins"]) { + const left = await sql<{ n: number }>` + SELECT COUNT(*) AS n FROM sqlite_schema WHERE type = 'table' AND name = ${table} + `.execute(db); + expect(left.rows[0].n).toBe(0); + } + }); +}); diff --git a/apps/server/api/src/db/types/index.ts b/apps/server/api/src/db/types/index.ts index d7d997127..c624b06b0 100644 --- a/apps/server/api/src/db/types/index.ts +++ b/apps/server/api/src/db/types/index.ts @@ -17,6 +17,7 @@ import type { PromptStackItemTable, PromptStackTable } from "@/db/types/prompt-s import type { PromptTable } from "@/db/types/prompts.db-types.js"; import type { RecentPathTable } from "@/db/types/recent-paths.db-types.js"; import type { SettingTable } from "@/db/types/settings.db-types.js"; +import type { SshGrantRequestTable, SshHostPinTable, SshKeyGrantTable } from "@/db/types/ssh-grants.db-types.js"; import type { SshSavedHostTable } from "@/db/types/ssh-saved-hosts.db-types.js"; import type { SubshellShareTable } from "@/db/types/subshell-shares.db-types.js"; import type { SubshellTable } from "@/db/types/subshells.db-types.js"; @@ -59,4 +60,7 @@ export interface Database { notificationsSubscriptions: NotificationSubscriptionTable; deviceTokens: DeviceTokenTable; sshSavedHosts: SshSavedHostTable; + sshKeyGrants: SshKeyGrantTable; + sshGrantRequests: SshGrantRequestTable; + sshHostPins: SshHostPinTable; } diff --git a/apps/server/api/src/db/types/ssh-grants.db-types.ts b/apps/server/api/src/db/types/ssh-grants.db-types.ts new file mode 100644 index 000000000..fb6a2bd4d --- /dev/null +++ b/apps/server/api/src/db/types/ssh-grants.db-types.ts @@ -0,0 +1,122 @@ +/** + * Storage types for the grant tier (spec 2026-10-08 §6, §9; migration 0050). + * Three records: the standing authorization (`ssh_key_grants`), the durable + * first-use approval queue (`ssh_grant_requests`), and the M2 TOFU host-key + * store (`ssh_host_pins`, schema here per the plan's file map; its read/write + * logic is T12's). + */ + +/** How a grant came to exist; the runtime census is {@link SSH_GRANT_CREATED_VIA}. */ +export type SshGrantCreatedVia = "first-use" | "manual"; + +/** + * The two doors a grant row arrives through (spec §6.2: "Creation via the + * screen and via first use write the same row and the same audit event", the + * `via` field tells them apart after the fact). `first-use` is the standing + * grant an APPROVED request writes; `manual` is the grants screen. + */ +export const SSH_GRANT_CREATED_VIA: readonly SshGrantCreatedVia[] = ["first-use", "manual"]; + +/** + * Lifecycle of a first-use approval request; the runtime census is + * {@link SSH_GRANT_REQUEST_STATUSES}. `pending` is the only OPEN state: + * `approved` wrote a grant, `denied` wrote an audit row only, and `expired` + * was swept at the deadline and wrote NOTHING (spec §6.2). A terminal row + * stays in the table - the queue is small and the trail is the answer. + */ +export type SshGrantRequestStatus = "pending" | "approved" | "denied" | "expired"; + +/** Every grant-request status the schema can hold (validation and sweeps iterate this). */ +export const SSH_GRANT_REQUEST_STATUSES: readonly SshGrantRequestStatus[] = [ + "pending", + "approved", + "denied", + "expired", +]; + +/** + * A standing key grant (spec §6.1): to reach destinations matching the + * selector, sign with the agent on the key-home machine, using exactly the + * selected public identities. No secret and no key bytes live here - the + * fingerprints are OpenSSH `SHA256:` display identifiers over the agent wire + * encoding (public by nature), yet they still never enter a notification body + * or a log line (Global Constraints; docs/security.md §10 names the COUNT). + */ +export interface SshKeyGrantTable { + /** Unique row id (uuid) */ + id: string; + /** Owning user id (FK to users, ON DELETE CASCADE) - the account whose launches this authorizes */ + ownerUserId: string; + /** Display name the operator gave the grant (normalized, <= 120 chars at the route) */ + name: string; + /** Machine A: the key home whose ssh-agent signs (no FK - a vanished node is refused at the gate) */ + keyHomeNodeId: string; + /** + * The destination selector, STORED RESOLVED (spec §6.1: an alias typed at + * approval is resolved to the concrete hostname and the alias kept only as + * display metadata elsewhere). A concrete hostname or a hostname pattern + * with `*` wildcards, lowercase, matched against the resolved destination + * hostname at launch; no whitespace, no control characters. + */ + resolvedSelector: string; + /** JSON array of `SHA256:` fingerprint strings (<= SSH_MAX_GRANT_FINGERPRINTS; empty is representable and serves nothing) */ + fingerprints: string; + /** Which door created the row; see {@link SshGrantCreatedVia} */ + createdVia: SshGrantCreatedVia; + /** ISO 8601 creation stamp (the match tie-break: oldest grant wins) */ + createdAt: string; + /** ISO 8601 of the last operator edit (name/selector) */ + updatedAt: string; +} + +/** + * A durable first-use approval request (spec §6.2): recorded BEFORE B's launch + * fails fast, so a plane restart does not forget outstanding approvals and a + * re-launch simply finds the standing pending row. The row is the fact that a + * human must answer it; the audit `node.ssh_grant.request` is the trail. + */ +export interface SshGrantRequestTable { + /** Unique row id (uuid) - the opaque `requestRef` the refusal and the notification name */ + id: string; + /** Owning user id (FK to users, ON DELETE CASCADE): the key home's owner, the one whose approval is asked */ + ownerUserId: string; + /** Machine A asked to sign (no FK; the approvals gate re-reads the row) */ + keyHomeNodeId: string; + /** The resolved destination hostname the launch would have dialed (stored resolved, like the grant) */ + resolvedSelector: string; + /** JSON array of pre-selected `SHA256:` fingerprints, or null when none rode the request (the approver picks) */ + requestedFingerprints: string | null; + /** B's pane (subshell id) whose launch asked - what the approvals screen names as the requester */ + paneId: string; + /** B's node id: the connecting machine of the asking launch */ + bNodeId: string; + /** ISO 8601 deadline (setup-key scale, 24 h); the sweep marks a passed row `expired` and writes no audit */ + expiresAt: string; + /** Lifecycle state; see {@link SshGrantRequestStatus} - only `pending` is answerable */ + status: SshGrantRequestStatus; + /** ISO 8601 creation stamp */ + createdAt: string; +} + +/** + * One TOFU host-key pin for one owner's resolved destination (spec §9): the + * `user@host:port` -> pinned public host key + timestamps record the M2 relay + * path validates against. Per-owner scoping is deliberate: a global pin would + * let another account's first connection poison (or DoS-lock) a destination + * this owner never touched. The capture/verification LOGIC is T12; this is + * the storage shape it must not re-cut. + */ +export interface SshHostPinTable { + /** Unique row id (uuid) */ + id: string; + /** Owning user id (FK to users, ON DELETE CASCADE) */ + ownerUserId: string; + /** Canonical resolved destination `user@host:port` (the `sshCanonicalDestination` spelling) */ + destination: string; + /** The pinned public host key line (` `, OpenSSH known_hosts spelling), never a secret */ + hostKey: string; + /** ISO 8601 first capture (the TOFU moment) */ + createdAt: string; + /** ISO 8601 of the last accepted match (a differing key never writes it - that is the §9 hard block) */ + updatedAt: string; +} diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 8b1e25885..3a7fc3b33 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0049-ssh-relay-identity"; +export const LATEST_BACKUP_MIGRATION = "0050-ssh-grants"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { From dd39d40a9621412d86e8e79f565adbe407716dad Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:42:02 -0700 Subject: [PATCH 43/92] feat(ssh-grants): grant repository, five named refusal codes, grant_approval notify Owner-scoped repository for 0050's two readable tables (every read ANDs ownerUserId so a foreign row is absent, the saved-hosts doctrine; the compare-and-set answer and the expiry sweep ride it too). New BackendErrorCodes for the grant tier: SSH_GRANT_APPROVAL_REQUIRED, SSH_GRANT_KEYS_OVER_LIMIT, SSH_GRANT_KEYS_INVALID, SSH_RELAY_OPEN_FAILED, SSH_RELAY_IDENTITY_MISSING. notify.service gains the grant_approval kind and notifyGrantApprovalOwner: the owner-addressed sibling of notifySubshell, with the web-push and device fan-outs extracted to one user-shaped pair both paths use; fixed copy, opaque request id, no secret or fingerprint anywhere. --- .../db/repositories/ssh-grants.repository.ts | 196 ++++++++++++++++++ apps/server/api/src/services/expo-push.ts | 4 + .../server/api/src/services/notify.service.ts | 142 ++++++++++--- packages/backend-errors/src/error-codes.ts | 55 +++++ 4 files changed, 366 insertions(+), 31 deletions(-) create mode 100644 apps/server/api/src/db/repositories/ssh-grants.repository.ts diff --git a/apps/server/api/src/db/repositories/ssh-grants.repository.ts b/apps/server/api/src/db/repositories/ssh-grants.repository.ts new file mode 100644 index 000000000..f88e61bfa --- /dev/null +++ b/apps/server/api/src/db/repositories/ssh-grants.repository.ts @@ -0,0 +1,196 @@ +import { BaseRepository } from "@/db/repositories/base.repository.js"; +import type { SshGrantRequestStatus, SshGrantRequestTable, SshKeyGrantTable } from "@/db/types/ssh-grants.db-types.js"; + +/** + * Repository for the grant tier (spec 2026-10-08 §6; migration 0050): the + * standing key grants and the durable first-use approval queue. Every read + * and write here is OWNER-SCOPED at the WHERE clause, mirroring the + * saved-hosts ledger: a foreign row is not "forbidden", it is absent, and the + * route renders absence as the 404 the ownership axis demands (docs/ + * security.md §3). No clock lives in the repository: callers pass their ISO + * stamps, the same posture as `LedgerWrite.at` (0048). + * + * The insert shapes live HERE, beside the repository, not in the db-types + * file (the task-6 note restated): both tables are full rows on every write, + * the repository owns no defaults, and there is no partial vocabulary to + * type. `host_pins` has no reader yet - the shape exists (0050) and T12 + * writes the capture logic; a repository for a table nothing reads yet is a + * second thing to keep in sync. + */ + +/** One full grant row as the two writers (approve, grants screen) compose it. */ +export type NewSshKeyGrant = SshKeyGrantTable; + +/** One full request row as {@link SshGrantsRepository.insertRequest} takes it. */ +export type NewSshGrantRequest = SshGrantRequestTable; + +export class SshGrantsRepository extends BaseRepository { + /** Write a standing grant. The caller validated the row (cap, grammar, ids). */ + async insertGrant(grant: NewSshKeyGrant): Promise { + await this.db.insertInto("sshKeyGrants").values(grant).execute(); + } + + /** One of the owner's grants by id; a foreign id is indistinguishable from an absent one. */ + async getGrant(ownerUserId: string, grantId: string): Promise { + return await this.db + .selectFrom("sshKeyGrants") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("id", "=", grantId) + .executeTakeFirst(); + } + + /** + * The owner's grants for one key home - the whole launch-time match read. + * The service does the selector matching (the glob policy is not SQL's); + * the row count per owner per key home is small by construction. + */ + async grantsFor(ownerUserId: string, keyHomeNodeId: string): Promise { + return await this.db + .selectFrom("sshKeyGrants") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("keyHomeNodeId", "=", keyHomeNodeId) + .orderBy("createdAt", "asc") + .execute(); + } + + /** The owner's grants for the screen, newest first. */ + async listGrants(ownerUserId: string): Promise { + return await this.db + .selectFrom("sshKeyGrants") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .orderBy("createdAt", "desc") + .execute(); + } + + /** + * Apply an operator edit (name and/or selector), returning the row or + * null for foreign-and-absent. `updatedAt` is the caller's stamp. + */ + async updateGrant( + ownerUserId: string, + grantId: string, + patch: { name?: string; resolvedSelector?: string; updatedAt: string }, + ): Promise { + const { name, resolvedSelector, updatedAt } = patch; + await this.db + .updateTable("sshKeyGrants") + .set({ + ...(name === undefined ? {} : { name }), + ...(resolvedSelector === undefined ? {} : { resolvedSelector }), + updatedAt, + }) + .where("ownerUserId", "=", ownerUserId) + .where("id", "=", grantId) + .execute(); + return await this.getGrant(ownerUserId, grantId); + } + + /** + * Delete one of the owner's grants; false for foreign AND absent. The + * caller (the revoke act) reaches the live relay AFTER this returns true - + * the row going first is the ordering that makes a raced relay-open's + * liveness re-check answer "gone". + */ + async deleteGrant(ownerUserId: string, grantId: string): Promise { + const res = await this.db + .deleteFrom("sshKeyGrants") + .where("id", "=", grantId) + .where("ownerUserId", "=", ownerUserId) + .executeTakeFirst(); + return Number(res.numDeletedRows ?? 0n) > 0; + } + + /** Record a first-use request. Only `insertRequest` writes `pending`. */ + async insertRequest(request: NewSshGrantRequest): Promise { + await this.db.insertInto("sshGrantRequests").values(request).execute(); + } + + /** One of the owner's requests by id; foreign is absent (the same 404). */ + async getRequest(ownerUserId: string, requestId: string): Promise { + return await this.db + .selectFrom("sshGrantRequests") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("id", "=", requestId) + .executeTakeFirst(); + } + + /** + * The OPEN question for one (owner, key home, selector): a standing + * pending row whose deadline has not passed. A relaunch finds THIS row + * (spec §6.2: "a re-launch simply finds the pending row") rather than + * stacking a second approval and a second notification. + */ + async findPendingRequest( + ownerUserId: string, + keyHomeNodeId: string, + resolvedSelector: string, + nowIso: string, + ): Promise { + return await this.db + .selectFrom("sshGrantRequests") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("keyHomeNodeId", "=", keyHomeNodeId) + .where("resolvedSelector", "=", resolvedSelector) + .where("status", "=", "pending") + .where("expiresAt", ">", nowIso) + .orderBy("createdAt", "desc") + .executeTakeFirst(); + } + + /** The owner's queue for the approvals screen: pending newest first, plus whatever statuses are asked. */ + async listRequests( + ownerUserId: string, + statuses: readonly SshGrantRequestStatus[] = ["pending"], + ): Promise { + return await this.db + .selectFrom("sshGrantRequests") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("status", "in", statuses) + .orderBy("createdAt", "desc") + .execute(); + } + + /** + * The compare-and-set answer: flip a request from `expected` to `next` only + * if it still stands where the approver read it. False is the 409 the + * screen renders (a second approver, or the sweep, beat this answer). + */ + async markRequestStatus( + ownerUserId: string, + requestId: string, + expected: SshGrantRequestStatus, + next: SshGrantRequestStatus, + ): Promise { + const res = await this.db + .updateTable("sshGrantRequests") + .set({ status: next }) + .where("id", "=", requestId) + .where("ownerUserId", "=", ownerUserId) + .where("status", "=", expected) + .executeTakeFirst(); + return Number(res.numUpdatedRows ?? 0n) > 0; + } + + /** + * The expiry sweep: mark every PAST-DEADLINE pending row `expired`. + * Returns the count. Writes no audit row and no grant (spec §6.2: + * "expiry writes nothing at all" - the row is the fact it was asked, the + * status is the fact it went unanswered). Rows already terminal are + * untouched; `nowIso` is the caller's clock. + */ + async sweepExpiredRequests(nowIso: string): Promise { + const res = await this.db + .updateTable("sshGrantRequests") + .set({ status: "expired" }) + .where("status", "=", "pending") + .where("expiresAt", "<=", nowIso) + .executeTakeFirst(); + return Number(res.numUpdatedRows ?? 0n); + } +} diff --git a/apps/server/api/src/services/expo-push.ts b/apps/server/api/src/services/expo-push.ts index 260ed22f0..8c772202c 100644 --- a/apps/server/api/src/services/expo-push.ts +++ b/apps/server/api/src/services/expo-push.ts @@ -72,6 +72,10 @@ const KIND_COPY: Record = { crashed: "A subshell crashed, auto-restarting", crashed_final: "A subshell crashed", maintenance: "A subshell was stopped for node maintenance", + // Not a subshell: a first-use key-grant request waiting on the owner + // (spec 2026-10-08 §6.2). Fixed copy, names nothing - `sid` carries the + // opaque request id, never a destination or a fingerprint. + grant_approval: "A key grant needs your approval", }; /** diff --git a/apps/server/api/src/services/notify.service.ts b/apps/server/api/src/services/notify.service.ts index d015bece3..92d50f20f 100644 --- a/apps/server/api/src/services/notify.service.ts +++ b/apps/server/api/src/services/notify.service.ts @@ -9,7 +9,6 @@ import { NotificationsRepository } from "@/db/repositories/notifications.reposit import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; import type { Database } from "@/db/types/index.js"; -import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import { beginBackupStateWrite } from "@/services/backup-capture-lock.js"; import { badgeCount, @@ -34,7 +33,20 @@ import { logger } from "@/utils/logger.js"; * `/attention` route's body union — hooks can only report * `turn_complete`/`needs_attention`. */ -export type NotifyKind = "turn_complete" | "needs_attention" | "exited" | "crashed" | "crashed_final" | "maintenance"; +/** + * What happened. `grant_approval` is the ONE kind not attached to a subshell: + * a first-use key-grant request is waiting on the owner's answer (spec + * 2026-10-08 §6.2), delivered through {@link NotifyService.notifyGrantApprovalOwner} + * rather than `notifySubshell`, and it never reads or writes any subshell row. + */ +export type NotifyKind = + | "turn_complete" + | "needs_attention" + | "exited" + | "crashed" + | "crashed_final" + | "maintenance" + | "grant_approval"; const BODY: Record = { turn_complete: "Ready for your next message", @@ -43,6 +55,10 @@ const BODY: Record = { crashed: "Session stopped unexpectedly. Restarting…", crashed_final: "Session stopped unexpectedly", maintenance: "Stopped for node maintenance", + // Fixed text, named per the brief; carries no destination, no key, no + // fingerprint - only the opaque request id rides the tag/url (Global + // Constraints, spec 2026-10-08 §6.2). + grant_approval: "A key grant needs your approval", }; /** @@ -50,6 +66,9 @@ const BODY: Record = { * pane's follow-ups until the owner opens the pane, and only an event that * strictly OUTRANKS the unseen one rings through. `crashed_final` tops it — * the restart loop giving up is news even behind an unseen `crashed`. + * `grant_approval` sits beside `needs_attention`: it is not consulted by the + * grant send (no subshell row carries the ladder state), and the value exists + * to keep the Record total, exactly as the mobile copy table does. */ const PUSH_URGENCY: Record = { turn_complete: 1, @@ -58,6 +77,7 @@ const PUSH_URGENCY: Record = { crashed: 3, maintenance: 3, crashed_final: 4, + grant_approval: 2, }; /** @@ -70,6 +90,17 @@ export function buildNotificationPayload(row: { id: string; name: string }, kind return { title: row.name, body: BODY[kind], url: `/subshells/${row.id}`, tag: row.id }; } +/** + * The web-push payload for a first-use key-grant request (spec 2026-10-08 + * §6.2). FIXED text naming only the act, the app name as title, and the + * opaque request id as the tag - no destination, no key, no fingerprint, and + * nothing derived from the pending row beyond its own id (Global + * Constraints). The URL is the grants/approvals screen under SSH settings. + */ +export function buildGrantApprovalPayload(requestId: string) { + return { title: "Subshell", body: BODY.grant_approval, url: "/settings/ssh", tag: requestId }; +} + /** * Minimal seam over web-push so tests inject a fake. * @@ -136,11 +167,22 @@ export function createNotifyService(deps: NotifyServiceDeps) { * rows pruned only on the relay's DeviceNotRegistered verdict. A service * built without the device transport behaves exactly as it did before the * mobile app existed (invariant 2). + * + * Shaped by USER + SUBJECT id, not by a subshell row, because the one + * non-subshell kind (a first-use `grant_approval`, spec 2026-10-08 §6.2) + * rides the SAME device-token machinery: its subject is the opaque request + * id and it has no waiting-stamp (`waitingSince: null` reads as "not + * stamped", which `badgeCount` already spells). */ /** @returns the number of relay-usable device tokens this fan-out targeted */ - async function notifyDevices(row: SubshellTable, kind: NotifyKind): Promise { + async function fanOutExpo( + userId: string, + subjectId: string, + kind: NotifyKind, + waitingSince: string | null, + ): Promise { if (!deps.devices) return 0; - const enrolled = await deps.devices.listByUser(row.userId); + const enrolled = await deps.devices.listByUser(userId); if (enrolled.length === 0) return 0; // One pass partitions relay-usable rows from junk; the junk rows are ones // the relay can never use, so they go now rather than on every send. @@ -156,11 +198,11 @@ export function createNotifyService(deps: NotifyServiceDeps) { // unreachable — the blessed predicate comes from the registry (importing // it from subshell-manager would cycle: subshell-manager already imports // this module). - const counts = await subshellsRepo.countsByUser(row.userId, isNodeOffline); - const badge = badgeCount(counts.waiting, kind, row.waitingSince); + const counts = await subshellsRepo.countsByUser(userId, isNodeOffline); + const badge = badgeCount(counts.waiting, kind, waitingSince); const messages = buildExpoMessages( live.map((t) => t.token), - row.id, + subjectId, kind, badge, ); @@ -184,6 +226,37 @@ export function createNotifyService(deps: NotifyServiceDeps) { return live.length; } + /** + * Web-push fan-out for one user, the transport the subshell bell has always + * used. Returns the number of subscriptions ATTEMPTED (the unseen gate's + * "an attempt happened" test), pruning only the permanently-dead endpoints + * (403/404/410) the sender contract names. + */ + async function fanOutWebPush(userId: string, payload: object): Promise { + const subs = await deps.subs.listByUser(userId); + if (subs.length === 0) return 0; + const serialized = JSON.stringify(payload); + for (const sub of subs) { + try { + await send({ endpoint: sub.endpoint, p256dh: sub.p256dh, auth: sub.auth }, serialized); + } catch (err) { + const status = (err as { statusCode?: number }).statusCode; + // 404/410 = the endpoint is gone. 403 = the gateway rejects our + // VAPID JWT for this binding (Apple's BadJwtToken: the + // subscription was made against a different server key — e.g. + // after vapid.json rotation). Nothing we sign can ever heal it, + // so it joins the prune set; the client re-subscribes on its + // next enablePush (which now re-binds unconditionally). + if (status === 403 || status === 404 || status === 410) { + await deps.subs.deleteByEndpoint(sub.endpoint); + } else { + logger.withError(err).warn(`push send failed (kept): ${sub.endpoint.slice(0, 60)}…`); + } + } + } + return subs.length; + } + return { async vapidPublicKey(): Promise { return (await keys()).publicKey; @@ -217,29 +290,8 @@ export function createNotifyService(deps: NotifyServiceDeps) { // The transports are independent. Start the device fan-out NOW, before // the sequential web-push loop, so a phone never waits behind N HTTPS // round-trips to browser push gateways (review, efficiency #6). - const deviceDelivery = notifyDevices(row, kind); - const subs = await deps.subs.listByUser(row.userId); - if (subs.length > 0) { - const payload = JSON.stringify(buildNotificationPayload(row, kind)); - for (const sub of subs) { - try { - await send({ endpoint: sub.endpoint, p256dh: sub.p256dh, auth: sub.auth }, payload); - } catch (err) { - const status = (err as { statusCode?: number }).statusCode; - // 404/410 = the endpoint is gone. 403 = the gateway rejects our - // VAPID JWT for this binding (Apple's BadJwtToken: the - // subscription was made against a different server key — e.g. - // after vapid.json rotation). Nothing we sign can ever heal it, - // so it joins the prune set; the client re-subscribes on its - // next enablePush (which now re-binds unconditionally). - if (status === 403 || status === 404 || status === 410) { - await deps.subs.deleteByEndpoint(sub.endpoint); - } else { - logger.withError(err).warn(`push send failed (kept): ${sub.endpoint.slice(0, 60)}…`); - } - } - } - } + const deviceDelivery = fanOutExpo(row.userId, row.id, kind, row.waitingSince); + const attempted = await fanOutWebPush(row.userId, buildNotificationPayload(row, kind)); const liveDevices = await deviceDelivery; // The urgency sticks only behind an ATTEMPTED delivery: the condition // is targets existing (a send was made to at least one), not a gateway @@ -247,7 +299,7 @@ export function createNotifyService(deps: NotifyServiceDeps) { // received nothing and was attempted nothing — such an owner must not // have the pane's future escalations silenced by an attempt that never // existed. - if (subs.length > 0 || liveDevices > 0) { + if (attempted > 0 || liveDevices > 0) { await subshellsRepo.update(subshellId, { lastPushUrgency: urgency }); publishLive({ kind: "subshell.changed", id: subshellId }); } @@ -256,6 +308,34 @@ export function createNotifyService(deps: NotifyServiceDeps) { logger.withError(err).warn(`notifySubshell(${subshellId}, ${kind}) failed`); } }, + /** + * Ring the OWNER's devices about one outstanding first-use key-grant + * request (spec 2026-10-08 §6.2, the owner-addressed sibling of + * `notifySubshell` whose surface is subshell-row-shaped). The subject is + * the opaque request id; the copy is FIXED text naming the act, and the + * message carries no destination, no key, and no fingerprint (Global + * Constraints). The per-user master switch still gates it (a user who + * muted all pushes stays muted); there is no per-pane bell to consult, + * and the unseen gate does not apply (no subshell row carries the state). + * Best-effort by the same contract as `notifySubshell`: a failed push is + * one log line, the durable pending row is the real record. + */ + async notifyGrantApprovalOwner(ownerUserId: string, requestId: string): Promise { + try { + if (!(await userMetaRepo.getNotifyEnabled(ownerUserId))) return; + // Same parallel-transport posture as notifySubshell (efficiency #6). + const deviceDelivery = fanOutExpo(ownerUserId, requestId, "grant_approval", null); + const attempted = await fanOutWebPush(ownerUserId, buildGrantApprovalPayload(requestId)); + const liveDevices = await deviceDelivery; + if (attempted === 0 && liveDevices === 0) { + // No targets at all: nothing was attempted. The approvals queue is + // the delivery that cannot be missed; the log only notes silence. + logger.debug(`grant_approval notify: no push targets for the owner of request ${requestId}`); + } + } catch (err) { + logger.withError(err).warn(`notifyGrantApprovalOwner(${requestId}) failed`); + } + }, }; } diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index 07df9a5f5..8deea36c9 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -227,6 +227,41 @@ export enum BackendErrorCodes { * code, exactly as it drops a `view` grantee's keystrokes. */ SSH_OWNER_INPUT_ONLY = "SSH_OWNER_INPUT_ONLY", + /** + * A relay launch the grant layer refused for lack of a standing grant + * (spec 2026-10-08 §6.2): a first-use approval was RECORDED and asked of + * the key home's owner, and the launch fails fast rather than hang a pane + * on a human. The message names the asking machine and the remedy (answer + * the approval, launch again); never the key or destination beyond what + * the pending row already discloses. + */ + SSH_GRANT_APPROVAL_REQUIRED = "SSH_GRANT_APPROVAL_REQUIRED", + /** + * A grant approval selecting MORE than `SSH_MAX_GRANT_FINGERPRINTS` keys + * (spec 2026-10-08 §5.4): a hard, loud, named refusal on the grants + * surface, NEVER a silent truncation of the operator's selection. The cap + * is law. + */ + SSH_GRANT_KEYS_OVER_LIMIT = "SSH_GRANT_KEYS_OVER_LIMIT", + /** + * A grant fingerprint outside the `SHA256:` display grammar (agent-wire + * base64; `isSshGrantFingerprints`). Refused before any write; the value + * itself is never echoed into the message. + */ + SSH_GRANT_KEYS_INVALID = "SSH_GRANT_KEYS_INVALID", + /** + * The relay broker refused to open (spec 2026-10-08 §5.3): quota, gate, + * handshake, or a refused side. The pane was not launched; the message + * names which door. No grant was consumed and no session exists. + */ + SSH_RELAY_OPEN_FAILED = "SSH_RELAY_OPEN_FAILED", + /** + * A relay launch aimed at a machine that has no registered relay identity + * (spec 2026-10-08 §4.2/§4.3: no `node:` record, or its signing slot is + * still empty). The remedy is on the machine: re-enroll, or let its next + * `ready` fill the slot. + */ + SSH_RELAY_IDENTITY_MISSING = "SSH_RELAY_IDENTITY_MISSING", } /** @@ -493,4 +528,24 @@ export const BackendErrorCodeDefs = { message: "SSH panes accept input from their owner only", statusCode: 403, }, + [BackendErrorCodes.SSH_GRANT_APPROVAL_REQUIRED]: { + message: "This connection needs a key-grant approval first", + statusCode: 409, + }, + [BackendErrorCodes.SSH_GRANT_KEYS_OVER_LIMIT]: { + message: "Too many keys selected for one grant", + statusCode: 400, + }, + [BackendErrorCodes.SSH_GRANT_KEYS_INVALID]: { + message: "That key fingerprint is not in the SHA256 form grants store", + statusCode: 400, + }, + [BackendErrorCodes.SSH_RELAY_OPEN_FAILED]: { + message: "The relay refused to open; the pane was not launched", + statusCode: 409, + }, + [BackendErrorCodes.SSH_RELAY_IDENTITY_MISSING]: { + message: "That machine has no registered relay identity yet", + statusCode: 409, + }, }; From c4904d7e7eeeca87f33dfdacd7ee9378539b91ec Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:45:09 -0700 Subject: [PATCH 44/92] feat(ssh-grants): the grant layer service - first use, approval, match, revoke The authorization layer (spec 2026-10-08 SS6): requestFirstUse writes the durable pending row + owner notify (a relaunch FINDS the row, no second ask); approve/deny answer on a compare-and-set (the raced approver sees the named 409); matchGrant applies the selector grammar (concrete host or '*' globs, most specific wins, ties to the oldest); revoke deletes the row then cuts every live relay through closeForGrant(grant-revoked) - the T8 hook closes here. prepareRelayLeg gives openRelay its first production caller with decision 3's pre/post liveness re-checks, so a revoke landing mid-open cuts the fresh session instead of orphaning it (T8 NIT1). Audit metadata is ids/hosts/fingerprint-COUNT only, and the suite pins that the whole trail carries no fingerprint VALUE. Two more named codes: SSH_GRANT_SELECTOR_ INVALID, SSH_GRANT_ALREADY_ANSWERED. 21 service tests, all seams injected (clock/broker/notify): nothing waits, nothing dials, nothing real is contacted. --- .../__tests__/ssh-grants.service.test.ts | 674 +++++++++++++++++ .../api/src/services/ssh-grants.service.ts | 709 ++++++++++++++++++ packages/backend-errors/src/error-codes.ts | 24 + 3 files changed, 1407 insertions(+) create mode 100644 apps/server/api/src/services/__tests__/ssh-grants.service.test.ts create mode 100644 apps/server/api/src/services/ssh-grants.service.ts diff --git a/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts new file mode 100644 index 000000000..4e1510f72 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts @@ -0,0 +1,674 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SshGrantsRepository } from "@/db/repositories/ssh-grants.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { + approveGrant, + createGrant, + denyGrant, + listGrantRequests, + matchGrant, + normalizeSshGrantSelector, + prepareRelayLeg, + requestFirstUse, + revokeGrant, + type SshGrantView, + setSshGrantsDepsForTests, + sshGrantSelectorMatches, + sweepExpiredGrantRequests, + updateGrant, +} from "@/services/ssh-grants.service.js"; +import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service.js"; + +/** + * The grant authorization layer (spec 2026-10-08 §6; Task 10). What this + * suite exists to pin, property by property: + * + * - FIRST USE: a launch with no standing grant records ONE durable pending + * row, notifies the owner once, and a relaunch FINDS that row (no second + * approval, no second notification). The row is DB state: a "restart" + * (deps swapped, nothing else) still finds it. + * - APPROVE writes the grant with EXACTLY the chosen fingerprints, flips the + * request, and audits `approve` + `create` whose metadata names the COUNT, + * never a fingerprint string. A selection over SSH_MAX_GRANT_FINGERPRINTS + * is the named red error with the input untouched, never a truncation. + * - DENY audits only. EXPIRY sweeps the row to `expired` and writes NOTHING. + * - MATCH: owner + key home + selector against the resolved host; globs are + * policy; most specific wins, ties to the OLDEST grant. + * - REVOKE cuts live relays: closeForGrant(grantId, "grant-revoked") is the + * revoke's own act (the T8 hook closing here). + * - THE LAUNCH LEG: match -> openRelay (peers from the identities store) -> + * socket; no grant -> requestFirstUse + the named fail-fast, and the + * openRelay NEVER happens; a grant revoked mid-open is found by the + * post-open liveness re-check and cut, not orphaned (T8 NIT1). + * + * The clock, the broker, and the notification sink are the service's own + * injected seams (the `getNodeWsDeps` pattern) - nothing waits, nothing dials. + */ + +const repo = new SshGrantsRepository(db); +const emails: string[] = []; + +async function mkUser(tag: string): Promise { + const email = `grants-svc-${tag}-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("grants-svc-1"), + role: "user", + }); +} + +let owner: string; +let stranger: string; +const NODE_A = "gsvc-node-a"; +const NODE_B = "gsvc-node-b"; +const nodes = new NodesRepository(db); + +/** The one destination this suite routes through. */ +const HOST = "git.example.test"; + +/** A fake clock the sweep and the expiry windows read; setT() moves it. */ +let clockMs = Date.parse("2026-10-08T00:00:00.000Z"); +const nowIso = () => new Date(clockMs).toISOString(); + +interface FakeCall { + grantId: string; + reason: string; +} +let openCalls: unknown[] = []; +let closeGrantCalls: FakeCall[] = []; +let notifyCalls: { ownerUserId: string; requestId: string }[] = []; + +function installFakeDeps(brokerOverrides: Partial = {}) { + const broker = { + openRelay: async (input: unknown) => { + openCalls.push(input); + return { + relayId: "relay-1", + ref: "ref-1", + expiresAt: new Date(clockMs + 30_000).toISOString(), + socketPath: "/home/scripted/.subshell/ssh/pane/agent.sock", + }; + }, + closeForGrant: async (grantId: string, reason: string) => { + closeGrantCalls.push({ grantId, reason }); + return 1; + }, + ...brokerOverrides, + } as unknown as RelayBroker; + setSshGrantsDepsForTests({ + nowIso, + broker: () => broker, + notifyGrantApproval: (ownerUserId, requestId) => { + notifyCalls.push({ ownerUserId, requestId }); + }, + }); +} + +const FP = (n: number) => `SHA256:${"a".repeat(43).slice(0, 43 - String(n).length)}${String(n).padStart(2, "0")}`; +const FPS = [FP(1), FP(2)]; + +beforeAll(async () => { + await setupAuthTables(); + await ensureMigratedTestDb(); + owner = await mkUser("owner"); + stranger = await mkUser("stranger"); + await nodes.create({ id: NODE_A, ownerUserId: owner, name: "key home", kind: "agent", status: "offline" }); + await nodes.setSshEnabled(NODE_A, { on: true, changedAt: nowIso() }); + await nodes.create({ id: NODE_B, ownerUserId: owner, name: "connector", kind: "agent", status: "offline" }); + await nodes.setSshEnabled(NODE_B, { on: true, changedAt: nowIso() }); + const identities = new IdentitiesRepository(db); + await identities.register({ + principalId: `node:${NODE_A}`, + publicKey: '{"kty":"EC","crv":"P-256","x":"AAA","y":"BBB"}', + signingPublicKey: '{"kty":"EC","crv":"P-256","x":"CCC","y":"DDD"}', + displayName: "A", + }); + await identities.register({ + principalId: `node:${NODE_B}`, + publicKey: '{"kty":"EC","crv":"P-256","x":"EEE","y":"FFF"}', + signingPublicKey: '{"kty":"EC","crv":"P-256","x":"GGG","y":"HHH"}', + displayName: "B", + }); +}); + +afterAll(async () => { + setSshGrantsDepsForTests(null); + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("auditEvents").execute(); + await db.deleteFrom("identities").execute(); + for (const id of [NODE_A, NODE_B]) await nodes.deleteById(id).catch(() => {}); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +beforeEach(() => { + clockMs = Date.parse("2026-10-08T00:00:00.000Z"); + openCalls = []; + closeGrantCalls = []; + notifyCalls = []; + installFakeDeps(); +}); + +async function cleanTier() { + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("auditEvents").execute(); +} + +async function latestAudit(action: string): Promise> { + const rows = await db + .selectFrom("auditEvents") + .select("metadataJson") + .where("action", "=", action) + .orderBy("createdAt", "desc") + .limit(1) + .execute(); + return rows[0] ? (JSON.parse(rows[0].metadataJson ?? "{}") as Record) : {}; +} + +describe("ssh-grants.service", () => { + beforeEach(async () => { + await cleanTier(); + }); + + describe("requestFirstUse", () => { + it("creates the pending row, notifies the owner once, and audits the ask (ids + destination, nothing else)", async () => { + const answer = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "pane-ask-1", + }); + expect(answer.ok).toBe(true); + if (!answer.ok) return; + const row = await repo.getRequest(owner, answer.value.requestId); + expect(row).toBeDefined(); + expect(row?.status).toBe("pending"); + expect(row?.keyHomeNodeId).toBe(NODE_A); + expect(row?.bNodeId).toBe(NODE_B); + expect(row?.paneId).toBe("pane-ask-1"); + // setup-key scale (24 h): the deadline is a day out from the ask. + if (!row) throw new Error("pending row vanished"); + expect(Date.parse(row.expiresAt) - Date.parse(row.createdAt)).toBe(24 * 60 * 60 * 1000); + expect(notifyCalls).toEqual([{ ownerUserId: owner, requestId: answer.value.requestId }]); + const audit = await latestAudit("node.ssh_grant.request"); + expect(audit.requestId).toBe(answer.value.requestId); + expect(audit.paneId).toBe("pane-ask-1"); + expect(audit.aNodeId).toBe(NODE_A); + expect(audit.bNodeId).toBe(NODE_B); + expect(audit.destination).toBe(HOST); + }); + + it("a relaunch FINDS the pending row: same id, no second row, no second notify, no second audit", async () => { + const first = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p1", + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + clockMs += 60_000; + const again = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p2", + }); + expect(again.ok).toBe(true); + if (!again.ok) return; + expect(again.value.requestId).toBe(first.value.requestId); + expect(again.value.reused).toBe(true); + expect(notifyCalls).toHaveLength(1); + const rows = await db.selectFrom("sshGrantRequests").selectAll().execute(); + expect(rows).toHaveLength(1); + const asks = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_grant.request") + .execute(); + expect(asks).toHaveLength(1); + }); + + it("survives a simulated restart: fresh deps (nothing else changed) still find the standing pending row", async () => { + const first = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p1", + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + // "Restart": drop and reinstall the seam - only the DB remains. + setSshGrantsDepsForTests(null); + notifyCalls = []; + installFakeDeps(); + const after = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p1", + }); + expect(after.ok).toBe(true); + if (!after.ok) return; + expect(after.value.requestId).toBe(first.value.requestId); + expect(notifyCalls).toHaveLength(0); + }); + }); + + describe("approval cap and shape", () => { + let requestRef = ""; + beforeEach(async () => { + const asked = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p1", + }); + if (asked.ok) requestRef = asked.value.requestId; + }); + + it("selecting more than SSH_MAX_GRANT_FINGERPRINTS keys is the named red error, never a truncation", async () => { + const over = Array.from({ length: SSH_MAX_GRANT_FINGERPRINTS + 1 }, (_, i) => FP(i + 100)); + const refusal = await approveGrant({ ownerUserId: owner, requestId: requestRef, fingerprints: over }); + expect(refusal.ok).toBe(false); + if (refusal.ok) return; + expect(refusal.refusal).toMatchObject({ status: 400, code: BackendErrorCodes.SSH_GRANT_KEYS_OVER_LIMIT }); + // Nothing was written: the request still stands pending, no grant row. + expect((await repo.getRequest(owner, requestRef))?.status).toBe("pending"); + expect(await db.selectFrom("sshKeyGrants").selectAll().execute()).toHaveLength(0); + }); + + it("a fingerprint outside the SHA256 grammar is refused by its own code; duplicates do not count twice", async () => { + const bad = await approveGrant({ + ownerUserId: owner, + requestId: requestRef, + fingerprints: ["not-a-fingerprint"], + }); + expect(bad.ok).toBe(false); + if (bad.ok) return; + expect(bad.refusal.code).toBe(BackendErrorCodes.SSH_GRANT_KEYS_INVALID); + // 8 entries where 7 are the same key: the UNIQUE selection fits under the + // cap, and the stored grant carries the deduped set. + const dupes = [...FPS, FP(2), FP(2), FP(2), FP(2), FP(2), FP(2)]; + const eight = [...Array.from({ length: 8 }, (_, i) => FP(i + 200))]; + const deduped = await approveGrant({ + ownerUserId: owner, + requestId: requestRef, + fingerprints: dupes.slice(0, 7), + }); + expect(deduped.ok).toBe(true); + if (!deduped.ok) return; + expect(new Set(deduped.value.grant.fingerprints).size).toBe(deduped.value.grant.fingerprints.length); + // exactly 8 fits; 9 does not (recompute on a fresh request). + await repo.markRequestStatus(owner, requestRef, "approved", "pending"); + await db.deleteFrom("sshKeyGrants").execute(); + const exact = await approveGrant({ ownerUserId: owner, requestId: requestRef, fingerprints: eight }); + expect(exact.ok).toBe(true); + await db.deleteFrom("sshKeyGrants").execute(); + const one = await approveGrant({ ownerUserId: owner, requestId: requestRef, fingerprints: [...eight, FP(299)] }); + expect(one.ok).toBe(false); + }); + + it("approve writes the grant (createdVia first-use), answers the request, and audits COUNTS, never fingerprint strings", async () => { + const approved = await approveGrant({ + ownerUserId: owner, + requestId: requestRef, + fingerprints: FPS, + name: "work git", + }); + expect(approved.ok).toBe(true); + if (!approved.ok) return; + expect(approved.value.grant.createdVia).toBe("first-use"); + expect(approved.value.grant.fingerprints).toEqual(FPS); + expect(approved.value.grant.resolvedSelector).toBe(HOST); + expect(approved.value.grant.keyHomeNodeId).toBe(NODE_A); + expect((await repo.getRequest(owner, requestRef))?.status).toBe("approved"); + const create = await latestAudit("node.ssh_grant.create"); + expect(create.fingerprintsCount).toBe(2); + expect(create.via).toBe("first-use"); + const approve = await latestAudit("node.ssh_grant.approve"); + expect(approve.grantId).toBe(approved.value.grant.id); + expect(approve.fingerprintsCount).toBe(2); + // The Global Constraint, read off the WHOLE trail: no fingerprint VALUE + // in any audit row this act wrote. + const all = await db.selectFrom("auditEvents").select("metadataJson").execute(); + for (const row of all) { + for (const fp of FPS) expect(row.metadataJson).not.toContain(fp); + } + }); + + it("a second answer to the same request is the named 409 (a raced or repeat approver)", async () => { + const first = await approveGrant({ ownerUserId: owner, requestId: requestRef, fingerprints: FPS }); + expect(first.ok).toBe(true); + const second = await approveGrant({ ownerUserId: owner, requestId: requestRef, fingerprints: FPS }); + expect(second.ok).toBe(false); + if (second.ok) return; + expect(second.refusal.status).toBe(409); + }); + + it("a foreign owner's request is absent: 404, and the row stands unanswered", async () => { + const denied = await denyGrant({ ownerUserId: stranger, requestId: requestRef }); + expect(denied.ok).toBe(false); + if (denied.ok) return; + expect(denied.refusal.status).toBe(404); + expect((await repo.getRequest(owner, requestRef))?.status).toBe("pending"); + }); + + it("deny writes the deny audit and NO grant; expiry sweeps pending rows and writes NOTHING", async () => { + const denied = await denyGrant({ ownerUserId: owner, requestId: requestRef }); + expect(denied.ok).toBe(true); + expect(await db.selectFrom("sshKeyGrants").selectAll().execute()).toHaveLength(0); + expect((await repo.getRequest(owner, requestRef))?.status).toBe("denied"); + const audit = await latestAudit("node.ssh_grant.deny"); + expect(audit.requestId).toBe(requestRef); + + // Fresh ask, then push the clock past the deadline and sweep. + await cleanTier(); + const asked = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p9", + }); + expect(asked.ok).toBe(true); + if (!asked.ok) return; + const beforeSweep = await db.selectFrom("auditEvents").select("id").execute(); + clockMs += 24 * 60 * 60 * 1000 + 1_000; + const swept = await sweepExpiredGrantRequests(); + expect(swept).toBe(1); + expect((await repo.getRequest(owner, asked.value.requestId))?.status).toBe("expired"); + const afterSweep = await db.selectFrom("auditEvents").select("id").execute(); + expect(afterSweep).toHaveLength(beforeSweep.length); // expiry writes NOTHING + // And an expired row no longer dedups: a relaunch asks a NEW question. + notifyCalls = []; + const again = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p9", + }); + expect(again.ok).toBe(true); + if (!again.ok) return; + expect(again.value.requestId).not.toBe(asked.value.requestId); + expect(notifyCalls).toHaveLength(1); + }); + }); + + describe("matchGrant", () => { + async function grant(opts: { + selector: string; + fps?: string[]; + via?: "first-use" | "manual"; + at?: string; + }): Promise { + const created = await createGrant({ + ownerUserId: owner, + aNodeId: NODE_A, + name: "g", + selector: opts.selector, + fingerprints: opts.fps ?? FPS, + }); + if (!created.ok) throw new Error("grant fixture refused"); + if (opts.at) { + await db + .updateTable("sshKeyGrants") + .set({ createdAt: opts.at }) + .where("id", "=", created.value.grant.id) + .execute(); + } + return created.value.grant; + } + + it("exact host matches; a glob matches under it; the other owner and the other key home never match", async () => { + const exact = await grant({ selector: HOST }); + expect((await matchGrant({ ownerUserId: owner, aNodeId: NODE_A, resolvedHost: HOST }))?.id).toBe(exact.id); + expect(await matchGrant({ ownerUserId: owner, aNodeId: NODE_A, resolvedHost: "other.example.test" })).toBeNull(); + const globs = await grant({ selector: "*.example.test" }); + await db.deleteFrom("sshKeyGrants").where("id", "=", exact.id).execute(); + expect( + (await matchGrant({ ownerUserId: owner, aNodeId: NODE_A, resolvedHost: "deep.git.example.test" }))?.id, + ).toBe(globs.id); + expect(await matchGrant({ ownerUserId: stranger, aNodeId: NODE_A, resolvedHost: "git.example.test" })).toBeNull(); + expect(await matchGrant({ ownerUserId: owner, aNodeId: NODE_B, resolvedHost: HOST })).toBeNull(); + }); + + it("most specific wins, and equal specificity ties to the OLDEST grant", async () => { + await grant({ selector: "*.example.test", at: "2026-01-01T00:00:00.000Z" }); + const concrete = await grant({ selector: HOST, at: "2026-02-01T00:00:00.000Z" }); + expect((await matchGrant({ ownerUserId: owner, aNodeId: NODE_A, resolvedHost: HOST }))?.id).toBe(concrete.id); + // Two matching selectors of EQUAL specificity (identical globs here): + // the tie breaks to the older grant, §6.3's stated rule. + await db.deleteFrom("sshKeyGrants").execute(); + const older = await grant({ selector: "*.example.test", at: "2026-01-01T00:00:00.000Z" }); + await grant({ selector: "*.example.test", at: "2026-02-01T00:00:00.000Z" }); + expect((await matchGrant({ ownerUserId: owner, aNodeId: NODE_A, resolvedHost: HOST }))?.id).toBe(older.id); + }); + + it("a matching grant makes the launch leg open the relay with the grant's facts and the STORED peers, and hand back the socket", async () => { + const g = await grant({ selector: HOST }); + const leg = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "key home" }, + bNodeId: NODE_B, + resolvedHost: HOST, + paneId: "pane-1", + }); + expect(leg.ok).toBe(true); + if (!leg.ok) return; + expect(leg.value.grantId).toBe(g.id); + expect(openCalls).toHaveLength(1); + const call = openCalls[0] as Record; + expect(call.grantId).toBe(g.id); + expect(call.fingerprints).toEqual(FPS); + expect(call.paneId).toBe("pane-1"); + expect(call.aNode).toBe(NODE_A); + expect(call.bNode).toBe(NODE_B); + // The peer halves come from the identities store, not from the caller. + expect((call.aPeer as Record).signingPublicKey).toContain("CCC"); + expect((call.bPeer as Record).encryptionPublicJwk).toContain("EEE"); + expect(typeof leg.value.socketPath).toBe("string"); + }); + + it("NO grant: the leg asks (requestFirstUse), refuses with the named code, and NEVER opens", async () => { + const leg = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "key home" }, + bNodeId: NODE_B, + resolvedHost: HOST, + paneId: "pane-ask", + }); + expect(leg.ok).toBe(false); + if (leg.ok) return; + expect(leg.refusal).toMatchObject({ status: 409, code: BackendErrorCodes.SSH_GRANT_APPROVAL_REQUIRED }); + expect(openCalls).toHaveLength(0); + expect(notifyCalls).toHaveLength(1); // the ask happened exactly as first-use prescribes + // The message names the machine and the remedy, never a key or secret. + expect(leg.refusal.message).toContain("key home"); + expect(leg.refusal.message.toLowerCase()).toContain("approve"); + }); + + it("a machine with no registered signing half refuses SSH_RELAY_IDENTITY_MISSING before anything opens", async () => { + await grant({ selector: HOST }); + await db + .updateTable("identities") + .set({ signingPublicKey: null }) + .where("principalId", "=", `node:${NODE_B}`) + .execute(); + const leg = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "A" }, + bNodeId: NODE_B, + resolvedHost: HOST, + paneId: "p", + }); + expect(leg.ok).toBe(false); + if (leg.ok) return; + expect(leg.refusal.code).toBe(BackendErrorCodes.SSH_RELAY_IDENTITY_MISSING); + expect(openCalls).toHaveLength(0); + await db + .updateTable("identities") + .set({ signingPublicKey: '{"kty":"EC","crv":"P-256","x":"GGG","y":"HHH"}' }) + .where("principalId", "=", `node:${NODE_B}`) + .execute(); + }); + + it("the broker's refusal comes back as the named SSH_RELAY_OPEN_FAILED and launches nothing", async () => { + await grant({ selector: HOST }); + installFakeDeps({ + openRelay: (async () => { + throw new SshRelayRefusal("quota", "relay open refused: node already holds 8 live relay sessions (max 8)"); + }) as RelayBroker["openRelay"], + }); + const leg = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "A" }, + bNodeId: NODE_B, + resolvedHost: HOST, + paneId: "p", + }); + expect(leg.ok).toBe(false); + if (leg.ok) return; + expect(leg.refusal.code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); + }); + + it("a revoke landing MID-OPEN is caught by the post-open liveness re-check and the fresh session is cut, not orphaned (T8 NIT1)", async () => { + const g = await grant({ selector: HOST }); + installFakeDeps({ + openRelay: (async (input: unknown) => { + // The revoke's DB half lands between the match and the open. + await db + .deleteFrom("sshKeyGrants") + .where("id", "=", (input as { grantId: string }).grantId) + .execute(); + return { + relayId: "r", + ref: "f", + expiresAt: "2026-10-08T00:00:30.000Z", + socketPath: "/x/agent.sock", + }; + }) as RelayBroker["openRelay"], + }); + const leg = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "A" }, + bNodeId: NODE_B, + resolvedHost: HOST, + paneId: "p", + }); + expect(leg.ok).toBe(false); + if (leg.ok) return; + expect(leg.refusal.code).toBe(BackendErrorCodes.SSH_GRANT_APPROVAL_REQUIRED); + expect(closeGrantCalls).toEqual([{ grantId: g.id, reason: "grant-revoked" }]); + }); + }); + + describe("the grants screen and revoke", () => { + it("create validates the selector and stores it normalized", async () => { + expect(normalizeSshGrantSelector(" Git.Example.TEST ")).toBe("git.example.test"); + expect(normalizeSshGrantSelector("*")).toBeNull(); // names nothing concrete + expect(normalizeSshGrantSelector("a b")).toBeNull(); + expect(normalizeSshGrantSelector("bad\nhost")).toBeNull(); + expect(normalizeSshGrantSelector("**a**")).toBeNull(); // doubled wildcards are not a host pattern + expect(normalizeSshGrantSelector(".example.test")).toBeNull(); + const refused = await createGrant({ + ownerUserId: owner, + aNodeId: NODE_A, + name: "n", + selector: "bad host", + fingerprints: FPS, + }); + expect(refused.ok).toBe(false); + if (refused.ok) return; + expect(refused.refusal.status).toBe(400); + }); + + it("the selector matcher treats * as the only wildcard and lowercases the host", () => { + expect(sshGrantSelectorMatches("*.example.test", "GIT.EXAMPLE.TEST")).toBe(true); + expect(sshGrantSelectorMatches("git.*.test", "git.example.test")).toBe(true); + expect(sshGrantSelectorMatches("git.example.test", "evilgit.example.test")).toBe(false); + expect(sshGrantSelectorMatches("a.b", "axb")).toBe(false); // the DOT is literal, not the regex dot + }); + + it("update edits name and selector and audits the change (no fingerprint rewrite path exists)", async () => { + const created = await createGrant({ + ownerUserId: owner, + aNodeId: NODE_A, + name: "one", + selector: HOST, + fingerprints: FPS, + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const updated = await updateGrant({ + ownerUserId: owner, + grantId: created.value.grant.id, + selector: "*.example.test", + }); + expect(updated.ok).toBe(true); + if (!updated.ok) return; + expect(updated.value.grant.resolvedSelector).toBe("*.example.test"); + expect(updated.value.grant.fingerprints).toEqual(FPS); // selection is immutable through edit + const audit = await latestAudit("node.ssh_grant.update"); + expect(audit.grantId).toBe(created.value.grant.id); + expect(audit.fingerprintsCount).toBe(2); + }); + + it("revoke deletes the row, cuts every live relay through closeForGrant, and audits the count", async () => { + const created = await createGrant({ + ownerUserId: owner, + aNodeId: NODE_A, + name: "revoke me", + selector: HOST, + fingerprints: FPS, + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const revoked = await revokeGrant({ ownerUserId: owner, grantId: created.value.grant.id }); + expect(revoked.ok).toBe(true); + if (!revoked.ok) return; + expect(closeGrantCalls).toEqual([{ grantId: created.value.grant.id, reason: "grant-revoked" }]); + expect(await repo.getGrant(owner, created.value.grant.id)).toBeUndefined(); + const audit = await latestAudit("node.ssh_grant.delete"); + expect(audit.grantId).toBe(created.value.grant.id); + expect(audit.relaysClosed).toBe(1); + // A foreign id is the same 404 as an absent one, and cuts nothing. + closeGrantCalls = []; + const foreign = await revokeGrant({ ownerUserId: stranger, grantId: created.value.grant.id }); + expect(foreign.ok).toBe(false); + if (foreign.ok) return; + expect(foreign.refusal.status).toBe(404); + expect(closeGrantCalls).toHaveLength(0); + }); + + it("the queue read reports only the caller's rows", async () => { + await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: "p", + }); + expect((await listGrantRequests({ ownerUserId: owner })).length).toBe(1); + expect((await listGrantRequests({ ownerUserId: stranger })).length).toBe(0); + }); + }); +}); diff --git a/apps/server/api/src/services/ssh-grants.service.ts b/apps/server/api/src/services/ssh-grants.service.ts new file mode 100644 index 000000000..e61e2318c --- /dev/null +++ b/apps/server/api/src/services/ssh-grants.service.ts @@ -0,0 +1,709 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { isSshGrantFingerprints, SSH_MAX_GRANT_FINGERPRINTS, SSH_NAME_MAX_CHARS } from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { type NewSshGrantRequest, SshGrantsRepository } from "@/db/repositories/ssh-grants.repository.js"; +import type { SshGrantCreatedVia, SshGrantRequestStatus, SshKeyGrantTable } from "@/db/types/ssh-grants.db-types.js"; +import { type AuditEventInput, audit } from "@/services/audit.js"; +import { getNotifyService } from "@/services/notify.service.js"; +import { getRelayBroker, type RelayBroker, type RelayPeerKeys, SshRelayRefusal } from "@/services/ssh-relay.service.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The key-grant authorization layer (spec 2026-10-08 §6; migration 0050). + * This module is the ONLY place a grant is decided, asked, answered, edited, + * or revoked; the launch path and the routes are its callers. + * + * **The flow the layer implements** (§6.2/§6.3): a relay launch asks + * {@link matchGrant} first. A standing grant answers with the selection; + * nothing else does, so {@link prepareRelayLeg} opens a broker session only + * under a row that existed at the match read and still exists after the open + * (the liveness re-check is decision 3's answer to T8 NIT1: a revoke landing + * between the match and `openRelay` finds no orphan, because the fresh + * session is cut with the named reason and the launch refuses). No grant: + * {@link requestFirstUse} records the durable pending row, notifies the + * owner, and the launch fails fast with the named refusal - never a pane + * hanging on a human. + * + * **The owner model** (decision 5, spec §6.2): a grant and its requests are + * owned by the key home's owner, and the launch gates A through `nodeCanSsh` + * before this layer is ever consulted - SSH is owner-reserved on agent nodes, + * so the asking user, the row's owner, and the approving operator are one + * account. The routes render foreign ids as the 404 the ownership axis + * demands; a foreign row is absent here, never "forbidden". + * + * **What never leaves this layer's row** (Global Constraints): fingerprint + * VALUES. They are public identifiers and they live in the grant column, but + * they appear in NO audit metadata (the COUNT is the law), NO notification + * (fixed copy + the opaque request id), and NO log line this file writes. + * + * Everything outside the module is an injected seam (the `getNodeWsDeps` + * pattern): the clock, the relay broker, and the notification sink. The + * production defaults sit at the bottom; tests replace the whole trio. + */ + +/** How long an unanswered first-use request stays answerable: setup-key scale (24 h). */ +export const GRANT_REQUEST_TTL_MS = 24 * 60 * 60 * 1000; + +const repo = new SshGrantsRepository(db); + +/** The service's world. Production at {@link defaultGrantsDeps}; tests install a fake. */ +export interface SshGrantsDeps { + /** The one clock: row stamps, dedup cuts, the expiry sweep. */ + nowIso(): string; + /** The relay broker the revoke hook and the launch leg reach (the module singleton). */ + broker(): RelayBroker; + /** Ring the owner about one pending request (fire-and-forget; the sink swallows its own failures). */ + notifyGrantApproval(ownerUserId: string, requestId: string): void; +} + +let depsOverride: SshGrantsDeps | null = null; + +function grantsDeps(): SshGrantsDeps { + if (depsOverride) return depsOverride; + return { + nowIso: () => new Date().toISOString(), + broker: () => getRelayBroker(), + notifyGrantApproval: (ownerUserId, requestId) => { + // The notify surface is best-effort by its own contract; the durable + // pending row is the real record, so this call is never awaited. + getNotifyService() + .notifyGrantApprovalOwner(ownerUserId, requestId) + .catch((err: unknown) => logger.warn(`grant_approval notify dispatch failed: ${String(err)}`)); + }, + }; +} + +/** + * Install (or with null, drop) the whole seam. + * @internal test-only - the suite pins first-use/notify, the broker hooks, and + * the clock without a live plane, socket, or real 24 h wait. + */ +export function setSshGrantsDepsForTests(deps: SshGrantsDeps | null): void { + depsOverride = deps; +} + +/* ------------------------------------------------------------------ */ +/* selector grammar: concrete host or `*` pattern, matched on hostnames */ +/* ------------------------------------------------------------------ */ + +/** + * Normalize one destination selector: trim, lowercase (hostnames are + * case-insensitive; the stored selector is compared against lowercased + * resolved hosts), and enforce the pattern grammar - `[a-z0-9*._-]`, must + * hold at least one literal character ("*" alone names nothing), no leading + * `-`/`.`, no doubled `*`, no `..`, no whitespace or control characters, at + * most {@link SSH_NAME_MAX_CHARS}. Parent §5.1: the selector is a concrete + * host or a host glob matched against the resolved destination hostname, as + * policy only, unrelated to the wildcard patterns discovery drops. + * + * @returns the normalized selector, or null for anything the grammar refuses. + */ +export function normalizeSshGrantSelector(value: string): string | null { + const s = value.trim().toLowerCase(); + if (s.length === 0 || s.length > SSH_NAME_MAX_CHARS) return null; + if (/\s/.test(s) || /\p{Cc}/u.test(s)) return null; + if (s.startsWith("-") || s.startsWith(".") || s.endsWith(".") || s.includes("..") || s.includes("**")) return null; + if (!/^[a-z0-9*][a-z0-9.*_-]*$/.test(s)) return null; + if (!/[a-z0-9]/.test(s)) return null; + return s; +} + +/** Escape one glob segment: `*` is the only wildcard, the dot stays a dot. */ +function escapeSegment(segment: string): string { + return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Match a normalized selector against one resolved destination hostname + * (§6.1: matched against the resolved hostname, not the alias the human + * typed). A selector with no `*` is an exact string equality; with wildcards, + * every `*` spans any run of characters and everything else is literal. + */ +export function sshGrantSelectorMatches(normalizedSelector: string, resolvedHost: string): boolean { + const host = resolvedHost.trim().toLowerCase(); + if (!normalizedSelector.includes("*")) return normalizedSelector === host; + const pattern = new RegExp(`^${normalizedSelector.split("*").map(escapeSegment).join(".*")}$`); + return pattern.test(host); +} + +/** Specificity for the §6.3 tie-break: fewer wildcards first, then more literal characters. */ +function selectorSpecificity(selector: string): { wildcards: number; literals: number } { + return { + wildcards: (selector.match(/\*/g) ?? []).length, + literals: selector.replace(/\*/g, "").length, + }; +} + +/* ------------------------------------------------------------------ */ +/* views: the row with its JSON columns parsed */ +/* ------------------------------------------------------------------ */ + +/** One grant row as the API and the launch leg read it: fingerprints parsed, not a JSON string. */ +export interface SshGrantView { + id: string; + name: string; + keyHomeNodeId: string; + resolvedSelector: string; + fingerprints: string[]; + createdVia: SshGrantCreatedVia; + createdAt: string; + updatedAt: string; +} + +/** One first-use request row as the approvals screen reads it. */ +export interface SshGrantRequestView { + id: string; + keyHomeNodeId: string; + resolvedSelector: string; + requestedFingerprints: string[] | null; + paneId: string; + bNodeId: string; + expiresAt: string; + status: SshGrantRequestStatus; + createdAt: string; +} + +function parseFingerprintArray(stored: string): string[] { + try { + const parsed: unknown = JSON.parse(stored); + return Array.isArray(parsed) && parsed.every((f) => typeof f === "string") ? (parsed as string[]) : []; + } catch { + return []; + } +} + +function grantView(row: SshKeyGrantTable): SshGrantView { + return { ...row, fingerprints: parseFingerprintArray(row.fingerprints) }; +} + +/* ------------------------------------------------------------------ */ +/* fingerprint selection: cap and grammar, law first */ +/* ------------------------------------------------------------------ */ + +/** The refusal union the grant surface returns (a subset of the ssh-launch one; same render path). */ +type SshRefusalNarrow = { status: 400 | 404 | 409; code: BackendErrorCodes; message: string }; + +/** The grant surface's answer union: a value, or the refusal the route returns as-is. */ +export type SshGrantAnswer = { ok: true; value: T } | { ok: false; refusal: SshRefusalNarrow }; + +function granted(value: T): SshGrantAnswer { + return { ok: true, value }; +} + +function refused(refusal: SshRefusalNarrow): SshGrantAnswer { + return { ok: false, refusal }; +} + +const NOT_FOUND_REQUEST = { + status: 404, + code: BackendErrorCodes.NOT_FOUND_ERROR, + message: "Grant request not found", +} as const; + +const NOT_FOUND_GRANT = { + status: 404, + code: BackendErrorCodes.NOT_FOUND_ERROR, + message: "Grant not found", +} as const; + +const ALREADY_ANSWERED = { + status: 409, + code: BackendErrorCodes.SSH_GRANT_ALREADY_ANSWERED, + message: "That grant request has already been answered.", +} as const; + +/* ------------------------------------------------------------------ */ +/* audit: ids, hosts, COUNTS - the §9 shape, never a fingerprint */ +/* ------------------------------------------------------------------ */ + +function grantAudit( + actorUserId: string, + action: string, + aNodeId: string, + metadata: Record, +): Promise { + const event: AuditEventInput = { + actorUserId, + action, + targetType: "node", + targetId: aNodeId, + metadataJson: JSON.stringify(metadata), + }; + return audit(event); +} + +/* ------------------------------------------------------------------ */ +/* first use */ +/* ------------------------------------------------------------------ */ + +/** + * Record the first-use approval question (spec §6.2): the DURABLE pending row + * first, then the owner-addressed notify, then the launch's caller fails it + * fast. A standing pending row for the same (owner, key home, selector) is + * FOUND, not duplicated (§6.2: "a re-launch simply finds the pending row"), + * and the found path sends no second notification and no second audit. + * Does NOT open a relay - that is {@link prepareRelayLeg} on the next launch. + */ +export async function requestFirstUse(args: { + ownerUserId: string; + aNodeId: string; + bNodeId: string; + resolvedSelector: string; + paneId: string; +}): Promise> { + const now = grantsDeps().nowIso(); + await repo.sweepExpiredRequests(now); + const existing = await repo.findPendingRequest(args.ownerUserId, args.aNodeId, args.resolvedSelector, now); + if (existing) return granted({ requestId: existing.id, reused: true }); + const request: NewSshGrantRequest = { + id: crypto.randomUUID(), + ownerUserId: args.ownerUserId, + keyHomeNodeId: args.aNodeId, + resolvedSelector: args.resolvedSelector, + requestedFingerprints: null, // the approver selects; the asking pane proposes nothing (spec §6.2) + paneId: args.paneId, + bNodeId: args.bNodeId, + expiresAt: new Date(Date.parse(now) + GRANT_REQUEST_TTL_MS).toISOString(), + status: "pending", + createdAt: now, + }; + await repo.insertRequest(request); + await grantAudit(args.ownerUserId, "node.ssh_grant.request", args.aNodeId, { + requestId: request.id, + paneId: args.paneId, + aNodeId: args.aNodeId, + bNodeId: args.bNodeId, + destination: args.resolvedSelector, + }); + grantsDeps().notifyGrantApproval(args.ownerUserId, request.id); + return granted({ requestId: request.id, reused: false }); +} + +/** + * The expiry sweep (spec §6.2: "expiry writes nothing at all"): mark every + * past-deadline pending row `expired`. No audit row, no grant, no + * notification; the boot/hourly hook in `index.ts` runs it on the same + * cadence as the signup queue it borrows, and the read paths call it lazily + * so an unanswered question flips state before anyone answers it. + * + * @returns the number of rows swept + */ +export async function sweepExpiredGrantRequests(): Promise { + return await repo.sweepExpiredRequests(grantsDeps().nowIso()); +} + +/* ------------------------------------------------------------------ */ +/* answering: approve / deny */ +/* ------------------------------------------------------------------ */ + +/** + * Answer a pending request YES (spec §6.2): validate the selection against + * the cap and the grammar FIRST, then flip the row `pending -> approved` on a + * compare-and-set (a raced approver or the sweep loses here and sees the 409, + * never a double grant), then write the standing grant with EXACTLY the + * chosen fingerprints, created via first-use. Audits BOTH facts: the answer + * (`approve`) and the row's birth (`create`, the same event the screen's own + * create writes, per §6.2), naming ids, destination, and the fingerprint + * COUNT only. + */ +export async function approveGrant(args: { + ownerUserId: string; + requestId: string; + fingerprints: readonly string[]; + name?: string; +}): Promise> { + const keys = await validateSelectionOf(args.fingerprints); + if (!keys.ok) return keys.answer; + const now = grantsDeps().nowIso(); + await repo.sweepExpiredRequests(now); + const row = await repo.getRequest(args.ownerUserId, args.requestId); + if (!row) return refused(NOT_FOUND_REQUEST); + if (row.status !== "pending") return refused(ALREADY_ANSWERED); + const flipped = await repo.markRequestStatus(args.ownerUserId, args.requestId, "pending", "approved"); + if (!flipped) return refused(ALREADY_ANSWERED); + const grant: SshKeyGrantTable = { + id: crypto.randomUUID(), + ownerUserId: args.ownerUserId, + name: args.name?.trim() || row.resolvedSelector, + keyHomeNodeId: row.keyHomeNodeId, + resolvedSelector: row.resolvedSelector, + fingerprints: JSON.stringify(keys.value), + createdVia: "first-use", + createdAt: now, + updatedAt: now, + }; + await repo.insertGrant(grant); + await grantAudit(args.ownerUserId, "node.ssh_grant.approve", row.keyHomeNodeId, { + requestId: row.id, + grantId: grant.id, + destination: row.resolvedSelector, + fingerprintsCount: keys.value.length, // the COUNT; §10 law + }); + await grantAudit(args.ownerUserId, "node.ssh_grant.create", row.keyHomeNodeId, { + grantId: grant.id, + destination: row.resolvedSelector, + fingerprintsCount: keys.value.length, + via: "first-use", + }); + return granted({ grant: grantView(grant) }); +} + +/** + * Answer a pending request NO (spec §6.2): the audit row and nothing else - + * no grant, no pin, and the requesting pane's next relaunch simply asks + * again (a fresh question, since denial is an answer, not a standing state). + */ +export async function denyGrant(args: { + ownerUserId: string; + requestId: string; +}): Promise> { + const now = grantsDeps().nowIso(); + await repo.sweepExpiredRequests(now); + const row = await repo.getRequest(args.ownerUserId, args.requestId); + if (!row) return refused(NOT_FOUND_REQUEST); + if (row.status !== "pending") return refused(ALREADY_ANSWERED); + const flipped = await repo.markRequestStatus(args.ownerUserId, args.requestId, "pending", "denied"); + if (!flipped) return refused(ALREADY_ANSWERED); + await grantAudit(args.ownerUserId, "node.ssh_grant.deny", row.keyHomeNodeId, { + requestId: row.id, + paneId: row.paneId, + bNodeId: row.bNodeId, + destination: row.resolvedSelector, + }); + return granted({ requestId: row.id }); +} + +/* ------------------------------------------------------------------ */ +/* matching */ +/* ------------------------------------------------------------------ */ + +/** + * The §6.3 match: the owner's standing grants for the key home chosen at + * launch, selector-matched against the resolved destination hostname. Most + * specific selector wins; equal specificity ties to the OLDEST grant. B is + * not part of the predicate (a grant authorizes the owner's own connecting + * machines, §6.3), and a foreign or deleted grant is invisible here exactly + * as on the screen. + */ +export async function matchGrant(args: { + ownerUserId: string; + aNodeId: string; + resolvedHost: string; +}): Promise { + const rows = await repo.grantsFor(args.ownerUserId, args.aNodeId); + const host = args.resolvedHost.trim().toLowerCase(); + const matches = rows + .map((row) => ({ row, spec: selectorSpecificity(row.resolvedSelector) })) + .filter((m) => sshGrantSelectorMatches(m.row.resolvedSelector, host)); + if (matches.length === 0) return null; + matches.sort((a, b) => { + if (a.spec.wildcards !== b.spec.wildcards) return a.spec.wildcards - b.spec.wildcards; + if (a.spec.literals !== b.spec.literals) return b.spec.literals - a.spec.literals; + return a.row.createdAt < b.row.createdAt ? -1 : a.row.createdAt > b.row.createdAt ? 1 : 0; + }); + const first = matches[0]; + return first ? grantView(first.row) : null; +} + +/* ------------------------------------------------------------------ */ +/* the grants screen: create / edit / revoke */ +/* ------------------------------------------------------------------ */ + +/** Create a grant from the screen (spec §8; §6.2: the same row and event the approval writes). */ +export async function createGrant(args: { + ownerUserId: string; + aNodeId: string; + name: string; + selector: string; + fingerprints: readonly string[]; +}): Promise> { + const selector = normalizeSshGrantSelector(args.selector); + if (selector === null) return refused(SELECTOR_INVALID); + const keys = await validateSelectionOf(args.fingerprints); + if (!keys.ok) return keys.answer; + const now = grantsDeps().nowIso(); + const grant: SshKeyGrantTable = { + id: crypto.randomUUID(), + ownerUserId: args.ownerUserId, + name: args.name.trim() || selector, + keyHomeNodeId: args.aNodeId, + resolvedSelector: selector, + fingerprints: JSON.stringify(keys.value), + createdVia: "manual", + createdAt: now, + updatedAt: now, + }; + await repo.insertGrant(grant); + await grantAudit(args.ownerUserId, "node.ssh_grant.create", args.aNodeId, { + grantId: grant.id, + destination: selector, + fingerprintsCount: keys.value.length, + via: "manual", + }); + return granted({ grant: grantView(grant) }); +} + +const SELECTOR_INVALID: SshRefusalNarrow = { + status: 400, + code: BackendErrorCodes.SSH_GRANT_SELECTOR_INVALID, + message: "That grant selector is not a hostname pattern.", +}; + +/** + * Edit the screen's own fields: name and/or selector. The fingerprint + * SELECTION is immutable through edit - changing which keys serve is a + * revoke plus a fresh grant, so the standing selection always has exactly the + * audit trail that chose it (§6.2's posture extended to edits by the + * revoke-is-the-only-widening rule). + */ +export async function updateGrant(args: { + ownerUserId: string; + grantId: string; + name?: string; + selector?: string; +}): Promise> { + let selector: string | undefined; + if (args.selector !== undefined) { + const normalized = normalizeSshGrantSelector(args.selector); + if (normalized === null) return refused(SELECTOR_INVALID); + selector = normalized; + } + const existing = await repo.getGrant(args.ownerUserId, args.grantId); + if (!existing) return refused(NOT_FOUND_GRANT); + const updated = await repo.updateGrant(args.ownerUserId, args.grantId, { + name: args.name?.trim(), + resolvedSelector: selector, + updatedAt: grantsDeps().nowIso(), + }); + if (!updated) return refused(NOT_FOUND_GRANT); + await grantAudit(args.ownerUserId, "node.ssh_grant.update", updated.keyHomeNodeId, { + grantId: updated.id, + destination: updated.resolvedSelector, + fingerprintsCount: parseFingerprintArray(updated.fingerprints).length, + changed: [args.name !== undefined ? "name" : null, selector !== undefined ? "selector" : null].filter(Boolean), + }); + return granted({ grant: grantView(updated) }); +} + +/** + * Revoke, the instant both-ways cut (§6.3): the row goes FIRST (the raced + * launch's post-open liveness re-check then answers "gone"), then the broker + * tears down every live session that ran under this grant, with the named + * `grant-revoked` reason. This is the hook T8 left standing; without the + * second call a signing session would ride on after the operator said stop. + */ +export async function revokeGrant(args: { + ownerUserId: string; + grantId: string; +}): Promise> { + const existing = await repo.getGrant(args.ownerUserId, args.grantId); + if (!existing) return refused(NOT_FOUND_GRANT); + const deleted = await repo.deleteGrant(args.ownerUserId, args.grantId); + if (!deleted) return refused(NOT_FOUND_GRANT); // raced with itself; the row is gone either way + const relaysClosed = await grantsDeps().broker().closeForGrant(args.grantId, "grant-revoked"); + await grantAudit(args.ownerUserId, "node.ssh_grant.delete", existing.keyHomeNodeId, { + grantId: args.grantId, + destination: existing.resolvedSelector, + relaysClosed, + }); + return granted({ relaysClosed }); +} + +/** The grants screen list. */ +export async function listGrants(args: { ownerUserId: string }): Promise { + return (await repo.listGrants(args.ownerUserId)).map(grantView); +} + +/** The approvals queue (pending by default); the lazy sweep runs first. */ +export async function listGrantRequests(args: { + ownerUserId: string; + statuses?: readonly SshGrantRequestStatus[]; +}): Promise { + const now = grantsDeps().nowIso(); + await repo.sweepExpiredRequests(now); + return (await repo.listRequests(args.ownerUserId, args.statuses)).map((row) => ({ + id: row.id, + keyHomeNodeId: row.keyHomeNodeId, + resolvedSelector: row.resolvedSelector, + requestedFingerprints: row.requestedFingerprints === null ? null : parseFingerprintArray(row.requestedFingerprints), + paneId: row.paneId, + bNodeId: row.bNodeId, + expiresAt: row.expiresAt, + status: row.status, + createdAt: row.createdAt, + })); +} + +/* ------------------------------------------------------------------ */ +/* the launch leg: match -> open (or ask -> refuse) */ +/* ------------------------------------------------------------------ */ + +/** + * The relay launch's authorization leg, run by `sshLaunch` once B is gated, + * the destination resolved, and the pane id minted (decision 2: this is + * where `openRelay` finally gets its production caller): + * + * 1. {@link matchGrant} - a standing grant answers. + * 2. No grant: {@link requestFirstUse} + the named 409 fail-fast. Nothing + * was opened, nothing will hang; the pane never exists. + * 3. A grant: read BOTH machines' registered halves from the identities + * store (a slot still empty is the named identity refusal, with the + * §4.3 `ready` bootstrap as the remedy), re-check grant liveness, + * `openRelay`, then re-check liveness AGAIN: a revoke landing across the + * open cuts the fresh session with the named reason and refuses the + * launch, so no orphan rides under a dead grant (decision 3 / T8 NIT1). + * + * @returns the broker's byte-checked `socketPath` (the launch exports it as + * the scoped `SSH_AUTH_SOCK` through `sshRelayPaneEnv`) and the grant id. + */ +export async function prepareRelayLeg(args: { + viewerId: string; + aNode: { id: string; name: string }; + bNodeId: string; + resolvedHost: string; + paneId: string; +}): Promise> { + const grant = await matchGrant({ + ownerUserId: args.viewerId, + aNodeId: args.aNode.id, + resolvedHost: args.resolvedHost, + }); + if (!grant) { + const asked = await requestFirstUse({ + ownerUserId: args.viewerId, + aNodeId: args.aNode.id, + bNodeId: args.bNodeId, + resolvedSelector: args.resolvedHost, + paneId: args.paneId, + }); + const ref = asked.ok ? asked.value.requestId : ""; + return refused({ + status: 409, + code: BackendErrorCodes.SSH_GRANT_APPROVAL_REQUIRED, + message: `Asked ${args.aNode.name} to approve signing with its keys${ref ? ` (request ${ref})` : ""}. Launch again once it is approved.`, + }); + } + const identities = new IdentitiesRepository(db); + const [rowA, rowB] = await Promise.all([ + identities.findByPrincipal(`node:${args.aNode.id}`), + identities.findByPrincipal(`node:${args.bNodeId}`), + ]); + // The two halves the broker pairs: the registered ES256 signing JWK and the + // ECDH-ES encryption JWK, both read from the identities store, never from + // the caller. A row without either half is the named refusal - the §4.3 + // `ready` bootstrap or a re-enroll is the only thing that fills a slot. + const peerHalf = (row: typeof rowA): RelayPeerKeys | null => + row && row.signingPublicKey !== null + ? { signingPublicKey: row.signingPublicKey, encryptionPublicJwk: row.publicKey } + : null; + const aPeer = peerHalf(rowA); + const bPeer = peerHalf(rowB); + if (!aPeer || !bPeer) { + return refused({ + status: 409, + code: BackendErrorCodes.SSH_RELAY_IDENTITY_MISSING, + message: + "That key home or connecting machine has not registered its relay identity yet. Re-enroll it, or wait for its next check-in to deliver the key.", + }); + } + // Liveness immediately BEFORE the open: the cheap read that makes the + // window this call cannot close (between match and here) at least start + // from a row that still exists. + const standing = await repo.getGrant(args.viewerId, grant.id); + if (!standing) { + return refused(GRANT_REVOKED_DURING_LAUNCH); + } + const broker = grantsDeps().broker(); + let socketPath: string; + try { + const opened = await broker.openRelay({ + grantId: grant.id, + fingerprints: grant.fingerprints, + paneId: args.paneId, + aNode: args.aNode.id, + bNode: args.bNodeId, + aPeer, + bPeer, + }); + socketPath = opened.socketPath; + } catch (err) { + if (err instanceof SshRelayRefusal) { + return refused({ + status: 409, + code: BackendErrorCodes.SSH_RELAY_OPEN_FAILED, + message: relayRefusalCopy(err.code), + }); + } + throw err; + } + // Liveness immediately AFTER the open (decision 3): a revoke that landed + // between the reads found an empty session map and cut nothing, so THIS + // call cuts the fresh session itself and the launch refuses. + if (!(await repo.getGrant(args.viewerId, grant.id))) { + await broker.closeForGrant(grant.id, "grant-revoked"); + return refused(GRANT_REVOKED_DURING_LAUNCH); + } + return granted({ socketPath, grantId: grant.id }); +} + +const GRANT_REVOKED_DURING_LAUNCH: SshRefusalNarrow = { + status: 409, + code: BackendErrorCodes.SSH_GRANT_APPROVAL_REQUIRED, + message: "The key grant was revoked while this launch was opening. Launch again to ask for approval.", +}; + +/** Human copy per broker refusal code; ids only, never key material or sockets. */ +function relayRefusalCopy(code: SshRelayRefusal["code"]): string { + switch (code) { + case "quota": + return "That machine already carries its full share of live relay sessions. Wait for one to close and retry."; + case "handshake": + return "The key home or the connecting machine did not complete the relay handshake. Check both machines are online and retry."; + case "node-off": + return "SSH was switched off on one of the machines between the gate and the relay open."; + case "no-node": + case "no-datadir": + return "One of the machines is not fully connected; retry once it reports in."; + case "local-node": + return "The server host itself can be neither key home nor connecting machine for a relay."; + case "same-node": + return "The key home and the connecting machine are the same machine; this connection needs no relay."; + case "bad-socket-path": + return "The connecting machine answered a socket path the server could not verify. Nothing was launched."; + case "bad-pane-id": + case "bad-fingerprints": + return "The relay refused the session's identifiers as malformed; the launch was refused."; + } +} + +/* ------------------------------------------------------------------ */ +/* selection validation shared by approve and create */ +/* ------------------------------------------------------------------ */ + +type SelectionResult = { ok: true; value: string[] } | { ok: false; answer: SshGrantAnswer }; + +async function validateSelectionOf(fingerprints: readonly string[]): Promise { + if (!Array.isArray(fingerprints) || fingerprints.some((f) => typeof f !== "string")) + return { ok: false, answer: refused(invalidRefusal()) }; + const unique = [...new Set(fingerprints)]; + if (unique.length > SSH_MAX_GRANT_FINGERPRINTS) return { ok: false, answer: refused(overRefusal()) }; + if (!isSshGrantFingerprints(unique)) return { ok: false, answer: refused(invalidRefusal()) }; + return { ok: true, value: unique }; +} + +function overRefusal(): SshRefusalNarrow { + return { + status: 400, + code: BackendErrorCodes.SSH_GRANT_KEYS_OVER_LIMIT, + message: `A grant can carry at most ${SSH_MAX_GRANT_FINGERPRINTS} keys. Deselect some and try again; nothing was truncated.`, + }; +} + +function invalidRefusal(): SshRefusalNarrow { + return { + status: 400, + code: BackendErrorCodes.SSH_GRANT_KEYS_INVALID, + message: "Every selected key must be a SHA256 fingerprint exactly as the roster reports it.", + }; +} diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index 8deea36c9..c01c7c28c 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -262,6 +262,22 @@ export enum BackendErrorCodes { * `ready` fill the slot. */ SSH_RELAY_IDENTITY_MISSING = "SSH_RELAY_IDENTITY_MISSING", + /** + * A grant destination selector the grammar refuses: blank, over 253 + * characters, leading `-`/`.`, doubled `*`, control characters or + * whitespace. A selector is a hostname pattern (concrete host or `*` + * globs) matched against resolved destination hostnames; it is refused + * before anything is stored. + */ + SSH_GRANT_SELECTOR_INVALID = "SSH_GRANT_SELECTOR_INVALID", + /** + * An approval question already answered (or already expired) when someone + * tried to answer it: the queue is compare-and-set, so a second approver or + * the sweep wins and this is the loud no the screen shows (spec 2026-10-08 + * §6.2 - the row records the whole lifecycle, the answer does not rewrite + * history). + */ + SSH_GRANT_ALREADY_ANSWERED = "SSH_GRANT_ALREADY_ANSWERED", } /** @@ -548,4 +564,12 @@ export const BackendErrorCodeDefs = { message: "That machine has no registered relay identity yet", statusCode: 409, }, + [BackendErrorCodes.SSH_GRANT_SELECTOR_INVALID]: { + message: "That grant selector is not a hostname pattern", + statusCode: 400, + }, + [BackendErrorCodes.SSH_GRANT_ALREADY_ANSWERED]: { + message: "That grant request has already been answered", + statusCode: 409, + }, }; From 507aa9625631f01ddc28a9a31e59cae423b2cba9 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:49:51 -0700 Subject: [PATCH 45/92] feat(ssh-grants): grants + approvals routes, the relay launch leg, the expiry sweep /api/ssh gains GET/POST /grants, PATCH/DELETE /grants/:id, and the grant-requests queue (GET, /:id/approve, /:id/deny) - mounted INSIDE the existing ssh group (the App-type-depth rule), with the siblings' cookie doctrine verbatim and foreign rows rendering as the 404 the ownership axis demands. Create runs the key home's gate (gateSshNode + agent kind) so a grant can never be stored pointed at a machine the caller may not SSH through. sshLaunch gains keyHome: the relay leg runs AFTER B's resolve and BEFORE the pane exists - matchGrant -> openRelay (peers from the identities store, decision 3's pre/post liveness re-checks) -> the broker's socket becomes the scoped SSH_AUTH_SOCK via sshRelayPaneEnv, overriding the snapshot's own agent socket; no grant -> requestFirstUse + the named 409 fail-fast, no pane, broker never opened. A create that throws after an open cuts the session (child-exit). The boot+hourly approval sweep now also expires stale grant requests (spec 6.2: expiry writes nothing). Route suite: cookie doctrine (empty cookie 401, subshell token 403), owner scoping, the named 400/409 arms, DELETE 204 + closeForGrant(grant-revoked), and the relay launch pinned both ways including direct-mode untouched. 33 tests across the ssh suites, all green. --- .../api/ssh/__tests__/grants-route.test.ts | 570 ++++++++++++++++++ .../server/api/src/api/ssh/approvals.route.ts | 116 ++++ apps/server/api/src/api/ssh/grants.route.ts | 170 ++++++ apps/server/api/src/api/ssh/index.ts | 10 +- apps/server/api/src/api/ssh/launch.route.ts | 12 + apps/server/api/src/api/ssh/ssh-views.ts | 44 ++ apps/server/api/src/index.ts | 6 + .../api/src/services/ssh-grants.service.ts | 10 +- .../api/src/services/ssh-launch.service.ts | 156 ++++- 9 files changed, 1073 insertions(+), 21 deletions(-) create mode 100644 apps/server/api/src/api/ssh/__tests__/grants-route.test.ts create mode 100644 apps/server/api/src/api/ssh/approvals.route.ts create mode 100644 apps/server/api/src/api/ssh/grants.route.ts diff --git a/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts b/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts new file mode 100644 index 000000000..1b5e70973 --- /dev/null +++ b/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts @@ -0,0 +1,570 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { sshRoutes } from "@/api/ssh/index.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { createGrant, requestFirstUse, setSshGrantsDepsForTests } from "@/services/ssh-grants.service.js"; +import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { attachScriptedNode, ok, statDirEcho } from "@/test-helpers/scripted-node.js"; + +/** + * The grant tier's HTTP surface (spec 2026-10-08 §6/§8; Task 10 routes + + * the relay launch leg). The properties pinned here: + * + * - The COOKIE doctrine verbatim from the siblings: empty cookie 401, a real + * subshell token 403 on every grants/approvals/relay-launch door (decision + * 5: these routes match `launch`/`resolve`/`saved-hosts`, which refuse + * machine credentials at the door). + * - Owner scoping: lists never carry another owner's rows; a foreign id is + * the SAME 404 an absent one gets (grants, requests, and their answers). + * - The named refusals: over-cap selection and off-grammar fingerprints are + * the 400 codes, an already-answered request is the named 409, a key home + * the caller may not SSH through is the gate's own 403/404. + * - REVOKE reaches live relays over HTTP too: the delete answers 204 with no + * body and the broker's closeForGrant has seen exactly this grant id with + * reason "grant-revoked". + * - The RELAY LAUNCH: no standing grant -> the pane is NOT created, the + * broker never opens, a durable pending row + notification stand, and the + * answer is 409 SSH_GRANT_APPROVAL_REQUIRED. A standing grant -> 201, the + * broker opened for (grant, A, B, pane) with the grant's fingerprints, and + * the launch frame carries the BROKER's socket as the scoped SSH_AUTH_SOCK, + * overriding the snapshot's own agent socket. + * + * The clock/broker/notify trio is the service's injected seam: every case + * drives the REAL route stack (real gate, real resolve over a scripted node, + * real create path) while the relay itself is a recorder. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(sshRoutes); + +const pw = "grants-api-1"; +const ownerEmail = `grantsapi-owner-${crypto.randomUUID()}@subshell.local`; +const otherEmail = `grantsapi-other-${crypto.randomUUID()}@subshell.local`; +let ownerId: string; +let ownerCookie: string; +let otherCookie: string; + +const NODE_A = `n-grants-a-${crypto.randomUUID()}`; +const NODE_B = `n-grants-b-${crypto.randomUUID()}`; +/** SSH switched OFF on this one: the key-home gate's negative fixture. */ +const NODE_OFF = `n-grants-off-${crypto.randomUUID()}`; +const nodes = new NodesRepository(db); +const createdSubshellIds: string[] = []; +const emails: string[] = []; + +/** The broker's stand-in: it records the pairing and answers a fixed socket. */ +const FAKE_SOCK = "/home/scripted/.subshell/ssh/fake-pane/agent.sock"; +let openCalls: { grantId: string; fingerprints: string[]; paneId: string; aNode: string; bNode: string }[] = []; +let closeGrantCalls: { grantId: string; reason: string }[] = []; +let notifyCalls: string[] = []; +let openRelayThrows: ((input: unknown) => never) | null = null; + +function installFakeDeps() { + const broker = { + async openRelay(input: { grantId: string; fingerprints: string[]; paneId: string; aNode: string; bNode: string }) { + if (openRelayThrows) openRelayThrows(input); + openCalls.push(input); + return { relayId: "relay-x", ref: "ref-x", expiresAt: "2026-10-08T00:00:30.000Z", socketPath: FAKE_SOCK }; + }, + async closeForGrant(grantId: string, reason: string) { + closeGrantCalls.push({ grantId, reason }); + return 0; + }, + } as unknown as RelayBroker; + setSshGrantsDepsForTests({ + nowIso: () => new Date().toISOString(), + broker: () => broker, + notifyGrantApproval: (_owner, requestId) => notifyCalls.push(requestId), + }); +} + +async function mkNode(id: string, sshEnabled: boolean): Promise { + await nodes.create({ id, ownerUserId: ownerId, name: `grantsapi-${id.slice(-8)}`, kind: "agent", status: "offline" }); + if (sshEnabled) await nodes.setSshEnabled(id, { on: true, changedAt: "2026-10-08T09:00:00.000Z" }); + // The ssh-launch usability gate reads the machine's own detection (the + // sibling suite's mkNode does the same): no inventory, no ssh pane. + await db + .updateTable("nodes") + .set({ + inventoryJson: JSON.stringify([{ harnessId: "ssh", installed: true, binaryPath: "/usr/bin/ssh", version: "1" }]), + inventoryAt: new Date().toISOString(), + } as never) + .where("id", "=", id) + .execute(); +} + +/** An approved snapshot whose OWN agent socket must never ride a relay launch. */ +function snapshot(over: Record = {}): Record { + return { + alias: "git", + host: "git.example.test", + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: "/run/user/501/ssh-agent.sock", + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...over, + }; +} +const ACCEPTED = snapshot(); + +function sshFetch(path: string, init: { method?: string; body?: unknown; cookie?: string; bearer?: string } = {}) { + const headers: Record = { "content-type": "application/json" }; + if (init.cookie) headers.cookie = `better-auth.session_token=${init.cookie}`; + if (init.bearer) headers.authorization = `Bearer ${init.bearer}`; + return app.fetch( + new Request(`http://localhost:3099${path}`, { + method: init.method ?? "GET", + headers, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }), + ); +} + +const FP = (n: number) => `SHA256:${("b".repeat(43) + n).slice(-42)}${n % 10}`; + +beforeAll(async () => { + await setupAuthTables(); + await ensureMigratedTestDb(); + const mk = async (email: string) => + await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerId = await mk(ownerEmail); + await mk(otherEmail); + emails.push(ownerEmail, otherEmail); + ownerCookie = await signIn(ownerEmail, pw); + otherCookie = await signIn(otherEmail, pw); + await mkNode(NODE_A, true); + await mkNode(NODE_B, true); + await mkNode(NODE_OFF, false); + const identities = new IdentitiesRepository(db); + await identities.register({ + principalId: `node:${NODE_A}`, + publicKey: '{"kty":"EC","crv":"P-256","x":"A1","y":"A2"}', + signingPublicKey: '{"kty":"EC","crv":"P-256","x":"A3","y":"A4"}', + displayName: "A", + }); + await identities.register({ + principalId: `node:${NODE_B}`, + publicKey: '{"kty":"EC","crv":"P-256","x":"B1","y":"B2"}', + signingPublicKey: '{"kty":"EC","crv":"P-256","x":"B3","y":"B4"}', + displayName: "B", + }); + installFakeDeps(); +}); + +afterAll(async () => { + setSshGrantsDepsForTests(null); + resetNodeRegistryForTests(); + for (const id of createdSubshellIds) await new SubshellsRepository(db).delete(id).catch(() => {}); + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("sshSavedHosts").execute(); + await db.deleteFrom("auditEvents").execute(); + await db.deleteFrom("identities").execute(); + for (const id of [NODE_A, NODE_B, NODE_OFF]) await nodes.deleteById(id).catch(() => {}); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +describe("POST /api/ssh/grants + the list", () => { + it("a bare agent node with SSH off is the gate's 403; a vanished node is the 404; an invalid selector is the named 400", async () => { + const off = await sshFetch("/api/ssh/grants", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_OFF, name: "no", selector: "git.example.test", fingerprints: [FP(1)] }, + }); + expect(off.status).toBe(403); + expect(((await off.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GATE_OFF); + + const ghost = await sshFetch("/api/ssh/grants", { + method: "POST", + cookie: ownerCookie, + body: { node: "node-does-not-exist", name: "no", selector: "git.example.test", fingerprints: [FP(1)] }, + }); + expect(ghost.status).toBe(404); + + const bad = await sshFetch("/api/ssh/grants", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_A, name: "no", selector: "bad host", fingerprints: [FP(1)] }, + }); + expect(bad.status).toBe(400); + expect(((await bad.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GRANT_SELECTOR_INVALID); + }); + + it("create stores the grant and the owner's list reads it back (fingerprints as an array); a foreign list never shows it", async () => { + const created = await sshFetch("/api/ssh/grants", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_A, name: "git push", selector: "*.example.test", fingerprints: [FP(1), FP(2)] }, + }); + expect(created.status).toBe(201); + const { grant } = (await created.json()) as { grant: Record }; + expect(grant.createdVia).toBe("manual"); + expect(grant.fingerprints).toEqual([FP(1), FP(2)]); + + const mine = (await (await sshFetch("/api/ssh/grants", { cookie: ownerCookie })).json()) as { + grants: { id: string }[]; + }; + expect(mine.grants.map((g) => g.id)).toContain(grant.id as string); + const theirs = (await (await sshFetch("/api/ssh/grants", { cookie: otherCookie })).json()) as { + grants: unknown[]; + }; + expect(theirs.grants).toHaveLength(0); + + // An over-cap selection is the hard named error, never a truncation. + const over = await sshFetch("/api/ssh/grants", { + method: "POST", + cookie: ownerCookie, + body: { + node: NODE_A, + name: "too many", + selector: "git.example.test", + fingerprints: Array.from({ length: SSH_MAX_GRANT_FINGERPRINTS + 1 }, (_, i) => FP(i + 50)), + }, + }); + expect(over.status).toBe(400); + expect(((await over.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GRANT_KEYS_OVER_LIMIT); + const afterOver = (await (await sshFetch("/api/ssh/grants", { cookie: ownerCookie })).json()) as { + grants: unknown[]; + }; + expect(afterOver.grants).toHaveLength(mine.grants.length); // nothing stored, nothing truncated + }); + + it("PATCH edits the selector, a foreign id is the 404, and DELETE revokes through the broker's grant hook", async () => { + const created = await createGrant({ + ownerUserId: ownerId, + aNodeId: NODE_A, + name: "ed", + selector: "git.example.test", + fingerprints: [FP(3)], + }); + if (!created.ok) throw new Error("fixture grant refused"); + const id = created.value.grant.id; + + const foreign = await sshFetch(`/api/ssh/grants/${id}`, { + method: "PATCH", + cookie: otherCookie, + body: { name: "hijack" }, + }); + expect(foreign.status).toBe(404); + + const patched = await sshFetch(`/api/ssh/grants/${id}`, { + method: "PATCH", + cookie: ownerCookie, + body: { selector: "*.example.test" }, + }); + expect(patched.status).toBe(200); + expect(((await patched.json()) as { grant: { resolvedSelector: string } }).grant.resolvedSelector).toBe( + "*.example.test", + ); + + closeGrantCalls = []; + const gone = await sshFetch(`/api/ssh/grants/${id}`, { method: "DELETE", cookie: ownerCookie }); + expect(gone.status).toBe(204); + expect(await gone.text()).toBe(""); // 204 carries NO body + expect(closeGrantCalls).toEqual([{ grantId: id, reason: "grant-revoked" }]); + const again = await sshFetch(`/api/ssh/grants/${id}`, { method: "DELETE", cookie: ownerCookie }); + expect(again.status).toBe(404); + }); +}); + +describe("GET/POST /api/ssh/grant-requests", () => { + let requestId = ""; + + beforeAll(async () => { + const asked = await requestFirstUse({ + ownerUserId: ownerId, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: "git.example.test", + paneId: "pane-queue-probe", + }); + if (!asked.ok) throw new Error("fixture ask refused"); + requestId = asked.value.requestId; + }); + + it("the owner's queue names the requester facts; a foreign queue is empty", async () => { + const mine = (await (await sshFetch("/api/ssh/grant-requests", { cookie: ownerCookie })).json()) as { + requests: { id: string; status: string; paneId: string; bNodeId: string; resolvedSelector: string }[]; + }; + const row = mine.requests.find((r) => r.id === requestId); + expect(row).toBeDefined(); + expect(row?.status).toBe("pending"); + expect(row?.paneId).toBe("pane-queue-probe"); + expect(row?.bNodeId).toBe(NODE_B); + expect(row?.resolvedSelector).toBe("git.example.test"); + const theirs = (await (await sshFetch("/api/ssh/grant-requests", { cookie: otherCookie })).json()) as { + requests: unknown[]; + }; + expect(theirs.requests).toHaveLength(0); + }); + + it("approve refuses an off-grammar fingerprint with the named 400, then answers: grant row + approved request; a second answer is the named 409", async () => { + const badShape = await sshFetch(`/api/ssh/grant-requests/${requestId}/approve`, { + method: "POST", + cookie: ownerCookie, + body: { fingerprints: ["fingerprint-not-in-SHA256-form"] }, + }); + expect(badShape.status).toBe(400); + expect(((await badShape.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GRANT_KEYS_INVALID); + + const foreign = await sshFetch(`/api/ssh/grant-requests/${requestId}/approve`, { + method: "POST", + cookie: otherCookie, + body: { fingerprints: [FP(7)] }, + }); + expect(foreign.status).toBe(404); + + const approved = await sshFetch(`/api/ssh/grant-requests/${requestId}/approve`, { + method: "POST", + cookie: ownerCookie, + body: { fingerprints: [FP(7)], name: "by queue" }, + }); + expect(approved.status).toBe(200); + const grant = (await approved.json()) as { grant: { id: string; createdVia: string } }; + expect(grant.grant.createdVia).toBe("first-use"); + + const twice = await sshFetch(`/api/ssh/grant-requests/${requestId}/approve`, { + method: "POST", + cookie: ownerCookie, + body: { fingerprints: [FP(7)] }, + }); + expect(twice.status).toBe(409); + expect(((await twice.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GRANT_ALREADY_ANSWERED); + + await db.deleteFrom("sshKeyGrants").where("id", "=", grant.grant.id).execute(); + }); + + it("deny answers the row (audit-only, no grant); a second answer is the named 409", async () => { + await db.deleteFrom("sshKeyGrants").execute(); // "no grant exists for a denial" reads off a clean tier + const second = await requestFirstUse({ + ownerUserId: ownerId, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: "second.example.test", + paneId: "pane-deny-probe", + }); + if (!second.ok) throw new Error("fixture ask refused"); + const denied = await sshFetch(`/api/ssh/grant-requests/${second.value.requestId}/deny`, { + method: "POST", + cookie: ownerCookie, + body: {}, + }); + expect(denied.status).toBe(200); + expect((await denied.json()) as { requestId: string }).toEqual({ requestId: second.value.requestId }); + expect(await db.selectFrom("sshKeyGrants").selectAll().execute()).toHaveLength(0); + const twice = await sshFetch(`/api/ssh/grant-requests/${second.value.requestId}/deny`, { + method: "POST", + cookie: ownerCookie, + body: {}, + }); + expect(twice.status).toBe(409); + await db.deleteFrom("sshGrantRequests").where("id", "=", second.value.requestId).execute(); + }); +}); + +describe("the cookie doctrine over the grant doors", () => { + it("an empty cookie is a 401 and a real subshell token is a 403 on grants, approvals, and launch", async () => { + const subshellId = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id: subshellId, + userId: ownerId, + harnessId: "terminal", + name: "bearer-probe", + workingDir: mkdtempSync(`${tmpdir()}/grants-bearer-`), + tmuxSocket: null, + nodeId: LOCAL_NODE_ID, + }); + createdSubshellIds.push(subshellId); + const key = await issueSubshellToken(subshellId, ownerId); + for (const res of [ + await sshFetch("/api/ssh/grants", { cookie: "" }), + await sshFetch("/api/ssh/grant-requests", { cookie: "" }), + await sshFetch("/api/ssh/grants", { bearer: key }), + await sshFetch("/api/ssh/grant-requests", { bearer: key }), + await sshFetch("/api/ssh/launch", { + method: "POST", + bearer: key, + body: { node: NODE_B, destination: "git", keyHome: NODE_A }, + }), + ]) { + expect(res.status).toBeLessThanOrEqual(403); + } + const grantBearer = await sshFetch("/api/ssh/grants", { bearer: key }); + expect(((await grantBearer.json()) as { message: string }).message).toContain("browser sessions"); + const launchBearer = await sshFetch("/api/ssh/launch", { + method: "POST", + bearer: key, + body: { node: NODE_B, destination: "git", keyHome: NODE_A }, + }); + expect(launchBearer.status).toBe(403); + }); +}); + +describe("POST /api/ssh/launch in relay mode (the grant-gated relay leg)", () => { + function bScript() { + return attachScriptedNode(NODE_B, { + ssh_resolve_config: () => ({ accepted: true, snapshot: ACCEPTED }), + launch: ok, + stat_dir: statDirEcho, + }); + } + + /** Panes currently on B - negative launch assertions read against THIS baseline. */ + async function bPaneCount(): Promise { + return (await db.selectFrom("subshells").select("id").where("nodeId", "=", NODE_B).execute()).length; + } + + it("no standing grant: a pending row stands, the pane is NOT created, and the answer is the named 409", async () => { + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + const scripted = bScript(); + notifyCalls = []; + openCalls = []; + const panesBefore = await bPaneCount(); + try { + const res = await sshFetch("/api/ssh/launch", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_B, destination: "git", keyHome: NODE_A }, + }); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe(BackendErrorCodes.SSH_GRANT_APPROVAL_REQUIRED); + // The copy names the key home (row name) and the remedy; two sentences, no secrets. + expect(body.message).toContain(`grantsapi-${NODE_A.slice(-8)}`); + expect(body.message.toLowerCase()).toContain("approve"); + expect(openCalls).toHaveLength(0); + expect(notifyCalls).toHaveLength(1); + expect(await bPaneCount()).toBe(panesBefore); + expect(scripted.cmdsOf("launch")).toHaveLength(0); + // The ask went through exactly once, durable, pending. + const rows = await db.selectFrom("sshGrantRequests").selectAll().execute(); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("pending"); + } finally { + scripted.detach(); + await db.deleteFrom("sshGrantRequests").execute(); + } + }); + + it("a standing grant: the broker opens for (grant, A, B, pane) and the pane's SSH_AUTH_SOCK is the BROKER's socket, not the snapshot's", async () => { + await db.deleteFrom("sshKeyGrants").execute(); + const grant = await createGrant({ + ownerUserId: ownerId, + aNodeId: NODE_A, + name: "launch leg", + selector: "git.example.test", + fingerprints: [FP(11), FP(12)], + }); + if (!grant.ok) throw new Error("fixture grant refused"); + const scripted = bScript(); + openCalls = []; + try { + const res = await sshFetch("/api/ssh/launch", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_B, destination: "git", keyHome: NODE_A }, + }); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: { id: string } }; + createdSubshellIds.push(subshell.id); + + expect(openCalls).toHaveLength(1); + expect(openCalls[0]).toMatchObject({ + grantId: grant.value.grant.id, + fingerprints: [FP(11), FP(12)], + paneId: subshell.id, // the PRE-MINTED id: the broker byte-checks B's socket against it + aNode: NODE_A, + bNode: NODE_B, + }); + const cmd = scripted.cmdsOf("launch")[0] as { subshellEnv?: Record }; + // The relay override: the snapshot names /run/user/501/ssh-agent.sock; + // the pane gets the proxy socket the broker verified (spec §5.2). + expect(cmd.subshellEnv?.SSH_AUTH_SOCK).toBe(FAKE_SOCK); + } finally { + scripted.detach(); + } + }); + + it("a broker refusal (quota) comes back as the named 409 and no pane is created", async () => { + await db.deleteFrom("sshKeyGrants").execute(); + const grant = await createGrant({ + ownerUserId: ownerId, + aNodeId: NODE_A, + name: "quota leg", + selector: "git.example.test", + fingerprints: [FP(11)], + }); + if (!grant.ok) throw new Error("fixture grant refused"); + const scripted = bScript(); + openRelayThrows = () => { + throw new SshRelayRefusal("quota", "node already holds 8 live relay sessions (max 8)", NODE_B); + }; + const panesBefore = await bPaneCount(); + try { + const res = await sshFetch("/api/ssh/launch", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_B, destination: "git", keyHome: NODE_A }, + }); + expect(res.status).toBe(409); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); + expect(await bPaneCount()).toBe(panesBefore); + expect(scripted.cmdsOf("launch")).toHaveLength(0); + } finally { + openRelayThrows = null; + scripted.detach(); + } + }); + + it("direct mode is untouched: no keyHome means no grant read, no relay, and the snapshot socket still rides", async () => { + const scripted = bScript(); + openCalls = []; + try { + const res = await sshFetch("/api/ssh/launch", { + method: "POST", + cookie: ownerCookie, + body: { node: NODE_B, destination: "git" }, + }); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: { id: string } }; + createdSubshellIds.push(subshell.id); + expect(openCalls).toHaveLength(0); + const cmd = scripted.cmdsOf("launch")[0] as { subshellEnv?: Record }; + expect(cmd.subshellEnv?.SSH_AUTH_SOCK).toBe("/run/user/501/ssh-agent.sock"); + } finally { + scripted.detach(); + } + }); +}); diff --git a/apps/server/api/src/api/ssh/approvals.route.ts b/apps/server/api/src/api/ssh/approvals.route.ts new file mode 100644 index 000000000..96721a8cc --- /dev/null +++ b/apps/server/api/src/api/ssh/approvals.route.ts @@ -0,0 +1,116 @@ +import { normalizeLabel } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SshGrantRequestViewSchema, SshGrantViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { apiModels } from "@/schema/index.js"; +import { approveGrant, denyGrant, listGrantRequests } from "@/services/ssh-grants.service.js"; + +/** + * `/api/ssh/grant-requests` - the first-use approval queue (spec 2026-10-08 + * §6.2, modeled on the signup pending-approvals queue): GET the owner's + * pending questions, POST `/:id/approve` (selects the fingerprints and writes + * the standing grant), POST `/:id/deny` (an answer, no grant). + * + * The actor model is decision 5 stated concretely: rows are owned by the key + * home's owner, the asking launch could only have gated A through + * `nodeCanSsh` (SSH is owner-reserved on agent machines), and these routes + * are cookie-scoped to the row's owner - so the answering human IS the key + * home's owner, always, and a foreign id is the same 404 an absent row is. + * The selection cap and grammar live in the service; a second answer to an + * answered row is the named 409, because the row records the whole lifecycle + * rather than being rewritten by it. + * + * GET sweeps lazily first: a queue read never shows a question the deadline + * has already closed as still open. + */ +export const sshGrantRequestsRoutes = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/grant-requests", + async ({ user, actor }) => { + requireCookieActor(actor, "SSH grant approvals are restricted to browser sessions"); + return { requests: await listGrantRequests({ ownerUserId: user.id }) }; + }, + { + response: { + 200: t.Object({ + requests: t.Array(SshGrantRequestViewSchema, { description: "The caller's pending approvals, newest first" }), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + }, + detail: { + operationId: "listSshGrantRequests", + tags: ["ssh"], + description: + "Lists the caller's outstanding first-use key-grant approvals, each naming the asking pane, the connecting machine, and the resolved destination", + }, + }, + ) + .post( + "/grant-requests/:id/approve", + async ({ body, params, user, actor }) => { + requireCookieActor(actor, "SSH grant approvals are restricted to browser sessions"); + const answer = await approveGrant({ + ownerUserId: user.id, + requestId: params.id, + fingerprints: body.fingerprints, + name: body.name === undefined ? undefined : normalizeLabel(body.name, 120), + }); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return { grant: answer.value.grant }; + }, + { + params: t.Object({ id: t.String({ description: "Request row id" }) }), + body: t.Object({ + fingerprints: t.Array(t.String({ minLength: 1, maxLength: 253, description: "SHA256: fingerprint" }), { + description: + "The public agent identities this grant may sign with (from the roster fetch); more than SSH_MAX_GRANT_FINGERPRINTS is a hard error, never a truncation", + }), + name: t.Optional(t.String({ minLength: 1, maxLength: 253, description: "Display name for the new grant" })), + }), + response: { + 200: t.Object({ grant: SshGrantViewSchema }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "approveSshGrantRequest", + tags: ["ssh"], + description: + "Answers a pending first-use approval YES: writes the standing grant with exactly the selected identities and audits the answer with the fingerprint COUNT, never the values", + }, + }, + ) + .post( + "/grant-requests/:id/deny", + async ({ params, user, actor }) => { + requireCookieActor(actor, "SSH grant approvals are restricted to browser sessions"); + const answer = await denyGrant({ ownerUserId: user.id, requestId: params.id }); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return { requestId: answer.value.requestId }; + }, + { + params: t.Object({ id: t.String({ description: "Request row id" }) }), + body: t.Object({}), + response: { + 200: t.Object({ + requestId: t.String({ description: "The request that was answered (now standing denied)" }), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "denySshGrantRequest", + tags: ["ssh"], + description: + "Answers a pending first-use approval NO: the audit row is the only writing, no grant and no pin exists for a denial, and a later relaunch simply asks again", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/grants.route.ts b/apps/server/api/src/api/ssh/grants.route.ts new file mode 100644 index 000000000..16c4ae06f --- /dev/null +++ b/apps/server/api/src/api/ssh/grants.route.ts @@ -0,0 +1,170 @@ +import { normalizeLabel } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SshGrantViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { apiErrorBody } from "@/lib/api-error.js"; +import { apiModels } from "@/schema/index.js"; +import { listGrants, revokeGrant, updateGrant } from "@/services/ssh-grants.service.js"; +import { sshCreateGrant } from "@/services/ssh-launch.service.js"; + +/** + * `GET|POST /api/ssh/grants`, `PATCH|DELETE /api/ssh/grants/:id` - the grants + * screen (spec 2026-10-08 §6, §8): list, create, edit the selector/name, and + * revoke. The rows are per-owner exactly like the saved-hosts ledger they sit + * beside: a foreign id answers the SAME 404 an absent one does (ids are never + * existence oracles, docs/security.md §3), no list carries another owner's + * rows, and the cookie doctrine is the siblings' verbatim (`requireCookieActor` + * - these routes decide which of the OWNER's keys may sign from which + * machine, which is a browser-session act). + * + * Create runs the key home's gate (only a machine you may SSH through can be + * your key home); the fingerprint SELECTION is immutable through PATCH by + * design - widening which keys serve is a revoke plus a fresh grant, so + * every standing selection has exactly the audit trail that chose it. + * DELETE is the instant both-ways cut: the row goes and every live relay + * that ran under it is torn down inside the service (spec §6.3). + * + * What the audit rows carry (written by the service, not here): ids, hosts, + * and the fingerprint COUNT - never a fingerprint string, which is the + * screen's to show and the trail's never to hold (docs/security.md §10). + */ +export const sshGrantsRoutes = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/grants", + async ({ user, actor }) => { + requireCookieActor(actor, "SSH key grants are restricted to browser sessions"); + return { grants: await listGrants({ ownerUserId: user.id }) }; + }, + { + response: { + 200: t.Object({ + grants: t.Array(SshGrantViewSchema, { description: "The caller's standing grants, newest first" }), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + }, + detail: { + operationId: "listSshKeyGrants", + tags: ["ssh"], + description: + "Lists the caller's key grants: which key home may sign for which destinations, and which selected agent identities carry that authorization", + }, + }, + ) + .post( + "/grants", + async ({ body, user, actor, status }) => { + requireCookieActor(actor, "SSH key grants are restricted to browser sessions"); + const answer = await sshCreateGrant({ + viewerId: user.id, + aNodeId: body.node, + name: normalizeLabel(body.name, 120), + selector: body.selector, + fingerprints: body.fingerprints, + }); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return status(201, { grant: answer.value.grant }); + }, + { + body: t.Object({ + node: t.String({ minLength: 1, description: "Key home machine whose agent will sign (an agent node you own)" }), + name: t.String({ minLength: 1, maxLength: 253, description: "Display name for the grant" }), + selector: t.String({ + minLength: 1, + maxLength: 253, + description: "Destination selector: a concrete hostname or a '*' host pattern, stored resolved", + }), + fingerprints: t.Array(t.String({ minLength: 1, maxLength: 253, description: "SHA256: fingerprint" }), { + description: "The selected public agent identities; more than SSH_MAX_GRANT_FINGERPRINTS is refused outright", + }), + }), + response: { + 201: t.Object({ grant: SshGrantViewSchema }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "createSshKeyGrant", + tags: ["ssh"], + description: + "Creates a standing key grant (the same row an approved first use writes). Over-cap selections are a hard error, never a truncation", + }, + }, + ) + .patch( + "/grants/:id", + async ({ body, params, user, actor }) => { + requireCookieActor(actor, "SSH key grants are restricted to browser sessions"); + const answer = await updateGrant({ + ownerUserId: user.id, + grantId: params.id, + name: body.name === undefined ? undefined : normalizeLabel(body.name, 120), + selector: body.selector, + }); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return { grant: answer.value.grant }; + }, + { + params: t.Object({ id: t.String({ description: "Grant row id" }) }), + body: t.Object({ + name: t.Optional(t.String({ minLength: 1, maxLength: 253, description: "New display name" })), + selector: t.Optional( + t.String({ + minLength: 1, + maxLength: 253, + description: "New destination selector (hostname or host pattern)", + }), + ), + }), + response: { + 200: t.Object({ grant: SshGrantViewSchema }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "updateSshKeyGrant", + tags: ["ssh"], + description: + "Edits a grant's display name and/or destination selector. Which keys serve is immutable: change that by revoking and granting again", + }, + }, + ) + .delete( + "/grants/:id", + async ({ params, user, actor, status, set }) => { + requireCookieActor(actor, "SSH key grants are restricted to browser sessions"); + const answer = await revokeGrant({ ownerUserId: user.id, grantId: params.id }); + if (!answer.ok) { + // Foreign AND absent, one 404 (the saved-hosts posture), rendered via + // apiErrorBody since this arm RETURNS rather than throws; revoke's + // only refusal arm is the 404, and the declared response agrees. + return status(404, apiErrorBody({ code: answer.refusal.code, message: answer.refusal.message })); + } + // 204 with NO body at all (the 422 Response-constructor trap the + // saved-hosts delete documents: a declared 204 schema would validate + // an absent body and answer 400 for a successful revoke). + set.status = 204; + }, + { + params: t.Object({ id: t.String({ description: "Grant row id" }) }), + response: { + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "revokeSshKeyGrant", + tags: ["ssh"], + description: + "Revokes a grant instantly: the row goes and every live relay session running under it is cut (204 no body on success; a foreign row is the same 404 as an absent one)", + }, + }, + ); diff --git a/apps/server/api/src/api/ssh/index.ts b/apps/server/api/src/api/ssh/index.ts index 0b4e086de..67262bc05 100644 --- a/apps/server/api/src/api/ssh/index.ts +++ b/apps/server/api/src/api/ssh/index.ts @@ -1,5 +1,7 @@ import { Elysia } from "elysia"; import { sshAliasesRoute } from "@/api/ssh/aliases.route.js"; +import { sshGrantRequestsRoutes } from "@/api/ssh/approvals.route.js"; +import { sshGrantsRoutes } from "@/api/ssh/grants.route.js"; import { sshLaunchRoute } from "@/api/ssh/launch.route.js"; import { sshResolveRoute } from "@/api/ssh/resolve.route.js"; import { sshSavedHostsRoutes } from "@/api/ssh/saved-hosts.route.js"; @@ -17,4 +19,10 @@ export const sshRoutes = new Elysia({ prefix: "/api/ssh" }) .use(sshAliasesRoute) .use(sshResolveRoute) .use(sshLaunchRoute) - .use(sshSavedHostsRoutes); + .use(sshSavedHostsRoutes) + // The grant tier (spec 2026-10-08 §6-§8, Task 10): the standing grants and + // the first-use approval queue. Mounted INSIDE this group on purpose: a new + // top-level `.route()` tier is the Elysia App-type-depth trap, and these + // are the launcher's own authorization surface. + .use(sshGrantsRoutes) + .use(sshGrantRequestsRoutes); diff --git a/apps/server/api/src/api/ssh/launch.route.ts b/apps/server/api/src/api/ssh/launch.route.ts index 9e4cca3b8..0221b9fe2 100644 --- a/apps/server/api/src/api/ssh/launch.route.ts +++ b/apps/server/api/src/api/ssh/launch.route.ts @@ -37,6 +37,11 @@ export const sshLaunchRoute = new Elysia() // The same label rule every human-chosen name takes (idempotent // through the manager's choke-point re-normalization). name: body.name === undefined ? undefined : normalizeLabel(body.name, 120), + // RELAY MODE (spec 2026-10-08 §6): the key home whose agent signs, + // gated and grant-matched inside the service. Absent = the M1 direct + // launch. A first-use refusal answers 409 SSH_GRANT_APPROVAL_REQUIRED + // and launches nothing (a durable approval row now stands). + keyHomeNodeId: body.keyHome, subshells: ctx.services.subshells, }); if (!answer.ok) { @@ -55,6 +60,13 @@ export const sshLaunchRoute = new Elysia() node: t.String({ minLength: 1, description: "Connecting machine ('local' = the control-plane host)" }), destination: SshDestinationField, name: t.Optional(t.String({ minLength: 1, maxLength: 120, description: "Pane display name" })), + keyHome: t.Optional( + t.String({ + minLength: 1, + description: + "RELAY MODE: the key home machine whose agent signs for this connection; absent uses the connecting machine's own keys", + }), + ), }), response: { 201: t.Object({ subshell: SubshellSchema }), diff --git a/apps/server/api/src/api/ssh/ssh-views.ts b/apps/server/api/src/api/ssh/ssh-views.ts index 2489168cb..96a110f88 100644 --- a/apps/server/api/src/api/ssh/ssh-views.ts +++ b/apps/server/api/src/api/ssh/ssh-views.ts @@ -127,3 +127,47 @@ export function throwCodedRefusal(refusal: SshRefusal): never { } throwApiError({ code: refusal.code, message: refusal.message, doNotLog: true }); } + +/** + * One standing key grant as the grants screen reads it (spec 2026-10-08 §6.1, + * §8). The fingerprint SET is public key identifiers (the `SHA256:` display + * notation) and it is deliberately ON this response: the screen is the + * owner's own, and "which keys serve" is exactly what the owner manages. The + * values never appear in an audit row, a log line, or a push body (the + * Global Constraint lives there, not at the owner's screen). + */ +export const SshGrantViewSchema = t.Object({ + id: t.String({ description: "Grant row id (uuid)" }), + name: t.String({ description: "Display name the operator gave the grant" }), + keyHomeNodeId: t.String({ description: "Key home machine: its ssh-agent signs for this grant" }), + resolvedSelector: t.String({ + description: "Destination selector, stored RESOLVED: a concrete hostname or a '*' host pattern", + }), + fingerprints: t.Array(t.String({ description: "OpenSSH SHA256: fingerprint of a selected agent identity" }), { + description: "The selected public identities (at most SSH_MAX_GRANT_FINGERPRINTS); an empty set serves nothing", + }), + createdVia: t.Union([t.Literal("first-use"), t.Literal("manual")], { + description: "Which door created the row: an approved first use, or the grants screen", + }), + createdAt: t.String({ description: "ISO 8601 creation stamp (the match tie-break: oldest wins)" }), + updatedAt: t.String({ description: "ISO 8601 of the last operator edit" }), +}); + +/** One first-use approval request as the queue reads it (spec 2026-10-08 §6.2). */ +export const SshGrantRequestViewSchema = t.Object({ + id: t.String({ description: "Request row id (uuid) - the opaque ref the refusal and the notification name" }), + keyHomeNodeId: t.String({ description: "The key home whose approval is asked" }), + resolvedSelector: t.String({ description: "The resolved destination hostname the launch would have dialed" }), + requestedFingerprints: t.Nullable( + t.Array(t.String({ description: "Pre-selected SHA256: fingerprint" }), { + description: "Pre-selection riding the request, or null (the approver picks)", + }), + ), + paneId: t.String({ description: "The asking pane's id (the requester the screen names)" }), + bNodeId: t.String({ description: "The connecting machine of the asking launch" }), + expiresAt: t.String({ description: "ISO 8601 deadline (24 h); past it the sweep answers the row expired" }), + status: t.Union([t.Literal("pending"), t.Literal("approved"), t.Literal("denied"), t.Literal("expired")], { + description: "Lifecycle state; only pending can be answered", + }), + createdAt: t.String({ description: "ISO 8601 creation stamp" }), +}); diff --git a/apps/server/api/src/index.ts b/apps/server/api/src/index.ts index ec5c826ba..58efb5f37 100644 --- a/apps/server/api/src/index.ts +++ b/apps/server/api/src/index.ts @@ -63,6 +63,7 @@ import { completeUpdate, readPending, recordFailure, revertUpdate } from "@/serv import { getNotifyService } from "@/services/notify.service.js"; import { sweepExpiredPaneLogs, tightenPaneLogModes } from "@/services/pane-log-hygiene.js"; import { expirePendingApprovals, remarkUnmarkedArrivals } from "@/services/pending-approvals.js"; +import { sweepExpiredGrantRequests } from "@/services/ssh-grants.service.js"; import { createIdleWatcher, IDLE_TICK_MS } from "@/services/notify-idle.js"; import { hasAnyUser } from "@/services/registration-gate.js"; import { SubshellManagerService } from "@/services/subshell-manager.service.js"; @@ -366,6 +367,11 @@ async function bootServer(): Promise { const sweepPendingApprovals = async (): Promise => { await expirePendingApprovals(db); await remarkUnmarkedArrivals(db); + // The FIRST-USE grant queue rides the same cadence and the same boot-pass + // doctrine (spec 2026-10-08 §6.2): unanswered grant requests expire at a + // day's scale, the sweep writes no audit row, and the durable pending + // rows are what make a plane restart forget nothing. + await sweepExpiredGrantRequests(); }; try { await sweepPendingApprovals(); diff --git a/apps/server/api/src/services/ssh-grants.service.ts b/apps/server/api/src/services/ssh-grants.service.ts index e61e2318c..f15a21cfd 100644 --- a/apps/server/api/src/services/ssh-grants.service.ts +++ b/apps/server/api/src/services/ssh-grants.service.ts @@ -181,8 +181,14 @@ function grantView(row: SshKeyGrantTable): SshGrantView { /* fingerprint selection: cap and grammar, law first */ /* ------------------------------------------------------------------ */ -/** The refusal union the grant surface returns (a subset of the ssh-launch one; same render path). */ -type SshRefusalNarrow = { status: 400 | 404 | 409; code: BackendErrorCodes; message: string }; +/** + * The refusal union the grant surface returns. The status set matches the + * ssh-launch one's coded arms (no 422 - the outcome-in-data grammar is the + * resolve surface's), so the SAME `throwCodedRefusal` renders both. The gate + * arms (403 gate-off, 502 machine-refused) reach it through the launcher's + * `gateSshNode` when a create names a machine the caller may not use. + */ +type SshRefusalNarrow = { status: 400 | 403 | 404 | 409 | 502; code: BackendErrorCodes; message: string }; /** The grant surface's answer union: a value, or the refusal the route returns as-is. */ export type SshGrantAnswer = { ok: true; value: T } | { ok: false; refusal: SshRefusalNarrow }; diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index ca2d9840d..adf62b7e7 100644 --- a/apps/server/api/src/services/ssh-launch.service.ts +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -35,6 +35,8 @@ import { getHeld, getLive } from "@/services/nodes/node-registry.js"; import { SshRpcError, sshDiscover, sshResolve } from "@/services/nodes/ssh-rpc.js"; import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import { createGrant, prepareRelayLeg, type SshGrantAnswer, type SshGrantView } from "@/services/ssh-grants.service.js"; +import { closeRelayForPaneExit, sshRelayPaneEnv } from "@/services/ssh-relay.service.js"; import type { SubshellsService } from "@/services/subshells.service.js"; import { logger } from "@/utils/logger.js"; @@ -377,6 +379,19 @@ export async function sshLaunch(args: { nodeId: string; destination: string; name?: string; + /** + * RELAY MODE (spec 2026-10-08 §5-§6, T10): the key home A whose agent signs + * for B. Absent is the M1 direct launch (B's own keys, snapshot agent + * socket as before). Present: after B's resolve, the grant layer decides - + * a standing grant opens a brokered relay session and the pane's scoped + * `SSH_AUTH_SOCK` points at B's proxy socket (the broker's byte-checked + * path); NO grant records the first-use request and FAILS the launch fast + * with `SSH_GRANT_APPROVAL_REQUIRED` - the pane never exists, and a + * re-launch after approval simply proceeds (or finds the standing pending + * row). A is gated exactly like B (its own row, its own owner); `local` + * can be neither side. + */ + keyHomeNodeId?: string; /** The REQUEST-scoped subshells service (the create path is its own). */ subshells: Pick; }): Promise> { @@ -402,29 +417,74 @@ export async function sshLaunch(args: { ); } const subshellId = crypto.randomUUID(); + // The relay leg runs BEFORE the pane exists (decision 2): the grant match + // answers from stored facts, `openRelay` verifies B's proxy socket path + // against the pane id minted here, and the refused launch writes no pane. + // A refusal (no grant -> asked, or a broker refusal) returns here; the + // only thing that ever existed was a durable pending row, which is the + // point of it. + let relay: { socketPath: string; grantId: string } | null = null; + if (args.keyHomeNodeId !== undefined) { + const gateA = await gateSshNode(args.viewerId, args.keyHomeNodeId); + if (!gateA.ok) return gateA; + const aRow = gateA.value.row; + if (aRow.kind !== "agent") { + // Acceptance (d) restated at the door that can name it: the server host + // is never a key home, whatever its own gate says about SSH. + return codedRefusal( + 400, + BackendErrorCodes.SSH_RELAY_OPEN_FAILED, + "The server host itself cannot hold a relay key home; pick a machine running the Subshell app.", + ); + } + const leg = await prepareRelayLeg({ + viewerId: args.viewerId, + aNode: { id: aRow.id, name: aRow.name }, + bNodeId: row.id, + resolvedHost: snapshot.host, + paneId: subshellId, + }); + if (!leg.ok) return leg; + relay = leg.value; + } const composed = composeSshLaunch({ snapshot, targetDataDir: dataDir, subshellId }); + // Relay mode RE-POINTS the scoped exception (spec §5.2): the socket the + // pane's ssh authenticates through is B's proxy the broker just verified, + // never the snapshot's agent socket even if the config names one. Still + // one key, still the ssh invocation only, still never the whole-pane env. + if (relay) composed.extraPaneEnv = sshRelayPaneEnv(relay.socketPath); // The create can still refuse (node switched off, harness gone, a // lockdown, a maintenance window) — those ride the global error handler // with their own codes. They land here, AFTER the resolve: the honest // order, since this act's gate (SSH use) is strictly narrower than the // launch's and every refusal below writes no pane. - const created = await args.subshells.createSubshell({ - userId: args.viewerId, - harnessId: "ssh", - presetId: null, - // The presetless-terminal default (spec 2026-10-01 §2): the launch node's - // home, resolved by the create path itself — exactly what the wizard's - // "connect from this machine" journey wants as the pane's starting cwd. - workingDir: undefined, - name: args.name, - nodeId: row.id, - machineActor: false, // the routes are cookie-only (requireCookieActor) - crossAgent: false, - subshellId, - ssh: { configPath: composed.configPath, fileContent: composed.fileContent, snapshot }, - presetFlags: composed.presetFlags, - ...(composed.extraPaneEnv ? { extraPaneEnv: composed.extraPaneEnv } : {}), - }); + let created; + try { + created = await args.subshells.createSubshell({ + userId: args.viewerId, + harnessId: "ssh", + presetId: null, + // The presetless-terminal default (spec 2026-10-01 §2): the launch node's + // home, resolved by the create path itself — exactly what the wizard's + // "connect from this machine" journey wants as the pane's starting cwd. + workingDir: undefined, + name: args.name, + nodeId: row.id, + machineActor: false, // the routes are cookie-only (requireCookieActor) + crossAgent: false, + subshellId, + ssh: { configPath: composed.configPath, fileContent: composed.fileContent, snapshot }, + presetFlags: composed.presetFlags, + ...(composed.extraPaneEnv ? { extraPaneEnv: composed.extraPaneEnv } : {}), + }); + } catch (err) { + // A session was opened under a pane id that will never have a child: + // the child-exit word is the honest cut (the process is gone at t=0), + // and the sweep is the same best-effort helper every death site uses. + // The refusal the caller sees is the create's own, untouched. + if (relay) closeRelayForPaneExit(subshellId); + throw err; + } // Recency FIRST (a bookkeeping refresh the human already sees as part of the // launch), then the audit — both AFTER the pane exists. The ledger is // best-effort (a failed touch must not turn a live pane into an error, the @@ -444,7 +504,14 @@ export async function sshLaunch(args: { action: "ssh.launch", targetType: "node", targetId: row.id, - metadataJson: JSON.stringify({ nodeId: row.id, destination, subshellId: created.id }), + // The relay's grant id rides the success row: ids only, and it lets the + // trail read which authorization carried the launch (spec §9's audit + // posture; the fingerprint set lives on the grant, not here). + metadataJson: JSON.stringify( + relay + ? { nodeId: row.id, destination, subshellId: created.id, grantId: relay.grantId } + : { nodeId: row.id, destination, subshellId: created.id }, + ), }); return ok({ subshellId: created.id }); } @@ -486,6 +553,59 @@ export async function sshRemoveSavedHost(viewerId: string, id: string): Promise< return await new SshSavedHostsRepository(db).remove(viewerId, id); } +/** + * `POST /api/ssh/grants` — create a standing grant from the screen (spec + * 2026-10-08 §8). The gate here is the key home's: a grant authorizes AN's + * agent, so A must be an agent node this caller may SSH through, switched on + * and unheld - the same `gateSshNode` a relay launch runs, run at creation + * so the screen cannot store an authorization pointed at a machine the + * caller has no say over. `local` is never a key home (acceptance (d)); the + * refusal names that door with the open-failure code because it is exactly + * what the broker would have refused, refused before anything was stored. + */ +export async function sshCreateGrant(args: { + viewerId: string; + aNodeId: string; + name: string; + selector: string; + fingerprints: readonly string[]; +}): Promise> { + const gate = await gateSshNode(args.viewerId, args.aNodeId); + if (!gate.ok) { + // gateSshNode only ever carries the CODED arm (its refusal is a gate + // verdict, never a resolve outcome); the 422 type-arm is unreachable, + // and an unreachable arm is rendered loudly, not swallowed. + if (gate.refusal.status === 422) { + return { + ok: false, + refusal: { + status: 502, + code: BackendErrorCodes.SSH_NODE_REFUSED, + message: "The grant surface cannot answer that gate check.", + }, + }; + } + return { ok: false, refusal: gate.refusal }; + } + if (gate.value.row.kind !== "agent") { + return { + ok: false, + refusal: { + status: 400, + code: BackendErrorCodes.SSH_RELAY_OPEN_FAILED, + message: "The server host itself cannot hold a grant key home; pick a machine running the Subshell app.", + }, + }; + } + return await createGrant({ + ownerUserId: args.viewerId, + aNodeId: args.aNodeId, + name: args.name, + selector: args.selector, + fingerprints: args.fingerprints, + }); +} + /** The GET payload of the launcher screen: the owner's rows plus their default machine. */ export async function sshSavedHostsView(viewerId: string): Promise<{ saved: SshSavedHostTable[]; From 6394a548a97424276f3b0add63eca4a637b2bd89 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:50:04 -0700 Subject: [PATCH 46/92] docs(security): SS10 gains the grant + relay audit families node.ssh_grant.request|create|update|delete|approve|deny (ids/hosts/ fingerprintsCount only; deny audit-only; expiry writes nothing; delete carries relaysClosed) and node.ssh_relay.open|close (plane rows, named reason) - the latter the pair T8 deferred to this task. ssh.launch names grantId on a relay launch. The working summary's chain updated to match. --- .claude/rules/security-context.md | 2 +- docs/security.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 78cee0db1..052777891 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_grant.request|ssh_grant.create (via first-use|manual; fingerprintsCount only, NEVER a fingerprint value)|ssh_grant.update|ssh_grant.delete (relaysClosed; the revoke also cuts live relays via closeForGrant "grant-revoked")|ssh_grant.approve|ssh_grant.deny (audit-only; grant-request expiry writes nothing)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, grantId on a relay launch}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. ## Deliberate non-validation & accepted risks diff --git a/docs/security.md b/docs/security.md index d65262b28..24aeee28d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2282,6 +2282,18 @@ Audit events are written, grouped by family: and nothing else in the metadata - never the key value; the idempotent re-report of the same bytes and the refused different-bytes report write no row), + `node.ssh_grant.request|create|update|delete|approve|deny` (spec §6/§9, + migration 0050: the ask names the pane, the two machines, and the resolved + destination; approve/create name the grant, the destination, and the + selected-key COUNT (`fingerprintsCount`) - the fingerprint VALUES are + public identifiers but they never enter an audit row, a log line, or a + notification body; deny is audit-only and expiry writes NOTHING; delete + carries `relaysClosed`, the revoke's live-session cut made real; create + carries `via: "first-use" | "manual"`), + `node.ssh_relay.open|close` (spec §5.3/§9: open names grant, A, B, pane, + the routing ref, and the fingerprint COUNT; close names the one + `reason` word from the grammar - the plane's broker rows, `actorUserId: + null`, because the human's act is the launch/grant trail), `node.logging.update`, `node.update`, `node.update.unknown`, `node.shares_set`, `node.local_share_changed`, and `node.service` (with `{ verb: "restart", forced }` metadata; the restart From 81216761191d20c9c0433ae5228d3ff8e23b98d5 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 14:56:53 -0700 Subject: [PATCH 47/92] docs(ssh-relay): approve audit names the chosen fingerprints (spec 9/10 governs over the brief); ratify owner-scoped host pins --- .claude/rules/security-context.md | 2 +- docs/security.md | 17 ++++++++++------- .../specs/2026-10-08-ssh-agent-relay-design.md | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 052777891..0de45ab7d 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_grant.request|ssh_grant.create (via first-use|manual; fingerprintsCount only, NEVER a fingerprint value)|ssh_grant.update|ssh_grant.delete (relaysClosed; the revoke also cuts live relays via closeForGrant "grant-revoked")|ssh_grant.approve|ssh_grant.deny (audit-only; grant-request expiry writes nothing)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, grantId on a relay launch}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_grant.request|ssh_grant.create (via first-use|manual; approve/create name the CHOSEN fingerprint VALUES + destination per spec §10 - the approve row is the only durable record of the operator's selection, and a fingerprint appears in NO log line or notification body)|ssh_grant.update|ssh_grant.delete (relaysClosed; the revoke also cuts live relays via closeForGrant "grant-revoked")|ssh_grant.approve|ssh_grant.deny (audit-only; grant-request expiry writes nothing)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, grantId on a relay launch}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. ## Deliberate non-validation & accepted risks diff --git a/docs/security.md b/docs/security.md index 24aeee28d..f7877408d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2282,14 +2282,17 @@ Audit events are written, grouped by family: and nothing else in the metadata - never the key value; the idempotent re-report of the same bytes and the refused different-bytes report write no row), - `node.ssh_grant.request|create|update|delete|approve|deny` (spec §6/§9, + `node.ssh_grant.request|create|update|delete|approve|deny` (spec §6/§9/§10, migration 0050: the ask names the pane, the two machines, and the resolved - destination; approve/create name the grant, the destination, and the - selected-key COUNT (`fingerprintsCount`) - the fingerprint VALUES are - public identifiers but they never enter an audit row, a log line, or a - notification body; deny is audit-only and expiry writes NOTHING; delete - carries `relaysClosed`, the revoke's live-session cut made real; create - carries `via: "first-use" | "manual"`), + destination; approve/create name the grant, the destination, and the CHOSEN + key FINGERPRINTS (the `SHA256:` public identifiers - spec §10 makes the + approve row the only durable record of the operator's selection, since a + plane could widen the set it hands A and no plane-independent surface shows + what A enforced); those fingerprint values appear ONLY in the grant + approve/create audit rows - never in a log line or a notification body; deny + is audit-only and expiry writes NOTHING; delete carries `relaysClosed`, the + revoke's live-session cut made real; create carries `via: "first-use" | + "manual"`; no key material, challenge, or signature rides any row), `node.ssh_relay.open|close` (spec §5.3/§9: open names grant, A, B, pane, the routing ref, and the fingerprint COUNT; close names the one `reason` word from the grammar - the plane's broker rows, `actorUserId: diff --git a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md index 9ef4d77ef..94101f1a9 100644 --- a/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md +++ b/docs/superpowers/specs/2026-10-08-ssh-agent-relay-design.md @@ -117,7 +117,7 @@ Confidentiality is not authenticity: anyone holding A's public key, the plane in ### 6.1 The grant record -`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set (OpenSSH `SHA256:` base64; agent keys hash their agent wire encoding, §4.5, the same notation the trust card uses). No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. +`ssh_key_grants` (parent §7): id, owner, name, key-home node id (A), **resolved** destination selector, and the selected key-fingerprint set (OpenSSH `SHA256:` base64; agent keys hash their agent wire encoding, §4.5, the same notation the trust card uses). No secret, no key bytes. The selector is matched against the resolved destination hostname (parent §7); an alias typed at approval is **resolved to the concrete `host` at grant creation and stored resolved, the alias kept only as display metadata**, so a later `~/.ssh/config` edit cannot silently re-point the grant, honoring the same canonical-destination rule parent §11 applies to saved hosts and pins. Host-key pins are **not** part of a grant; they are per resolved `user@host:port` (§9, `ssh_host_pins`), so one selector over many hosts yields many pins. Ratified deviation from parent §7's "one pin per resolved destination": the row key is scoped per **owner** as well as destination, `(owner, user@host:port)`, so one account's TOFU decision can never be poisoned by another; under the single-operator posture this is equal to parent §7's one-per-destination, but the shape is owner-scoped on purpose (T12 builds on it). ### 6.2 First use creates the grant From 2a0a8af8986f1115a53f88293efa5dfa07f7197b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:05:12 -0700 Subject: [PATCH 48/92] fix(ssh-relay): approve audit names the chosen fingerprints (spec 9/10); clear relay-grant NITs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approve/create audit rows carry the chosen SHA256: fingerprint VALUES and the destination (spec 2026-10-08 §9/§10, docs/security.md §10: the approve row is the durable record of the operator's selection). The values still appear nowhere else: an edit names the count, no other audit row carries a value, and the retargeted suite proves no fingerprint reaches a log line or a notification and no key material rides any row. NITs: the local-as-key-home refusals declare the 409 their code canonically renders (pinned by a new /api/ssh/grants case); the DELETE /grants/:id decorative 409 arm is gone; the sshCreateGrant JSDoc says the key home's agent signs; T10's two em dashes cleared; SshGrantView/SshGrantRequestView gain per-property docs. --- .../src/api/ssh/__tests__/ssh-routes.test.ts | 20 ++++ .../server/api/src/api/ssh/approvals.route.ts | 2 +- apps/server/api/src/api/ssh/grants.route.ts | 10 +- .../api/src/db/types/ssh-grants.db-types.ts | 6 +- .../__tests__/ssh-grants.service.test.ts | 113 +++++++++++++----- .../api/src/services/ssh-grants.service.ts | 42 +++++-- .../api/src/services/ssh-launch.service.ts | 25 ++-- 7 files changed, 162 insertions(+), 56 deletions(-) diff --git a/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts index b2c7056b3..9b9aefbb6 100644 --- a/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts +++ b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts @@ -672,6 +672,26 @@ describe("the local node answers in-process (no agent socket exists to reach)", expect((await aliases(LOCAL_NODE_ID, otherCookie)).status).toBe(403); }); + it("`local` is never a key home (acceptance (d)): the create names the door with the code's canonical 409, and stores nothing", async () => { + await nodes.setSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); + const res = await sshFetch("/api/ssh/grants", { + method: "POST", + cookie: adminCookie, + body: { + node: LOCAL_NODE_ID, + name: "server keys", + selector: "git.example.test", + fingerprints: [`SHA256:${"c".repeat(43)}`], + }, + }); + // 409 is what the OPEN-FAILURE code renders (the declared arm rides the + // code, not the service's own number); the server host is refused exactly + // as the broker would refuse it, BEFORE anything is stored. + expect(res.status).toBe(409); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); + expect(await db.selectFrom("sshKeyGrants").selectAll().execute()).toHaveLength(0); + }); + it("local resolve runs the same engine in-process: the -G answer becomes an approved snapshot", async () => { await nodes.setSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); const res = await resolve(LOCAL_NODE_ID, "verify.example", adminCookie); diff --git a/apps/server/api/src/api/ssh/approvals.route.ts b/apps/server/api/src/api/ssh/approvals.route.ts index 96721a8cc..4fc30ee96 100644 --- a/apps/server/api/src/api/ssh/approvals.route.ts +++ b/apps/server/api/src/api/ssh/approvals.route.ts @@ -82,7 +82,7 @@ export const sshGrantRequestsRoutes = new Elysia() operationId: "approveSshGrantRequest", tags: ["ssh"], description: - "Answers a pending first-use approval YES: writes the standing grant with exactly the selected identities and audits the answer with the fingerprint COUNT, never the values", + "Answers a pending first-use approval YES: writes the standing grant with exactly the selected identities and audits the answer naming the chosen fingerprints (public SHA256: identifiers) and the destination, per the durable selection record in docs/security.md §10", }, }, ) diff --git a/apps/server/api/src/api/ssh/grants.route.ts b/apps/server/api/src/api/ssh/grants.route.ts index 16c4ae06f..958d65e5e 100644 --- a/apps/server/api/src/api/ssh/grants.route.ts +++ b/apps/server/api/src/api/ssh/grants.route.ts @@ -25,8 +25,11 @@ import { sshCreateGrant } from "@/services/ssh-launch.service.js"; * that ran under it is torn down inside the service (spec §6.3). * * What the audit rows carry (written by the service, not here): ids, hosts, - * and the fingerprint COUNT - never a fingerprint string, which is the - * screen's to show and the trail's never to hold (docs/security.md §10). + * and on the grant approve/create rows the CHOSEN fingerprint VALUES - the + * public `SHA256:` identifiers, named because spec §10 makes those rows the + * durable record of the operator's selection. Everywhere else the trail names + * the COUNT only, no fingerprint value rides a log line or a notification, + * and no row carries key material (docs/security.md §10). */ export const sshGrantsRoutes = new Elysia() .use(authGuard) @@ -155,10 +158,11 @@ export const sshGrantsRoutes = new Elysia() { params: t.Object({ id: t.String({ description: "Grant row id" }) }), response: { + // The comment above is the law now: revoke's ONLY refusal arm is the + // 404, so no decorative 409 is declared. 401: "ApiErrorResponse", 403: "ApiErrorResponse", 404: "ApiErrorResponse", - 409: "ApiErrorResponse", }, detail: { operationId: "revokeSshKeyGrant", diff --git a/apps/server/api/src/db/types/ssh-grants.db-types.ts b/apps/server/api/src/db/types/ssh-grants.db-types.ts index fb6a2bd4d..e5781b9ea 100644 --- a/apps/server/api/src/db/types/ssh-grants.db-types.ts +++ b/apps/server/api/src/db/types/ssh-grants.db-types.ts @@ -39,8 +39,10 @@ export const SSH_GRANT_REQUEST_STATUSES: readonly SshGrantRequestStatus[] = [ * selector, sign with the agent on the key-home machine, using exactly the * selected public identities. No secret and no key bytes live here - the * fingerprints are OpenSSH `SHA256:` display identifiers over the agent wire - * encoding (public by nature), yet they still never enter a notification body - * or a log line (Global Constraints; docs/security.md §10 names the COUNT). + * encoding (public by nature): spec 2026-10-08 §10 makes the grant's + * approve/create audit rows NAME the chosen values (the durable selection + * record), while they enter no other audit row, no notification body, and no + * log line (docs/security.md §10). */ export interface SshKeyGrantTable { /** Unique row id (uuid) */ diff --git a/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts index 4e1510f72..6ed51c1dc 100644 --- a/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { BackendErrorCodes } from "@internal/backend-errors"; import { SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; import { hashPassword } from "better-auth/crypto"; @@ -26,6 +26,7 @@ import { updateGrant, } from "@/services/ssh-grants.service.js"; import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service.js"; +import { logger } from "@/utils/logger.js"; /** * The grant authorization layer (spec 2026-10-08 §6; Task 10). What this @@ -36,9 +37,13 @@ import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service. * approval, no second notification). The row is DB state: a "restart" * (deps swapped, nothing else) still finds it. * - APPROVE writes the grant with EXACTLY the chosen fingerprints, flips the - * request, and audits `approve` + `create` whose metadata names the COUNT, - * never a fingerprint string. A selection over SSH_MAX_GRANT_FINGERPRINTS - * is the named red error with the input untouched, never a truncation. + * request, and audits `approve` + `create` naming the CHOSEN fingerprint + * VALUES + destination (spec 2026-10-08 §9/§10: the approve row is the + * durable record of the operator's selection, and a count records no + * selection). The values stay OUT of every other audit row, every log line, + * and every notification, and no key material, challenge, or signature + * rides any row. A selection over SSH_MAX_GRANT_FINGERPRINTS is the named + * red error with the input untouched, never a truncation. * - DENY audits only. EXPIRY sweeps the row to `expired` and writes NOTHING. * - MATCH: owner + key home + selector against the resolved host; globs are * policy; most specific wins, ties to the OLDEST grant. @@ -326,31 +331,73 @@ describe("ssh-grants.service", () => { expect(one.ok).toBe(false); }); - it("approve writes the grant (createdVia first-use), answers the request, and audits COUNTS, never fingerprint strings", async () => { - const approved = await approveGrant({ - ownerUserId: owner, - requestId: requestRef, - fingerprints: FPS, - name: "work git", - }); - expect(approved.ok).toBe(true); - if (!approved.ok) return; - expect(approved.value.grant.createdVia).toBe("first-use"); - expect(approved.value.grant.fingerprints).toEqual(FPS); - expect(approved.value.grant.resolvedSelector).toBe(HOST); - expect(approved.value.grant.keyHomeNodeId).toBe(NODE_A); - expect((await repo.getRequest(owner, requestRef))?.status).toBe("approved"); - const create = await latestAudit("node.ssh_grant.create"); - expect(create.fingerprintsCount).toBe(2); - expect(create.via).toBe("first-use"); - const approve = await latestAudit("node.ssh_grant.approve"); - expect(approve.grantId).toBe(approved.value.grant.id); - expect(approve.fingerprintsCount).toBe(2); - // The Global Constraint, read off the WHOLE trail: no fingerprint VALUE - // in any audit row this act wrote. - const all = await db.selectFrom("auditEvents").select("metadataJson").execute(); - for (const row of all) { - for (const fp of FPS) expect(row.metadataJson).not.toContain(fp); + it("approve writes the grant (createdVia first-use), answers the request, and its approve+create rows NAME the chosen fingerprints", async () => { + // The spec's split (2026-10-08 §9/§10, docs/security.md §10): the + // approve row is the DURABLE RECORD OF THE SELECTION, so it carries the + // chosen `SHA256:` values (public identifiers) + destination; the same + // values must appear in NO log line and NO notification this act writes. + const logged: string[] = []; + const levels = ["debug", "info", "warn", "error"] as const; + const originals = levels.map((level) => + (logger[level] as unknown as (...args: unknown[]) => unknown).bind(logger), + ); + const spies = levels.map((level, i) => + spyOn(logger, level).mockImplementation(((...args: unknown[]) => { + logged.push(args.filter((a) => typeof a === "string").join(" ")); + return originals[i](...args); + }) as never), + ); + try { + const approved = await approveGrant({ + ownerUserId: owner, + requestId: requestRef, + fingerprints: FPS, + name: "work git", + }); + expect(approved.ok).toBe(true); + if (!approved.ok) return; + expect(approved.value.grant.createdVia).toBe("first-use"); + expect(approved.value.grant.fingerprints).toEqual(FPS); + expect(approved.value.grant.resolvedSelector).toBe(HOST); + expect(approved.value.grant.keyHomeNodeId).toBe(NODE_A); + expect((await repo.getRequest(owner, requestRef))?.status).toBe("approved"); + // The selection record: approve and create carry the VALUES. + const create = await latestAudit("node.ssh_grant.create"); + expect(create.fingerprints).toEqual(FPS); + expect(create.destination).toBe(HOST); + expect(create.via).toBe("first-use"); + expect(create.fingerprintsCount).toBeUndefined(); // replaced by the values, not paired with a count + const approve = await latestAudit("node.ssh_grant.approve"); + expect(approve.grantId).toBe(approved.value.grant.id); + expect(approve.fingerprints).toEqual(FPS); + expect(approve.destination).toBe(HOST); + expect(approve.fingerprintsCount).toBeUndefined(); + // The notification seam carries only opaque ids (fixed copy + the + // request id) - not one fingerprint reaches the owner's push. + const notified = JSON.stringify(notifyCalls); + for (const fp of FPS) expect(notified).not.toContain(fp); + // The WHOLE trail, re-targeted: the values appear EXACTLY in the + // grant approve/create rows (spec §10 names them nowhere else), and + // NO row anywhere carries key material, a challenge, or a signature. + const all = await db.selectFrom("auditEvents").select(["action", "metadataJson"]).execute(); + const valueBearing = new Set(["node.ssh_grant.approve", "node.ssh_grant.create"]); + for (const row of all) { + const meta = row.metadataJson ?? ""; + for (const fp of FPS) { + if (valueBearing.has(row.action)) expect(meta).toContain(fp); + else expect(meta).not.toContain(fp); + } + for (const marker of ['"kty"', "PRIVATE KEY", "signingPublicKey", "challenge", "signature"]) { + expect(meta).not.toContain(marker); + } + } + } finally { + for (const spy of spies) spy.mockRestore(); + } + // The log-line half of the guard: no fingerprint VALUE in anything this + // act handed the logger (the act is silent; a logging regression trips here). + for (const line of logged) { + for (const fp of FPS) expect(line).not.toContain(fp); } }); @@ -627,9 +674,15 @@ describe("ssh-grants.service", () => { if (!updated.ok) return; expect(updated.value.grant.resolvedSelector).toBe("*.example.test"); expect(updated.value.grant.fingerprints).toEqual(FPS); // selection is immutable through edit + // The manual create is the SAME selection record (spec §10): it names + // the chosen values, exactly like an approved first use does. + const create = await latestAudit("node.ssh_grant.create"); + expect(create.fingerprints).toEqual(FPS); + expect(create.via).toBe("manual"); const audit = await latestAudit("node.ssh_grant.update"); expect(audit.grantId).toBe(created.value.grant.id); - expect(audit.fingerprintsCount).toBe(2); + expect(audit.fingerprintsCount).toBe(2); // an EDIT names the count... + expect(audit.fingerprints).toBeUndefined(); // ...only approve/create rows carry the values }); it("revoke deletes the row, cuts every live relay through closeForGrant, and audits the count", async () => { diff --git a/apps/server/api/src/services/ssh-grants.service.ts b/apps/server/api/src/services/ssh-grants.service.ts index f15a21cfd..be8bbcf24 100644 --- a/apps/server/api/src/services/ssh-grants.service.ts +++ b/apps/server/api/src/services/ssh-grants.service.ts @@ -32,10 +32,13 @@ import { logger } from "@/utils/logger.js"; * account. The routes render foreign ids as the 404 the ownership axis * demands; a foreign row is absent here, never "forbidden". * - * **What never leaves this layer's row** (Global Constraints): fingerprint - * VALUES. They are public identifiers and they live in the grant column, but - * they appear in NO audit metadata (the COUNT is the law), NO notification - * (fixed copy + the opaque request id), and NO log line this file writes. + * **What may and may not leave this layer's row** (Global Constraints; spec + * §9/§10): the fingerprint VALUES are public `SHA256:` identifiers, and the + * `approve` and `create` audit rows NAME them - that row pair is the durable + * record of the operator's selection, and a count records no selection. The + * values appear in NO OTHER audit row (an edit names the count), NO + * notification (fixed copy + the opaque request id), and NO log line this + * file writes; no key material, challenge, or signature ever rides any row. * * Everything outside the module is an injected seam (the `getNodeWsDeps` * pattern): the clock, the relay broker, and the notification sink. The @@ -141,26 +144,43 @@ function selectorSpecificity(selector: string): { wildcards: number; literals: n /** One grant row as the API and the launch leg read it: fingerprints parsed, not a JSON string. */ export interface SshGrantView { + /** Unique grant row id (uuid) */ id: string; + /** Display name the operator gave the grant (defaults to the selector when unnamed) */ name: string; + /** Machine A: the key home whose ssh-agent signs under this grant */ keyHomeNodeId: string; + /** The destination selector, stored resolved (concrete hostname or `*` host pattern, lowercase) */ resolvedSelector: string; + /** The chosen public agent identities (`SHA256:` strings, parsed from the stored JSON array) - exactly which keys may sign */ fingerprints: string[]; + /** Which door created the row: an approved first use or the grants screen */ createdVia: SshGrantCreatedVia; + /** ISO 8601 creation stamp (the match tie-break: oldest grant wins) */ createdAt: string; + /** ISO 8601 of the last operator edit (name/selector) */ updatedAt: string; } /** One first-use request row as the approvals screen reads it. */ export interface SshGrantRequestView { + /** Request row id (uuid) - the opaque ref the launch refusal and the notification name */ id: string; + /** Machine A asked to sign (the key home whose owner must answer) */ keyHomeNodeId: string; + /** The resolved destination hostname the asking launch would have dialed */ resolvedSelector: string; + /** Fingerprints that rode the request; null when none did (the approver selects, the asking pane proposes nothing) */ requestedFingerprints: string[] | null; + /** B's pane (subshell id) whose launch asked - what the screen names as the requester */ paneId: string; + /** B's node id: the connecting machine of the asking launch */ bNodeId: string; + /** ISO 8601 answer deadline (24 h); past it the lazy sweep marks the row expired and writes nothing */ expiresAt: string; + /** Lifecycle state - only `pending` is answerable */ status: SshGrantRequestStatus; + /** ISO 8601 creation stamp */ createdAt: string; } @@ -220,7 +240,8 @@ const ALREADY_ANSWERED = { } as const; /* ------------------------------------------------------------------ */ -/* audit: ids, hosts, COUNTS - the §9 shape, never a fingerprint */ +/* audit: ids, hosts, the CHOSEN fingerprints on approve/create (§10), */ +/* counts elsewhere - never key material, challenge, or signature */ /* ------------------------------------------------------------------ */ function grantAudit( @@ -310,8 +331,9 @@ export async function sweepExpiredGrantRequests(): Promise { * never a double grant), then write the standing grant with EXACTLY the * chosen fingerprints, created via first-use. Audits BOTH facts: the answer * (`approve`) and the row's birth (`create`, the same event the screen's own - * create writes, per §6.2), naming ids, destination, and the fingerprint - * COUNT only. + * create writes, per §6.2), naming ids, destination, and the CHOSEN + * fingerprint VALUES - §10 makes the approve row the only durable record of + * the operator's selection, so a count here would record no selection. */ export async function approveGrant(args: { ownerUserId: string; @@ -344,12 +366,12 @@ export async function approveGrant(args: { requestId: row.id, grantId: grant.id, destination: row.resolvedSelector, - fingerprintsCount: keys.value.length, // the COUNT; §10 law + fingerprints: keys.value, // the CHOSEN VALUES; §10's durable selection record }); await grantAudit(args.ownerUserId, "node.ssh_grant.create", row.keyHomeNodeId, { grantId: grant.id, destination: row.resolvedSelector, - fingerprintsCount: keys.value.length, + fingerprints: keys.value, via: "first-use", }); return granted({ grant: grantView(grant) }); @@ -444,7 +466,7 @@ export async function createGrant(args: { await grantAudit(args.ownerUserId, "node.ssh_grant.create", args.aNodeId, { grantId: grant.id, destination: selector, - fingerprintsCount: keys.value.length, + fingerprints: keys.value, // the same selection record the approved first use writes (§10) via: "manual", }); return granted({ grant: grantView(grant) }); diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index adf62b7e7..738dd2720 100644 --- a/apps/server/api/src/services/ssh-launch.service.ts +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -430,9 +430,11 @@ export async function sshLaunch(args: { const aRow = gateA.value.row; if (aRow.kind !== "agent") { // Acceptance (d) restated at the door that can name it: the server host - // is never a key home, whatever its own gate says about SSH. + // is never a key home, whatever its own gate says about SSH. The 409 + // is the code's canonical render (`throwCodedRefusal` rides the code's + // own status), matching what the broker's own `local-node` refusal is. return codedRefusal( - 400, + 409, BackendErrorCodes.SSH_RELAY_OPEN_FAILED, "The server host itself cannot hold a relay key home; pick a machine running the Subshell app.", ); @@ -465,7 +467,7 @@ export async function sshLaunch(args: { harnessId: "ssh", presetId: null, // The presetless-terminal default (spec 2026-10-01 §2): the launch node's - // home, resolved by the create path itself — exactly what the wizard's + // home, resolved by the create path itself - exactly what the wizard's // "connect from this machine" journey wants as the pane's starting cwd. workingDir: undefined, name: args.name, @@ -554,12 +556,12 @@ export async function sshRemoveSavedHost(viewerId: string, id: string): Promise< } /** - * `POST /api/ssh/grants` — create a standing grant from the screen (spec - * 2026-10-08 §8). The gate here is the key home's: a grant authorizes AN's - * agent, so A must be an agent node this caller may SSH through, switched on - * and unheld - the same `gateSshNode` a relay launch runs, run at creation - * so the screen cannot store an authorization pointed at a machine the - * caller has no say over. `local` is never a key home (acceptance (d)); the + * `POST /api/ssh/grants` - create a standing grant from the screen (spec + * 2026-10-08 §8). The gate here is the key home's: a grant authorizes the key + * home's agent to sign, so A must be an agent node this caller may SSH + * through, switched on and unheld - the same `gateSshNode` a relay launch + * runs, run at creation so the screen cannot store an authorization pointed + * at a machine the caller has no say over. `local` is never a key home (acceptance (d)); the * refusal names that door with the open-failure code because it is exactly * what the broker would have refused, refused before anything was stored. */ @@ -591,7 +593,10 @@ export async function sshCreateGrant(args: { return { ok: false, refusal: { - status: 400, + // 409 is this code's canonical render (`throwCodedRefusal` rides the + // code's own status), and it is what the broker's own `local-node` + // refusal gives: the same door, refused before anything is stored. + status: 409, code: BackendErrorCodes.SSH_RELAY_OPEN_FAILED, message: "The server host itself cannot hold a grant key home; pick a machine running the Subshell app.", }, From 0f664bcc6c3450dacd62ee4f77fc73fa23bc2b0b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:14:21 -0700 Subject: [PATCH 49/92] feat(ssh-relay): arm ssh_agent_identities in the node-link grammar with a blobs-withheld roster result Task 11 (spec 2026-10-08 SS5.4): the command is its type alone (it asks the whole roster); the result validator rebuilds each entry as { fingerprint, comment } so blob bytes are unrepresentable, and the fingerprint grammar is one exported predicate shared by the grant selection and the roster read. --- .../src/__tests__/node-frames.test.ts | 17 +++++- .../src/__tests__/node-results-ssh.test.ts | 55 ++++++++++++++++++- packages/subshell-protocol/src/index.ts | 5 ++ packages/subshell-protocol/src/node-frames.ts | 1 + .../subshell-protocol/src/node-results.ts | 34 +++++++++++- packages/subshell-protocol/src/ssh-frames.ts | 43 +++++++++++++-- packages/subshell-protocol/src/ssh-results.ts | 37 +++++++++++-- 7 files changed, 177 insertions(+), 15 deletions(-) diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 7a1feb1bc..b232427eb 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -963,16 +963,17 @@ describe("ssh command arms and the launch ssh block", () => { type: "ssh_register_identity", }); expect(parseNodeCommandBody({ type: "ssh_register_identity" })).not.toBeNull(); - // The family's census: exactly these five types, and the count is the + // The family's census: exactly these six types, and the count is the // tripwire - a further arm must show up here before it ships. expect([...SSH_COMMAND_TYPES].sort()).toEqual([ + "ssh_agent_identities", "ssh_discover_aliases", "ssh_register_identity", "ssh_relay_close", "ssh_relay_open", "ssh_resolve_config", ]); - expect(SSH_COMMAND_TYPES).toHaveLength(5); + expect(SSH_COMMAND_TYPES).toHaveLength(6); // Every census type is a type the dispatcher actually narrows. for (const t of SSH_COMMAND_TYPES) { const body = @@ -987,6 +988,18 @@ describe("ssh command arms and the launch ssh block", () => { } }); + it("the ssh_agent_identities arm is its type alone (spec 2026-10-08 §5.4: the roster command asks the WHOLE roster)", () => { + expect(parseNodeCommandBody({ type: "ssh_agent_identities" })).toEqual({ type: "ssh_agent_identities" }); + // Nothing beyond the type travels: the command enumerates A's entire public + // roster, so a plane-sent selection could only be a widening attempt. + expect(parseNodeCommandBody({ type: "ssh_agent_identities", fingerprints: ["x"] })).toEqual({ + type: "ssh_agent_identities", + }); + expect(parseNodeCommandBody({ type: "ssh_agent_identities", grantId: "g" })).toEqual({ + type: "ssh_agent_identities", + }); + }); + it("launch accepts an ssh block and refuses malformed ones", () => { const good = { ...launchCmd, ssh: { configPath: "/d/ssh/s1/config", fileContent: "Host *\n" } }; expect(parseNodeCommandBody(good)).toMatchObject({ diff --git a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts index f6f46859a..d98fea2df 100644 --- a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { parseNodeSshAliasList, parseNodeSshIdentity, parseNodeSshResolveOutcome } from "../node-results.js"; +import { + parseNodeSshAgentIdentities, + parseNodeSshAliasList, + parseNodeSshIdentity, + parseNodeSshResolveOutcome, +} from "../node-results.js"; import { makeAliasList, makeResolveOk, makeResolveRefused } from "./fixtures/ssh-fixtures.js"; /** A well-formed public-JWK STRING: the validator's whole job is that it is JSON and an object. */ @@ -49,6 +54,54 @@ describe("parseNodeSshResolveOutcome", () => { }); }); +describe("parseNodeSshAgentIdentities", () => { + /** A canonical grant-grammar fingerprint (SHA256: + base64url digest text). */ + const FP_A = `SHA256:${"A".repeat(20)}${"b".repeat(20)}cd`; + const _FP_B = `SHA256:${"-_9".repeat(14)}x`; + + test("narrows a well-formed roster answer", () => { + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: FP_A, comment: "laptop key" }] })).toEqual({ + identities: [{ fingerprint: FP_A, comment: "laptop key" }], + }); + }); + test("an EMPTY roster is a legal answer (a live agent may carry zero keys)", () => { + expect(parseNodeSshAgentIdentities({ identities: [] })).toEqual({ identities: [] }); + }); + test("blob bytes are NOT representable: a wire `blob` member is dropped from the narrowed answer", () => { + // The grammar has no slot for key material, so even a (buggy or hostile) + // node that tried to ship one cannot get it past the rebuild. + const blob = Buffer.from("PRIVATE-WIRE-MATERIAL").toString("base64"); + const parsed = parseNodeSshAgentIdentities({ identities: [{ fingerprint: FP_A, comment: "c", blob }] }); + expect(parsed).toEqual({ identities: [{ fingerprint: FP_A, comment: "c" }] }); + expect(JSON.stringify(parsed)).not.toContain("PRIVATE-WIRE-MATERIAL"); + expect(JSON.stringify(parsed)).not.toContain(blob); + }); + test("refuses fingerprints outside the canonical SHA256: grammar", () => { + // The roster's fingerprint must be a valid grant selection, the SAME + // grammar both directions (the approve error copy promises "exactly as + // the roster reports it"); anything else is a malformed answer, refused. + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: "MD5:aa:bb", comment: "c" }] })).toBeNull(); + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: "SHA256:", comment: "c" }] })).toBeNull(); + expect( + parseNodeSshAgentIdentities({ identities: [{ fingerprint: "SHA256:with space", comment: "c" }] }), + ).toBeNull(); + expect( + parseNodeSshAgentIdentities({ identities: [{ fingerprint: "SHA256:with/slash+plus=", comment: "c" }] }), + ).toBeNull(); + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: 7, comment: "c" }] })).toBeNull(); + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: FP_A }] })).toBeNull(); + }); + test("refuses malformed entries, non-string comments, and non-array shapes", () => { + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: FP_A, comment: 4 }] })).toBeNull(); + expect(parseNodeSshAgentIdentities({ identities: [{ fingerprint: FP_A, comment: "x".repeat(254) }] })).toBeNull(); + expect(parseNodeSshAgentIdentities({ identities: "not an array" })).toBeNull(); + expect(parseNodeSshAgentIdentities({ identities: ["bare string"] })).toBeNull(); + expect(parseNodeSshAgentIdentities({})).toBeNull(); + expect(parseNodeSshAgentIdentities(null)).toBeNull(); + expect(parseNodeSshAgentIdentities([])).toBeNull(); + }); +}); + describe("parseNodeSshIdentity", () => { test("narrows a well-formed signing-public-key answer", () => { expect(parseNodeSshIdentity({ signingPublicKey: SIGNING_JWK })).toEqual({ signingPublicKey: SIGNING_JWK }); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 32f30ed35..87f4f78b5 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -151,6 +151,7 @@ export { parseNodePathExistsResult, parseNodeProbeEntries, parseNodePromptDeliver, + parseNodeSshAgentIdentities, parseNodeSshAliasList, parseNodeSshIdentity, parseNodeSshResolveOutcome, @@ -251,11 +252,13 @@ export { type SshErrorCode, } from "./ssh-errors.js"; export { + isSshGrantFingerprint, isSshGrantFingerprints, isSshPaneId, parseSshNodeCommandBody, SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, + type SshAgentIdentitiesCommand, type SshDiscoverAliasesCommand, type SshNodeCommandBody, type SshRegisterIdentityCommand, @@ -307,6 +310,8 @@ export { verifyRelayEnvelope, } from "./ssh-relay.js"; export type { + NodeSshAgentIdentitiesResult, + NodeSshAgentIdentity, NodeSshAliasListResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire, diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index d5bf04075..45910346a 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1889,6 +1889,7 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { case "ssh_discover_aliases": case "ssh_resolve_config": case "ssh_register_identity": + case "ssh_agent_identities": case "ssh_relay_open": case "ssh_relay_close": // Delegation, not a second parser: the SSH grammar (all arms, one diff --git a/packages/subshell-protocol/src/node-results.ts b/packages/subshell-protocol/src/node-results.ts index c8a30440a..1271e1141 100644 --- a/packages/subshell-protocol/src/node-results.ts +++ b/packages/subshell-protocol/src/node-results.ts @@ -23,8 +23,15 @@ import { BASE64_RE, isBool, isInt, isRecord, isStr, isStrArray, isStringMap } fr import { MAX_ARCHIVE_BYTES, MAX_MANIFEST_PAGE_ENTRIES } from "./node-frames.js"; import { parseSshConnectionSnapshot } from "./ssh-config.js"; import { isSshErrorCode } from "./ssh-errors.js"; -import { SSH_MAX_DISCOVERED_ALIASES } from "./ssh-limits.js"; -import type { NodeSshAliasListResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire } from "./ssh-results.js"; +import { isSshGrantFingerprint } from "./ssh-frames.js"; +import { SSH_MAX_DISCOVERED_ALIASES, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; +import type { + NodeSshAgentIdentitiesResult, + NodeSshAgentIdentity, + NodeSshAliasListResult, + NodeSshIdentityResult, + NodeSshResolveOutcomeWire, +} from "./ssh-results.js"; function isNonEmptyStr(value: unknown): value is string { return isStr(value) && value.length > 0; @@ -585,6 +592,29 @@ export function parseNodeSshResolveOutcome(data: unknown): NodeSshResolveOutcome return { accepted: false, code: data.code, settings: [...(data.settings as string[])] }; } +/** + * Validates and narrows an `ssh_agent_identities` command's `result{data}` + * (spec 2026-10-08 §5.4, Task 11). The BLOBS are unrepresentable by + * construction: each entry is REBUILT from its two checked fields, so a + * `blob` member a buggy or hostile node tried to ship drops here and the + * narrowed answer has nowhere to hold key material. Fingerprints must be in + * the grant grammar's own spelling (one predicate, both directions: the + * approve surface takes roster values verbatim); comments are OpenSSH's + * labels, passed through as bounded text, never parsed. + * @param data - the `data` member of a successful result frame + * @returns the narrowed roster, or null when malformed + */ +export function parseNodeSshAgentIdentities(data: unknown): NodeSshAgentIdentitiesResult | null { + if (!isRecord(data) || !Array.isArray(data.identities)) return null; + const identities: NodeSshAgentIdentity[] = []; + for (const entry of data.identities as unknown[]) { + if (!isRecord(entry) || !isSshGrantFingerprint(entry.fingerprint)) return null; + if (!isStr(entry.comment) || entry.comment.length > SSH_NAME_MAX_CHARS) return null; + identities.push({ fingerprint: entry.fingerprint, comment: entry.comment }); + } + return { identities }; +} + /** * Validates and narrows an `ssh_register_identity` command's `result{data}` * (spec 2026-10-08 §4.3). Deliberately shallow: the grammar proves the field diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index c2731f96a..857213128 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -9,9 +9,11 @@ * connection`, the run start/status/read/cancel quartet, `ssh_terminal_ * launch`, `ssh_input_control`) retired with that destination-execution * product, which never shipped; their `type` arms are therefore deleted, not - * refused. The sealed agent-relay milestone (M2) adds three commands here: + * refused. The sealed agent-relay milestone (M2) adds four commands here: * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node's signing key reaches - * the plane inside a signed command on the existing node link), and the + * the plane inside a signed command on the existing node link), the roster + * read `ssh_agent_identities` (§5.4: the approval screen asks A's agent for + * its public identities, fingerprints plus comments, blobs withheld), and the * brokered pair `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane's OPEN * hands one machine the whole pairing, the CLOSE names why it ends). What * rides the link AFTER an open - the sealed blobs themselves - is not a @@ -64,6 +66,20 @@ export interface SshRegisterIdentityCommand { type: "ssh_register_identity"; } +/** + * Enumerate the key home's LIVE agent identities for the first-use approval + * screen (spec 2026-10-08 §5.4, Task 11): the plane asks A's agent for its + * WHOLE public roster, and the answer is fingerprints plus comments with the + * blobs withheld. No input beyond the type: the command asks the roster, so a + * plane-sent selection field could only be a narrowing or widening attempt at + * what A's own agent reports. It is answered with no grant outstanding and no + * relay session, like `detect` and `ssh_register_identity`, and it writes no + * audit row on either side. + */ +export interface SshAgentIdentitiesCommand { + type: "ssh_agent_identities"; +} + /** * Which side of a relay pairing a machine is (spec 2026-10-08 §2): A is the * key home whose agent signs, B is the connecting machine that holds none. @@ -170,6 +186,7 @@ export type SshNodeCommandBody = | SshDiscoverAliasesCommand | SshResolveConfigCommand | SshRegisterIdentityCommand + | SshAgentIdentitiesCommand | SshRelayOpenCommand | SshRelayCloseCommand; @@ -178,6 +195,7 @@ export const SSH_COMMAND_TYPES = [ "ssh_discover_aliases", "ssh_resolve_config", "ssh_register_identity", + "ssh_agent_identities", "ssh_relay_open", "ssh_relay_close", ] as const; @@ -212,6 +230,18 @@ function isIdStr(value: unknown): value is string { */ const GRANT_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9_-]{1,128}$/; +/** + * Whether `value` is ONE fingerprint in the canonical display grammar. This is + * the roster answer's grammar too, deliberately the SAME predicate: the + * approve surface promises selections "exactly as the roster reports them", + * so a fingerprint the roster may carry and a grant may not select would be + * one broken round trip invented by drift. Exported for `node-results.ts`'s + * roster validator. + */ +export function isSshGrantFingerprint(value: unknown): value is string { + return isStr(value) && GRANT_FINGERPRINT_RE.test(value); +} + /** * The pane id the relay-open command names: exactly the shape pane-runtime's * socket-path composition accepts (`buildSshConfigPath`'s own guard - the @@ -242,9 +272,7 @@ export function isSshPaneId(value: unknown): value is string { * (one definition of the grant-selection law, both directions). */ export function isSshGrantFingerprints(value: unknown): value is string[] { - return ( - isStrArray(value) && value.every((f) => GRANT_FINGERPRINT_RE.test(f)) && value.length <= SSH_MAX_GRANT_FINGERPRINTS - ); + return isStrArray(value) && value.every(isSshGrantFingerprint) && value.length <= SSH_MAX_GRANT_FINGERPRINTS; } /** @@ -268,6 +296,11 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu // The whole command is its type: the machine answers about ITSELF, and // the answer's shape is `node-results.ts`'s business, not this grammar's. return { type: "ssh_register_identity" }; + case "ssh_agent_identities": + // The roster read is its type alone, for the same reason: the command + // asks the WHOLE roster, so a plane-sent field could only aim at + // narrowing or widening what A's agent actually reports. + return { type: "ssh_agent_identities" }; case "ssh_relay_open": { // EVERY pairing field is required (§5.1): this command IS the pairing, // and an executor guessing a missing half would be guessing trust diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts index f86027ea5..daef58083 100644 --- a/packages/subshell-protocol/src/ssh-results.ts +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -2,11 +2,12 @@ import type { SshConnectionSnapshotWire } from "./ssh-config.js"; import type { SshErrorCode } from "./ssh-errors.js"; /** - * The SSH result ENVELOPE types - what the two surviving `ssh_*` discovery - * commands' `result{data}` carries (the Gate A split from `ssh-frames.ts`; - * exported NAMES unchanged). The test/run/control envelopes retired with the - * destination product (design 2026-10-05 §7). Their `parse*` validators - * (`parseNodeSshAliasList`, `parseNodeSshResolveOutcome`) live in + * The SSH result ENVELOPE types - what the `ssh_*` commands' `result{data}` + * carries (the Gate A split from `ssh-frames.ts`; exported NAMES unchanged). + * The test/run/control envelopes retired with the destination product (design + * 2026-10-05 §7); M2 added the identity report and the §5.4 roster. Their + * `parse*` validators (`parseNodeSshAliasList`, `parseNodeSshResolveOutcome`, + * `parseNodeSshIdentity`, `parseNodeSshAgentIdentities`) live in * `node-results.ts` beside every other result validator. * * Imports no `node:` builtin; lives in the Metro-safe barrel. @@ -71,3 +72,29 @@ export interface NodeSshIdentityResult { /** JSON-serialized public JWK (P-256 / ES256) - never the private half. */ signingPublicKey: string; } + +/** + * One entry of the `ssh_agent_identities` answer (spec 2026-10-08 §5.4, + * Task 11): the public identity A's agent carries, spelled so the operator's + * selection can round trip into a grant unchanged. + */ +export interface NodeSshAgentIdentity { + /** The OpenSSH display fingerprint: `SHA256:` + base64url over the agent WIRE encoding of the public blob (the grant grammar's shape, {@link isSshGrantFingerprint}). */ + fingerprint: string; + /** OpenSSH's label for the key, passed through as text; the empty comment is legal. */ + comment: string; +} + +/** + * `ssh_agent_identities` answer (spec 2026-10-08 §5.4): A's agent's WHOLE + * public roster as fingerprints plus comments, with the key BLOBS withheld. + * The shape has no slot for key material by construction: the validator + * rebuilds each entry from its two checked fields, so a `blob` member a buggy + * or hostile node tried to ship is dropped at the grammar, and an empty + * roster from a live agent is a legal answer (distinct from the named error + * an offline or unparseable roster produces, which never reaches this shape). + */ +export interface NodeSshAgentIdentitiesResult { + /** Every public identity A's live agent carries; the approval screen's choice list. */ + identities: NodeSshAgentIdentity[]; +} From db89b0463d3ec6b7ec908ad0eb1f3b8f30b45ff3 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:16:02 -0700 Subject: [PATCH 50/92] feat(ssh-relay): ssh_agent_identities enumerates A's public roster for grant approval Task 11 (spec 2026-10-08 SS5.4): the A-side handler gates on the ssh-enabled mirror, probes the live agent's numbering (never a guessed codepoint), asks the identities request in the resolved scheme, and answers fingerprints + comments with the blob bytes discarded before the validator sees them. No audit row; no agent socket, no resolved scheme, and an unparsable roster each name their refusal so the plane's approval question stays pending. --- .../__tests__/commands-ssh-identity.test.ts | 207 +++++++++++++++++- apps/node/agent/src/commands/index.ts | 8 +- apps/node/agent/src/commands/ssh-identity.ts | 91 +++++++- 3 files changed, 303 insertions(+), 3 deletions(-) diff --git a/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts b/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts index 9d0c5c4fb..234da3d4d 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts @@ -1,11 +1,15 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; import { mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { parseNodeSshIdentity } from "@internal/subshell-protocol"; +import { parseNodeSshAgentIdentities, parseNodeSshIdentity } from "@internal/subshell-protocol"; import type { CommandContext } from "../commands/context.js"; import { dispatchCommand } from "../commands/index.js"; +import { execSshAgentIdentities } from "../commands/ssh-identity.js"; import { loadOrCreateIdentity, signingIdentityPath } from "../identity.js"; +import { type AgentScheme, CLASSIC_SCHEME, OPENSSH_10X_SCHEME } from "../relay-agent-scheme.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; /** * `ssh_register_identity` (spec 2026-10-08 §4.3): the machine answers its @@ -99,3 +103,204 @@ describe("ssh_register_identity", () => { expect(readdirSync(dir)).not.toContain("node-signing-identity.json"); }); }); + +/* ------------------------------------------------------------------ */ +/* ssh_agent_identities (spec 2026-10-08 §5.4, Task 11) */ +/* ------------------------------------------------------------------ */ + +/** + * The roster command: probe A's agent scheme, ask the identities request, + * and answer `{ identities: [{ fingerprint, comment }] }` with the blobs + * WITHHELD. Driven with a scripted requestAgent (the relay-agent-scheme + * test's fixture posture): the probe is real (candidate order and + * positive-confirmation are what the 2026-10-08 ruling pins), only the + * socket is a stand-in. + */ + +const GATE_STAMP = "2026-10-08T10:00:00.000Z"; + +function be32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +function sshStr(bytes: Buffer | string): Buffer { + const b = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes; + return Buffer.concat([be32(b.length), b]); +} + +/** Independent oracle: SHA-256 over the wire blob, base64url (never the production helper). */ +function fp(wire: Uint8Array): string { + return `SHA256:${createHash("sha256").update(wire).digest("base64url")}`; +} + +const KEY_LAPTOP = Buffer.concat([sshStr("ssh-ed25519"), sshStr(Buffer.from("LAPTOP-BLOB"))]); +const KEY_BACKUP = Buffer.concat([sshStr("ssh-rsa"), sshStr(Buffer.from("BACKUP-BLOB"))]); + +function identitiesAnswer(scheme: AgentScheme, entries: { blob: Buffer; comment: string }[]): Buffer { + return Buffer.concat([ + Buffer.from([scheme.answer]), + be32(entries.length), + ...entries.flatMap((e) => [sshStr(e.blob), sshStr(e.comment)]), + ]); +} + +const ROSTER = [ + { blob: KEY_LAPTOP, comment: "laptop key" }, + { blob: KEY_BACKUP, comment: "backup key" }, +]; + +/** A scripted agent: records every payload, answers per the script. */ +function scriptedAgent(script: (payload: Buffer) => Buffer): { + sent: Buffer[]; + requestAgent: (path: string, payload: Buffer) => Promise; +} { + const sent: Buffer[] = []; + return { + sent, + requestAgent: async (_path: string, payload: Buffer): Promise => { + sent.push(Buffer.from(payload)); + return script(payload); + }, + }; +} + +/** Gate-ON context in a fresh dir (the mirror written by its own writer). */ +function gatedDir(tag: string): string { + const dir = join(base, `${tag}-gated`, "data"); + mkdirSync(dir, { recursive: true }); + writeSshEnabled(dir, { on: true, changedAt: GATE_STAMP }); + return dir; +} + +describe("ssh_agent_identities", () => { + it("answers the roster as fingerprints + comments in BOTH schemes, and the blob bytes never ride out", async () => { + for (const [scheme, probeScript] of [ + // A 10.x agent: the classic candidate answers FAILURE(5), byte 11 resolves 10.x. + [ + OPENSSH_10X_SCHEME, + (p: Buffer): Buffer | null => + p.equals(Buffer.from([13])) ? Buffer.from([5]) : identitiesAnswer(OPENSSH_10X_SCHEME, ROSTER), + ], + // A classic agent resolves on the FIRST probe, no second round trip. + [ + CLASSIC_SCHEME, + (p: Buffer): Buffer | null => (p.equals(Buffer.from([13])) ? identitiesAnswer(CLASSIC_SCHEME, ROSTER) : null), + ], + ] as const) { + const agent = scriptedAgent((p) => { + const a = probeScript(p); + if (a === null) throw new Error(`unexpected roster request byte ${p[0]} for ${scheme.name}`); + return a; + }); + const result = await execSshAgentIdentities(makeCtx(gatedDir(scheme.name)), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result.ok).toBe(true); + const parsed = parseNodeSshAgentIdentities(result.ok ? result.data : null); + expect(parsed).toEqual({ + identities: [ + { fingerprint: fp(KEY_LAPTOP), comment: "laptop key" }, + { fingerprint: fp(KEY_BACKUP), comment: "backup key" }, + ], + }); + // The roster request went ONLY as the RESOLVED scheme's identities byte. + const identitiesRequests = agent.sent.filter((p) => p.length === 1 && p[0] === scheme.identities); + expect(identitiesRequests.length).toBe(2); // probe (positive confirmation) + roster ask + if (scheme === CLASSIC_SCHEME) expect(agent.sent.length).toBe(2); // no second candidate was ever sent + // Blobs withheld: no spelling of either blob appears in the serialized frame. + const wire = JSON.stringify(result); + for (const blob of [KEY_LAPTOP, KEY_BACKUP]) { + expect(wire).not.toContain(blob.toString("base64")); + expect(wire).not.toContain(blob.toString("base64url")); + } + expect(wire).not.toContain("LAPTOP-BLOB"); + expect(wire).not.toContain("BACKUP-BLOB"); + } + }); + + it("an empty roster from a live agent is the honest answer, not an error", async () => { + const agent = scriptedAgent((p) => + p.equals(Buffer.from([13])) + ? identitiesAnswer(CLASSIC_SCHEME, []) + : (() => { + throw new Error("never"); + })(), + ); + const result = await execSshAgentIdentities(makeCtx(gatedDir("empty")), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result.ok && result.data).toEqual({ identities: [] }); + }); + + it("the gate speaks FIRST: an unmirrored machine refuses before the socket or the agent", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + const dir = join(base, "roster-gate-off", "data"); + mkdirSync(dir, { recursive: true }); // deliberately no mirror: fail-closed OFF + const result = await execSshAgentIdentities(makeCtx(dir), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result).toEqual({ ok: false, error: "ssh disabled on this node" }); + expect(agent.sent).toEqual([]); + }); + + it("no live agent socket is a named error and the agent is never asked", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + const result = await execSshAgentIdentities(makeCtx(gatedDir("no-sock")), { + resolveAgentSocket: () => null, + requestAgent: agent.requestAgent, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("agent socket"); + expect(agent.sent).toEqual([]); + }); + + it("an agent that resolves NO scheme is refused by name; nothing is ever asked under a guess", async () => { + const agent = scriptedAgent(() => Buffer.from([5])); + const result = await execSshAgentIdentities(makeCtx(gatedDir("no-scheme")), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("numbering"); + expect(agent.sent).toEqual([Buffer.from([13]), Buffer.from([11])]); // both candidates probed, then refusal + }); + + it("a roster answer that does not parse is refused, never partially answered", async () => { + // The probe (first byte-11 ask) resolves 10.x positively; the SECOND + // byte-11 ask is the roster read, and THAT answer comes back truncated. + let elevenAsks = 0; + const agent = scriptedAgent((p) => { + if (p.equals(Buffer.from([13]))) return Buffer.from([5]); // the classic candidate fails + elevenAsks += 1; + if (elevenAsks === 1) return identitiesAnswer(OPENSSH_10X_SCHEME, ROSTER); // probe: valid, resolves + return Buffer.from([12, 0, 0, 0, 5, ...sshStr(KEY_LAPTOP).subarray(0, 3)]); // roster: truncated + }); + const result = await execSshAgentIdentities(makeCtx(gatedDir("malformed")), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: agent.requestAgent, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toInclude("did not parse"); + }); + + it("dispatch routes the arm: a gate-ON machine with no SSH_AUTH_SOCK answers the handler's own named error, not `unsupported`", async () => { + const saved = process.env.SSH_AUTH_SOCK; + process.env.SSH_AUTH_SOCK = ""; // the honest no-agent spelling liveAgentSocketPath must refuse + try { + const result = await dispatchCommand(makeCtx(gatedDir("route")), { type: "ssh_agent_identities" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toInclude("agent socket"); + expect(result.error).not.toBe("unsupported"); + } + } finally { + if (saved === undefined) delete process.env.SSH_AUTH_SOCK; + else process.env.SSH_AUTH_SOCK = saved; + } + }); +}); diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 82df78cf6..ed4a45ca3 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -29,7 +29,7 @@ import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; -import { execSshRegisterIdentity } from "./ssh-identity.js"; +import { execSshAgentIdentities, execSshRegisterIdentity } from "./ssh-identity.js"; import { execSshRelayClose, execSshRelayOpen } from "./ssh-relay-exec.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; import { execTransferWrite } from "./transfer-write.js"; @@ -58,6 +58,10 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * local gate mirror before any lookup or spawn), the M2 §4.3 bootstrap * `ssh_register_identity` (spec 2026-10-08 §4.3 - the machine's own signing * PUBLIC key, ungated like `set_ssh_enabled`: registration is not an SSH act), + * the roster read `ssh_agent_identities` (spec 2026-10-08 §5.4 - the live + * agent's public identities as fingerprints plus comments, blobs withheld; + * gated like the config arms, because reading the account's agent IS an SSH + * act, and it probes the agent's numbering before asking, never guessing), * and the brokered relay pair `ssh_relay_open` / `ssh_relay_close` * (spec 2026-10-08 §5.1, protocol 18 - open consults the gate then drives the * T6/T7 pairing branches by the command's own role; close is ungated teardown, @@ -145,6 +149,8 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return await execSshResolveConfig(ctx, cmd); case "ssh_register_identity": return await execSshRegisterIdentity(ctx); + case "ssh_agent_identities": + return await execSshAgentIdentities(ctx); case "ssh_relay_open": return await execSshRelayOpen(ctx, cmd); case "ssh_relay_close": diff --git a/apps/node/agent/src/commands/ssh-identity.ts b/apps/node/agent/src/commands/ssh-identity.ts index 4290a55de..d1d27616a 100644 --- a/apps/node/agent/src/commands/ssh-identity.ts +++ b/apps/node/agent/src/commands/ssh-identity.ts @@ -1,6 +1,11 @@ -import { type JsonValue, parseNodeSshIdentity } from "@internal/subshell-protocol"; +import { type JsonValue, parseNodeSshAgentIdentities, parseNodeSshIdentity } from "@internal/subshell-protocol"; import { loadOrCreateIdentity } from "../identity.js"; +import type { AgentIdentity, AgentRequestFn } from "../relay-agent-scheme.js"; +import { fingerprintAgentBlob, parseIdentitiesAnswer, probeAgentScheme } from "../relay-agent-scheme.js"; +import { liveAgentSocketPath, requestLiveAgent } from "../relay-agent-socket.js"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; import type { CommandContext, CommandResult } from "./context.js"; +import { SSH_GATE_REFUSAL } from "./ssh-shared.js"; /** * `ssh_register_identity` (spec 2026-10-08 §4.3): the machine reports its @@ -31,3 +36,87 @@ export async function execSshRegisterIdentity(ctx: CommandContext): Promise string | null; + /** One framed agent round trip; defaults to the real socket client. */ + requestAgent?: AgentRequestFn; +} + +/** + * `ssh_agent_identities` (spec 2026-10-08 §5.4, Task 11): enumerate this + * machine's LIVE agent public identities for the first-use approval screen, + * as `{ identities: [{ fingerprint, comment }] }` with the key BLOBS + * withheld - the fingerprints are the grant grammar's own `SHA256:` spelling + * (over the agent WIRE encoding, {@link fingerprintAgentBlob}), so whatever + * the operator selects round trips into a grant verbatim. + * + * The three rules this arm keeps: + * - **The gate speaks first** (the config arms' doctrine): this reads the + * connecting account's SSH agent, so it is an SSH act, and a machine whose + * mirror is not ON refuses {@link SSH_GATE_REFUSAL} before any socket is + * consulted. Unlike {@link execSshRegisterIdentity} - the machine's own + * registration - there is no pre-SSH reason to ask a roster. + * - **The scheme is probed, never assumed** (ruling 2026-10-08): the + * identities codepoint depends on which numbering the live agent speaks, + * so {@link probeAgentScheme} runs against the same socket with the same + * positive-confirmation rule as the relay responder, and an agent that + * resolves nothing is refused by name rather than asked under a guess. + * - **Fail closed, never fabricate**: no socket, an unresolvable scheme, a + * refused or unparsable roster each answer `ok:false` with a named error. + * An EMPTY roster from a live agent is the honest answer (`{identities:[]}`) + * - it is A reporting zero keys, which is a fact; the plane's offline path + * keeps the grant request pending precisely so a fabrication cannot read as + * one. No audit row is written here, on either side, and no blob byte + * reaches the result frame, a log line, or the answer's JSON. + * + * @param ctx - the per-daemon execution context (data dir for the gate mirror) + * @param seams - test-only socket/round-trip seams + * @returns `{ ok: true, data: { identities } }`, or the named refusal + */ +export async function execSshAgentIdentities( + ctx: CommandContext, + seams?: AgentIdentitiesSeams, +): Promise { + if (!sshAllowed(readSshEnabled(ctx.config.dataDir))) return { ok: false, error: SSH_GATE_REFUSAL }; + const socketPath = (seams?.resolveAgentSocket ?? liveAgentSocketPath)(); + if (socketPath === null) { + return { ok: false, error: "no live agent socket (SSH_AUTH_SOCK unset): cannot read the roster" }; + } + const requestAgent = seams?.requestAgent ?? requestLiveAgent; + const scheme = await probeAgentScheme(socketPath, requestAgent); + if (scheme === null) { + return { + ok: false, + error: "cannot resolve the agent's identities numbering: the roster read refuses rather than guess", + }; + } + let answer: Buffer; + try { + answer = await requestAgent(socketPath, Buffer.from([scheme.identities])); + } catch (err) { + return { ok: false, error: `the agent roster request failed: ${err instanceof Error ? err.message : String(err)}` }; + } + let entries: AgentIdentity[]; + try { + entries = parseIdentitiesAnswer(answer, scheme); + } catch { + // A FAILURE answer, a foreign scheme's byte, or a truncated roster: the + // partial-parse risk §5.4 refuses. The plane's caller keeps the approval + // question pending on this error, which is the fail-closed half. + return { ok: false, error: "the agent's IDENTITIES_ANSWER did not parse; refusing to answer a partial roster" }; + } + // The blobs are discarded HERE, before any validator sees the answer: the + // result frame structurally cannot carry one. + const validated = parseNodeSshAgentIdentities({ + identities: entries.map((entry) => ({ + fingerprint: fingerprintAgentBlob(entry.blob), + comment: entry.comment.toString("utf8"), + })), + }); + if (validated === null) return { ok: false, error: "malformed agent roster" }; + // The seam cast: JSON-safe by construction, contract owned by `node-results.ts`. + return { ok: true, data: validated as unknown as JsonValue }; +} From 1810b4bdb2e428793fe419af2a5daf56abafa093 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:25:10 -0700 Subject: [PATCH 51/92] feat(ssh-grants): the approvals roster fetch - ssh_agent_identities over the signed RPC Task 11 plane half: sshAgentIdentities wraps the frozen roster parser on the existing ssh-rpc path, listRequestAgentIdentities reads a pending request's key home through it (seam-injected, production default the RPC), and GET /api/ssh/grant-requests/:id/identities renders the choice list for the approval screen. The read writes no audit row and touches no row: offline, outdated, timeout, or refusal answers the named error with the request left pending; an answered request never reaches the machine. --- .../api/ssh/__tests__/grants-route.test.ts | 133 +++++++++- .../server/api/src/api/ssh/approvals.route.ts | 52 +++- .../__tests__/ssh-agent-identities.test.ts | 249 ++++++++++++++++++ apps/server/api/src/services/nodes/ssh-rpc.ts | 32 ++- .../api/src/services/ssh-grants.service.ts | 97 ++++++- 5 files changed, 553 insertions(+), 10 deletions(-) create mode 100644 apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts diff --git a/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts b/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts index 1b5e70973..c6c44b589 100644 --- a/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts +++ b/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts @@ -1,8 +1,8 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { BackendErrorCodes } from "@internal/backend-errors"; -import { SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; +import { type NodeSshAgentIdentitiesResult, SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; import { hashPassword } from "better-auth/crypto"; import { Elysia } from "elysia"; import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; @@ -16,6 +16,7 @@ import { UsersRepository } from "@/db/repositories/users.repository.js"; import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; import { createGrant, requestFirstUse, setSshGrantsDepsForTests } from "@/services/ssh-grants.service.js"; import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service.js"; import { issueSubshellToken } from "@/services/subshell-tokens.js"; @@ -72,6 +73,11 @@ let openCalls: { grantId: string; fingerprints: string[]; paneId: string; aNode: let closeGrantCalls: { grantId: string; reason: string }[] = []; let notifyCalls: string[] = []; let openRelayThrows: ((input: unknown) => never) | null = null; +/** Scripted roster RPC (Task 11): a canned answer or a canned failure, plus the call log. */ +let rosterAnswer: () => NodeSshAgentIdentitiesResult = () => ({ + identities: [{ fingerprint: `SHA256:${"D".repeat(43)}`, comment: "work laptop" }], +}); +let rosterCalls: string[] = []; function installFakeDeps() { const broker = { @@ -89,6 +95,10 @@ function installFakeDeps() { nowIso: () => new Date().toISOString(), broker: () => broker, notifyGrantApproval: (_owner, requestId) => notifyCalls.push(requestId), + fetchAgentIdentities: async (nodeId: string) => { + rosterCalls.push(nodeId); + return rosterAnswer(); + }, }); } @@ -568,3 +578,122 @@ describe("POST /api/ssh/launch in relay mode (the grant-gated relay leg)", () => } }); }); + +/* ------------------------------------------------------------------ */ +/* GET /api/ssh/grant-requests/:id/identities (Task 11 roster fetch) */ +/* ------------------------------------------------------------------ */ + +describe("GET /api/ssh/grant-requests/:id/identities (the roster behind the approval screen)", () => { + async function mkAsk(paneId: string): Promise { + const asked = await requestFirstUse({ + ownerUserId: ownerId, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: "git.example.test", + paneId, + }); + if (!asked.ok) throw new Error("fixture ask refused"); + return asked.value.requestId; + } + async function queueStatus(id: string): Promise { + const body = (await (await sshFetch("/api/ssh/grant-requests", { cookie: ownerCookie })).json()) as { + requests: { id: string; status: string }[]; + }; + return body.requests.find((r) => r.id === id)?.status; + } + + beforeEach(() => { + installFakeDeps(); // reset the scripted roster RPC and its call log between cases + rosterCalls = []; + }); + + it("answers the key home's public roster, blobs withheld, and the queue row is untouched", async () => { + const requestId = await mkAsk("pane-roster-1"); + rosterAnswer = () => ({ + identities: [ + { fingerprint: `SHA256:${"D".repeat(43)}`, comment: "work laptop" }, + { fingerprint: `SHA256:${"E".repeat(43)}`, comment: "" }, + ], + }); + const res = await sshFetch(`/api/ssh/grant-requests/${requestId}/identities`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = (await res.json()) as { identities: Record[] }; + expect(body.identities.map((e) => e.fingerprint)).toEqual([`SHA256:${"D".repeat(43)}`, `SHA256:${"E".repeat(43)}`]); + expect(body.identities[0]?.comment).toBe("work laptop"); + expect(body.identities[1]?.comment).toBe(""); // the agent's empty comment is data, not a gap + // The entry shape is exactly the two public fields: nowhere for a blob to ride. + for (const entry of body.identities) expect(Object.keys(entry).sort()).toEqual(["comment", "fingerprint"]); + expect(JSON.stringify(body)).not.toContain("blob"); + expect(rosterCalls).toEqual([NODE_A]); // asked the KEY HOME, exactly once + expect(await queueStatus(requestId)).toBe("pending"); // the read changed nothing + }); + + it("the roster rides the REAL signed RPC: scripted A answers over the link and the command that left was its type alone", async () => { + // Deps WITHOUT the roster seam: production wiring is under test, so the + // command passes through signing, the RPC correlator, and the frozen + // result parser exactly as it does between the shipped processes. + const scripted = attachScriptedNode(NODE_A, { + ssh_agent_identities: () => ({ + identities: [{ fingerprint: `SHA256:${"F".repeat(43)}`, comment: "real path key" }], + }), + }); + setSshGrantsDepsForTests({ + nowIso: () => new Date().toISOString(), + broker: () => + ({ + openRelay: async () => { + throw new Error("the roster fetch opens no relay"); + }, + closeForGrant: async () => 0, + }) as unknown as RelayBroker, + notifyGrantApproval: () => {}, + }); + const requestId = await mkAsk("pane-roster-e2e"); + try { + const res = await sshFetch(`/api/ssh/grant-requests/${requestId}/identities`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = (await res.json()) as { identities: { fingerprint: string; comment: string }[] }; + expect(body.identities).toEqual([{ fingerprint: `SHA256:${"F".repeat(43)}`, comment: "real path key" }]); + // Its type ALONE travelled the link: the roster command asks the whole + // roster, and no plane-side selection field reached the machine. + expect(scripted.cmdsOf("ssh_agent_identities")).toEqual([{ type: "ssh_agent_identities" }]); + } finally { + scripted.detach(); + installFakeDeps(); + } + }); + + it("an unreachable key home answers the named refusal with the request PENDING; an answered one is never re-asked", async () => { + const requestId = await mkAsk("pane-roster-offline"); + rosterAnswer = () => { + throw new SshRpcError("offline", "node has no live connection", NODE_A); + }; + const offline = await sshFetch(`/api/ssh/grant-requests/${requestId}/identities`, { cookie: ownerCookie }); + expect(offline.status).toBe(409); + expect(((await offline.json()) as { code: string }).code).toBe(BackendErrorCodes.NODE_OFFLINE); + expect(await queueStatus(requestId)).toBe("pending"); // §5.4: stays pending until A is reachable + const approved = await sshFetch(`/api/ssh/grant-requests/${requestId}/approve`, { + method: "POST", + cookie: ownerCookie, + body: { fingerprints: [`SHA256:${"D".repeat(43)}`] }, + }); + expect(approved.status).toBe(200); + rosterCalls = []; + rosterAnswer = () => { + throw new Error("the machine must not be asked after the answer"); + }; + const after = await sshFetch(`/api/ssh/grant-requests/${requestId}/identities`, { cookie: ownerCookie }); + expect(after.status).toBe(409); + expect(((await after.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GRANT_ALREADY_ANSWERED); + expect(rosterCalls).toEqual([]); + }); + + it("cookie doctrine and ownership: an empty cookie is a 401, a stranger's cookie sees the absent-row 404, and no machine is asked", async () => { + const requestId = await mkAsk("pane-roster-stranger"); + rosterCalls = []; + expect((await sshFetch(`/api/ssh/grant-requests/${requestId}/identities`, { cookie: "" })).status).toBe(401); + const foreign = await sshFetch(`/api/ssh/grant-requests/${requestId}/identities`, { cookie: otherCookie }); + expect(foreign.status).toBe(404); + expect(rosterCalls).toEqual([]); + }); +}); diff --git a/apps/server/api/src/api/ssh/approvals.route.ts b/apps/server/api/src/api/ssh/approvals.route.ts index 4fc30ee96..f7bba50f5 100644 --- a/apps/server/api/src/api/ssh/approvals.route.ts +++ b/apps/server/api/src/api/ssh/approvals.route.ts @@ -3,13 +3,19 @@ import { Elysia, t } from "elysia"; import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; import { SshGrantRequestViewSchema, SshGrantViewSchema, throwCodedRefusal } from "@/api/ssh/ssh-views.js"; import { apiModels } from "@/schema/index.js"; -import { approveGrant, denyGrant, listGrantRequests } from "@/services/ssh-grants.service.js"; +import { + approveGrant, + denyGrant, + listGrantRequests, + listRequestAgentIdentities, +} from "@/services/ssh-grants.service.js"; /** * `/api/ssh/grant-requests` - the first-use approval queue (spec 2026-10-08 * §6.2, modeled on the signup pending-approvals queue): GET the owner's - * pending questions, POST `/:id/approve` (selects the fingerprints and writes - * the standing grant), POST `/:id/deny` (an answer, no grant). + * pending questions, GET `/:id/identities` (the key home's public agent + * roster to choose FROM, §5.4), POST `/:id/approve` (selects the fingerprints + * and writes the standing grant), POST `/:id/deny` (an answer, no grant). * * The actor model is decision 5 stated concretely: rows are owned by the key * home's owner, the asking launch could only have gated A through @@ -48,6 +54,46 @@ export const sshGrantRequestsRoutes = new Elysia() }, }, ) + .get( + "/grant-requests/:id/identities", + async ({ params, user, actor }) => { + requireCookieActor(actor, "SSH grant approvals are restricted to browser sessions"); + const answer = await listRequestAgentIdentities({ ownerUserId: user.id, requestId: params.id }); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return { identities: answer.value.identities }; + }, + { + params: t.Object({ id: t.String({ description: "Request row id" }) }), + response: { + 200: t.Object({ + identities: t.Array( + t.Object({ + fingerprint: t.String({ + description: + "Public agent identity in the canonical SHA256: notation (the approval body sends these back verbatim)", + }), + comment: t.String({ description: "OpenSSH's label for the key, as the agent reports it (display only)" }), + }), + { + description: + "The key home's WHOLE public roster with the key blobs withheld; the operator selects the grant's subset (cap enforced at approval, never by truncating this list)", + }, + ), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "listSshGrantRequestAgentIdentities", + tags: ["ssh"], + description: + "Enumerates the key home's live agent identities for the approval screen (fingerprints plus comments, blobs withheld); an offline or refusing key home answers a named error and the request stays pending, never a fabricated empty roster", + }, + }, + ) .post( "/grant-requests/:id/approve", async ({ body, params, user, actor }) => { diff --git a/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts b/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts new file mode 100644 index 000000000..40e29dc26 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts @@ -0,0 +1,249 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import type { NodeSshAgentIdentitiesResult } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SshGrantsRepository } from "@/db/repositories/ssh-grants.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; +import { + approveGrant, + listRequestAgentIdentities, + requestFirstUse, + setSshGrantsDepsForTests, +} from "@/services/ssh-grants.service.js"; +import type { RelayBroker } from "@/services/ssh-relay.service.js"; + +/** + * The roster fetch behind the approvals screen (spec 2026-10-08 §5.4, Task + * 11): `listRequestAgentIdentities` sends A the signed `ssh_agent_identities` + * command over the existing node-link RPC and hands the operator's screen + * the WHOLE public roster - fingerprints plus comments, blobs withheld (the + * protocol validator makes them unrepresentable; this suite pins the pass- + * through adds nothing). What the approval screen must never lose: + * + * - NOTHING rides a roster read: no audit row, no row change, no grant. The + * fetch is a question to the machine, and the durable record of an answer + * stays the approve/deny pair. + * - A is offline / refused / too old / silent: the named refusal answers and + * the grant request STAYS PENDING. Never an empty fabricated roster, which + * is exactly the fiction §5.4 forbids - a real empty roster from a live + * agent is data (last case), the offline case is an error. + * - Ownership: a foreign request is the 404 the grants tier already answers; + * an answered request never reaches the machine (no roster read after the + * operator has said yes or no); expiry sweeps lazily first, like every + * other read on this table. + * + * The roster RPC is the service's injected seam (the clock/broker/notify + * trio's fourth member, the `??` at the call site is the production wiring); + * nothing here dials a socket. + */ + +const repo = new SshGrantsRepository(db); +const emails: string[] = []; + +async function mkUser(tag: string): Promise { + const email = `roster-svc-${tag}-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("roster-svc-1"), + role: "user", + }); +} + +let owner: string; +let stranger: string; +const NODE_A = "roster-node-a"; +const NODE_B = "roster-node-b"; + +const HOST = "git.example.test"; +const PANE = "11111111-2222-4333-8444-555555555555"; + +let clockMs = Date.parse("2026-10-08T00:00:00.000Z"); +const nowIso = () => new Date(clockMs).toISOString(); + +/** The scripted roster RPC: a canned answer, or a canned transport failure. */ +let rosterResult: NodeSshAgentIdentitiesResult = { identities: [] }; +let rosterFailure: (() => Error) | null = null; +let rosterCalls: string[] = []; + +function installRosterDeps(): void { + rosterResult = { + identities: [{ fingerprint: `SHA256:${"A".repeat(43)}`, comment: "laptop key" }], + }; + rosterFailure = null; + rosterCalls = []; + setSshGrantsDepsForTests({ + nowIso, + broker: () => + ({ + openRelay: async () => { + throw new Error("the roster fetch opens no relay"); + }, + closeForGrant: async () => 0, + }) as unknown as RelayBroker, + notifyGrantApproval: () => {}, + fetchAgentIdentities: async (nodeId: string): Promise => { + rosterCalls.push(nodeId); + if (rosterFailure) throw rosterFailure(); + return rosterResult; + }, + }); +} + +async function mkPendingRequest(): Promise { + const answer = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + paneId: PANE, + }); + if (!answer.ok) throw new Error("the pending row was not created"); + return answer.value.requestId; +} + +async function auditCount(): Promise { + return (await db.selectFrom("auditEvents").select("id").execute()).length; +} + +async function requestStatus(requestId: string): Promise { + return (await repo.getRequest(owner, requestId))?.status; +} + +beforeAll(async () => { + await setupAuthTables(); + await ensureMigratedTestDb(); + owner = await mkUser("owner"); + stranger = await mkUser("stranger"); + const nodes = new NodesRepository(db); + await nodes.create({ id: NODE_A, ownerUserId: owner, name: "key home", kind: "agent", status: "offline" }); + await nodes.setSshEnabled(NODE_A, { on: true, changedAt: nowIso() }); + await nodes.create({ id: NODE_B, ownerUserId: owner, name: "connector", kind: "agent", status: "offline" }); + await nodes.setSshEnabled(NODE_B, { on: true, changedAt: nowIso() }); +}); + +afterAll(async () => { + setSshGrantsDepsForTests(null); + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("auditEvents").execute(); + for (const id of [NODE_A, NODE_B]) await new NodesRepository(db).deleteById(id).catch(() => {}); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +beforeEach(async () => { + clockMs = Date.parse("2026-10-08T00:00:00.000Z"); + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("auditEvents").execute(); + installRosterDeps(); +}); + +describe("listRequestAgentIdentities", () => { + it("hands the screen the roster verbatim, asked the KEY HOME once, and writes nothing", async () => { + const requestId = await mkPendingRequest(); + const auditsBefore = await auditCount(); // the request itself audited the ask + const answer = await listRequestAgentIdentities({ ownerUserId: owner, requestId }); + expect(answer.ok).toBe(true); + if (!answer.ok) return; + expect(answer.value.identities).toEqual(rosterResult.identities); + // The entry shape is EXACTLY fingerprint + comment: no blob, no extra field, + // nowhere for key material to ride (the protocol grammar refuses one, and + // this is the pass-through that must not re-add it). + for (const entry of answer.value.identities) { + expect(Object.keys(entry).sort()).toEqual(["comment", "fingerprint"]); + } + expect(JSON.stringify(answer.value)).not.toContain("blob"); + expect(rosterCalls).toEqual([NODE_A]); + // No audit row, no row change: the fetch is a question, not an answer. + expect(await auditCount()).toBe(auditsBefore); + expect(await requestStatus(requestId)).toBe("pending"); + }); + + it("an offline A is the named 409 NODE_OFFLINE and the request STAYS PENDING", async () => { + const requestId = await mkPendingRequest(); + const auditsBefore = await auditCount(); + rosterFailure = () => new SshRpcError("offline", "node has no live connection", NODE_A); + const answer = await listRequestAgentIdentities({ ownerUserId: owner, requestId }); + expect(answer.ok).toBe(false); + if (answer.ok) return; + expect(answer.refusal.status).toBe(409); + expect(answer.refusal.code).toBe(BackendErrorCodes.NODE_OFFLINE); + expect(rosterCalls).toEqual([NODE_A]); + expect(await auditCount()).toBe(auditsBefore); + expect(await requestStatus(requestId)).toBe("pending"); + }); + + it("the failure families: unsupported is the update remedy, timeout the retry, refusal the 502, and none fabricates a roster", async () => { + for (const [kind, status, code] of [ + ["unsupported", 409, BackendErrorCodes.NODE_OUTDATED], + ["timeout", 409, BackendErrorCodes.NODE_UNREACHABLE], + ["refused", 502, BackendErrorCodes.SSH_NODE_REFUSED], + ["malformed", 502, BackendErrorCodes.SSH_NODE_REFUSED], + ] as const) { + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("auditEvents").execute(); + const requestId = await mkPendingRequest(); + rosterFailure = () => new SshRpcError(kind, `machine said no (${kind})`, NODE_A, "ssh disabled on this node"); + const answer = await listRequestAgentIdentities({ ownerUserId: owner, requestId }); + expect(answer.ok).toBe(false); + if (answer.ok) return; + expect(answer.refusal.status).toBe(status); + expect(answer.refusal.code).toBe(code); + // The agent's own text is never echoed into a response body. + expect(answer.refusal.message).not.toContain("ssh disabled"); + expect(await requestStatus(requestId)).toBe("pending"); + expect(await auditCount()).toBe(1); // the request's own ask row, and nothing from the fetch + } + }); + + it("an already-approved request is the named 409 and the machine is never asked again", async () => { + const requestId = await mkPendingRequest(); + const approved = await approveGrant({ ownerUserId: owner, requestId, fingerprints: [`SHA256:${"C".repeat(43)}`] }); + expect(approved.ok).toBe(true); + rosterCalls = []; + const answer = await listRequestAgentIdentities({ ownerUserId: owner, requestId }); + expect(answer.ok).toBe(false); + if (answer.ok) return; + expect(answer.refusal.code).toBe(BackendErrorCodes.SSH_GRANT_ALREADY_ANSWERED); + expect(rosterCalls).toEqual([]); + }); + + it("a foreign or absent request id is the same 404 the grants tier answers, and no machine is asked", async () => { + const requestId = await mkPendingRequest(); + const foreign = await listRequestAgentIdentities({ ownerUserId: stranger, requestId }); + expect(foreign.ok).toBe(false); + if (foreign.ok) return; + expect(foreign.refusal.status).toBe(404); + const absent = await listRequestAgentIdentities({ ownerUserId: owner, requestId: crypto.randomUUID() }); + expect(absent.ok).toBe(false); + expect(rosterCalls).toEqual([]); + }); + + it("a past-deadline pending row is swept before the read: the question is expired, the roster never fetched", async () => { + const requestId = await mkPendingRequest(); + clockMs += 24 * 60 * 60 * 1000 + 1; // past the 24 h answer window + const answer = await listRequestAgentIdentities({ ownerUserId: owner, requestId }); + expect(answer.ok).toBe(false); + if (answer.ok) return; + expect(answer.refusal.code).toBe(BackendErrorCodes.SSH_GRANT_ALREADY_ANSWERED); + expect(await requestStatus(requestId)).toBe("expired"); + expect(rosterCalls).toEqual([]); + }); + + it("an empty roster from a live agent is an honest answer, not an error", async () => { + const requestId = await mkPendingRequest(); + rosterResult = { identities: [] }; + const answer = await listRequestAgentIdentities({ ownerUserId: owner, requestId }); + expect(answer.ok).toBe(true); + if (!answer.ok) return; + expect(answer.value.identities).toEqual([]); + expect(rosterCalls).toEqual([NODE_A]); + }); +}); diff --git a/apps/server/api/src/services/nodes/ssh-rpc.ts b/apps/server/api/src/services/nodes/ssh-rpc.ts index 5e8f63264..b00499089 100644 --- a/apps/server/api/src/services/nodes/ssh-rpc.ts +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -1,6 +1,8 @@ import { + type NodeSshAgentIdentitiesResult, type NodeSshAliasListResult, type NodeSshResolveOutcomeWire, + parseNodeSshAgentIdentities, parseNodeSshAliasList, parseNodeSshIdentity, parseNodeSshResolveOutcome, @@ -9,8 +11,9 @@ import { NodeRpcError, sendCommand } from "@/services/nodes/node-rpc.js"; /** * The ssh commands as plane-side RPC wrappers (spec 2026-10-07 §5 for the - * discovery/resolve pair; spec 2026-10-08 §4.3 for the identity bootstrap). - * Each is `sendCommand` plus ITS frozen result parser: + * discovery/resolve pair; spec 2026-10-08 §4.3 for the identity bootstrap and + * the §5.4 agent roster the approval screen enumerates). Each is `sendCommand` + * plus ITS frozen result parser: * the value these return is a validated plain object (or the function throws), * so no caller can branch on a machine answer it has not parsed. * @@ -122,3 +125,28 @@ export async function sshRegisterIdentity(nodeId: string): Promise { } return parsed.signingPublicKey; } + +/** + * Ask one machine for its LIVE agent's public roster (spec 2026-10-08 §5.4, + * Task 11): the approval screen's choice list, as the frozen validator + * narrows it - fingerprints plus comments, the blobs already withheld on the + * machine (the grammar cannot carry them). The command asks the WHOLE + * roster, so this wrapper has no input beyond the target: a selection sent + * from the plane would be a decision about A's keys made where A's agent is + * the only witness. + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshAgentIdentities(nodeId: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_agent_identities" }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshAgentIdentities(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed agent roster`, nodeId); + } + return parsed; +} diff --git a/apps/server/api/src/services/ssh-grants.service.ts b/apps/server/api/src/services/ssh-grants.service.ts index be8bbcf24..0711e251b 100644 --- a/apps/server/api/src/services/ssh-grants.service.ts +++ b/apps/server/api/src/services/ssh-grants.service.ts @@ -1,10 +1,17 @@ import { BackendErrorCodes } from "@internal/backend-errors"; -import { isSshGrantFingerprints, SSH_MAX_GRANT_FINGERPRINTS, SSH_NAME_MAX_CHARS } from "@internal/subshell-protocol"; +import { + isSshGrantFingerprints, + type NodeSshAgentIdentitiesResult, + type NodeSshAgentIdentity, + SSH_MAX_GRANT_FINGERPRINTS, + SSH_NAME_MAX_CHARS, +} from "@internal/subshell-protocol"; import { db } from "@/db/index.js"; import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; import { type NewSshGrantRequest, SshGrantsRepository } from "@/db/repositories/ssh-grants.repository.js"; import type { SshGrantCreatedVia, SshGrantRequestStatus, SshKeyGrantTable } from "@/db/types/ssh-grants.db-types.js"; import { type AuditEventInput, audit } from "@/services/audit.js"; +import { SshRpcError, sshAgentIdentities } from "@/services/nodes/ssh-rpc.js"; import { getNotifyService } from "@/services/notify.service.js"; import { getRelayBroker, type RelayBroker, type RelayPeerKeys, SshRelayRefusal } from "@/services/ssh-relay.service.js"; import { logger } from "@/utils/logger.js"; @@ -41,8 +48,9 @@ import { logger } from "@/utils/logger.js"; * file writes; no key material, challenge, or signature ever rides any row. * * Everything outside the module is an injected seam (the `getNodeWsDeps` - * pattern): the clock, the relay broker, and the notification sink. The - * production defaults sit at the bottom; tests replace the whole trio. + * pattern): the clock, the relay broker, the notification sink, and the + * roster RPC the approval screen reads. The production defaults sit at the + * bottom; tests replace the whole set. */ /** How long an unanswered first-use request stays answerable: setup-key scale (24 h). */ @@ -58,6 +66,13 @@ export interface SshGrantsDeps { broker(): RelayBroker; /** Ring the owner about one pending request (fire-and-forget; the sink swallows its own failures). */ notifyGrantApproval(ownerUserId: string, requestId: string): void; + /** + * Ask one machine for its live agent's public roster over the node link + * (default: `ssh-rpc.sshAgentIdentities`, the `??` at the call site being + * the production wiring - the NodeWsDeps.detect shape). A test seam so the + * approval surface can pin fail-closed behavior without a live socket. + */ + fetchAgentIdentities?(nodeId: string): Promise; } let depsOverride: SshGrantsDeps | null = null; @@ -563,6 +578,82 @@ export async function listGrantRequests(args: { })); } +/* ------------------------------------------------------------------ */ +/* the roster fetch: the approval screen's choice list (§5.4, Task 11) */ +/* ------------------------------------------------------------------ */ + +/** + * Read the key home's public agent roster for the approval screen (spec + * 2026-10-08 §5.4): the signed `ssh_agent_identities` command goes to A over + * the existing node-link RPC, and the answer is A's WHOLE roster - the + * operator selects the grant's subset from it, capped at approval, never + * here. The read is a question, not an answer: it writes NO audit row and + * changes no row, and every failure (offline, outdated, timeout, refusal, + * malformed answer) answers the named error with the request left PENDING. + * Fabricating an empty roster would read to the operator as "A holds no + * keys"; an empty roster that is the truth comes from a live agent and rides + * the answer untouched. + */ +export async function listRequestAgentIdentities(args: { + ownerUserId: string; + requestId: string; +}): Promise> { + const now = grantsDeps().nowIso(); + await repo.sweepExpiredRequests(now); + const row = await repo.getRequest(args.ownerUserId, args.requestId); + if (!row) return refused(NOT_FOUND_REQUEST); + if (row.status !== "pending") return refused(ALREADY_ANSWERED); + try { + const fetcher = grantsDeps().fetchAgentIdentities ?? sshAgentIdentities; // the `??` is the production wiring + const roster = await fetcher(row.keyHomeNodeId); + return granted({ identities: roster.identities }); + } catch (err) { + if (err instanceof SshRpcError) return refused(rosterRpcRefusal(err)); + throw err; + } +} + +/** + * The roster RPC's failure as a grant-surface refusal: the same code family + * `ssh-launch`'s `rpcRefusal` uses (they are the same doors on the same + * link), with the KEY HOME named in the copy and the agent's own text kept + * out of the response (it reaches the log on the refused/malformed arm, per + * the files-remote-browse posture). The pending row rides untouched through + * every arm - §5.4's "stays pending until A is reachable" is exactly the + * absence of a write here. + */ +function rosterRpcRefusal(err: SshRpcError): SshRefusalNarrow { + if (err.kind === "offline") { + return { + status: 409, + code: BackendErrorCodes.NODE_OFFLINE, + message: "The key home has no live connection right now; bring its Subshell app online and ask again.", + }; + } + if (err.kind === "unsupported") { + return { + status: 409, + code: BackendErrorCodes.NODE_OUTDATED, + message: "The Subshell app on the key home is too old to report its agent keys. Update it from its machine page.", + }; + } + if (err.kind === "timeout") { + return { + status: 409, + code: BackendErrorCodes.NODE_UNREACHABLE, + message: "The key home did not answer the roster request in time; check its connection and ask again.", + }; + } + logger + .withError(err) + .warn(`ssh roster rpc ${err.kind} failed against node ${err.nodeId}: ${err.detail ?? err.message}`); + return { + status: 502, + code: BackendErrorCodes.SSH_NODE_REFUSED, + message: "The key home refused the roster request. Check that SSH is switched on there and an agent is running.", + }; +} + /* ------------------------------------------------------------------ */ /* the launch leg: match -> open (or ask -> refuse) */ /* ------------------------------------------------------------------ */ From 998eacc1dbbe24f1f2c142a3a39e32aa55186e3c Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:26:48 -0700 Subject: [PATCH 52/92] test(ssh-relay): flip matrix case 10 - ssh_agent_identities answered with no grant Task 9's deferred todo, now real: the roster read runs WITHOUT the stack - no broker, no pairing, no grant - because A's live agent and gate mirror are its entire world. Asserts the WHOLE roster (not grant-filtered) answers with fingerprints + comments, both blob encodings absent from the frame, the probe sent only one-byte candidate requests, and the data dir gained nothing. --- .../__tests__/helpers/ssh-relay-stack.ts | 8 +- .../__tests__/ssh-relay.integration.test.ts | 75 +++++++++++++++---- 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts index c71552136..733080600 100644 --- a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts +++ b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts @@ -156,7 +156,13 @@ export interface StubAgent { close(): Promise; } -async function startStubAgent(roster: RosterEntry[], scheme: AgentScheme): Promise { +/** + * Start the scheme-behavior stub agent on its own socket. Exported for the + * roster-command case (Task 11): the `ssh_agent_identities` read needs A's + * live agent and NOTHING ELSE - no broker, no grant, no pairing - so it + * drives the stub without the full stack. + */ +export async function startStubAgent(roster: RosterEntry[], scheme: AgentScheme): Promise { const dir = mkdtempSync(join(tmpdir(), "subshell-relay-stack-stub-")); const path = join(dir, "agent.sock"); const received: Buffer[] = []; diff --git a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts index 83e6402c8..20ee64bac 100644 --- a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts @@ -1,16 +1,24 @@ import { expect, test } from "bun:test"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; import { createConnection, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { buildAgentSocketPath } from "@internal/pane-runtime"; import { base64UrlNoPad, newNonce, + parseNodeSshAgentIdentities, parseRelayFrame, type RelayFrame, SSH_RELAY_LIFETIME_MS, type SshRelayOpenCommand, } from "@internal/subshell-protocol"; import { exportJWK, generateKeyPair } from "jose"; +import type { CommandContext } from "../../../../../node/agent/src/commands/context.js"; +import { execSshAgentIdentities } from "../../../../../node/agent/src/commands/ssh-identity.js"; import { openARelaySession, openBRelaySession } from "../../../../../node/agent/src/commands/ssh-relay.js"; +import { OPENSSH_10X_SCHEME } from "../../../../../node/agent/src/relay-agent-scheme.js"; +import { writeSshEnabled } from "../../../../../node/agent/src/ssh-enabled.js"; import { buildSignRequestTenX, decodeA2B, @@ -23,11 +31,13 @@ import { openStack, PANE_ID, parseAnswer, + ROSTER, type Stack, sealA2B, sealB2A, sleep, sshStr, + startStubAgent, waitUntil, } from "./helpers/ssh-relay-stack.js"; @@ -50,8 +60,9 @@ import { * A's agent, roster answer first or not. * * Matrix case 10 (the `ssh_agent_identities` roster command accepted with no - * grant) is DEFERRED to Task 11, which builds that command; the todo at the - * bottom names it. + * grant) shipped with Task 11, which built the command; it is the one case + * that runs WITHOUT the stack, because the roster read consults nothing but + * A's live agent and A's own gate mirror. */ /** An ES256 pair pinned by nobody: signatures from it are forgeries. */ @@ -601,18 +612,52 @@ test("9. a forged A2B signature fails the pane's connection without a byte writt }); /* ------------------------------------------------------------------ */ -/* 10. the roster command (DEFERRED to Task 11) */ +/* 10. the roster command: answered with no grant, no relay session */ /* ------------------------------------------------------------------ */ -// Matrix case 10 - `ssh_agent_identities` accepted with no grant - belongs to -// Task 11: the roster command does not exist yet and this task must not -// build it. The responder-side surface (fingerprintAgentBlob, -// parseIdentitiesAnswer) is already exported for that task to reuse. -const TODO_PLACEHOLDER = (): void => { - /* Task 11 will drive the roster command here. */ -}; - -test.todo( - "10. (Task 11) ssh_agent_identities is accepted with no grant and answers the filtered roster", - TODO_PLACEHOLDER, -); +// Matrix case 10, flipped from the Task 9 todo now that the command exists +// (spec 2026-10-08 §5.4, Task 11). The whole matrix stack is deliberately +// ABSENT: there is no broker, no grant, no pairing, and no B. The point of +// the case is that the roster read needs none of that - A's live agent and +// A's own gate mirror are its entire world. +test("10. ssh_agent_identities is accepted with no grant and no relay session: the WHOLE roster answers, blobs withheld", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-relay-roster-")); + const stub = await startStubAgent(ROSTER, OPENSSH_10X_SCHEME); + writeSshEnabled(dir, { on: true, changedAt: "2026-10-08T10:00:00.000Z" }); + const savedSock = process.env.SSH_AUTH_SOCK; + process.env.SSH_AUTH_SOCK = stub.path; // the handler's production socket lookup, over a real round trip + try { + // The arm dispatch would run, on the same data-dir-only context the node + // suite builds; routing of the type is the node suite's own pin. + const result = await execSshAgentIdentities({ config: { dataDir: dir } } as unknown as CommandContext); + expect(result.ok).toBe(true); + const roster = parseNodeSshAgentIdentities(result.ok ? result.data : null); + expect(roster).not.toBeNull(); + // The WHOLE roster, not a grant-filtered one: with nothing granted there + // is no set to scope by, and the approval screen must show both keys. + expect(roster?.identities).toEqual([ + { fingerprint: fp(KEY_IN), comment: "granted key" }, + { fingerprint: fp(KEY_OUT), comment: "ungranted key" }, + ]); + // The blobs are withheld: neither wire encoding of either blob appears + // anywhere in the serialized result frame. + const wire = JSON.stringify(result); + for (const blob of [KEY_IN, KEY_OUT]) { + expect(wire).not.toContain(blob.toString("base64")); + expect(wire).not.toContain(blob.toString("base64url")); + } + // Only one-byte requests ever reached the agent: the classic candidate + // (13) confirmed FAILURE, the 10.x candidate (11) resolved the scheme + // positively, and the roster was asked in the RESOLVED numbering. No + // sign body, no session byte: nothing the responder's rules exclude. + expect(stub.received).toEqual([Buffer.from([13]), Buffer.from([11]), Buffer.from([11])]); + // Nothing was written anywhere the read could not already see: the data + // dir holds exactly the gate mirror, no session state, no audit artifact. + expect(readdirSync(dir)).toEqual(["ssh-enabled.json"]); + } finally { + if (savedSock === undefined) delete process.env.SSH_AUTH_SOCK; + else process.env.SSH_AUTH_SOCK = savedSock; + await stub.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); From 51e10425093e2dee0d41082fd66e3ffbf1351efd Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:38:53 -0700 Subject: [PATCH 53/92] fix(node): spell the package.json import with { type: json } The relay integration suite under @internal/server already imports agent modules in place, and Task 11's roster case reaches commands/ssh-identity.ts, whose CommandContext type pulls commands/context.ts - and with it ../update.js - into the server's NodeNext typecheck, where TS1543 refuses a JSON import without the attribute. The attribute is the spelling this repo already ships (server version.ts, this package's release script); runtime behaviour is unchanged (tsdown and bun build inline it either way). --- apps/node/agent/src/version.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/node/agent/src/version.ts b/apps/node/agent/src/version.ts index c8227fdb9..e7e0da6f1 100644 --- a/apps/node/agent/src/version.ts +++ b/apps/node/agent/src/version.ts @@ -1,8 +1,12 @@ -import pkg from "../package.json"; +import pkg from "../package.json" with { type: "json" }; /** * The agent's own version, sourced from package.json (the single source of * truth). resolveJsonModule lets tsc type it, Bun's bundler inlines it for - * both tsdown and `bun build --compile`. + * both tsdown and `bun build --compile`. The `with { type: "json" }` attribute + * is what the `@internal/server` typecheck needs: its tests import the agent's + * modules in place under NodeNext, which requires the attribute on a JSON + * import - the spelling the server's own version.ts and this package's + * release script already use. */ export const NODE_VERSION: string = pkg.version; From 9df24127994aeccaae1f8b8d7b5590af5df0a3e1 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 15:59:58 -0700 Subject: [PATCH 54/92] feat(ssh-protocol): the ssh_host_key capture arm and the required relay-open host-pin carriage Task 12 (spec 2026-10-08 S9): the capture command names one resolved destination (host/port/user, every field grammar-checked before the plane signs); its answer is a list of verbatim known_hosts lines sharing ONE shape predicate with the relay-open's pin field (isSshKnownHostsPinLine: one bounded printable line, no newline smuggle, no comment marker). The ssh_relay_open grammar makes hostPin REQUIRED - a pinless relay-open is a relay grant with no pin, which does not exist - same pre-merge grammar-extension class as T8's paneId. Census 6->7. --- .../src/__tests__/node-frames.test.ts | 82 ++++++++++++- .../src/__tests__/node-results-ssh.test.ts | 31 +++++ packages/subshell-protocol/src/index.ts | 6 + packages/subshell-protocol/src/node-frames.ts | 1 + .../subshell-protocol/src/node-results.ts | 31 ++++- packages/subshell-protocol/src/ssh-frames.ts | 109 +++++++++++++++++- packages/subshell-protocol/src/ssh-limits.ts | 18 +++ packages/subshell-protocol/src/ssh-results.ts | 18 +++ 8 files changed, 286 insertions(+), 10 deletions(-) diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index b232427eb..314e18661 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -69,6 +69,10 @@ const relayOpenCmd: SshRelayOpenCommand = { fingerprints: ["SHA256:AAAA", "SHA256:BBBB"], lifetimeMs: 30_000, paneId: "11111111-2222-4333-8444-555555555555", + // Task 12: the destination's pinned host-key line (A's known_hosts entry, + // captured at grant creation). Required: a relay-open without it is a + // relay grant with no pin, which is exactly what the grammar refuses. + hostPin: "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000", }; describe("parseNodeCommandBody", () => { @@ -963,17 +967,18 @@ describe("ssh command arms and the launch ssh block", () => { type: "ssh_register_identity", }); expect(parseNodeCommandBody({ type: "ssh_register_identity" })).not.toBeNull(); - // The family's census: exactly these six types, and the count is the + // The family's census: exactly these seven types, and the count is the // tripwire - a further arm must show up here before it ships. expect([...SSH_COMMAND_TYPES].sort()).toEqual([ "ssh_agent_identities", "ssh_discover_aliases", + "ssh_host_key", "ssh_register_identity", "ssh_relay_close", "ssh_relay_open", "ssh_resolve_config", ]); - expect(SSH_COMMAND_TYPES).toHaveLength(6); + expect(SSH_COMMAND_TYPES).toHaveLength(7); // Every census type is a type the dispatcher actually narrows. for (const t of SSH_COMMAND_TYPES) { const body = @@ -983,7 +988,9 @@ describe("ssh command arms and the launch ssh block", () => { ? relayOpenCmd : t === "ssh_relay_close" ? { type: t, ref: "r-1", reason: "lifetime-expiry" } - : { type: t }; + : t === "ssh_host_key" + ? { type: t, host: "git.example.test", port: 22, user: null } + : { type: t }; expect(parseNodeCommandBody(body)).not.toBeNull(); } }); @@ -1000,6 +1007,39 @@ describe("ssh command arms and the launch ssh block", () => { }); }); + it("the ssh_host_key arm names a destination and nothing else (spec 2026-10-08 §9, Task 12)", () => { + // The capture command asks for ONE destination's known_hosts entries: host + // always, port always (ssh's own lookup spells the port), user as its + // honest null when the snapshot named none. Stray members do not ride. + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "git.example.test", port: 22, user: null })).toEqual({ + type: "ssh_host_key", + host: "git.example.test", + port: 22, + user: null, + }); + expect( + parseNodeCommandBody({ type: "ssh_host_key", host: "git.example.test", port: 2222, user: "deploy" }), + ).toEqual({ type: "ssh_host_key", host: "git.example.test", port: 2222, user: "deploy" }); + expect( + parseNodeCommandBody({ type: "ssh_host_key", host: "[2001:db8::1]", port: 22, user: null, smuggled: "x" }), + ).not.toHaveProperty("smuggled"); + // Refusals: the host takes the alias grammar (option-like, whitespace, + // control chars, over-long); the port is a positive 16-bit int; the user + // is null or an alias-grammar name. + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "-oProxyCommand=x", port: 22, user: null })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "a b", port: 22, user: null })).toBeNull(); + expect( + parseNodeCommandBody({ type: "ssh_host_key", host: `h${"x".repeat(300)}`, port: 22, user: null }), + ).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 0, user: null })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 65536, user: null })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 22.5, user: null })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 22, user: "a b" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 22, user: 7 })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 22 })).toBeNull(); // user must be present (null is spelled) + expect(parseNodeCommandBody({ type: "ssh_host_key", host: "h", port: 22, user: undefined })).toBeNull(); + }); + it("launch accepts an ssh block and refuses malformed ones", () => { const good = { ...launchCmd, ssh: { configPath: "/d/ssh/s1/config", fileContent: "Host *\n" } }; expect(parseNodeCommandBody(good)).toMatchObject({ @@ -1049,6 +1089,7 @@ describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => "fingerprints", "lifetimeMs", "paneId", + "hostPin", ] as const) { const partial = structuredClone(relayOpenCmd) as unknown as Record; delete partial[drop]; @@ -1119,6 +1160,41 @@ describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => expect(parsed).toEqual(relayOpenCmd); }); + it("refuses a hostPin that is not one known_hosts line (spec 2026-10-08 §9, Task 12)", () => { + // The pin carriage is A's recorded key line delivered to B; the grammar's + // job is the SHAPE: one printable line, bounded, never a comment, never a + // multi-line smuggle (the node writes it verbatim into B's 0600 pinned + // file, so a newline would be a second, plane-authored trust entry). + // Deep truth (does the key match D) stays OpenSSH's at connect time. + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: "" })).toBeNull(); // empty is no pin + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: " " })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: "host ssh-rsa AAA\nsecond ssh-rsa BBB" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: "host ssh-rsa AAA\rBBB" })).toBeNull(); // CR smuggle + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: "host ssh-rsa AAA\tB\tBB" })).toBeNull(); // tab smuggle + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: "# comment line" })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: 7 })).toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: `host ssh-rsa ${"A".repeat(4084)}` })).toBeNull(); // 4097 > SSH_MAX_HOST_PIN_LINE_CHARS + // Honest spellings parse: plain, hashed-pattern, and user-qualified lines + // all carry A's recorded trust in OpenSSH's own known_hosts form. + expect( + parseNodeCommandBody({ ...relayOpenCmd, hostPin: "[git.example.test]:2222 ssh-ed25519 AAAAC3Nza==" }), + ).not.toBeNull(); + expect( + parseNodeCommandBody({ + ...relayOpenCmd, + hostPin: + "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000 comment@host", + }), + ).not.toBeNull(); + expect( + parseNodeCommandBody({ + ...relayOpenCmd, + hostPin: "|1|bnVsbHNhbHRudWxsc2FsdA==|dGhlaGFzaHRoYXRpc25vdHRoaXM=|ssh-ed25519 AAAAC3NzaC1lZDI1NTE5", + }), + ).not.toBeNull(); + expect(parseNodeCommandBody({ ...relayOpenCmd, hostPin: `host ssh-rsa ${"A".repeat(4083)}` })).not.toBeNull(); // at the bound (4096 total) + }); + it("refuses fingerprint entries outside the SHA256 display form", () => { // The tight form is what stops control chars (and plain junk) reaching a // future audit row through a field that promises to be a fingerprint. diff --git a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts index d98fea2df..64a814c1b 100644 --- a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts @@ -2,9 +2,11 @@ import { describe, expect, test } from "bun:test"; import { parseNodeSshAgentIdentities, parseNodeSshAliasList, + parseNodeSshHostKey, parseNodeSshIdentity, parseNodeSshResolveOutcome, } from "../node-results.js"; +import { SSH_MAX_HOST_KEY_LINES } from "../ssh-limits.js"; import { makeAliasList, makeResolveOk, makeResolveRefused } from "./fixtures/ssh-fixtures.js"; /** A well-formed public-JWK STRING: the validator's whole job is that it is JSON and an object. */ @@ -122,3 +124,32 @@ describe("parseNodeSshIdentity", () => { expect(parseNodeSshIdentity({ signingPublicKey: "null" })).toBeNull(); }); }); + +describe("parseNodeSshHostKey (spec 2026-10-08 §9, Task 12)", () => { + const LINE_A = "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000"; + const LINE_B = "|1|bnVsbHNhbHRudWxsc2FsdA==|dGhlaGFzaA==| ssh-ed25519 AAAA"; + test("narrows a well-formed answer of verbatim known_hosts lines", () => { + expect(parseNodeSshHostKey({ lines: [LINE_A] })).toEqual({ lines: [LINE_A] }); + expect(parseNodeSshHostKey({ lines: [] })).toEqual({ lines: [] }); // the honest "recorded nothing" + }); + test("rebuilds the lines array (extra answer members do not ride)", () => { + const parsed = parseNodeSshHostKey({ lines: [LINE_A, LINE_B], sneaky: "x" }); + expect(parsed).not.toHaveProperty("sneaky"); + expect(parsed?.lines).toEqual([LINE_A, LINE_B]); + }); + test("refuses a malformed or over-cap answer", () => { + expect(parseNodeSshHostKey({ lines: "not an array" })).toBeNull(); + expect(parseNodeSshHostKey({ lines: [7] })).toBeNull(); + expect(parseNodeSshHostKey({ lines: ["host ssh-rsa AAA\nsecond entry"] })).toBeNull(); // smuggled newline + expect(parseNodeSshHostKey({ lines: [""] })).toBeNull(); + expect(parseNodeSshHostKey({ lines: null })).toBeNull(); + expect( + parseNodeSshHostKey({ + lines: Array.from({ length: SSH_MAX_HOST_KEY_LINES + 1 }, (_, i) => `h${i} ssh-ed25519 A`), + }), + ).toBeNull(); + expect( + parseNodeSshHostKey({ lines: Array.from({ length: SSH_MAX_HOST_KEY_LINES }, (_, i) => `h${i} ssh-ed25519 A`) }), + ).not.toBeNull(); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 87f4f78b5..9293e5e97 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -153,6 +153,7 @@ export { parseNodePromptDeliver, parseNodeSshAgentIdentities, parseNodeSshAliasList, + parseNodeSshHostKey, parseNodeSshIdentity, parseNodeSshResolveOutcome, parseNodeStatDirResult, @@ -254,12 +255,14 @@ export { export { isSshGrantFingerprint, isSshGrantFingerprints, + isSshKnownHostsPinLine, isSshPaneId, parseSshNodeCommandBody, SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, type SshAgentIdentitiesCommand, type SshDiscoverAliasesCommand, + type SshHostKeyCommand, type SshNodeCommandBody, type SshRegisterIdentityCommand, type SshRelayCloseCommand, @@ -272,6 +275,8 @@ export { SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_DISCOVERED_ALIASES, SSH_MAX_GRANT_FINGERPRINTS, + SSH_MAX_HOST_KEY_LINES, + SSH_MAX_HOST_PIN_LINE_CHARS, SSH_MAX_PROXY_HOPS, SSH_NAME_MAX_CHARS, SSH_PATH_MAX_CHARS, @@ -313,6 +318,7 @@ export type { NodeSshAgentIdentitiesResult, NodeSshAgentIdentity, NodeSshAliasListResult, + NodeSshHostKeyResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire, } from "./ssh-results.js"; diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 45910346a..653e3c942 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1890,6 +1890,7 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { case "ssh_resolve_config": case "ssh_register_identity": case "ssh_agent_identities": + case "ssh_host_key": case "ssh_relay_open": case "ssh_relay_close": // Delegation, not a second parser: the SSH grammar (all arms, one diff --git a/packages/subshell-protocol/src/node-results.ts b/packages/subshell-protocol/src/node-results.ts index 1271e1141..020544239 100644 --- a/packages/subshell-protocol/src/node-results.ts +++ b/packages/subshell-protocol/src/node-results.ts @@ -23,12 +23,13 @@ import { BASE64_RE, isBool, isInt, isRecord, isStr, isStrArray, isStringMap } fr import { MAX_ARCHIVE_BYTES, MAX_MANIFEST_PAGE_ENTRIES } from "./node-frames.js"; import { parseSshConnectionSnapshot } from "./ssh-config.js"; import { isSshErrorCode } from "./ssh-errors.js"; -import { isSshGrantFingerprint } from "./ssh-frames.js"; -import { SSH_MAX_DISCOVERED_ALIASES, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; +import { isSshGrantFingerprint, isSshKnownHostsPinLine } from "./ssh-frames.js"; +import { SSH_MAX_DISCOVERED_ALIASES, SSH_MAX_HOST_KEY_LINES, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; import type { NodeSshAgentIdentitiesResult, NodeSshAgentIdentity, NodeSshAliasListResult, + NodeSshHostKeyResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire, } from "./ssh-results.js"; @@ -615,6 +616,32 @@ export function parseNodeSshAgentIdentities(data: unknown): NodeSshAgentIdentiti return { identities }; } +/** + * Validates and narrows an `ssh_host_key` command's `result{data}` (spec + * 2026-10-08 §9, Task 12). Each line is checked with the SAME predicate that + * gates the relay-open's pin carriage ({@link isSshKnownHostsPinLine}) - one + * definition of "what a wire host-key line is", answer in and pin out, so a + * line A answered can always ride the open that carries it - and the answer + * is capped at {@link SSH_MAX_HOST_KEY_LINES} rather than truncated (the + * roster cap's reasoning: a past-cap answer is a malformed machine, not a + * long one). An empty list parses: "A has recorded nothing" is the fact the + * capture fails closed on, and a fabrication-shaped error would hide it. + * Deep well-formedness (which token is the key, whether it matches D) is the + * capture service's fingerprint extraction and B's OpenSSH at connect time. + * @param data - the `data` member of a successful result frame + * @returns the narrowed entries, or null when malformed + */ +export function parseNodeSshHostKey(data: unknown): NodeSshHostKeyResult | null { + if (!isRecord(data) || !Array.isArray(data.lines)) return null; + if ((data.lines as unknown[]).length > SSH_MAX_HOST_KEY_LINES) return null; + const lines: string[] = []; + for (const line of data.lines as unknown[]) { + if (!isSshKnownHostsPinLine(line)) return null; + lines.push(line); + } + return { lines }; +} + /** * Validates and narrows an `ssh_register_identity` command's `result{data}` * (spec 2026-10-08 §4.3). Deliberately shallow: the grammar proves the field diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index 857213128..0bda2cd7a 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -9,13 +9,17 @@ * connection`, the run start/status/read/cancel quartet, `ssh_terminal_ * launch`, `ssh_input_control`) retired with that destination-execution * product, which never shipped; their `type` arms are therefore deleted, not - * refused. The sealed agent-relay milestone (M2) adds four commands here: + * refused. The sealed agent-relay milestone (M2) adds five commands here: * `ssh_register_identity` (§4.3 bootstrap: a pre-M2 node's signing key reaches * the plane inside a signed command on the existing node link), the roster * read `ssh_agent_identities` (§5.4: the approval screen asks A's agent for - * its public identities, fingerprints plus comments, blobs withheld), and the - * brokered pair `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane's OPEN - * hands one machine the whole pairing, the CLOSE names why it ends). What + * its public identities, fingerprints plus comments, blobs withheld), the + * host-key capture `ssh_host_key` (§9, Task 12: the plane asks A's + * `known_hosts` for one resolved destination's entries, and the answer + * becomes the pin the relay-open carries to B), and the brokered pair + * `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane's OPEN hands one + * machine the whole pairing plus the destination's host-key pin, the CLOSE + * names why it ends). What * rides the link AFTER an open - the sealed blobs themselves - is not a * command at all: it is the `relay` link frame in `node-frames.ts`, which * carries no per-message signing because the link already authenticates. @@ -33,7 +37,7 @@ */ import { BASE64_RE, isInt, isRecord, isStr, isStrArray } from "./guards.js"; -import { SSH_MAX_GRANT_FINGERPRINTS, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; +import { SSH_MAX_GRANT_FINGERPRINTS, SSH_MAX_HOST_PIN_LINE_CHARS, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; /* ------------------------------------------------------------------ */ /* command bodies */ @@ -80,6 +84,33 @@ export interface SshAgentIdentitiesCommand { type: "ssh_agent_identities"; } +/** + * Ask the key home for the `known_hosts` entries its connecting account has + * recorded for ONE resolved destination (spec 2026-10-08 §9 "M2 host-key pin + * path", Task 12): the capture command whose answer becomes the pin delivered + * to B on the relay-open. Unlike the roster read it must NAME a destination, + * because OpenSSH's host-key lookup is per destination - `host`, `port`, and + * the connecting `user` (null when the snapshot named none) are the three + * facts the lookup's candidate spellings are built from, and each is sent in + * its resolved form so a later `~/.ssh/config` edit cannot retarget the ask + * (the same store-resolved rule §6.1 applies to the grant selector). + * + * Answered with NO grant and no relay session, like `detect`, + * `ssh_register_identity`, and `ssh_agent_identities`; it writes no audit row + * on either side (the durable record of a pin is `node.ssh_host_pin.create`, + * written by the capture ACT, not by the question), and an unreachable or + * refusing machine answers a named error - the capture never guesses. + */ +export interface SshHostKeyCommand { + type: "ssh_host_key"; + /** The resolved destination host (bracketed IPv6 spelled as OpenSSH spells it). */ + host: string; + /** The resolved port; always sent, because `[host]:port` is one of ssh's own lookup spellings. */ + port: number; + /** The connecting user, or null when the snapshot named none (the bare-`host` spellings still match). */ + user: string | null; +} + /** * Which side of a relay pairing a machine is (spec 2026-10-08 §2): A is the * key home whose agent signs, B is the connecting machine that holds none. @@ -115,6 +146,19 @@ export type SshRelayRole = "A" | "B"; * matching {@link isSshPaneId}; the grammar names the SAME shape so a command * that could only ever fail the node's socket-path guard is refused here, * before the plane signs it. + * + * `hostPin` (Task 12, spec 2026-10-08 §9): the destination's pinned + * `known_hosts` line, captured from A at grant creation and REQUIRED. A + * relay-open without it is a relay grant with no pin, and the invariant says + * such a thing does not exist: B must never fall back to its own ambient + * TOFU, so the grammar refuses the pinless open rather than let the launch + * render an `accept-new` config against an untrusted machine's file. The B + * side writes the line, byte-for-byte, to `/ssh//known_hosts` + * (0600, beside the config the pane's `ssh -F` reads); the shape rule is + * {@link isSshKnownHostsPinLine} - one printable line, never a smuggled + * second entry. The same class of pre-merge grammar extension as `paneId`: + * plane and agent ship together under the exact-match protocol gate, so the + * field is required outright, not version-gated. */ export interface SshRelayOpenCommand { type: "ssh_relay_open"; @@ -140,6 +184,8 @@ export interface SshRelayOpenCommand { lifetimeMs: number; /** The pane (subshell id) the pairing serves: names B's proxy socket path (§5.2). */ paneId: string; + /** The destination's pinned host-key line (A's known_hosts entry; required - a pinless relay-open is malformed, §9). */ + hostPin: string; } /** @@ -187,6 +233,7 @@ export type SshNodeCommandBody = | SshResolveConfigCommand | SshRegisterIdentityCommand | SshAgentIdentitiesCommand + | SshHostKeyCommand | SshRelayOpenCommand | SshRelayCloseCommand; @@ -196,6 +243,7 @@ export const SSH_COMMAND_TYPES = [ "ssh_resolve_config", "ssh_register_identity", "ssh_agent_identities", + "ssh_host_key", "ssh_relay_open", "ssh_relay_close", ] as const; @@ -275,6 +323,35 @@ export function isSshGrantFingerprints(value: unknown): value is string[] { return isStrArray(value) && value.every(isSshGrantFingerprint) && value.length <= SSH_MAX_GRANT_FINGERPRINTS; } +/** + * Whether `value` is ONE OpenSSH `known_hosts` line as the wire carries it: + * non-empty, bounded ({@link SSH_MAX_HOST_PIN_LINE_CHARS}), trimmed, and free + * of control characters - which subsumes the newline, the load-bearing half. + * The B side writes a relay-open's pin line verbatim into the pane's 0600 + * pinned file, so a smuggled `\n` would install a SECOND, plane-authored + * trust entry beside A's; the comment-marker refusal keeps `#` lines out of a + * field that promises to be a key. Everything past shape - whether the line + * is a well-formed entry, which key it carries, whether it matches D - is + * OpenSSH's own judgment at connect time on B (StrictHostKeyChecking yes + * against this exact file), and the capture service's fingerprint extraction + * on the plane. Hashed (`|1|…`) and option-prefixed entries pass: they are + * how the operator's own file may spell A's trust, verbatim-rewritten. + * + * One definition, both directions: the `ssh_host_key` ANSWER validator + * (`node-results.ts`) and the `ssh_relay_open` pin carriage both check with + * this predicate, so a line A answered can always ride the relay-open. + */ +export function isSshKnownHostsPinLine(value: unknown): value is string { + return ( + isStr(value) && + value.length > 0 && + value.length <= SSH_MAX_HOST_PIN_LINE_CHARS && + value === value.trim() && + !/\p{Cc}/u.test(value) && + !value.startsWith("#") + ); +} + /** * Validates and narrows any `ssh_*` command body. `parseNodeCommandBody` * routes every `ssh_` arm here, so the SSH grammar lives in ONE file @@ -301,6 +378,20 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu // asks the WHOLE roster, so a plane-sent field could only aim at // narrowing or widening what A's agent actually reports. return { type: "ssh_agent_identities" }; + case "ssh_host_key": { + // The capture asks for ONE destination's entries, so the destination is + // the whole input: host and user take the SAME token hygiene the alias + // and snapshot names apply (option-like, whitespace, and control-char + // refusals at the grammar, before the plane signs the ask), the port is + // a real 16-bit int because `[host]:port` is one of ssh's own lookup + // spellings, and the user is REQUIRED to state itself - null spelled - + // so no omitted field can silently widen or narrow who the lookup names. + if (!isAliasName(value.host)) return null; + if (!isInt(value.port) || (value.port as number) < 1 || (value.port as number) > 65_535) return null; + if (!("user" in value)) return null; + if (!(value.user === null || isAliasName(value.user))) return null; + return { type: "ssh_host_key", host: value.host, port: value.port as number, user: value.user }; + } case "ssh_relay_open": { // EVERY pairing field is required (§5.1): this command IS the pairing, // and an executor guessing a missing half would be guessing trust @@ -345,6 +436,13 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu // by it, so it must be a path-composability id - the SAME guard the // node's socket bind runs, applied before anything is signed. if (!isSshPaneId(value.paneId)) return null; + // Task 12 (spec §9): the destination's pinned host-key line, REQUIRED. + // A relay-open with no pin is a relay grant with no pin, and B must + // never ambient-TOFU a destination it does not already trust: the + // refusal is the grammar's, before the plane signs and before either + // endpoint runs. Shape only here ({@link isSshKnownHostsPinLine}); + // whether the line matches D is ssh's own hard block on B at connect. + if (!isSshKnownHostsPinLine(value.hostPin)) return null; // Rebuilt from the validated fields, never a cast of the candidate: a // stray member on the wire drops here, exactly as the close arm drops. return { @@ -360,6 +458,7 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu fingerprints: [...value.fingerprints], lifetimeMs: value.lifetimeMs as number, paneId: value.paneId, + hostPin: value.hostPin, }; } case "ssh_relay_close": { diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts index e27b738fc..ae52fdc62 100644 --- a/packages/subshell-protocol/src/ssh-limits.ts +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -177,6 +177,24 @@ export const SSH_RELAY_TEARDOWN_GRACE_MS = 5_000; */ export const SSH_MAX_GRANT_FINGERPRINTS = 8; +/** + * Longest single OpenSSH `known_hosts` line the wire may carry, in EITHER + * direction: the `ssh_host_key` answer's entries and the `ssh_relay_open` + * pin's line alike (spec 2026-10-08 §9, Task 12). A real line is a pattern + * list, a key type, and base64 key material - an RSA-8192 entry with a long + * pattern runs well past a kilobyte, and the grammar's bound must not refuse + * the honest file while leaving a hostile plane an unbounded text field. + */ +export const SSH_MAX_HOST_PIN_LINE_CHARS = 4096; + +/** + * Host-key lines one `ssh_host_key` answer may carry (Task 12). A destination + * with more recorded entries than this is not the operator's `known_hosts` + * the capture expects; the answer is malformed, refused at the grammar rather + * than truncated. + */ +export const SSH_MAX_HOST_KEY_LINES = 32; + /** * Largest raw stdout slice one brokered `session_frame` event may carry * (design §3's pump, restated as a number: the node-frames `session_frame` diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts index daef58083..cc233d19d 100644 --- a/packages/subshell-protocol/src/ssh-results.ts +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -98,3 +98,21 @@ export interface NodeSshAgentIdentitiesResult { /** Every public identity A's live agent carries; the approval screen's choice list. */ identities: NodeSshAgentIdentity[]; } + +/** + * `ssh_host_key` answer (spec 2026-10-08 §9, Task 12): the entries A's + * connecting account has recorded for ONE resolved destination, each in + * OpenSSH's own `known_hosts` line spelling (pattern, key type, base64 key + * material, optional comment), verbatim. An EMPTY list is the honest fact "A + * has recorded nothing for this destination" - a distinct answer from the + * named error an unreadable file or a missing tool produces, and the capture + * service fails the grant creation closed on exactly this empty answer + * (a relay grant must carry a pin). The key material here is PUBLIC by + * nature (a known_hosts entry is what a client uses to CHECK a server), and + * it is what becomes the pin; the audit rows name only the destination and + * the entry's `SHA256:` fingerprint, never these bytes. + */ +export interface NodeSshHostKeyResult { + /** Every `known_hosts` line matching the destination's lookup; verbatim, at most SSH_MAX_HOST_KEY_LINES. */ + lines: string[]; +} From cb0b4bf2885494c42f6da6750a1f288f67a7ba43 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 16:01:34 -0700 Subject: [PATCH 55/92] feat(pane-runtime): relay-mode ssh config render pins the trust source and forces yes Task 12 (spec 2026-10-08 S9): renderSshConfigContents gains an optional relay fact - the session's pinned known_hosts path. In relay mode the render emits StrictHostKeyChecking yes and a single UserKnownHostsFile naming the pinned file, REPLACING (never appending) the snapshot's own trust refs, so B's ambient file is never consulted for a relay destination. Without the option the bytes are the M1 accept-new posture byte-for-byte. buildSshKnownHostsPath composes the path with the same guards as the config/socket paths; the plane renders it into the config, B writes to its own derivation. --- packages/pane-runtime/src/index.ts | 2 + .../src/ssh/__tests__/ssh-render.test.ts | 83 +++++++++++++++++++ packages/pane-runtime/src/ssh/ssh-render.ts | 76 ++++++++++++++++- 3 files changed, 158 insertions(+), 3 deletions(-) diff --git a/packages/pane-runtime/src/index.ts b/packages/pane-runtime/src/index.ts index bbbeaaf48..5b5c939ed 100644 --- a/packages/pane-runtime/src/index.ts +++ b/packages/pane-runtime/src/index.ts @@ -152,7 +152,9 @@ export { export { buildAgentSocketPath, buildSshConfigPath, + buildSshKnownHostsPath, renderSshConfigContents, + type SshRelayRenderOption, sshDestinationToken, sshOptionTokens, } from "./ssh/ssh-render.js"; diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts index 01adceec4..0f94245bf 100644 --- a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { buildAgentSocketPath, buildSshConfigPath, + buildSshKnownHostsPath, renderSshConfigContents, sshDestinationToken, sshOptionTokens, @@ -219,6 +220,88 @@ describe("sshDestinationToken", () => { }); }); +describe("buildSshKnownHostsPath", () => { + // Spec 2026-10-08 §9 (Task 12): the B side writes the delivered pin to a + // 0600 known_hosts file in the SAME per-pane ssh dir, so `ssh -F` reads a + // config whose UserKnownHostsFile points at it. One derivation, both ends. + it("composes the pinned file beside the config and socket, one dir", () => { + expect(buildSshKnownHostsPath("/data", "s1")).toBe("/data/ssh/s1/known_hosts"); + expect(dirname(buildSshKnownHostsPath("/data", "s1"))).toBe(dirname(buildSshConfigPath("/data", "s1"))); + expect(dirname(buildSshKnownHostsPath("/data", "s1"))).toBe(dirname(buildAgentSocketPath("/data", "s1"))); + }); + + it("runs the same guards as the config path", () => { + expect(() => buildSshKnownHostsPath("data", "s1")).toThrow(/absolute/); + expect(() => buildSshKnownHostsPath("/d", "")).toThrow(/paneId/); + expect(() => buildSshKnownHostsPath("/d", "../escape")).toThrow(/paneId/); + expect(() => buildSshKnownHostsPath("/d", "a".repeat(65))).toThrow(/paneId/); + expect(buildSshKnownHostsPath("/d", "a".repeat(64))).toBe(`/d/ssh/${"a".repeat(64)}/known_hosts`); + }); +}); + +describe("renderSshConfigContents relay mode (spec 2026-10-08 §9)", () => { + const PIN = "/data/ssh/s1/known_hosts"; + const relay = { hostPinPath: PIN }; + + it("emits the pinned UserKnownHostsFile and forces yes, not accept-new", () => { + const out = renderSshConfigContents(baseSnapshot({ knownHostsFiles: ["/home/deploy/.ssh/known_hosts"] }), relay); + expect(out).toContain(" StrictHostKeyChecking yes"); + expect(out).not.toContain("accept-new"); + // The pinned file is the ONLY trust source: B's own ambient known_hosts + // is NOT the authority (spec §9), so the snapshot's own trust refs are + // replaced, never appended. + expect(out).toContain(` UserKnownHostsFile ${PIN}`); + expect(out).not.toContain("/home/deploy/.ssh/known_hosts"); + }); + + it("forces yes even when the snapshot named no trust refs (relay replaces, never defers)", () => { + const out = renderSshConfigContents(baseSnapshot({ knownHostsFiles: [] }), relay); + expect(out).toContain(" StrictHostKeyChecking yes"); + expect(out).not.toContain("accept-new"); + expect(out).toContain(` UserKnownHostsFile ${PIN}`); + }); + + it("quotes the pinned path only when it needs it (the same tokenizer rule as other file values)", () => { + const spaced = renderSshConfigContents(baseSnapshot(), { hostPinPath: "/data/ssh dir/s1/known_hosts" }); + expect(spaced).toContain(' UserKnownHostsFile "/data/ssh dir/s1/known_hosts"'); + }); + + it("keeps every other mandatory policy line unchanged (relay mode is ONLY the trust source + yes)", () => { + const out = renderSshConfigContents(baseSnapshot(), relay); + for (const [key, value] of [ + ["ForwardAgent", "no"], + ["ForwardX11", "no"], + ["ClearAllForwardings", "yes"], + ["EscapeChar", "none"], + ["RemoteCommand", "none"], + ["VerifyHostKeyDNS", "no"], + ["CanonicalizeHostname", "no"], + ] as [string, string][]) { + expect(out).toContain(` ${key} ${value}`); + } + }); + + it("a non-relay render is byte-for-byte the M1 accept-new posture (the branch is untouched)", () => { + const snap = baseSnapshot(); + expect(renderSshConfigContents(snap)).toContain(" StrictHostKeyChecking accept-new"); + // No second argument is the M1 call; it must equal an explicit-undefined. + expect(renderSshConfigContents(snap, undefined)).toBe(renderSshConfigContents(snap)); + }); + + it("refuses a non-absolute or oversized hostPinPath (never composes a stray trust file)", () => { + expect(() => renderSshConfigContents(baseSnapshot(), { hostPinPath: "relative/known_hosts" })).toThrow(/absolute/); + expect(() => renderSshConfigContents(baseSnapshot(), { hostPinPath: "" })).toThrow(/absolute/); + expect(() => renderSshConfigContents(baseSnapshot(), { hostPinPath: `/${"x".repeat(5000)}` })).toThrow(/path/i); + }); + + it("refuses a hand-built snapshot carrying a forbidden member, same as non-relay", () => { + const smuggled = { ...baseSnapshot(), proxyCommand: "nc attacker 4444" } as unknown as ReturnType< + typeof baseSnapshot + >; + expect(() => renderSshConfigContents(smuggled, relay)).toThrow("not renderable"); + }); +}); + describe("buildSshConfigPath", () => { it("composes the derived path from the two facts each side already holds", () => { expect(buildSshConfigPath("/data", "s1")).toBe("/data/ssh/s1/config"); diff --git a/packages/pane-runtime/src/ssh/ssh-render.ts b/packages/pane-runtime/src/ssh/ssh-render.ts index 99bf2fa9f..d74535256 100644 --- a/packages/pane-runtime/src/ssh/ssh-render.ts +++ b/packages/pane-runtime/src/ssh/ssh-render.ts @@ -128,6 +128,24 @@ function knownHostsLines(files: string[]): string[] { return files.map((f) => ` UserKnownHostsFile ${configPathValue(f)}`); } +/** + * The relay-mode render option (spec 2026-10-08 §9, Task 12): the path of the + * per-session pinned `known_hosts` file the B machine writes from the + * relay-open's delivered pin. It is deliberately a PATH and not the key + * line: the pin reaches B on the signed command and lands in that 0600 file, + * and the config only has to POINT the connection at it. A per-launch relay + * fact passed as an option, never smuggled through the frozen destination + * snapshot (which records what the operator approved, not which session + * carries it). + */ +export interface SshRelayRenderOption { + /** Absolute POSIX path of the session's pinned known_hosts file (the 0600 beside the config). */ + hostPinPath: string; +} + +/** The two spellings of the ONE host-key-checking line: the M1 posture and the relay block. */ +const HOST_KEY_CHECK: Record<"direct" | "relay", string> = { direct: "accept-new", relay: "yes" }; + /** * Render the runtime ssh_config FILE for one snapshot: the policy above, * every hop included, plus the snapshot's trust refs and identity refs under @@ -136,15 +154,47 @@ function knownHostsLines(files: string[]): string[] { * from the snapshot's own refs"). Contents are deterministic in the snapshot * — the same approval always renders the same file, which is what makes the * run's recorded config byte-checkable. + * + * Relay mode (the second argument, spec 2026-10-08 §9) changes EXACTLY two + * things, and pins the test suite to all of them: `StrictHostKeyChecking` + * says `yes` instead of `accept-new` (a changed D key is refused by OpenSSH + * naming D; nothing new is ever recorded), and `UserKnownHostsFile` is the + * session's pinned file and NOTHING ELSE - the snapshot's own trust refs are + * replaced, because on the relay path B's ambient known_hosts is not the + * authority, A's recorded key is. Absent the option (every M1 launch) the + * bytes are the accept-new posture byte-for-byte, untouched. */ -export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire): string { +export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire, relay?: SshRelayRenderOption): string { assertRenderable(snapshot); + if (relay !== undefined) { + // The same path discipline `sshOptionTokens` applies to the config path: + // this value is interpolated into a file ssh reads verbatim, and a + // relative or oversized name is not a renderable trust file. + if ( + relay.hostPinPath.length === 0 || + !relay.hostPinPath.startsWith("/") || + relay.hostPinPath.length > SSH_PATH_MAX_CHARS + ) { + throw new Error("relay host pin path must be absolute POSIX"); + } + } + const checking = relay ? HOST_KEY_CHECK.relay : HOST_KEY_CHECK.direct; const lines = [ "# Subshell managed SSH config - GENERATED, do not edit.", "# Mandatory runtime policy for the connection and every ProxyJump hop (spec 2026-10-07 §5.2).", + ...(relay + ? [ + "# Relay mode: D is verified against the pinned host-key file only - no new keys are recorded (spec 2026-10-08 §9).", + ] + : []), "Host *", - ...MANDATORY_POLICY.map(([key, value]) => ` ${key} ${value}`), - ...knownHostsLines(snapshot.knownHostsFiles), + ...MANDATORY_POLICY.map(([key, value]) => ` ${key} ${key === "StrictHostKeyChecking" ? checking : value}`), + // Relay: the pinned file is the ONE trust source, the snapshot's own + // refs must not ride beside it. Direct: the M1 rule (silence IS the + // policy, the machine's default known_hosts is the trust store). + ...(relay + ? [` UserKnownHostsFile ${configPathValue(relay.hostPinPath)}`] + : knownHostsLines(snapshot.knownHostsFiles)), ...snapshot.identityFiles.map((f) => ` IdentityFile ${configPathValue(f)}`), ...snapshot.certificateFiles.map((f) => ` CertificateFile ${configPathValue(f)}`), "", @@ -234,3 +284,23 @@ export function buildAgentSocketPath(dataDir: string, paneId: string): string { if (!/^[a-zA-Z0-9_-]{1,64}$/.test(paneId)) throw new Error("ssh agent socket path: invalid paneId"); return `${dataDir}/ssh/${paneId}/agent.sock`; } + +/** + * The relay session's PINNED host-key file for a pane (spec 2026-10-08 §9, + * Task 12): `known_hosts` beside the config and the agent socket, in the one + * per-pane dir that is removed with the pane. Like {@link + * buildAgentSocketPath}, BOTH ends derive it from (dataDir, paneId) and the + * byte-equality is the check - but here the directions are the reverse of the + * socket's: the PLANE composes it into the relay-mode config it renders (the + * `UserKnownHostsFile` line the pane's ssh reads), and B's relay-open + * executor writes the delivered pin to its OWN derivation, never to a path + * named on the wire. A config pointing at a path B did not derive would + * point ssh at a file nothing wrote: with `StrictHostKeyChecking yes` that + * is a refusal naming D, which is the fail-closed half of the design. Same + * guards, same refusals (throws) as the config path. + */ +export function buildSshKnownHostsPath(dataDir: string, paneId: string): string { + if (!dataDir.startsWith("/")) throw new Error("ssh known-hosts path: dataDir must be absolute POSIX"); + if (!/^[a-zA-Z0-9_-]{1,64}$/.test(paneId)) throw new Error("ssh known-hosts path: invalid paneId"); + return `${dataDir}/ssh/${paneId}/known_hosts`; +} From b3b8eb7e5373c3f4e63ad9d51baea4941e95f30b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 16:06:58 -0700 Subject: [PATCH 56/92] feat(node): ssh_host_key capture arm and the B-side pinned known_hosts write Task 12 (spec 2026-10-08 S9): the ssh_host_key executor answers A's known_hosts entries for one destination by evaluating them with ssh-keygen -F (OpenSSH's own matching, not a re-parse), one run per candidate spelling, headers stripped and lines deduped through the frozen answer validator. The gate speaks first; an absent trust file is the honest empty fact the capture fails closed on; a missing keygen, a timeout, a spawn failure, an unreadable file, and a malformed answer each refuse by name. No audit row, no key bytes in any log. openBRelaySession writes the delivered relay-open pin line, shape-re-checked, to buildSshKnownHostsPath(dataDir, paneId) at 0600 (dir 0700) before binding the agent socket, refusing without writing on a malformed pin. --- .../__tests__/commands-ssh-host-key.test.ts | 248 ++++++++++++++++++ .../src/__tests__/commands-ssh-relay.test.ts | 3 + .../src/__tests__/relay-responder.test.ts | 90 ++++++- apps/node/agent/src/commands/index.ts | 7 + apps/node/agent/src/commands/ssh-host-key.ts | 144 ++++++++++ apps/node/agent/src/commands/ssh-relay.ts | 46 +++- apps/node/agent/src/commands/ssh-shared.ts | 28 ++ 7 files changed, 558 insertions(+), 8 deletions(-) create mode 100644 apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts create mode 100644 apps/node/agent/src/commands/ssh-host-key.ts diff --git a/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts b/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts new file mode 100644 index 000000000..c1a483136 --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts @@ -0,0 +1,248 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { SshProcessResult } from "@internal/pane-runtime"; +import type { JsonValue, NodeCommandBody, SshHostKeyCommand } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { execSshHostKey, type HostKeySeams, sshHostKeyCandidates } from "../commands/ssh-host-key.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; + +/** + * The `ssh_host_key` capture arm (spec 2026-10-08 §9, Task 12): A answers the + * `known_hosts` entries for one resolved destination so the plane can + * capture the pin the relay-open carries to B. Posture copied from + * commands-ssh.test.ts (real temp dirs, the gate mirror written by its own + * writer, refusals by `ok:false` + message) with the ssh-keygen run replaced + * by the executor's `runProcess` seam - the OPPOSITE of what runs against a + * real agent is what the tests pin here: the candidate SPELLINGS, the + * comment-header strip, the dedup, and the fail-closed error classes. The + * real `ssh-keygen -F` behavior against a fixture file is the same tool's + * behavior at the sshd suite's disposal; this file owns the arm's decisions. + */ + +const STAMP = "2026-10-08T10:00:00.000Z"; +const GATE_REFUSAL = "ssh disabled on this node"; +const LINE_A = "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000"; + +let base: string; +let savedKeygen: string | undefined; +let savedKnownHosts: string | undefined; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-hostkey-"))); + savedKeygen = process.env.SUBSHELL_SSH_KEYGEN_PATH; + savedKnownHosts = process.env.SUBSHELL_SSH_KNOWN_HOSTS; +}); + +afterAll(() => { + if (savedKeygen === undefined) delete process.env.SUBSHELL_SSH_KEYGEN_PATH; + else process.env.SUBSHELL_SSH_KEYGEN_PATH = savedKeygen; + if (savedKnownHosts === undefined) delete process.env.SUBSHELL_SSH_KNOWN_HOSTS; + else process.env.SUBSHELL_SSH_KNOWN_HOSTS = savedKnownHosts; + rmSync(base, { recursive: true, force: true }); +}); + +function dataDir(tag: string, gateOn: boolean): string { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + if (gateOn) writeSshEnabled(dir, { on: true, changedAt: STAMP }); + return dir; +} + +function makeCtx(tag: string, gateOn = true): CommandContext { + return { config: { dataDir: dataDir(tag, gateOn) } } as unknown as CommandContext; +} + +function cmd(over: Partial = {}): SshHostKeyCommand { + return { type: "ssh_host_key", host: "git.example.test", port: 22, user: null, ...over }; +} + +function trustFile(tag: string, content: string | null): string { + const path = join(base, tag, "known_hosts"); + mkdirSync(join(base, tag), { recursive: true }); + if (content !== null) writeFileSync(path, content, { mode: 0o600 }); + return path; +} + +interface KeygenCall { + candidate: string; + argv: readonly string[]; +} + +/** A fake ssh-keygen: canned stdout per candidate, with the call log. */ +function fakeRun(byCandidate: Record>): { + run: NonNullable; + calls: KeygenCall[]; +} { + const calls: KeygenCall[] = []; + return { + calls, + run: async (argv): Promise => { + const candidate = argv[argv.length - 1]; + calls.push({ candidate, argv }); + const canned = byCandidate[candidate] ?? {}; + return { + code: canned.code ?? 0, + stdout: canned.stdout ?? "", + stderr: canned.stderr ?? "", + timedOut: canned.timedOut ?? false, + spawnError: canned.spawnError ?? false, + }; + }, + }; +} + +function seamsFor(trust: string, run: HostKeySeams["runProcess"]): HostKeySeams { + return { + resolveKeygenBin: async () => "/usr/bin/ssh-keygen", + trustFile: () => trust, + runProcess: run ?? (async () => ({ code: 0, stdout: "", stderr: "", timedOut: false, spawnError: false })), + }; +} + +describe("ssh_host_key candidates (OpenSSH's lookup spellings)", () => { + it("a default-port ask without a user asks the bare host and the bracketed-port spelling", () => { + expect(sshHostKeyCandidates(cmd())).toEqual(["[git.example.test]:22", "git.example.test"]); + }); + it("a user and a non-default port cover all four spellings, deduped", () => { + expect(sshHostKeyCandidates(cmd({ port: 2222, user: "deploy" }))).toEqual([ + "[git.example.test]:2222", + "git.example.test", + "[deploy@git.example.test]:2222", + "deploy@git.example.test", + ]); + // A host already spelled with brackets is not re-bracketed: OpenSSH + // writes known_hosts entries for `[::1]:2222` exactly once spelled. + expect(sshHostKeyCandidates(cmd({ host: "[::1]", port: 2222, user: "deploy" }))).toEqual([ + "[::1]:2222", + "[::1]", + "[deploy@[::1]]:2222", + "deploy@[::1]", + ]); + }); +}); + +describe("ssh_host_key arm (gate ON)", () => { + it("the gate speaks before any lookup: no keygen resolution, no run", async () => { + let resolved = false; + const res = await execSshHostKey(makeCtx("gate", false), cmd(), { + resolveKeygenBin: async () => { + resolved = true; + return "/usr/bin/ssh-keygen"; + }, + }); + expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + expect(resolved).toBe(false); + }); + + it("an absent trust file is the honest EMPTY answer - the capture's fail-closed fact, not an error", async () => { + const missing = join(base, "never-created", "known_hosts"); + const fake = fakeRun({}); + const res = await execSshHostKey(makeCtx("missing"), cmd(), seamsFor(missing, fake.run)); + expect(res).toEqual({ ok: true, data: { lines: [] } }); + // Nothing spawned: the file's ABSENCE is answered without OpenSSH. + expect(fake.calls).toHaveLength(0); + }); + + it("a missing ssh-keygen refuses by name (the binary-ladder message class)", async () => { + const trust = trustFile("nokeygen", `${LINE_A}\n`); + const res = await execSshHostKey(makeCtx("nokeygen"), cmd(), { + resolveKeygenBin: async () => null, + trustFile: () => trust, + }); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("binary missing"); + }); + + it("answers the matching entry verbatim, headers and blanks stripped, argv spelled -f file -F candidate", async () => { + const trust = trustFile("match", `${LINE_A}\n# a comment\nother.example ssh-rsa BBB\n`); + const fake = fakeRun({ + "git.example.test": { + stdout: `# Host git.example.test found: line 2\n${LINE_A}\n`, + }, + }); + const res = await execSshHostKey(makeCtx("match"), cmd(), seamsFor(trust, fake.run)); + expect(res.ok).toBe(true); + expect((res as { data: JsonValue }).data).toEqual({ lines: [LINE_A] }); + // The ask spells the file target with `-f` (never a HOME-relative read) + // and each candidate as its own argv element - no shell string anywhere. + expect(fake.calls.map((c) => c.candidate)).toEqual(["[git.example.test]:22", "git.example.test"]); + expect(fake.calls[1]?.argv.slice(0, 4)).toEqual(["/usr/bin/ssh-keygen", "-f", trust, "-F"]); + }); + + it("dedups a line matched under several candidate spellings", async () => { + const trust = trustFile("dedup", `${LINE_A}\n`); + const fake = fakeRun({ + "[git.example.test]:22": { stdout: `${LINE_A}\n` }, + "git.example.test": { stdout: `${LINE_A}\n` }, + "deploy@git.example.test": { stdout: `${LINE_A}\n` }, + "[deploy@git.example.test]:22": { code: 1, stdout: "", stderr: "" }, + }); + const res = await execSshHostKey(makeCtx("dedup"), cmd({ user: "deploy" }), seamsFor(trust, fake.run)); + expect((res as { data: { lines: string[] } }).data.lines).toEqual([LINE_A]); + }); + + it("a silent non-zero run with stderr is the named unreadable-file refusal, never the empty fact", async () => { + const trust = trustFile("unreadable", `${LINE_A}\n`); + const fake = fakeRun({ + "[git.example.test]:22": { code: 1, stderr: "Load key ...: Permission denied" }, + "git.example.test": { code: 1, stderr: "Load key ...: Permission denied" }, + }); + const res = await execSshHostKey(makeCtx("unreadable"), cmd(), seamsFor(trust, fake.run)); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("could not be read"); + }); + + it("a timeout and a spawn failure each refuse by name", async () => { + const trust = trustFile("failures", `${LINE_A}\n`); + const timeout = fakeRun({ "[git.example.test]:22": { timedOut: true } }); + const t = await execSshHostKey(makeCtx("failures"), cmd(), seamsFor(trust, timeout.run)); + expect(t.ok).toBe(false); + expect(String((t as { error?: string }).error)).toInclude("deadline"); + const spawn = fakeRun({ "[git.example.test]:22": { spawnError: true } }); + const s = await execSshHostKey(makeCtx("failures"), cmd(), seamsFor(trust, spawn.run)); + expect(s.ok).toBe(false); + expect(String((s as { error?: string }).error)).toInclude("could not be started"); + }); + + it("an answer the frozen validator rejects (an over-cap line) is refused, not passed through", async () => { + const trust = trustFile("malformed", "x\n"); + const huge = `host ssh-ed25519 ${"A".repeat(5000)}`; + const fake = fakeRun({ "git.example.test": { stdout: `${huge}\n` }, "[git.example.test]:22": { code: 1 } }); + const res = await execSshHostKey(makeCtx("malformed"), cmd(), seamsFor(trust, fake.run)); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("malformed"); + }); + + it("dispatch routes ssh_host_key to the HANDLER, not to unsupported (gate ON, no keygen, no trust file -> empty fact)", async () => { + // Gate ON, a keygen that resolves, and an ABSENT trust file: only the + // handler can produce the empty ok:true answer; `unsupported` here would + // mean the dispatch arm is missing, the gate's words would mean it ran + // before the lookup. + process.env.SUBSHELL_SSH_KEYGEN_PATH = "/bin/sh"; + process.env.SUBSHELL_SSH_KNOWN_HOSTS = join(base, "dispatch-missing", "known_hosts"); + const res = await dispatchCommand(makeCtx("dispatch"), { + type: "ssh_host_key", + host: "git.example.test", + port: 22, + user: null, + } satisfies NodeCommandBody); + expect(res).toEqual({ ok: true, data: { lines: [] } }); + }); + + it("dispatch routes to the HANDLER's binary-missing class when the file EXISTS (still not `unsupported`)", async () => { + process.env.SUBSHELL_SSH_KEYGEN_PATH = "/nonexistent/ssh-keygen-binary"; + const trust = trustFile("dispatch-exists", `${LINE_A}\n`); + process.env.SUBSHELL_SSH_KNOWN_HOSTS = trust; + const res = await dispatchCommand(makeCtx("dispatch2"), { + type: "ssh_host_key", + host: "git.example.test", + port: 22, + user: null, + } satisfies NodeCommandBody); + expect(res.ok).toBe(false); + expect(String((res as { error?: string }).error)).toInclude("binary missing"); + }); +}); diff --git a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts index 90e727daa..831369f74 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts @@ -81,6 +81,9 @@ function openCmd(over: Partial = {}): SshRelayOpenCommand { fingerprints: ["SHA256:AAAA"], lifetimeMs: 30_000, paneId: PANE, + // Task 12: the relay-open's required destination pin; the B branch writes + // it to the pane's 0600 known_hosts file before binding the socket. + hostPin: "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000", ...over, }; } diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts index f4af31db4..6ee7df87b 100644 --- a/apps/node/agent/src/__tests__/relay-responder.test.ts +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { existsSync, mkdtempSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { createServer, type Server, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -281,6 +281,8 @@ const evilKeysReady: Promise = es256Pair(); const KEY_IN = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-IN"))]); const KEY_OUT = Buffer.concat([Buffer.from([0, 0, 0, 7]), Buffer.from("ssh-ed25519"), sshStr(Buffer.from("KEY-OUT"))]); +// Task 12: the destination's pinned known_hosts line the relay-open carries. +const HOST_PIN = "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000"; const ROSTER: RosterEntry[] = [ { blob: KEY_IN, comment: "granted key" }, { blob: KEY_OUT, comment: "ungranted key" }, @@ -340,6 +342,7 @@ async function openFixture( fingerprints: [fp(KEY_IN)], lifetimeMs: 30_000, paneId: "pane-a-test", // grammar (b): the command names the pane + hostPin: HOST_PIN, // grammar (Task 12): the destination's pinned key line ...overrides, }; await openARelaySession({ @@ -1036,6 +1039,7 @@ test("openARelaySession refuses a command that does not name this machine in its fingerprints: [], lifetimeMs: 30_000, paneId: "pane-a-test", + hostPin: HOST_PIN, }; // role B: the A branch is not for this command. await expect( @@ -1106,6 +1110,7 @@ test("openARelaySession enforces §4.4 byte-equality on B's pin and the duplicat fingerprints: [], lifetimeMs: 30_000, paneId: "pane-a-test", + hostPin: HOST_PIN, }; // A moved signing half under the SAME peer id: a hard block, never a silent overwrite. await expect( @@ -1281,6 +1286,7 @@ async function refusalCmd(over: Partial): Promise { + const aKeys = await machineKeys(); + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-hostpin-")); + const cmd: SshRelayOpenCommand = { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-pin", + role: "B", + aNodeId: "a-node", + bNodeId: "b-node", // selfNodeId below matches: this machine is B + peerSigningPublicKey: aKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(aKeys.encryption.publicJwk, "utf8").toString("base64"), + grantId: "grant-1", + fingerprints: [fp(KEY_IN)], + lifetimeMs: 30_000, + paneId: "pane-b-test", + hostPin: HOST_PIN, + }; + const { socketPath } = await openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-b-test", + cmd, + sendRelayFrame: () => {}, + }); + try { + const pinPath = join(dataDir, "ssh", "pane-b-test", "known_hosts"); + // The file is the delivered line, byte-for-byte, plus the trailing + // newline ssh_config's own file reader expects. + expect(readFileSync(pinPath, "utf8")).toBe(`${HOST_PIN}\n`); + // 0600 (enforced past any umask), and the socket bound beside it. + expect(statSync(pinPath).mode & 0o777).toBe(0o600); + expect(existsSync(socketPath)).toBe(true); + } finally { + relay.closeAll("lifetime-expiry"); + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("openBRelaySession refuses a malformed host pin WITHOUT writing a file", async () => { + const aKeys = await machineKeys(); + const relay = new RelaySessions(); + const dataDir = mkdtempSync(join(tmpdir(), "subshell-relay-badmin-")); + const cmd: SshRelayOpenCommand = { + type: "ssh_relay_open", + relayId: "relay-1", + ref: "r-bad", + role: "B", + aNodeId: "a-node", + bNodeId: "b-node", + peerSigningPublicKey: aKeys.signing.publicJwk, + peerEncryptPublicKey: Buffer.from(aKeys.encryption.publicJwk, "utf8").toString("base64"), + grantId: "grant-1", + fingerprints: [fp(KEY_IN)], + lifetimeMs: 30_000, + paneId: "pane-bad", + // A smuggled second entry: the executor's last-station shape check (the + // grammar refuses this on the wire too, but the write is the trust file). + hostPin: `host ssh-rsa AAA\nother-host ssh-rsa BBB`, + }; + await expect( + openBRelaySession({ + relay, + dataDir, + selfNodeId: "b-node", + paneId: "pane-bad", + cmd, + sendRelayFrame: () => {}, + }), + ).rejects.toThrow(/host pin/); + // Nothing bound, nothing written: the pane's dir does not even exist. + expect(existsSync(join(dataDir, "ssh", "pane-bad", "known_hosts"))).toBe(false); + expect(relay.size).toBe(0); + rmSync(dataDir, { recursive: true, force: true }); +}); diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index ed4a45ca3..c199a7dba 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -29,6 +29,7 @@ import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; +import { execSshHostKey } from "./ssh-host-key.js"; import { execSshAgentIdentities, execSshRegisterIdentity } from "./ssh-identity.js"; import { execSshRelayClose, execSshRelayOpen } from "./ssh-relay-exec.js"; import { execLogRead, execTailStart, execTailStop } from "./tail.js"; @@ -62,6 +63,10 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * agent's public identities as fingerprints plus comments, blobs withheld; * gated like the config arms, because reading the account's agent IS an SSH * act, and it probes the agent's numbering before asking, never guessing), + * the host-key capture `ssh_host_key` (spec 2026-10-08 §9, Task 12 - the + * `known_hosts` entries for one destination, evaluated by `ssh-keygen -F` so + * OpenSSH's own matching answers; gated like the config arms, and an absent + * file is the honest empty answer the capture fails closed on), * and the brokered relay pair `ssh_relay_open` / `ssh_relay_close` * (spec 2026-10-08 §5.1, protocol 18 - open consults the gate then drives the * T6/T7 pairing branches by the command's own role; close is ungated teardown, @@ -151,6 +156,8 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return await execSshRegisterIdentity(ctx); case "ssh_agent_identities": return await execSshAgentIdentities(ctx); + case "ssh_host_key": + return await execSshHostKey(ctx, cmd); case "ssh_relay_open": return await execSshRelayOpen(ctx, cmd); case "ssh_relay_close": diff --git a/apps/node/agent/src/commands/ssh-host-key.ts b/apps/node/agent/src/commands/ssh-host-key.ts new file mode 100644 index 000000000..5f950dadf --- /dev/null +++ b/apps/node/agent/src/commands/ssh-host-key.ts @@ -0,0 +1,144 @@ +import { existsSync } from "node:fs"; +import { runSshProcess, sshChildPath } from "@internal/pane-runtime"; +import { type JsonValue, parseNodeSshHostKey, type SshHostKeyCommand } from "@internal/subshell-protocol"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; +import type { CommandContext, CommandResult } from "./context.js"; +import { knownHostsPath, resolveSshKeygenBin, SSH_GATE_REFUSAL } from "./ssh-shared.js"; + +/** + * `ssh_host_key` (spec 2026-10-08 §9, Task 12): answer with the + * `known_hosts` entries this machine has recorded for ONE resolved + * destination, so the plane can capture the pin that later rides the + * relay-open to B. + * + * The evaluator is OpenSSH's own `ssh-keygen -F`, not a hand-rolled parse. + * known_hosts matching (pattern globs, the `[host]:port` and `user@host` + * spellings, hashed entries) is OpenSSH's own logic; anything less is the + * loose re-parse the design refuses. One `-F` run per candidate spelling + * covers the forms a file may carry, and the union is deduped by line - the + * answer is A's recorded trust, verbatim, never a reconstruction of it. + * + * The three rules this arm keeps, shared with the roster read: + * - **The gate speaks first.** Reading the account's trust file is an SSH + * act; a machine whose mirror is not ON refuses {@link SSH_GATE_REFUSAL} + * before any lookup or spawn. + * - **Fail closed, never fabricate.** No ssh-keygen, an unreadable file, a + * timeout, or an answer the grammar rejects each answer `ok:false` with a + * named error. The ABSENT case is the one `ok:true` answer that carries + * nothing: no `known_hosts` file, or a file with no entry for this + * destination, is the honest fact "A has recorded nothing" - and the + * capture service fails grant creation closed on exactly that empty + * answer, which is why the emptiness is reported as a fact and never as + * an error the caller might paper over. + * - **No audit row, no logging of the bytes.** The durable record of a pin + * is the `node.ssh_host_pin.create` row the CAPTURE ACT writes, naming the + * destination and a fingerprint only; this command is the question, and a + * question writes nothing. The key bytes are public material, but they + * belong in the pin row and the relay command, not in this machine's log. + */ + +/** Test seams for {@link execSshHostKey} (production omits all three). */ +export interface HostKeySeams { + /** Where ssh-keygen is; defaults to this machine's own lookup ladder. */ + resolveKeygenBin?: () => Promise; + /** The trust file to read; defaults to the connecting account's `~/.ssh/known_hosts`. */ + trustFile?: () => string; + /** One ssh-keygen run; defaults to {@link runSshProcess}. The seam's contract: absolute argv[0], full child env, deadline. */ + runProcess?: typeof runSshProcess; +} + +/** + * The candidate spellings `ssh-keygen -F` must be asked with to cover how a + * destination may appear in known_hosts: OpenSSH looks a connection up under + * `[user@host]:port`, `[host]:port`, `user@host`, and the bare `host`, and + * the same set is what capture asks with. An honest superset (the bracketed + * spelling is asked at the default port too) costs nothing: a pattern only + * matches a candidate it actually spells, and any line this returns is a + * line A recorded for THIS destination's host/user/port triple. + * + * The bracket rule is OpenSSH's own spelling: an IPv6 literal carries its + * brackets in known_hosts already (`[::1]:2222`, and `[::1]` for the + * default-port entry), so a host spelled with brackets is NOT wrapped again. + * A resolved snapshot's host is either a plain name or a bracketed literal - + * the grammar keeps the brackets in (the ssh-render doc says so) - so this + * one rule reproduces the exact bytes ssh wrote. + */ +export function sshHostKeyCandidates({ host, port, user }: SshHostKeyCommand): string[] { + const bracket = (h: string): string => (h.startsWith("[") ? h : `[${h}]`); + const out = [`${bracket(host)}:${port}`, host]; + if (user !== null) out.push(`${bracket(`${user}@${host}`)}:${port}`, `${user}@${host}`); + return [...new Set(out)]; +} + +/** + * One ssh-keygen -F run against one candidate, stdout split into entry lines. + * ssh-keygen precedes each found entry with a `# Host x found: line N` + * comment; those and blanks are the only lines the file's OWN entries are + * not, and both are dropped here. Non-zero exit with no output is OpenSSH's + * spelling of "this candidate matched nothing" (and of an unreadable file - + * the caller distinguishes those by whether ANY candidate produced bytes + * and by the stderr it logs); non-zero with output still yields its entries. + */ +function entryLines(stdout: string): string[] { + const lines: string[] = []; + for (const raw of stdout.split("\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + lines.push(line); + } + return lines; +} + +/** Execute `ssh_host_key` for one destination. */ +export async function execSshHostKey( + ctx: CommandContext, + cmd: SshHostKeyCommand, + seams?: HostKeySeams, +): Promise { + if (!sshAllowed(readSshEnabled(ctx.config.dataDir))) return { ok: false, error: SSH_GATE_REFUSAL }; + const keygen = await (seams?.resolveKeygenBin ?? resolveSshKeygenBin)(); + if (keygen === null) return { ok: false, error: "ssh-keygen binary missing: ssh-keygen" }; + const trustFile = (seams?.trustFile ?? knownHostsPath)(); + // An absent trust file is not an error to throw around - it is the common + // honest case (a machine that has never connected anywhere), reported as + // the empty fact so the capture's fail-closed branch, not a transport + // failure, is what the operator's screen names. + if (!existsSync(trustFile)) { + const validated = parseNodeSshHostKey({ lines: [] }); + if (validated === null) return { ok: false, error: "malformed host-key answer" }; + return { ok: true, data: validated as unknown as JsonValue }; + } + const runProcess = seams?.runProcess ?? runSshProcess; + const env: Record = { + HOME: process.env.HOME ?? "", + PATH: await sshChildPath(), + }; + const found = new Set(); + let sawReadFailure = false; + for (const candidate of sshHostKeyCandidates(cmd)) { + // `-f ` (NOT `-F file`): the search target spelled as OpenSSH's + // own usage prints, so the read never depends on whose HOME the daemon + // was launched under. stdin ignored, streams captured - runSshProcess's + // short-RPC posture, the same one `ssh -G` resolution runs under. + const run = await runProcess([keygen, "-f", trustFile, "-F", candidate], env, 10_000); + if (run.spawnError) return { ok: false, error: "ssh-keygen could not be started on this machine" }; + if (run.timedOut) return { ok: false, error: "ssh-keygen did not finish within the deadline" }; + if (run.code !== 0 && run.stdout.trim() === "") { + // Non-zero AND silent: "no match" (normal, keep looking) is + // indistinguishable from "cannot read the file" only by stderr, and + // the file's readability was not claimed - so an unreadable file must + // not read as "A recorded nothing". One line to the log (names never + // key bytes); the refusal goes to the plane as the named error. + sawReadFailure = sawReadFailure || run.stderr.trim() !== ""; + continue; + } + for (const line of entryLines(run.stdout)) found.add(line); + } + if (found.size === 0 && sawReadFailure) { + return { ok: false, error: "the known_hosts trust file could not be read on this machine" }; + } + const validated = parseNodeSshHostKey({ lines: [...found] }); + if (validated === null) return { ok: false, error: "malformed host-key answer" }; + // The seam cast: JSON-safe by construction, contract owned by `node-results.ts`. + return { ok: true, data: validated as unknown as JsonValue }; +} diff --git a/apps/node/agent/src/commands/ssh-relay.ts b/apps/node/agent/src/commands/ssh-relay.ts index 3d928f453..013cf9959 100644 --- a/apps/node/agent/src/commands/ssh-relay.ts +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -1,6 +1,10 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { buildSshKnownHostsPath, enforceMode } from "@internal/pane-runtime"; import { bytesOfJwk, + isSshKnownHostsPinLine, type RelayFrame, SSH_RELAY_LIFETIME_MS, SSH_RELAY_MAX_PER_NODE, @@ -254,15 +258,18 @@ function decodePeerEncryptionJwk(b64: string): string { /** * Open the B side of a brokered relay pairing: enforce §4.4's machine pin on - * A (first pairing pins both halves, a moved pin is a hard block), then bind + * A (first pairing pins both halves, a moved pin is a hard block), write the + * destination's pinned host-key line to the pane's 0600 known_hosts file + * (Task 12, §9 - the trust source the relay-mode config names), then bind * the pane's agent proxy socket and register its ref. Throws a named refusal * (the dispatch wrapper turns it into `ok:false`) on: role other than B, an - * unusable peer key, a MOVED pin (naming the peer and §4.5's re-pair), a ref - * already owned, a ref the plane already closed (tombstoned), or the - * node-side registry cap - and EVERY refusal releases the proxy this call - * just bound (fix round T8 MAJOR 1: a thrown cap refusal once bypassed that - * release). Resolves with the composed socket path - the value the launch's - * scoped `SSH_AUTH_SOCK` must match byte for byte (§5.2). + * unusable peer key, a MOVED pin (naming the peer and §4.5's re-pair), a + * malformed host pin (never a second trust entry), a ref already owned, a + * ref the plane already closed (tombstoned), or the node-side registry cap - + * and EVERY refusal releases the proxy this call just bound (fix round T8 + * MAJOR 1: a thrown cap refusal once bypassed that release). Resolves with + * the composed socket path - the value the launch's scoped `SSH_AUTH_SOCK` + * must match byte for byte (§5.2). * * The gate (ssh-enabled mirror) is the CALLER's first check, like every SSH * arm's: this function is the pairing's mechanics, not the policy. @@ -303,6 +310,31 @@ export async function openBRelaySession(args: BRelaySessionArgs): Promise<{ sock ); } + // The destination's pinned host-key line, written to the pane's 0600 + // known_hosts file BEFORE the proxy binds (Task 12, spec 2026-10-08 §9): + // the pane's `ssh -F` reads the relay-mode config, whose one + // UserKnownHostsFile names this exact path. The line is verbatim from the + // signed command; the shape is re-checked HERE because this is the last + // station before a trust file a live ssh consults - a smuggled newline + // would be a second, plane-authored entry, and `yes` would then block on + // the wrong key. The path is this machine's OWN derivation + // ({@link buildSshKnownHostsPath}(dataDir, paneId)), never a name taken + // from the wire, so a hostile plane cannot point the write outside the + // per-session dir that leaves with the pane. Refusal throws (dispatch + // answers ok:false); nothing was bound yet, so there is no resource to + // release on this path. + if (!isSshKnownHostsPinLine(cmd.hostPin)) { + throw new Error("relay open refused: the delivered host pin is not one known_hosts line"); + } + const hostPinPath = buildSshKnownHostsPath(args.dataDir, args.paneId); + { + const dir = dirname(hostPinPath); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await enforceMode(dir, 0o700); + await writeFile(hostPinPath, `${cmd.hostPin}\n`, { mode: 0o600 }); + await enforceMode(hostPinPath, 0o600); // umask cannot leak bits past this + } + const identity = await loadOrCreateIdentity(args.dataDir); const proxy = await startAgentProxy({ dataDir: args.dataDir, diff --git a/apps/node/agent/src/commands/ssh-shared.ts b/apps/node/agent/src/commands/ssh-shared.ts index b6d895798..9f81f794b 100644 --- a/apps/node/agent/src/commands/ssh-shared.ts +++ b/apps/node/agent/src/commands/ssh-shared.ts @@ -23,9 +23,20 @@ import { findBinary } from "@internal/pane-runtime"; /** env-override seam for the ssh binary, spelled like the harness overrides. */ export const SSH_BINARY_ENV = "SUBSHELL_SSH_PATH"; +/** env-override seam for ssh-keygen, the `ssh_host_key` capture arm's binary (Task 12). */ +export const SSH_KEYGEN_BINARY_ENV = "SUBSHELL_SSH_KEYGEN_PATH"; + /** The known install locations the ladder falls back to. */ const SSH_KNOWN_PATHS = ["/usr/bin/ssh", "/bin/ssh", "/usr/local/bin/ssh", "/opt/homebrew/bin/ssh"]; +/** ssh-keygen's install locations: the same packages that ship `ssh`, beside it. */ +const SSH_KEYGEN_KNOWN_PATHS = [ + "/usr/bin/ssh-keygen", + "/bin/ssh-keygen", + "/usr/local/bin/ssh-keygen", + "/opt/homebrew/bin/ssh-keygen", +]; + /** * The gate's own words, spelled once: a machine whose local mirror is not ON * refuses EVERY SSH command with exactly this, before any binary lookup or @@ -39,6 +50,23 @@ export async function resolveSshBin(): Promise { return await findBinary("ssh", SSH_BINARY_ENV, SSH_KNOWN_PATHS); } +/** Resolve the ssh-keygen binary for this machine, or null (the `ssh_host_key` arm). */ +export async function resolveSshKeygenBin(): Promise { + return await findBinary("ssh-keygen", SSH_KEYGEN_BINARY_ENV, SSH_KEYGEN_KNOWN_PATHS); +} + +/** + * The connecting account's `known_hosts` trust file - the capture source + * (spec 2026-10-08 §9: A's trust in D is read from A's OWN file). Overridable + * by env for tests and non-default homes, exactly like the binary seams; the + * default is `~/.ssh/known_hosts`. + */ +export function knownHostsPath(): string { + const override = process.env.SUBSHELL_SSH_KNOWN_HOSTS; + if (override !== undefined && override !== "") return override; + return `${connectingHomeDir()}/.ssh/known_hosts`; +} + /** The connecting account's home, the same way the daemon itself sees it. */ export function connectingHomeDir(): string { return process.env.HOME || homedir(); From 451b53d42bfe953f796a7d466c45c26ce55056a3 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 16:42:42 -0700 Subject: [PATCH 57/92] feat(ssh-host-pins): the TOFU store - capture from A, explicit supply, delete-recovery, audited by fingerprint only Migration 0051 gives a pending grant request its resolved destination (user@host:port, nullable: legacy rows fail the approval by name), and the new ssh-host-pins service owns the ssh_host_pins rows 0050 shipped: captureHostPin asks the key home over the ssh_host_key arm (or takes one explicit known_hosts line from the trust screen), pins exactly one answer or refuses by name (no-pin / ambiguous / changed), never overwrites a standing pin, and audits destination + SHA256 fingerprint only - the key bytes never enter an audit row. Delete + re-capture is the TOFU recovery; pins outlive the grants that captured them. The ssh-rpc seam gains sshHostKey; backend-errors gains SSH_HOST_PIN_MISSING / _CHANGED / _INVALID. --- .../__tests__/backup-cli-subprocess.test.ts | 17 +- apps/server/api/src/db/migrate.ts | 5 + .../0051-ssh-request-destination.ts | 20 + .../0051-ssh-request-destination.test.ts | 96 ++++ .../repositories/ssh-host-pins.repository.ts | 78 ++++ .../api/src/db/types/ssh-grants.db-types.ts | 22 +- .../src/db/types/ssh-saved-hosts.db-types.ts | 32 ++ .../__tests__/ssh-host-pins.service.test.ts | 309 +++++++++++++ .../api/src/services/backups/database.ts | 2 +- apps/server/api/src/services/nodes/ssh-rpc.ts | 30 ++ .../api/src/services/ssh-host-pins.service.ts | 413 ++++++++++++++++++ packages/backend-errors/src/error-codes.ts | 36 ++ 12 files changed, 1050 insertions(+), 10 deletions(-) create mode 100644 apps/server/api/src/db/migrations/0051-ssh-request-destination.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0051-ssh-request-destination.test.ts create mode 100644 apps/server/api/src/db/repositories/ssh-host-pins.repository.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts create mode 100644 apps/server/api/src/services/ssh-host-pins.service.ts diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index e0ed7c3ee..20f49f0d0 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,17 +392,17 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. - // WHY 0047+0048+0049+0050 together: this era's four migrations are the tail - // of the registered ledger, so the prefix runs contiguously through 0046 and - // boot re-applies all four. Advancing LATEST_BACKUP_MIGRATION (0050) means - // the strip set moves with it in the same commit. + // WHY 0047+0048+0049+0050+0051 together: this era's five migrations are the + // tail of the registered ledger, so the prefix runs contiguously through 0046 + // and boot re-applies all five. Advancing LATEST_BACKUP_MIGRATION (0051) + // means the strip set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; DROP TABLE ssh_key_grants; DROP TABLE ssh_grant_requests; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; DROP TABLE ssh_key_grants; DROP TABLE ssh_grant_requests; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination')", ) .all(), ).toEqual([]); @@ -430,7 +430,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination') ORDER BY name", ) .all(), ).toEqual([ @@ -438,6 +438,7 @@ test("real entry: available older upgrade snapshot restores and migrates before { name: "0048-ssh-launch-and-saved-hosts" }, { name: "0049-ssh-relay-identity" }, { name: "0050-ssh-grants" }, + { name: "0051-ssh-request-destination" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 0fed9f7cd..d043abfdf 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -50,6 +50,7 @@ import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; import * as sshRelayIdentityMigration from "@/db/migrations/0049-ssh-relay-identity.js"; import * as sshGrantsMigration from "@/db/migrations/0050-ssh-grants.js"; +import * as sshRequestDestinationMigration from "@/db/migrations/0051-ssh-request-destination.js"; /** * Runs all pending Kysely migrations against the app database. @@ -149,6 +150,10 @@ export async function runMigrations(): Promise { // The grant tier: standing key grants, the durable first-use approval // queue, and the M2 host-pin store (spec 2026-10-08 §6, §9). "0050-ssh-grants": sshGrantsMigration, + // The first-use request carries the full resolved destination so the + // approval that creates the grant can capture the host-key pin at the + // exact `user@host:port` the launch dialed (spec 2026-10-08 §9). + "0051-ssh-request-destination": sshRequestDestinationMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0051-ssh-request-destination.ts b/apps/server/api/src/db/migrations/0051-ssh-request-destination.ts new file mode 100644 index 000000000..74330cd84 --- /dev/null +++ b/apps/server/api/src/db/migrations/0051-ssh-request-destination.ts @@ -0,0 +1,20 @@ +import type { Kysely } from "kysely"; + +/** + * The first-use request carries the FULL resolved destination (spec + * 2026-10-08 §9, Task 12). The grant's selector is hostname-scoped (matched + * against the resolved host at launch); the host-key PIN is keyed per + * `user@host:port`, and the approval that creates the grant is also the + * capture moment for that pin - which means the durable pending row must + * outlive the asking launch with the exact destination the launch dialed, not + * just its host. Nullable BY DESIGN: a pending row written before this + * migration exists cannot be back-filled (the launch is gone), and the + * approval path fails closed on null rather than guessing a port or a user. + */ +export async function up(db: Kysely): Promise { + await db.schema.alterTable("ssh_grant_requests").addColumn("destination", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("ssh_grant_requests").dropColumn("destination").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0051-ssh-request-destination.test.ts b/apps/server/api/src/db/migrations/__tests__/0051-ssh-request-destination.test.ts new file mode 100644 index 000000000..d16e7d44c --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0051-ssh-request-destination.test.ts @@ -0,0 +1,96 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { CamelCasePlugin, Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as grantsMigration from "@/db/migrations/0050-ssh-grants.js"; +import * as destinationMigration from "@/db/migrations/0051-ssh-request-destination.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + sshGrantRequests: Record; +} + +/** + * Task 12's one storage change: `ssh_grant_requests` gains `destination`, the + * full resolved `user@host:port` the asking launch dialed. Nullable on + * purpose (a pre-T12 pending row cannot be back-filled, and approval fails + * closed on null); everything else 0050 froze stays untouched. + */ +describe("migration 0051-ssh-request-destination", () => { + let dbFile: string; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0051-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ + dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }), + plugins: [new CamelCasePlugin()], + }); + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await sql`INSERT INTO user (id, name, email) VALUES ('u1', 'Owner', 'u1@test')`.execute(db); + await grantsMigration.up(db); + await destinationMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds destination as a nullable column on the request row", async () => { + const cols = await sql<{ name: string; notnull: number }>` + SELECT name, "notnull" FROM pragma_table_info('ssh_grant_requests') + `.execute(db); + const names = cols.rows.map((r) => r.name); + expect(names).toContain("destination"); + const destination = cols.rows.find((r) => r.name === "destination"); + expect(destination?.notnull).toBe(0); // legacy pending rows carry null; approval refuses them by name + }); + + it("an old-shaped row still inserts (null destination) and a full one round-trips", async () => { + await db + .insertInto("sshGrantRequests") + .values({ + id: "req-legacy", + ownerUserId: "u1", + keyHomeNodeId: "n-a", + resolvedSelector: "git.example.test", + requestedFingerprints: null, + paneId: "p1", + bNodeId: "n-b", + expiresAt: "2026-10-09T00:00:00.000Z", + status: "pending", + createdAt: "2026-10-08T00:00:00.000Z", + } as never) + .execute(); + await db + .insertInto("sshGrantRequests") + .values({ + id: "req-full", + ownerUserId: "u1", + keyHomeNodeId: "n-a", + resolvedSelector: "git.example.test", + requestedFingerprints: null, + paneId: "p2", + bNodeId: "n-b", + expiresAt: "2026-10-09T00:00:00.000Z", + status: "pending", + createdAt: "2026-10-08T00:00:00.000Z", + destination: "deploy@git.example.test:2222", + } as never) + .execute(); + const rows = await db.selectFrom("sshGrantRequests").select(["id", "destination"]).execute(); + expect(rows).toEqual( + expect.arrayContaining([ + { id: "req-legacy", destination: null }, + { id: "req-full", destination: "deploy@git.example.test:2222" }, + ]), + ); + }); + + it("down() removes only the new column and keeps the 0050 tables", async () => { + await destinationMigration.down(db); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('ssh_grant_requests')`.execute(db); + expect(cols.rows.map((r) => r.name)).not.toContain("destination"); + expect(await db.selectFrom("sshHostPins").selectAll().execute()).toEqual([]); + }); +}); diff --git a/apps/server/api/src/db/repositories/ssh-host-pins.repository.ts b/apps/server/api/src/db/repositories/ssh-host-pins.repository.ts new file mode 100644 index 000000000..7c4ceba04 --- /dev/null +++ b/apps/server/api/src/db/repositories/ssh-host-pins.repository.ts @@ -0,0 +1,78 @@ +import { BaseRepository } from "@/db/repositories/base.repository.js"; +import type { SshHostPinTable } from "@/db/types/ssh-grants.db-types.js"; + +/** + * Repository for the M2 host-key TOFU store (spec 2026-10-08 §9; the table + * ships in migration 0050, this file is T12's logic on it). The row key is + * the UNIQUE `(owner_user_id, destination)`: one pin per owner per resolved + * `user@host:port`, independent of grants (one grant selector over many hosts + * yields many pins, and a grant's revoke never touches a pin - the pin is the + * owner's TOFU record, not the grant's property). + * + * The capture path has ONE write: {@link insertPin}. There is deliberately NO + * update-by-key write here: a differing key for a pinned destination is §9's + * hard block, not a row to overwrite - recovery is {@link deletePin} plus a + * fresh capture at the next grant creation. `host_key` is a PUBLIC known_hosts + * line, and the row is the durable record the audit names only a fingerprint + * of; nothing in this file logs or echoes the bytes. + * + * Every read and write is OWNER-SCOPED at the WHERE clause, the saved-hosts + * posture verbatim: a foreign row is absent, never "forbidden". + */ +export class SshHostPinsRepository extends BaseRepository { + /** Record a capture. The caller validated the row (line grammar, key choice). */ + async insertPin(pin: SshHostPinTable): Promise { + await this.db.insertInto("sshHostPins").values(pin).execute(); + } + + /** The owner's pin for one canonical destination; null when unpinned. */ + async getPin(ownerUserId: string, destination: string): Promise { + const row = await this.db + .selectFrom("sshHostPins") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .where("destination", "=", destination) + .executeTakeFirst(); + return row ?? null; + } + + /** + * Re-stamp `updated_at` after an ACCEPTED match (a capture at the same key + * agreeing with the standing pin). The row's key, id, and bytes never move + * - only the "last accepted match" stamp does, and only on a match: a + * differing key is §9's block, not an update. + */ + async touchPin(ownerUserId: string, destination: string, at: string): Promise { + await this.db + .updateTable("sshHostPins") + .set({ updatedAt: at }) + .where("ownerUserId", "=", ownerUserId) + .where("destination", "=", destination) + .execute(); + } + + /** The owner's pins for the trust screen, newest first. */ + async listPins(ownerUserId: string): Promise { + return await this.db + .selectFrom("sshHostPins") + .selectAll() + .where("ownerUserId", "=", ownerUserId) + .orderBy("createdAt", "desc") + .execute(); + } + + /** + * Delete one of the owner's pins; false for foreign AND absent (one 404 + * at the route). The §9 recovery half: TOFU re-decides after a delete, so + * the next capture at a differing key SUCCEEDS as a new first-capture + * rather than overwriting the old one. + */ + async deletePin(ownerUserId: string, destination: string): Promise { + const res = await this.db + .deleteFrom("sshHostPins") + .where("ownerUserId", "=", ownerUserId) + .where("destination", "=", destination) + .executeTakeFirst(); + return Number(res.numDeletedRows ?? 0n) > 0; + } +} diff --git a/apps/server/api/src/db/types/ssh-grants.db-types.ts b/apps/server/api/src/db/types/ssh-grants.db-types.ts index e5781b9ea..5e731431c 100644 --- a/apps/server/api/src/db/types/ssh-grants.db-types.ts +++ b/apps/server/api/src/db/types/ssh-grants.db-types.ts @@ -86,6 +86,17 @@ export interface SshGrantRequestTable { keyHomeNodeId: string; /** The resolved destination hostname the launch would have dialed (stored resolved, like the grant) */ resolvedSelector: string; + /** + * The FULL canonical destination `user@host:port` the asking launch dialed + * (the `sshCanonicalDestination` spelling, migration 0051). The selector + * above is hostname-scoped (the grant's match key); the host-key PIN is + * keyed per this triple, and the approval that creates the grant captures + * the pin at exactly what was dialed - which requires the row to outlive + * the launch carrying it. NULL only on a row written before 0051 (the + * asking launch is gone, nothing to back-fill); approval refuses such a + * row by name rather than guess a port or a user. + */ + destination: string | null; /** JSON array of pre-selected `SHA256:` fingerprints, or null when none rode the request (the approver picks) */ requestedFingerprints: string | null; /** B's pane (subshell id) whose launch asked - what the approvals screen names as the requester */ @@ -115,7 +126,16 @@ export interface SshHostPinTable { ownerUserId: string; /** Canonical resolved destination `user@host:port` (the `sshCanonicalDestination` spelling) */ destination: string; - /** The pinned public host key line (` `, OpenSSH known_hosts spelling), never a secret */ + /** + * The pinned public host-key entry, one OpenSSH `known_hosts` line: the + * destination's pattern, the key type, and the base64 key material (plus + * any comment A's own file carried) - stored VERBATIM from A's recorded + * trust so B's file carries exactly the bytes A matched, never a + * reconstruction that re-spells A's entry. Public material end to end + * (a known_hosts entry CHECKS a server, it is not a secret); the audit + * rows name the destination and this line's `SHA256:` fingerprint, never + * the bytes themselves. + */ hostKey: string; /** ISO 8601 first capture (the TOFU moment) */ createdAt: string; diff --git a/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts index 1070089f5..43a828e32 100644 --- a/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts +++ b/apps/server/api/src/db/types/ssh-saved-hosts.db-types.ts @@ -46,3 +46,35 @@ export function sshCanonicalDestination({ }): string { return user === null ? `${host}:${port}` : `${user}@${host}:${port}`; } + +/** A canonical destination's three facts (the {@link sshCanonicalDestination} shape). */ +export interface SshDestinationTriple { + host: string; + port: number; + user: string | null; +} + +/** + * The exact inverse of {@link sshCanonicalDestination}: split the stored + * `user@host:port` back into its three facts (Task 12: the approval captures + * the host-key pin by ASKING A for a destination, and the ask takes the + * triple). Unambiguous because the composer built it: the user is before the + * first `@`, the port after the LAST `:` - and the host is either a plain + * name or a bracketed IPv6 literal, so a `:` inside a host never sits after + * the final one. null for anything the composer would not have written: no + * port part, a non-numeric or out-of-range port, an empty host or user. + */ +export function parseSshCanonicalDestination(text: string): SshDestinationTriple | null { + const at = text.indexOf("@"); + const user = at === -1 ? null : text.slice(0, at); + const rest = at === -1 ? text : text.slice(at + 1); + const colon = rest.lastIndexOf(":"); + if (colon < 1) return null; + const host = rest.slice(0, colon); + const portText = rest.slice(colon + 1); + if (host === "" || (user !== null && (user === "" || user.includes("@")))) return null; + if (!/^[0-9]{1,5}$/.test(portText)) return null; + const port = Number(portText); + if (port < 1 || port > 65_535) return null; + return { host, port, user }; +} diff --git a/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts b/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts new file mode 100644 index 000000000..5b5cf8eff --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts @@ -0,0 +1,309 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import type { NodeSshHostKeyResult } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; +import { + captureHostPin, + deleteHostPin, + hostKeyFingerprints, + hostPinFor, + hostPinRefusal, + listHostPins, + SshHostPinError, + setSshHostPinsDepsForTests, +} from "@/services/ssh-host-pins.service.js"; + +/** + * The host-key pin store (spec 2026-10-08 §9, Task 12). The properties this + * suite exists to pin, one per §9 sentence: + * + * - A CAPTURE writes the destination's pin from A's `known_hosts` answer and + * audits `node.ssh_host_pin.create` naming destination + fingerprint ONLY: + * the audit row and every log path must never carry the line's bytes, and + * the fingerprint is OpenSSH's own `SHA256:` over the decoded blob. + * - The EMPTY answer is the named `no-pin` failure (mapped to + * SSH_HOST_PIN_MISSING): a relay grant never stands without its pin. + * - MULTIPLE distinct keys are `ambiguous`, refused rather than picked. + * - A capture at a key DIFFERENT from the stored pin is `changed`: nothing + * is written, nothing is overwritten, the row stands verbatim. + * - The SAME key again is idempotent (no second create row; updatedAt moves). + * - DELETE + re-capture is the TOFU recovery: the differing key becomes the + * new pin only AFTER the delete, and the delete audits destination + + * fingerprint. + * - Per-owner scoping: a foreign owner's pin is absent, never forbidden. + * - The explicit line (trust-screen supply) is grammar-checked and audits + * `via: explicit`. + * + * The A fetch is the service's own seam; no test here touches a socket, an + * ssh-keygen, or a developer's `~/.ssh`. + */ + +const HOST = "git.example.test"; +const DEST = `${HOST}:22`; +/** A real ed25519 public blob shape: the wire encoding, base64'd as known_hosts spells it. */ +const KEY_BYTES = Buffer.concat([ + Buffer.from([0, 0, 0, 11]), + Buffer.from("ssh-ed25519"), + Buffer.from([0, 0, 0, 32]), + Buffer.alloc(32, 7), +]); +const LINE = `${HOST} ssh-ed25519 ${KEY_BYTES.toString("base64")}`; +const OTHER_BYTES = Buffer.concat([Buffer.from([0, 0, 0, 11]), Buffer.from("ssh-ed25519"), Buffer.alloc(32, 9)]); +const OTHER_LINE = `${HOST} ssh-ed25519 ${OTHER_BYTES.toString("base64")}`; +const EXPECTED_FP = `SHA256:${createHash("sha256").update(KEY_BYTES).digest("base64url")}`; +const OTHER_FP = `SHA256:${createHash("sha256").update(OTHER_BYTES).digest("base64url")}`; + +/** A clock the suite moves: stamps assert the touch, the row, and the audit. */ +let clockMs = Date.parse("2026-10-08T00:00:00.000Z"); + +/** Scripted A: what the fetch answers, or the RPC error it throws. */ +let fetchAnswer: () => NodeSshHostKeyResult = () => ({ lines: [LINE] }); +let fetchCalls: { nodeId: string; triple: unknown }[] = []; + +function installDeps() { + fetchCalls = []; + setSshHostPinsDepsForTests({ + nowIso: () => new Date(clockMs).toISOString(), + fetchHostKey: async (nodeId, triple) => { + fetchCalls.push({ nodeId, triple }); + return fetchAnswer(); + }, + }); +} + +async function expectPinError(fn: () => Promise, code: SshHostPinError["code"]): Promise { + try { + await fn(); + } catch (err) { + expect(err).toBeInstanceOf(SshHostPinError); + const e = err as SshHostPinError; + expect(e.code).toBe(code); + return e; + } + throw new Error(`expected SshHostPinError(${code}); nothing threw`); +} + +let owner: string; +let stranger: string; +const emails: string[] = []; + +async function mkUser(tag: string): Promise { + const email = `hostpins-${tag}-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("hostpins-1"), + role: "user", + }); +} + +beforeAll(async () => { + await setupAuthTables(); + await ensureMigratedTestDb(); + owner = await mkUser("owner"); + stranger = await mkUser("stranger"); +}); + +afterAll(async () => { + setSshHostPinsDepsForTests(null); + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("auditEvents").execute(); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +beforeEach(async () => { + clockMs = Date.parse("2026-10-08T00:00:00.000Z"); + fetchAnswer = () => ({ lines: [LINE] }); + installDeps(); + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("auditEvents").execute(); +}); + +async function latestAudit(action: string): Promise> { + const rows = await db + .selectFrom("auditEvents") + .select(["metadataJson"]) + .where("action", "=", action) + .orderBy("createdAt", "desc") + .limit(1) + .execute(); + return rows[0] ? (JSON.parse(rows[0].metadataJson ?? "{}") as Record) : {}; +} + +describe("capture", () => { + it("writes the pin from A's answer and audits destination + fingerprint, never the bytes", async () => { + const row = await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(row.hostKey).toBe(LINE); + expect(row.destination).toBe(DEST); + // The ask carried the PARSED triple (not the raw text): host git.example.test, port 22, user null. + expect(fetchCalls).toEqual([{ nodeId: "node-a", triple: { host: HOST, port: 22, user: null } }]); + const audit = await latestAudit("node.ssh_host_pin.create"); + expect(audit.destination).toBe(DEST); + expect(audit.fingerprint).toBe(EXPECTED_FP); + expect(audit.via).toBe("captured"); + // The bytes are nowhere on the trail: the base64 key material is not in + // the audit metadata (the fingerprint is its digest, not its spelling). + const meta = JSON.stringify(audit); + expect(meta).not.toContain(KEY_BYTES.toString("base64")); + expect(hostKeyFingerprints(LINE)).toEqual([EXPECTED_FP]); + }); + + it("an empty answer is the named no-pin failure and writes NOTHING (a relay grant never lacks its pin)", async () => { + fetchAnswer = () => ({ lines: [] }); + const err = await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "no-pin", + ); + expect(hostPinRefusal(err)).toMatchObject({ status: 409, code: BackendErrorCodes.SSH_HOST_PIN_MISSING }); + expect(await hostPinFor({ ownerUserId: owner, destination: DEST })).toBeNull(); + const rows = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.create") + .execute(); + expect(rows).toHaveLength(0); + }); + + it("two distinct keys from A are refused (ambiguous), not picked", async () => { + fetchAnswer = () => ({ lines: [LINE, OTHER_LINE] }); + await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "ambiguous", + ); + expect(await hostPinFor({ ownerUserId: owner, destination: DEST })).toBeNull(); + }); + + it("a differing capture is the changed hard block: nothing written, the row stands verbatim", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + fetchAnswer = () => ({ lines: [OTHER_LINE] }); + const err = await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "changed", + ); + expect(hostPinRefusal(err).code).toBe(BackendErrorCodes.SSH_HOST_PIN_CHANGED); + const standing = await hostPinFor({ ownerUserId: owner, destination: DEST }); + expect(standing?.hostKey).toBe(LINE); // never overwritten + const rows = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.create") + .execute(); + expect(rows).toHaveLength(1); // the original capture is the only create row + }); + + it("the same key again is idempotent: no second create row, the updatedAt stamp moves", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + clockMs += 60_000; + const again = await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(again.updatedAt).toBe(new Date(clockMs).toISOString()); + const creates = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.create") + .execute(); + expect(creates).toHaveLength(1); + }); + + it("an explicit line (the trust-screen door) pins without asking A, audited via explicit", async () => { + const row = await captureHostPin({ ownerUserId: owner, aNodeId: null, destination: DEST, hostKeyLine: LINE }); + expect(row.hostKey).toBe(LINE); + expect(fetchCalls).toHaveLength(0); // supplied is never fetched + const audit = await latestAudit("node.ssh_host_pin.create"); + expect(audit.via).toBe("explicit"); + }); + + it("an explicit line that is not ONE grammar-valid key entry is refused", async () => { + await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: null, destination: DEST, hostKeyLine: "just-a-host" }), + "invalid-line", + ); + await expectPinError( + () => + captureHostPin({ + ownerUserId: owner, + aNodeId: null, + destination: DEST, + hostKeyLine: "a b\nsecond ssh-ed25519 A", + }), + "invalid-line", + ); + }); + + it("the machine's RPC failures leave as the named capture causes (offline, refused)", async () => { + fetchAnswer = () => { + throw new SshRpcError("offline", "no live socket", "node-a"); + }; + const offline = await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "offline", + ); + expect(hostPinRefusal(offline)).toMatchObject({ status: 409, code: BackendErrorCodes.NODE_OFFLINE }); + fetchAnswer = () => { + throw new SshRpcError("refused", "ssh disabled on this node", "node-a"); + }; + const refused = await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "refused", + ); + expect(hostPinRefusal(refused)).toMatchObject({ status: 502, code: BackendErrorCodes.SSH_NODE_REFUSED }); + expect(await hostPinFor({ ownerUserId: owner, destination: DEST })).toBeNull(); + }); +}); + +describe("delete and re-capture (the TOFU recovery)", () => { + it("delete removes the row and audits destination + fingerprint; the re-capture at the NEW key succeeds as a fresh TOFU", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + const removed = await deleteHostPin({ ownerUserId: owner, destination: DEST }); + expect(removed?.hostKey).toBe(LINE); + const audit = await latestAudit("node.ssh_host_pin.delete"); + expect(audit.destination).toBe(DEST); + expect(audit.fingerprint).toBe(EXPECTED_FP); + // The §9 recovery: only AFTER the delete does the differing key become the pin. + fetchAnswer = () => ({ lines: [OTHER_LINE] }); + const re = await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(re.hostKey).toBe(OTHER_LINE); + expect(re.createdAt).toBe(new Date(clockMs).toISOString()); + // The recovered pin fingerprints as the NEW key. + expect(hostKeyFingerprints(re.hostKey)).toEqual([OTHER_FP]); + }); + + it("deleting an absent or foreign destination answers null and audits nothing", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(await deleteHostPin({ ownerUserId: stranger, destination: DEST })).toBeNull(); + expect(await deleteHostPin({ ownerUserId: owner, destination: "other.test:22" })).toBeNull(); + expect(await hostPinFor({ ownerUserId: owner, destination: DEST })).not.toBeNull(); // stranger deleted nothing + const dels = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.delete") + .execute(); + expect(dels).toHaveLength(0); + }); + + it("pins are per-owner and per-destination: one owner's pin is absent for the other, and ports/users are distinct keys", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); + expect(await hostPinFor({ ownerUserId: stranger, destination: DEST })).toBeNull(); + expect(await listHostPins({ ownerUserId: stranger })).toHaveLength(0); + const withUser = `deploy@${HOST}:22`; + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: withUser, hostKeyLine: LINE }); + expect(await listHostPins({ ownerUserId: owner })).toHaveLength(2); + // The screens list never carries key bytes - only the fingerprint view. + const views = await listHostPins({ ownerUserId: owner }); + expect(JSON.stringify(views)).not.toContain(KEY_BYTES.toString("base64")); + expect(views.every((v) => v.fingerprint === EXPECTED_FP)).toBe(true); + }); +}); + +describe("the user@host:port triple", () => { + it("a user and a non-default port parse to the ask the capture sends", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: "deploy@git.example.test:2222" }); + expect(fetchCalls).toEqual([{ nodeId: "node-a", triple: { host: HOST, port: 2222, user: "deploy" } }]); + }); +}); diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 3a7fc3b33..9c23198d3 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0050-ssh-grants"; +export const LATEST_BACKUP_MIGRATION = "0051-ssh-request-destination"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/services/nodes/ssh-rpc.ts b/apps/server/api/src/services/nodes/ssh-rpc.ts index b00499089..b5534b9e7 100644 --- a/apps/server/api/src/services/nodes/ssh-rpc.ts +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -1,9 +1,11 @@ import { type NodeSshAgentIdentitiesResult, type NodeSshAliasListResult, + type NodeSshHostKeyResult, type NodeSshResolveOutcomeWire, parseNodeSshAgentIdentities, parseNodeSshAliasList, + parseNodeSshHostKey, parseNodeSshIdentity, parseNodeSshResolveOutcome, } from "@internal/subshell-protocol"; @@ -126,6 +128,34 @@ export async function sshRegisterIdentity(nodeId: string): Promise { return parsed.signingPublicKey; } +/** + * Ask one machine for the `known_hosts` entries it has recorded for one + * resolved destination (spec 2026-10-08 §9, Task 12): the host-key capture + * whose answer becomes the pin the relay-open delivers to B. The destination + * is the whole input because OpenSSH's host-key lookup is per destination; + * the triple is sent RESOLVED so a later config edit cannot retarget the ask. + * An empty answer is a SUCCESS (`{ lines: [] }`, the honest "recorded + * nothing"); only a transport/wire failure throws. + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshHostKey( + nodeId: string, + destination: { host: string; port: number; user: string | null }, +): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_host_key", ...destination }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshHostKey(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed host-key list`, nodeId); + } + return parsed; +} + /** * Ask one machine for its LIVE agent's public roster (spec 2026-10-08 §5.4, * Task 11): the approval screen's choice list, as the frozen validator diff --git a/apps/server/api/src/services/ssh-host-pins.service.ts b/apps/server/api/src/services/ssh-host-pins.service.ts new file mode 100644 index 000000000..1f250b15a --- /dev/null +++ b/apps/server/api/src/services/ssh-host-pins.service.ts @@ -0,0 +1,413 @@ +import { createHash } from "node:crypto"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { isSshKnownHostsPinLine, type NodeSshHostKeyResult } from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { SshHostPinsRepository } from "@/db/repositories/ssh-host-pins.repository.js"; +import type { SshHostPinTable } from "@/db/types/ssh-grants.db-types.js"; +import { parseSshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { type AuditEventInput, audit } from "@/services/audit.js"; +import { SshRpcError, sshHostKey } from "@/services/nodes/ssh-rpc.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The host-key pin store (spec 2026-10-08 §9, Task 12): the M2 TOFU record + * that tells a relay WHERE D's identity comes from. Trust for a destination + * follows the key home, not the connecting box: the pin is captured from A's + * own `~/.ssh/known_hosts` at grant creation (or supplied explicitly by the + * operator), stored owner-scoped per canonical `user@host:port`, delivered to + * B on the signed relay-open, and rendered into the session as + * `UserKnownHostsFile ` + `StrictHostKeyChecking yes`. A changed D + * key is then refused by OpenSSH itself, naming D; the plane never overwrites + * a stored pin (a differing capture is the §9 hard block), and recovery is + * delete-the-pin plus a fresh grant-creation TOFU - no separate rotate act. + * + * **The fail-closed rules this module keeps:** + * - A capture that finds nothing fails with the named `no-pin` cause. A + * relay launch refuses on it (`prepareRelayLeg`): B never ambient-TOFUs a + * destination, and a grant approval refuses on it too, so no grant ever + * stands without its destination's pin. + * - A capture that finds MULTIPLE distinct keys for the destination is + * `ambiguous`, refused rather than "pick one" - the pin must name A's one + * recorded key, not a coin flip over A's file. + * - A capture whose key differs from the stored pin is `changed`: nothing is + * written, nothing is deleted; the operator verifies out of band, deletes + * the pin, and re-creates the grant to re-decide TOFU. + * - Transport truth is the machine's, not this module's: offline / outdated / + * timeout / refused / malformed each leave as the named cause, and the + * grant approval stays PENDING through all of them (the ask can be retried; + * a fabricated pin could not be un-fabricated). + * - The audit rows (`node.ssh_host_pin.create|delete`) name the destination + * and the pinned key's `SHA256:` fingerprint ONLY - a host-key fingerprint + * is a public identifier (docs/security.md §10) - never the line's bytes, + * and no log line or notification this module writes carries a key. The + * capture COMMAND writes no row at all; the row belongs to the capture ACT. + */ + +/** Why a capture refused; each code names its own door (the routes render the copy). */ +export type SshHostPinFailure = + | "no-pin" + | "ambiguous" + | "changed" + | "invalid-line" + | "offline" + | "unsupported" + | "timeout" + | "refused" + | "malformed"; + +/** The loud refusal every pin act raises. No row exists after a thrown `changed`-class failure. */ +export class SshHostPinError extends Error { + readonly code: SshHostPinFailure; + /** The canonical destination the act was about (ids/hosts in copy, never key bytes). */ + readonly destination: string; + + constructor(code: SshHostPinFailure, message: string, destination: string) { + super(message); + this.name = "SshHostPinError"; + this.code = code; + this.destination = destination; + } +} + +const repo = new SshHostPinsRepository(db); + +/** The service's world. Production at {@link defaultPinsDeps}; tests install a fake. */ +export interface SshHostPinsDeps { + /** The one clock: row stamps. */ + nowIso(): string; + /** + * Ask one machine for its `known_hosts` entries for one destination + * (default: `ssh-rpc.sshHostKey`, the `??` at the call site being the + * production wiring). A test seam so the capture surface can pin its + * fail-closed behavior without a live ssh-keygen. + */ + fetchHostKey?( + nodeId: string, + destination: { host: string; port: number; user: string | null }, + ): Promise; +} + +let depsOverride: SshHostPinsDeps | null = null; + +function pinsDeps(): SshHostPinsDeps { + if (depsOverride) return depsOverride; + return { nowIso: () => new Date().toISOString() }; +} + +/** + * Install (or with null, drop) the whole seam. + * @internal test-only - the suite pins capture/fail-closed without a node. + */ +export function setSshHostPinsDepsForTests(deps: SshHostPinsDeps | null): void { + depsOverride = deps; +} + +/* ------------------------------------------------------------------ */ +/* the line -> fingerprint extraction (public identifiers, never keys) */ +/* ------------------------------------------------------------------ */ + +/** OpenSSH key-type tokens as known_hosts spells them (plain, cert, and security-key families). */ +const KEY_TYPE_RE = /^(?:ssh|ecdsa|sk)-[A-Za-z0-9][A-Za-z0-9@._-]*$/; +/** Strict standard base64 (the known_hosts field's own spelling), shape only. */ +const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +/** + * The `SHA256:` display fingerprint of a known_hosts line's key material - + * OpenSSH's own notation: SHA-256 over the decoded public blob (the agent + * wire encoding is the same bytes a known_hosts base64 field decodes to), + * base64url WITHOUT padding. A line with no extractable (type, base64) pair + * is malformed; a line whose several pairs disagree on the key is `ambiguous` + * for CAPTURE purposes, so the return is a SET and the callers decide what + * plurality means. + */ +export function hostKeyFingerprints(line: string): string[] { + const tokens = line.split(/\s+/); + const fps = new Set(); + for (let i = 0; i + 1 < tokens.length; i += 1) { + const type = tokens[i]; + const b64 = tokens[i + 1]; + if (type === undefined || b64 === undefined) continue; + if (!KEY_TYPE_RE.test(type) || !B64_RE.test(b64)) continue; + const bytes = Buffer.from(b64, "base64"); + if (bytes.length === 0) continue; + fps.add(`SHA256:${createHash("sha256").update(bytes).digest("base64url")}`); + } + return [...fps]; +} + +/* ------------------------------------------------------------------ */ +/* capture */ +/* ------------------------------------------------------------------ */ + +/** The line a capture would pin, chosen from A's answer; raises the named refusals. */ +function chooseCapturedLine(destination: string, lines: string[]): string { + if (lines.length === 0) { + throw new SshHostPinError( + "no-pin", + `no known_hosts entry for ${destination}: connect to the destination once from the key home, or supply its host key explicitly`, + destination, + ); + } + const perLine = lines.map((line) => ({ line, fps: hostKeyFingerprints(line) })); + if (perLine.some((e) => e.fps.length === 0)) { + throw new SshHostPinError( + "invalid-line", + `the host-key answer for ${destination} carried an unparsable entry`, + destination, + ); + } + const distinct = new Set(perLine.flatMap((e) => e.fps)); + if (distinct.size > 1) { + // Several KEYS (not several spellings of one key) match the destination: + // pinning would pick one of A's entries on the capture's own authority, + // and that is exactly the coin flip §9's byte-equality posture refuses. + throw new SshHostPinError( + "ambiguous", + `the key home recorded more than one host key for ${destination}; clean the known_hosts entry or supply the key explicitly`, + destination, + ); + } + const first = perLine[0]; + if (first === undefined) { + // Unreachable (lines.length was checked above), but the grammar's "pick + // the one key" step never casts: an empty pick is a named refusal, not a + // crash. + throw new SshHostPinError("no-pin", `no known_hosts entry for ${destination}`, destination); + } + return first.line; +} + +/** + * Ask A for the destination's `known_hosts` entries and pick the one line to + * pin (the fetch half of {@link captureHostPin}, split out so the explicit + * path never builds an answer it will not read). Every transport failure + * leaves as the named {@link SshHostPinError}; the empty answer is the + * `no-pin` refusal, never a fabricated key. + */ +async function fetchAndChooseLine(aNodeId: string | null, destination: string): Promise { + const triple = parseSshCanonicalDestination(destination); + if (triple === null) { + throw new SshHostPinError( + "invalid-line", + `${destination} is not a canonical user@host:port destination`, + destination, + ); + } + // Without a supplied line there is no pin without a key home to ask: the + // trust screen supplies lines, never fetches. + if (aNodeId === null) { + throw new SshHostPinError("invalid-line", `no key home to ask for ${destination}'s host key`, destination); + } + let answer: NodeSshHostKeyResult; + try { + const fetcher = pinsDeps().fetchHostKey ?? sshHostKey; // the `??` is the production wiring + answer = await fetcher(aNodeId, triple); + } catch (err) { + if (err instanceof SshRpcError) throw rpcFailure(err, destination); + throw err; + } + return chooseCapturedLine(destination, answer.lines); +} + +/** + * Capture (or accept, idempotently) the owner's pin for one canonical + * destination. `hostKeyLine` supplies the explicit-pin posture (the operator's + * key, not a fetch); absent it, A's `known_hosts` is asked over the signed + * RPC. Returns the standing row. Raises {@link SshHostPinError} with the + * named cause on every fail-closed door, writing NOTHING: a stored pin that + * disagrees is `changed` (the hard block, never an overwrite), a fresh + * capture audits `node.ssh_host_pin.create` naming destination + fingerprint. + */ +export async function captureHostPin(args: { + ownerUserId: string; + /** The key home the fetch asks; null when the line is SUPPLIED (the trust screen names no machine). */ + aNodeId: string | null; + destination: string; // canonical `user@host:port` + hostKeyLine?: string; +}): Promise { + const { ownerUserId, aNodeId, destination } = args; + if (!isSshKnownHostsPinLine(args.hostKeyLine ?? "") && args.hostKeyLine !== undefined) { + throw new SshHostPinError( + "invalid-line", + `the supplied host key for ${destination} is not one known_hosts line`, + destination, + ); + } + const line = args.hostKeyLine ?? (await fetchAndChooseLine(aNodeId, destination)); + const fps = hostKeyFingerprints(line); + if (fps.length !== 1) { + throw new SshHostPinError( + "invalid-line", + `the host key for ${destination} carries no single public key`, + destination, + ); + } + const existing = await repo.getPin(ownerUserId, destination); + if (existing !== null) { + const stored = hostKeyFingerprints(existing.hostKey); + if (stored.length !== 1 || stored[0] !== fps[0]) { + // §9's byte-equality hard block, at the plane's edge: A now reports a + // different key than the pin. Nothing written, nothing deleted - the + // operator's re-decide (delete + fresh capture) is the only way past. + throw new SshHostPinError( + "changed", + `the key home now reports a different host key for ${destination} than the pinned one`, + destination, + ); + } + // The same key again is the TOFU record agreeing with itself: the row + // stands (updatedAt = the last accepted match), no second create row. + await repo.touchPin(ownerUserId, destination, pinsDeps().nowIso()); + return { ...existing, updatedAt: pinsDeps().nowIso() }; + } + const now = pinsDeps().nowIso(); + const row: SshHostPinTable = { + id: crypto.randomUUID(), + ownerUserId, + destination, + hostKey: line, + createdAt: now, + updatedAt: now, + }; + await repo.insertPin(row); + await pinAudit(ownerUserId, "node.ssh_host_pin.create", aNodeId, { + destination, + fingerprint: fps[0], // the public identifier ONLY, never the line's bytes (§10) + via: args.hostKeyLine !== undefined ? "explicit" : "captured", + }); + return row; +} + +/** Read the owner's pin for one destination, or null (no audit, no side effect). */ +export async function hostPinFor(args: { ownerUserId: string; destination: string }): Promise { + return await repo.getPin(args.ownerUserId, args.destination); +} + +/** Delete the owner's pin (the §9 recovery's first half); false for foreign AND absent. */ +export async function deleteHostPin(args: { + ownerUserId: string; + destination: string; +}): Promise { + const existing = await repo.getPin(args.ownerUserId, args.destination); + if (existing === null) return null; + if (!(await repo.deletePin(args.ownerUserId, args.destination))) return null; // raced away + const fps = hostKeyFingerprints(existing.hostKey); + await pinAudit(args.ownerUserId, "node.ssh_host_pin.delete", null, { + destination: args.destination, + fingerprint: fps[0] ?? null, // names the removed pin; never its bytes + }); + return existing; +} + +/** One pin as the trust screen reads it: destination + fingerprint, NEVER key bytes. */ +export interface SshHostPinView { + /** Pin row id (uuid) */ + id: string; + /** Canonical resolved destination `user@host:port` */ + destination: string; + /** The pinned key's `SHA256:` display fingerprint (a public identifier; the line itself is not serialized) */ + fingerprint: string; + /** ISO 8601 first capture (the TOFU moment) */ + createdAt: string; + /** ISO 8601 of the last accepted match */ + updatedAt: string; +} + +/** The trust screen's list: every one of the owner's pins, display-shaped. */ +export async function listHostPins(args: { ownerUserId: string }): Promise { + const rows = await repo.listPins(args.ownerUserId); + return rows.map((row) => ({ + id: row.id, + destination: row.destination, + fingerprint: hostKeyFingerprints(row.hostKey)[0] ?? "", + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); +} + +/* ------------------------------------------------------------------ */ +/* refusal mapping: the RPC's kinds and the capture's own doors */ +/* ------------------------------------------------------------------ */ + +/** The grant-surface refusal shape (identical arms to `ssh-grants`'s narrow type). */ +export type SshHostPinRefusal = { status: 400 | 403 | 404 | 409 | 502; code: BackendErrorCodes; message: string }; + +/** Map the capture's own error onto the coded refusal its caller renders. */ +export function hostPinRefusal(err: SshHostPinError): SshHostPinRefusal { + switch (err.code) { + case "no-pin": + return { + status: 409, + code: BackendErrorCodes.SSH_HOST_PIN_MISSING, + message: + "The key home has no recorded host key for that destination. Connect to it once from the key home, or supply its host key on the trust screen.", + }; + case "ambiguous": + return { + status: 409, + code: BackendErrorCodes.SSH_HOST_PIN_MISSING, + message: + "The key home recorded more than one host key for that destination. Resolve its known_hosts entry, then ask again.", + }; + case "changed": + return { + status: 409, + code: BackendErrorCodes.SSH_HOST_PIN_CHANGED, + message: + "The key home now reports a different host key for that destination than the stored pin. Verify out of band, delete the pin, and create the grant again to re-trust.", + }; + case "invalid-line": + return { status: 400, code: BackendErrorCodes.SSH_HOST_PIN_INVALID, message: err.message }; + case "offline": + return { + status: 409, + code: BackendErrorCodes.NODE_OFFLINE, + message: "The key home has no live connection right now; bring its Subshell app online and ask again.", + }; + case "unsupported": + return { + status: 409, + code: BackendErrorCodes.NODE_OUTDATED, + message: "The Subshell app on the key home is too old to report host keys. Update it from its machine page.", + }; + case "timeout": + return { + status: 409, + code: BackendErrorCodes.NODE_UNREACHABLE, + message: "The key home did not answer the host-key request in time; check its connection and ask again.", + }; + case "refused": + case "malformed": + return { + status: 502, + code: BackendErrorCodes.SSH_NODE_REFUSED, + message: "The key home refused the host-key request. Check that SSH is switched on there.", + }; + } +} + +/** The RPC error as the capture's own named cause (the machine's text goes to the LOG, never the refusal). */ +function rpcFailure(err: SshRpcError, destination: string): SshHostPinError { + if (err.kind === "refused" || err.kind === "malformed") { + logger + .withError(err) + .warn(`ssh host-key rpc ${err.kind} failed against node ${err.nodeId}: ${err.detail ?? err.message}`); + } + return new SshHostPinError(err.kind, `host-key capture for ${destination} failed: ${err.kind}`, destination); +} + +function pinAudit( + actorUserId: string, + action: string, + aNodeId: string | null, + metadata: Record, +): Promise { + const event: AuditEventInput = { + actorUserId, + action, + targetType: aNodeId === null ? null : "node", + targetId: aNodeId, + metadataJson: JSON.stringify(metadata), + }; + return audit(event); +} diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index c01c7c28c..b9fc93fad 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -278,6 +278,30 @@ export enum BackendErrorCodes { * history). */ SSH_GRANT_ALREADY_ANSWERED = "SSH_GRANT_ALREADY_ANSWERED", + /** + * A relay grant creation the host-key capture refused because the key home + * has no `known_hosts` entry for the destination (spec 2026-10-08 §9, + * Task 12). A relay grant must carry a pin - B verifies D against A's + * recorded key, never its own ambient TOFU - so the grant is NOT created + * and the approval stays pending. The remedy the message names: connect to + * the destination once from the key home, or supply the key on the trust + * screen. + */ + SSH_HOST_PIN_MISSING = "SSH_HOST_PIN_MISSING", + /** + * The host-key capture found the destination pinned to a DIFFERENT key + * (spec 2026-10-08 §9): the TOFU hard block at the plane's edge, the mirror + * of the block OpenSSH itself raises on B. Nothing is overwritten; recovery + * is delete the pin (trust screen) plus a fresh grant-creation TOFU, no + * separate rotate act. + */ + SSH_HOST_PIN_CHANGED = "SSH_HOST_PIN_CHANGED", + /** + * A host-key line (an explicit pin, or a canonical destination) the wire + * grammar refuses: not one bounded printable line, a comment or blank, a + * smuggled newline. Refused before any write (spec 2026-10-08 §9). + */ + SSH_HOST_PIN_INVALID = "SSH_HOST_PIN_INVALID", } /** @@ -572,4 +596,16 @@ export const BackendErrorCodeDefs = { message: "That grant request has already been answered", statusCode: 409, }, + [BackendErrorCodes.SSH_HOST_PIN_MISSING]: { + message: "The key home has no recorded host key for that destination", + statusCode: 409, + }, + [BackendErrorCodes.SSH_HOST_PIN_CHANGED]: { + message: "The host key for that destination does not match the stored pin", + statusCode: 409, + }, + [BackendErrorCodes.SSH_HOST_PIN_INVALID]: { + message: "That host-key entry is not a valid known_hosts line", + statusCode: 400, + }, }; From 59ef1e3e4e83b2145bb2f971c77e45ba696f7694 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 16:42:47 -0700 Subject: [PATCH 58/92] feat(ssh-relay): every relay leg carries the destination pin Approval captures before the grant exists: a pending request without a destination is refused by name, an un-pinnable destination (no known_hosts entry on A, an offline or refusing key home) fails the approval CLOSED with the grant left pending. prepareRelayLeg then re-derives the pin per resolved destination (manual and wildcard grants are covered; one selector over many hosts yields many pins), so a relay open either carries a validated known_hosts line or the launch refuses - never ambient TOFU on B. openRelay refuses a bad hostPin before signing, and the field rides the signed ssh_relay_open to both sides; B writes it verbatim into the pane's 0600 known_hosts. composeSshLaunch in relay mode renders UserKnownHostsFile and StrictHostKeyChecking yes; the M1 render is byte-for-byte untouched. /api/ssh/host-pins (cookie-only) is the trust screen's door: supply a line, list fingerprints, delete for the TOFU recovery. security.md names the audit family. --- .../api/ssh/__tests__/grants-route.test.ts | 40 ++- .../api/ssh/__tests__/host-pins-route.test.ts | 242 ++++++++++++++++++ apps/server/api/src/api/ssh/index.ts | 8 +- apps/server/api/src/api/ssh/trust.route.ts | 167 ++++++++++++ .../__tests__/helpers/ssh-relay-stack.ts | 3 + .../__tests__/ssh-agent-identities.test.ts | 12 + .../__tests__/ssh-grants.service.test.ts | 139 +++++++++- .../__tests__/ssh-launch.service.test.ts | 28 +- .../__tests__/ssh-relay.integration.test.ts | 1 + .../__tests__/ssh-relay.service.test.ts | 18 ++ .../api/src/services/ssh-grants.service.ts | 106 +++++++- .../api/src/services/ssh-launch.service.ts | 30 ++- .../api/src/services/ssh-relay.service.ts | 22 ++ docs/security.md | 8 + 14 files changed, 810 insertions(+), 14 deletions(-) create mode 100644 apps/server/api/src/api/ssh/__tests__/host-pins-route.test.ts create mode 100644 apps/server/api/src/api/ssh/trust.route.ts diff --git a/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts b/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts index c6c44b589..7cc6c8ba3 100644 --- a/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts +++ b/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts @@ -18,6 +18,7 @@ import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; import { createGrant, requestFirstUse, setSshGrantsDepsForTests } from "@/services/ssh-grants.service.js"; +import { setSshHostPinsDepsForTests } from "@/services/ssh-host-pins.service.js"; import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service.js"; import { issueSubshellToken } from "@/services/subshell-tokens.js"; import { attachScriptedNode, ok, statDirEcho } from "@/test-helpers/scripted-node.js"; @@ -69,7 +70,14 @@ const emails: string[] = []; /** The broker's stand-in: it records the pairing and answers a fixed socket. */ const FAKE_SOCK = "/home/scripted/.subshell/ssh/fake-pane/agent.sock"; -let openCalls: { grantId: string; fingerprints: string[]; paneId: string; aNode: string; bNode: string }[] = []; +let openCalls: { + grantId: string; + fingerprints: string[]; + paneId: string; + aNode: string; + bNode: string; + hostPin?: string; +}[] = []; let closeGrantCalls: { grantId: string; reason: string }[] = []; let notifyCalls: string[] = []; let openRelayThrows: ((input: unknown) => never) | null = null; @@ -79,7 +87,20 @@ let rosterAnswer: () => NodeSshAgentIdentitiesResult = () => ({ }); let rosterCalls: string[] = []; +/** Task 12: the key home's canned host-key answer (the approval + launch captures). */ +const PIN_LINE = "git.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000"; +const pinAnswer: () => { lines: string[] } = () => ({ lines: [PIN_LINE] }); +let pinFetch: { nodeId: string; destination: string }[] = []; + function installFakeDeps() { + pinFetch = []; + setSshHostPinsDepsForTests({ + nowIso: () => new Date().toISOString(), + fetchHostKey: async (nodeId, triple) => { + pinFetch.push({ nodeId, destination: `${triple.host}:${triple.port}` }); + return pinAnswer(); + }, + }); const broker = { async openRelay(input: { grantId: string; fingerprints: string[]; paneId: string; aNode: string; bNode: string }) { if (openRelayThrows) openRelayThrows(input); @@ -194,6 +215,8 @@ beforeAll(async () => { afterAll(async () => { setSshGrantsDepsForTests(null); + setSshHostPinsDepsForTests(null); + await db.deleteFrom("sshHostPins").execute(); resetNodeRegistryForTests(); for (const id of createdSubshellIds) await new SubshellsRepository(db).delete(id).catch(() => {}); await db.deleteFrom("sshGrantRequests").execute(); @@ -317,6 +340,7 @@ describe("GET/POST /api/ssh/grant-requests", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: "git.example.test", + destination: "git.example.test:22", paneId: "pane-queue-probe", }); if (!asked.ok) throw new Error("fixture ask refused"); @@ -382,6 +406,7 @@ describe("GET/POST /api/ssh/grant-requests", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: "second.example.test", + destination: "second.example.test:22", paneId: "pane-deny-probe", }); if (!second.ok) throw new Error("fixture ask refused"); @@ -522,6 +547,18 @@ describe("POST /api/ssh/launch in relay mode (the grant-gated relay leg)", () => // The relay override: the snapshot names /run/user/501/ssh-agent.sock; // the pane gets the proxy socket the broker verified (spec §5.2). expect(cmd.subshellEnv?.SSH_AUTH_SOCK).toBe(FAKE_SOCK); + // Task 12 end to end at the door: the broker received the captured pin, + // and the launch's OWN rendered config is the relay one - yes against + // the pinned file the B open writes beside the socket, never accept-new + // against B's ambient known_hosts. + expect(openCalls[0]?.hostPin).toBe(PIN_LINE); + const launch = scripted.cmdsOf("launch")[0] as unknown as { + ssh?: { configPath: string; fileContent: string }; + }; + const dir = launch.ssh?.configPath?.slice(0, launch.ssh.configPath.lastIndexOf("/")) ?? ""; + expect(launch.ssh?.fileContent).toContain("StrictHostKeyChecking yes"); + expect(launch.ssh?.fileContent).not.toContain("accept-new"); + expect(launch.ssh?.fileContent).toContain(`UserKnownHostsFile ${dir}/known_hosts`); } finally { scripted.detach(); } @@ -590,6 +627,7 @@ describe("GET /api/ssh/grant-requests/:id/identities (the roster behind the appr aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: "git.example.test", + destination: "git.example.test:22", paneId, }); if (!asked.ok) throw new Error("fixture ask refused"); diff --git a/apps/server/api/src/api/ssh/__tests__/host-pins-route.test.ts b/apps/server/api/src/api/ssh/__tests__/host-pins-route.test.ts new file mode 100644 index 000000000..18b9230ea --- /dev/null +++ b/apps/server/api/src/api/ssh/__tests__/host-pins-route.test.ts @@ -0,0 +1,242 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { sshRoutes } from "@/api/ssh/index.js"; +import { db } from "@/db/index.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { setSshHostPinsDepsForTests } from "@/services/ssh-host-pins.service.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; + +/** + * `/api/ssh/host-pins` (spec 2026-10-08 §8-§9, Task 12): the destination + * trust screen's HTTP surface. Pinned here: + * - the cookie doctrine of every ssh sibling (empty cookie 401, a bearer + * token 403), owner-scoped reads (a foreign destination is the SAME 404 an + * absent one is on DELETE); + * - the serialized view carries destination + `SHA256:` fingerprint and NEVER + * the key bytes; + * - POST supplies an explicit pin (grammar-checked destination and line, the + * §9 hard block as a 409 `SSH_HOST_PIN_CHANGED`), DELETE removes a pin and + * the follow-up re-capture succeeds (the TOFU recovery), and the audit trail + * names only destination + fingerprint. + * + * No key home is asked by these routes (the screen SUPPLIES lines), so no + * node, broker, or fetch is wired - the pins service's fetch seam is installed + * with a never-called script to keep the promise. + */ +const app = new Elysia().use(errorHandlerPlugin).use(sshRoutes); + +const pw = "hostpinsapi-1"; +const ownerEmail = `hostpinsapi-owner-${crypto.randomUUID()}@subshell.local`; +const otherEmail = `hostpinsapi-other-${crypto.randomUUID()}@subshell.local`; +let ownerId: string; +let ownerCookie: string; +let otherCookie: string; +const emails: string[] = []; + +const KEY_BYTES = Buffer.concat([ + Buffer.from([0, 0, 0, 11]), + Buffer.from("ssh-ed25519"), + Buffer.from([0, 0, 0, 32]), + Buffer.alloc(32, 5), +]); +const LINE = `git.example.test ssh-ed25519 ${KEY_BYTES.toString("base64")}`; +const FP = `SHA256:${createHash("sha256").update(KEY_BYTES).digest("base64url")}`; +const DEST = "git.example.test:22"; +const probeSubshellIds: string[] = []; + +function fetchPins( + path = "/api/ssh/host-pins", + init: { method?: string; body?: unknown; cookie?: string; bearer?: string } = {}, +) { + const headers: Record = { "content-type": "application/json" }; + if (init.cookie) headers.cookie = `better-auth.session_token=${init.cookie}`; + if (init.bearer) headers.authorization = `Bearer ${init.bearer}`; + return app.fetch( + new Request(`http://localhost:3099${path}`, { + method: init.method ?? "GET", + headers, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }), + ); +} + +beforeAll(async () => { + await setupAuthTables(); + await ensureMigratedTestDb(); + const mk = async (email: string) => + await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerId = await mk(ownerEmail); + await mk(otherEmail); + emails.push(ownerEmail, otherEmail); + ownerCookie = await signIn(ownerEmail, pw); + otherCookie = await signIn(otherEmail, pw); + // No fetch door on these routes; a call here is a bug the test would surface. + setSshHostPinsDepsForTests({ + nowIso: () => new Date().toISOString(), + fetchHostKey: async () => { + throw new Error("the trust screen must never ask a key home"); + }, + }); +}); + +afterAll(async () => { + setSshHostPinsDepsForTests(null); + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("auditEvents").execute(); + for (const id of probeSubshellIds) await new SubshellsRepository(db).delete(id).catch(() => {}); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +beforeEach(async () => { + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("auditEvents").execute(); +}); + +describe("the cookie doctrine", () => { + it("an empty cookie is 401 and a real subshell token is 403 on every door", async () => { + // A MACHINE credential must be refused the same way the sibling ssh doors + // refuse it (the fake-token path 401s at better-auth before the guard). + const subshellId = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id: subshellId, + userId: ownerId, + harnessId: "terminal", + name: "hostpins-bearer-probe", + workingDir: mkdtempSync(join(tmpdir(), "hostpins-bearer-")), + tmuxSocket: null, + nodeId: LOCAL_NODE_ID, + }); + probeSubshellIds.push(subshellId); + const key = await issueSubshellToken(subshellId, ownerId); + expect((await fetchPins()).status).toBe(401); + expect((await fetchPins(undefined, { bearer: key })).status).toBe(403); + expect((await fetchPins(undefined, { method: "POST", body: { destination: DEST, hostKey: LINE } })).status).toBe( + 401, + ); + expect((await fetchPins(`/api/ssh/host-pins/${DEST}`, { method: "DELETE", bearer: key })).status).toBe(403); + }); +}); + +describe("POST /api/ssh/host-pins (explicit pin)", () => { + it("supplies a pin: the view carries destination + fingerprint, never the key bytes", async () => { + const res = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: LINE }, + }); + expect(res.status).toBe(200); + const json = (await res.json()) as { pin: Record }; + expect(json.pin.destination).toBe(DEST); + expect(json.pin.fingerprint).toBe(FP); + expect(JSON.stringify(json)).not.toContain(KEY_BYTES.toString("base64")); + // The audit row names destination + fingerprint ONLY. + const row = await db + .selectFrom("auditEvents") + .select("metadataJson") + .where("action", "=", "node.ssh_host_pin.create") + .executeTakeFirstOrThrow(); + const meta = JSON.parse(row.metadataJson ?? "{}") as Record; + expect(meta).toMatchObject({ destination: DEST, fingerprint: FP, via: "explicit" }); + expect(JSON.stringify(meta)).not.toContain(KEY_BYTES.toString("base64")); + }); + + it("refuses a non-canonical destination and a malformed key line with the named 400", async () => { + const badDest = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: "not-canonical", hostKey: LINE }, + }); + expect(badDest.status).toBe(400); + expect(((await badDest.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_HOST_PIN_INVALID); + const badLine = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: "git.example.test just-a-token" }, + }); + expect(badLine.status).toBe(400); + const smuggled = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: `a b\nsecond ssh-ed25519 AAA` }, + }); + expect(smuggled.status).toBe(400); + expect(await db.selectFrom("sshHostPins").selectAll().execute()).toHaveLength(0); + }); + + it("a destination already pinned to a DIFFERENT key is the §9 hard block, never an overwrite", async () => { + await fetchPins(undefined, { method: "POST", cookie: ownerCookie, body: { destination: DEST, hostKey: LINE } }); + const other = Buffer.concat([Buffer.from([0, 0, 0, 11]), Buffer.from("ssh-ed25519"), Buffer.alloc(32, 9)]).toString( + "base64", + ); + const res = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: `git.example.test ssh-ed25519 ${other}` }, + }); + expect(res.status).toBe(409); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_HOST_PIN_CHANGED); + // The standing pin is untouched. + const standing = await db.selectFrom("sshHostPins").select("hostKey").executeTakeFirstOrThrow(); + expect(standing.hostKey).toBe(LINE); + }); +}); + +describe("GET list and the owner axis", () => { + it("the list shows the caller's pins only, as fingerprint views", async () => { + await fetchPins(undefined, { method: "POST", cookie: ownerCookie, body: { destination: DEST, hostKey: LINE } }); + const mine = (await (await fetchPins(undefined, { cookie: ownerCookie })).json()) as { pins: unknown[] }; + const theirs = (await (await fetchPins(undefined, { cookie: otherCookie })).json()) as { pins: unknown[] }; + expect(mine.pins).toHaveLength(1); + expect(theirs.pins).toHaveLength(0); + expect(JSON.stringify(mine.pins)).not.toContain(KEY_BYTES.toString("base64")); + }); +}); + +describe("DELETE /api/ssh/host-pins/:destination (the recovery)", () => { + it("deletes the pin (audited with fingerprint) and the next supply at a NEW key succeeds as a fresh TOFU", async () => { + await fetchPins(undefined, { method: "POST", cookie: ownerCookie, body: { destination: DEST, hostKey: LINE } }); + const del = await fetchPins(`/api/ssh/host-pins/${DEST}`, { method: "DELETE", cookie: ownerCookie }); + expect(del.status).toBe(200); + expect(((await del.json()) as { deleted: boolean }).deleted).toBe(true); + const delAudit = await db + .selectFrom("auditEvents") + .select("metadataJson") + .where("action", "=", "node.ssh_host_pin.delete") + .executeTakeFirstOrThrow(); + expect(JSON.parse(delAudit.metadataJson ?? "{}")).toMatchObject({ destination: DEST, fingerprint: FP }); + const other = Buffer.concat([Buffer.from([0, 0, 0, 11]), Buffer.from("ssh-ed25519"), Buffer.alloc(32, 9)]).toString( + "base64", + ); + const re = await fetchPins(undefined, { + method: "POST", + cookie: ownerCookie, + body: { destination: DEST, hostKey: `git.example.test ssh-ed25519 ${other}` }, + }); + expect(re.status).toBe(200); + }); + + it("a foreign or absent destination is the same 404", async () => { + await fetchPins(undefined, { method: "POST", cookie: ownerCookie, body: { destination: DEST, hostKey: LINE } }); + expect((await fetchPins(`/api/ssh/host-pins/${DEST}`, { method: "DELETE", cookie: otherCookie })).status).toBe(404); + expect( + (await fetchPins("/api/ssh/host-pins/nobody.test:22", { method: "DELETE", cookie: ownerCookie })).status, + ).toBe(404); + expect(await db.selectFrom("sshHostPins").selectAll().execute()).toHaveLength(1); + }); +}); diff --git a/apps/server/api/src/api/ssh/index.ts b/apps/server/api/src/api/ssh/index.ts index 67262bc05..9c5351566 100644 --- a/apps/server/api/src/api/ssh/index.ts +++ b/apps/server/api/src/api/ssh/index.ts @@ -5,6 +5,7 @@ import { sshGrantsRoutes } from "@/api/ssh/grants.route.js"; import { sshLaunchRoute } from "@/api/ssh/launch.route.js"; import { sshResolveRoute } from "@/api/ssh/resolve.route.js"; import { sshSavedHostsRoutes } from "@/api/ssh/saved-hosts.route.js"; +import { sshTrustRoutes } from "@/api/ssh/trust.route.js"; /** * `/api/ssh` — the launcher surface (spec 2026-10-07 §5/§7, plan 2 Task 7): @@ -25,4 +26,9 @@ export const sshRoutes = new Elysia({ prefix: "/api/ssh" }) // top-level `.route()` tier is the Elysia App-type-depth trap, and these // are the launcher's own authorization surface. .use(sshGrantsRoutes) - .use(sshGrantRequestsRoutes); + .use(sshGrantRequestsRoutes) + // The host-key pin trust screen (spec 2026-10-08 §8-§9, Task 12): the TOFU + // pins a relay launch verifies D against. Mounted inside this group for the + // same Elysia App-type-depth reason the grant tier is; these are the + // grant layer's destination-trust half. + .use(sshTrustRoutes); diff --git a/apps/server/api/src/api/ssh/trust.route.ts b/apps/server/api/src/api/ssh/trust.route.ts new file mode 100644 index 000000000..6711633c7 --- /dev/null +++ b/apps/server/api/src/api/ssh/trust.route.ts @@ -0,0 +1,167 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { isSshKnownHostsPinLine } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { parseSshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { apiModels } from "@/schema/index.js"; +import { + captureHostPin, + deleteHostPin, + hostKeyFingerprints, + hostPinRefusal, + listHostPins, + SshHostPinError, +} from "@/services/ssh-host-pins.service.js"; + +/** + * `/api/ssh/host-pins` - the destination trust screen (spec 2026-10-08 §8-§9, + * Task 12): LIST the caller's host-key pins, SUPPLY an explicit pin (the §9 + * "or an explicit pin" door: the operator's key for a destination the key + * home has not connected to yet), and DELETE one - the recovery's first half, + * whose second half is the fresh grant-creation TOFU the next capture runs. + * + * The row model is the saved-hosts/grants posture verbatim: per-owner reads, + * a foreign id the SAME 404 an absent row is, and the cookie doctrine of + * every ssh sibling (deciding which key authenticates a destination is a + * browser-session act). What the screen NEVER sees is the key bytes: the + * serialized view carries the destination and the pinned key's `SHA256:` + * fingerprint - a public identifier - and the stored line leaves this server + * only on the signed relay-open to B. + * + * The audit trail (written by the service): `node.ssh_host_pin.create` and + * `.delete`, each naming destination + fingerprint only (docs/security.md + * §10). Supplying a pin for a destination that already stands pinned to a + * DIFFERENT key is the §9 hard block (409, nothing written) - delete the pin + * first, that is the whole recovery flow. + */ + +/** The trust screen's row shape: destination + public fingerprint, never key bytes. */ +const SshHostPinViewSchema = t.Object({ + id: t.String({ description: "Pin row id (uuid)" }), + destination: t.String({ description: "Canonical resolved destination `user@host:port`" }), + fingerprint: t.String({ description: "The pinned key's SHA256: display fingerprint (public identifier)" }), + createdAt: t.String({ description: "ISO 8601 first capture (the TOFU moment)" }), + updatedAt: t.String({ description: "ISO 8601 of the last accepted match" }), +}); + +export const sshTrustRoutes = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/host-pins", + async ({ user, actor }) => { + requireCookieActor(actor, "SSH host-key trust is restricted to browser sessions"); + return { pins: await listHostPins({ ownerUserId: user.id }) }; + }, + { + response: { + 200: t.Object({ + pins: t.Array(SshHostPinViewSchema, { description: "The caller's pinned destinations, newest first" }), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + }, + detail: { + operationId: "listSshHostPins", + tags: ["ssh"], + description: + "Lists the caller's destination host-key pins as destination plus SHA256: fingerprint; the pinned key line itself is display-excluded by construction", + }, + }, + ) + .post( + "/host-pins", + async ({ body, user, actor }) => { + requireCookieActor(actor, "SSH host-key trust is restricted to browser sessions"); + const destination = body.destination.trim(); + if (parseSshCanonicalDestination(destination) === null) { + return throwCodedRefusal({ + status: 400, + code: BackendErrorCodes.SSH_HOST_PIN_INVALID, + message: "That destination is not a canonical user@host:port.", + }); + } + const line = body.hostKey.trim(); + if (!isSshKnownHostsPinLine(line) || hostKeyFingerprints(line).length !== 1) { + return throwCodedRefusal({ + status: 400, + code: BackendErrorCodes.SSH_HOST_PIN_INVALID, + message: "The host key must be one known_hosts line carrying exactly one public key.", + }); + } + try { + const row = await captureHostPin({ ownerUserId: user.id, aNodeId: null, destination, hostKeyLine: line }); + return { + pin: { + id: row.id, + destination: row.destination, + fingerprint: hostKeyFingerprints(row.hostKey)[0] ?? "", + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, + }; + } catch (err) { + if (err instanceof SshHostPinError) return throwCodedRefusal(hostPinRefusal(err)); + throw err; + } + }, + { + body: t.Object({ + destination: t.String({ + minLength: 1, + maxLength: 512, + description: "Canonical resolved destination `user@host:port` (the spelling the launch keys the pin by)", + }), + hostKey: t.String({ + minLength: 1, + maxLength: 4096, + description: + "The pinned entry in OpenSSH known_hosts form (pattern, key type, base64 key material) - stored verbatim and carried verbatim to B", + }), + }), + response: { + 200: t.Object({ pin: SshHostPinViewSchema }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + detail: { + operationId: "createSshHostPin", + tags: ["ssh"], + description: + "Supplies an explicit host-key pin for a destination (the §9 alternative to capturing from the key home); a destination pinned to a different key is the named hard block, never an overwrite", + }, + }, + ) + .delete( + "/host-pins/:destination", + async ({ params, user, actor }) => { + requireCookieActor(actor, "SSH host-key trust is restricted to browser sessions"); + const deleted = await deleteHostPin({ ownerUserId: user.id, destination: params.destination }); + if (deleted === null) { + return throwCodedRefusal({ + status: 404, + code: BackendErrorCodes.NOT_FOUND_ERROR, + message: "No pin for that destination", + }); + } + return { deleted: true }; + }, + { + params: t.Object({ destination: t.String({ description: "Canonical destination whose pin is removed" }) }), + response: { + 200: t.Object({ deleted: t.Boolean({ description: "True once the pin row is gone" }) }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "deleteSshHostPin", + tags: ["ssh"], + description: + "Deletes the caller's pin for one destination (the TOFU recovery's first half): the next capture at a fresh key re-decides trust; the audit names the removed destination and fingerprint only", + }, + }, + ); diff --git a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts index 733080600..c2085f7fe 100644 --- a/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts +++ b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts @@ -412,6 +412,9 @@ export async function openStack(opts: StackOptions = {}): Promise { grantId: "grant-1", fingerprints, paneId: PANE_ID, + // Task 12: the pin is REQUIRED on the open; B's branch writes it beside + // the socket, which is part of what this stack proves end to end. + hostPin: `git.example.test ssh-ed25519 ${"A".repeat(51)}`, aNode: "a-node", bNode: "b-node", aPeer: { signingPublicKey: aId.signingPublicJwk, encryptionPublicJwk: aId.publicJwk }, diff --git a/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts b/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts index 40e29dc26..5bfdcd31d 100644 --- a/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts @@ -15,6 +15,7 @@ import { requestFirstUse, setSshGrantsDepsForTests, } from "@/services/ssh-grants.service.js"; +import { setSshHostPinsDepsForTests } from "@/services/ssh-host-pins.service.js"; import type { RelayBroker } from "@/services/ssh-relay.service.js"; /** @@ -62,6 +63,8 @@ const NODE_A = "roster-node-a"; const NODE_B = "roster-node-b"; const HOST = "git.example.test"; +/** Task 12: the canned key-home pin the approve door captures. */ +const PIN_LINE = `${HOST} ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000`; const PANE = "11111111-2222-4333-8444-555555555555"; let clockMs = Date.parse("2026-10-08T00:00:00.000Z"); @@ -78,6 +81,13 @@ function installRosterDeps(): void { }; rosterFailure = null; rosterCalls = []; + // Task 12: approveGrant captures the destination's host-key pin; this + // roster suite approves a request, so the pins seam answers with a canned + // line and never reaches the (unwired) node RPC. + setSshHostPinsDepsForTests({ + nowIso: () => new Date().toISOString(), + fetchHostKey: async () => ({ lines: [PIN_LINE] }), + }); setSshGrantsDepsForTests({ nowIso, broker: () => @@ -102,6 +112,7 @@ async function mkPendingRequest(): Promise { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: `${HOST}:22`, paneId: PANE, }); if (!answer.ok) throw new Error("the pending row was not created"); @@ -130,6 +141,7 @@ beforeAll(async () => { afterAll(async () => { setSshGrantsDepsForTests(null); + setSshHostPinsDepsForTests(null); await db.deleteFrom("sshGrantRequests").execute(); await db.deleteFrom("sshKeyGrants").execute(); await db.deleteFrom("auditEvents").execute(); diff --git a/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts index 6ed51c1dc..8f1cee9d7 100644 --- a/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts @@ -25,6 +25,7 @@ import { sweepExpiredGrantRequests, updateGrant, } from "@/services/ssh-grants.service.js"; +import { captureHostPin, hostKeyFingerprints, setSshHostPinsDepsForTests } from "@/services/ssh-host-pins.service.js"; import { type RelayBroker, SshRelayRefusal } from "@/services/ssh-relay.service.js"; import { logger } from "@/utils/logger.js"; @@ -78,8 +79,14 @@ const NODE_A = "gsvc-node-a"; const NODE_B = "gsvc-node-b"; const nodes = new NodesRepository(db); -/** The one destination this suite routes through. */ +/** The one destination this suite routes through, and its canonical triple. */ const HOST = "git.example.test"; +const DEST = "git.example.test:22"; +/** A's canned known_hosts answer for DEST (Task 12: the approval's capture). */ +const PIN_LINE = `${HOST} ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000`; +/** What the pinned host-key fetch answers; a test script can empty or swap it. */ +let pinAnswer: () => { lines: string[] } = () => ({ lines: [PIN_LINE] }); +let pinFetchCalls: { nodeId: string; destination: string }[] = []; /** A fake clock the sweep and the expiry windows read; setT() moves it. */ let clockMs = Date.parse("2026-10-08T00:00:00.000Z"); @@ -94,6 +101,14 @@ let closeGrantCalls: FakeCall[] = []; let notifyCalls: { ownerUserId: string; requestId: string }[] = []; function installFakeDeps(brokerOverrides: Partial = {}) { + pinFetchCalls = []; + setSshHostPinsDepsForTests({ + nowIso, + fetchHostKey: async (nodeId, triple) => { + pinFetchCalls.push({ nodeId, destination: `${triple.host}:${triple.port}` }); + return pinAnswer(); + }, + }); const broker = { openRelay: async (input: unknown) => { openCalls.push(input); @@ -148,6 +163,8 @@ beforeAll(async () => { afterAll(async () => { setSshGrantsDepsForTests(null); + setSshHostPinsDepsForTests(null); + await db.deleteFrom("sshHostPins").execute(); await db.deleteFrom("sshGrantRequests").execute(); await db.deleteFrom("sshKeyGrants").execute(); await db.deleteFrom("auditEvents").execute(); @@ -167,7 +184,9 @@ beforeEach(() => { async function cleanTier() { await db.deleteFrom("sshGrantRequests").execute(); await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("sshHostPins").execute(); await db.deleteFrom("auditEvents").execute(); + pinAnswer = () => ({ lines: [PIN_LINE] }); } async function latestAudit(action: string): Promise> { @@ -193,6 +212,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "pane-ask-1", }); expect(answer.ok).toBe(true); @@ -221,6 +241,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p1", }); expect(first.ok).toBe(true); @@ -231,6 +252,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p2", }); expect(again.ok).toBe(true); @@ -254,6 +276,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p1", }); expect(first.ok).toBe(true); @@ -267,6 +290,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p1", }); expect(after.ok).toBe(true); @@ -284,6 +308,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p1", }); if (asked.ok) requestRef = asked.value.requestId; @@ -433,6 +458,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p9", }); expect(asked.ok).toBe(true); @@ -451,6 +477,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p9", }); expect(again.ok).toBe(true); @@ -517,6 +544,7 @@ describe("ssh-grants.service", () => { aNode: { id: NODE_A, name: "key home" }, bNodeId: NODE_B, resolvedHost: HOST, + destination: DEST, paneId: "pane-1", }); expect(leg.ok).toBe(true); @@ -541,6 +569,7 @@ describe("ssh-grants.service", () => { aNode: { id: NODE_A, name: "key home" }, bNodeId: NODE_B, resolvedHost: HOST, + destination: DEST, paneId: "pane-ask", }); expect(leg.ok).toBe(false); @@ -565,6 +594,7 @@ describe("ssh-grants.service", () => { aNode: { id: NODE_A, name: "A" }, bNodeId: NODE_B, resolvedHost: HOST, + destination: DEST, paneId: "p", }); expect(leg.ok).toBe(false); @@ -590,6 +620,7 @@ describe("ssh-grants.service", () => { aNode: { id: NODE_A, name: "A" }, bNodeId: NODE_B, resolvedHost: HOST, + destination: DEST, paneId: "p", }); expect(leg.ok).toBe(false); @@ -619,6 +650,7 @@ describe("ssh-grants.service", () => { aNode: { id: NODE_A, name: "A" }, bNodeId: NODE_B, resolvedHost: HOST, + destination: DEST, paneId: "p", }); expect(leg.ok).toBe(false); @@ -628,6 +660,110 @@ describe("ssh-grants.service", () => { }); }); + describe("host-key pinning at the grant doors (Task 12, spec 2026-10-08 §9)", () => { + async function ask(): Promise { + const asked = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + destination: DEST, + paneId: "p-pin", + }); + if (!asked.ok) throw new Error("ask refused"); + return asked.value.requestId; + } + + it("approve captures A's host key as ssh_host_pins for user@host:port BEFORE the grant stands (audit names destination + fingerprint)", async () => { + const requestId = await ask(); + const approved = await approveGrant({ ownerUserId: owner, requestId, fingerprints: FPS }); + expect(approved.ok).toBe(true); + // The ask went to the KEY HOME with the parsed triple. + expect(pinFetchCalls).toEqual([{ nodeId: NODE_A, destination: DEST }]); + const pin = await db.selectFrom("sshHostPins").selectAll().execute(); + expect(pin).toHaveLength(1); + expect(pin[0]?.destination).toBe(DEST); + expect(pin[0]?.hostKey).toBe(PIN_LINE); + const audit = await latestAudit("node.ssh_host_pin.create"); + expect(audit.destination).toBe(DEST); + expect(audit.fingerprint).toBe(hostKeyFingerprints(PIN_LINE)[0]); + // The bytes are on no grant/audit row anywhere. + const all = await db.selectFrom("auditEvents").select(["action", "metadataJson"]).execute(); + for (const row of all) expect(row.metadataJson ?? "").not.toContain("AAAAC3NzaC1lZDI1NTE5"); + }); + + it("approve with NO pin to capture refuses by name: the request stays pending and NO grant row exists", async () => { + const requestId = await ask(); + pinAnswer = () => ({ lines: [] }); // A has recorded nothing for DEST + const refused = await approveGrant({ ownerUserId: owner, requestId, fingerprints: FPS }); + expect(refused.ok).toBe(false); + if (refused.ok) return; + expect(refused.refusal).toMatchObject({ status: 409, code: BackendErrorCodes.SSH_HOST_PIN_MISSING }); + expect((await repo.getRequest(owner, requestId))?.status).toBe("pending"); + expect(await db.selectFrom("sshKeyGrants").selectAll().execute()).toHaveLength(0); + expect(await db.selectFrom("sshHostPins").selectAll().execute()).toHaveLength(0); + }); + + it("a standing pin satisfies the approval door WITHOUT re-asking A (TOFU persists across grants)", async () => { + await captureHostPin({ ownerUserId: owner, aNodeId: NODE_A, destination: DEST }); + const requestId = await ask(); + pinAnswer = () => { + throw new Error("must not fetch: the pin stands"); + }; + const approved = await approveGrant({ ownerUserId: owner, requestId, fingerprints: FPS }); + expect(approved.ok).toBe(true); + }); + + it("the launch leg hands the broker the pin line; no pin means the open is refused before it is attempted", async () => { + const g = await createGrant({ + ownerUserId: owner, + aNodeId: NODE_A, + name: "g", + selector: HOST, + fingerprints: FPS, + }); + if (!g.ok) throw new Error("grant fixture refused"); + const leg = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "key home" }, + bNodeId: NODE_B, + resolvedHost: HOST, + destination: DEST, + paneId: "pane-pin", + }); + expect(leg.ok).toBe(true); + // Manual grants have no approve-capture: the FIRST OPEN captured it. + expect(pinFetchCalls).toEqual([{ nodeId: NODE_A, destination: DEST }]); + const call = openCalls[0] as Record; + expect(call.hostPin).toBe(PIN_LINE); + // And with nothing to capture, a second destination under the same grant refuses before any open. + openCalls = []; + pinAnswer = () => ({ lines: [] }); + const denied = await prepareRelayLeg({ + viewerId: owner, + aNode: { id: NODE_A, name: "key home" }, + bNodeId: NODE_B, + resolvedHost: HOST, + destination: "other.example.test:22", + paneId: "pane-pin-2", + }); + expect(denied.ok).toBe(false); + if (denied.ok) return; + expect(denied.refusal.code).toBe(BackendErrorCodes.SSH_HOST_PIN_MISSING); + expect(openCalls).toHaveLength(0); // no session, no relay-open, no ambient TOFU on B + }); + + it("the legacy pending row (no destination to pin) is refused by name, never approved blind", async () => { + const requestId = await ask(); + await db.updateTable("sshGrantRequests").set({ destination: null }).where("id", "=", requestId).execute(); + const refused = await approveGrant({ ownerUserId: owner, requestId, fingerprints: FPS }); + expect(refused.ok).toBe(false); + if (refused.ok) return; + expect(refused.refusal.code).toBe(BackendErrorCodes.SSH_HOST_PIN_MISSING); + expect((await repo.getRequest(owner, requestId))?.status).toBe("pending"); + }); + }); + describe("the grants screen and revoke", () => { it("create validates the selector and stores it normalized", async () => { expect(normalizeSshGrantSelector(" Git.Example.TEST ")).toBe("git.example.test"); @@ -718,6 +854,7 @@ describe("ssh-grants.service", () => { aNodeId: NODE_A, bNodeId: NODE_B, resolvedSelector: HOST, + destination: DEST, paneId: "p", }); expect((await listGrantRequests({ ownerUserId: owner })).length).toBe(1); diff --git a/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts b/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts index 0ca430c04..fbc841dc8 100644 --- a/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-launch.service.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { buildSshConfigPath, renderSshConfigContents } from "@internal/pane-runtime"; +import { buildSshConfigPath, buildSshKnownHostsPath, renderSshConfigContents } from "@internal/pane-runtime"; import type { SshConnectionSnapshotWire } from "@internal/subshell-protocol"; import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; import { subshellSshConfigPath } from "@/services/nodes/subshell-paths.js"; @@ -58,6 +58,32 @@ describe("isWireSafeSshDestination", () => { }); }); +describe("composeSshLaunch in relay mode (Task 12, spec 2026-10-08 §9)", () => { + it("the relay render forces yes on the PINNED file and replaces the snapshot's trust refs", () => { + const snapshot = SNAP({ knownHostsFiles: ["/home/scripted/.ssh/known_hosts"] }); + const pinPath = buildSshKnownHostsPath("/home/scripted/.subshell", ID); + const composed = composeSshLaunch({ + snapshot, + targetDataDir: "/home/scripted/.subshell", + subshellId: ID, + hostPinPath: pinPath, + }); + expect(composed.fileContent).toContain("StrictHostKeyChecking yes"); + expect(composed.fileContent).not.toContain("accept-new"); + expect(composed.fileContent).toContain(` UserKnownHostsFile ${pinPath}`); + expect(composed.fileContent).not.toContain("/home/scripted/.ssh/known_hosts"); // B's ambient file is not consulted + // Same path derivation as the config: the pane's dir holds config + known_hosts + agent.sock. + expect(pinPath).toBe(`/home/scripted/.subshell/ssh/${ID}/known_hosts`); + }); + + it("absent the pin option, the bytes are the M1 compose byte-for-byte (the accept-new branch is untouched)", () => { + const snapshot = SNAP(); + const plain = composeSshLaunch({ snapshot, targetDataDir: "/home/n/.subshell", subshellId: ID }); + expect(plain.fileContent).toBe(renderSshConfigContents(snapshot)); + expect(plain.fileContent).toContain("accept-new"); + }); +}); + describe("composeSshLaunch", () => { it("composes the config at the target machine's derived path, with the rendered bytes and the full option tail", () => { const snapshot = SNAP(); diff --git a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts index 20ee64bac..401ecdf3d 100644 --- a/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts @@ -403,6 +403,7 @@ test("6. a captured envelope is INERT replayed inside the lifetime and inert rep fingerprints: [fp(KEY_IN)], lifetimeMs: SSH_RELAY_LIFETIME_MS, paneId: PANE_ID, + hostPin: `git.example.test ssh-ed25519 ${"A".repeat(51)}`, }); await openARelaySession({ relay: s.aRelay, diff --git a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts index c025f0269..2119ca09e 100644 --- a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts @@ -41,6 +41,7 @@ import { * routing is a byte-for-byte copy and no session record ever holds a blob. */ +const PIN_LINE = `git.example.test ssh-ed25519 ${"A".repeat(51)}`; const PANE = "11111111-2222-4333-8444-555555555555"; const A_SIGNING = '{"kty":"EC","crv":"P-256","x":"AX","y":"AY","use":"sig"}'; const A_ENCRYPT = '{"kty":"EC","crv":"P-256","x":"EX","y":"EY","use":"enc"}'; @@ -162,6 +163,9 @@ function makeHarness( bNode: "b", aPeer: { signingPublicKey: A_SIGNING, encryptionPublicJwk: A_ENCRYPT }, bPeer: { signingPublicKey: B_SIGNING, encryptionPublicJwk: B_ENCRYPT }, + // Task 12: the destination's pin is REQUIRED at the broker; the base + // carries a well-formed line and one refusal test drops it. + hostPin: PIN_LINE, ...over, }), }; @@ -208,6 +212,12 @@ describe("openRelay (spec §5.1/§5.3, Task 8 (a)(b)(d)(g)(h))", () => { // (a) the grant's selected fingerprints are delivered for A's responder. expect(toA?.fingerprints).toEqual(FINGERPRINTS); expect(toA?.grantId).toBe("grant-1"); + // Task 12 (spec §9): the destination's pinned host-key line rides the + // signed open to BOTH sides (B writes it beside the socket it binds); the + // audit still names only ids, never the pin bytes. + expect(toA?.hostPin).toBe(PIN_LINE); + expect(toB?.hostPin).toBe(PIN_LINE); + expect(JSON.stringify(h.audits[0]?.meta ?? {})).not.toContain("ssh-ed25519"); // The open is audited once, ids only. expect(h.audits.map((a) => a.action)).toEqual(["node.ssh_relay.open"]); expect(h.audits[0]?.meta).toEqual({ @@ -279,6 +289,14 @@ describe("openRelay (spec §5.1/§5.3, Task 8 (a)(b)(d)(g)(h))", () => { await expect(h.open({ paneId: "" })).rejects.toMatchObject({ code: "bad-pane-id" }); await expect(h.open({ paneId: "x".repeat(65) })).rejects.toMatchObject({ code: "bad-pane-id" }); await expect(h.open({ fingerprints: ["not-a-fingerprint"] })).rejects.toMatchObject({ code: "bad-fingerprints" }); + // Task 12 (spec §9): the pin door at the mint - a session without the + // destination's pinned line does not exist, so a missing, empty, or + // multi-line hostPin is refused BEFORE any command leaves the plane. + await expect(h.open({ hostPin: "" })).rejects.toMatchObject({ code: "bad-host-pin" }); + await expect(h.open({ hostPin: undefined })).rejects.toMatchObject({ code: "bad-host-pin" }); + await expect(h.open({ hostPin: "host ssh-rsa AAA\nsecond ssh-rsa BBB" })).rejects.toMatchObject({ + code: "bad-host-pin", + }); expect(h.commands).toHaveLength(0); expect(h.audits).toHaveLength(0); }); diff --git a/apps/server/api/src/services/ssh-grants.service.ts b/apps/server/api/src/services/ssh-grants.service.ts index 0711e251b..bf058fd0e 100644 --- a/apps/server/api/src/services/ssh-grants.service.ts +++ b/apps/server/api/src/services/ssh-grants.service.ts @@ -13,6 +13,7 @@ import type { SshGrantCreatedVia, SshGrantRequestStatus, SshKeyGrantTable } from import { type AuditEventInput, audit } from "@/services/audit.js"; import { SshRpcError, sshAgentIdentities } from "@/services/nodes/ssh-rpc.js"; import { getNotifyService } from "@/services/notify.service.js"; +import { captureHostPin, hostPinFor, hostPinRefusal, SshHostPinError } from "@/services/ssh-host-pins.service.js"; import { getRelayBroker, type RelayBroker, type RelayPeerKeys, SshRelayRefusal } from "@/services/ssh-relay.service.js"; import { logger } from "@/utils/logger.js"; @@ -292,6 +293,13 @@ export async function requestFirstUse(args: { aNodeId: string; bNodeId: string; resolvedSelector: string; + /** + * The FULL canonical destination `user@host:port` the asking launch dialed + * (Task 12): the approval that answers this row also captures the host-key + * pin, and the pin is keyed per triple - the host alone cannot name a port + * or a user. Row-stored (migration 0051), never re-derived at approval. + */ + destination: string; paneId: string; }): Promise> { const now = grantsDeps().nowIso(); @@ -303,6 +311,7 @@ export async function requestFirstUse(args: { ownerUserId: args.ownerUserId, keyHomeNodeId: args.aNodeId, resolvedSelector: args.resolvedSelector, + destination: args.destination, requestedFingerprints: null, // the approver selects; the asking pane proposes nothing (spec §6.2) paneId: args.paneId, bNodeId: args.bNodeId, @@ -341,14 +350,21 @@ export async function sweepExpiredGrantRequests(): Promise { /** * Answer a pending request YES (spec §6.2): validate the selection against - * the cap and the grammar FIRST, then flip the row `pending -> approved` on a - * compare-and-set (a raced approver or the sweep loses here and sees the 409, - * never a double grant), then write the standing grant with EXACTLY the - * chosen fingerprints, created via first-use. Audits BOTH facts: the answer - * (`approve`) and the row's birth (`create`, the same event the screen's own - * create writes, per §6.2), naming ids, destination, and the CHOSEN - * fingerprint VALUES - §10 makes the approve row the only durable record of - * the operator's selection, so a count here would record no selection. + * the cap and the grammar FIRST, then - Task 12 - ensure the destination's + * host-key PIN exists (capture from A's `known_hosts`, or accept the owner's + * standing/explicit pin: the approval is "grant creation" for the first-use + * door, and a relay grant is never created without its pin), then flip the + * row `pending -> approved` on a compare-and-set (a raced approver or the + * sweep loses here and sees the 409, never a double grant), then write the + * standing grant with EXACTLY the chosen fingerprints, created via first-use. + * Audits BOTH facts: the answer (`approve`) and the row's birth (`create`, + * the same event the screen's own create writes, per §6.2), naming ids, + * destination, and the CHOSEN fingerprint VALUES - §10 makes the approve row + * the only durable record of the operator's selection, so a count here would + * record no selection. A capture failure leaves the row PENDING and the grant + * unwritten (the pending row is what makes the ask retryable); a pin row + * captured before a lost race stands harmless - same owner, same key home, + * same destination, and the retried capture accepts it idempotently. */ export async function approveGrant(args: { ownerUserId: string; @@ -363,6 +379,29 @@ export async function approveGrant(args: { const row = await repo.getRequest(args.ownerUserId, args.requestId); if (!row) return refused(NOT_FOUND_REQUEST); if (row.status !== "pending") return refused(ALREADY_ANSWERED); + // Capture-at-grant-creation (spec §9), before the flip and before the grant + // row exists: no pin, no grant. The stored pin (a previous grant's TOFU + // record for the same destination) satisfies the door WITHOUT re-asking A - + // the TOFU decision persists across grants; only an ABSENT pin triggers the + // fetch, whose every failure leaves the request pending untouched. + if (row.destination === null) { + return refused({ + status: 409, + code: BackendErrorCodes.SSH_HOST_PIN_MISSING, + message: + "This request predates host-key pinning and carries no full destination to pin. Deny it and launch again to ask fresh.", + }); + } + try { + await ensureDestinationPin({ + ownerUserId: args.ownerUserId, + aNodeId: row.keyHomeNodeId, + destination: row.destination, + }); + } catch (err) { + if (err instanceof SshHostPinError) return refused(hostPinRefusal(err)); + throw err; + } const flipped = await repo.markRequestStatus(args.ownerUserId, args.requestId, "pending", "approved"); if (!flipped) return refused(ALREADY_ANSWERED); const grant: SshKeyGrantTable = { @@ -654,6 +693,31 @@ function rosterRpcRefusal(err: SshRpcError): SshRefusalNarrow { }; } +/* ------------------------------------------------------------------ */ +/* the pin door (spec 2026-10-08 §9, Task 12): a relay never opens */ +/* without the destination's stored pin - the approval captures one */ +/* (this helper's first-use half) and the launch leg captures at the */ +/* first open for a destination a manual grant has not pinned yet. */ +/* ------------------------------------------------------------------ */ + +/** + * Read the destination's pin, capturing from A's `known_hosts` when none + * stands. Returns the row's pinned `known_hosts` line - the exact bytes that + * ride the relay-open to B. Raises {@link SshHostPinError} with the named + * cause on every fail-closed door (no entry, ambiguous, changed, the machine + * unreachable); nothing is written and no relay opens past one. + */ +export async function ensureDestinationPin(args: { + ownerUserId: string; + aNodeId: string; + destination: string; +}): Promise<{ line: string }> { + const existing = await hostPinFor({ ownerUserId: args.ownerUserId, destination: args.destination }); + if (existing !== null) return { line: existing.hostKey }; + const row = await captureHostPin(args); + return { line: row.hostKey }; +} + /* ------------------------------------------------------------------ */ /* the launch leg: match -> open (or ask -> refuse) */ /* ------------------------------------------------------------------ */ @@ -681,6 +745,14 @@ export async function prepareRelayLeg(args: { aNode: { id: string; name: string }; bNodeId: string; resolvedHost: string; + /** + * The canonical destination `user@host:port` the launch dialed (Task 12): + * the pin lookup key AND the capture destination (a manual wildcard grant + * gets its pin at this first open for each concrete destination - "one + * selector over many hosts yields many pins"), AND the first-use row's + * stored destination. The relay-open never carries a pairing without it. + */ + destination: string; paneId: string; }): Promise> { const grant = await matchGrant({ @@ -694,6 +766,7 @@ export async function prepareRelayLeg(args: { aNodeId: args.aNode.id, bNodeId: args.bNodeId, resolvedSelector: args.resolvedHost, + destination: args.destination, paneId: args.paneId, }); const ref = asked.ok ? asked.value.requestId : ""; @@ -733,6 +806,20 @@ export async function prepareRelayLeg(args: { if (!standing) { return refused(GRANT_REVOKED_DURING_LAUNCH); } + // The pin door (spec §9, Task 12): read the destination's stored pin, + // capturing from A's `known_hosts` at first open when none stands (the + // manual-grant and later-destination case). Every failure refuses the + // launch BEFORE any session exists - a relay without a pin would mean B + // ambient-TOFUing D, which is the one posture the design refuses. + let hostPin: string; + try { + hostPin = ( + await ensureDestinationPin({ ownerUserId: args.viewerId, aNodeId: args.aNode.id, destination: args.destination }) + ).line; + } catch (err) { + if (err instanceof SshHostPinError) return refused(hostPinRefusal(err)); + throw err; + } const broker = grantsDeps().broker(); let socketPath: string; try { @@ -744,6 +831,7 @@ export async function prepareRelayLeg(args: { bNode: args.bNodeId, aPeer, bPeer, + hostPin, }); socketPath = opened.socketPath; } catch (err) { @@ -793,6 +881,8 @@ function relayRefusalCopy(code: SshRelayRefusal["code"]): string { case "bad-pane-id": case "bad-fingerprints": return "The relay refused the session's identifiers as malformed; the launch was refused."; + case "bad-host-pin": + return "The relay refused the destination host pin as malformed; the launch was refused."; } } diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index 738dd2720..f6d22f13d 100644 --- a/apps/server/api/src/services/ssh-launch.service.ts +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -4,6 +4,7 @@ import { dirname } from "node:path"; import { BackendErrorCodes } from "@internal/backend-errors"; import { buildSshConfigPath, + buildSshKnownHostsPath, discoverSshAliases, findBinary, getHarness, @@ -125,6 +126,17 @@ export function composeSshLaunch(args: { snapshot: SshConnectionSnapshotWire; targetDataDir: string; subshellId: string; + /** + * The relay session's pinned host-key file path (Task 12, spec 2026-10-08 + * §9). PRESENT is the relay mode: the rendered config forces + * `StrictHostKeyChecking yes` and names this one file as the ONLY trust + * source - the pane's ssh verifies D against A's recorded key, never + * against B's ambient known_hosts. ABSENT is the M1 direct launch: the + * accept-new posture, byte-for-byte. The path is `buildSshKnownHostsPath` + * of the SAME two facts the config path derives from; B writes the pin + * delivered on the signed relay-open to its own derivation of them. + */ + hostPinPath?: string; }): { configPath: string; fileContent: string; @@ -134,7 +146,10 @@ export function composeSshLaunch(args: { const approved = parseSshConnectionSnapshot(args.snapshot); if (approved === null) throw new Error("ssh compose: snapshot failed grammar re-validation"); const configPath = buildSshConfigPath(args.targetDataDir, args.subshellId); - const fileContent = renderSshConfigContents(approved); + const fileContent = renderSshConfigContents( + approved, + args.hostPinPath === undefined ? undefined : { hostPinPath: args.hostPinPath }, + ); const presetFlags = [...sshOptionTokens(approved, configPath), "--", sshDestinationToken(approved)]; return approved.authAgentSocket === null ? { configPath, fileContent, presetFlags } @@ -444,12 +459,23 @@ export async function sshLaunch(args: { aNode: { id: aRow.id, name: aRow.name }, bNodeId: row.id, resolvedHost: snapshot.host, + // The canonical triple the pin is keyed by, from the SAME validated + // snapshot whose host drove the grant match (Task 12). + destination: sshCanonicalDestination({ host: snapshot.host, port: snapshot.port, user: snapshot.user }), paneId: subshellId, }); if (!leg.ok) return leg; relay = leg.value; } - const composed = composeSshLaunch({ snapshot, targetDataDir: dataDir, subshellId }); + const composed = composeSshLaunch({ + snapshot, + targetDataDir: dataDir, + subshellId, + // Relay mode (Task 12): the config pins B's trust source to the file the + // signed relay-open just wrote beside it; a direct launch renders the M1 + // accept-new bytes untouched. + ...(relay ? { hostPinPath: buildSshKnownHostsPath(dataDir, subshellId) } : {}), + }); // Relay mode RE-POINTS the scoped exception (spec §5.2): the socket the // pane's ssh authenticates through is B's proxy the broker just verified, // never the snapshot's agent socket even if the config names one. Still diff --git a/apps/server/api/src/services/ssh-relay.service.ts b/apps/server/api/src/services/ssh-relay.service.ts index e22a15a63..6dd7ec9ed 100644 --- a/apps/server/api/src/services/ssh-relay.service.ts +++ b/apps/server/api/src/services/ssh-relay.service.ts @@ -1,6 +1,7 @@ import { buildAgentSocketPath } from "@internal/pane-runtime"; import { isSshGrantFingerprints, + isSshKnownHostsPinLine, isSshPaneId, NODE_MAX_FRAME_BYTES, type NodeCommandBody, @@ -101,6 +102,7 @@ export interface RelayPeerKeys { export type SshRelayRefusalCode = | "bad-pane-id" | "bad-fingerprints" + | "bad-host-pin" | "same-node" | "no-node" | "local-node" @@ -171,6 +173,14 @@ export interface OpenRelayInput { aPeer: RelayPeerKeys; /** B's registered public halves, from B's identity row. */ bPeer: RelayPeerKeys; + /** + * The destination's pinned `known_hosts` line (spec 2026-10-08 §9, Task + * 12), REQUIRED and validated with the wire grammar's own predicate before + * anything is signed. B's open writes it to the pane's 0600 pinned file; + * a broker call without it is a broker bug, refused like the bad-pane-id + * and bad-fingerprints doors beside it. + */ + hostPin: string; } /** What a successful `openRelay` answers: the pairing, plus the socket the pane's scoped env must point at. */ @@ -267,6 +277,7 @@ function openCmd(input: OpenRelayInput, role: "A" | "B", ref: string, relayId: s fingerprints: [...input.fingerprints], lifetimeMs: SSH_RELAY_LIFETIME_MS, paneId: input.paneId, + hostPin: input.hostPin, }; } @@ -349,6 +360,17 @@ export function createRelayBroker(deps: RelayBrokerDeps): RelayBroker { "relay open refused: the grant's fingerprint set is malformed or over SSH_MAX_GRANT_FINGERPRINTS", ); } + // Task 12 (spec §9): the destination's pin travels on the open, and the + // grammar's own line predicate is the broker's check too - a relay + // session with no pin (or with a multi-line smuggle) is refused BEFORE + // anything is signed or sent, so "the relay grant carries a pin" is + // enforced at the mint, not hoped for at the endpoints. + if (!isSshKnownHostsPinLine(input.hostPin)) { + throw new SshRelayRefusal( + "bad-host-pin", + "relay open refused: the destination host pin is missing, empty, or not one known_hosts line", + ); + } if (input.aNode === input.bNode) { // A relay bridges two machines; a one-machine pairing means a caller // that should not have asked at all. diff --git a/docs/security.md b/docs/security.md index f7877408d..d45dda8a0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2293,6 +2293,14 @@ Audit events are written, grouped by family: is audit-only and expiry writes NOTHING; delete carries `relaysClosed`, the revoke's live-session cut made real; create carries `via: "first-use" | "manual"`; no key material, challenge, or signature rides any row), + `node.ssh_host_pin.create|delete` (spec §9, Task 12: the destination's + TOFU record, created at a grant's capture or an explicit supply and deleted + as the recovery's first half; metadata names the canonical destination and + the pinned key's `SHA256:` fingerprint ONLY - a host-key fingerprint is a + public identifier like the grant's agent fingerprints - plus `via: + "captured" | "explicit"` on create; the pinned line's bytes ride NO audit + row, log line, or notification, and the `ssh_host_key` capture COMMAND + itself writes nothing: the row belongs to the capture act), `node.ssh_relay.open|close` (spec §5.3/§9: open names grant, A, B, pane, the routing ref, and the fingerprint COUNT; close names the one `reason` word from the grammar - the plane's broker rows, `actorUserId: From 69264e6fcd14e4e3ec9e140ec812a33e3e95f5b6 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 16:44:12 -0700 Subject: [PATCH 59/92] test(node): narrow the ssh_host_key answer and pin the JWK-refusal fixture verify-types caught the two spots: the dedup assertion now narrows ok before reaching for lines, and the private-JWK refusal fixture carries the required host pin like every other open command. --- apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts | 3 ++- apps/node/agent/src/__tests__/relay-responder.test.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts b/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts index c1a483136..2687e27e0 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts @@ -181,7 +181,8 @@ describe("ssh_host_key arm (gate ON)", () => { "[deploy@git.example.test]:22": { code: 1, stdout: "", stderr: "" }, }); const res = await execSshHostKey(makeCtx("dedup"), cmd({ user: "deploy" }), seamsFor(trust, fake.run)); - expect((res as { data: { lines: string[] } }).data.lines).toEqual([LINE_A]); + if (!res.ok) throw new Error(`expected ok:true, got ${JSON.stringify(res)}`); + expect((res.data as { lines: string[] }).lines).toEqual([LINE_A]); }); it("a silent non-zero run with stderr is the named unreadable-file refusal, never the empty fact", async () => { diff --git a/apps/node/agent/src/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts index 6ee7df87b..fab847686 100644 --- a/apps/node/agent/src/__tests__/relay-responder.test.ts +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -1174,6 +1174,7 @@ test("a pinned peer key that is not a usable public P-256 JWK rejects the sessio fingerprints: [], lifetimeMs: 30_000, paneId: "pane-a-test", + hostPin: HOST_PIN, }, sendRelayFrame: () => {}, resolveAgentSocket: () => null, From bcbab66a13ccf689ea3201fa2897c89934b909fb Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:05:52 -0700 Subject: [PATCH 60/92] fix(ssh-relay): neutralize GlobalKnownHostsFile in relay mode; idempotent host-pin capture --- apps/node/agent/src/commands/ssh-host-key.ts | 8 +- .../__tests__/ssh-host-pins.service.test.ts | 81 +++++++++++++++++ .../api/src/services/ssh-host-pins.service.ts | 87 +++++++++++++++---- .../src/ssh/__tests__/ssh-render.test.ts | 18 +++- packages/pane-runtime/src/ssh/ssh-render.ts | 20 +++-- 5 files changed, 185 insertions(+), 29 deletions(-) diff --git a/apps/node/agent/src/commands/ssh-host-key.ts b/apps/node/agent/src/commands/ssh-host-key.ts index 5f950dadf..10fe06426 100644 --- a/apps/node/agent/src/commands/ssh-host-key.ts +++ b/apps/node/agent/src/commands/ssh-host-key.ts @@ -77,7 +77,8 @@ export function sshHostKeyCandidates({ host, port, user }: SshHostKeyCommand): s * not, and both are dropped here. Non-zero exit with no output is OpenSSH's * spelling of "this candidate matched nothing" (and of an unreadable file - * the caller distinguishes those by whether ANY candidate produced bytes - * and by the stderr it logs); non-zero with output still yields its entries. + * and by the stderr it reports); non-zero with output still yields its + * entries. */ function entryLines(stdout: string): string[] { const lines: string[] = []; @@ -127,8 +128,9 @@ export async function execSshHostKey( // Non-zero AND silent: "no match" (normal, keep looking) is // indistinguishable from "cannot read the file" only by stderr, and // the file's readability was not claimed - so an unreadable file must - // not read as "A recorded nothing". One line to the log (names never - // key bytes); the refusal goes to the plane as the named error. + // not read as "A recorded nothing". No log line is written here: the + // refusal below names the cause to the plane, which is the whole + // record this arm keeps. sawReadFailure = sawReadFailure || run.stderr.trim() !== ""; continue; } diff --git a/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts b/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts index 5b5cf8eff..19e88cd04 100644 --- a/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts @@ -6,7 +6,9 @@ import { hashPassword } from "better-auth/crypto"; import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; import { db } from "@/db/index.js"; +import { SshHostPinsRepository } from "@/db/repositories/ssh-host-pins.repository.js"; import { UsersRepository } from "@/db/repositories/users.repository.js"; +import type { SshHostPinTable } from "@/db/types/ssh-grants.db-types.js"; import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; import { captureHostPin, @@ -257,6 +259,85 @@ describe("capture", () => { }); }); +describe("the concurrent-first-capture race (the UNIQUE index decides, never a raw error)", () => { + /** + * Simulate the interleaving deterministically without a second connection: + * blind the service's FIRST getPin (the read every serialized capture made + * before its rival committed) while the real UNIQUE(owner_user_id, + * destination) index stays live, so the loser's insert collides for real + * and the re-read sees the winner. Every later read answers truthfully. + */ + async function withBlindedFirstRead(fn: () => Promise): Promise { + const proto = SshHostPinsRepository.prototype as { + getPin: (ownerUserId: string, destination: string) => Promise; + }; + const realGetPin = proto.getPin; + let blinded = false; + proto.getPin = async function (this: SshHostPinsRepository, o: string, d: string) { + if (!blinded) { + blinded = true; + return null; + } + return await realGetPin.call(this, o, d); + }; + try { + return await fn(); + } finally { + proto.getPin = realGetPin; + } + } + + async function pinRowCount(): Promise { + const rows = await db.selectFrom("sshHostPins").select("id").execute(); + return rows.length; + } + + async function createAuditCount(): Promise { + const rows = await db + .selectFrom("auditEvents") + .select("id") + .where("action", "=", "node.ssh_host_pin.create") + .execute(); + return rows.length; + } + + it("two concurrent FIRST captures at the SAME key: no raw error, one row, one create row, both answer the winner's pin", async () => { + // This is what the unguarded get-then-insert surfaced as a raw + // SqliteError (a 500 on the grant path): the collision must resolve to + // the serialized path's outcome - idempotent success, touched stamp. + const [a, b] = await withBlindedFirstRead(async () => { + return await Promise.all([ + captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + ]); + }); + expect(a.hostKey).toBe(LINE); + expect(b.hostKey).toBe(LINE); + expect(a.id).toBe(b.id); // both answered the SAME standing row + expect(await pinRowCount()).toBe(1); + expect(await createAuditCount()).toBe(1); // the winner created; the loser only touched + }); + + it("a FIRST capture colliding with a DIFFERENT key is the changed block through the race path too, the winner's row standing", async () => { + // The winner's LINE stands; the loser (blinded initial read) races in + // with OTHER_LINE, hits the index, and must read back and refuse exactly + // as the serialized path does - not crash, not overwrite. + await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); // the winner + fetchAnswer = () => ({ lines: [OTHER_LINE] }); + const err = await withBlindedFirstRead(async () => { + return await expectPinError( + () => captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }), + "changed", + ); + }); + expect(hostPinRefusal(err).code).toBe(BackendErrorCodes.SSH_HOST_PIN_CHANGED); + const standing = await hostPinFor({ ownerUserId: owner, destination: DEST }); + expect(standing?.hostKey).toBe(LINE); + expect(await pinRowCount()).toBe(1); + expect(await createAuditCount()).toBe(1); + }); +}); + describe("delete and re-capture (the TOFU recovery)", () => { it("delete removes the row and audits destination + fingerprint; the re-capture at the NEW key succeeds as a fresh TOFU", async () => { await captureHostPin({ ownerUserId: owner, aNodeId: "node-a", destination: DEST }); diff --git a/apps/server/api/src/services/ssh-host-pins.service.ts b/apps/server/api/src/services/ssh-host-pins.service.ts index 1f250b15a..df86bcd72 100644 --- a/apps/server/api/src/services/ssh-host-pins.service.ts +++ b/apps/server/api/src/services/ssh-host-pins.service.ts @@ -71,6 +71,17 @@ export class SshHostPinError extends Error { const repo = new SshHostPinsRepository(db); +/** + * True when a failed insert hit this table's UNIQUE + * `(owner_user_id, destination)` index (idx_ssh_host_pins_owner_destination): + * the `node-errors` message-containment posture, scoped to the table so any + * OTHER constraint violation stays loud instead of being swallowed as a race. + */ +function isUniquePinViolation(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return msg.includes("UNIQUE constraint failed") && msg.includes("ssh_host_pins"); +} + /** The service's world. Production at {@link defaultPinsDeps}; tests install a fake. */ export interface SshHostPinsDeps { /** The one clock: row stamps. */ @@ -209,6 +220,37 @@ async function fetchAndChooseLine(aNodeId: string | null, destination: string): return chooseCapturedLine(destination, answer.lines); } +/** + * The decision over a STANDING row, shared by the serialized read and the + * insert-collision readback so a raced capture decides EXACTLY as the + * serialized one: a differing key is §9's byte-equality hard block (nothing + * written, nothing deleted - the operator's re-decide is the only way past), + * the same key is the TOFU record agreeing with itself (the row stands, + * updatedAt = the last accepted match, no second create row). + */ +async function acceptStandingPin( + ownerUserId: string, + existing: SshHostPinTable, + fps: string[], + destination: string, +): Promise { + const stored = hostKeyFingerprints(existing.hostKey); + if (stored.length !== 1 || stored[0] !== fps[0]) { + // §9's byte-equality hard block, at the plane's edge: A now reports a + // different key than the pin. Nothing written, nothing deleted - the + // operator's re-decide (delete + fresh capture) is the only way past. + throw new SshHostPinError( + "changed", + `the key home now reports a different host key for ${destination} than the pinned one`, + destination, + ); + } + // The same key again is the TOFU record agreeing with itself: the row + // stands (updatedAt = the last accepted match), no second create row. + await repo.touchPin(ownerUserId, destination, pinsDeps().nowIso()); + return { ...existing, updatedAt: pinsDeps().nowIso() }; +} + /** * Capture (or accept, idempotently) the owner's pin for one canonical * destination. `hostKeyLine` supplies the explicit-pin posture (the operator's @@ -243,23 +285,7 @@ export async function captureHostPin(args: { ); } const existing = await repo.getPin(ownerUserId, destination); - if (existing !== null) { - const stored = hostKeyFingerprints(existing.hostKey); - if (stored.length !== 1 || stored[0] !== fps[0]) { - // §9's byte-equality hard block, at the plane's edge: A now reports a - // different key than the pin. Nothing written, nothing deleted - the - // operator's re-decide (delete + fresh capture) is the only way past. - throw new SshHostPinError( - "changed", - `the key home now reports a different host key for ${destination} than the pinned one`, - destination, - ); - } - // The same key again is the TOFU record agreeing with itself: the row - // stands (updatedAt = the last accepted match), no second create row. - await repo.touchPin(ownerUserId, destination, pinsDeps().nowIso()); - return { ...existing, updatedAt: pinsDeps().nowIso() }; - } + if (existing !== null) return await acceptStandingPin(ownerUserId, existing, fps, destination); const now = pinsDeps().nowIso(); const row: SshHostPinTable = { id: crypto.randomUUID(), @@ -269,7 +295,32 @@ export async function captureHostPin(args: { createdAt: now, updatedAt: now, }; - await repo.insertPin(row); + // The get-then-insert above races the UNIQUE (owner_user_id, destination) + // index: two concurrent FIRST captures of one destination both read "no + // row". A collision is decided, never surfaced raw: the loser re-reads the + // winner's row and accepts it through the SAME rule the serialized path + // applies (same key = the idempotent touch, different key = the changed + // block). A winner that vanished between the refused insert and the + // readback (a concurrent delete) gets one retry; a second double-race + // refuses by name rather than looping - a non-violation DB error is not a + // race and stays loud. + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await repo.insertPin(row); + break; + } catch (err) { + if (!isUniquePinViolation(err)) throw err; + const standing = await repo.getPin(ownerUserId, destination); + if (standing !== null) return await acceptStandingPin(ownerUserId, standing, fps, destination); + if (attempt === 1) { + throw new SshHostPinError( + "changed", + `the host-pin store refused the capture for ${destination} twice while its row kept changing; ask again`, + destination, + ); + } + } + } await pinAudit(ownerUserId, "node.ssh_host_pin.create", aNodeId, { destination, fingerprint: fps[0], // the public identifier ONLY, never the line's bytes (§10) diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts index 0f94245bf..bb33c86d4 100644 --- a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts @@ -261,12 +261,24 @@ describe("renderSshConfigContents relay mode (spec 2026-10-08 §9)", () => { expect(out).toContain(` UserKnownHostsFile ${PIN}`); }); + it("neutralizes the system-wide file too: BOTH trust refs, UserKnownHostsFile pinned and GlobalKnownHostsFile /dev/null", () => { + // OpenSSH consults GlobalKnownHostsFile (/etc/ssh/ssh_known_hosts by + // default) ALONGSIDE UserKnownHostsFile, and a match there satisfies + // `yes` - outranking the user file is not enough. A planted or stale key + // in B's system-wide file must not authenticate D: the destination's + // global trust DB is /dev/null for every hop (spec 2026-10-08 §9: the + // pinned file is the authority, not ambient state on B). + const out = renderSshConfigContents(baseSnapshot(), relay); + expect(out).toContain(` UserKnownHostsFile ${PIN}`); + expect(out).toContain(" GlobalKnownHostsFile /dev/null"); + }); + it("quotes the pinned path only when it needs it (the same tokenizer rule as other file values)", () => { const spaced = renderSshConfigContents(baseSnapshot(), { hostPinPath: "/data/ssh dir/s1/known_hosts" }); expect(spaced).toContain(' UserKnownHostsFile "/data/ssh dir/s1/known_hosts"'); }); - it("keeps every other mandatory policy line unchanged (relay mode is ONLY the trust source + yes)", () => { + it("keeps every other mandatory policy line unchanged (relay mode is ONLY the trust refs + yes)", () => { const out = renderSshConfigContents(baseSnapshot(), relay); for (const [key, value] of [ ["ForwardAgent", "no"], @@ -286,6 +298,10 @@ describe("renderSshConfigContents relay mode (spec 2026-10-08 §9)", () => { expect(renderSshConfigContents(snap)).toContain(" StrictHostKeyChecking accept-new"); // No second argument is the M1 call; it must equal an explicit-undefined. expect(renderSshConfigContents(snap, undefined)).toBe(renderSshConfigContents(snap)); + // The relay's global-file neutralization must NOT leak into the M1 + // posture: the direct branch keeps consulting the machine's own trust + // files exactly as M1 rendered them. + expect(renderSshConfigContents(snap)).not.toContain("GlobalKnownHostsFile"); }); it("refuses a non-absolute or oversized hostPinPath (never composes a stray trust file)", () => { diff --git a/packages/pane-runtime/src/ssh/ssh-render.ts b/packages/pane-runtime/src/ssh/ssh-render.ts index d74535256..a15d88b26 100644 --- a/packages/pane-runtime/src/ssh/ssh-render.ts +++ b/packages/pane-runtime/src/ssh/ssh-render.ts @@ -155,14 +155,18 @@ const HOST_KEY_CHECK: Record<"direct" | "relay", string> = { direct: "accept-new * — the same approval always renders the same file, which is what makes the * run's recorded config byte-checkable. * - * Relay mode (the second argument, spec 2026-10-08 §9) changes EXACTLY two + * Relay mode (the second argument, spec 2026-10-08 §9) changes EXACTLY three * things, and pins the test suite to all of them: `StrictHostKeyChecking` * says `yes` instead of `accept-new` (a changed D key is refused by OpenSSH - * naming D; nothing new is ever recorded), and `UserKnownHostsFile` is the + * naming D; nothing new is ever recorded); `UserKnownHostsFile` is the * session's pinned file and NOTHING ELSE - the snapshot's own trust refs are * replaced, because on the relay path B's ambient known_hosts is not the - * authority, A's recorded key is. Absent the option (every M1 launch) the - * bytes are the accept-new posture byte-for-byte, untouched. + * authority, A's recorded key is; and `GlobalKnownHostsFile` becomes + * `/dev/null`, because OpenSSH consults the system-wide file ALONGSIDE the + * user file and a match there satisfies `yes` - a planted or stale entry in + * B's `/etc/ssh/ssh_known_hosts` must not authenticate D either. Absent the + * option (every M1 launch) the bytes are the accept-new posture + * byte-for-byte, untouched: the direct branch never names the global file. */ export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire, relay?: SshRelayRenderOption): string { assertRenderable(snapshot); @@ -189,11 +193,13 @@ export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire, rel : []), "Host *", ...MANDATORY_POLICY.map(([key, value]) => ` ${key} ${key === "StrictHostKeyChecking" ? checking : value}`), - // Relay: the pinned file is the ONE trust source, the snapshot's own - // refs must not ride beside it. Direct: the M1 rule (silence IS the + // Relay: the pinned file is the ONE trust source - the snapshot's own + // refs must not ride beside it, and neither may the system-wide DB + // (`yes` consults GlobalKnownHostsFile too, so it is /dev/null'd; one + // site names both trust refs). Direct: the M1 rule (silence IS the // policy, the machine's default known_hosts is the trust store). ...(relay - ? [` UserKnownHostsFile ${configPathValue(relay.hostPinPath)}`] + ? [` UserKnownHostsFile ${configPathValue(relay.hostPinPath)}`, " GlobalKnownHostsFile /dev/null"] : knownHostsLines(snapshot.knownHostsFiles)), ...snapshot.identityFiles.map((f) => ` IdentityFile ${configPathValue(f)}`), ...snapshot.certificateFiles.map((f) => ` CertificateFile ${configPathValue(f)}`), From 7ec2e82cb6109d2d0e607459d1537558419be777 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:13:11 -0700 Subject: [PATCH 61/92] feat(ssh-relay): ssh-fingerprint block in the runtime report, hand-rolled canonical validator --- .../src/__tests__/node-frames.test.ts | 102 ++++++++++++++++++ packages/subshell-protocol/src/index.ts | 4 + packages/subshell-protocol/src/node-frames.ts | 101 +++++++++++++++++ 3 files changed, 207 insertions(+) diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 314e18661..1278098f8 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -22,6 +22,7 @@ import { parseNodeCommandBody, parseNodeEvent, parseNodeRuntimeReport, + parseNodeSshFingerprintReport, partPathOf, } from "../node-frames.js"; import { @@ -748,6 +749,107 @@ describe("ready.runtime (additive)", () => { }); }); +describe("ready.runtime.sshFingerprint (spec 2026-10-08 §4.6)", () => { + const base = { + type: "ready", + agentVersion: "0.2.0", + protocolVersion: NODE_PROTOCOL_VERSION, + os: "linux", + arch: "x64", + hostname: "h", + dataDir: "/d", + capabilities: [], + }; + const runtime = { + startedAt: "2026-09-12T10:00:00.000Z", + supervised: true, + service: { + manager: "systemd", + installed: true, + definitionPath: "/u/.config/systemd/user/subshell.service", + state: "running", + pid: 42, + enabled: true, + linger: true, + paneSafety: "keeps", + }, + configPath: "/u/.config/subshell/config.json", + agentLogPath: "/u/.config/subshell/logs/agent.log", + logging: { debug: false, source: "default" }, + logPath: null, + logHint: null, + tmuxPath: "/usr/bin/tmux", + binaryPath: "/u/.local/bin/subshell", + }; + /** A canonical 32-byte SHA-256 digest printed as 43 base64url characters. */ + const FP_A = `SHA256:${"A".repeat(43)}`; + const FP_B = `SHA256:${"B".repeat(43)}`; + const FP_C = `SHA256:${"C".repeat(43)}`; + const FP_D = `SHA256:${"D".repeat(43)}`; + const block = { + own: { signing: FP_A, encryption: FP_B }, + peers: [{ nodeId: "0f8e2c1a-0000-4000-8000-000000000001", signing: FP_C, encryption: FP_D }], + }; + + it("parseNodeSshFingerprintReport accepts a canonical block, empty peers included", () => { + expect(parseNodeSshFingerprintReport(block)).toEqual(block); + expect(parseNodeSshFingerprintReport({ own: block.own, peers: [] })).toEqual({ own: block.own, peers: [] }); + }); + + it("refuses fingerprints that are not canonical SHA256 base64url", () => { + // The spelling the node emits is `fingerprintJwk`'s exactly: the `SHA256:` + // prefix, 43 unpadded base64url characters, never the `+` `/` `=` spellings + // of standard base64 (a different scheme would fingerprint-compare against + // bytes the machine never pinned). + for (const bad of [ + "A".repeat(43), // no prefix + `SHA256:${"A".repeat(42)}`, // short digest + `SHA256:${"A".repeat(44)}`, // long digest + `SHA256:${"A".repeat(42)}=`, // padded, not canonical here + `SHA256:${"+".repeat(43)}`, // standard-base64 alphabet + `sha256:${"A".repeat(43)}`, // case-sensitive prefix + ]) { + expect(parseNodeSshFingerprintReport({ own: { signing: bad, encryption: FP_B }, peers: [] })).toBeNull(); + } + }); + + it("refuses structural junk: missing halves, non-array peers, bad peer ids", () => { + expect(parseNodeSshFingerprintReport(null)).toBeNull(); + expect(parseNodeSshFingerprintReport("own")).toBeNull(); + expect(parseNodeSshFingerprintReport({ own: { signing: FP_A }, peers: [] })).toBeNull(); + expect(parseNodeSshFingerprintReport({ own: block.own })).toBeNull(); // peers REQUIRED (empty = no peers) + expect(parseNodeSshFingerprintReport({ own: block.own, peers: "none" })).toBeNull(); + expect(parseNodeSshFingerprintReport({ own: block.own, peers: [{ signing: FP_C, encryption: FP_D }] })).toBeNull(); + expect( + parseNodeSshFingerprintReport({ own: block.own, peers: [{ nodeId: "", signing: FP_C, encryption: FP_D }] }), + ).toBeNull(); + expect( + parseNodeSshFingerprintReport({ own: block.own, peers: [{ nodeId: "a b/c", signing: FP_C, encryption: FP_D }] }), + ).toBeNull(); + expect( + parseNodeSshFingerprintReport({ + own: block.own, + peers: [{ nodeId: "n".repeat(65), signing: FP_C, encryption: FP_D }], + }), + ).toBeNull(); + }); + + it("runtime carries a well-formed block and drops a malformed one, keeping the report", () => { + expect(parseNodeRuntimeReport({ ...runtime, sshFingerprint: block })?.sshFingerprint).toEqual(block); + const withBad = parseNodeRuntimeReport({ ...runtime, sshFingerprint: { own: "yes", peers: [] } }); + expect(withBad).not.toBeNull(); + expect(withBad && "sshFingerprint" in withBad ? withBad.sshFingerprint : undefined).toBeUndefined(); + expect(parseNodeRuntimeReport(runtime) && "sshFingerprint" in (parseNodeRuntimeReport(runtime) as object)).toBe( + false, + ); + }); + + it("the ready frame keeps a reported block end to end", () => { + const ev = parseNodeEvent({ ...base, runtime: { ...runtime, sshFingerprint: block } }); + expect(ev && ev.type === "ready" ? ev.runtime?.sshFingerprint : undefined).toEqual(block); + }); +}); + describe("service command", () => { it("parses every verb, with and without force", () => { for (const verb of NODE_SERVICE_VERBS) { diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 9293e5e97..f336cbc4c 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -105,6 +105,9 @@ export { type NodeRuntimeReport, type NodeServiceVerb, type NodeSshEnabledWire, + type NodeSshFingerprintPair, + type NodeSshFingerprintReport, + type NodeSshPeerFingerprint, type PluginReportWire, type PresetDefinitionWire, parseNodeCommandBody, @@ -112,6 +115,7 @@ export { parseNodeMaintenance, parseNodeRuntimeReport, parseNodeSshEnabled, + parseNodeSshFingerprintReport, parseRelayFrame, partPathOf, RELAY_DIRECTIONS, diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 653e3c942..06b57bde7 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -491,6 +491,101 @@ export interface NodeRuntimeReport { tmuxPath: string | null; /** The agent binary this process re-enters (`selfInvoke.command`). */ binaryPath: string; + /** + * The machine's SSH trust block (spec 2026-10-08 §4.6): this machine's own + * signing and encryption key fingerprints, and the pinned half of every + * relay peer, as the canonical `SHA256:` form of `fingerprintJwk`. + * + * Fingerprints are the DISPLAY form of the trust the node enforces as raw + * byte equality (`machine-pin-store.ts`): this block carries no key + * material, only public identifiers, and exists so an operator can compare + * what one machine STORED about a peer against what the peer COMPUTES from + * its own keys. Absent from agents that predate M2, and dropped rather than + * fatal when malformed, like `logging`. + */ + sshFingerprint?: NodeSshFingerprintReport; +} + +/** + * One machine's two public relay halves as `SHA256:` fingerprints (spec + * 2026-10-08 §4.5): the ES256 signing key and the ECDH-ES encryption key. + */ +export interface NodeSshFingerprintPair { + /** Fingerprint of the ES256 machine-signing public key. */ + signing: string; + /** Fingerprint of the ECDH-ES encryption public key. */ + encryption: string; +} + +/** One pinned peer, named by id, with both of its fingerprints. */ +export interface NodeSshPeerFingerprint extends NodeSshFingerprintPair { + /** The pinned peer's node id. */ + nodeId: string; +} + +/** + * The §4.6 trust block: what this machine pins for itself and every peer, + * computed from its OWN key files, never from the plane's copy of them. The + * plane mirrors it onto `nodes.ssh_fingerprint` (the trust card's stale half). + */ +export interface NodeSshFingerprintReport { + /** This machine's own both halves. */ + own: NodeSshFingerprintPair; + /** Every pinned peer; the empty array is the honest "no peers yet". */ + peers: NodeSshPeerFingerprint[]; +} + +/** + * The canonical fingerprint spelling of `fingerprintJwk`: the `SHA256:` + * prefix and exactly the 43 unpadded base64url characters of a 32-byte + * SHA-256 digest. Nothing else is admitted: the plane mirrors and renders + * bytes it cannot re-derive, so a looser grammar would let another scheme's + * digests into the same out-of-band comparison the block exists to enable. + */ +const SSH_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9_-]{43}$/; + +/** Whether `value` is a canonical `SHA256:` fingerprint string. */ +function isSshFingerprint(value: unknown): value is string { + return typeof value === "string" && SSH_FINGERPRINT_RE.test(value); +} + +/** + * Node ids as the plane mints them: uuid text (hex and hyphens). `isNodeSubshellId`'s + * shape, reused because a peer id reaching the block is the same registry id. + */ +function isPeerNodeId(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-fA-F-]{1,64}$/.test(value); +} + +/** The `{ signing, encryption }` pair, or null when either half is off. */ +function parseFingerprintPair(value: unknown): NodeSshFingerprintPair | null { + if (!isRecord(value)) return null; + if (!isSshFingerprint(value.signing) || !isSshFingerprint(value.encryption)) return null; + return { signing: value.signing, encryption: value.encryption }; +} + +/** + * Shape-check a `NodeSshFingerprintReport` (the runtime report's §4.6 block). + * STRICT inside, like every required field of the runtime report it rides: + * a half-refused block would print a trust card whose compare is meaningless, + * so one bad entry refuses the whole block (the block itself is dropped from + * the runtime report leniently, the `logging` way: the card is lost, not the + * connection). + * @param value - the candidate, typically `ready.runtime.sshFingerprint` + * @returns the narrowed report, or null when malformed + */ +export function parseNodeSshFingerprintReport(value: unknown): NodeSshFingerprintReport | null { + if (!isRecord(value) || !Array.isArray(value.peers)) return null; + const own = parseFingerprintPair(value.own); + if (own === null) return null; + const peers: NodeSshPeerFingerprint[] = []; + for (const entry of value.peers) { + if (!isRecord(entry) || !isPeerNodeId(entry.nodeId)) return null; + const pair = parseFingerprintPair(entry); + if (pair === null) return null; + peers.push({ nodeId: entry.nodeId, signing: pair.signing, encryption: pair.encryption }); + } + return { own, peers }; } /** The agent's debug state, or the default when it did not say it properly. */ @@ -536,6 +631,11 @@ export function parseNodeRuntimeReport(value: unknown): NodeRuntimeReport | null ) { return null; } + // Lenient like `logging`, strict inside: a present-but-malformed block + // drops the BLOCK (no trust card), never the runtime report beside it + // (paths, supervision, the verbs). A valid one passes through narrowed. + const sshFingerprint = + value.sshFingerprint === undefined ? null : parseNodeSshFingerprintReport(value.sshFingerprint); return { startedAt: value.startedAt, supervised: value.supervised, @@ -562,6 +662,7 @@ export function parseNodeRuntimeReport(value: unknown): NodeRuntimeReport | null logHint: value.logHint, tmuxPath: value.tmuxPath, binaryPath: value.binaryPath, + ...(sshFingerprint ? { sshFingerprint } : {}), }; } From 5f352fcb202846009cb1e85ce83bc647fee4404c Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:17:01 -0700 Subject: [PATCH 62/92] =?UTF-8?q?feat(ssh-relay):=20agent=20carries=20the?= =?UTF-8?q?=20=C2=A74.6=20trust=20block=20in=20ready=20and=20on=20its=20lo?= =?UTF-8?q?opback=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/node/agent/src/__tests__/daemon.test.ts | 71 +++++++++++++++++ .../src/__tests__/machine-pin-store.test.ts | 24 ++++++ .../__tests__/ssh-fingerprint-report.test.ts | 79 +++++++++++++++++++ apps/node/agent/src/daemon.ts | 31 +++++++- .../src/dashboard/__tests__/dashboard.test.ts | 44 ++++++++++- apps/node/agent/src/dashboard/view.ts | 11 +++ apps/node/agent/src/machine-pin-store.ts | 14 ++++ apps/node/agent/src/ssh-fingerprint-report.ts | 52 ++++++++++++ 8 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 apps/node/agent/src/__tests__/ssh-fingerprint-report.test.ts create mode 100644 apps/node/agent/src/ssh-fingerprint-report.ts diff --git a/apps/node/agent/src/__tests__/daemon.test.ts b/apps/node/agent/src/__tests__/daemon.test.ts index 504c6f29d..ae43fd39c 100644 --- a/apps/node/agent/src/__tests__/daemon.test.ts +++ b/apps/node/agent/src/__tests__/daemon.test.ts @@ -16,6 +16,7 @@ import { join, resolve } from "node:path"; import type { TmuxRunner } from "@internal/pane-runtime"; import { type ControlKeyPair, + fingerprintJwk, generateControlKeys, HARNESS_BINARY_PLACEHOLDER, NODE_CLOSE_HANDSHAKE_REQUIRED, @@ -51,6 +52,7 @@ import { wsUrlFor, } from "../daemon.js"; import { type DaemonLock, lockPath } from "../lock.js"; +import { MachinePinStore } from "../machine-pin-store.js"; import { maintenancePath, writeMaintenance } from "../maintenance.js"; import { sweepIsScheduled } from "../retention-settings.js"; import { SubshellMetaStore } from "../subshell-meta.js"; @@ -2234,6 +2236,75 @@ describe("restart command (spec 2026-09-12 § 6.3)", () => { expect(h.plane.unparsed).toEqual([]); }); + /** + * A public EC P-256 JWK whose coordinates are 32 bytes of zero and of four: + * canonical unpadded base64url, so `fingerprintJwk` digests it, and no key + * VALUE the relay could seal to. Pin fixtures for the §4.6 block. + */ + const peerJwk = `{"kty":"EC","crv":"P-256","x":"${"A".repeat(43)}","y":"${"E".repeat(43)}"}`; + const peerJwk2 = `{"kty":"EC","crv":"P-256","x":"${"E".repeat(43)}","y":"${"A".repeat(43)}"}`; + + test("ready carries the ssh trust block computed from this machine's key files (spec §4.6)", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-daemon-sshfp-")); + daemonDirs.push(dir); + new MachinePinStore(dir).pin("f0000000-0000-4000-8000-000000000001", { + signing: peerJwk, + encryption: peerJwk2, + }); + const h = await startDaemon({ runtime: supervised, config: { dataDir: dir } }); + const ready = await waitForReady(h); + expect(ready.type).toBe("ready"); + if (ready.type !== "ready") return; + const block = ready.runtime?.sshFingerprint; + expect(block).toBeDefined(); + // Own halves come from the identity files the daemon just generated, the + // peer half from the pin file seeded above, all through `fingerprintJwk`. + const sig = (JSON.parse(readFileSync(join(dir, "node-signing-identity.json"), "utf8")) as { publicJwk: string }) + .publicJwk; + const enc = (JSON.parse(readFileSync(join(dir, "identity.json"), "utf8")) as { publicJwk: string }).publicJwk; + expect(block?.own).toEqual({ signing: await fingerprintJwk(sig), encryption: await fingerprintJwk(enc) }); + expect(block?.peers).toEqual([ + { + nodeId: "f0000000-0000-4000-8000-000000000001", + signing: await fingerprintJwk(peerJwk), + encryption: await fingerprintJwk(peerJwk2), + }, + ]); + // And the frame SURVIVES the real parser on the way in (the fake plane + // parses every outbound frame with `parseNodeEvent`): a block the plane's + // grammar would drop is a block that never reaches the trust card. + expect(h.plane.unparsed).toEqual([]); + }); + + test("no runtime report means no trust block and no key reads (the block rides the runtime report)", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-daemon-sshfp-null-")); + daemonDirs.push(dir); + const h = await startDaemon({ runtime: null, config: { dataDir: dir } }); + const ready = await waitForReady(h); + expect(ready.type).toBe("ready"); + if (ready.type !== "ready") return; + expect(ready.runtime).toBeUndefined(); + // The builder never ran: a null runtime must not mint the node's keypair + // or touch the pin file just to prove the block is absent. + expect(existsSync(join(dir, "identity.json"))).toBe(false); + expect(existsSync(join(dir, "ssh-machine-pins.json"))).toBe(false); + }); + + test("an unreadable pin file costs the block, never the connection", async () => { + const dir = mkdtempSync(join(tmpdir(), "subshell-daemon-sshfp-corrupt-")); + daemonDirs.push(dir); + writeFileSync(join(dir, "ssh-machine-pins.json"), "}{"); + const h = await startDaemon({ runtime: supervised, config: { dataDir: dir } }); + const ready = await waitForReady(h); + expect(ready.type).toBe("ready"); + if (ready.type !== "ready") return; + expect(ready.runtime).toBeDefined(); + expect( + ready.runtime && "sshFingerprint" in ready.runtime ? ready.runtime.sshFingerprint : undefined, + ).toBeUndefined(); + expect(h.plane.unparsed).toEqual([]); + }); + // The ORDER is the contract: the daemon is the only sender of `result`, so // an executor that exited itself would leave the plane waiting out a // timeout instead of seeing a success. diff --git a/apps/node/agent/src/__tests__/machine-pin-store.test.ts b/apps/node/agent/src/__tests__/machine-pin-store.test.ts index 434acecd6..b2c4ebe17 100644 --- a/apps/node/agent/src/__tests__/machine-pin-store.test.ts +++ b/apps/node/agent/src/__tests__/machine-pin-store.test.ts @@ -163,6 +163,30 @@ test("SUBSHELL_CHANNEL_PIN has NO effect on this store: it is always strict", () } }); +test("entries() lists every pinned peer, id ascending (the §4.6 report's source)", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + // Empty store: the honest "no peers yet", not a missing method. + expect(store.entries()).toEqual([]); + // Insertion order deliberately unsorted; the list comes back sorted by id + // so the reported block (and its plane mirror) does not reshuffle on a + // re-pair of one peer. + store.pin("node-2", keyB); + store.pin("node-1", keyA); + expect(store.entries()).toEqual([ + { nodeId: "node-1", pin: keyA }, + { nodeId: "node-2", pin: keyB }, + ]); + // An entry's pin is a copy of the stored pair, not a live handle into a + // later re-pin of the same peer. + const [first] = store.entries(); + first.pin.signing = "mutated"; + expect(store.get("node-1")).toEqual(keyA); + // Fail-closed like every other read: the corrupt file quarantines and throws. + writeFileSync(machinePinPath(dir), "["); + expect(() => store.entries()).toThrow(); +}); + test("the source never consults SUBSHELL_CHANNEL_PIN (code, not prose, is scanned)", () => { // The header comment NAMES the env var to say the store never reads it; the // load-bearing fact is that no CODE expression touches it. Comments stripped diff --git a/apps/node/agent/src/__tests__/ssh-fingerprint-report.test.ts b/apps/node/agent/src/__tests__/ssh-fingerprint-report.test.ts new file mode 100644 index 000000000..d198a8d1c --- /dev/null +++ b/apps/node/agent/src/__tests__/ssh-fingerprint-report.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { base64UrlNoPad } from "@internal/subshell-protocol"; +import { identityPath, signingIdentityPath } from "../identity.js"; +import { MachinePinStore, machinePinPath } from "../machine-pin-store.js"; +import { buildSshFingerprintReport } from "../ssh-fingerprint-report.js"; + +/** + * The §4.6 trust block the node reports from its OWN files (spec 2026-10-08). + * The expected digests are derived through node:crypto here, independently of + * `fingerprintJwk` (whose DER preimage is pinned byte-identical to node:crypto's + * own encoder in the protocol package): the composition pins WHICH key lands + * in WHICH slot, not the hash. + */ + +/** node:crypto's independent digest of a public JWK string, unpadded base64url. */ +function nodeFp(jwkJson: string): string { + const key = createPublicKey({ key: JSON.parse(jwkJson), format: "jwk" }); + const spki = key.export({ type: "spki", format: "der" }); + return base64UrlNoPad(new Uint8Array(createHash("sha256").update(spki).digest())); +} + +/** A fresh P-256 public key as its raw JWK string. */ +function freshJwk(): string { + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + return JSON.stringify(publicKey.export({ format: "jwk" })); +} + +function freshDir(): string { + return mkdtempSync(join(tmpdir(), "subshell-sshfp-")); +} + +/** The public half stored in one keypair file. */ +function publicJwkIn(file: string): string { + return (JSON.parse(readFileSync(file, "utf8")) as { publicJwk: string }).publicJwk; +} + +test("own comes from the identity files: signing from node-signing-identity.json, encryption from identity.json", async () => { + const dir = freshDir(); + const report = await buildSshFingerprintReport(dir); + // First run generates through the ordinary loadOrCreateIdentity path, and + // the report then reads back exactly what the files hold. + expect(report.own.encryption).toBe(`SHA256:${nodeFp(publicJwkIn(identityPath(dir)))}`); + expect(report.own.signing).toBe(`SHA256:${nodeFp(publicJwkIn(signingIdentityPath(dir)))}`); + expect(report.peers).toEqual([]); + // The builder is a READ path: it never mints the pin file just to say "empty". + expect(existsSync(machinePinPath(dir))).toBe(false); +}); + +test("peers come from the machine pin store, sorted by nodeId, both halves fingerprinted", async () => { + const dir = freshDir(); + const a = freshJwk(); + const b = freshJwk(); + // Insertion order is deliberately reversed against the sorted output. + new MachinePinStore(dir).pin("f0000000-0000-4000-8000-000000000002", { signing: b, encryption: a }); + new MachinePinStore(dir).pin("f0000000-0000-4000-8000-000000000001", { signing: a, encryption: b }); + const report = await buildSshFingerprintReport(dir); + expect(report.peers).toEqual([ + { + nodeId: "f0000000-0000-4000-8000-000000000001", + signing: `SHA256:${nodeFp(a)}`, + encryption: `SHA256:${nodeFp(b)}`, + }, + { + nodeId: "f0000000-0000-4000-8000-000000000002", + signing: `SHA256:${nodeFp(b)}`, + encryption: `SHA256:${nodeFp(a)}`, + }, + ]); +}); + +test("a corrupt pin file throws (fail closed, the store's doctrine), and the report never invents a peer set", async () => { + const dir = freshDir(); + writeFileSync(machinePinPath(dir), "}{ not json"); + await expect(buildSshFingerprintReport(dir)).rejects.toThrow(); +}); diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index a7f14c663..070f40789 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -45,6 +45,7 @@ import { noteSweepScheduled } from "./retention-settings.js"; import { collectRuntime } from "./runtime.js"; import { selfInvokePrefix } from "./self-invoke.js"; import { sweepOrphanSshDirs } from "./ssh-dir-retention.js"; +import { buildSshFingerprintReport } from "./ssh-fingerprint-report.js"; import { SubshellMetaStore } from "./subshell-meta.js"; import { completeUpdate, revertAfterRefusal } from "./update.js"; import { NODE_VERSION } from "./version.js"; @@ -454,6 +455,30 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // degrades to null — the `ready` simply carries no `runtime`. const runtime = deps.runtime === undefined ? await collectRuntime().catch(() => null) : deps.runtime; + // The `ready` frame's runtime, with the §4.6 trust block merged in when + // this machine can state it. Recomputed PER DIAL, unlike `runtime` itself: + // the process facts cannot change while the pid does not, but peers get + // paired (and re-paired) during a process's life, and a trust block frozen + // at boot would under-report the pin store the whole comparison is about. + let sshRuntime: NodeRuntimeReport | null = runtime; + + /** + * The runtime report plus this machine's live trust block, or the bare + * report when the block cannot be built. A failure is logged once per dial + * and never costs the connection: a machine whose pin file is corrupt still + * comes online and keeps launching; only its trust card stays empty, on the + * plane and on its own dashboard alike. + */ + async function withSshFingerprint(report: NodeRuntimeReport | null): Promise { + if (report === null) return null; // no runtime report to ride: no block, no key reads + try { + return { ...report, sshFingerprint: await buildSshFingerprintReport(config.dataDir) }; + } catch (err) { + log(`ready: ssh trust block unavailable: ${err instanceof Error ? err.message : String(err)}`); + return report; + } + } + // PER-PROCESS lifetimes (mixing these up is a security bug — see VerifyContext in node-signing): const jtiLru = new JtiLru(); // survives every reconnect: a replayed jti never gets a second evaluation const seqTracker = new SeqTracker(); // per-CONNECTION value; reset on every open below @@ -1070,7 +1095,7 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // The SSH mirror rides the same synchronous read + seed as the // maintenance one (spec 2026-10-07 §4.3): the value `ready` carries and // the memo that stops the first heartbeat repeating it are one read. - send(ws, readyEvent(config, runtime, seedMaintenanceMemo(ctx), seedSshEnabledMemo(ctx))); + send(ws, readyEvent(config, sshRuntime, seedMaintenanceMemo(ctx), seedSshEnabledMemo(ctx))); // Connect-time `subshells_report` (spec §3.3): re-projects the panes // that survived an agent restart so the control plane heals its rows. // Fire-and-forget with catch-log — a scan failure (junk meta, tmux @@ -1198,6 +1223,10 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // backoff sleep exits 0 here — the first Ctrl-C never waits out a sleep or a dial. if (shuttingDown) stop(0); await provisioning; // §5: R7's provisioning close must not redial past its own key write + // Fresh block per dial, BEFORE the socket: `ready` is built synchronously + // in the open turn (the arm() comment argues the posture), so this is + // the last moment anything may await. + sshRuntime = await withSshFingerprint(runtime); const close = await runConnection(); if (shuttingDown) stop(0); // graceful: the socket closed cleanly on our request if (close.code === NODE_CLOSE_SUPERSEDED) { diff --git a/apps/node/agent/src/dashboard/__tests__/dashboard.test.ts b/apps/node/agent/src/dashboard/__tests__/dashboard.test.ts index 76ad51257..658952d0e 100644 --- a/apps/node/agent/src/dashboard/__tests__/dashboard.test.ts +++ b/apps/node/agent/src/dashboard/__tests__/dashboard.test.ts @@ -1,9 +1,12 @@ import { expect, test } from "bun:test"; -import { mkdtempSync, readFileSync } from "node:fs"; +import { generateKeyPairSync } from "node:crypto"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fingerprintJwk } from "@internal/subshell-protocol"; import type { UpdateExecContext } from "../../commands/update.js"; import { type NodeConfig, saveConfig } from "../../config.js"; +import { MachinePinStore } from "../../machine-pin-store.js"; import { noteSweepScheduled } from "../../retention-settings.js"; import { newHome } from "../../test-preload.js"; import { NODE_VERSION } from "../../version.js"; @@ -11,6 +14,12 @@ import { refuseRequest } from "../guards.js"; import { buildRoutes } from "../routes.js"; import { getDaemonState, registerRestart, setDaemonState } from "../state.js"; +/** A fresh P-256 public key as its raw JWK string. */ +function freshJwk(): string { + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + return JSON.stringify(publicKey.export({ format: "jwk" })); +} + /** * The dashboard's routes against a throwaway enrolled config — the contract * the extracted `@internal/node-admin` cards fetch, pinned HERE because the @@ -94,6 +103,39 @@ test("GET /api/nodes/:id answers the local view — online, owner-managed, serve expect(v.runningSubshells).toBe(0); }); +test("the view carries the §4.6 trust block from the node-local store (spec 2026-10-08 §4.6)", async () => { + const cfg = await enrolled(); + const a = freshJwk(); + const b = freshJwk(); + new MachinePinStore(cfg.dataDir).pin("f0000000-0000-4000-8000-000000000001", { signing: a, encryption: b }); + const v = await (await call(cfg, "GET", "/api/nodes/self")).json(); + expect(v.sshTrust.stale).toBe(false); // the machine answering about ITSELF is never stale + expect(v.sshTrust.peers).toEqual([ + { + nodeId: "f0000000-0000-4000-8000-000000000001", + signing: await fingerprintJwk(a), + encryption: await fingerprintJwk(b), + }, + ]); + // Own halves from this machine's identity files, not from anything a plane said. + const enc = (JSON.parse(readFileSync(join(cfg.dataDir, "identity.json"), "utf8")) as { publicJwk: string }).publicJwk; + const sig = ( + JSON.parse(readFileSync(join(cfg.dataDir, "node-signing-identity.json"), "utf8")) as { publicJwk: string } + ).publicJwk; + expect(v.sshTrust.own.encryption).toBe(await fingerprintJwk(enc)); + expect(v.sshTrust.own.signing).toBe(await fingerprintJwk(sig)); +}); + +test("a corrupt pin file costs the trust field, never the page", async () => { + const cfg = await enrolled(); + writeFileSync(join(cfg.dataDir, "ssh-machine-pins.json"), "}{"); + const r = await call(cfg, "GET", "/api/nodes/self"); + expect(r.status).toBe(200); + const v = await r.json(); + expect("sshTrust" in v).toBe(false); // absent, not a broken half-block + expect(v.id).toBe("node-abc"); // the rest of the view renders +}); + test("the maintenance flip round-trips through the real mirror, and `off` carries stopped: []", async () => { const cfg = await enrolled(); // `on` with no panes running is the clean case the CLI answers identically. diff --git a/apps/node/agent/src/dashboard/view.ts b/apps/node/agent/src/dashboard/view.ts index 89b52fa69..401ef4f1c 100644 --- a/apps/node/agent/src/dashboard/view.ts +++ b/apps/node/agent/src/dashboard/view.ts @@ -6,6 +6,7 @@ import type { NodeConfig } from "../config.js"; import { mapOs } from "../enroll.js"; import { readMaintenance } from "../maintenance.js"; import { collectRuntime } from "../runtime.js"; +import { buildSshFingerprintReport } from "../ssh-fingerprint-report.js"; import { SubshellMetaStore } from "../subshell-meta.js"; import { NODE_VERSION } from "../version.js"; import { getDaemonState } from "./state.js"; @@ -109,6 +110,15 @@ export async function liveSubshellCount(dataDir: string): Promise { export async function buildLocalNodeView(cfg: NodeConfig) { const runtime = await runtimeForView(); const m = maintenanceFields(cfg.dataDir); + // The §4.6 trust block from the node-local store (identity files plus + // `ssh-machine-pins.json`), the plane-independent side of the out-of-band + // compare. `stale: false` by definition: this page is the machine answering + // about itself, never a mirror of a departed connection. An unreadable pin + // set costs the FIELD (the card renders nothing), never the page, and the + // store's quarantine is the operator's repair. + const sshTrust = await buildSshFingerprintReport(cfg.dataDir) + .then((block) => ({ ...block, stale: false })) + .catch(() => null); // The `ready` frame's capability list, restated for the view so the card // shows what the plane is shown. const capabilities = ["uploads", "mcp"]; @@ -141,6 +151,7 @@ export async function buildLocalNodeView(cfg: NodeConfig) { held: null, serverUrl: cfg.serverUrl, runtime, + ...(sshTrust ? { sshTrust } : {}), runningSubshells: await liveSubshellCount(cfg.dataDir), }; } diff --git a/apps/node/agent/src/machine-pin-store.ts b/apps/node/agent/src/machine-pin-store.ts index b3893564b..e5bda717a 100644 --- a/apps/node/agent/src/machine-pin-store.ts +++ b/apps/node/agent/src/machine-pin-store.ts @@ -93,6 +93,20 @@ export class MachinePinStore { this.saveAll(all); } + /** + * Every pinned peer as `{ nodeId, pin }` pairs, id ascending: the source of + * the §4.6 trust block the `ready` report and the loopback dashboard render + * (fingerprints are computed from these raw strings by the caller, never + * stored). The empty array is the honest "no peers yet"; a corrupt file + * throws like every other read, it never lists as empty. + */ + entries(): { nodeId: string; pin: MachinePin }[] { + const all = this.loadAll(); + return Object.keys(all) + .sort() + .map((nodeId) => ({ nodeId, pin: { signing: all[nodeId].signing, encryption: all[nodeId].encryption } })); + } + /** * Compare a candidate against the stored pin, BYTE-FOR-BYTE on both raw * public strings (fingerprints are display, not trust). Any difference in diff --git a/apps/node/agent/src/ssh-fingerprint-report.ts b/apps/node/agent/src/ssh-fingerprint-report.ts new file mode 100644 index 000000000..ecff708d9 --- /dev/null +++ b/apps/node/agent/src/ssh-fingerprint-report.ts @@ -0,0 +1,52 @@ +/** + * The §4.6 SSH trust block, computed from THIS machine's own files (spec + * 2026-10-08 §4.5/§4.6): both fingerprints of the node's own keypair (from + * `identity.ts`) and both fingerprints of every relay peer pinned in + * `machine-pin-store.ts`. + * + * This is the out-of-band check's node half: an operator compares what one + * machine prints here against what the PEER computes from its own private + * keys, on the two machines' own surfaces, and the only agreement that counts + * is byte equality of the `SHA256:` strings. Fingerprints are the DISPLAY + * form; the store next door still enforces the raw JWK strings. + * + * The fingerprints come from `fingerprintJwk` (canonical SHA-256 over the DER + * SubjectPublicKeyInfo, JWK member order cannot move it) - never a re-derive: + * a second hash rule would let two honest machines disagree about the same + * key, which is the exact failure this surface exists to catch. + * + * THROWING BY CONTRACT: an unreadable pin file or an unusable identity is + * fail-closed noise, not an empty block, and every caller decides what the + * absence costs (the `ready` carriage drops the block and says nothing; the + * dashboard card simply has nothing to render). + */ +import { + fingerprintJwk, + type NodeSshFingerprintReport, + type NodeSshPeerFingerprint, +} from "@internal/subshell-protocol"; +import { loadOrCreateIdentity } from "./identity.js"; +import { MachinePinStore } from "./machine-pin-store.js"; + +/** + * Build the trust block from the node's data dir: own halves from the two + * identity files (generating a pre-M2 node's signing pair through the + * ordinary first-run path), peers from `ssh-machine-pins.json`, id ascending. + * @param dataDir - the agent's configured data dir (`loadConfig()` value) + */ +export async function buildSshFingerprintReport(dataDir: string): Promise { + const identity = await loadOrCreateIdentity(dataDir); + const own = { + signing: await fingerprintJwk(identity.signingPublicJwk), + encryption: await fingerprintJwk(identity.publicJwk), + }; + const peers: NodeSshPeerFingerprint[] = []; + for (const { nodeId, pin } of new MachinePinStore(dataDir).entries()) { + peers.push({ + nodeId, + signing: await fingerprintJwk(pin.signing), + encryption: await fingerprintJwk(pin.encryption), + }); + } + return { own, peers }; +} From 6a56de1368b59ec53b61f2508ccde147c8cde569 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:19:59 -0700 Subject: [PATCH 63/92] feat(ssh-relay): mirror the trust block at ready, serialize it under the runtime gate, clear the signing slot on rotation --- .../nodes/__tests__/nodes-crud-route.test.ts | 66 +++++++++++++++ .../__tests__/service-node.route.test.ts | 82 +++++++++++++++++++ .../api/src/api/nodes/get-node.route.ts | 10 ++- apps/server/api/src/api/nodes/node-view.ts | 74 +++++++++++++++++ .../src/api/nodes/rotate-node-key.route.ts | 20 ++++- .../src/db/repositories/nodes.repository.ts | 20 +++++ .../nodes/__tests__/node-ws-handler.test.ts | 64 +++++++++++++++ .../api/src/services/nodes/node-ws-handler.ts | 13 ++- 8 files changed, 346 insertions(+), 3 deletions(-) diff --git a/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts b/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts index 6cc555bd3..16729f00a 100644 --- a/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts @@ -1,10 +1,12 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { hashPassword } from "better-auth/crypto"; import { Elysia } from "elysia"; +import { exportJWK, generateKeyPair } from "jose"; import { nodesRoutes } from "@/api/nodes/index.js"; import { authDatabase } from "@/auth/database.js"; import { getAuth } from "@/auth.js"; import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; @@ -21,6 +23,7 @@ import { } from "@/services/nodes/node-registry.js"; import { NodeRpcError } from "@/services/nodes/node-rpc.js"; import { ensureLocalNode, localPlatform } from "@/services/nodes/seed-local.js"; +import { deliverSigningKey } from "@/services/nodes/ssh-identity.js"; import { issueSubshellToken } from "@/services/subshell-tokens.js"; import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; @@ -98,6 +101,7 @@ describe("/api/nodes registry CRUD", () => { const app = new Elysia().use(errorHandlerPlugin).use(nodesRoutes); const nodes = new NodesRepository(db); const nodeShares = new NodeSharesRepository(db); + const identities = new IdentitiesRepository(db); let aliceCookie = ""; let bobCookie = ""; @@ -670,6 +674,68 @@ describe("/api/nodes registry CRUD", () => { resetNodeRegistryForTests(); }); + it("rotate: clears the signing slot too, and the SAME-bytes redial re-files it (no block, no peer re-pair)", async () => { + // Spec §13's rotation units, plane side. Rotation replaces the BEARER and + // everything entrusted under its link, and it clears BOTH machine-key + // slots: the X25519 link pin (previous case) and the ES256 signing slot + // here. What makes this safe rather than destructive is that the machine + // keys themselves never moved: the redial's §4.3 bootstrap answers with + // the SAME bytes (a quiet re-file, not a refusal), and the node's own + // peer pins are untouched by anything the plane does, so the relay keeps + // byte-equality and no peer has to re-pair. The fingerprint MIRROR also + // survives: the machine's reported trust is about keys that did not move. + const n = await mkNode(aliceId, `rot-sign-${crypto.randomUUID().slice(0, 8)}`); + const { publicKey } = await generateKeyPair("ES256", { crv: "P-256", extractable: true }); + const signingJwk = JSON.stringify(await exportJWK(publicKey)); + await identities.register({ + principalId: `node:${n.id}`, + publicKey: `{"kty":"EC","crv":"P-256","x":"${"B".repeat(43)}","y":"${"A".repeat(43)}"}`, + signingPublicKey: signingJwk, + displayName: null, + }); + const block = { + own: { signing: `SHA256:${"A".repeat(43)}`, encryption: `SHA256:${"B".repeat(43)}` }, + peers: [ + { + nodeId: "0f8e2c1a-0000-4000-8000-000000000001", + signing: `SHA256:${"C".repeat(43)}`, + encryption: `SHA256:${"D".repeat(43)}`, + }, + ], + }; + await nodes.setEncryptPublicKey(n.id, "AAECzaWRtZXgtcHVibGljLWtleQ"); + await nodes.setSshFingerprint(n.id, JSON.stringify(block)); + + const sock = fakeSocket(); + attachConnection(n.id, sock); + try { + expect((await req("POST", `/${n.id}/rotate-key`, { cookie: aliceCookie })).status).toBe(200); + + // Cleared: the link pin and the signing slot. Preserved: the mirror + // (both halves and the peer entries, byte for byte). + const row = await nodes.findById(n.id); + expect(row?.encryptPublicKey).toBeNull(); + expect((await identities.findByPrincipal(`node:${n.id}`))?.signingPublicKey).toBeNull(); + expect(row?.sshFingerprint).toBe(JSON.stringify(block)); + + // The redial's §4.3 delivery, SAME bytes into the now-empty slot: stored + // quietly (a DIFFERENT key here would be refused against a filled slot, + // and the cleared slot plus the unchanged disk identity make that + // branch unreachable for an honest agent). + expect(await deliverSigningKey(n.id, signingJwk)).toBe(signingJwk); + expect((await identities.findByPrincipal(`node:${n.id}`))?.signingPublicKey).toBe(signingJwk); + + // And the redial's `ready` re-reports the SAME block: the mirror moves + // to a byte-identical string. The peer entry the plane still mirrors + // proves no peer was re-paired: same ids, same digests, untouched. + await nodes.setSshFingerprint(n.id, JSON.stringify(block)); + expect((await nodes.findById(n.id))?.sshFingerprint).toBe(JSON.stringify(block)); + } finally { + await db.deleteFrom("identities").where("principalId", "=", `node:${n.id}`).execute(); + resetNodeRegistryForTests(); + } + }); + it("rotate: view-grantee 403, admin on an agent node 403", async () => { const n = await mkNode(aliceId, `rot-gate-${crypto.randomUUID().slice(0, 8)}`); await nodeShares.replaceForNode(n.id, [{ granteeUserId: bobId, permission: "edit" }], aliceId); diff --git a/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts b/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts index 674ab47b4..84674d7dd 100644 --- a/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts @@ -456,4 +456,86 @@ describe("/api/nodes service + runtime", () => { resetNodeRegistryForTests(); } }); + + // ── GET /api/nodes/:id sshTrust (spec 2026-10-08 §4.6) ────────────────── + + /** A canonical trust block: `SHA256:` + 43 unpadded base64url chars each. */ + const TRUST_BLOCK = { + own: { signing: `SHA256:${"A".repeat(43)}`, encryption: `SHA256:${"B".repeat(43)}` }, + peers: [ + { + nodeId: "0f8e2c1a-0000-4000-8000-000000000001", + signing: `SHA256:${"C".repeat(43)}`, + encryption: `SHA256:${"D".repeat(43)}`, + }, + ], + }; + + it("sshTrust serializes under the runtime gate: live for owner/edit, never for view", async () => { + const id = await mkNode(); + await nodeShares.replaceForNode(id, [{ granteeUserId: carolId, permission: "view" }], aliceId); + // The `view` grantee is refused even with a MIRROR on the row: the gate is + // about WHO, not whether the node is live (spec §4.6/§6). + await nodes.setSshFingerprint(id, JSON.stringify(TRUST_BLOCK)); + const offline = (await (await req("GET", `/api/nodes/${id}`, { cookie: carolCookie })).json()) as Record< + string, + unknown + >; + expect("sshTrust" in offline).toBe(false); + goOnline(id, { ...runtime, sshFingerprint: TRUST_BLOCK }); + try { + const asOwner = (await (await req("GET", `/api/nodes/${id}`, { cookie: aliceCookie })).json()) as { + sshTrust?: { own: unknown; peers: unknown; stale: boolean }; + }; + expect(asOwner.sshTrust).toEqual({ ...TRUST_BLOCK, stale: false }); + const asViewer = (await (await req("GET", `/api/nodes/${id}`, { cookie: carolCookie })).json()) as Record< + string, + unknown + >; + expect("sshTrust" in asViewer).toBe(false); + } finally { + resetNodeRegistryForTests(); + } + }); + + it("an offline agent shows the mirrored block STALE-marked, never as live", async () => { + const id = await mkNode(); + await nodes.setSshFingerprint(id, JSON.stringify(TRUST_BLOCK)); + const view = (await (await req("GET", `/api/nodes/${id}`, { cookie: aliceCookie })).json()) as { + sshTrust?: { own: unknown; peers: unknown; stale: boolean }; + }; + // The mirror IS the card's content offline, and it says so: `stale: true` + // is what separates "this machine last said" from "this socket says". + expect(view.sshTrust).toEqual({ ...TRUST_BLOCK, stale: true }); + }); + + it("an online agent that reports no block falls back to the mirror, stale", async () => { + const id = await mkNode(); + await nodes.setSshFingerprint(id, JSON.stringify(TRUST_BLOCK)); + goOnline(id, runtime); // live connection, runtime WITHOUT a trust block + try { + const view = (await (await req("GET", `/api/nodes/${id}`, { cookie: aliceCookie })).json()) as { + sshTrust?: { stale: boolean }; + }; + expect(view.sshTrust?.stale).toBe(true); + } finally { + resetNodeRegistryForTests(); + } + }); + + it("sshTrust is never serialized for `local`, not even with a mirror set on the row", async () => { + // The control-plane host runs no relay agent and reports no block; a + // hand-set column must still not reach a view. `alice` holds the seeded + // Everyone/`edit` grant on `local`, so this is the config-capable case. + await nodes.setSshFingerprint("local", JSON.stringify(TRUST_BLOCK)); + try { + const view = (await (await req("GET", "/api/nodes/local", { cookie: aliceCookie })).json()) as Record< + string, + unknown + >; + expect("sshTrust" in view).toBe(false); + } finally { + await nodes.setSshFingerprint("local", null); + } + }); }); diff --git a/apps/server/api/src/api/nodes/get-node.route.ts b/apps/server/api/src/api/nodes/get-node.route.ts index 24216b601..f2cf1f599 100644 --- a/apps/server/api/src/api/nodes/get-node.route.ts +++ b/apps/server/api/src/api/nodes/get-node.route.ts @@ -2,7 +2,7 @@ import { BackendErrorCodes } from "@internal/backend-errors"; import { Elysia } from "elysia"; import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; import { loadNodeGate } from "@/api/nodes/node-gate.js"; -import { GetNodeResponseSchema, toNodeShareViews, toNodeView } from "@/api/nodes/node-view.js"; +import { GetNodeResponseSchema, sshTrustView, toNodeShareViews, toNodeView } from "@/api/nodes/node-view.js"; import { db } from "@/db/index.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { apiErrorBody } from "@/lib/api-error.js"; @@ -48,6 +48,13 @@ export const getNodeRoute = new Elysia() // stale by definition. `local` reports nothing: the control-plane host's // own deployment is the Service page's subject, not a node's. const runtime = gate.row.kind === "agent" ? getLive(gate.row.id)?.agent?.runtime : undefined; + // The §4.6 trust block, on the SAME config-capable gate as the two + // fields around it and for the same reason: the fingerprints name which + // machines this one has paired with, which is machine-relationship + // disclosure, not a launch need (spec 2026-10-08 §4.6/§6). Live report + // when the socket has one; the durable mirror, STALE-marked, otherwise; + // never `local`, never a `view` grantee. + const sshTrust = sshTrustView(gate.row, runtime?.sshFingerprint); // What entering maintenance would cost, on a NARROWER gate than the two // fields above (spec 2026-09-14 §5.5): `canManage`, not // `nodeCanConfigure`. Only a manager can flip that switch, so only a @@ -61,6 +68,7 @@ export const getNodeRoute = new Elysia() ...view, shares: await toNodeShareViews(gate.shares), ...(runtime ? { runtime } : {}), + ...(sshTrust ? { sshTrust } : {}), ...(runningSubshells === undefined ? {} : { runningSubshells }), }; }, diff --git a/apps/server/api/src/api/nodes/node-view.ts b/apps/server/api/src/api/nodes/node-view.ts index b199f2ad0..f69cb8155 100644 --- a/apps/server/api/src/api/nodes/node-view.ts +++ b/apps/server/api/src/api/nodes/node-view.ts @@ -1,3 +1,4 @@ +import { type NodeSshFingerprintReport, parseNodeSshFingerprintReport } from "@internal/subshell-protocol"; import { type Static, t } from "elysia"; import { db } from "@/db/index.js"; import { NodeAllowedDirsRepository } from "@/db/repositories/node-allowed-dirs.repository.js"; @@ -266,6 +267,40 @@ export const NodeRuntimeSchema = t.Object({ binaryPath: t.String({ description: "The node binary this process re-enters" }), }); +/** One machine's public relay halves as `SHA256:` fingerprints (spec 2026-10-08 §4.6). */ +const SshFingerprintPairSchema = t.Object({ + signing: t.String({ description: "Fingerprint of the ES256 machine-signing public key" }), + encryption: t.String({ description: "Fingerprint of the ECDH-ES encryption public key" }), +}); + +/** One pinned relay peer of that machine, named by node id. */ +const SshFingerprintPeerSchema = t.Object({ + nodeId: t.String({ description: "The pinned peer's node id" }), + signing: t.String({ description: "Fingerprint of that peer's signing key, as this machine pins it" }), + encryption: t.String({ description: "Fingerprint of that peer's encryption key, as this machine pins it" }), +}); + +/** + * The §4.6 trust card's payload: the protocol's fingerprint block plus ONE + * derived flag the node itself cannot honestly state about a future read. + * + * The fingerprints are public identifiers (display of the trust the node + * enforces as byte equality), never key material: they serialize under the + * SAME gate as the live `runtime` block they mostly come from (config-capable + * viewer, agent node), because a `view` grantee's business is launching a + * pane here, not auditing which machines this one has paired with. + */ +export const SshTrustSchema = t.Object({ + own: SshFingerprintPairSchema, + peers: t.Array(SshFingerprintPeerSchema, { + description: "Every relay peer this machine pins, id ascending; empty means none paired yet", + }), + stale: t.Boolean({ + description: + "true = the plane's durable MIRROR of this machine's last report (offline node, or one that reported no block), so the card is last-known truth, not current; false = what the live connection reported this connect", + }), +}); + /** `GET /api/nodes/:id` — the view plus the grant set, ONLY when the viewer can configure. */ export const GetNodeResponseSchema = t.Object({ ...NodeViewSchema.properties, @@ -276,6 +311,12 @@ export const GetNodeResponseSchema = t.Object({ "How the node runs, present only while the node is online, only for config-capable viewers, and only on machine nodes", }), ), + sshTrust: t.Optional( + t.Object(SshTrustSchema.properties, { + description: + "This machine's SSH trust block (own + pinned-peer fingerprints, spec 2026-10-08 §4.6): the live report's when the node is connected, else the durable mirror with `stale: true`. Same gate as `runtime`: config-capable viewers on agent nodes only, never `view`, never `local`", + }), + ), runningSubshells: t.Optional( t.Number({ description: @@ -324,6 +365,39 @@ function parseCapabilities(json: string | null): string[] { } } +/** The stored `nodes.ssh_fingerprint` JSON → block, or null. Never throws. */ +function readMirrorBlock(json: string | null): NodeSshFingerprintReport | null { + if (!json) return null; + try { + return parseNodeSshFingerprintReport(JSON.parse(json)); + } catch { + // A hand-edited or half-written column costs the trust card, never the + // node page: the view renders without the block rather than 500-ing on + // someone's stray bytes. (The same grammar the live report went through + // at ingest; re-running it here refuses whatever the column is not.) + return null; + } +} + +/** + * The detail view's `sshTrust` (spec 2026-10-08 §4.6), under the caller's + * already-checked config gate. The live connection's block wins when it has + * one (`stale: false`); otherwise the durable mirror renders marked + * (`stale: true`), because the offline card an operator compares during an + * incident is worth its last-known truth and the word that says so. `local` + * answers null at the call site's kind gate, and an absent mirror/absent + * live block means the field is ABSENT on the view, not null. + */ +export function sshTrustView( + row: NodeTable, + liveBlock: NodeSshFingerprintReport | undefined, +): Static | null { + if (row.kind !== "agent") return null; + if (liveBlock) return { ...liveBlock, stale: false }; + const mirror = readMirrorBlock(row.sshFingerprint); + return mirror ? { ...mirror, stale: true } : null; +} + /** * Everything but the harness merge — the one place row→view fields are mapped. * `local`'s os/arch fall back to this process (see the inline note) — the only diff --git a/apps/server/api/src/api/nodes/rotate-node-key.route.ts b/apps/server/api/src/api/nodes/rotate-node-key.route.ts index e2aae89f3..c6289a378 100644 --- a/apps/server/api/src/api/nodes/rotate-node-key.route.ts +++ b/apps/server/api/src/api/nodes/rotate-node-key.route.ts @@ -58,7 +58,8 @@ export const rotateNodeKeyRoute = new Elysia() }, })) as unknown as CreatedApiKey; - // 2. Flip the binding, clear the link pin, THEN 3. disable the old key. + // 2. Flip the binding, clear the link pin (and 2b, the signing slot), + // THEN 3. disable the old key. // `encrypt_public_key` goes to NULL with the bearer it was trusted // under (spec 2026-09-24 §5): the row falls back to legacy mode and // the agent's redial re-provisions a fresh encryption identity @@ -79,6 +80,23 @@ export const rotateNodeKeyRoute = new Elysia() .where("targetNodeId", "=", gate.row.id) .where("usedAt", "is", null) .execute(); + // 2b. The ES256 signing slot goes with the link pin (spec 2026-10-08 + // §13): it was filed under the OLD bearer's trust, and rotation + // is the operator saying that trust ends now. This clears the + // PLANE's record, never the machine's key: the redial's §4.3 + // bootstrap answers `ssh_register_identity` from the disk pair, + // the empty slot takes the SAME bytes back quietly, the node's + // own peer pins are untouched by anything the plane does, and + // `nodes.ssh_fingerprint` (the trust card's mirror) deliberately + // SURVIVES, because the fingerprints it holds are digests of + // keys that did not move. A machine that reports a DIFFERENT + // key into the refilled slot has rotated its identity without + // saying so, which §4.3's guard will keep refusing. + await tx + .updateTable("identities") + .set({ signingPublicKey: null }) + .where("principalId", "=", `node:${gate.row.id}`) + .execute(); if (gate.row.apiKeyId) { await sql`UPDATE apikey SET enabled = 0, "updatedAt" = ${new Date().toISOString()} WHERE id = ${gate.row.apiKeyId}`.execute( tx, diff --git a/apps/server/api/src/db/repositories/nodes.repository.ts b/apps/server/api/src/db/repositories/nodes.repository.ts index 14045c278..3fd95ca53 100644 --- a/apps/server/api/src/db/repositories/nodes.repository.ts +++ b/apps/server/api/src/db/repositories/nodes.repository.ts @@ -180,6 +180,26 @@ export class NodesRepository extends BaseRepository { .executeTakeFirst(); } + /** + * Mirror (or clear) this node's §4.6 SSH trust block (spec 2026-10-08 + * §4.5/§4.6): the JSON of what the machine last reported about its own and + * its pinned peers' key fingerprints, so the trust card survives an offline + * node and a plane restart alike. + * + * The `ready` ingest calls this ONLY when the report carried a well-formed + * block: a report that says nothing never clears the mirror (absence is not + * evidence the keys are gone). `null` is for callers that DO know the block + * is dead (tests today; a future machine-side identity wipe would clear it + * by re-report, not by null). + */ + async setSshFingerprint(id: string, json: string | null): Promise { + await this.db + .updateTable("nodes") + .set({ sshFingerprint: json, updatedAt: new Date().toISOString() }) + .where("id", "=", id) + .execute(); + } + /** Cache a harness inventory snapshot (spec §6.2 TTL is the caller's). */ async applyInventory(id: string, inventoryJson: string): Promise { const now = new Date().toISOString(); diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index bcf725ee7..0a5ea8c69 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -102,6 +102,8 @@ interface Harness { disabledOwners: Set; ready: { id: string; report: NodeReadyReport }[]; inventories: { id: string; json: string }[]; + /** JSON trust blocks `ready` mirror writes landed (spec 2026-10-08 §4.6) */ + sshFingerprints: { id: string; json: string | null }[]; touched: string[]; statuses: { id: string; status: string }[]; /** what deps.resolveResult saw: the connection handed to it + the event */ @@ -125,6 +127,7 @@ function makeHarness(): Harness { disabledOwners: new Set(), ready: [], inventories: [], + sshFingerprints: [], touched: [], statuses: [], results: [], @@ -164,6 +167,9 @@ function makeHarness(): Harness { setStatus: async (id, status) => { h.statuses.push({ id, status }); }, + setSshFingerprint: async (id, json) => { + h.sshFingerprints.push({ id, json }); + }, } as unknown as NodeWsDeps["nodes"], resolveResult: (conn, event) => { h.results.push({ conn, event }); @@ -623,6 +629,64 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => expect(ws.closed).toHaveLength(0); }); + /** + * A complete runtime report the strict validator accepts, so the frames + * below exercise the MIRROR rules rather than the parser's floor. + */ + const runtimeWith = (extra: Record) => ({ + startedAt: "2026-10-08T00:00:00.000Z", + supervised: false, + service: { + manager: "systemd", + installed: false, + definitionPath: null, + state: "running", + pid: null, + enabled: null, + linger: null, + paneSafety: "unknown", + }, + configPath: "/c", + agentLogPath: "/c/agent.log", + logPath: null, + logHint: null, + tmuxPath: null, + binaryPath: "/b", + logging: { debug: false, source: "default" }, + ...extra, + }); + + /** The canonical §4.6 trust block shapes; one good, one the grammar refuses. */ + const TRUST_BLOCK = { + own: { signing: `SHA256:${"A".repeat(43)}`, encryption: `SHA256:${"B".repeat(43)}` }, + peers: [], + }; + + it("ready mirrors the ssh trust block onto the row; no block, no write, no clear", async () => { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ runtime: runtimeWith({ sshFingerprint: TRUST_BLOCK }) })), + ); + expect(h.sshFingerprints).toEqual([{ id: "n1", json: JSON.stringify(TRUST_BLOCK) }]); + // A later ready WITHOUT a block (the agent predates M2, or the read + // failed): the mirror must survive it. Absence is not evidence the + // machine's keys are gone; only a fresh true report moves the copy. + await handleNodeMessage(h.deps, fakeSocket("n1"), JSON.stringify(readyFrame())); + expect(h.sshFingerprints).toHaveLength(1); + // And a malformed block is dropped by the frame parser BEFORE the switch, + // so it reaches no write at all (the same frame still comes online: the + // ready itself is kept, `runtime` with it, minus the junk block). + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ runtime: runtimeWith({ sshFingerprint: { own: "junk", peers: [] } }) })), + ); + expect(h.sshFingerprints).toHaveLength(1); + expect(h.ready).toHaveLength(3); + }); + /** * Harness detection when a node comes ONLINE — the half of freshness a * person cannot supply (spec 2026-09-10 §4 as extended; the periodic other diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index 1fc2ee210..1e6100280 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -185,7 +185,7 @@ export interface NodeVerifiedKey { /** Repository slice the socket touches (full `NodesRepository` satisfies it). */ export type NodeWsNodesRepo = Pick< NodesRepository, - "findById" | "applyReady" | "applyInventory" | "touch" | "setStatus" + "findById" | "applyReady" | "applyInventory" | "touch" | "setStatus" | "setSshFingerprint" >; /** Everything the handler reaches outside its own module. */ @@ -879,6 +879,17 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: ...(event.runtime ? { runtime: event.runtime } : {}), }; } + // Mirror the §4.6 trust block the report carried onto the row (spec + // 2026-10-08 §4.5/§4.6) — the durable copy behind the offline, + // STALE-marked card, written on the same "record FIRST" posture as the + // facts stash above and on the same surviving-socket rule (the stillLive + // probe already returned for a disowned one). A ready WITHOUT a block + // — an agent that predates M2, or one whose block the grammar refused — + // writes NOTHING: absence is not evidence the machine's keys are gone, + // and only a fresh true report may move the plane's copy. + if (event.runtime?.sshFingerprint) { + await deps.nodes.setSshFingerprint(nodeId, JSON.stringify(event.runtime.sshFingerprint)); + } // The floor FIRST, because its refusal is the one a person can act on: // it names the version to install and the version found, where a bare // protocol number names neither. Identity is already persisted above, From b6008a8df9eab22d465938ac18fd5ab64ce98f75 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:26:02 -0700 Subject: [PATCH 64/92] feat(ssh-relay): machine trust card on the node page, owner/edit only, stale-marked mirror --- .../ssh/__tests__/trust-card.test.tsx | 110 ++++++++++++++++++ .../web/src/components/ssh/trust-card.tsx | 95 +++++++++++++++ apps/server/web/src/routes/nodes_.$id.tsx | 10 ++ packages/node-admin/src/index.ts | 1 + packages/node-admin/src/types/node.ts | 42 +++++++ 5 files changed, 258 insertions(+) create mode 100644 apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx create mode 100644 apps/server/web/src/components/ssh/trust-card.tsx diff --git a/apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx b/apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx new file mode 100644 index 000000000..ff23f85a7 --- /dev/null +++ b/apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { NodeDetail } from "@internal/node-admin"; +import { cleanup, render, screen } from "@testing-library/react"; +import { SshTrustCard } from "@/components/ssh/trust-card"; + +/** + * The node page's §4.6 machine trust card (spec 2026-10-08). The card is + * pure prop rendering: the GATE lives on the server (the field is absent for + * a `view` grantee and on `local`, never null), and the card's own check is + * belt-not-control - a payload that arrived against the rule must still not + * render. The fingerprints are public display of the trust the machines + * enforce as byte equality; the card exists for the out-of-band compare + * against what the PEER machine prints about itself. + */ + +const FP_OWN_SIGN = `SHA256:${"A".repeat(43)}`; +const FP_OWN_ENC = `SHA256:${"B".repeat(43)}`; +const FP_PEER_SIGN = `SHA256:${"C".repeat(43)}`; +const FP_PEER_ENC = `SHA256:${"D".repeat(43)}`; +const PEER_ID = "0f8e2c1a-0000-4000-8000-000000000001"; + +function node(over: Partial = {}): NodeDetail { + return { + id: "node1", + name: "box", + kind: "agent", + os: "linux", + arch: "x64", + hostname: "box", + status: "online", + lastSeenAt: null, + agentVersion: "1.0.0", + protocolVersion: 18, + access: "owner", + canManage: true, + canLaunch: true, + capabilities: [], + allowedDirs: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + sshEnabled: false, + held: null, + ...over, + }; +} + +const LIVE_TRUST = { + own: { signing: FP_OWN_SIGN, encryption: FP_OWN_ENC }, + peers: [{ nodeId: PEER_ID, signing: FP_PEER_SIGN, encryption: FP_PEER_ENC }], + stale: false, +}; + +afterEach(cleanup); + +describe("SshTrustCard", () => { + it("owner with a live block: own and peer fingerprints, no stale marking", () => { + const { container } = render(); + expect(container.firstChild).not.toBeNull(); + expect(screen.getByText("Machine trust")).toBeDefined(); + expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); + expect(screen.getByText(FP_OWN_ENC)).toBeDefined(); + expect(screen.getByText(`Peer ${PEER_ID}`)).toBeDefined(); + expect(screen.getByText(FP_PEER_SIGN)).toBeDefined(); + expect(screen.getByText(FP_PEER_ENC)).toBeDefined(); + expect(screen.queryByText(/stale/i)).toBeNull(); + }); + + it("an edit grantee sees the same card the owner sees", () => { + render(); + expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); + }); + + it("the stale mirror renders STALE-marked, distinguishable from live", () => { + render(); + // The flag carries both a badge and its own sentence: the §4.6 compare is + // exactly the thing a reader must not do against a last-known value + // thinking it current. + expect(screen.getByText(/offline/i)).toBeDefined(); + expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); + }); + + it("a view grantee never gets the card, absent field or not", () => { + // The server's gate leaves the field OFF the payload for this viewer; the + // card's own kind/access check is the belt, pinned here so a future + // refactor of the route's gate cannot silently widen what this renders. + const { container } = render(); + expect(container.firstChild).toBeNull(); + const belt = render(); + expect(belt.container.firstChild).toBeNull(); + }); + + it("never on `local`, not even with a block that must not have serialized", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("no block on the view means no card (older agent, nothing reported yet)", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("an empty peer set says so plainly instead of rendering a lone header", () => { + render(); + expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); + expect(screen.getByText(/no ssh relay peers pinned yet/i)).toBeDefined(); + }); +}); diff --git a/apps/server/web/src/components/ssh/trust-card.tsx b/apps/server/web/src/components/ssh/trust-card.tsx new file mode 100644 index 000000000..7cb83c558 --- /dev/null +++ b/apps/server/web/src/components/ssh/trust-card.tsx @@ -0,0 +1,95 @@ +import { Badge, Card, CardContent, CardHeader, CardTitle, CopyableValue, type NodeDetail } from "@internal/node-admin"; +import type { JSX } from "react"; + +/** + * The machine trust card (spec 2026-10-08 §4.6): this machine's own SSH relay + * key fingerprints and each pinned peer's, for the out-of-band compare. + * + * The check this serves is deliberately NOT a green tick: the only honest + * reading is a person comparing these strings against what the OTHER machine + * prints about the SAME keys on its own surface (the node dashboard side, fed + * from the machine's key files). Fingerprints are public display of the trust + * the machines enforce as byte equality; nothing here is key material, and + * nothing here proves anything until both machines have been read. + * + * Gated twice over, on purpose. The server omits `sshTrust` for a `view` + * grantee and on `local` entirely (it is machine-relationship disclosure, the + * same class as the runtime block), and this component refuses the same two + * cases so a payload that arrived against the rule still renders nothing. + * + * Staleness is not the gate: an OFFLINE agent renders the durable mirror, and + * says so plainly, because comparing last-known fingerprints during an + * incident is exactly when the card earns its keep and exactly when reading a + * stale value as current would mislead. + */ +export function SshTrustCard({ node }: { node: NodeDetail }): JSX.Element | null { + if (node.kind !== "agent" || (node.access !== "owner" && node.access !== "edit")) return null; + const trust = node.sshTrust; + if (!trust) return null; + return ( + + + + Machine trust + {trust.stale && Stale} + + + +

+ {trust.stale + ? "This machine is offline. These are the last fingerprints it reported." + : "Fingerprints of this machine's relay keys and every peer it pins. Compare them against what each machine prints about the same keys itself."} +

+
+ + + {trust.peers.length === 0 ? ( +
+
Pinned peers
+
No SSH relay peers pinned yet.
+
+ ) : ( + trust.peers.map((peer) => ( +
+
Peer {peer.nodeId}
+
+ + +
+
+ )) + )} +
+
+
+ ); +} + +/** One labelled fingerprint: `label` over a mono `detail`, the line-item shape. */ +function FingerprintFact({ label, value, copyLabel }: { label: string; value: string; copyLabel: string }) { + return ( +
+
{label}
+
+ +
+
+ ); +} + +/** The value plus its copy affordance, the thing a person takes to the other machine. */ +function FingerprintValue({ value, copyLabel }: { value: string; copyLabel: string }) { + return ( + + + + ); +} diff --git a/apps/server/web/src/routes/nodes_.$id.tsx b/apps/server/web/src/routes/nodes_.$id.tsx index 0914a8432..5693f8e4c 100644 --- a/apps/server/web/src/routes/nodes_.$id.tsx +++ b/apps/server/web/src/routes/nodes_.$id.tsx @@ -28,6 +28,7 @@ import { osLabel } from "@/components/nodes/node-row"; import { managesNodeSections } from "@/components/nodes/node-section-nav"; import { NodeSharingDialog } from "@/components/nodes/node-sharing-dialog"; import { NodeUpdateCard } from "@/components/nodes/node-update-card"; +import { SshTrustCard } from "@/components/ssh/trust-card"; import { useDeleteNode, useRenameNode } from "@/hooks/use-nodes"; import { SUBSHELLS_QUERY_KEY } from "@/lib/query-keys"; @@ -261,6 +262,15 @@ function NodeDetailPage() { + {/* The §4.6 machine trust block (spec 2026-10-08): own and pinned + peers' key fingerprints, for the out-of-band compare each side + can verify against its own machine. The server serializes the + field only for owner/edit on agent nodes, so a `view` grantee + never receives it and the card renders nothing; the card's own + kind/access check is the belt. Live report while the machine is + connected, durable mirror marked stale when it is not. */} + {managesNodeSections(n) && } + {/* The node's RULES live on its page, not in a tab of one card: the allowlist gates launches on this machine including the viewer's own, and its empty-means-unrestricted shape is diff --git a/packages/node-admin/src/index.ts b/packages/node-admin/src/index.ts index 360eb771a..eaf95b0da 100644 --- a/packages/node-admin/src/index.ts +++ b/packages/node-admin/src/index.ts @@ -88,6 +88,7 @@ export type { NodeKind, NodeRuntime, NodeShare, + NodeSshTrust, NodeStatus, RotatedNodeKey, SetupKeyRow, diff --git a/packages/node-admin/src/types/node.ts b/packages/node-admin/src/types/node.ts index 941035cdc..c09edd284 100644 --- a/packages/node-admin/src/types/node.ts +++ b/packages/node-admin/src/types/node.ts @@ -312,6 +312,37 @@ export interface NodeRuntime { tmuxPath: string | null; /** The node binary this process re-enters */ binaryPath: string; + /** + * The §4.6 trust block as THIS report stated it (spec 2026-10-08): own and + * pinned-peer key fingerprints. Absent when the agent reported none. The + * detail view's `sshTrust` is the same fact with the one flag only the + * plane can state (whether it is live or the durable mirror); the runtime + * card renders nothing from this field, the trust card reads `sshTrust`. + */ + sshFingerprint?: { + own: { signing: string; encryption: string }; + peers: { nodeId: string; signing: string; encryption: string }[]; + }; +} + +/** + * The §4.6 SSH trust card's payload (spec 2026-10-08), mirroring the plane's + * `SshTrustSchema`: this machine's own key fingerprints and every pinned + * peer's, as `SHA256:` display strings. Fingerprints are public identifiers + * of the trust the machine enforces as byte equality; the card's whole purpose + * is the out-of-band compare against what the PEER computes from its own keys. + */ +export interface NodeSshTrust { + /** This machine's both halves */ + own: { signing: string; encryption: string }; + /** Every pinned peer, id ascending; empty means nothing paired yet */ + peers: { nodeId: string; signing: string; encryption: string }[]; + /** + * true = the plane's durable MIRROR of the last report (the node is offline, + * or its live connection reported no block), so the card is last-known truth + * and says so; false = what this connection reported at connect. + */ + stale: boolean; } /** `GET /api/nodes/:id` — the view plus the grant set, ONLY for config-capable viewers (then the key is absent, not null). */ @@ -352,6 +383,17 @@ export interface NodeDetail extends Node { * supports. */ runningSubshells?: number; + /** + * The machine's SSH trust block (§4.6): its own and its pinned peers' + * fingerprints, live report when the node is connected, durable mirror + * (`stale: true`) when it is not. + * + * Present only for a config-capable viewer on an AGENT node, the same gate + * as `runtime` and for the same kind of reason: which machines this one has + * paired with is machine-relationship disclosure, not something a `view` + * grantee's launch needs. Absent, never null, for everyone else. + */ + sshTrust?: NodeSshTrust; } /** From 32b3666d1a76d57e8e2bb4f7d866ee6a7ae56da6 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:38:23 -0700 Subject: [PATCH 65/92] feat(ssh-relay): render the trust card on the node's own dashboard; cover the edit grantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T13 review MINORs: the card moves into @internal/node-admin as NodeSshTrustCard so the machine's own loopback dashboard (the §4.6 compare's LOCAL half) renders it from the agent view's sshTrust, and the get-node gate test gains the missing edit-grantee case. NITs: drop the tautological rotate-test tail, isPeerNodeId calls the real isNodeSubshellId guard, the rotate comment states what the cleared slot actually does (peers' pins block a silent re-key), and the dead NodeRuntime.sshFingerprint mirror field is removed. --- .../src/routes/__tests__/status-page.test.tsx | 121 ++++++++++++++++++ apps/node/web/src/routes/index.tsx | 6 +- .../nodes/__tests__/nodes-crud-route.test.ts | 6 - .../__tests__/service-node.route.test.ts | 8 ++ .../src/api/nodes/rotate-node-key.route.ts | 11 +- apps/server/web/src/routes/nodes_.$id.tsx | 4 +- .../src/__tests__/ssh-trust-card.test.tsx | 35 ++--- .../src/components/ssh-trust-card.tsx | 26 +++- packages/node-admin/src/index.ts | 11 +- packages/node-admin/src/types/node.ts | 18 +-- packages/subshell-protocol/src/node-frames.ts | 8 +- 11 files changed, 199 insertions(+), 55 deletions(-) create mode 100644 apps/node/web/src/routes/__tests__/status-page.test.tsx rename apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx => packages/node-admin/src/__tests__/ssh-trust-card.test.tsx (68%) rename apps/server/web/src/components/ssh/trust-card.tsx => packages/node-admin/src/components/ssh-trust-card.tsx (76%) diff --git a/apps/node/web/src/routes/__tests__/status-page.test.tsx b/apps/node/web/src/routes/__tests__/status-page.test.tsx new file mode 100644 index 000000000..88423aa7a --- /dev/null +++ b/apps/node/web/src/routes/__tests__/status-page.test.tsx @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { NodeDetail } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen } from "@testing-library/react"; +import { StatusPage } from "../index"; + +/** + * The status page is the §4.6 out-of-band compare's LOCAL half (spec + * 2026-10-08): the check happens on the two machines' OWN loopback + * dashboards, because a fingerprint read off the plane proves nothing about + * the machine behind it. So this page must render the trust card, fed from + * the agent view's own `sshTrust` (the node-local key files, `stale: false` + * by definition on the machine's own surface). + * + * The fetcher is stubbed: the page's `useNode("self")` speaks the node's + * mirror of `/api/nodes/:id`, and `NodeLogCard` polls `/logs` even though + * this suite is about the trust block, not the tail. + */ + +const FP_OWN_SIGN = `SHA256:${"A".repeat(43)}`; +const FP_OWN_ENC = `SHA256:${"B".repeat(43)}`; +const FP_PEER_SIGN = `SHA256:${"C".repeat(43)}`; +const FP_PEER_ENC = `SHA256:${"D".repeat(43)}`; +const PEER_ID = "0f8e2c1a-0000-4000-8000-000000000001"; + +/** This machine answering `GET /api/nodes/self` about itself, as `view.ts` shapes it. */ +function localView(): NodeDetail { + return { + id: "self", + name: "devbox", + kind: "agent", + os: "linux", + arch: "x64", + hostname: "devbox", + status: "online", + lastSeenAt: "2026-10-08T10:00:00.000Z", + agentVersion: "1.0.0", + protocolVersion: 18, + // The loopback view's standing answers: no viewer identity exists here, + // and the human at this machine outranks any web grant (view.ts). + access: "owner", + canManage: true, + canLaunch: true, + allowedDirs: [], + capabilities: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + sshEnabled: true, + held: null, + serverUrl: "http://127.0.0.1:3080", + // No `runtime`: the page then skips the Runtime/Service cards, and this + // suite stays about the trust block. The trust card needs no runtime. + sshTrust: { + own: { signing: FP_OWN_SIGN, encryption: FP_OWN_ENC }, + peers: [{ nodeId: PEER_ID, signing: FP_PEER_SIGN, encryption: FP_PEER_ENC }], + stale: false, + }, + runningSubshells: 0, + }; +} + +let realFetch: typeof globalThis.fetch; + +beforeEach(() => { + realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const body = url.includes("/logs") ? { text: "", nextByte: 0, size: 0, truncated: false } : localView(); + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +}); + +afterEach(() => { + cleanup(); + globalThis.fetch = realFetch; +}); + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render( + + + , + ); +} + +describe("status page — machine trust", () => { + it("renders own and peer fingerprints from the agent view, unmarked (the local truth is never stale)", async () => { + renderPage(); + expect(await screen.findByText("Machine trust")).toBeTruthy(); + expect(screen.getByText(FP_OWN_SIGN)).toBeTruthy(); + expect(screen.getByText(FP_OWN_ENC)).toBeTruthy(); + expect(screen.getByText(`Peer ${PEER_ID}`)).toBeTruthy(); + expect(screen.getByText(FP_PEER_SIGN)).toBeTruthy(); + expect(screen.getByText(FP_PEER_ENC)).toBeTruthy(); + // No stale marking on the machine's own surface: this page is the local + // truth, never a mirror of a departed connection. + expect(screen.queryByText(/stale/i)).toBeNull(); + }); + + it("renders no trust card when the node's own store reported no block", async () => { + // The unreadable-pin-set case: `view.ts` costs the FIELD, never the page. + const base = localView(); + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const body = url.includes("/logs") + ? { text: "", nextByte: 0, size: 0, truncated: false } + : { ...base, sshTrust: undefined }; + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + renderPage(); + await screen.findByText("This machine"); + expect(screen.queryByText("Machine trust")).toBeNull(); + }); +}); diff --git a/apps/node/web/src/routes/index.tsx b/apps/node/web/src/routes/index.tsx index 262da8462..e50b1ec42 100644 --- a/apps/node/web/src/routes/index.tsx +++ b/apps/node/web/src/routes/index.tsx @@ -6,6 +6,7 @@ import { NodeMaintenanceCard, NodeRuntimeCard, NodeServiceCard, + NodeSshTrustCard, relativeElapsed, useNode, } from "@internal/node-admin"; @@ -25,7 +26,8 @@ export const Route = createFileRoute("/")({ component: StatusPage }); * not make you click between tabs. The cards are the shared ones, unmodified — * which is the point of the extraction. */ -function StatusPage() { +/** Exported for the dashboard test; the route wires it as its component. */ +export function StatusPage() { const node = useNode("self"); if (node.isError) { @@ -77,6 +79,8 @@ function StatusPage() {

This node did not report how it runs.

)} + + ); diff --git a/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts b/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts index 16729f00a..9bb87c325 100644 --- a/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts @@ -724,12 +724,6 @@ describe("/api/nodes registry CRUD", () => { // branch unreachable for an honest agent). expect(await deliverSigningKey(n.id, signingJwk)).toBe(signingJwk); expect((await identities.findByPrincipal(`node:${n.id}`))?.signingPublicKey).toBe(signingJwk); - - // And the redial's `ready` re-reports the SAME block: the mirror moves - // to a byte-identical string. The peer entry the plane still mirrors - // proves no peer was re-paired: same ids, same digests, untouched. - await nodes.setSshFingerprint(n.id, JSON.stringify(block)); - expect((await nodes.findById(n.id))?.sshFingerprint).toBe(JSON.stringify(block)); } finally { await db.deleteFrom("identities").where("principalId", "=", `node:${n.id}`).execute(); resetNodeRegistryForTests(); diff --git a/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts b/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts index 84674d7dd..655ecee9e 100644 --- a/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/service-node.route.test.ts @@ -493,6 +493,14 @@ describe("/api/nodes service + runtime", () => { unknown >; expect("sshTrust" in asViewer).toBe(false); + // The named gate has THREE actors: owner (above), `view` (above), and + // the `edit` grantee, who configures this machine and so sees the same + // disclosure the owner does, live (the same gate as `runtime`). + await nodeShares.replaceForNode(id, [{ granteeUserId: carolId, permission: "edit" }], aliceId); + const asEditor = (await (await req("GET", `/api/nodes/${id}`, { cookie: carolCookie })).json()) as { + sshTrust?: { own: unknown; peers: unknown; stale: boolean }; + }; + expect(asEditor.sshTrust).toEqual({ ...TRUST_BLOCK, stale: false }); } finally { resetNodeRegistryForTests(); } diff --git a/apps/server/api/src/api/nodes/rotate-node-key.route.ts b/apps/server/api/src/api/nodes/rotate-node-key.route.ts index c6289a378..447d8114c 100644 --- a/apps/server/api/src/api/nodes/rotate-node-key.route.ts +++ b/apps/server/api/src/api/nodes/rotate-node-key.route.ts @@ -89,9 +89,14 @@ export const rotateNodeKeyRoute = new Elysia() // own peer pins are untouched by anything the plane does, and // `nodes.ssh_fingerprint` (the trust card's mirror) deliberately // SURVIVES, because the fingerprints it holds are digests of - // keys that did not move. A machine that reports a DIFFERENT - // key into the refilled slot has rotated its identity without - // saying so, which §4.3's guard will keep refusing. + // keys that did not move. What the clearing does NOT do is + // police the redial: the slot stands empty, so §4.3's + // first-fill branch takes ANY reported key quietly (its + // same-bytes refusal guards a FILLED slot, which is the steady + // state an honest agent returns the key to). A machine that + // re-files a DIFFERENT identity is blocked by its peers' own + // pins instead: the relay's byte-equality check fails it, as + // §4.6 intends. await tx .updateTable("identities") .set({ signingPublicKey: null }) diff --git a/apps/server/web/src/routes/nodes_.$id.tsx b/apps/server/web/src/routes/nodes_.$id.tsx index 5693f8e4c..9cec92b17 100644 --- a/apps/server/web/src/routes/nodes_.$id.tsx +++ b/apps/server/web/src/routes/nodes_.$id.tsx @@ -5,6 +5,7 @@ import { errMessage, NodeAllowedDirs, NodeMaintenanceCard, + NodeSshTrustCard, relativeElapsed, useNode, } from "@internal/node-admin"; @@ -28,7 +29,6 @@ import { osLabel } from "@/components/nodes/node-row"; import { managesNodeSections } from "@/components/nodes/node-section-nav"; import { NodeSharingDialog } from "@/components/nodes/node-sharing-dialog"; import { NodeUpdateCard } from "@/components/nodes/node-update-card"; -import { SshTrustCard } from "@/components/ssh/trust-card"; import { useDeleteNode, useRenameNode } from "@/hooks/use-nodes"; import { SUBSHELLS_QUERY_KEY } from "@/lib/query-keys"; @@ -269,7 +269,7 @@ function NodeDetailPage() { never receives it and the card renders nothing; the card's own kind/access check is the belt. Live report while the machine is connected, durable mirror marked stale when it is not. */} - {managesNodeSections(n) && } + {managesNodeSections(n) && } {/* The node's RULES live on its page, not in a tab of one card: the allowlist gates launches on this machine including the diff --git a/apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx b/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx similarity index 68% rename from apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx rename to packages/node-admin/src/__tests__/ssh-trust-card.test.tsx index ff23f85a7..c9a033a1e 100644 --- a/apps/server/web/src/components/ssh/__tests__/trust-card.test.tsx +++ b/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx @@ -1,16 +1,17 @@ import { afterEach, describe, expect, it } from "bun:test"; -import type { NodeDetail } from "@internal/node-admin"; import { cleanup, render, screen } from "@testing-library/react"; -import { SshTrustCard } from "@/components/ssh/trust-card"; +import { NodeSshTrustCard } from "../components/ssh-trust-card"; +import type { NodeDetail } from "../types/node"; /** - * The node page's §4.6 machine trust card (spec 2026-10-08). The card is - * pure prop rendering: the GATE lives on the server (the field is absent for - * a `view` grantee and on `local`, never null), and the card's own check is - * belt-not-control - a payload that arrived against the rule must still not - * render. The fingerprints are public display of the trust the machines - * enforce as byte equality; the card exists for the out-of-band compare - * against what the PEER machine prints about itself. + * The §4.6 machine trust card (spec 2026-10-08), shared by the plane's node + * page and the node's own dashboard. The card is pure prop rendering: the + * GATE lives on the server (the field is absent for a `view` grantee and on + * `local`, never null), and the card's own check is belt-not-control - a + * payload that arrived against the rule must still not render. The + * fingerprints are public display of the trust the machines enforce as byte + * equality; the card exists for the out-of-band compare against what the PEER + * machine prints about itself. */ const FP_OWN_SIGN = `SHA256:${"A".repeat(43)}`; @@ -57,7 +58,7 @@ afterEach(cleanup); describe("SshTrustCard", () => { it("owner with a live block: own and peer fingerprints, no stale marking", () => { - const { container } = render(); + const { container } = render(); expect(container.firstChild).not.toBeNull(); expect(screen.getByText("Machine trust")).toBeDefined(); expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); @@ -69,12 +70,12 @@ describe("SshTrustCard", () => { }); it("an edit grantee sees the same card the owner sees", () => { - render(); + render(); expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); }); it("the stale mirror renders STALE-marked, distinguishable from live", () => { - render(); + render(); // The flag carries both a badge and its own sentence: the §4.6 compare is // exactly the thing a reader must not do against a last-known value // thinking it current. @@ -86,24 +87,24 @@ describe("SshTrustCard", () => { // The server's gate leaves the field OFF the payload for this viewer; the // card's own kind/access check is the belt, pinned here so a future // refactor of the route's gate cannot silently widen what this renders. - const { container } = render(); + const { container } = render(); expect(container.firstChild).toBeNull(); - const belt = render(); + const belt = render(); expect(belt.container.firstChild).toBeNull(); }); it("never on `local`, not even with a block that must not have serialized", () => { - const { container } = render(); + const { container } = render(); expect(container.firstChild).toBeNull(); }); it("no block on the view means no card (older agent, nothing reported yet)", () => { - const { container } = render(); + const { container } = render(); expect(container.firstChild).toBeNull(); }); it("an empty peer set says so plainly instead of rendering a lone header", () => { - render(); + render(); expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); expect(screen.getByText(/no ssh relay peers pinned yet/i)).toBeDefined(); }); diff --git a/apps/server/web/src/components/ssh/trust-card.tsx b/packages/node-admin/src/components/ssh-trust-card.tsx similarity index 76% rename from apps/server/web/src/components/ssh/trust-card.tsx rename to packages/node-admin/src/components/ssh-trust-card.tsx index 7cb83c558..8064bc8b8 100644 --- a/apps/server/web/src/components/ssh/trust-card.tsx +++ b/packages/node-admin/src/components/ssh-trust-card.tsx @@ -1,5 +1,8 @@ -import { Badge, Card, CardContent, CardHeader, CardTitle, CopyableValue, type NodeDetail } from "@internal/node-admin"; import type { JSX } from "react"; +import type { NodeDetail } from "../types/node"; +import { Badge } from "../ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { CopyableValue } from "../ui/copyable-value"; /** * The machine trust card (spec 2026-10-08 §4.6): this machine's own SSH relay @@ -7,22 +10,31 @@ import type { JSX } from "react"; * * The check this serves is deliberately NOT a green tick: the only honest * reading is a person comparing these strings against what the OTHER machine - * prints about the SAME keys on its own surface (the node dashboard side, fed - * from the machine's key files). Fingerprints are public display of the trust - * the machines enforce as byte equality; nothing here is key material, and - * nothing here proves anything until both machines have been read. + * prints about the SAME keys on its own surface. That is why this card lives + * in the shared package: the §4.6 compare happens on the TWO MACHINES' OWN + * loopback dashboards, where the page is fed from the machine's own key + * files, and the plane shows the same card as a convenience copy of what a + * connected (or last-connected) agent reported. Fingerprints are public + * display of the trust the machines enforce as byte equality; nothing here is + * key material, and nothing here proves anything until both machines have + * been read. * * Gated twice over, on purpose. The server omits `sshTrust` for a `view` * grantee and on `local` entirely (it is machine-relationship disclosure, the * same class as the runtime block), and this component refuses the same two - * cases so a payload that arrived against the rule still renders nothing. + * cases so a payload that arrived against the rule still renders nothing. On + * the node's own dashboard the view says `kind: "agent"`, `access: "owner"` + * (there is no viewer identity at loopback, and the human here holds the CLI + * that outranks any web grant), and `stale: false` by construction: this page + * is the machine answering about itself, never a mirror of a departed + * connection. * * Staleness is not the gate: an OFFLINE agent renders the durable mirror, and * says so plainly, because comparing last-known fingerprints during an * incident is exactly when the card earns its keep and exactly when reading a * stale value as current would mislead. */ -export function SshTrustCard({ node }: { node: NodeDetail }): JSX.Element | null { +export function NodeSshTrustCard({ node }: { node: NodeDetail }): JSX.Element | null { if (node.kind !== "agent" || (node.access !== "owner" && node.access !== "edit")) return null; const trust = node.sshTrust; if (!trust) return null; diff --git a/packages/node-admin/src/index.ts b/packages/node-admin/src/index.ts index eaf95b0da..ffca24926 100644 --- a/packages/node-admin/src/index.ts +++ b/packages/node-admin/src/index.ts @@ -1,11 +1,11 @@ /** * `@internal/node-admin` — the shared admin UI for operating a Subshell node. * - * One copy of the six node cards (runtime, service, log, maintenance, control - * plane, allowed directories), the hooks and types they speak, and the UI - * primitives they render, so the control plane's Nodes pages and the node's - * OWN loopback dashboard show the same machine in the same words and answer - * to the same `/api/nodes/:id/*` contract from two backends. + * One copy of the node cards (runtime, service, log, maintenance, control + * plane, allowed directories, machine trust), the hooks and types they speak, + * and the UI primitives they render, so the control plane's Nodes pages and + * the node's OWN loopback dashboard show the same machine in the same words + * and answer to the same `/api/nodes/:id/*` contract from two backends. * * ## The relicensing this package is * @@ -41,6 +41,7 @@ export { NodeMaintenanceCard } from "./components/node-maintenance-card"; export { NodeRuntimeCard, supervisionLine } from "./components/node-runtime-card"; export { NodeServerUrlCard } from "./components/node-server-url-card"; export { NodeServiceCard } from "./components/node-service-card"; +export { NodeSshTrustCard } from "./components/ssh-trust-card"; export { nodeDetailQuery, useNode, diff --git a/packages/node-admin/src/types/node.ts b/packages/node-admin/src/types/node.ts index c09edd284..fd7a0927c 100644 --- a/packages/node-admin/src/types/node.ts +++ b/packages/node-admin/src/types/node.ts @@ -251,6 +251,13 @@ export interface NodeShare { * Facts about a process, never about the machine: the control plane holds * the report on the live socket and drops it when that goes, so a value here * is always current or absent. + * + * One deliberate narrowing from the protocol's report: the §4.6 + * `sshFingerprint` block is NOT mirrored here, because no view carries it. + * The plane's route serializes `runtime` through a schema that does not + * declare it, and the node's own dashboard passes its pre-merge report (the + * trust block goes to the SPA as the top-level `sshTrust`, which is where + * `NodeSshTrustCard` reads it). */ export interface NodeRuntime { /** ISO 8601 start time of this node process */ @@ -312,17 +319,6 @@ export interface NodeRuntime { tmuxPath: string | null; /** The node binary this process re-enters */ binaryPath: string; - /** - * The §4.6 trust block as THIS report stated it (spec 2026-10-08): own and - * pinned-peer key fingerprints. Absent when the agent reported none. The - * detail view's `sshTrust` is the same fact with the one flag only the - * plane can state (whether it is live or the durable mirror); the runtime - * card renders nothing from this field, the trust card reads `sshTrust`. - */ - sshFingerprint?: { - own: { signing: string; encryption: string }; - peers: { nodeId: string; signing: string; encryption: string }[]; - }; } /** diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 06b57bde7..251aa30c6 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -550,11 +550,13 @@ function isSshFingerprint(value: unknown): value is string { } /** - * Node ids as the plane mints them: uuid text (hex and hyphens). `isNodeSubshellId`'s - * shape, reused because a peer id reaching the block is the same registry id. + * Node ids as the plane mints them: uuid text (hex and hyphens). THE guard, + * called, not copied: a peer id reaching the block is the same registry id + * the path-interpolation policy admits, and one regex must not drift from the + * other. */ function isPeerNodeId(value: unknown): value is string { - return typeof value === "string" && /^[0-9a-fA-F-]{1,64}$/.test(value); + return typeof value === "string" && isNodeSubshellId(value); } /** The `{ signing, encryption }` pair, or null when either half is off. */ From c9c27a2993d3918a26e0d712d683e424b69afb81 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:46:57 -0700 Subject: [PATCH 66/92] fix(install.sh): the 404 advice branch prints --key , never the live key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Set up Subshell here' act (spec 2026-10-08 §7) captures this script's output through a non-interactive exec; the old 'subshell setup --key ' advice line let bash expand the unspent key into the captured output. The placeholder renders identically for every caller, and the absent/unknown/ spent/expired render stays byte-identical (never a setup-key oracle). --- .../src/api/__tests__/install-script.test.ts | 87 +++++++++++++++++++ apps/server/api/src/api/install-script.ts | 3 +- 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 apps/server/api/src/api/__tests__/install-script.test.ts diff --git a/apps/server/api/src/api/__tests__/install-script.test.ts b/apps/server/api/src/api/__tests__/install-script.test.ts new file mode 100644 index 000000000..f28a8e50c --- /dev/null +++ b/apps/server/api/src/api/__tests__/install-script.test.ts @@ -0,0 +1,87 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { installScriptRoute } from "@/api/install-script.js"; +import { db } from "@/db/index.js"; +import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; + +/** + * `GET /install.sh` render rules (spec 2026-08-31 §8; the placeholder rule + * added by spec 2026-10-08 §7, Task 14). The "Set up Subshell here" act runs + * this exact script through a non-interactive exec and shows only a parsed + * status verb, so ANY line the script prints must be safe to capture: the + * 404 advice branch used to echo `--key $KEY`, which bash expands to the live + * setup key at RUNTIME - invisible to the rendered-text tests, visible in the + * captured output. It now prints the literal `--key `: a copy- + * paste template, never a value. + * + * The oracle rule stays: absent, unknown, spent, and expired keys render + * byte-identically (the endpoint is not a setup-key oracle). + */ + +const app = new Elysia().use(errorHandlerPlugin).use(installScriptRoute); +const repo = new NodeSetupKeysRepository(db); +const email = `install-${crypto.randomUUID()}@subshell.local`; +let userId = ""; +const keyIds: string[] = []; + +async function mkKey(ttlMs?: number): Promise { + const row = await repo.create(userId, ttlMs); + keyIds.push(row.id); + return row.key; +} + +async function install(key?: string): Promise { + const q = key ? `?setup_key=${encodeURIComponent(key)}` : ""; + return app.fetch(new Request(`http://localhost:3080/install.sh${q}`)); +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + userId = await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("install-1"), + role: "user", + }); +}); + +afterAll(async () => { + for (const id of keyIds) await repo.deleteById(id, userId).catch(() => {}); + await deleteUserByEmailOrId({ email }).catch(() => {}); +}); + +describe("install.sh 404 advice branch (spec 2026-10-08 §7)", () => { + it("prints the literal `--key ` placeholder and never the key value", async () => { + const key = await mkKey(); + const body = await (await install(key)).text(); + expect(body).toContain("--key "); + // The runtime-expanding spelling is gone: bash would interpolate $KEY into + // the advice line, and the "Set up Subshell here" parser captures that + // line verbatim on a D that cannot fetch the node binary. + expect(body).not.toContain("--key $KEY"); + // The key still appears EXACTLY once: the KEY= assignment the render doc + // names as its only home. + expect(body.split(key).length - 1).toBe(1); + }); +}); + +describe("install.sh failure rendering is one byte-identical answer (never an oracle)", () => { + it("absent, unknown, spent, and expired keys render the same usage script", async () => { + const absent = await (await install()).text(); + const unknown = await (await install(`nsk_${"x".repeat(32)}`)).text(); + const spentKey = await mkKey(); + await repo.consume(spentKey, crypto.randomUUID()); + const spent = await (await install(spentKey)).text(); + const expired = await (await install(await mkKey(-60_000))).text(); + expect(unknown).toBe(absent); + expect(spent).toBe(absent); + expect(expired).toBe(absent); + expect(absent).toContain("exit 2"); + }); +}); diff --git a/apps/server/api/src/api/install-script.ts b/apps/server/api/src/api/install-script.ts index a88140734..b2f7ff35c 100644 --- a/apps/server/api/src/api/install-script.ts +++ b/apps/server/api/src/api/install-script.ts @@ -361,7 +361,8 @@ else echo " cli-node-vX.Y.Z GitHub Release into that dir, or run" >&2 echo " 'bun run release:cli-node' from a checkout on the server host." >&2 echo " Or install the node for this machine another way and run setup directly:" >&2 - echo " subshell setup --server $SERVER --key $KEY\${DATA_DIR:+ --data-dir \\"$DATA_DIR\\"}" >&2 + # Keep credentials out of captured setup output. The user supplies this placeholder. + echo " subshell setup --server $SERVER --key \${DATA_DIR:+ --data-dir \\"$DATA_DIR\\"}" >&2 exit 1 ;; *) From e3692b8ae72666930ab8658464a755423db3c7b7 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 17:51:23 -0700 Subject: [PATCH 67/92] feat(ssh-relay): non-interactive 'Set up Subshell here' enrollment over the open session Task 14's machine half. The ssh_exec / ssh_exec_status pair runs the rendered installer as a SEPARATE short-lived 'ssh D ' on the connecting machine - never typed into the pane, so the minted setup key reaches neither the pane screen nor its log. Grammar: every field required, the command is one printable line, the relay/own-mode contradiction refused at the grammar. Executor: gate and maintenance speak first, the config path is byte-checked against this machine's own derivation (the launch doctrine restated), the relay socket is derived not claimed, the run goes OFF the command chain (an installer takes minutes), and the nsk_ redactor drops every key line BEFORE the tail truncation - redact then cut, never cut then redact. The per-act files leave with the act; the plane re-redacts what comes back. --- .../src/__tests__/commands-ssh-exec.test.ts | 303 ++++++++++++++++++ apps/node/agent/src/commands/index.ts | 10 +- apps/node/agent/src/commands/ssh-exec.ts | 199 ++++++++++++ .../src/__tests__/node-frames.test.ts | 153 ++++++++- .../src/__tests__/node-results-ssh.test.ts | 60 +++- packages/subshell-protocol/src/index.ts | 12 + packages/subshell-protocol/src/node-frames.ts | 2 + .../subshell-protocol/src/node-results.ts | 63 +++- packages/subshell-protocol/src/ssh-frames.ts | 156 ++++++++- packages/subshell-protocol/src/ssh-limits.ts | 46 +++ packages/subshell-protocol/src/ssh-results.ts | 35 ++ 11 files changed, 1027 insertions(+), 12 deletions(-) create mode 100644 apps/node/agent/src/__tests__/commands-ssh-exec.test.ts create mode 100644 apps/node/agent/src/commands/ssh-exec.ts diff --git a/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts new file mode 100644 index 000000000..30643364d --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts @@ -0,0 +1,303 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + buildAgentSocketPath, + buildSshConfigPath, + buildSshKnownHostsPath, + type SshProcessResult, +} from "@internal/pane-runtime"; +import { NODE_RESULT_MAINTENANCE, type SshExecCommand, type SshExecStatusCommand } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { execSshExec, execSshExecStatus, resetSshExecRunsForTests, type SshExecSeams } from "../commands/ssh-exec.js"; +import { writeMaintenance } from "../maintenance.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; +import { isSubshellId } from "../subshell-meta.js"; + +/** + * The `ssh_exec` non-interactive setup run (spec 2026-10-08 §7, Task 14): the + * "Set up Subshell here" act's separate short-lived `ssh D ''` on B, + * with the `nsk_` redactor standing between the installer's output and every + * surface that outlives the command. Posture copied from the sibling ssh + * arms: the gate speaks first, the config path is byte-checked against THIS + * machine's own derivation (the launch-frame doctrine restated for the exec), + * the run is off the command chain (an installer takes minutes; the chain + * must stay free), and the status answer carries only redacted, tail-truncated + * output. The runProcess seam replaces the real ssh child; this file owns the + * arm's decisions, not OpenSSH's behavior. + */ + +const STAMP = "2026-10-08T10:00:00.000Z"; +const GATE_REFUSAL = "ssh disabled on this node"; +const EXEC_ID = "11111111-2222-4333-8444-555555555555"; +const KEY = "nsk_LYr6F2g63jWYpSF1g9MWZaqx6V9HDGJi"; + +let base: string; +let savedSshPath: string | undefined; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-cmds-exec-"))); + savedSshPath = process.env.SUBSHELL_SSH_PATH; + // A stub ssh so the REAL runSshProcess cases can spawn something real. + const stub = join(base, "ssh-stub.sh"); + writeFileSync(stub, "#!/bin/sh\necho '==> done.'\n", { mode: 0o755 }); + resetSshExecRunsForTests(); +}); + +afterAll(() => { + if (savedSshPath === undefined) delete process.env.SUBSHELL_SSH_PATH; + else process.env.SUBSHELL_SSH_PATH = savedSshPath; + resetSshExecRunsForTests(); + rmSync(base, { recursive: true, force: true }); +}); + +// The exec registry is module state (one daemon, one map); tests share the +// EXEC_ID, so each one starts from an empty map. +beforeEach(() => resetSshExecRunsForTests()); + +function makeCtx(tag: string, opts: { gateOn?: boolean; maintenanceOn?: boolean } = {}): CommandContext { + const dir = join(base, tag, "data"); + mkdirSync(dir, { recursive: true }); + if (opts.gateOn !== false) writeSshEnabled(dir, { on: true, changedAt: STAMP }); + if (opts.maintenanceOn) writeMaintenance(dir, { on: true, changedAt: STAMP }); + return { config: { dataDir: dir }, nowMs: () => Date.now() } as unknown as CommandContext; +} + +function execCmd(dir: string, over: Partial = {}): SshExecCommand { + return { + type: "ssh_exec", + execId: EXEC_ID, + configPath: buildSshConfigPath(dir, EXEC_ID), + fileContent: "Host *\n StrictHostKeyChecking yes\n", + presetFlags: ["-o", "BatchMode=yes", "-F", buildSshConfigPath(dir, EXEC_ID), "--", "d.example.test"], + command: `curl -fsSL "http://plane.test/install.sh?setup_key=${KEY}" | SUBSHELL_NODE_NAME="d" bash`, + relay: true, + agentSocketPath: null, + timeoutMs: 600_000, + ...over, + }; +} + +function statusCmd(execId = EXEC_ID): SshExecStatusCommand { + return { type: "ssh_exec_status", execId }; +} + +/** Bind the relay proxy socket path the way openBRelaySession left it (a file at the derived path). */ +function bindSocket(dir: string): string { + const path = buildAgentSocketPath(dir, EXEC_ID); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "stub"); + return path; +} + +/** A runProcess seam that answers a canned result and records the argv + env it was handed. */ +function fakeRun( + result: Partial, + opts: { defer?: boolean } = {}, +): { + seams: SshExecSeams; + calls: { argv: readonly string[]; env: Record; timeoutMs: number }[]; + finish: (r?: Partial) => void; +} { + const calls: { argv: readonly string[]; env: Record; timeoutMs: number }[] = []; + let settle: ((r: SshProcessResult) => void) | null = null; + const seams: SshExecSeams = { + resolveSshBin: async () => "/usr/bin/fake-ssh", + runProcess: ((argv, env, timeoutMs) => { + calls.push({ argv, env, timeoutMs }); + const full: SshProcessResult = { code: 0, stdout: "", stderr: "", timedOut: false, spawnError: false, ...result }; + if (opts.defer) { + return new Promise((resolve) => { + settle = (r) => resolve({ ...full, ...r }); + }); + } + return Promise.resolve(full); + }) as unknown as SshExecSeams["runProcess"], + }; + return { seams, calls, finish: (r) => settle?.(r ?? {}) }; +} + +describe("ssh_exec gate and path discipline", () => { + it("the gate speaks first: a mirror not ON refuses before any spawn", async () => { + const ctx = makeCtx("gate-off", { gateOn: false }); + const { seams, calls } = fakeRun({}); + const res = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(res).toEqual({ ok: false, error: GATE_REFUSAL }); + expect(calls).toHaveLength(0); + }); + + it("maintenance refuses the run (the machine is out of service for new work)", async () => { + const ctx = makeCtx("maint", { maintenanceOn: true }); + const { seams, calls } = fakeRun({}); + const res = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(res).toEqual({ ok: false, error: NODE_RESULT_MAINTENANCE }); + expect(calls).toHaveLength(0); + }); + + it("refuses a config path that is not this machine's own derivation, and writes nothing", async () => { + const ctx = makeCtx("path"); + const hostile = execCmd(ctx.config.dataDir, { configPath: "/home/u/.ssh/config" }); + const { seams, calls } = fakeRun({}); + expect(await execSshExec(ctx, hostile, seams)).toMatchObject({ ok: false }); + expect(calls).toHaveLength(0); + expect(existsSync(buildSshConfigPath(ctx.config.dataDir, EXEC_ID))).toBe(false); + }); + + it("refuses an exec id outside the path-composition guard", async () => { + const ctx = makeCtx("babadid"); + const bad = execCmd(ctx.config.dataDir, { execId: "not an id" }); + expect(isSubshellId(bad.execId)).toBe(false); + const { seams } = fakeRun({}); + expect(await execSshExec(ctx, bad, seams)).toMatchObject({ ok: false }); + }); + + it("relay mode refuses when the proxy socket is not bound (no half-running the install)", async () => { + const ctx = makeCtx("no-sock"); + const { seams, calls } = fakeRun({}); + expect(await execSshExec(ctx, execCmd(ctx.config.dataDir), seams)).toMatchObject({ ok: false }); + expect(calls).toHaveLength(0); + }); + + it("refuses when this machine has no ssh binary", async () => { + const ctx = makeCtx("nobin"); + bindSocket(ctx.config.dataDir); + const seams: SshExecSeams = { resolveSshBin: async () => null }; + expect(await execSshExec(ctx, execCmd(ctx.config.dataDir), seams)).toMatchObject({ + ok: false, + error: "ssh binary missing: ssh", + }); + }); +}); + +describe("ssh_exec kick + status", () => { + it("writes the config at the derived path, spawns ssh off-chain, and reports running then done", async () => { + const ctx = makeCtx("happy"); + const socket = bindSocket(ctx.config.dataDir); + // The relay-open also wrote the pinned known_hosts; the exec's cleanup must remove it too. + writeFileSync(buildSshKnownHostsPath(ctx.config.dataDir, EXEC_ID), "d.example.test ssh-ed25519 AAAA\n", { + mode: 0o600, + }); + const { seams, calls, finish } = fakeRun({}, { defer: true }); + const kick = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(kick).toEqual({ ok: true, data: { started: true, execId: EXEC_ID } }); + + const configPath = buildSshConfigPath(ctx.config.dataDir, EXEC_ID); + expect(existsSync(configPath)).toBe(true); + // 0600 in a 0700 dir, the launch ssh-member rule (umask cannot leak past enforceMode). + expect(statSync(configPath).mode & 0o777).toBe(0o600); + + // One argv element for the command: ssh joins operands itself. + expect(calls[0]?.argv).toEqual([ + "/usr/bin/fake-ssh", + ...execCmd(ctx.config.dataDir).presetFlags, + execCmd(ctx.config.dataDir).command, + ]); + expect(calls[0]?.env.SSH_AUTH_SOCK).toBe(socket); // derived, never claimed + expect(calls[0]?.timeoutMs).toBe(600_000); + + // The run is OFF the chain: the status arm answers while ssh is still out there. + const dup = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(dup).toMatchObject({ ok: false }); + expect(await execSshExecStatus(ctx, statusCmd(), seams)).toEqual({ ok: true, data: { state: "running" } }); + + finish({ code: 0, stdout: "==> downloading subshell\n==> done.\n", stderr: "" }); + await new Promise((r) => setTimeout(r, 5)); // let the off-chain completion land + expect(await execSshExecStatus(ctx, statusCmd(), seams)).toEqual({ + ok: true, + data: { state: "done", code: 0, timedOut: false, stdout: "==> downloading subshell\n==> done.\n", stderr: "" }, + }); + // The per-act files are gone; the dir itself is the hourly sweep's garbage. + expect(existsSync(configPath)).toBe(false); + expect(existsSync(buildSshKnownHostsPath(ctx.config.dataDir, EXEC_ID))).toBe(false); + }); + + it("a captured nsk_ line NEVER survives into the status answer", async () => { + const ctx = makeCtx("redact"); + bindSocket(ctx.config.dataDir); + const leaky = [ + "==> downloading subshell (linux-x64) from http://plane.test", + `curl: (22) The requested URL returned error: 404 with ${KEY} in hand`, + `KEY="${KEY}"`, + "subshell: setup ran with the key", + "==> done.", + ].join("\n"); + const { seams } = fakeRun({ code: 0, stdout: leaky, stderr: leaky }); + const kick = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + expect(kick.ok).toBe(true); + await new Promise((r) => setTimeout(r, 5)); + const status = await execSshExecStatus(ctx, statusCmd(), seams); + expect(status.ok).toBe(true); + const serialized = JSON.stringify(status); + expect(serialized).not.toContain("nsk_"); + expect(serialized).not.toContain(KEY); + }); + + it("redaction runs BEFORE the tail truncation: a leaking line ahead of the tail cannot be cut into a survivor", async () => { + const ctx = makeCtx("redact-tail"); + bindSocket(ctx.config.dataDir); + const filler = Array.from({ length: 4000 }, (_, i) => `filler line ${i}`).join("\n"); + const stdout = `setup_key=${KEY} at the head\n${filler}\n==> done.`; + const { seams } = fakeRun({ code: 0, stdout, stderr: "" }); + await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + await new Promise((r) => setTimeout(r, 5)); + const status = await execSshExecStatus(ctx, statusCmd(), seams); + const serialized = JSON.stringify(status); + expect(serialized).not.toContain("nsk_"); + expect(serialized).toContain("==> done."); + }); + + it("the deadline is reported honestly: timedOut true, code null", async () => { + const ctx = makeCtx("timeout"); + bindSocket(ctx.config.dataDir); + const { seams } = fakeRun({ code: null, timedOut: true, stdout: "", stderr: "killed" }); + await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + await new Promise((r) => setTimeout(r, 5)); + expect(await execSshExecStatus(ctx, statusCmd(), seams)).toEqual({ + ok: true, + data: { state: "done", code: null, timedOut: true, stdout: "", stderr: "killed" }, + }); + }); + + it("direct mode: no socket claim means no SSH_AUTH_SOCK; the snapshot's socket rides when named", async () => { + const ctx = makeCtx("direct"); + const named = fakeRun({}); + await execSshExec( + ctx, + execCmd(ctx.config.dataDir, { relay: false, agentSocketPath: "/run/user/501/ssh-agent.sock" }), + named.seams, + ); + expect(named.calls[0]?.env.SSH_AUTH_SOCK).toBe("/run/user/501/ssh-agent.sock"); + resetSshExecRunsForTests(); + const bare = fakeRun({}); + await execSshExec(ctx, execCmd(ctx.config.dataDir, { relay: false, agentSocketPath: null }), bare.seams); + expect("SSH_AUTH_SOCK" in (bare.calls[0]?.env ?? {})).toBe(false); + }); + + it("status refuses an unknown exec id", async () => { + const ctx = makeCtx("unknown"); + expect( + await execSshExecStatus(ctx, statusCmd(), { + resolveSshBin: async () => "/usr/bin/fake-ssh", + }), + ).toMatchObject({ ok: false }); + }); + + it("dispatch routes both arms", async () => { + const ctx = makeCtx("dispatch"); + bindSocket(ctx.config.dataDir); + // The dispatch path runs the REAL binary lookup and spawn; point the + // ladder at a stub ssh that prints the success verb and exits. + process.env.SUBSHELL_SSH_PATH = join(base, "ssh-stub.sh"); + const kick = await dispatchCommand(ctx, execCmd(ctx.config.dataDir) as never); + expect(kick.ok).toBe(true); + await new Promise((r) => setTimeout(r, 20)); + const st = await dispatchCommand(ctx, statusCmd() as never); + expect(st).toEqual({ + ok: true, + data: { state: "done", code: 0, timedOut: false, stdout: "==> done.\n", stderr: "" }, + }); + delete process.env.SUBSHELL_SSH_PATH; + }); +}); diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index c199a7dba..814896976 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -29,6 +29,7 @@ import { execSetMaintenance } from "./set-maintenance.js"; import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; +import { execSshExec, execSshExecStatus } from "./ssh-exec.js"; import { execSshHostKey } from "./ssh-host-key.js"; import { execSshAgentIdentities, execSshRegisterIdentity } from "./ssh-identity.js"; import { execSshRelayClose, execSshRelayOpen } from "./ssh-relay-exec.js"; @@ -70,7 +71,10 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * and the brokered relay pair `ssh_relay_open` / `ssh_relay_close` * (spec 2026-10-08 §5.1, protocol 18 - open consults the gate then drives the * T6/T7 pairing branches by the command's own role; close is ungated teardown, - * because ending a session is not an SSH act). + * because ending a session is not an SSH act), and the non-interactive setup + * pair `ssh_exec` / `ssh_exec_status` (spec 2026-10-08 §7, Task 14 - the + * "Set up Subshell here" install runs on its own short-lived ssh, off the + * command chain, its captured output `nsk_`-redacted before it is kept). * Any * unknown type still answers `unsupported` — the integration * contract that lets the backend and agent tracks move independently. @@ -162,6 +166,10 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return await execSshRelayOpen(ctx, cmd); case "ssh_relay_close": return execSshRelayClose(ctx, cmd); + case "ssh_exec": + return await execSshExec(ctx, cmd); + case "ssh_exec_status": + return await execSshExecStatus(ctx, cmd); case "set_server_url": return await execSetServerUrl(ctx, cmd); case "update": diff --git a/apps/node/agent/src/commands/ssh-exec.ts b/apps/node/agent/src/commands/ssh-exec.ts new file mode 100644 index 000000000..99942d8d1 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-exec.ts @@ -0,0 +1,199 @@ +import { existsSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { + buildAgentSocketPath, + buildSshConfigPath, + buildSshKnownHostsPath, + enforceMode, + runSshProcess, + type SshProcessResult, + sshChildPath, +} from "@internal/pane-runtime"; +import { + NODE_RESULT_MAINTENANCE, + redactSshSetupKeyLines, + SSH_EXEC_RETAIN_BYTES, + type SshExecCommand, + type SshExecStatusCommand, +} from "@internal/subshell-protocol"; +import { readMaintenance } from "../maintenance.js"; +import { pathAllowed, realpathRoots } from "../path-policy.js"; +import { readSshEnabled, sshAllowed } from "../ssh-enabled.js"; +import { isSubshellId } from "../subshell-meta.js"; +import type { CommandContext, CommandResult } from "./context.js"; +import { resolveSshBin, SSH_GATE_REFUSAL } from "./ssh-shared.js"; + +/** + * The `ssh_exec` non-interactive run (spec 2026-10-08 §7, Task 14): the + * "Set up Subshell here" act's SEPARATE short-lived `ssh D ''` on + * this machine - never typed into the interactive pane, so the minted setup + * key in the command string reaches neither the pane screen nor the pane log. + * The rules, each load-bearing: + * + * - **The gate and maintenance speak first**, like every SSH arm and the + * launch: a mirror not ON refuses {@link SSH_GATE_REFUSAL} before any + * lookup or spawn, and a machine out of service takes no new work. + * - **The config path is byte-checked against this machine's own + * derivation** (the launch frame's ssh-member doctrine restated): the exec + * id names `/ssh//`, and a frame claiming any other path + * is refused, so a signed command can never become an arbitrary write. + * - **Relay mode authenticates through the socket `ssh_relay_open` bound at + * this same derived path** - derived HERE, claimed by nothing on the wire; + * a kick whose proxy socket is not bound is refused rather than run with + * half its authentication. + * - **The run is OFF the command chain.** An installer downloads a ~100 MB + * binary; the serial chain that orders every other command must stay free + * for the pane's own machine, so the kick acks `started` immediately and + * the outcome is polled with `ssh_exec_status`. + * - **The `nsk_` redactor stands before anything is kept.** Every captured + * line carrying the setup-key marker is dropped BEFORE the answer-side + * truncation (line-wise first, tail second - a cut must never leave the + * key's tail bytes behind), and the per-act files (config, pinned + * `known_hosts`) are unlinked when the child finishes. The durable record + * of the act is the plane's audit, which names ids and stages only. + * + * State is the module's own map keyed by the (plane-minted, path-guarded) + * exec id, TTL-swept at every touch: a done result is kept long enough for + * the plane's poll, and a daemon that restarts forgets the act (the plane's + * poll then names the failure; a stale entry can never outlive the sweep). + */ + +/** How long a DONE exec answer is kept for the plane's poll (a running child is never swept). */ +const EXEC_RESULT_TTL_MS = 30 * 60_000; + +interface ExecEntry { + startedAt: number; + done?: { code: number | null; timedOut: boolean; stdout: string; stderr: string }; +} + +const runs = new Map(); + +/** Drop every expired done-entry (running children are kept whatever their age). */ +function sweepRuns(nowMs: number): void { + for (const [id, e] of runs) { + if (e.done !== undefined && e.startedAt <= nowMs - EXEC_RESULT_TTL_MS) runs.delete(id); + } +} + +/** + * Forget every tracked exec (daemon-test isolation). + * @internal test-only + */ +export function resetSshExecRunsForTests(): void { + runs.clear(); +} + +/** Test seams (production omits all three; the contract mirrors `HostKeySeams`). */ +export interface SshExecSeams { + /** Where ssh is; defaults to this machine's own lookup ladder. */ + resolveSshBin?: () => Promise; + /** One ssh run; defaults to {@link runSshProcess}. Absolute argv[0], full child env, deadline. */ + runProcess?: typeof runSshProcess; + /** Epoch clock for the result TTL (defaults to Date.now). */ + nowMs?: () => number; +} + +/** Redact per line, THEN keep the tail: the order is the leak rule (module doc). */ +function retained(stream: string): string { + const clean = redactSshSetupKeyLines(stream); + const bytes = Buffer.from(clean, "utf8"); + if (bytes.byteLength <= SSH_EXEC_RETAIN_BYTES) return clean; + const tail = bytes.subarray(bytes.byteLength - SSH_EXEC_RETAIN_BYTES); + // Drop a leading partial codepoint the byte cut may have produced. + return new TextDecoder("utf-8", { fatal: false }).decode(tail); +} + +/** Execute `ssh_exec`: gate, byte-check, bind, spawn off-chain, ack. */ +export async function execSshExec( + ctx: CommandContext, + cmd: SshExecCommand, + seams?: SshExecSeams, +): Promise { + if (!sshAllowed(readSshEnabled(ctx.config.dataDir))) return { ok: false, error: SSH_GATE_REFUSAL }; + const maintenance = readMaintenance(ctx.config.dataDir); + if (maintenance.kind === "unreadable" || (maintenance.kind === "state" && maintenance.state.on)) { + return { ok: false, error: NODE_RESULT_MAINTENANCE }; + } + // The path guard is this machine's own id rule (hex+hyphen, ≤64): every fs + // path below interpolates the exec id, so nothing past this line runs on an + // id the composition guards could not have produced. + if (!isSubshellId(cmd.execId)) return { ok: false, error: "invalid exec id" }; + const derivedConfig = buildSshConfigPath(ctx.config.dataDir, cmd.execId); + if (cmd.configPath !== derivedConfig) { + return { ok: false, error: "ssh config path refused: not the derived path" }; + } + if (!(await pathAllowed(derivedConfig, await realpathRoots([ctx.config.dataDir])))) { + return { ok: false, error: "ssh config path refused" }; + } + // Relay mode: the proxy socket lives at THIS machine's derivation of the + // same (dataDir, execId) pair the relay-open bound. No claim is read; the + // fact is checked. An unbound socket means no signing path to D - refuse + // loudly rather than run the install down a road that stops at auth. + const agentSocket = cmd.relay ? buildAgentSocketPath(ctx.config.dataDir, cmd.execId) : null; + if (agentSocket !== null && !existsSync(agentSocket)) { + return { ok: false, error: "relay agent socket is not bound on this machine" }; + } + const now = seams?.nowMs ?? Date.now; + sweepRuns(now()); + if (runs.has(cmd.execId)) return { ok: false, error: "an exec with this id is already tracked" }; + const sshBin = await (seams?.resolveSshBin ?? resolveSshBin)(); + if (sshBin === null) return { ok: false, error: "ssh binary missing: ssh" }; + + // Place the config like the launch arm does: own dir at 0700, file at 0600, + // enforceMode after both (a umask can only clear bits, never past this). + const dir = dirname(derivedConfig); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await enforceMode(dir, 0o700); + await writeFile(derivedConfig, cmd.fileContent, { mode: 0o600 }); + await enforceMode(derivedConfig, 0o600); + + // The scoped agent env, one key, for the ssh invocation only (never a + // whole-env inheritance - runSshProcess takes the COMPLETE child env). + const env: Record = { + HOME: process.env.HOME ?? "", + PATH: await sshChildPath(), + }; + if (agentSocket !== null) env.SSH_AUTH_SOCK = agentSocket; + else if (cmd.agentSocketPath !== null) env.SSH_AUTH_SOCK = cmd.agentSocketPath; + + const entry: ExecEntry = { startedAt: now() }; + runs.set(cmd.execId, entry); + // OFF the chain by construction: the run's promise is carried, never + // awaited; the finish handler redacts, retains, stores, and sweeps files. + void (seams?.runProcess ?? runSshProcess)([sshBin, ...cmd.presetFlags, cmd.command], env, cmd.timeoutMs).then( + (r: SshProcessResult) => { + entry.done = { + code: r.spawnError ? null : r.code, + timedOut: r.timedOut, + stdout: retained(r.stdout), + stderr: retained(r.stderr), + }; + // The act's own files leave with the act; the dir joins the hourly + // orphan sweep (`ssh-dir-retention`), which owns every leftover name. + void rm(derivedConfig, { force: true }).catch(() => {}); + void rm(buildSshKnownHostsPath(ctx.config.dataDir, cmd.execId), { force: true }).catch(() => {}); + }, + () => { + // runSshProcess never rejects today; the shape is the guard, so a + // future throw cannot strand the entry as "running" forever. + entry.done = { code: null, timedOut: false, stdout: "", stderr: "the ssh process could not be tracked" }; + }, + ); + return { ok: true, data: { started: true, execId: cmd.execId } }; +} + +/** Execute `ssh_exec_status`: the polled state of one kicked run. */ +export async function execSshExecStatus( + ctx: CommandContext, + cmd: SshExecStatusCommand, + seams?: SshExecSeams, +): Promise { + if (!sshAllowed(readSshEnabled(ctx.config.dataDir))) return { ok: false, error: SSH_GATE_REFUSAL }; + if (!isSubshellId(cmd.execId)) return { ok: false, error: "invalid exec id" }; + sweepRuns((seams?.nowMs ?? Date.now)()); + const e = runs.get(cmd.execId); + if (e === undefined) return { ok: false, error: "no exec with this id on this machine" }; + if (e.done === undefined) return { ok: true, data: { state: "running" } }; + return { ok: true, data: { state: "done", ...e.done } }; +} diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 1278098f8..92ce9acf0 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -28,11 +28,19 @@ import { import { isSshGrantFingerprints, isSshPaneId, + redactSshSetupKeyLines, SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, + type SshExecCommand, type SshRelayOpenCommand, } from "../ssh-frames.js"; -import { SSH_CONFIG_FILE_MAX_BYTES, SSH_MAX_GRANT_FINGERPRINTS } from "../ssh-limits.js"; +import { + SSH_CONFIG_FILE_MAX_BYTES, + SSH_EXEC_COMMAND_MAX_CHARS, + SSH_EXEC_MAX_PRESET_FLAGS, + SSH_EXEC_TIMEOUT_MAX_MS, + SSH_MAX_GRANT_FINGERPRINTS, +} from "../ssh-limits.js"; import { MIN_NODE_VERSION } from "../versions.js"; const launchCmd = { @@ -1069,18 +1077,20 @@ describe("ssh command arms and the launch ssh block", () => { type: "ssh_register_identity", }); expect(parseNodeCommandBody({ type: "ssh_register_identity" })).not.toBeNull(); - // The family's census: exactly these seven types, and the count is the + // The family's census: exactly these nine types, and the count is the // tripwire - a further arm must show up here before it ships. expect([...SSH_COMMAND_TYPES].sort()).toEqual([ "ssh_agent_identities", "ssh_discover_aliases", + "ssh_exec", + "ssh_exec_status", "ssh_host_key", "ssh_register_identity", "ssh_relay_close", "ssh_relay_open", "ssh_resolve_config", ]); - expect(SSH_COMMAND_TYPES).toHaveLength(7); + expect(SSH_COMMAND_TYPES).toHaveLength(9); // Every census type is a type the dispatcher actually narrows. for (const t of SSH_COMMAND_TYPES) { const body = @@ -1092,7 +1102,11 @@ describe("ssh command arms and the launch ssh block", () => { ? { type: t, ref: "r-1", reason: "lifetime-expiry" } : t === "ssh_host_key" ? { type: t, host: "git.example.test", port: 22, user: null } - : { type: t }; + : t === "ssh_exec" + ? execCmdFixture() + : t === "ssh_exec_status" + ? { type: t, execId: EXEC_ID } + : { type: t }; expect(parseNodeCommandBody(body)).not.toBeNull(); } }); @@ -1384,6 +1398,137 @@ describe("ssh_relay_open / ssh_relay_close arms (spec 2026-10-08 §5.1)", () => }); }); +/** + * A complete `ssh_exec` kick (spec 2026-10-08 §7, Task 14): the non-interactive + * "Set up Subshell here" run on the connecting machine. Every field is + * required - the node byte-checks the config path against its own derivation, + * so a frame guessing any half is refused rather than executed with a hole. + */ +const EXEC_ID = "11111111-2222-4333-8444-555555555555"; +const execCmdFixture = (): SshExecCommand => ({ + type: "ssh_exec", + execId: EXEC_ID, + configPath: `/home/u/.subshell/ssh/${EXEC_ID}/config`, + fileContent: "Host *\n StrictHostKeyChecking yes\n", + presetFlags: [ + "-o", + "BatchMode=yes", + "-F", + `/home/u/.subshell/ssh/${EXEC_ID}/config`, + "-p", + "22", + "--", + "d.example.test", + ], + command: 'curl -fsSL "http://plane.test/install.sh?setup_key=nsk_x" | SUBSHELL_NODE_NAME="d" bash', + relay: true, + agentSocketPath: null, + timeoutMs: 300_000, +}); + +describe("ssh_exec / ssh_exec_status arms (spec 2026-10-08 §7, Task 14)", () => { + it("narrows a complete kick, every field carried through", () => { + expect(parseNodeCommandBody(structuredClone(execCmdFixture()))).toEqual(execCmdFixture()); + // Direct mode: no relay, the snapshot's own agent socket rides (or null). + expect(parseNodeCommandBody({ ...execCmdFixture(), relay: false, agentSocketPath: null })).toMatchObject({ + relay: false, + agentSocketPath: null, + }); + expect( + parseNodeCommandBody({ ...execCmdFixture(), relay: false, agentSocketPath: "/run/user/501/agent.sock" }), + ).toMatchObject({ + agentSocketPath: "/run/user/501/agent.sock", + }); + }); + + it("refuses a kick missing any field", () => { + for (const drop of [ + "execId", + "configPath", + "fileContent", + "presetFlags", + "command", + "relay", + "agentSocketPath", + "timeoutMs", + ] as const) { + const partial = structuredClone(execCmdFixture()) as unknown as Record; + delete partial[drop]; + expect(parseNodeCommandBody(partial)).toBeNull(); + } + }); + + it("refuses malformed kick fields: ids, paths, flags, command, and the relay contradiction", () => { + expect(parseNodeCommandBody({ ...execCmdFixture(), execId: "not an id" })).toBeNull(); // path-id shape + expect(parseNodeCommandBody({ ...execCmdFixture(), execId: "" })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), configPath: "ssh/x/config" })).toBeNull(); // absolute only + expect( + parseNodeCommandBody({ ...execCmdFixture(), fileContent: "x".repeat(SSH_CONFIG_FILE_MAX_BYTES + 1) }), + ).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), presetFlags: "not-an-array" })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), presetFlags: [] })).toBeNull(); // the launch needs at least the -F tail + expect( + parseNodeCommandBody({ + ...execCmdFixture(), + presetFlags: Array.from({ length: SSH_EXEC_MAX_PRESET_FLAGS + 1 }, (_, i) => `-${i}`), + }), + ).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), presetFlags: ["-F", "path with space"] })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), presetFlags: ["-x", "nl\n"] })).toBeNull(); // control chars refused + expect(parseNodeCommandBody({ ...execCmdFixture(), command: "" })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), command: "a\x00b" })).toBeNull(); // one printable line + expect( + parseNodeCommandBody({ ...execCmdFixture(), command: "c".repeat(SSH_EXEC_COMMAND_MAX_CHARS + 1) }), + ).toBeNull(); + // relay true names the DERIVED socket; an agentSocketPath claim beside it + // is a contradiction, and agentSocketPath null in direct mode is legal. + expect( + parseNodeCommandBody({ + ...execCmdFixture(), + relay: true, + agentSocketPath: "/home/u/.subshell/ssh/other/agent.sock", + }), + ).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), timeoutMs: 0 })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), timeoutMs: SSH_EXEC_TIMEOUT_MAX_MS + 1 })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), timeoutMs: 1.5 })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), relay: "yes" })).toBeNull(); + expect(parseNodeCommandBody({ ...execCmdFixture(), agentSocketPath: "relative/sock" })).toBeNull(); + // A stray member drops; the narrowed body is rebuilt from the checked fields. + const withExtra = parseNodeCommandBody({ ...execCmdFixture(), smuggled: "x" }); + expect(withExtra).not.toBeNull(); + expect(Object.keys(withExtra as Record).sort()).toEqual(Object.keys(execCmdFixture()).sort()); + }); + + it("the status arm is one path id", () => { + expect(parseNodeCommandBody({ type: "ssh_exec_status", execId: EXEC_ID })).toEqual({ + type: "ssh_exec_status", + execId: EXEC_ID, + }); + expect(parseNodeCommandBody({ type: "ssh_exec_status", execId: "bad id" })).toBeNull(); + expect(parseNodeCommandBody({ type: "ssh_exec_status" })).toBeNull(); + }); + + it("redactSshSetupKeyLines drops EVERY line carrying the setup-key marker", () => { + const out = redactSshSetupKeyLines( + [ + "==> downloading subshell (linux-x64) from http://plane.test", + "curl: (7) could not reach http://plane.test/install.sh?setup_key=nsk_SECRET_VALUE_00000000000000000000", + 'KEY="nsk_SECRET_VALUE_00000000000000000000"', + "==> done.", + ].join("\n"), + ); + expect(out).not.toContain("nsk_"); + expect(out).not.toContain("SECRET"); + expect(out).toContain("==> downloading"); + expect(out).toContain("==> done."); + // A line whose key marker was cut mid-token at the truncation seam still + // drops when the `nsk_` prefix survives; the function is whole-line by law. + expect(redactSshSetupKeyLines("")).toBe(""); + expect(redactSshSetupKeyLines("clean\nlines\nonly")).toBe("clean\nlines\nonly"); + }); +}); + describe("update command (protocol 10, spec 2026-09-15 §5.1)", () => { it("pins the wire shape as a LITERAL, because this one is frozen across protocol bumps", () => { // Every other command travels between two ends that agreed on diff --git a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts index 64a814c1b..4dd46babd 100644 --- a/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-results-ssh.test.ts @@ -2,11 +2,13 @@ import { describe, expect, test } from "bun:test"; import { parseNodeSshAgentIdentities, parseNodeSshAliasList, + parseNodeSshExecKick, + parseNodeSshExecStatus, parseNodeSshHostKey, parseNodeSshIdentity, parseNodeSshResolveOutcome, } from "../node-results.js"; -import { SSH_MAX_HOST_KEY_LINES } from "../ssh-limits.js"; +import { SSH_EXEC_RESULT_MAX_CHARS, SSH_MAX_HOST_KEY_LINES } from "../ssh-limits.js"; import { makeAliasList, makeResolveOk, makeResolveRefused } from "./fixtures/ssh-fixtures.js"; /** A well-formed public-JWK STRING: the validator's whole job is that it is JSON and an object. */ @@ -153,3 +155,59 @@ describe("parseNodeSshHostKey (spec 2026-10-08 §9, Task 12)", () => { ).not.toBeNull(); }); }); + +describe("parseNodeSshExecKick / parseNodeSshExecStatus (spec 2026-10-08 §7, Task 14)", () => { + const EXEC_ID = "11111111-2222-4333-8444-555555555555"; + test("the kick ack narrows to the started marker plus the id", () => { + expect(parseNodeSshExecKick({ started: true, execId: EXEC_ID })).toEqual({ started: true, execId: EXEC_ID }); + expect(parseNodeSshExecKick({ started: false, execId: EXEC_ID })).toBeNull(); + expect(parseNodeSshExecKick({ started: true })).toBeNull(); + expect(parseNodeSshExecKick({ started: true, execId: "bad id" })).toBeNull(); + expect(parseNodeSshExecKick("pong")).toBeNull(); + }); + test("the status answer narrows both states", () => { + expect(parseNodeSshExecStatus({ state: "running" })).toEqual({ state: "running" }); + expect( + parseNodeSshExecStatus({ state: "done", code: 0, timedOut: false, stdout: "==> done.\n", stderr: "" }), + ).toEqual({ state: "done", code: 0, timedOut: false, stdout: "==> done.\n", stderr: "" }); + // A signal-killed child reports a null code honestly. + expect(parseNodeSshExecStatus({ state: "done", code: null, timedOut: true, stdout: "", stderr: "" })).toEqual({ + state: "done", + code: null, + timedOut: true, + stdout: "", + stderr: "", + }); + }); + test("the status answer refuses malformed shapes", () => { + expect(parseNodeSshExecStatus({ state: "half" })).toBeNull(); + expect(parseNodeSshExecStatus({ state: "running", smuggled: 1 })).toBeNull(); // rebuilt from checked fields + expect(parseNodeSshExecStatus({ state: "done" })).toBeNull(); // done states ALL four facts + expect(parseNodeSshExecStatus({ state: "done", code: 0.5, timedOut: false, stdout: "", stderr: "" })).toBeNull(); + expect(parseNodeSshExecStatus({ state: "done", code: 0, stdout: "", stderr: "" })).toBeNull(); + expect( + parseNodeSshExecStatus({ state: "done", code: 0, timedOut: false, stdout: "a\x00b", stderr: "" }), + ).toBeNull(); // control chars other than newlines/tabs/CR are refused + expect( + parseNodeSshExecStatus({ + state: "done", + code: 0, + timedOut: false, + stdout: "x".repeat(SSH_EXEC_RESULT_MAX_CHARS + 1), + stderr: "", + }), + ).toBeNull(); + }); + test("the narrowed status answer drops unknown members (rebuild, not cast)", () => { + const parsed = parseNodeSshExecStatus({ + state: "done", + code: 1, + timedOut: false, + stdout: "s", + stderr: "e", + nodeKey: "leak?", + }); + expect(parsed).not.toHaveProperty("nodeKey"); + expect(parsed).toEqual({ state: "done", code: 1, timedOut: false, stdout: "s", stderr: "e" }); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index f336cbc4c..9461a05ac 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -157,6 +157,8 @@ export { parseNodePromptDeliver, parseNodeSshAgentIdentities, parseNodeSshAliasList, + parseNodeSshExecKick, + parseNodeSshExecStatus, parseNodeSshHostKey, parseNodeSshIdentity, parseNodeSshResolveOutcome, @@ -262,10 +264,13 @@ export { isSshKnownHostsPinLine, isSshPaneId, parseSshNodeCommandBody, + redactSshSetupKeyLines, SSH_COMMAND_TYPES, SSH_RELAY_CLOSE_REASONS, type SshAgentIdentitiesCommand, type SshDiscoverAliasesCommand, + type SshExecCommand, + type SshExecStatusCommand, type SshHostKeyCommand, type SshNodeCommandBody, type SshRegisterIdentityCommand, @@ -277,6 +282,11 @@ export { } from "./ssh-frames.js"; export { SSH_CONFIG_FILE_MAX_BYTES, + SSH_EXEC_COMMAND_MAX_CHARS, + SSH_EXEC_MAX_PRESET_FLAGS, + SSH_EXEC_RESULT_MAX_CHARS, + SSH_EXEC_RETAIN_BYTES, + SSH_EXEC_TIMEOUT_MAX_MS, SSH_MAX_DISCOVERED_ALIASES, SSH_MAX_GRANT_FINGERPRINTS, SSH_MAX_HOST_KEY_LINES, @@ -322,6 +332,8 @@ export type { NodeSshAgentIdentitiesResult, NodeSshAgentIdentity, NodeSshAliasListResult, + NodeSshExecKickResult, + NodeSshExecStatusResult, NodeSshHostKeyResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire, diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 251aa30c6..47c611e0e 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1996,6 +1996,8 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { case "ssh_host_key": case "ssh_relay_open": case "ssh_relay_close": + case "ssh_exec": + case "ssh_exec_status": // Delegation, not a second parser: the SSH grammar (all arms, one // file) lives in ssh-frames.ts beside the commands it narrows. return parseSshNodeCommandBody(value); diff --git a/packages/subshell-protocol/src/node-results.ts b/packages/subshell-protocol/src/node-results.ts index 020544239..1f8f3a934 100644 --- a/packages/subshell-protocol/src/node-results.ts +++ b/packages/subshell-protocol/src/node-results.ts @@ -23,12 +23,19 @@ import { BASE64_RE, isBool, isInt, isRecord, isStr, isStrArray, isStringMap } fr import { MAX_ARCHIVE_BYTES, MAX_MANIFEST_PAGE_ENTRIES } from "./node-frames.js"; import { parseSshConnectionSnapshot } from "./ssh-config.js"; import { isSshErrorCode } from "./ssh-errors.js"; -import { isSshGrantFingerprint, isSshKnownHostsPinLine } from "./ssh-frames.js"; -import { SSH_MAX_DISCOVERED_ALIASES, SSH_MAX_HOST_KEY_LINES, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; +import { isSshGrantFingerprint, isSshKnownHostsPinLine, isSshPaneId } from "./ssh-frames.js"; +import { + SSH_EXEC_RESULT_MAX_CHARS, + SSH_MAX_DISCOVERED_ALIASES, + SSH_MAX_HOST_KEY_LINES, + SSH_NAME_MAX_CHARS, +} from "./ssh-limits.js"; import type { NodeSshAgentIdentitiesResult, NodeSshAgentIdentity, NodeSshAliasListResult, + NodeSshExecKickResult, + NodeSshExecStatusResult, NodeSshHostKeyResult, NodeSshIdentityResult, NodeSshResolveOutcomeWire, @@ -661,3 +668,55 @@ export function parseNodeSshIdentity(data: unknown): NodeSshIdentityResult | nul } return { signingPublicKey: data.signingPublicKey }; } + +/** + * Validates and narrows an `ssh_exec` KICK's `result{data}` (spec 2026-10-08 + * §7, Task 14). The ack is two fields and no opinion: a machine that did not + * start answers `ok:false` (a refusal), never a `{started:false}`. + * @param data - the `data` member of a successful result frame + * @returns the narrowed ack, or null when malformed + */ +export function parseNodeSshExecKick(data: unknown): NodeSshExecKickResult | null { + if (!isRecord(data)) return null; + if (data.started !== true) return null; + if (!isSshPaneId(data.execId)) return null; + return { started: true, execId: data.execId }; +} + +/** + * Validates and narrows an `ssh_exec_status` answer (spec 2026-10-08 §7, + * Task 14). Two closed states, and `done` states ALL FOUR facts (a code, a + * timeout flag, and both streams): a machine answering half the outcome + * would let the plane guess which half the installer broke on. The streams + * are bounded printable text - newlines, tabs, and CR survive; every other + * control character refuses the answer (an escape sequence reaching a + * renderable field is not "output", and the plane re-redacts anyway). + * @param data - the `data` member of a successful result frame + * @returns the narrowed status, or null when malformed + */ +export function parseNodeSshExecStatus(data: unknown): NodeSshExecStatusResult | null { + if (!isRecord(data)) return null; + if (data.state === "running") { + // Running is running: nothing may ride it (a smuggled half-outcome would + // be exactly the partial truth the "done states all four" rule refuses). + if (Object.keys(data).length !== 1) return null; + return { state: "running" }; + } + if (data.state !== "done") return null; + if (!("code" in data) || !(data.code === null || isInt(data.code))) return null; + if (!isBool(data.timedOut)) return null; + if (!isExecStreamStr(data.stdout) || !isExecStreamStr(data.stderr)) return null; + return { + state: "done", + code: data.code === null ? null : (data.code as number), + timedOut: data.timedOut, + stdout: data.stdout, + stderr: data.stderr, + }; +} + +/** A captured stream: bounded text whose only control characters are newlines, tabs, and CR. */ +function isExecStreamStr(value: unknown): value is string { + if (!isStr(value) || value.length > SSH_EXEC_RESULT_MAX_CHARS) return false; + return !/\p{Cc}/u.test(value.replace(/[\n\r\t]/g, "")); +} diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index 0bda2cd7a..7e8db4301 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -19,7 +19,11 @@ * becomes the pin the relay-open carries to B), and the brokered pair * `ssh_relay_open` / `ssh_relay_close` (§5.1: the plane's OPEN hands one * machine the whole pairing plus the destination's host-key pin, the CLOSE - * names why it ends). What + * names why it ends), and the non-interactive setup-exec pair `ssh_exec` / + * `ssh_exec_status` (spec 2026-10-08 §7, Task 14: the "Set up Subshell here" + * act runs the rendered installer over its own short-lived `ssh` connection, + * never the pane, and the answer carries output only through the setup-key + * redactor). What * rides the link AFTER an open - the sealed blobs themselves - is not a * command at all: it is the `relay` link frame in `node-frames.ts`, which * carries no per-message signing because the link already authenticates. @@ -36,8 +40,17 @@ * data (the relay pair answer with a plain ack - no envelope to validate). */ -import { BASE64_RE, isInt, isRecord, isStr, isStrArray } from "./guards.js"; -import { SSH_MAX_GRANT_FINGERPRINTS, SSH_MAX_HOST_PIN_LINE_CHARS, SSH_NAME_MAX_CHARS } from "./ssh-limits.js"; +import { BASE64_RE, isBool, isInt, isRecord, isStr, isStrArray } from "./guards.js"; +import { + SSH_CONFIG_FILE_MAX_BYTES, + SSH_EXEC_COMMAND_MAX_CHARS, + SSH_EXEC_MAX_PRESET_FLAGS, + SSH_EXEC_TIMEOUT_MAX_MS, + SSH_MAX_GRANT_FINGERPRINTS, + SSH_MAX_HOST_PIN_LINE_CHARS, + SSH_NAME_MAX_CHARS, + SSH_PATH_MAX_CHARS, +} from "./ssh-limits.js"; /* ------------------------------------------------------------------ */ /* command bodies */ @@ -227,6 +240,57 @@ export interface SshRelayCloseCommand { reason: SshRelayCloseReason; } +/** + * The non-interactive setup exec (spec 2026-10-08 §7, Task 14): run + * `ssh -- ''` on THIS machine as one short- + * lived, output-capturing connection - the "Set up Subshell here" act's + * separate channel, NEVER typed into the interactive pane. The command string + * carries the minted setup key; every refusal and every captured byte on the + * answer side obeys the redaction rule ({@link redactSshSetupKeyLines}), so + * the key lands nowhere the pane, the pane log, or the plane's trail can + * read. + * + * The shape mirrors the `launch` frame's ssh member, with one sharper rule + * repeated: `configPath` is a CLAIM. The node re-derives + * `buildSshConfigPath(dataDir, execId)` from the exec id and refuses a + * single-byte mismatch, so a signed command can never name a config path + * outside the ephemeral per-act directory the machine's own sweeps own. + * + * `relay: true` is relay mode: the ssh child's `SSH_AUTH_SOCK` is the agent + * proxy socket the earlier `ssh_relay_open` bound at + * `buildAgentSocketPath(dataDir, execId)` - derived HERE, claimed by nothing, + * which is why a non-null `agentSocketPath` beside `relay: true` is a + * contradiction the grammar refuses. `relay: false` is M1's direct posture: + * the snapshot's own agent socket rides as `agentSocketPath` (the same scoped + * env the direct pane launch carries) or nothing does. + */ +export interface SshExecCommand { + type: "ssh_exec"; + /** The ephemeral act id (path-composition shape): names `/ssh//`. */ + execId: string; + /** The rendered-config path the plane derived; the node byte-checks its own. */ + configPath: string; + /** The rendered ssh config bytes (relay render: pinned, `StrictHostKeyChecking yes`). */ + fileContent: string; + /** The ssh option tokens through the destination: `-F … … -- host`. */ + presetFlags: string[]; + /** The remote one-liner (the installer command; the setup key lives HERE, transiently). */ + command: string; + /** True: authenticate through the exec's own relay proxy socket (relay mode). */ + relay: boolean; + /** Direct mode only: the snapshot's agent socket, or null; MUST be null when relay is true. */ + agentSocketPath: string | null; + /** The node-side hard deadline for the run; the plane's RPC deadline rides past it. */ + timeoutMs: number; +} + +/** Ask one machine for the state of a kicked `ssh_exec` (running, or done with its captured answer). */ +export interface SshExecStatusCommand { + type: "ssh_exec_status"; + /** The same ephemeral act id the kick carried. */ + execId: string; +} + /** Every SSH-family command body, as one union the {@link NodeCommandBody} union folds in. */ export type SshNodeCommandBody = | SshDiscoverAliasesCommand @@ -235,7 +299,9 @@ export type SshNodeCommandBody = | SshAgentIdentitiesCommand | SshHostKeyCommand | SshRelayOpenCommand - | SshRelayCloseCommand; + | SshRelayCloseCommand + | SshExecCommand + | SshExecStatusCommand; /** Every SSH command `type`, for census tests and dispatch tables. */ export const SSH_COMMAND_TYPES = [ @@ -246,6 +312,8 @@ export const SSH_COMMAND_TYPES = [ "ssh_host_key", "ssh_relay_open", "ssh_relay_close", + "ssh_exec", + "ssh_exec_status", ] as const; /* ------------------------------------------------------------------ */ @@ -471,7 +539,87 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu if (!(SSH_RELAY_CLOSE_REASONS as readonly string[]).includes(value.reason)) return null; return { type: "ssh_relay_close", ref: value.ref, reason: value.reason as SshRelayCloseReason }; } + case "ssh_exec": { + // Every field is required (the §7 redaction rule has no omitted half + // to paper over): the exec id takes the SAME path-composition shape + // the relay's paneId takes, the config member takes the launch frame's + // ssh rules verbatim (absolute bounded path, bounded content), the + // flag list and the command are the argv this machine will spawn, so + // they are bounded TEXT, control characters refused (a smuggled + // newline would be a second command on the destination), and the + // relay/own-mode contradiction is refused at the grammar rather than + // guessed by the executor. + if (!isSshPaneId(value.execId)) return null; + if ( + !isStr(value.configPath) || + !value.configPath.startsWith("/") || + value.configPath.length > SSH_PATH_MAX_CHARS + ) { + return null; + } + if (!isStr(value.fileContent) || value.fileContent.length > SSH_CONFIG_FILE_MAX_BYTES) return null; + if (!isStrArray(value.presetFlags) || value.presetFlags.length === 0) return null; + if (value.presetFlags.length > SSH_EXEC_MAX_PRESET_FLAGS) return null; + for (const flag of value.presetFlags) { + if (!isExecToken(flag)) return null; + } + if (!isStr(value.command) || value.command.length === 0) return null; + if (value.command.length > SSH_EXEC_COMMAND_MAX_CHARS) return null; + if (/\p{Cc}/u.test(value.command)) return null; // one printable line; a control char is a smuggled second command + if (!isBool(value.relay)) return null; + if (!("agentSocketPath" in value)) return null; + if (!(value.agentSocketPath === null || isAbsPathStr(value.agentSocketPath))) return null; + if (value.relay && value.agentSocketPath !== null) return null; // relay derives its own socket + if (!isInt(value.timeoutMs) || (value.timeoutMs as number) < 1) return null; + if ((value.timeoutMs as number) > SSH_EXEC_TIMEOUT_MAX_MS) return null; + return { + type: "ssh_exec", + execId: value.execId, + configPath: value.configPath, + fileContent: value.fileContent, + presetFlags: [...value.presetFlags], + command: value.command, + relay: value.relay, + agentSocketPath: value.agentSocketPath, + timeoutMs: value.timeoutMs as number, + }; + } + case "ssh_exec_status": { + // One path-shaped id selects the act; the answer's shape is + // `node-results.ts`'s business, and a stray member drops here. + if (!isSshPaneId(value.execId)) return null; + return { type: "ssh_exec_status", execId: value.execId }; + } default: return null; } } + +/** A preset flag: bounded, non-empty, whitespace-free, control-char-free. */ +function isExecToken(value: string): boolean { + return value.length > 0 && value.length <= SSH_NAME_MAX_CHARS && !/\s|\p{Cc}/u.test(value); +} + +/** An absolute path claim the grammar may pass on (the launch frame's ssh rule, restated). */ +function isAbsPathStr(value: unknown): value is string { + return isStr(value) && value.startsWith("/") && value.length <= SSH_PATH_MAX_CHARS; +} + +/** + * Drop every line of captured installer output that carries the setup-key + * marker (`nsk_`, the mint shape's fixed prefix). Whole lines go, never bytes + * spliced: a line that mentions the key at all is a line the pane's machine, + * the plane's retained copy, and every downstream surface must not have. + * Deliberately broader than the key grammar - ANY `nsk_` substring drops - + * because the rule's job is to survive the mint's format changing under it. + * The node applies it before retaining or answering with ANY captured byte; + * the plane applies it again as the belt that does not trust the machine's + * hygiene (the "never trust a machine answer" doctrine, applied to output). + */ +export function redactSshSetupKeyLines(text: string): string { + if (!text.includes("nsk_")) return text; + return text + .split("\n") + .filter((line) => !line.includes("nsk_")) + .join("\n"); +} diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts index ae52fdc62..854e5482a 100644 --- a/packages/subshell-protocol/src/ssh-limits.ts +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -205,3 +205,49 @@ export const SSH_MAX_HOST_KEY_LINES = 32; * the stream nothing but an event. */ export const SSH_SESSION_PUMP_CHUNK_BYTES = 192 * 1024; + +/* ------------------------------------------------------------------ */ +/* the non-interactive setup exec (spec 2026-10-08 §7, Task 14) */ +/* ------------------------------------------------------------------ */ + +/** + * Largest `ssh_exec` preset-flag token list. The compose emits `-F`, + * `BatchMode`, the port, `--`, and the destination: far short of this, and + * the bound is the grammar's, so a hostile plane cannot grow the argv the + * node spawns. + */ +export const SSH_EXEC_MAX_PRESET_FLAGS = 64; + +/** + * The remote one-liner an `ssh_exec` runs on the destination (the rendered + * `install.sh` one-liner with a minted setup key). One printable line - the + * grammar refuses control characters, so a smuggled second command is not + * representable. The cap bounds the text the destination's login shell + * parses; the real installer line is a few hundred characters. + */ +export const SSH_EXEC_COMMAND_MAX_CHARS = 8192; + +/** + * Upper bound on the node-side deadline of ONE non-interactive `ssh_exec` + * run. The act streams a node-binary download to the destination over the + * pane's own connection; ten minutes bounds the worst honest install, and a + * longer one is a refusal to the plane (which raises a fresh act), never an + * unbounded child. + */ +export const SSH_EXEC_TIMEOUT_MAX_MS = 600_000; + +/** + * Bytes of one captured stream the node RETAINS after the `nsk_` redaction + * (tail-first: the status verbs that end the installer's output are the + * lines the plane parses). Redaction runs BEFORE truncation, so no cut can + * leave a key's tail bytes behind. + */ +export const SSH_EXEC_RETAIN_BYTES = 8192; + +/** + * Longest captured stream one `ssh_exec_status` answer may carry (each of + * stdout and stderr, in characters). Comfortably above the node's own + * {@link SSH_EXEC_RETAIN_BYTES} tail; past it the answer is malformed, and + * the plane never sees a machine that simply did not cap itself. + */ +export const SSH_EXEC_RESULT_MAX_CHARS = 32_768; diff --git a/packages/subshell-protocol/src/ssh-results.ts b/packages/subshell-protocol/src/ssh-results.ts index cc233d19d..f04ccc3ef 100644 --- a/packages/subshell-protocol/src/ssh-results.ts +++ b/packages/subshell-protocol/src/ssh-results.ts @@ -116,3 +116,38 @@ export interface NodeSshHostKeyResult { /** Every `known_hosts` line matching the destination's lookup; verbatim, at most SSH_MAX_HOST_KEY_LINES. */ lines: string[]; } + +/** + * The ack of a kicked `ssh_exec` (spec 2026-10-08 §7, Task 14): the run was + * accepted and started off the command chain (an installer takes minutes; a + * chain-occupying executor would stall the machine the act is meant to leave + * untouched). The outcome arrives through {@link NodeSshExecStatusResult}. + */ +export interface NodeSshExecKickResult { + /** True is the ONLY legal value: a kick that did not start answers `ok:false`, not this shape. */ + started: true; + /** The act id echoed back, so a reply is matched to its kick by equality. */ + execId: string; +} + +/** + * The pollable state of one `ssh_exec` (Task 14). `running` says the child is + * alive and nothing is kept of it yet; `done` carries the terminal facts: the + * exit code (null when a signal or a failed spawn left none), whether the + * deadline ended it, and the installer's captured output AFTER the node's + * own `nsk_` redaction and tail truncation. The plane redacts again; what + * reaches the pane, the log, or the trail is never a machine's raw word. + */ +export type NodeSshExecStatusResult = + | { state: "running" } + | { + state: "done"; + /** The child's exit code; null on signal death or a spawn that never ran. */ + code: number | null; + /** True when the node-side deadline ended the run (the child is gone). */ + timedOut: boolean; + /** Captured stdout, redacted and tail-truncated ({@link SSH_EXEC_RETAIN_BYTES}). */ + stdout: string; + /** Captured stderr, redacted and tail-truncated ({@link SSH_EXEC_RETAIN_BYTES}). */ + stderr: string; + }; From a90c6968ed9fd895646c86e6fd32d2c2a7933d27 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:15:02 -0700 Subject: [PATCH 68/92] feat(ssh-setup-here): the destination upgrade service - mint, exec, redact, await ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 14's plane half. Migration 0052 stores the relay pane's key home on the subshell row (the 30 s relay session is plane-memory; the act re-opens it minutes later and §6.3 disambiguates A only by what the launch chose), with the row update-shape omitting the column: immutable after create. setupHere walks the launcher's own doors (gate B, gate A, A-online named BEFORE any key or session), mints the ordinary single-use setup key, composes EXACTLY like the pane launch (relay render, exec's own derived dir), and drives ssh_exec through B's real command chain, polling status to the terminal answer. Output is re-redacted plane-side and parsed into controlled stage verbs; the egress refusal is its own code (SSH_UPGRADE_EGRESS, named cause distinct from any key error, pane untouched - the act never writes one, pinned). Success is the new node's ready frame observed in a bounded wait through the key row's consumedNodeId and the registry's agent facts, never the installer's cheer line. Every exit writes one node.ssh_upgrade.run row naming ids, destination, grant, exec id, and the outcome word; the key appears nowhere in it, in any refusal, or in a returned byte (belt test: a machine answering its own redaction failure still leaks nothing). Unspent keys are revoked on the way out. --- .../src/api/__tests__/install-script.test.ts | 2 +- .../__tests__/backup-cli-subprocess.test.ts | 17 +- apps/server/api/src/db/migrate.ts | 5 + .../db/migrations/0052-subshell-key-home.ts | 22 + .../__tests__/0052-subshell-key-home.test.ts | 100 ++++ .../__tests__/repositories.test.ts | 2 + .../__tests__/workspaces-repository.test.ts | 2 + .../db/repositories/subshells.repository.ts | 3 + .../api/src/db/types/subshells.db-types.ts | 14 +- .../__tests__/ssh-setup-here.service.test.ts | 545 ++++++++++++++++++ .../subshell-manager-maintenance.test.ts | 2 + .../__tests__/subshell-manager-mcp.test.ts | 2 + .../__tests__/subshell-manager-remote.test.ts | 2 + .../subshell-manager-titling.test.ts | 2 + .../subshell-manager.service.test.ts | 2 + .../api/src/services/backups/database.ts | 2 +- apps/server/api/src/services/nodes/ssh-rpc.ts | 62 ++ .../api/src/services/ssh-launch.service.ts | 31 +- .../src/services/ssh-setup-here.service.ts | 466 +++++++++++++++ .../src/services/subshell-manager.service.ts | 10 + packages/backend-errors/src/error-codes.ts | 24 + 21 files changed, 1299 insertions(+), 18 deletions(-) create mode 100644 apps/server/api/src/db/migrations/0052-subshell-key-home.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0052-subshell-key-home.test.ts create mode 100644 apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts create mode 100644 apps/server/api/src/services/ssh-setup-here.service.ts diff --git a/apps/server/api/src/api/__tests__/install-script.test.ts b/apps/server/api/src/api/__tests__/install-script.test.ts index f28a8e50c..456e59417 100644 --- a/apps/server/api/src/api/__tests__/install-script.test.ts +++ b/apps/server/api/src/api/__tests__/install-script.test.ts @@ -53,7 +53,7 @@ beforeAll(async () => { afterAll(async () => { for (const id of keyIds) await repo.deleteById(id, userId).catch(() => {}); - await deleteUserByEmailOrId({ email }).catch(() => {}); + await deleteUserByEmailOrId(email).catch(() => {}); }); describe("install.sh 404 advice branch (spec 2026-10-08 §7)", () => { diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 20f49f0d0..0a559682e 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,21 +392,21 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. - // WHY 0047+0048+0049+0050+0051 together: this era's five migrations are the - // tail of the registered ledger, so the prefix runs contiguously through 0046 - // and boot re-applies all five. Advancing LATEST_BACKUP_MIGRATION (0051) + // WHY 0047+0048+0049+0050+0051+0052 together: this era's six migrations are + // the tail of the registered ledger, so the prefix runs contiguously through + // 0046 and boot re-applies all six. Advancing LATEST_BACKUP_MIGRATION (0052) // means the strip set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; DROP TABLE ssh_key_grants; DROP TABLE ssh_grant_requests; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_saved_hosts; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_key_grants; DROP TABLE ssh_grant_requests; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; CREATE TABLE ssh_key_grants(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, key_home_node_id TEXT NOT NULL, resolved_selector TEXT NOT NULL, fingerprints TEXT NOT NULL, created_via TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE ssh_grant_requests(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), key_home_node_id TEXT NOT NULL, resolved_selector TEXT NOT NULL, requested_fingerprints TEXT, pane_id TEXT NOT NULL, b_node_id TEXT NOT NULL, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); UPDATE migration_restore_probe SET value='newer destination';", + "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_saved_hosts(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, saved_at TEXT, last_connect_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; ALTER TABLE subshells ADD COLUMN key_home_node_id text; CREATE TABLE ssh_key_grants(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, key_home_node_id TEXT NOT NULL, resolved_selector TEXT NOT NULL, fingerprints TEXT NOT NULL, created_via TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE ssh_grant_requests(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), key_home_node_id TEXT NOT NULL, resolved_selector TEXT NOT NULL, requested_fingerprints TEXT, pane_id TEXT NOT NULL, b_node_id TEXT NOT NULL, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); UPDATE migration_restore_probe SET value='newer destination';", ); for (const row of migrationRows) db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home')", ) .all(), ).toEqual([]); @@ -430,7 +430,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home') ORDER BY name", ) .all(), ).toEqual([ @@ -439,6 +439,7 @@ test("real entry: available older upgrade snapshot restores and migrates before { name: "0049-ssh-relay-identity" }, { name: "0050-ssh-grants" }, { name: "0051-ssh-request-destination" }, + { name: "0052-subshell-key-home" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index d043abfdf..cc71e22fe 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -51,6 +51,7 @@ import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-h import * as sshRelayIdentityMigration from "@/db/migrations/0049-ssh-relay-identity.js"; import * as sshGrantsMigration from "@/db/migrations/0050-ssh-grants.js"; import * as sshRequestDestinationMigration from "@/db/migrations/0051-ssh-request-destination.js"; +import * as subshellKeyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; /** * Runs all pending Kysely migrations against the app database. @@ -154,6 +155,10 @@ export async function runMigrations(): Promise { // approval that creates the grant can capture the host-key pin at the // exact `user@host:port` the launch dialed (spec 2026-10-08 §9). "0051-ssh-request-destination": sshRequestDestinationMigration, + // The relay pane's key home (spec 2026-10-08 §7): "Set up Subshell + // here" re-opens the pairing that authorized the pane, so the row + // carries the A it launched with. + "0052-subshell-key-home": subshellKeyHomeMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0052-subshell-key-home.ts b/apps/server/api/src/db/migrations/0052-subshell-key-home.ts new file mode 100644 index 000000000..0c1a191ab --- /dev/null +++ b/apps/server/api/src/db/migrations/0052-subshell-key-home.ts @@ -0,0 +1,22 @@ +import type { Kysely } from "kysely"; + +/** + * The relay pane remembers its key home (spec 2026-10-08 §7, Task 14). A + * relay launch chose its key home A at the door that could name it, and the + * relay session itself is plane-memory with a 30 s lifetime; "Set up Subshell + * here" RE-OPENS that pairing minutes or hours later, and the only durable + * fact of which A authorized the pane was the launch's audit row. The act may + * not re-derive A from the grant table (several key homes can match one + * selector, and §6.3 disambiguates them by the A chosen at launch), so the + * pane row carries it: NULL on every direct (M1) pane, the A node id on every + * relay pane. Immutable by construction (it joins the omit-list of the row + * update shape), because no later act rewrites which machine authorized the + * connection it opened with. + */ +export async function up(db: Kysely): Promise { + await db.schema.alterTable("subshells").addColumn("key_home_node_id", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("subshells").dropColumn("key_home_node_id").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0052-subshell-key-home.test.ts b/apps/server/api/src/db/migrations/__tests__/0052-subshell-key-home.test.ts new file mode 100644 index 000000000..2ffee7b28 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0052-subshell-key-home.test.ts @@ -0,0 +1,100 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as initMigration from "@/db/migrations/0001-init.js"; +import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; +import * as profileDefaultFlagMigration from "@/db/migrations/0010-profile-default-flag.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as subshellRenameMigration from "@/db/migrations/0019-subshell-rename.js"; +import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + subshells: Record; + user: Record; +} + +/** + * Task 14's one storage change: `subshells` gains `key_home_node_id`, the + * relay pane's key home A - the fact "Set up Subshell here" needs to re-open + * the pairing that authorized this pane, minutes or hours after the 30 s + * relay session itself is gone. NULL on every direct pane; the re-open act + * names A-online as its own refusal cause, so the column must exist for that + * reading, not be re-derived from the grant table. + */ +describe("migration 0052-subshell-key-home", () => { + let dbFile: string; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0052-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ + dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }), + }); + await sql`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT)`.execute(db); + await sql`INSERT INTO user (id, name, email) VALUES ('u1', 'Owner', 'u1@test')`.execute(db); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + await subshellRenameMigration.up(db); + await sshLaunchMigration.up(db); + await keyHomeMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds key_home_node_id as a nullable column on the subshell row", async () => { + const cols = await sql<{ name: string; notnull: number }>` + SELECT name, "notnull" FROM pragma_table_info('subshells') + `.execute(db); + const names = cols.rows.map((r) => r.name); + expect(names).toContain("key_home_node_id"); + expect(cols.rows.find((r) => r.name === "key_home_node_id")?.notnull).toBe(0); // direct panes carry null + }); + + it("a row written without the column reads null; a relay row round-trips the A id", async () => { + await db + .insertInto("subshells") + .values({ + id: "pane-direct", + user_id: "u1", + profile_id: "p1", + harness_id: "ssh", + name: "pane-direct", + working_dir: "/tmp", + ssh: '{"host":"d.example.test"}', + } as never) + .execute(); + await db + .insertInto("subshells") + .values({ + id: "pane-relay", + user_id: "u1", + profile_id: "p1", + harness_id: "ssh", + name: "pane-relay", + working_dir: "/tmp", + ssh: '{"host":"d.example.test"}', + key_home_node_id: "node-a", + } as never) + .execute(); + const rows = await db.selectFrom("subshells").select(["id", "key_home_node_id"]).execute(); + expect(rows).toEqual( + expect.arrayContaining([ + { id: "pane-direct", key_home_node_id: null }, + { id: "pane-relay", key_home_node_id: "node-a" }, + ]), + ); + }); + + it("down() removes only the new column", async () => { + await keyHomeMigration.down(db); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('subshells')`.execute(db); + expect(cols.rows.map((r) => r.name)).not.toContain("key_home_node_id"); + }); +}); diff --git a/apps/server/api/src/db/repositories/__tests__/repositories.test.ts b/apps/server/api/src/db/repositories/__tests__/repositories.test.ts index 5f180feb7..e68417732 100644 --- a/apps/server/api/src/db/repositories/__tests__/repositories.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/repositories.test.ts @@ -15,6 +15,7 @@ import * as presetsMigration from "@/db/migrations/0027-presets.js"; import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as subshellKeyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { RecentPathsRepository } from "@/db/repositories/recent-paths.repository.js"; @@ -53,6 +54,7 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await subshellKeyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) }); beforeEach(async () => { diff --git a/apps/server/api/src/db/repositories/__tests__/workspaces-repository.test.ts b/apps/server/api/src/db/repositories/__tests__/workspaces-repository.test.ts index 373bd7788..e99bed474 100644 --- a/apps/server/api/src/db/repositories/__tests__/workspaces-repository.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/workspaces-repository.test.ts @@ -17,6 +17,7 @@ import * as workspaceDraftsMigration from "@/db/migrations/0029-workspace-drafts import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent.js"; import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; +import * as subshellKeyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -77,6 +78,7 @@ beforeAll(async () => { await crossAgentMigration.up(db); // subshells.cross_agent — SubshellsRepository.create writes it (2026-09-25) await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason + await subshellKeyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) }); beforeEach(async () => { diff --git a/apps/server/api/src/db/repositories/subshells.repository.ts b/apps/server/api/src/db/repositories/subshells.repository.ts index 7edb7f6de..9c8195d51 100644 --- a/apps/server/api/src/db/repositories/subshells.repository.ts +++ b/apps/server/api/src/db/repositories/subshells.repository.ts @@ -64,6 +64,9 @@ export class SubshellsRepository extends BaseRepository { // Node pin defaults in the DB (migration 0017); mirror it so the typed // insert is complete and the row reads back whole. nodeId: subshell.nodeId ?? LOCAL_NODE_ID, + // The relay key home (migration 0052); a direct pane's row carries the + // column's own NULL default. + keyHomeNodeId: subshell.keyHomeNodeId ?? null, // Cross-agent provenance defaults to "human" (migration 0039); mirror it. crossAgent: subshell.crossAgent ?? 0, createdAt: new Date().toISOString(), diff --git a/apps/server/api/src/db/types/subshells.db-types.ts b/apps/server/api/src/db/types/subshells.db-types.ts index aba033df6..b36b40e81 100644 --- a/apps/server/api/src/db/types/subshells.db-types.ts +++ b/apps/server/api/src/db/types/subshells.db-types.ts @@ -85,6 +85,15 @@ export interface SubshellTable { * answer included. See migration 0048. */ ssh: string | null; + /** + * For a relay-mode ssh pane: the key home whose agent signed the + * connection, chosen at launch and IMMUTABLE after (spec 2026-10-08 §7, + * migration 0052). "Set up Subshell here" re-opens that pairing to reach + * the destination again, and the 30 s relay session itself is plane-memory + * by then - this column is the pane's only durable word on which A + * authorized it. NULL on every direct (M1) pane. + */ + keyHomeNodeId: string | null; /** * DEPRECATED (spec 2026-09-03): the terminal history cap moved per-USER * (`user_meta.terminal_replay_lines`, migration 0020). This column is @@ -121,6 +130,7 @@ export type NewSubshell = Omit< | "nodeId" | "crossAgent" | "ssh" + | "keyHomeNodeId" | "terminalReplayLines" > & { /** Preset launched with; omitted = NULL (presetless launch). Defaults on insert. */ @@ -131,6 +141,8 @@ export type NewSubshell = Omit< crossAgent?: number; /** SSH snapshot JSON for an ssh pane; omitted = NULL = not an ssh pane (migration 0048) */ ssh?: string | null; + /** Relay pane's key home; omitted = NULL = direct (migration 0052) */ + keyHomeNodeId?: string | null; /** Per-subshell terminal replay cap; omitted = NULL = instance default */ terminalReplayLines?: number | null; harnessSessionId?: string | null; @@ -158,4 +170,4 @@ export type NewSubshell = Omit< * the restart refusal's trigger, and the delete sweep's pointer. No later * act may rewrite or clear it, so no update can name it. */ -export type SubshellUpdate = Partial>; +export type SubshellUpdate = Partial>; diff --git a/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts new file mode 100644 index 000000000..6c00f2580 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts @@ -0,0 +1,545 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { buildAgentSocketPath, buildSshConfigPath } from "@internal/pane-runtime"; +import type { SshConnectionSnapshotWire, SshExecCommand } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SshGrantsRepository } from "@/db/repositories/ssh-grants.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import type { NodeTable } from "@/db/types/nodes.db-types.js"; +import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; +import { setSshSetupHereDepsForTests, setupHere } from "@/services/ssh-setup-here.service.js"; +import { attachScriptedNode, SCRIPTED_DATA_DIR, type ScriptedNode } from "@/test-helpers/scripted-node.js"; + +/** + * The "Set up Subshell here" service (spec 2026-10-08 §7, Task 14). What + * this suite exists to pin, the brief's five arms first: + * + * (b) a non-egressing D answers the NAMED egress cause (its own code) and + * the working pane row is untouched - this act never writes one; + * (c) relay mode RE-OPENS the relay (the broker sees a fresh pairing keyed + * to the exec's ephemeral id, not the pane's), and an A that is offline + * refuses naming the key home's online-ness before any key or session + * exists; + * (d) success is the new node's `ready` (the registry fact) observed inside + * the bounded wait, not the installer's cheer line; + * (e) a captured line containing `nsk_` never reaches the returned answer, + * any refusal copy, or the `node.ssh_upgrade.run` audit row. + * + * B answers over the REAL sendCommand chain through the scripted node (the + * emitted frames pass the real wire grammar); the relay broker is the fake + * the grants suite uses; every wait runs on injected seams. The key the test + * recovers from the exec command string is the mint's plaintext - which is + * what makes the leak assertions mean something. + */ + +const HOST = "d.example.test"; +const PIN_LINE = "d.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI00000000000000000000000000000000000000000"; +const DEST = sshCanonicalDestination({ host: HOST, port: 22, user: null }); +const SNAP = (over: Partial = {}): SshConnectionSnapshotWire => ({ + alias: HOST, + host: HOST, + user: null, + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + forwards: null, + tunnels: null, + localCommands: null, + remoteCommand: null, + sendEnv: null, + setEnv: null, + escapes: null, + ...over, +}); + +const emails: string[] = []; +const createdPanes: string[] = []; +const createdNodes: string[] = []; +const nodes = new NodesRepository(db); +const keys = new NodeSetupKeysRepository(db); +let owner = ""; +let stranger = ""; +const NODE_A = "setup-node-a"; +const NODE_B = "setup-node-b"; + +/** The relay broker fake: opens recorded, closes recorded, socket answer derived. */ +let openCalls: { paneId: string; aNode: string; bNode: string; grantId: string; hostPin: string }[] = []; +let closeCalls: { paneId: string; reason: string }[] = []; +let fakeBroker: RelayBroker; + +function makeFakeBroker(): RelayBroker { + return { + openRelay: async (input: { paneId: string; aNode: string; bNode: string; grantId: string; hostPin: string }) => { + openCalls.push(input); + return { + relayId: `relay-${openCalls.length}`, + ref: `ref-${openCalls.length}`, + expiresAt: new Date(Date.now() + 30_000).toISOString(), + // In-contract answer: the plane byte-checks this against the same + // derivation the node's relay-open bound. + socketPath: buildAgentSocketPath(SCRIPTED_DATA_DIR, input.paneId), + }; + }, + closeForPane: async (paneId: string, reason: string) => { + closeCalls.push({ paneId, reason }); + return 1; + }, + } as unknown as RelayBroker; +} + +function installDeps(): void { + setSshSetupHereDepsForTests({ + nowMs: () => Date.now(), + sleepMs: () => new Promise((r) => setTimeout(r, 1)), + execTimeoutMs: 600_000, + execPollMarginMs: 50, + readyBudgetMs: 1_000, + pollMs: 1, + broker: () => fakeBroker, + }); +} + +async function mkNode(id: string): Promise { + await nodes.create({ id, ownerUserId: owner, name: id, kind: "agent", status: "offline" }); + createdNodes.push(id); + await nodes.setSshEnabled(id, { on: true, changedAt: "2026-10-08T09:00:00.000Z" }); + await db + .updateTable("nodes") + .set({ + inventoryJson: JSON.stringify([{ harnessId: "ssh", installed: true, binaryPath: "/usr/bin/ssh", version: "1" }]), + inventoryAt: new Date().toISOString(), + } as never) + .where("id", "=", id) + .execute(); + return (await nodes.findById(id)) as NodeTable; +} + +async function mkPane(opts: { keyHome: boolean; ssh?: string | null }): Promise { + const id = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id, + userId: owner, + harnessId: "ssh", + name: `pane-${id.slice(0, 8)}`, + workingDir: "/home/scripted", + tmuxSocket: null, + nodeId: NODE_B, + // `??` would read a deliberate null as absent; the test's null IS the value. + ssh: opts.ssh === undefined ? JSON.stringify(SNAP()) : opts.ssh, + keyHomeNodeId: opts.keyHome ? NODE_A : null, + }); + createdPanes.push(id); + return id; +} + +/** The standing grant + pin + identities every relay leg reads (fixture, once). */ +async function authorizeRelay(): Promise { + const now = new Date().toISOString(); + await new SshGrantsRepository(db).insertGrant({ + id: crypto.randomUUID(), + ownerUserId: owner, + name: HOST, + keyHomeNodeId: NODE_A, + resolvedSelector: HOST, + fingerprints: JSON.stringify(["SHA256:AAAA"]), + createdVia: "manual", + createdAt: now, + updatedAt: now, + }); + await db + .insertInto("sshHostPins") + .values({ + id: crypto.randomUUID(), + ownerUserId: owner, + destination: DEST, + hostKey: PIN_LINE, + createdAt: now, + updatedAt: now, + }) + .execute(); + const identities = new IdentitiesRepository(db); + for (const [n, half] of [ + [NODE_A, "AAA"], + [NODE_B, "EEE"], + ] as const) { + await identities.register({ + principalId: `node:${n}`, + publicKey: `{"kty":"EC","crv":"P-256","x":"${half}","y":"${half}"}`, + signingPublicKey: `{"kty":"EC","crv":"P-256","x":"${half}","y":"${half}"}`, + displayName: n, + }); + } +} + +/** The key the act minted, recovered from the exec command the scripted machine saw. */ +function mintedKey(cmd: SshExecCommand | undefined): string { + const m = cmd?.command.match(/setup_key=(nsk_[A-Za-z0-9_-]{32})/); + if (!m) throw new Error("the exec command carried no minted key"); + return m[1]; +} + +async function upgradeRows(): Promise<{ action: string; metadataJson: string | null }[]> { + return await db + .selectFrom("auditEvents") + .select(["action", "metadataJson"]) + .where("action", "=", "node.ssh_upgrade.run") + .execute(); +} + +/** + * Attach the scripted machines: B answers the two exec arms from the caller's + * controller (kick recorded for the status arm to read back). A is attached + * silently ONLY when the test wants a live key home. + */ +function attachPair(impl: { + onKick?: (cmd: SshExecCommand) => void | Promise; + status: (kick: SshExecCommand | undefined) => unknown; + withA: boolean; +}): { b: ScriptedNode; a: ScriptedNode | null; kick: () => SshExecCommand | undefined } { + let seen: SshExecCommand | undefined; + const a = impl.withA ? attachScriptedNode(NODE_A, {}) : null; + const b = attachScriptedNode(NODE_B, { + ssh_exec: async (cmd) => { + if (cmd.type !== "ssh_exec") throw new Error("wrong arm"); + seen = cmd; + await impl.onKick?.(cmd); + return { started: true, execId: cmd.execId }; + }, + ssh_exec_status: (cmd) => { + if (cmd.type !== "ssh_exec_status") throw new Error("wrong arm"); + return impl.status(seen); + }, + }); + return { b, a, kick: () => seen }; +} + +/** The install's success answer as the scripted machine states it. */ +const DONE_OK = { state: "done", code: 0, timedOut: false, stdout: "==> done.\n", stderr: "" } as const; + +/** Stand-in for D's enrollment completing: spend the minted key, bring D's `ready` up. */ +async function enrollD(key: string): Promise<{ id: string; handle: ScriptedNode }> { + const id = crypto.randomUUID(); + await nodes.create({ id, ownerUserId: owner, name: `d-${id.slice(0, 8)}`, kind: "agent", status: "offline" }); + const spent = await keys.consume(key, id); + if (!spent) throw new Error("the fixture could not consume the minted key"); + return { id, handle: attachScriptedNode(id, {}) }; +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + const mk = async (email: string): Promise => { + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("setup-here-1"), + role: "user", + }); + }; + owner = await mk(`setup-owner-${crypto.randomUUID()}@subshell.local`); + stranger = await mk(`setup-other-${crypto.randomUUID()}@subshell.local`); + await mkNode(NODE_A); + await mkNode(NODE_B); + await authorizeRelay(); +}); + +afterAll(async () => { + setSshSetupHereDepsForTests(null); + setRelayBrokerForTests(null); + resetNodeRegistryForTests(); + for (const id of createdPanes) await new SubshellsRepository(db).delete(id).catch(() => {}); + await db.deleteFrom("sshHostPins").execute(); + await db.deleteFrom("sshKeyGrants").execute(); + await db.deleteFrom("sshGrantRequests").execute(); + await db.deleteFrom("identities").execute(); + await db.deleteFrom("nodeSetupKeys").execute(); + await db.deleteFrom("auditEvents").where("action", "=", "node.ssh_upgrade.run").execute(); + for (const id of createdNodes) await nodes.deleteById(id).catch(() => {}); + for (const e of emails) await deleteUserByEmailOrId(e).catch(() => {}); +}); + +beforeEach(() => { + openCalls = []; + closeCalls = []; + fakeBroker = makeFakeBroker(); + setRelayBrokerForTests(fakeBroker); + installDeps(); +}); + +/** Each act's trail row is consumed by the test that wrote it; start clean. */ +async function clearUpgradeRows(): Promise { + await db.deleteFrom("auditEvents").where("action", "=", "node.ssh_upgrade.run").execute(); +} + +describe("setupHere door order (pane first, gates before any key)", () => { + it("a foreign or absent pane is the 404 the ownership axis demands", async () => { + const paneId = await mkPane({ keyHome: true }); + const foreign = await setupHere({ viewerId: stranger, paneId }); + expect(foreign.ok).toBe(false); + if (foreign.ok) return; + expect(foreign.refusal.status).toBe(404); + const absent = await setupHere({ viewerId: owner, paneId: crypto.randomUUID() }); + expect(absent.ok).toBe(false); + if (absent.ok) return; + expect(absent.refusal.status).toBe(404); + }); + + it("a non-ssh pane refuses by name", async () => { + const paneId = await mkPane({ keyHome: false, ssh: null }); + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(res.refusal.message).toContain("SSH-terminal pane"); + }); +}); + +describe("setupHere relay mode", () => { + it("a non-egressing D answers the NAMED egress cause and leaves the pane untouched", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + const keysBefore = (await keys.listByUser(owner)).length; + const pair = attachPair({ + withA: true, + status: () => ({ + state: "done", + code: 1, + timedOut: false, + stdout: "", + stderr: + "subshell: could not reach http://plane.invalid; nothing was installed (/home/u/.local/bin/subshell untouched).", + }), + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(res.refusal.status).toBe(409); + if (res.refusal.status !== 409) return; + expect(res.refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + expect(res.refusal.message).toContain("could not reach"); + expect(res.refusal.message).toContain("untouched"); + // The working pane: the row is unchanged, and B saw ONLY the exec arms + // - no input, no terminate, no launch, no touch of any kind. + const row = await new SubshellsRepository(db).findById(paneId); + expect(row?.alive).toBe(1); + expect(row?.status).toBe("running"); + expect(pair.b.cmdTypes()).toEqual(["ssh_exec", "ssh_exec_status"]); + // The relay was opened once and closed with the named child-exit word. + expect(openCalls).toHaveLength(1); + expect(closeCalls).toEqual([{ paneId: pair.kick()?.execId, reason: "child-exit" }]); + // The unspent key is revoked on the way out; the trail names the cause. + expect((await keys.listByUser(owner)).length).toBe(keysBefore); + const audits = await upgradeRows(); + expect(audits).toHaveLength(1); + expect(audits[0]?.metadataJson).toContain('"cause":"egress"'); + expect(audits[0]?.metadataJson).not.toContain("nsk_"); + } finally { + pair.a?.detach(); + pair.b.detach(); + } + }); + + it("an offline A refuses naming the key home online, before any key or relay exists", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + const keysBefore = (await keys.listByUser(owner)).length; + // A has NO scripted connection: getLive(A) is null, the offline class. + const pair = attachPair({ withA: false, status: () => DONE_OK }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(res.refusal.code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); + expect(res.refusal.message).toContain(NODE_A); + expect(res.refusal.message).toMatch(/online/i); + expect(openCalls).toHaveLength(0); // never re-opened + expect(pair.b.cmdTypes()).toEqual([]); // B was never even asked to exec + expect((await keys.listByUser(owner)).length).toBe(keysBefore); // no key minted + } finally { + pair.b.detach(); + } + }); + + it("success is the new node's ready: the relay re-opens under the exec id, the bounded wait observes the fact", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + let polls = 0; + let d: { id: string; handle: ScriptedNode } | null = null; + const pair = attachPair({ + withA: true, + onKick: (cmd) => { + // D's enrollment lands a beat AFTER the kick, while the act is + // already polling: the wait observes it, no test-side sleep races. + void enrollD(mintedKey(cmd)).then((r) => { + d = r; + }); + }, + status: () => { + polls += 1; + // Keep the exec "running" for two polls; the ready wait then finds + // the spend + the live `ready` facts through the service's own loop. + if (polls <= 2) return { state: "running" }; + return DONE_OK; + }, + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(true); + if (!res.ok) return; + const nodeId = (d as { id: string; handle: ScriptedNode } | null)?.id ?? ""; + expect(nodeId).not.toBe(""); + expect(res.value).toEqual({ nodeId }); + // The relay was RE-OPENED for this act, keyed to the exec's ephemeral + // id (never the pane's), carrying the destination's pin; closed with + // the named word once the exec's child was gone. + const execId = pair.kick()?.execId; + expect(execId).toBeDefined(); + expect(execId).not.toBe(paneId); + expect(openCalls).toHaveLength(1); + expect(openCalls[0]?.paneId).toBe(execId); + expect(openCalls[0]?.aNode).toBe(NODE_A); + expect(openCalls[0]?.bNode).toBe(NODE_B); + expect(openCalls[0]?.hostPin).toBe(PIN_LINE); + expect(closeCalls).toEqual([{ paneId: execId, reason: "child-exit" }]); + // The composed run: the exec's OWN config dir, BatchMode first, the + // installer one-liner with the destination host as the scripted name. + const kick = pair.kick(); + expect(kick?.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, kick.execId)); + expect(kick?.presetFlags.slice(0, 2)).toEqual(["-o", "BatchMode=yes"]); + expect(kick?.presetFlags).toContain("--"); + expect(kick?.relay).toBe(true); + expect(kick?.agentSocketPath).toBeNull(); + expect(kick?.command).toContain(`install.sh?setup_key=${mintedKey(kick)}`); + expect(kick?.command).toContain("SUBSHELL_NODE_NAME="); + expect(kick?.command).toContain(HOST); + expect(polls).toBeGreaterThanOrEqual(3); + // Audit: exactly one enrolled row naming ids, never the key. + const audits = await upgradeRows(); + expect(audits).toHaveLength(1); + const meta = JSON.parse(audits[0]?.metadataJson ?? "{}") as Record; + expect(meta).toMatchObject({ + paneId, + bNodeId: NODE_B, + aNodeId: NODE_A, + destination: DEST, + outcome: "enrolled", + cause: "ready", + newNodeId: nodeId, + }); + expect(audits[0]?.metadataJson).not.toContain("nsk_"); + expect(JSON.stringify(res)).not.toContain("nsk_"); + d?.handle.detach(); + await nodes.deleteById(nodeId).catch(() => {}); + } finally { + d?.handle.detach(); + pair.a?.detach(); + pair.b.detach(); + } + }); + + it("a status answer carrying the minted key leaks NOTHING: belt redaction covers refusal, response, and trail", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + const pair = attachPair({ + withA: true, + status: (kick) => ({ + state: "done", + code: 1, + timedOut: false, + // The machine ANSWERS with its own hygiene failing: the plane's belt + // must hold anyway - no `nsk_` byte is kept, returned, or repeated. + stdout: `KEY="${mintedKey(kick)}" here it is\nsubshell: the install exploded`, + stderr: "", + }), + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + const key = mintedKey(pair.kick()); + const serialized = JSON.stringify(res); + expect(serialized).not.toContain("nsk_"); + expect(serialized).not.toContain(key); + const audits = await upgradeRows(); + expect(audits).toHaveLength(1); + expect(audits[0]?.metadataJson).not.toContain("nsk_"); + expect(audits[0]?.metadataJson).not.toContain(key); + expect(audits[0]?.metadataJson).toContain("install-exit-1"); + } finally { + pair.a?.detach(); + pair.b.detach(); + } + }); + + it("install finished but the key never enrolled: named failure, key revoked", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: true }); + const keysBefore = (await keys.listByUser(owner)).length; + const pair = attachPair({ withA: true, status: () => DONE_OK }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(res.refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_FAILED); + expect(res.refusal.message).toContain("without enrolling"); + expect((await keys.listByUser(owner)).length).toBe(keysBefore); + } finally { + await clearUpgradeRows(); + pair.a?.detach(); + pair.b.detach(); + } + }); +}); + +describe("setupHere direct mode (B's own keys, no relay)", () => { + it("a direct pane's exec carries no relay, and the snapshot's socket is the one scoped key", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ + keyHome: false, + ssh: JSON.stringify(SNAP({ authAgentSocket: "/run/user/700/agent.sock" })), + }); + let d: { id: string; handle: ScriptedNode } | null = null; + const pair = attachPair({ + withA: false, // direct: A must NEVER be part of this act + onKick: (cmd) => { + void enrollD(mintedKey(cmd)).then((r) => { + d = r; + }); + }, + status: () => DONE_OK, + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(true); + if (!res.ok) return; + const kick = pair.kick(); + expect(kick?.relay).toBe(false); + expect(kick?.agentSocketPath).toBe("/run/user/700/agent.sock"); + expect(kick?.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, kick.execId)); + // The direct pane's exec never touched the relay broker, and A's live + // absence was irrelevant to it (no key-home gate was walked). + expect(openCalls).toHaveLength(0); + expect(closeCalls).toHaveLength(0); + d?.handle.detach(); + await nodes.deleteById(d?.id ?? "").catch(() => {}); + } finally { + await clearUpgradeRows(); + pair.b.detach(); + } + }); +}); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts index 7529a2151..8aa336e65 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-maintenance.test.ts @@ -25,6 +25,7 @@ import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent. import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; @@ -187,6 +188,7 @@ beforeAll(async () => { await presetLaunchFieldsMigration.up(dbHandle); // presets launch trio (0042) - repository create enumerates it await presetCrossCommOptInMigration.up(dbHandle); // presets.cross_comm_enabled (0043) - same reason await sshLaunchMigration.up(dbHandle); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(dbHandle); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presetsRepo = new PresetsRepository(dbHandle); subshellsRepo = new SubshellsRepository(dbHandle); presetId = await seedPreset(presetsRepo); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts index 69ead9c15..df4fdfed5 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts @@ -23,6 +23,7 @@ import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent. import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -147,6 +148,7 @@ beforeAll(async () => { await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason await sshLaunchMigration.up(db); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presets = new PresetsRepository(db); subshells = new SubshellsRepository(db); presetId = await seedPreset(presets); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts index f05409fbc..0ff6b6c89 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-remote.test.ts @@ -21,6 +21,7 @@ import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent. import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -89,6 +90,7 @@ beforeAll(async () => { await presetLaunchFieldsMigration.up(dbHandle); // presets launch trio (0042) - repository create enumerates it await presetCrossCommOptInMigration.up(dbHandle); // presets.cross_comm_enabled (0043) - same reason await sshLaunchMigration.up(dbHandle); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(dbHandle); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presetsRepo = new PresetsRepository(dbHandle); subshellsRepo = new SubshellsRepository(dbHandle); presetId = await seedPreset(presetsRepo); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts index d25377ae8..3e627248c 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts @@ -23,6 +23,7 @@ import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent. import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -116,6 +117,7 @@ beforeAll(async () => { await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason await sshLaunchMigration.up(db); // 0048 subshells.ssh - SubshellsRepository.create writes it on every create now; the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presets = new PresetsRepository(db); subshells = new SubshellsRepository(db); presetId = await seedPreset(presets); diff --git a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts index 72802b5f2..a518b7020 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts @@ -24,6 +24,7 @@ import * as crossAgentMigration from "@/db/migrations/0039-subshell-cross-agent. import * as presetLaunchFieldsMigration from "@/db/migrations/0042-preset-launch-fields.js"; import * as presetCrossCommOptInMigration from "@/db/migrations/0043-preset-cross-comm-opt-in.js"; import * as sshLaunchMigration from "@/db/migrations/0048-ssh-launch-and-saved-hosts.js"; +import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import { openSqliteDatabase } from "@/db/open-database.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -156,6 +157,7 @@ beforeAll(async () => { await presetLaunchFieldsMigration.up(db); // presets launch trio (0042) - the repository create enumerates it await presetCrossCommOptInMigration.up(db); // presets.cross_comm_enabled (0043) - same reason await sshLaunchMigration.up(db); // 0048 subshells.ssh — SubshellsRepository.create writes it on EVERY create now (null for a non-ssh pane), and the delete sweep reads it as the kind fact (plan-2 Task 7) + await keyHomeMigration.up(db); // 0052 subshells.key_home_node_id - SubshellsRepository.create mirrors it on every insert (Task 14) presetsRepo = new PresetsRepository(db); subshellsRepo = new SubshellsRepository(db); subshellManager = new SubshellManagerService({ diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 9c23198d3..0b8dc26f9 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0051-ssh-request-destination"; +export const LATEST_BACKUP_MIGRATION = "0052-subshell-key-home"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/services/nodes/ssh-rpc.ts b/apps/server/api/src/services/nodes/ssh-rpc.ts index b5534b9e7..dce984a87 100644 --- a/apps/server/api/src/services/nodes/ssh-rpc.ts +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -1,10 +1,14 @@ import { type NodeSshAgentIdentitiesResult, type NodeSshAliasListResult, + type NodeSshExecKickResult, + type NodeSshExecStatusResult, type NodeSshHostKeyResult, type NodeSshResolveOutcomeWire, parseNodeSshAgentIdentities, parseNodeSshAliasList, + parseNodeSshExecKick, + parseNodeSshExecStatus, parseNodeSshHostKey, parseNodeSshIdentity, parseNodeSshResolveOutcome, @@ -180,3 +184,61 @@ export async function sshAgentIdentities(nodeId: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_exec", ...args }, { timeoutMs: 30_000 }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshExecKick(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed exec ack`, nodeId); + } + return parsed; +} + +/** + * Poll one machine for the state of a kicked setup run. The answer's streams + * are the machine's OWN redacted capture (Task 14's node-side rule); the + * CALLER redacts again before keeping or speaking a word of them - a machine + * answer about its own hygiene is never trusted (the socket-path doctrine, + * applied to output). + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshExecStatus(nodeId: string, execId: string): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, { type: "ssh_exec_status", execId }); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshExecStatus(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed exec status`, nodeId); + } + return parsed; +} diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index f6d22f13d..c9e2ba8a0 100644 --- a/apps/server/api/src/services/ssh-launch.service.ts +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -216,8 +216,13 @@ export function sweepLocalSshDir(row: Pick> { +/** + * The acting user's SSH-usable gate on one node, or the refusal that closes + * the request. Exported because the §7 setup-here act re-runs EXACTLY this + * door (B and A alike) before it touches either machine - one gate, one + * spelling of every cause. + */ +export async function gateSshNode(viewerId: string, nodeId: string): Promise> { // Invisible and absent collapse to one 404 (the node-gate doctrine: an // invisible node must never answer 403, so ids cannot be probed). const gate = await loadNodeGate(viewerId, nodeId); @@ -256,8 +261,13 @@ function gateCause(row: NodeTable, serverAccountEnabled: boolean): string { return `Subshell SSH on ${machine} is reserved to its owner; a shared machine stays the owner's to SSH from.`; } -/** The machine refused or could not answer; the code family, never the agent's own text. */ -function rpcRefusal(err: SshRpcError): SshAnswer { +/** + * The machine refused or could not answer; the code family, never the + * agent's own text. Exported for the §7 setup-here act, whose ssh_exec RPCs + * must map failures through the SAME doors (spec §12's naming doctrine: one + * cause, one sentence, wherever it was reached). + */ +export function rpcRefusal(err: SshRpcError): SshAnswer { if (err.kind === "offline") { return codedRefusal( 409, @@ -339,8 +349,8 @@ async function nodeResolve(row: NodeTable, destination: string): Promise'` that reuses the pane's authorization and leaves the + * pane itself untouched. The plan, in the order it runs: + * + * 1. THE PANE: owner's row (a foreign id is the 404 the ownership axis + * demands), its approved snapshot (D), and its key home column (0052): the + * pane was a relay pane exactly when it names an A. + * 2. THE GATES: B re-gated through the launcher's own door; in relay mode A + * too, AND live - the act signs with A's agent, so an offline key home is + * refused with A-online NAMED before a key is minted or a pane is touched. + * 3. THE KEY: a fresh single-use setup key, minted by the ordinary + * repository, owned by the caller (the new node's future owner). It is + * never typed anywhere a pane or a log can read: it rides the signed + * node-link command, then B's short-lived ssh argv, then D's install URL - + * the three accepted postures §7 names - and nothing else. The act's + * refusal copy, its audit rows, and every retained byte pass through the + * redactor (belt on the plane: the machine's own redaction is the first + * station, this one does not trust it). + * 4. THE RUN: `ssh_exec` on B - compose exactly like the pane launch + * (relay-mode render pins D's host key the same way), kick it off B's + * command chain, and poll `ssh_exec_status` to the terminal answer. The + * answer's output is PARSED into a controlled status verb; raw installer + * output is never echoed to a caller. + * 5. THE EGRESS CAUSE: an install that could not reach the plane answers + * `SSH_UPGRADE_EGRESS` - a code of its own, distinct from any key error, + * per §7. The pane is untouched either way (this act never writes one). + * 6. SUCCESS: the new node's `ready` frame, awaited with a bounded wait + * (the setup key's row carries the node id enrollment consumed it for; + * the registry's `agent` facts appear only when `ready` lands). Success + * is that fact, not the installer's own cheer line. + * + * Every exit writes ONE `node.ssh_upgrade.run` audit row naming ids, the + * destination, the grant, and the OUTCOME/stage - never the key, never the + * installer's words. + */ + +/** How long B's ssh child may run before the node-side deadline ends it. An installer pulls a ~100 MB binary; this is minutes, honestly. */ +export const SSH_SETUP_EXEC_TIMEOUT_MS = 8 * 60_000; + +/** The plane's poll margin past the node's own deadline (the deadline kills the child; the margin only decides when we stop asking). */ +const EXEC_POLL_MARGIN_MS = 60_000; + +/** The bounded wait for the new node to enroll (spend the key) and report `ready` after the installer exited 0. */ +export const SETUP_READY_BUDGET_MS = 60_000; + +/** The poll cadence for both waits; tests collapse it to zero with the injected clock. */ +const SETUP_POLL_MS = 1_500; + +/** The service's world. Production at {@link defaultDeps}; tests install a fake. */ +export interface SshSetupHereDeps { + /** Epoch-ms clock driving both bounded waits. */ + nowMs(): number; + /** Sleep between polls (production: real timers). */ + sleepMs(ms: number): Promise; + /** The node's ssh child deadline for this act (production: {@link SSH_SETUP_EXEC_TIMEOUT_MS}). */ + execTimeoutMs: number; + /** The plane's poll margin past that deadline (production: {@link EXEC_POLL_MARGIN_MS}). */ + execPollMarginMs: number; + /** The enroll+ready budget after a clean install (production: {@link SETUP_READY_BUDGET_MS}). */ + readyBudgetMs: number; + /** Poll cadence (production: {@link SETUP_POLL_MS}). */ + pollMs: number; + /** The relay broker the act's own pairing is closed through. */ + broker(): RelayBroker; +} + +let depsOverride: SshSetupHereDeps | null = null; + +function setupDeps(): SshSetupHereDeps { + if (depsOverride) return depsOverride; + return { + nowMs: () => Date.now(), + sleepMs: (ms) => new Promise((r) => setTimeout(r, ms)), + execTimeoutMs: SSH_SETUP_EXEC_TIMEOUT_MS, + execPollMarginMs: EXEC_POLL_MARGIN_MS, + readyBudgetMs: SETUP_READY_BUDGET_MS, + pollMs: SETUP_POLL_MS, + broker: () => getRelayBroker(), + }; +} + +/** + * Install (or with null, drop) the whole seam. + * @internal test-only - no suite waits eight minutes for a child or sixty + * seconds for a `ready` frame. + */ +export function setSshSetupHereDepsForTests(deps: SshSetupHereDeps | null): void { + depsOverride = deps; +} + +function coded(status: 400 | 403 | 404 | 409 | 502, code: BackendErrorCodes, message: string): SshAnswer { + return { ok: false, refusal: { status, code, message } }; +} + +/** What a completed act answers: the enrolled node's id, learned from `ready`. */ +export interface SetupHereResult { + nodeId: string; +} + +/** + * Run the act for one pane. The caller is the pane's owner (a cookie actor; + * a foreign or absent pane is one 404, the ordinary invisibility). Refusals + * name their stage; the key never rides one. + */ +export async function setupHere(args: { viewerId: string; paneId: string }): Promise> { + const deps = setupDeps(); + const pane = await db.selectFrom("subshells").selectAll().where("id", "=", args.paneId).executeTakeFirst(); + if (!pane || pane.userId !== args.viewerId) { + return coded(404, BackendErrorCodes.NOT_FOUND_ERROR, "Subshell not found"); + } + if (pane.ssh === null) { + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "Setting up Subshell here is an act on an SSH-terminal pane; this pane is not one.", + ); + } + let snapshot: SshConnectionSnapshotWire | null = null; + try { + snapshot = parseSshConnectionSnapshot(JSON.parse(pane.ssh) as unknown); + } catch { + snapshot = null; + } + if (snapshot === null) { + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "This pane's approved destination could not be read back from its row; open the connection again and run the act fresh.", + ); + } + + // Gates first (B, then A in relay mode): every refusal below writes no key, + // opens no session, and leaves the pane running. + const gateB = await gateSshNode(args.viewerId, pane.nodeId); + if (!gateB.ok) return gateB; + if (gateB.value.row.kind !== "agent") { + // The server host has no agent socket to carry an exec; §7's act runs on + // the Subshell app's machine, so a direct pane launched FROM the plane + // host refuses by name rather than inventing an in-process path. + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "Setting up Subshell here runs from the Subshell app on the connecting machine; the server host is not a connecting machine for it.", + ); + } + const dataDirB = targetDataDir(gateB.value.row); + if (dataDirB === null) { + return coded( + 409, + BackendErrorCodes.NODE_OFFLINE, + "That machine has not reported its data directory yet; retry once it is fully connected.", + ); + } + const destination = sshCanonicalDestination({ host: snapshot.host, port: snapshot.port, user: snapshot.user }); + + // The act's own ephemeral id: the config dir, the pinned-file path, the + // relay socket, and the exec registry key ALL derive from it. It is not the + // pane's id - the pane's config and socket stay exactly as they are. + const execId = crypto.randomUUID(); + + let grantId: string | null = null; + let aNodeId: string | null = null; + const keyHome = pane.keyHomeNodeId; + if (keyHome !== null && keyHome !== undefined) { + aNodeId = keyHome; + const gateA = await gateSshNode(args.viewerId, keyHome); + if (!gateA.ok) return gateA; + if (gateA.value.row.kind !== "agent") { + return coded( + 409, + BackendErrorCodes.SSH_RELAY_OPEN_FAILED, + "The server host itself cannot hold a relay key home; this pane was not opened through one.", + ); + } + if (!getLive(keyHome)) { + return coded( + 409, + BackendErrorCodes.SSH_RELAY_OPEN_FAILED, + `Setting up Subshell here signs with ${gateA.value.row.name}'s keys over the relay, and that key home must be online for it. Bring it online and run the act again.`, + ); + } + const leg = await prepareRelayLeg({ + viewerId: args.viewerId, + aNode: { id: gateA.value.row.id, name: gateA.value.row.name }, + bNodeId: pane.nodeId, + resolvedHost: snapshot.host, + destination, + // The pairing serves the EXEC, so the ephemeral id is its paneId: the + // proxy socket and the delivered host pin land in the exec's own + // directory, never the pane's. + paneId: execId, + }); + if (!leg.ok) return leg; + grantId = leg.value.grantId; + } + + // The single-use key, minted only once every door above has opened. + const keys = new NodeSetupKeysRepository(db); + const keyRow = await keys.create(args.viewerId); + + const composed = composeSshLaunch({ + snapshot, + targetDataDir: dataDirB, + subshellId: execId, + ...(grantId !== null ? { hostPinPath: buildSshKnownHostsPath(dataDirB, execId) } : {}), + }); + // BatchMode first: the exec must never fall back to a password or a + // host-key PROMPT - auth failure or pin mismatch is a fast refusal, an + // interactive prompt would sit out the deadline on a terminal no one holds. + const presetFlags = ["-o", "BatchMode=yes", ...composed.presetFlags]; + // The installer one-liner, exactly the copy-paste act §7 describes, with + // the scripted name (the pane's destination host) because the exec has no + // terminal to answer `setup`'s naming question. shellQuote every token; + // `bash -c` so pipefail exists and a failed fetch is not hidden by the + // empty-stdin pipe. + const installUrl = `${APP_BASE_URL}/install.sh?setup_key=${keyRow.key}`; + const nodeName = snapshot.host; + const remoteCommand = `bash -c ${shellQuote( + `set -o pipefail; curl -fsSL ${shellQuote(installUrl)} | SUBSHELL_NODE_NAME=${shellQuote(nodeName)} bash`, + )}`; + + // The audit this act OWNS: written at every exit with ids and the outcome + // only. Built once per path; the key never enters it (never interpolated). + const runAudit = async (outcome: "enrolled" | "refused", cause: string, newNodeId?: string): Promise => { + await audit({ + actorUserId: args.viewerId, + action: "node.ssh_upgrade.run", + targetType: "node", + targetId: pane.nodeId, + metadataJson: JSON.stringify({ + paneId: pane.id, + bNodeId: pane.nodeId, + ...(aNodeId !== null ? { aNodeId } : {}), + ...(grantId !== null ? { grantId } : {}), + destination, + execId, + outcome, + cause, // a STAGE word chosen by this file, never machine text + ...(newNodeId !== undefined ? { newNodeId } : {}), + }), + }); + }; + /** Best-effort: an unspent key is revoked when the act gives up; a spent one stands as enroll's record. */ + const revokeUnspentKey = async (): Promise => { + try { + const state = await keys.peekByKey(keyRow.key); + if (state && state.usedAt === null) await keys.deleteById(keyRow.id, args.viewerId); + } catch (err) { + logger.withError(err).warn(`setup-here: could not revoke the unspent key for pane ${pane.id}`); + } + }; + /** Close the exec's own relay pairing (the act's child is gone; the socket should be). */ + const closeExecRelay = async (): Promise => { + if (grantId === null) return; + try { + await deps.broker().closeForPane(execId, "child-exit"); + } catch (err) { + logger.withError(err).warn(`setup-here: relay close for exec ${execId} failed`); + } + }; + + // KICK. A refusal here has minted a key and opened a relay and must leave + // both as it found them (revoke the unspent key, cut the fresh session). + try { + await sshExec(pane.nodeId, { + execId, + configPath: composed.configPath, + fileContent: composed.fileContent, + presetFlags, + command: remoteCommand, + relay: grantId !== null, + agentSocketPath: grantId === null ? snapshot.authAgentSocket : null, + timeoutMs: deps.execTimeoutMs, + }); + } catch (err) { + await closeExecRelay(); + await revokeUnspentKey(); + if (err instanceof SshRpcError) { + const r = rpcRefusal(err); + await runAudit("refused", `exec-${err.kind}`); + return r; + } + throw err; + } + + // POLL to the terminal answer. The node holds the deadline; the plane only + // stops asking past its own margin. + const pollDeadline = deps.nowMs() + deps.execTimeoutMs + deps.execPollMarginMs; + let terminal: Awaited>; + for (;;) { + try { + terminal = await sshExecStatus(pane.nodeId, execId); + } catch (err) { + await closeExecRelay(); + await revokeUnspentKey(); + if (err instanceof SshRpcError) { + await runAudit("refused", `exec-lost-${err.kind}`); + return rpcRefusal(err); + } + throw err; + } + if (terminal.state === "done") break; + if (deps.nowMs() > pollDeadline) { + await closeExecRelay(); + await revokeUnspentKey(); + await runAudit("refused", "install-deadline"); + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The install did not finish within its deadline on the connecting machine; nothing was enrolled by this act.", + ); + } + await deps.sleepMs(deps.pollMs); + } + await closeExecRelay(); + + // The belt redaction: the machine's answer is the first station's word, + // this plane keeps NOTHING raw. Output informs the verb and then goes. + const output = redactSshSetupKeyLines(`${terminal.stdout}\n${terminal.stderr}`); + const installOk = terminal.code === 0 && !terminal.timedOut; + + if (!installOk) { + await revokeUnspentKey(); + // Awaited, not fire-and-forget: the trail of the act that just refused + // must be readable when the caller sees the refusal (the `ssh.launch` + // posture inverted - there success audits last, here every exit audits + // BEFORE the answer leaves). + const fail = async (cause: string, refusal: SshAnswer): Promise> => { + await runAudit("refused", cause); + return refusal; + }; + if (terminal.timedOut) { + return await fail( + "install-timeout", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The install ran out of time on the connecting machine; nothing was enrolled by this act.", + ), + ); + } + // The named causes, each from a phrase the rendered installer prints (the + // script's OWN words are the parser's contract; none of them is echoed). + if (output.includes("could not reach") || output.includes("could not fetch the checksum")) { + // §7's egress refusal: its own code, distinct from any key error, and + // the pane keeps running untouched. + return await fail( + "egress", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_EGRESS, + `The destination could not reach ${APP_BASE_URL}, so it cannot install Subshell. Nothing was enrolled; the connection and its pane are untouched.`, + ), + ); + } + if (output.includes("setup key was rejected")) { + return await fail( + "enroll-key", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The enrollment key was rejected during the install (invalid, expired, or already used); nothing was enrolled.", + ), + ); + } + if (output.includes("could not provide a")) { + return await fail( + "artifact", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "This server had no node build for the destination's platform; supply it from the server's machine page and run the act again.", + ), + ); + } + if (output.includes("tmux is still missing") || output.includes("refuse to enroll")) { + return await fail( + "preflight-tmux", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The destination has no tmux, which every node needs; install it there and run the act again.", + ), + ); + } + if (output.includes("already have a node named")) { + return await fail( + "enroll-name", + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "You already own a node named for this destination; delete or rename that machine entry and run the act again.", + ), + ); + } + return await fail( + `install-exit-${terminal.code ?? "signal"}`, + coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The install on the destination stopped before enrolling; nothing was enrolled by this act.", + ), + ); + } + + // Success is the new node's `ready`, not the installer's cheer. The setup + // key's row learns the node id enrollment spent it for; the registry's + // agent facts appear exactly when that node's `ready` frame lands. + const readyDeadline = deps.nowMs() + deps.readyBudgetMs; + for (;;) { + const spent = await keys.findById(keyRow.id); + const newNodeId = spent?.consumedNodeId ?? null; + if (newNodeId !== null && getLive(newNodeId)?.agent !== undefined) { + await runAudit("enrolled", "ready", newNodeId); + return { ok: true, value: { nodeId: newNodeId } }; + } + if (deps.nowMs() > readyDeadline) break; + await deps.sleepMs(deps.pollMs); + } + const spent = await keys.findById(keyRow.id); + if (spent?.consumedNodeId) { + // Enrolled but not connected: the daemon on D starts under its fresh + // service; the remedy is patience and the machine page, not a rerun. + await runAudit("refused", "ready-timeout", spent.consumedNodeId); + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The destination enrolled, but it has not connected to this server yet; watch its machine page and retry only if it stays offline.", + ); + } + await revokeUnspentKey(); + await runAudit("refused", "enroll-missing"); + return coded( + 409, + BackendErrorCodes.SSH_UPGRADE_FAILED, + "The install finished without enrolling a machine with the key this act minted; nothing was enrolled.", + ); +} diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index 8e706a090..29467577c 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -146,6 +146,13 @@ export interface SshLaunchPlumbing { fileContent: string; /** The snapshot the resolver approved (grammar-validated at each boundary already). */ snapshot: SshConnectionSnapshotWire; + /** + * RELAY MODE (spec 2026-10-08 §7): the key home whose agent signed the + * connection, stored on the row (migration 0052) so "Set up Subshell here" + * can re-open the pairing after the relay session's own memory is gone. + * Absent on every direct launch. + */ + keyHomeNodeId?: string; } /** @@ -493,6 +500,9 @@ export class SubshellManagerService { // read its presence. NULL for every pane this call did not compose from // an approved snapshot. ssh: ssh ? JSON.stringify(ssh.snapshot) : null, + // The relay pane's key home, recorded with the row (migration 0052): + // immutable thereafter, and no direct pane carries it. + keyHomeNodeId: ssh?.keyHomeNodeId ?? null, }); // The token is minted AFTER the row exists (issueSubshellToken writes the diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index b9fc93fad..fd65d874a 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -302,6 +302,22 @@ export enum BackendErrorCodes { * smuggled newline. Refused before any write (spec 2026-10-08 §9). */ SSH_HOST_PIN_INVALID = "SSH_HOST_PIN_INVALID", + /** + * The "Set up Subshell here" act's named EGRESS refusal (spec 2026-10-08 + * §7): the destination cannot reach the plane URL, so the install cannot + * run and no key was ever spent on an enrollment. Deliberately a code of + * its own: the spec requires this cause to read differently from any key + * error, and the working pane is untouched either way. + */ + SSH_UPGRADE_EGRESS = "SSH_UPGRADE_EGRESS", + /** + * The "Set up Subshell here" act stopped at a NAMED stage short of an + * enrolled, connected node (spec 2026-10-08 §7): the install failed, the + * enrollment was refused, or the new node never reported `ready` within + * the bounded wait. The message names which stage; the setup key is + * nowhere in it, and the interactive pane is untouched. + */ + SSH_UPGRADE_FAILED = "SSH_UPGRADE_FAILED", } /** @@ -608,4 +624,12 @@ export const BackendErrorCodeDefs = { message: "That host-key entry is not a valid known_hosts line", statusCode: 400, }, + [BackendErrorCodes.SSH_UPGRADE_EGRESS]: { + message: "The destination cannot reach this server, so it cannot install Subshell", + statusCode: 409, + }, + [BackendErrorCodes.SSH_UPGRADE_FAILED]: { + message: "Setting up Subshell here did not finish", + statusCode: 409, + }, }; From 06269279371854baf00f136e34c82fd63f3c7508 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:15:06 -0700 Subject: [PATCH 69/92] feat(ssh-setup-here): POST /api/ssh/setup-here, the destination-upgrade door The route is thin by design: cookie actor at the door (the launcher family's posture - a bearer cannot enroll machines), the service's refusal rendered as-is, and a 200 that answers the new node's id only because its ready frame landed. The 422 outcome arm cannot reach this surface (the destination was approved when the pane opened) and answers loudly rather than silently. docs/security.md 10 gains the node.ssh_upgrade.run family (ids, destination, grant, exec id, outcome word; never the setup key, never installer output), mirrored in the working rules summary. --- .claude/rules/security-context.md | 2 +- .../ssh/__tests__/setup-here-route.test.ts | 125 ++++++++++++++++++ apps/server/api/src/api/ssh/index.ts | 8 +- .../api/src/api/ssh/setup-here.route.ts | 74 +++++++++++ docs/security.md | 8 ++ 5 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts create mode 100644 apps/server/api/src/api/ssh/setup-here.route.ts diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 0de45ab7d..7f8778387 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_grant.request|ssh_grant.create (via first-use|manual; approve/create name the CHOSEN fingerprint VALUES + destination per spec §10 - the approve row is the only durable record of the operator's selection, and a fingerprint appears in NO log line or notification body)|ssh_grant.update|ssh_grant.delete (relaysClosed; the revoke also cuts live relays via closeForGrant "grant-revoked")|ssh_grant.approve|ssh_grant.deny (audit-only; grant-request expiry writes nothing)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, grantId on a relay launch}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_grant.request|ssh_grant.create (via first-use|manual; approve/create name the CHOSEN fingerprint VALUES + destination per spec §10 - the approve row is the only durable record of the operator's selection, and a fingerprint appears in NO log line or notification body)|ssh_grant.update|ssh_grant.delete (relaysClosed; the revoke also cuts live relays via closeForGrant "grant-revoked")|ssh_grant.approve|ssh_grant.deny (audit-only; grant-request expiry writes nothing)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|ssh_upgrade.run (the "Set up Subshell here" act, Task 14: pane/B/A/grant ids, canonical destination, exec id, outcome + service-chosen stage word; the minted setup key and ALL installer output appear in NO row and NO refusal copy)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, grantId on a relay launch}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. ## Deliberate non-validation & accepted risks diff --git a/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts b/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts new file mode 100644 index 000000000..00d0d59ec --- /dev/null +++ b/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts @@ -0,0 +1,125 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; +import { sshRoutes } from "@/api/ssh/index.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; + +/** + * `POST /api/ssh/setup-here` - the HTTP surface of the destination upgrade + * (spec 2026-10-08 §7, Task 14). The act itself - re-open, exec, redaction, + * the bounded `ready` wait - is pinned by `ssh-setup-here.service.test.ts` + * against the real RPC chain; this file owns the doors in front of it: the + * surface is browser sessions only (the launcher family's posture), a body + * that is not a pane id is a 400, and a foreign pane is the 404 the + * ownership axis demands, identical to an absent one. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(sshRoutes); +const port = "api/ssh/setup-here" as const; + +function post(body: unknown, opts: { cookie?: string; bearer?: string } = {}): Promise { + const headers: Record = { "content-type": "application/json" }; + if (opts.cookie) headers.cookie = `better-auth.session_token=${opts.cookie}`; + if (opts.bearer) headers.authorization = `Bearer ${opts.bearer}`; + return app.fetch( + new Request(`http://localhost:3099/${port}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }), + ); +} + +const pw = "setup-route-1"; +const ownerEmail = `sh-route-o-${crypto.randomUUID()}@subshell.local`; +const otherEmail = `sh-route-x-${crypto.randomUUID()}@subshell.local`; +let owner = ""; +let _other = ""; +let ownerCookie = ""; +let otherCookie = ""; +const createdPanes: string[] = []; + +async function mkPane(userId: string): Promise { + const id = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id, + userId, + harnessId: "terminal", + name: `route-probe-${id.slice(0, 8)}`, + workingDir: "/tmp", + tmuxSocket: null, + nodeId: LOCAL_NODE_ID, + }); + createdPanes.push(id); + return id; +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + owner = await new UsersRepository(db).createUser({ + email: ownerEmail, + name: ownerEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + _other = await new UsersRepository(db).createUser({ + email: otherEmail, + name: otherEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + ownerCookie = await signIn(ownerEmail, pw); + otherCookie = await signIn(otherEmail, pw); + await ensureLocalNode(db); +}); + +afterAll(async () => { + for (const id of createdPanes) await new SubshellsRepository(db).delete(id).catch(() => {}); + await new NodesRepository(db).deleteById(LOCAL_NODE_ID).catch(() => {}); + await deleteUserByEmailOrId(ownerEmail).catch(() => {}); + await deleteUserByEmailOrId(otherEmail).catch(() => {}); +}); + +describe("POST /api/ssh/setup-here doors", () => { + it("an empty cookie is a failed authentication (401), like every launcher door", async () => { + expect((await post({ paneId: "whatever" }, { cookie: "" })).status).toBe(401); + }); + + it("bearer credentials are refused at the door: this surface is browser sessions only", async () => { + const paneId = await mkPane(owner); + const key = await issueSubshellToken(paneId, owner); + const res = await post({ paneId }, { bearer: key }); + expect(res.status).toBe(403); + }); + + it("a body without a pane id is a 400 INPUT_VALIDATION_ERROR, not a service call", async () => { + const res = await post({}, { cookie: ownerCookie }); + expect(res.status).toBe(400); + }); + + it("a foreign pane and an absent pane are one 404 (the ordinary invisibility)", async () => { + const paneId = await mkPane(owner); + const foreign = await post({ paneId }, { cookie: otherCookie }); + expect(foreign.status).toBe(404); + const absent = await post({ paneId: crypto.randomUUID() }, { cookie: ownerCookie }); + expect(absent.status).toBe(404); + }); + + it("a non-ssh pane refuses by name through the service's own door", async () => { + const paneId = await mkPane(owner); + const res = await post({ paneId }, { cookie: ownerCookie }); + expect(res.status).toBe(409); + const body = (await res.json()) as { code?: string; message?: string }; + expect(body.message).toContain("SSH-terminal pane"); + }); +}); diff --git a/apps/server/api/src/api/ssh/index.ts b/apps/server/api/src/api/ssh/index.ts index 9c5351566..7e6988758 100644 --- a/apps/server/api/src/api/ssh/index.ts +++ b/apps/server/api/src/api/ssh/index.ts @@ -5,6 +5,7 @@ import { sshGrantsRoutes } from "@/api/ssh/grants.route.js"; import { sshLaunchRoute } from "@/api/ssh/launch.route.js"; import { sshResolveRoute } from "@/api/ssh/resolve.route.js"; import { sshSavedHostsRoutes } from "@/api/ssh/saved-hosts.route.js"; +import { sshSetupHereRoute } from "@/api/ssh/setup-here.route.js"; import { sshTrustRoutes } from "@/api/ssh/trust.route.js"; /** @@ -31,4 +32,9 @@ export const sshRoutes = new Elysia({ prefix: "/api/ssh" }) // pins a relay launch verifies D against. Mounted inside this group for the // same Elysia App-type-depth reason the grant tier is; these are the // grant layer's destination-trust half. - .use(sshTrustRoutes); + .use(sshTrustRoutes) + // The destination upgrade (spec 2026-10-08 §7, Task 14): "Set up Subshell + // here" turns an open pane's destination into an enrolled node. Mounted + // inside this group like every other ssh tier (App-type-depth rule); it is + // the launcher's own act on the launcher's own pane. + .use(sshSetupHereRoute); diff --git a/apps/server/api/src/api/ssh/setup-here.route.ts b/apps/server/api/src/api/ssh/setup-here.route.ts new file mode 100644 index 000000000..5cac4b174 --- /dev/null +++ b/apps/server/api/src/api/ssh/setup-here.route.ts @@ -0,0 +1,74 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { throwCodedRefusal } from "@/api/ssh/ssh-views.js"; +import { contextPlugin } from "@/plugins/context.plugin.js"; +import { apiModels } from "@/schema/index.js"; +import { setupHere } from "@/services/ssh-setup-here.service.js"; + +/** + * `POST /api/ssh/setup-here` `{paneId}` - "Set up Subshell here" (spec + * 2026-10-08 §7, Task 14): enroll the destination of an open SSH-terminal + * pane as a node by running the ordinary install over a SEPARATE + * non-interactive connection, leaving the pane itself untouched. The act is + * owner-only by construction (a foreign pane is the service's 404), cookie- + * only like every launcher door, and its answer is the new node's id ONLY + * once that node's `ready` frame has landed - the installer's cheer line is + * not success, the connection is. The minted setup key exists nowhere in + * this response, its refusals, or the audit; the machine ran the install + * where the key is allowed to live (spec §7's accepted postures) and the + * plane kept only the parsed outcome. + */ +export const sshSetupHereRoute = new Elysia() + .use(contextPlugin) + .use(authGuard) + .use(apiModels) + .post( + "/setup-here", + async ({ body, user, actor }) => { + requireCookieActor(actor, "SSH setup acts are restricted to browser sessions"); + const answer = await setupHere({ viewerId: user.id, paneId: body.paneId }); + if (!answer.ok) { + const r = answer.refusal; + if (r.status === 422) { + // Unreachable: no arm of this act carries a resolve outcome (the + // destination was approved when the pane opened). An unreachable + // arm is answered loudly, never swallowed - the grants create's + // posture restated. + return throwCodedRefusal({ + status: 502, + code: BackendErrorCodes.SSH_NODE_REFUSED, + message: "The setup surface cannot answer that refusal shape.", + }); + } + return throwCodedRefusal(r); + } + return answer.value; + }, + { + body: t.Object({ + paneId: t.String({ + minLength: 1, + maxLength: 64, + description: "The open SSH-terminal pane whose destination is to become a node (owner-only)", + }), + }), + response: { + 200: t.Object({ + nodeId: t.String({ description: "The newly enrolled node's id (its `ready` frame has landed)" }), + }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "sshSetupHere", + tags: ["ssh"], + description: + "Runs the ordinary node enrollment install on an open SSH pane's destination over a separate non-interactive connection and answers the new node's id once it is online; the pane keeps running and the enrollment key never appears in the pane, its log, or any returned text", + }, + }, + ); diff --git a/docs/security.md b/docs/security.md index d45dda8a0..c0c78df43 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2305,6 +2305,14 @@ Audit events are written, grouped by family: the routing ref, and the fingerprint COUNT; close names the one `reason` word from the grammar - the plane's broker rows, `actorUserId: null`, because the human's act is the launch/grant trail), + `node.ssh_upgrade.run` (spec §7, Task 14: one row per "Set up Subshell + here" act, naming the pane, B, A and the grant on a relay run, the + canonical destination, the act's exec id, and the OUTCOME with a STAGE + word chosen by the service; the minted setup key appears nowhere - never + the metadata, never a refusal's copy, and no installer output at all: + the act parses the machine's redacted capture into the stage verb and + keeps nothing else; the single-use key row is the only durable record of + the credential, matching the `setup_key.create` no-metadata precedent), `node.logging.update`, `node.update`, `node.update.unknown`, `node.shares_set`, `node.local_share_changed`, and `node.service` (with `{ verb: "restart", forced }` metadata; the restart From a94138d1d939fe0e3e9a3e94e717c789bd1cdc67 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:18:08 -0700 Subject: [PATCH 70/92] feat(web): 'Set up Subshell here' action on the SSH pane The owner-only menu item (live SSH panes only) opens a one-question dialog that POSTs the pane id to /api/ssh/setup-here and holds open through the install. The server's own words answer it: the new node's page on success, the named refusal verbatim on failure, and the act button returns so a refusal can be retried. --- .../__tests__/setup-here-dialog.test.tsx | 149 ++++++++++++++++++ .../__tests__/subshell-actions-menu.test.tsx | 34 ++++ .../web/src/components/setup-here-dialog.tsx | 103 ++++++++++++ .../src/components/subshell-actions-menu.tsx | 21 +++ 4 files changed, 307 insertions(+) create mode 100644 apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx create mode 100644 apps/server/web/src/components/setup-here-dialog.tsx diff --git a/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx b/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx new file mode 100644 index 000000000..e8ef0b884 --- /dev/null +++ b/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { SetupHereDialog } from "@/components/setup-here-dialog"; +import type { SubshellView } from "@/types/subshell"; + +/** Minimal ssh pane view (the menu fixture's shape) with overrides. */ +function makeSshPane(overrides: Partial = {}): SubshellView { + return { + id: "pane-1", + presetId: null, + harnessId: "ssh", + nodeId: "mac-mini", + nodeOffline: false, + name: "deploy box", + nameLocked: false, + workingDir: "/home/scripted", + status: "running", + createdAt: "2026-10-08T00:00:00.000Z", + endedAt: null, + lastOutputAt: null, + activity: "active", + alive: true, + exitCode: null, + startedAt: null, + backoffCount: 0, + restartOnExit: false, + nextRestartAt: null, + notify: false, + waitingSince: null, + unseenPush: false, + access: "owner", + ssh: true, + ...overrides, + }; +} + +describe("SetupHereDialog", () => { + afterEach(cleanup); + + /** fetch recorder; the setup POST answers per the case's script. */ + function mockFetch(answer: { status?: number; body: unknown }) { + const calls: { url: string; body: unknown }[] = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + calls.push({ url: url.pathname, body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined }); + return Promise.resolve(new Response(JSON.stringify(answer.body), { status: answer.status ?? 200 })) as never; + }) as typeof fetch; + return { calls, restore: () => (globalThis.fetch = original) }; + } + + async function settle(): Promise { + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + } + + async function renderDialog(subshell: SubshellView, onOpenChange = (_: boolean) => {}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const rootRoute = createRootRoute(); + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => , + }); + const nodeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/nodes/$id", + component: () => null, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nodeRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + defaultPreload: false, + }); + await router.load(); + render( + + + , + ); + await settle(); + } + + it("asks once with a static title and two sentences, then posts the pane id", async () => { + const { calls, restore } = mockFetch({ body: { nodeId: "new-node-1" } }); + try { + await renderDialog(makeSshPane()); + expect(screen.getByText("Set up Subshell here?")).toBeTruthy(); + expect(screen.getByText(/keep running/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + await settle(); + const post = calls.find((c) => c.url === "/api/ssh/setup-here"); + expect(post?.body).toEqual({ paneId: "pane-1" }); + } finally { + restore(); + } + }); + + it("success names the enrolled machine and links to its page", async () => { + const { restore } = mockFetch({ body: { nodeId: "new-node-1" } }); + try { + let closed = false; + await renderDialog(makeSshPane(), () => (closed = true)); + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + await waitFor(() => expect(screen.queryByText(/Installing on the destination/)).toBeNull()); + const link = screen.getByRole("link", { name: "Open its page" }) as HTMLAnchorElement; + expect(link.getAttribute("href")).toContain("/nodes/new-node-1"); + // Done leaves; the finished answer does not outlive the dialog (mount- + // while-open posture, checked at the menu level). + fireEvent.click(screen.getByRole("button", { name: "Done" })); + expect(closed).toBe(true); + } finally { + restore(); + } + }); + + it("the server's named refusal is shown verbatim, in destructive, and the act can be retried", async () => { + const { calls, restore } = mockFetch({ + status: 409, + body: { + code: "SSH_UPGRADE_EGRESS", + message: + "The destination could not reach http://plane.test, so it cannot install Subshell. Nothing was enrolled; the connection and its pane are untouched.", + }, + }); + try { + await renderDialog(makeSshPane()); + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + await waitFor(() => expect(screen.getByText(/could not reach/)).toBeTruthy()); + expect(screen.getByText(/untouched/)).toBeTruthy(); + // The act button comes back: a refused act is retriable (the key was + // minted and revoked fresh per run). + fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" })); + await settle(); + expect(calls.filter((c) => c.url === "/api/ssh/setup-here")).toHaveLength(2); + } finally { + restore(); + } + }); +}); diff --git a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx index 8e7702805..a9cb808b3 100644 --- a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx @@ -657,3 +657,37 @@ describe("SubshellActionsMenu — prompt input availability", () => { } }); }); + +describe("SubshellActionsMenu — Set up Subshell here (spec 2026-10-08 §7)", () => { + afterEach(cleanup); + + it("offers it on the owner's live SSH pane and opens the dialog", async () => { + const { restore } = mockFetch(); + try { + await renderMenu(makeSubshell({ ssh: true })); + await openMenu("subshell"); + fireEvent.click(screen.getByRole("menuitem", { name: "Set up Subshell here…" })); + expect(await screen.findByText("Set up Subshell here?")).toBeTruthy(); + } finally { + restore(); + } + }); + + for (const hidden of [ + { ssh: false }, + { ssh: true, access: "edit" as const }, + { ssh: true, access: "view" as const }, + { ssh: true, nodeOffline: true }, + ]) { + it(`does not offer it for ${JSON.stringify(hidden)}`, async () => { + const { restore } = mockFetch(); + try { + await renderMenu(makeSubshell(hidden)); + await openMenu("subshell"); + expect(screen.queryByRole("menuitem", { name: "Set up Subshell here…" })).toBeNull(); + } finally { + restore(); + } + }); + } +}); diff --git a/apps/server/web/src/components/setup-here-dialog.tsx b/apps/server/web/src/components/setup-here-dialog.tsx new file mode 100644 index 000000000..3863c2c48 --- /dev/null +++ b/apps/server/web/src/components/setup-here-dialog.tsx @@ -0,0 +1,103 @@ +import { apiFetch, Button, NODES_QUERY_KEY } from "@internal/node-admin"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import type { JSX } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { SubshellView } from "@/types/subshell"; + +/** + * "Set up Subshell here" (spec 2026-10-08 §7, Task 14): the secondary act + * on an open SSH-terminal pane that turns its destination into an enrolled + * node. The server runs the ordinary enrollment install over a separate + * non-interactive connection - the pane keeps running, and the enrollment + * key never enters it. The dialog asks once, holds itself open through the + * install (minutes are possible and the request answers only when the new + * node is ONLINE), then says which of the two happened in the server's own + * named words: the machine's id and page on success, the refusal's sentence + * on failure. Every stage the server can name (no egress, tmux missing, + * the key home offline, no ready) is a refusal sentence, so the dialog + * never needs its own failure taxonomy. + * + * Owner-only by the menu's gate (an SSH pane is input-reserved to its + * owner, and this act enrolls a machine on the owner's account); the server + * re-checks both doors. + */ +export function SetupHereDialog({ + subshell, + open, + onOpenChange, +}: { + /** The SSH-terminal pane whose destination is being set up */ + subshell: SubshellView; + /** Controlled open state, owned by the actions menu (clone-dialog posture) */ + open: boolean; + onOpenChange: (open: boolean) => void; +}): JSX.Element { + const queryClient = useQueryClient(); + const upgrade = useMutation({ + mutationFn: () => + apiFetch<{ nodeId: string }>("/api/ssh/setup-here", { + method: "POST", + body: JSON.stringify({ paneId: subshell.id }), + }), + onSuccess: () => { + // The new row belongs to the machine list; the panes queries are + // untouched because the act never writes the pane. + void queryClient.invalidateQueries({ queryKey: NODES_QUERY_KEY }); + }, + }); + const enrolled = upgrade.data; + + return ( + !upgrade.isPending && onOpenChange(next)}> + e.stopPropagation()}> + + Set up Subshell here? + {!enrolled && !upgrade.error && ( + + Runs the Subshell installer on the connected machine using this pane's authorization. It enrolls as a node + you own; the connection and this pane keep running. + + )} + + {upgrade.isPending && ( +

+ Installing on the destination; the download can take a few minutes. +

+ )} + {upgrade.error &&

{(upgrade.error as Error).message}

} + {enrolled && ( +

+ The machine enrolled and connected.{" "} + onOpenChange(false)} + > + Open its page + {" "} + to see what it can launch. +

+ )} + + + {!enrolled && ( + + )} + +
+
+ ); +} diff --git a/apps/server/web/src/components/subshell-actions-menu.tsx b/apps/server/web/src/components/subshell-actions-menu.tsx index 4f1ee45dd..2cae1522e 100644 --- a/apps/server/web/src/components/subshell-actions-menu.tsx +++ b/apps/server/web/src/components/subshell-actions-menu.tsx @@ -10,6 +10,7 @@ import { Keyboard, QrCode, RotateCcw, + ServerCog, Share2, SlidersHorizontal, TextCursorInput, @@ -20,6 +21,7 @@ import { type ActionItem, ActionsMenu } from "@/components/actions-menu"; import { CloneSubshellDialog } from "@/components/clone-subshell-dialog"; import { InjectPromptDialog } from "@/components/prompts/inject-prompt-dialog"; import { QrLinkDialog } from "@/components/qr-link-dialog"; +import { SetupHereDialog } from "@/components/setup-here-dialog"; import { SharingDialog } from "@/components/sharing-dialog"; import { SwitchPresetDialog } from "@/components/switch-preset-dialog"; import { TitleDialog } from "@/components/ui/title-dialog"; @@ -79,6 +81,7 @@ export function SubshellActionsMenu({ const [titleOpen, setTitleOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false); const [cloneOpen, setCloneOpen] = useState(false); + const [setupHereOpen, setSetupHereOpen] = useState(false); const [switchPresetOpen, setSwitchPresetOpen] = useState(false); const [injectOpen, setInjectOpen] = useState(false); const navigate = useNavigate(); @@ -255,6 +258,21 @@ export function SubshellActionsMenu({ }, ] : []), + // "Set up Subshell here" (spec 2026-10-08 §7): the destination upgrade, + // an act the OWNER performs on an SSH-terminal pane. The ssh flag is the + // pane's kind fact and the server re-checks it (a non-ssh pane refuses by + // name); the offline gate is the restart item's rule, because this act + // needs the connecting machine online to carry the install - promising it + // offline would end in a refusal the menu could have named first. + ...(isOwner && subshell.ssh === true && !subshell.nodeOffline + ? [ + { + icon: ServerCog, + label: "Set up Subshell here…", + onSelect: () => setSetupHereOpen(true), + }, + ] + : []), ...(preset ? [ { @@ -310,6 +328,9 @@ export function SubshellActionsMenu({ half-typed custom TEXT deliberately outlives the unmount (the picker's sessionStorage draft, ruling 2026-09-29). */} {injectOpen && } + {/* Mount-while-open, the menu's dialog posture: every open starts from + the question, and a finished act's answer leaves with the close. */} + {setupHereOpen && } Date: Thu, 8 Oct 2026 18:21:12 -0700 Subject: [PATCH 71/92] test(ssh-setup-here): keep the new suites clean under verify-types The refusal union needs its coded arm named before reading message/code, the closure-filled D handle needs its widening re-stated, the mocked fetch keeps the working cast shape, and the fake child result stays a partial from the test's side. --- .../src/__tests__/commands-ssh-exec.test.ts | 4 +- .../ssh/__tests__/setup-here-route.test.ts | 2 +- .../__tests__/ssh-setup-here.service.test.ts | 80 ++++++++++++------- .../__tests__/setup-here-dialog.test.tsx | 2 +- 4 files changed, 53 insertions(+), 35 deletions(-) diff --git a/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts index 30643364d..3f1b89b32 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts @@ -102,7 +102,7 @@ function fakeRun( finish: (r?: Partial) => void; } { const calls: { argv: readonly string[]; env: Record; timeoutMs: number }[] = []; - let settle: ((r: SshProcessResult) => void) | null = null; + let settle: ((r: Partial) => void) | null = null; const seams: SshExecSeams = { resolveSshBin: async () => "/usr/bin/fake-ssh", runProcess: ((argv, env, timeoutMs) => { @@ -110,7 +110,7 @@ function fakeRun( const full: SshProcessResult = { code: 0, stdout: "", stderr: "", timedOut: false, spawnError: false, ...result }; if (opts.defer) { return new Promise((resolve) => { - settle = (r) => resolve({ ...full, ...r }); + settle = (r) => resolve({ ...full, ...r } as SshProcessResult); }); } return Promise.resolve(full); diff --git a/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts b/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts index 00d0d59ec..70b453e4a 100644 --- a/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts +++ b/apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts @@ -26,7 +26,7 @@ import { issueSubshellToken } from "@/services/subshell-tokens.js"; const app = new Elysia().use(errorHandlerPlugin).use(sshRoutes); const port = "api/ssh/setup-here" as const; -function post(body: unknown, opts: { cookie?: string; bearer?: string } = {}): Promise { +function post(body: unknown, opts: { cookie?: string; bearer?: string } = {}) { const headers: Record = { "content-type": "application/json" }; if (opts.cookie) headers.cookie = `better-auth.session_token=${opts.cookie}`; if (opts.bearer) headers.authorization = `Bearer ${opts.bearer}`; diff --git a/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts index 6c00f2580..9243bfd66 100644 --- a/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts @@ -15,6 +15,7 @@ import { UsersRepository } from "@/db/repositories/users.repository.js"; import type { NodeTable } from "@/db/types/nodes.db-types.js"; import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import type { SshRefusal } from "@/services/ssh-launch.service.js"; import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; import { setSshSetupHereDepsForTests, setupHere } from "@/services/ssh-setup-here.service.js"; import { attachScriptedNode, SCRIPTED_DATA_DIR, type ScriptedNode } from "@/test-helpers/scripted-node.js"; @@ -192,6 +193,12 @@ function mintedKey(cmd: SshExecCommand | undefined): string { return m[1]; } +/** The refusal's coded facts; the 422 outcome arm is not an answer this act ever gives. */ +function coded(refusal: SshRefusal): { status: number; code: BackendErrorCodes; message: string } { + if (refusal.status === 422) throw new Error(`unexpected outcome refusal: ${JSON.stringify(refusal)}`); + return refusal; +} + async function upgradeRows(): Promise<{ action: string; metadataJson: string | null }[]> { return await db .selectFrom("auditEvents") @@ -304,7 +311,7 @@ describe("setupHere door order (pane first, gates before any key)", () => { const res = await setupHere({ viewerId: owner, paneId }); expect(res.ok).toBe(false); if (res.ok) return; - expect(res.refusal.message).toContain("SSH-terminal pane"); + expect(coded(res.refusal).message).toContain("SSH-terminal pane"); }); }); @@ -328,11 +335,11 @@ describe("setupHere relay mode", () => { const res = await setupHere({ viewerId: owner, paneId }); expect(res.ok).toBe(false); if (res.ok) return; - expect(res.refusal.status).toBe(409); - if (res.refusal.status !== 409) return; - expect(res.refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); - expect(res.refusal.message).toContain("could not reach"); - expect(res.refusal.message).toContain("untouched"); + const refusal = coded(res.refusal); + expect(refusal.status).toBe(409); + expect(refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + expect(refusal.message).toContain("could not reach"); + expect(refusal.message).toContain("untouched"); // The working pane: the row is unchanged, and B saw ONLY the exec arms // - no input, no terminate, no launch, no touch of any kind. const row = await new SubshellsRepository(db).findById(paneId); @@ -340,8 +347,10 @@ describe("setupHere relay mode", () => { expect(row?.status).toBe("running"); expect(pair.b.cmdTypes()).toEqual(["ssh_exec", "ssh_exec_status"]); // The relay was opened once and closed with the named child-exit word. + const exec = pair.kick(); + if (!exec) throw new Error("the act never kicked an exec"); expect(openCalls).toHaveLength(1); - expect(closeCalls).toEqual([{ paneId: pair.kick()?.execId, reason: "child-exit" }]); + expect(closeCalls).toEqual([{ paneId: exec.execId, reason: "child-exit" }]); // The unspent key is revoked on the way out; the trail names the cause. expect((await keys.listByUser(owner)).length).toBe(keysBefore); const audits = await upgradeRows(); @@ -364,9 +373,10 @@ describe("setupHere relay mode", () => { const res = await setupHere({ viewerId: owner, paneId }); expect(res.ok).toBe(false); if (res.ok) return; - expect(res.refusal.code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); - expect(res.refusal.message).toContain(NODE_A); - expect(res.refusal.message).toMatch(/online/i); + const refusal = coded(res.refusal); + expect(refusal.code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); + expect(refusal.message).toContain(NODE_A); + expect(refusal.message).toMatch(/online/i); expect(openCalls).toHaveLength(0); // never re-opened expect(pair.b.cmdTypes()).toEqual([]); // B was never even asked to exec expect((await keys.listByUser(owner)).length).toBe(keysBefore); // no key minted @@ -397,18 +407,23 @@ describe("setupHere relay mode", () => { return DONE_OK; }, }); + // `d` is filled by the onKick closure; the accessor re-widens what flow + // analysis narrows at the declaration. + const seenD = () => d as { id: string; handle: ScriptedNode } | null; try { const res = await setupHere({ viewerId: owner, paneId }); expect(res.ok).toBe(true); if (!res.ok) return; - const nodeId = (d as { id: string; handle: ScriptedNode } | null)?.id ?? ""; + const machine = seenD(); + const nodeId = machine?.id ?? ""; expect(nodeId).not.toBe(""); expect(res.value).toEqual({ nodeId }); // The relay was RE-OPENED for this act, keyed to the exec's ephemeral // id (never the pane's), carrying the destination's pin; closed with // the named word once the exec's child was gone. - const execId = pair.kick()?.execId; - expect(execId).toBeDefined(); + const kick = pair.kick(); + if (!kick) throw new Error("the act never kicked an exec"); + const execId = kick.execId; expect(execId).not.toBe(paneId); expect(openCalls).toHaveLength(1); expect(openCalls[0]?.paneId).toBe(execId); @@ -418,15 +433,14 @@ describe("setupHere relay mode", () => { expect(closeCalls).toEqual([{ paneId: execId, reason: "child-exit" }]); // The composed run: the exec's OWN config dir, BatchMode first, the // installer one-liner with the destination host as the scripted name. - const kick = pair.kick(); - expect(kick?.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, kick.execId)); - expect(kick?.presetFlags.slice(0, 2)).toEqual(["-o", "BatchMode=yes"]); - expect(kick?.presetFlags).toContain("--"); - expect(kick?.relay).toBe(true); - expect(kick?.agentSocketPath).toBeNull(); - expect(kick?.command).toContain(`install.sh?setup_key=${mintedKey(kick)}`); - expect(kick?.command).toContain("SUBSHELL_NODE_NAME="); - expect(kick?.command).toContain(HOST); + expect(kick.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, execId)); + expect(kick.presetFlags.slice(0, 2)).toEqual(["-o", "BatchMode=yes"]); + expect(kick.presetFlags).toContain("--"); + expect(kick.relay).toBe(true); + expect(kick.agentSocketPath).toBeNull(); + expect(kick.command).toContain(`install.sh?setup_key=${mintedKey(kick)}`); + expect(kick.command).toContain("SUBSHELL_NODE_NAME="); + expect(kick.command).toContain(HOST); expect(polls).toBeGreaterThanOrEqual(3); // Audit: exactly one enrolled row naming ids, never the key. const audits = await upgradeRows(); @@ -443,10 +457,10 @@ describe("setupHere relay mode", () => { }); expect(audits[0]?.metadataJson).not.toContain("nsk_"); expect(JSON.stringify(res)).not.toContain("nsk_"); - d?.handle.detach(); + seenD()?.handle.detach(); await nodes.deleteById(nodeId).catch(() => {}); } finally { - d?.handle.detach(); + seenD()?.handle.detach(); pair.a?.detach(); pair.b.detach(); } @@ -495,8 +509,9 @@ describe("setupHere relay mode", () => { const res = await setupHere({ viewerId: owner, paneId }); expect(res.ok).toBe(false); if (res.ok) return; - expect(res.refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_FAILED); - expect(res.refusal.message).toContain("without enrolling"); + const refusal = coded(res.refusal); + expect(refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_FAILED); + expect(refusal.message).toContain("without enrolling"); expect((await keys.listByUser(owner)).length).toBe(keysBefore); } finally { await clearUpgradeRows(); @@ -523,20 +538,23 @@ describe("setupHere direct mode (B's own keys, no relay)", () => { }, status: () => DONE_OK, }); + // Same closure-filled `d`: the accessor re-widens the declaration's flow type. + const seenD = () => d as { id: string; handle: ScriptedNode } | null; try { const res = await setupHere({ viewerId: owner, paneId }); expect(res.ok).toBe(true); if (!res.ok) return; const kick = pair.kick(); - expect(kick?.relay).toBe(false); - expect(kick?.agentSocketPath).toBe("/run/user/700/agent.sock"); - expect(kick?.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, kick.execId)); + if (!kick) throw new Error("the act never kicked an exec"); + expect(kick.relay).toBe(false); + expect(kick.agentSocketPath).toBe("/run/user/700/agent.sock"); + expect(kick.configPath).toBe(buildSshConfigPath(SCRIPTED_DATA_DIR, kick.execId)); // The direct pane's exec never touched the relay broker, and A's live // absence was irrelevant to it (no key-home gate was walked). expect(openCalls).toHaveLength(0); expect(closeCalls).toHaveLength(0); - d?.handle.detach(); - await nodes.deleteById(d?.id ?? "").catch(() => {}); + seenD()?.handle.detach(); + await nodes.deleteById(seenD()?.id ?? "").catch(() => {}); } finally { await clearUpgradeRows(); pair.b.detach(); diff --git a/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx b/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx index e8ef0b884..faa1e4207 100644 --- a/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx +++ b/apps/server/web/src/components/__tests__/setup-here-dialog.test.tsx @@ -52,7 +52,7 @@ describe("SetupHereDialog", () => { globalThis.fetch = ((input: unknown, init?: RequestInit) => { const url = new URL(String(input), "http://localhost"); calls.push({ url: url.pathname, body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined }); - return Promise.resolve(new Response(JSON.stringify(answer.body), { status: answer.status ?? 200 })) as never; + return Promise.resolve(new Response(JSON.stringify(answer.body), { status: answer.status ?? 200 })); }) as typeof fetch; return { calls, restore: () => (globalThis.fetch = original) }; } From a081310d7afe3caa0cf72e78828bf4e2a028c1e4 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:31:09 -0700 Subject: [PATCH 72/92] fix(ssh-relay): classify dark-destination curl failure as the named egress cause --- .../__tests__/ssh-setup-here.service.test.ts | 80 +++++++++++++++++++ apps/server/api/src/services/nodes/ssh-rpc.ts | 8 +- .../src/services/ssh-setup-here.service.ts | 48 +++++++++-- .../__tests__/subshell-actions-menu.test.tsx | 2 +- 4 files changed, 131 insertions(+), 7 deletions(-) diff --git a/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts index 9243bfd66..7a98af825 100644 --- a/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts @@ -5,6 +5,7 @@ import type { SshConnectionSnapshotWire, SshExecCommand } from "@internal/subshe import { hashPassword } from "better-auth/crypto"; import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { APP_BASE_URL } from "@/constants.js"; import { db } from "@/db/index.js"; import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; @@ -560,4 +561,83 @@ describe("setupHere direct mode (B's own keys, no relay)", () => { pair.b.detach(); } }); + + // The dominant no-egress shape (spec §7/§13): a DARK destination never + // runs install.sh, so none of the script's sentences exist in the answer; + // the act's FIRST curl is what fails, and the named cause must come from + // curl's own words/exit codes, not the generic install-exit stage. + const darkD = (code: number, stderr: string) => ({ + state: "done" as const, + code, + timedOut: false, + stdout: "", + stderr, + }); + + it("a dark destination (DNS failure on the first fetch) answers the NAMED egress cause, not install-exit", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: false }); + const keysBefore = (await keys.listByUser(owner)).length; + const pair = attachPair({ + withA: false, + status: () => darkD(6, "curl: (6) Could not resolve host: plane.invalid\n"), + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + const refusal = coded(res.refusal); + expect(refusal.code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + expect(refusal.message).toContain("could not reach"); + expect(refusal.message).toContain(APP_BASE_URL); + const audits = await upgradeRows(); + expect(audits).toHaveLength(1); + expect(audits[0]?.metadataJson).toContain('"cause":"egress"'); + expect(audits[0]?.metadataJson).not.toContain("install-exit"); + expect((await keys.listByUser(owner)).length).toBe(keysBefore); + expect(pair.b.cmdTypes()).toEqual(["ssh_exec", "ssh_exec_status"]); + } finally { + await clearUpgradeRows(); + pair.b.detach(); + } + }); + + it("a dark destination (refused TCP connect) answers the egress cause", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: false }); + const pair = attachPair({ + withA: false, + status: () => darkD(7, "curl: (7) Failed to connect to 10.9.9.9 port 80 after 3004 ms: Connection refused\n"), + }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(coded(res.refusal).code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + const audits = await upgradeRows(); + expect(audits[0]?.metadataJson).toContain('"cause":"egress"'); + expect(audits[0]?.metadataJson).not.toContain("install-exit"); + } finally { + await clearUpgradeRows(); + pair.b.detach(); + } + }); + + it("curl's connect-failure exit codes classify without curl's sentence (belt: no installer banner)", async () => { + await clearUpgradeRows(); + const paneId = await mkPane({ keyHome: false }); + // The belt arm: an older or stripped curl that exits 6/7 but words + // nothing. No `==>` banner means install.sh never ran, so the only + // network actor that failed is the act's first curl. + const pair = attachPair({ withA: false, status: () => darkD(7, "") }); + try { + const res = await setupHere({ viewerId: owner, paneId }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(coded(res.refusal).code).toBe(BackendErrorCodes.SSH_UPGRADE_EGRESS); + } finally { + await clearUpgradeRows(); + pair.b.detach(); + } + }); }); diff --git a/apps/server/api/src/services/nodes/ssh-rpc.ts b/apps/server/api/src/services/nodes/ssh-rpc.ts index dce984a87..456f10984 100644 --- a/apps/server/api/src/services/nodes/ssh-rpc.ts +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -231,7 +231,13 @@ export async function sshExec( export async function sshExecStatus(nodeId: string, execId: string): Promise { let data: unknown; try { - data = await sendCommand(nodeId, { type: "ssh_exec_status", execId }); + // 30 s, not the 10 s default: the ANSWER is cheap (the run itself is + // off-chain by design) but the frame rides the node's serial command + // chain, so a slow earlier command can park this poll behind it. A + // timed-out poll costs more than a late one: the caller names + // exec-lost-timeout, revokes the unspent setup key, and reports a live + // install as lost. The headroom is the whole honest mitigation. + data = await sendCommand(nodeId, { type: "ssh_exec_status", execId }, { timeoutMs: 30_000 }); } catch (err) { if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); throw err; diff --git a/apps/server/api/src/services/ssh-setup-here.service.ts b/apps/server/api/src/services/ssh-setup-here.service.ts index 6d2bf9ee2..ff0af51f5 100644 --- a/apps/server/api/src/services/ssh-setup-here.service.ts +++ b/apps/server/api/src/services/ssh-setup-here.service.ts @@ -57,9 +57,12 @@ import { logger } from "@/utils/logger.js"; * the registry's `agent` facts appear only when `ready` lands). Success * is that fact, not the installer's own cheer line. * - * Every exit writes ONE `node.ssh_upgrade.run` audit row naming ids, the - * destination, the grant, and the OUTCOME/stage - never the key, never the - * installer's words. + * Every exit AFTER THE KEY EXISTS writes ONE `node.ssh_upgrade.run` audit + * row naming ids, the destination, the grant, and the OUTCOME/stage - never + * the key, never the installer's words; an unexpected internal throw records + * its own `internal-error` stage on the way out (best effort) before + * propagating. Refusals raised by the gates write no row - there is no key + * or session to account for there. */ /** How long B's ssh child may run before the node-side deadline ends it. An installer pulls a ~100 MB binary; this is minutes, honestly. */ @@ -120,6 +123,26 @@ function coded(status: 400 | 403 | 404 | 409 | 502, code: BackendErrorCodes, mes return { ok: false, refusal: { status, code, message } }; } +/** + * Does this failed-install answer say "D could not reach the plane" (§7's + * NAMED egress stage)? Two shapes carry that fact, and both matter: the + * rendered installer's own sentence (it was running and a fetch failed + * mid-way), and curl's own connect/DNS failure - the act's FIRST fetch is + * install.sh itself, so a DARK destination never prints a script line at + * all; its answer is curl's stderr and curl's exit code alone (6 resolve, + * 7 connect, 28 timeout). A key or artifact failure never looks like this: + * those answer an HTTP status (curl 22, or the script's own advice), not a + * failed connect, and the belt arm keys on the missing `==>` banner, which + * only the pre-script curl failure lacks. + */ +function couldNotReachPlane(output: string, code: number | null): boolean { + if (output.includes("could not reach") || output.includes("could not fetch the checksum")) return true; + if (/\bcurl: \((6|7|28)\)/.test(output)) return true; + const lower = output.toLowerCase(); + if (lower.includes("could not resolve host") || /\bfailed( to)? connect\b/.test(lower)) return true; + return (code === 6 || code === 7) && !output.includes("==>"); +} + /** What a completed act answers: the enrolled node's id, learned from `ready`. */ export interface SetupHereResult { nodeId: string; @@ -277,6 +300,18 @@ export async function setupHere(args: { viewerId: string; paneId: string }): Pro logger.withError(err).warn(`setup-here: could not revoke the unspent key for pane ${pane.id}`); } }; + /** + * The post-mint invariant: even an unexpected internal throw (not the + * machine refusing, something HERE failing) leaves its terminal row. + * Best-effort: the audit system must never replace the original error. + */ + const auditInternalFailure = async (): Promise => { + try { + await runAudit("refused", "internal-error"); + } catch (auditErr) { + logger.withError(auditErr).warn(`setup-here: could not audit the internal failure for pane ${pane.id}`); + } + }; /** Close the exec's own relay pairing (the act's child is gone; the socket should be). */ const closeExecRelay = async (): Promise => { if (grantId === null) return; @@ -308,6 +343,7 @@ export async function setupHere(args: { viewerId: string; paneId: string }): Pro await runAudit("refused", `exec-${err.kind}`); return r; } + await auditInternalFailure(); throw err; } @@ -325,6 +361,7 @@ export async function setupHere(args: { viewerId: string; paneId: string }): Pro await runAudit("refused", `exec-lost-${err.kind}`); return rpcRefusal(err); } + await auditInternalFailure(); throw err; } if (terminal.state === "done") break; @@ -368,8 +405,9 @@ export async function setupHere(args: { viewerId: string; paneId: string }): Pro ); } // The named causes, each from a phrase the rendered installer prints (the - // script's OWN words are the parser's contract; none of them is echoed). - if (output.includes("could not reach") || output.includes("could not fetch the checksum")) { + // script's OWN words are the parser's contract; none of them is echoed), + // or from curl's own connect-failure shape when the script never ran. + if (couldNotReachPlane(output, terminal.code)) { // §7's egress refusal: its own code, distinct from any key error, and // the pane keeps running untouched. return await fail( diff --git a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx index a9cb808b3..1922bdf9b 100644 --- a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx @@ -658,7 +658,7 @@ describe("SubshellActionsMenu — prompt input availability", () => { }); }); -describe("SubshellActionsMenu — Set up Subshell here (spec 2026-10-08 §7)", () => { +describe("SubshellActionsMenu - Set up Subshell here (spec 2026-10-08 §7)", () => { afterEach(cleanup); it("offers it on the owner's live SSH pane and opens the dialog", async () => { From f0b52d574eab4d3acea676f9a3a918eab67f05dd Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:45:03 -0700 Subject: [PATCH 73/92] feat(ssh-relay): client shapes and hooks for grants, approvals, and host pins The Task 15 operator screens read T10/T11/T12 through this lib+hooks half: the grant/approval/host-pin wire mirrors beside their route definitions, the TanStack-Query hooks with one key per list, and the pure client spelling of the SSH_MAX_GRANT_FINGERPRINTS hard cap (the screen shows the server's own refusal before it, so a truncation can never be mistaken for the answer). Co-Authored-By: Claude --- apps/server/web/src/hooks/use-ssh.ts | 148 ++++++++++++++++++ apps/server/web/src/lib/__tests__/ssh.test.ts | 28 ++++ apps/server/web/src/lib/ssh.ts | 85 +++++++++- 3 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 apps/server/web/src/lib/__tests__/ssh.test.ts diff --git a/apps/server/web/src/hooks/use-ssh.ts b/apps/server/web/src/hooks/use-ssh.ts index 3bd151114..24073a5a3 100644 --- a/apps/server/web/src/hooks/use-ssh.ts +++ b/apps/server/web/src/hooks/use-ssh.ts @@ -1,7 +1,11 @@ import { apiFetch, apiPost } from "@internal/node-admin"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { + SshAgentIdentity, SshAliasesView, + SshGrant, + SshGrantRequest, + SshHostPin, SshLaunchRequest, SshLaunchResponse, SshSavedHost, @@ -109,3 +113,147 @@ export function useLaunchSsh() { onSuccess: () => void invalidate(), }); } + +/* ------------------------------------------------------------------ */ +/* key grants, first-use approvals, destination host-key pins */ +/* (spec 2026-10-08 §6, §8-§9; the operator screens of Task 15) */ +/* ------------------------------------------------------------------ */ + +export const SSH_GRANTS_QUERY_KEY = ["ssh-grants"] as const; +export const SSH_GRANT_REQUESTS_QUERY_KEY = ["ssh-grant-requests"] as const; +export const SSH_HOST_PINS_QUERY_KEY = ["ssh-host-pins"] as const; +/** The roster is per-request: the key home answers live, so no two cards share an entry. */ +export const sshRosterQueryKey = (requestId: string) => ["ssh-grant-roster", requestId] as const; + +/** The owner's standing key grants, newest first. */ +export function useSshGrants() { + return useQuery({ + queryKey: SSH_GRANTS_QUERY_KEY, + queryFn: () => apiFetch<{ grants: SshGrant[] }>("/api/ssh/grants"), + }); +} + +/** + * `DELETE /api/ssh/grants/:id` - the instant both-ways cut: the row goes and + * every live relay that ran under it is torn down server-side. 204 no body; + * a foreign row is the same 404 as an absent one. + */ +export function useRevokeSshGrant() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiFetch(`/api/ssh/grants/${encodeURIComponent(id)}`, { method: "DELETE" }), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: SSH_GRANTS_QUERY_KEY }), + }); +} + +/** The owner's outstanding first-use approvals, newest first (the GET sweeps expired rows first). */ +export function useSshGrantRequests() { + return useQuery({ + queryKey: SSH_GRANT_REQUESTS_QUERY_KEY, + queryFn: () => apiFetch<{ requests: SshGrantRequest[] }>("/api/ssh/grant-requests"), + }); +} + +/** + * The key home's live agent roster to approve FROM. `enabled` gates on the + * card actually being open: an offline or refusing key home answers a named + * error and the request stays pending, never a fabricated empty roster, so + * the roster is fetched on demand rather than for every queued question. + */ +export function useSshGrantRoster(requestId: string, enabled: boolean) { + return useQuery({ + queryKey: sshRosterQueryKey(requestId), + queryFn: () => + apiFetch<{ identities: SshAgentIdentity[] }>( + `/api/ssh/grant-requests/${encodeURIComponent(requestId)}/identities`, + ), + enabled, + }); +} + +/** The approve body: the selected fingerprints, and an optional display name for the new grant. */ +export interface SshGrantApproval { + requestId: string; + fingerprints: string[]; + /** Display name override; absent keeps the server's default naming */ + name?: string; +} + +/** + * `POST /api/ssh/grant-requests/:id/approve` - answers YES and writes the + * standing grant. Both lists move: the row leaves the queue, the grant joins + * the ledger. An over-cap selection is a hard refusal server-side, never a + * truncation; the screen shows the same sentence before sending. + */ +export function useApproveSshGrantRequest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (draft: SshGrantApproval) => + apiPost<{ grant: SshGrant }>(`/api/ssh/grant-requests/${encodeURIComponent(draft.requestId)}/approve`, { + fingerprints: draft.fingerprints, + ...(draft.name ? { name: draft.name } : {}), + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: SSH_GRANT_REQUESTS_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: SSH_GRANTS_QUERY_KEY }); + }, + }); +} + +/** + * `POST /api/ssh/grant-requests/:id/deny` - answers NO. No grant and no pin + * exists for a denial; a later relaunch simply asks again, so denying needs + * no confirm. + */ +export function useDenySshGrantRequest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (requestId: string) => + apiPost<{ requestId: string }>(`/api/ssh/grant-requests/${encodeURIComponent(requestId)}/deny`, {}), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: SSH_GRANT_REQUESTS_QUERY_KEY }), + }); +} + +/** The owner's destination host-key pins, newest first (key bytes are display-excluded by construction). */ +export function useSshHostPins() { + return useQuery({ + queryKey: SSH_HOST_PINS_QUERY_KEY, + queryFn: () => apiFetch<{ pins: SshHostPin[] }>("/api/ssh/host-pins"), + }); +} + +/** The explicit-pin body (spec §9's "or an explicit pin" door): canonical destination + one known_hosts line. */ +export interface SshHostPinDraft { + /** Canonical resolved destination `user@host:port` (the spelling the launch keys the pin by) */ + destination: string; + /** The pinned entry in OpenSSH known_hosts form */ + hostKey: string; +} + +/** + * `POST /api/ssh/host-pins` - supplies an explicit pin for a destination the + * key home has not connected to yet. A destination already pinned to a + * DIFFERENT key is the named hard block (409, nothing written): delete the + * pin first, that is the whole recovery flow. + */ +export function useCreateSshHostPin() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (draft: SshHostPinDraft) => apiPost<{ pin: SshHostPin }>("/api/ssh/host-pins", draft), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: SSH_HOST_PINS_QUERY_KEY }), + }); +} + +/** + * `DELETE /api/ssh/host-pins/:destination` - the TOFU recovery's first half; + * the next capture at a fresh key re-decides trust. The destination is the + * key, so it rides the path encoded. + */ +export function useDeleteSshHostPin() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (destination: string) => + apiFetch<{ deleted: boolean }>(`/api/ssh/host-pins/${encodeURIComponent(destination)}`, { method: "DELETE" }), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: SSH_HOST_PINS_QUERY_KEY }), + }); +} diff --git a/apps/server/web/src/lib/__tests__/ssh.test.ts b/apps/server/web/src/lib/__tests__/ssh.test.ts new file mode 100644 index 000000000..343559123 --- /dev/null +++ b/apps/server/web/src/lib/__tests__/ssh.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test"; +import { SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; +import { grantSelectionError } from "@/lib/ssh"; + +/** + * The client-side half of the grant selection rule (spec 2026-10-08 §5.4, + * §6.2). The server refuses an over-cap selection as a HARD error, never a + * truncation; the screen shows the same sentence before the POST so a person + * learns the cap from a red line, not from a lost click. The tests pin the + * boundary at SSH_MAX_GRANT_FINGERPRINTS itself: at the cap is legal. + */ +describe("grantSelectionError (the over-cap hard error)", () => { + const fp = (i: number) => `SHA256:${"A".repeat(40)}${i}`; + const many = (n: number) => Array.from({ length: n }, (_, i) => fp(i)); + + it("accepts a selection at the cap", () => { + expect(grantSelectionError(many(SSH_MAX_GRANT_FINGERPRINTS))).toBeNull(); + }); + + it("accepts an empty selection (an empty set is legal, it serves nothing)", () => { + expect(grantSelectionError([])).toBeNull(); + }); + + it("names the cap once the selection exceeds it", () => { + const error = grantSelectionError(many(SSH_MAX_GRANT_FINGERPRINTS + 1)); + expect(error).toContain(String(SSH_MAX_GRANT_FINGERPRINTS)); + }); +}); diff --git a/apps/server/web/src/lib/ssh.ts b/apps/server/web/src/lib/ssh.ts index c36040491..54330ed12 100644 --- a/apps/server/web/src/lib/ssh.ts +++ b/apps/server/web/src/lib/ssh.ts @@ -1,13 +1,15 @@ /** - * The client shapes of the `/api/ssh` surface (spec 2026-10-07 §5–§7), the - * same mirror `lib/prompts.ts` keeps for the prompt library: local types that - * state the wire field for field, read through `apiFetch`. The definitions - * live beside the routes that answer them + * The client shapes of the `/api/ssh` surface (specs 2026-10-07 and + * 2026-10-08), the same mirror `lib/prompts.ts` keeps for the prompt library: + * local types that state the wire field for field, read through `apiFetch`. The + * definitions live beside the routes that answer them * (`apps/server/api/src/api/ssh/ssh-views.ts`); the copy here restates that * contract for the SPA, and the two are kept honest by review like every * other mirror type in this app. */ +import { SSH_MAX_GRANT_FINGERPRINTS } from "@internal/subshell-protocol"; + /** One ProxyJump hop as the frozen snapshot spells it. */ export interface SshHop { /** Hop hostname (bracketed IPv6 literal kept as given) */ @@ -155,3 +157,78 @@ export interface SshLaunchRequest { export interface SshLaunchResponse { subshell: { id: string }; } + +/** + * One standing key grant as the grants screen reads it (spec 2026-10-08 §6.1, + * §8). The fingerprint set is public `SHA256:` identifiers and it is + * deliberately on this wire: the screen is the owner's own, and "which keys + * serve" is exactly what the owner manages. + */ +export interface SshGrant { + /** Grant row id (uuid) */ + id: string; + /** Display name the operator gave the grant */ + name: string; + /** Key home machine: its ssh-agent signs for this grant */ + keyHomeNodeId: string; + /** Destination selector, stored RESOLVED: a concrete hostname or a '*' host pattern */ + resolvedSelector: string; + /** The selected public identities (at most SSH_MAX_GRANT_FINGERPRINTS); an empty set serves nothing */ + fingerprints: string[]; + /** Which door created the row: an approved first use, or the grants screen */ + createdVia: "first-use" | "manual"; + /** ISO 8601 creation stamp (the match tie-break: oldest wins) */ + createdAt: string; + /** ISO 8601 of the last operator edit */ + updatedAt: string; +} + +/** One first-use approval request as the queue reads it (spec 2026-10-08 §6.2). */ +export interface SshGrantRequest { + /** Request row id (uuid) - the opaque ref the refusal and the notification name */ + id: string; + /** The key home whose approval is asked */ + keyHomeNodeId: string; + /** The resolved destination hostname the launch would have dialed */ + resolvedSelector: string; + /** Pre-selection riding the request, or null (the approver picks) */ + requestedFingerprints: string[] | null; + /** The asking pane's id (the requester the screen names) */ + paneId: string; + /** The connecting machine of the asking launch */ + bNodeId: string; + /** ISO 8601 deadline (24 h); past it the sweep answers the row expired */ + expiresAt: string; + /** Lifecycle state; only pending can be answered */ + status: "pending" | "approved" | "denied" | "expired"; + /** ISO 8601 creation stamp */ + createdAt: string; +} + +/** One key home agent identity from the approval roster (blobs are withheld by construction). */ +export interface SshAgentIdentity { + /** Public agent identity in the canonical SHA256: notation (the approval sends it back verbatim) */ + fingerprint: string; + /** OpenSSH's label for the key, as the agent reports it (display only) */ + comment: string; +} + +/** One pinned destination host key (spec 2026-10-08 §9): destination + public fingerprint, never key bytes. */ +export interface SshHostPin { + /** Pin row id (uuid) */ + id: string; + /** Canonical resolved destination `user@host:port` */ + destination: string; + /** The pinned key's SHA256: display fingerprint (public identifier) */ + fingerprint: string; + /** ISO 8601 first capture (the TOFU moment) */ + createdAt: string; + /** ISO 8601 of the last accepted match */ + updatedAt: string; +} + +/** The client-side spelling of the server's hard selection cap (spec 2026-10-08 §5.4). */ +export function grantSelectionError(fingerprints: readonly string[]): string | null { + if (fingerprints.length <= SSH_MAX_GRANT_FINGERPRINTS) return null; + return `A grant can serve at most ${SSH_MAX_GRANT_FINGERPRINTS} keys. Deselect some before approving.`; +} From 84dba145ab7028825e2e2d8dede109a1c1dd471d Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:45:06 -0700 Subject: [PATCH 74/92] feat(ssh-relay): grants screen with the instant-both-ways revoke List-and-revoke only, on purpose: which keys serve is immutable server-side, so the screen offers no key picker and the roster fetch exists only on the approval door. Revoke confirms under a static title with the grant's name in the body (ruling 2026-09-30) and a refused DELETE renders red on its row. Co-Authored-By: Claude --- .../ssh/__tests__/grants-screen.test.tsx | 162 ++++++++++++++++++ .../web/src/components/ssh/grants-screen.tsx | 108 ++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 apps/server/web/src/components/ssh/__tests__/grants-screen.test.tsx create mode 100644 apps/server/web/src/components/ssh/grants-screen.tsx diff --git a/apps/server/web/src/components/ssh/__tests__/grants-screen.test.tsx b/apps/server/web/src/components/ssh/__tests__/grants-screen.test.tsx new file mode 100644 index 000000000..997a8ff66 --- /dev/null +++ b/apps/server/web/src/components/ssh/__tests__/grants-screen.test.tsx @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { type ConfirmOptions, setConfirmHandler } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { GrantsScreen } from "@/components/ssh/grants-screen"; + +/** + * The grants screen (spec 2026-10-08 §6.1, §8): the owner's standing key + * grants, their selected agent identities, and the instant-both-ways revoke. + * The revoke is the screen's one destructive act, so its confirm is pinned: + * a STATIC title (the ruling 2026-09-30: the name rides the body) and a + * refusal of the DELETE rendered red on the row that failed. + */ + +const FP_ONE = `SHA256:${"A".repeat(43)}`; +const FP_TWO = `SHA256:${"B".repeat(43)}`; + +const GRANT = { + id: "g1", + name: "prod keys", + keyHomeNodeId: "nodeA", + resolvedSelector: "*.prod.example.com", + fingerprints: [FP_ONE, FP_TWO], + createdVia: "first-use", + createdAt: "2026-10-01T10:00:00Z", + updatedAt: "2026-10-01T10:00:00Z", +}; + +interface Sent { + method: string; + path: string; +} + +function stubFetch( + restore: (undo: () => void) => void, + opts: { revokeStatus?: number; revokeMessage?: string } = {}, +): Sent[] { + const sent: Sent[] = []; + const original = globalThis.fetch; + restore(() => { + globalThis.fetch = original; + }); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + sent.push({ method, path: url.pathname }); + if (url.pathname === "/api/ssh/grants") { + return new Response(JSON.stringify({ grants: [GRANT] }), { status: 200 }); + } + if (url.pathname === "/api/nodes") { + return new Response(JSON.stringify({ nodes: [{ id: "nodeA", name: "vault" }] }), { status: 200 }); + } + if (url.pathname === "/api/ssh/grants/g1" && method === "DELETE") { + if (opts.revokeStatus) { + return new Response(JSON.stringify({ message: opts.revokeMessage ?? "gone" }), { + status: opts.revokeStatus, + headers: { "content-type": "application/json" }, + }); + } + return new Response(null, { status: 204 }); + } + throw new Error(`unstubbed fetch: ${method} ${url.pathname}`); + }) as typeof globalThis.fetch; + return sent; +} + +const restores: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const undo of restores.splice(0)) undo(); +}); + +function mockConfirm(answer: boolean) { + const seen: ConfirmOptions[] = []; + const previous = setConfirmHandler((options) => { + seen.push(options); + return Promise.resolve(answer); + }); + return { seen, restore: () => setConfirmHandler(previous) }; +} + +function renderScreen() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return render( + + + , + ); +} + +describe("GrantsScreen", () => { + it("lists each grant with its machine, selector and selected fingerprints", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + expect(await screen.findByText("prod keys")).toBeDefined(); + // The key home reads by NAME (rail doctrine: nothing rendered derives from the id). + // The row's one detail line carries machine and selector, `machine · selector`. + expect(screen.getByText(/vault/)).toBeDefined(); + expect(screen.getByText(/\*\.prod\.example\.com/)).toBeDefined(); + expect(screen.getByText(FP_ONE)).toBeDefined(); + expect(screen.getByText(FP_TWO)).toBeDefined(); + }); + + it("says so plainly when there is nothing standing", async () => { + const original = globalThis.fetch; + restores.push(() => { + globalThis.fetch = original; + }); + globalThis.fetch = (async (input: unknown) => { + const path = new URL(String(input), "http://localhost").pathname; + const body = path === "/api/ssh/grants" ? { grants: [] } : { nodes: [] }; + return new Response(JSON.stringify(body), { status: 200 }); + }) as typeof globalThis.fetch; + renderScreen(); + expect(await screen.findByText(/No key grants yet/)).toBeDefined(); + }); + + it("revokes only after a confirm whose title is static, DELETEing the row", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + const confirm = mockConfirm(true); + restores.push(confirm.restore); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Revoke grant prod keys" })); + await waitFor(() => expect(confirm.seen.length).toBe(1)); + // Static title (design ruling 2026-09-30): the grant's name rides the body, never the heading. + expect(confirm.seen[0]?.title).toBe("Revoke this grant?"); + expect(confirm.seen[0]?.description).toContain("prod keys"); + await waitFor(() => expect(sent.some((s) => s.method === "DELETE" && s.path === "/api/ssh/grants/g1")).toBe(true)); + }); + + it("sends nothing when the confirm is declined", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + const confirm = mockConfirm(false); + restores.push(confirm.restore); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Revoke grant prod keys" })); + await waitFor(() => expect(confirm.seen.length).toBe(1)); + expect(sent.some((s) => s.method === "DELETE")).toBe(false); + }); + + it("renders a refused revoke as the row's own red line", async () => { + stubFetch( + (undo) => { + restores.push(undo); + }, + { revokeStatus: 404, revokeMessage: "No such grant" }, + ); + const confirm = mockConfirm(true); + restores.push(confirm.restore); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Revoke grant prod keys" })); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("No such grant"); + expect(alert.className).toContain("text-destructive"); + }); +}); diff --git a/apps/server/web/src/components/ssh/grants-screen.tsx b/apps/server/web/src/components/ssh/grants-screen.tsx new file mode 100644 index 000000000..1aaa04064 --- /dev/null +++ b/apps/server/web/src/components/ssh/grants-screen.tsx @@ -0,0 +1,108 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + confirmAction, + errMessage, +} from "@internal/node-admin"; +import { Trash2 } from "lucide-react"; +import { useNodes } from "@/hooks/use-nodes"; +import { useRevokeSshGrant, useSshGrants } from "@/hooks/use-ssh"; +import type { SshGrant } from "@/lib/ssh"; + +/** + * The grants screen (spec 2026-10-08 §6.1, §8): the owner's standing key + * grants, the selected public identities that carry them, and the revoke. + * The rows are line items, `label` over `detail`: the name, then machine and + * selector, then the fingerprints (public `SHA256:` identifiers, deliberately + * on the owner's own screen; "which keys serve" is what the owner manages). + * + * Which keys serve is IMMUTABLE by design: the server refuses to widen a + * standing selection (spec §6.1), so this screen has no key picker at all - + * the way to change a selection is revoke and re-ask, and the screen must + * not offer what the routes refuse. Revoke is the screen's one destructive + * act: it cuts both ways instantly (the row goes, live relays under it are + * torn down), so it confirms, with the consequence said in the body and a + * STATIC title. + */ +export function GrantsScreen() { + const { data: view } = useSshGrants(); + const { data: nodeData } = useNodes(); + const revoke = useRevokeSshGrant(); + const grants = view?.grants ?? []; + + const nameById = new Map( + (Array.isArray(nodeData?.nodes) ? nodeData.nodes : []).map((n) => [n.id, n.name]), + ); + + async function revokeGrant(grant: SshGrant) { + const ok = await confirmAction({ + // Static title (ruling 2026-09-30); the body carries the name. + title: "Revoke this grant?", + description: `"${grant.name}" stops signing right away, and every session already running under it is cut. You can grant again from the next approval.`, + confirmLabel: "Revoke", + danger: true, + }); + if (!ok) return; + revoke.mutate(grant.id); + } + + return ( + + + Key grants + + Which machine's agent keys sign for which destinations. Revoking cuts the grant and every live session + running under it at once. + + + + {grants.length === 0 ? ( +

+ No key grants yet. A first-use approval you answer yes becomes a standing grant here. +

+ ) : ( +
    + {grants.map((grant) => ( +
  • +
    +
    {grant.name}
    +
    + {[nameById.get(grant.keyHomeNodeId) ?? grant.keyHomeNodeId, grant.resolvedSelector].join(" · ")} +
    + {grant.fingerprints.map((fingerprint) => ( +
    + {fingerprint} +
    + ))} +
    + {grant.createdVia === "first-use" ? "From a first-use approval" : "Added by hand"} ·{" "} + {grant.createdAt.slice(0, 10)} +
    + {revoke.isError && revoke.variables === grant.id && ( +

    + {errMessage(revoke.error, "The grant could not be revoked.")} +

    + )} +
    + +
  • + ))} +
+ )} +
+
+ ); +} From 169a4bd00847cbf7d12f80e9fb0bf64c4a7a09b4 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:45:08 -0700 Subject: [PATCH 75/92] feat(ssh-relay): first-use approval queue naming the pane, machine, destination Each pending question carries its key home's live roster (T11) loaded with the card, the request's pre-selection carried, and the over-cap selection refused RED before the POST (a truncation must never read as the answer that was given). Approve sends the selection and an optional name; Deny answers without a confirm because it writes no grant and a relaunch simply re-asks. The card points a HostKeyAlias destination at the explicit-pin door (F2) instead of dead-ending. Co-Authored-By: Claude --- .../ssh/__tests__/pending-approvals.test.tsx | 223 ++++++++++++++++++ .../src/components/ssh/pending-approvals.tsx | 182 ++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx create mode 100644 apps/server/web/src/components/ssh/pending-approvals.tsx diff --git a/apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx b/apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx new file mode 100644 index 000000000..366d4421f --- /dev/null +++ b/apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { PendingApprovals } from "@/components/ssh/pending-approvals"; + +/** + * The first-use approval queue (spec 2026-10-08 §6.2): each card names the + * asking pane, the connecting machine and the destination, and lets the + * operator pick the grant's key set FROM the key home's live roster. The cap + * is the load-bearing case: more than SSH_MAX_GRANT_FINGERPRINTS is a RED + * hard error (a refused selection, not a silent truncation) and Approve stays + * gated until the selection is legal. + */ + +const fp = (i: number) => `SHA256:${i.toString().padStart(43, "0")}`; +const PRE = fp(1); + +const REQUEST = { + id: "req1", + keyHomeNodeId: "nodeA", + resolvedSelector: "build-1.example.com", + requestedFingerprints: [PRE], + paneId: "pane1", + bNodeId: "nodeB", + expiresAt: "2026-10-09T10:00:00Z", + status: "pending", + createdAt: "2026-10-08T10:00:00Z", +}; + +const ROSTER = Array.from({ length: 10 }, (_, i) => ({ fingerprint: fp(i + 1), comment: `key ${i + 1}` })); + +interface Sent { + method: string; + path: string; + body: unknown; +} + +function stubFetch(restore: (undo: () => void) => void): Sent[] { + const sent: Sent[] = []; + const original = globalThis.fetch; + restore(() => { + globalThis.fetch = original; + }); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + sent.push({ method, path: url.pathname, body }); + if (url.pathname === "/api/ssh/grant-requests") { + return new Response(JSON.stringify({ requests: [REQUEST] }), { status: 200 }); + } + if (url.pathname === "/api/ssh/grant-requests/req1/identities") { + return new Response(JSON.stringify({ identities: ROSTER }), { status: 200 }); + } + if (url.pathname === "/api/ssh/grant-requests/req1/approve") { + return new Response(JSON.stringify({ grant: { id: "g1" } }), { status: 200 }); + } + if (url.pathname === "/api/ssh/grant-requests/req1/deny") { + return new Response(JSON.stringify({ requestId: "req1" }), { status: 200 }); + } + if (url.pathname === "/api/nodes") { + return new Response( + JSON.stringify({ + nodes: [ + { id: "nodeA", name: "vault" }, + { id: "nodeB", name: "desk" }, + ], + }), + { + status: 200, + }, + ); + } + if (url.pathname === "/api/subshells") { + return new Response(JSON.stringify([{ id: "pane1", name: "builder", status: "running" }]), { status: 200 }); + } + throw new Error(`unstubbed fetch: ${method} ${url.pathname}`); + }) as typeof globalThis.fetch; + return sent; +} + +const restores: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const undo of restores.splice(0)) undo(); +}); + +/** Renders the queue inside a throwaway router: the requester renders as a Link. */ +function renderScreen() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const rootRoute = createRootRoute(); + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => , + }); + const subshellRoute = createRoute({ getParentRoute: () => rootRoute, path: "/subshells/$id", component: () => null }); + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, subshellRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + defaultPreload: false, + }); + void router.load(); + return render( + + + , + ); +} + +async function renderWithRoster() { + renderScreen(); + return screen.findAllByRole("checkbox"); +} + +describe("PendingApprovals", () => { + it("names the requester, the connecting machine and the destination", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + expect(await screen.findByText("builder")).toBeDefined(); + expect(screen.getByText(/desk/)).toBeDefined(); + expect(screen.getByText(/build-1\.example\.com/)).toBeDefined(); + }); + + it("shows the key home's roster with the request's pre-selection carried", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + const boxes = await renderWithRoster(); + expect(boxes.length).toBe(10); + const pre = screen.getByRole("checkbox", { name: PRE }); + expect(pre.getAttribute("data-checked")).not.toBeNull(); + }); + + it("refuses an over-cap selection as a RED hard error and gates Approve", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + // One is already chosen (the pre-selection); check until the ninth lands. + let boxes = await renderWithRoster(); + for (const box of boxes) { + if (box.getAttribute("data-checked") === null) fireEvent.click(box); + boxes = await screen.findAllByRole("checkbox"); + const chosen = boxes.filter((b) => b.getAttribute("data-checked") !== null).length; + if (chosen > 8) break; + } + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("8"); + expect(alert.className).toContain("text-destructive"); + expect((screen.getByRole("button", { name: "Approve" }) as HTMLButtonElement).disabled).toBe(true); + }); + + it("approves the selected fingerprints with the typed name", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + const boxes = await renderWithRoster(); + // Choose a second key, then take it back off: the pre-selection stays alone. + fireEvent.click(boxes[1]); + fireEvent.click(boxes[1]); + fireEvent.change(screen.getByLabelText("Grant name"), { target: { value: "prod keys" } }); + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + await waitFor(() => expect(sent.some((s) => s.path.endsWith("/approve"))).toBe(true)); + const approve = sent.find((s) => s.path.endsWith("/approve")); + expect(approve?.body).toEqual({ fingerprints: [PRE], name: "prod keys" }); + }); + + it("denies with one click: a denial writes no grant and simply re-asks later", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Deny" })); + await waitFor(() => expect(sent.some((s) => s.path.endsWith("/deny"))).toBe(true)); + }); + + it("names the key home's silence as the reason and leaves the card standing", async () => { + const original = globalThis.fetch; + restores.push(() => { + globalThis.fetch = original; + }); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/ssh/grant-requests/req1/identities") { + return new Response(JSON.stringify({ message: "The key home could not be reached" }), { + status: 502, + headers: { "content-type": "application/json" }, + }); + } + if (url.pathname === "/api/ssh/grant-requests") { + return new Response(JSON.stringify({ requests: [REQUEST] }), { status: 200 }); + } + if (url.pathname === "/api/nodes") { + return new Response(JSON.stringify({ nodes: [] }), { status: 200 }); + } + if (url.pathname === "/api/subshells") { + return new Response(JSON.stringify([]), { status: 200 }); + } + throw new Error(`unstubbed fetch: ${init?.method ?? "GET"} ${url.pathname}`); + }) as typeof globalThis.fetch; + renderScreen(); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("could not be reached"); + expect(screen.queryAllByRole("checkbox").length).toBe(0); + }); + + it("offers the explicit-pin way out for a HostKeyAlias destination", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + expect(await screen.findByText(/HostKeyAlias/)).toBeDefined(); + }); +}); diff --git a/apps/server/web/src/components/ssh/pending-approvals.tsx b/apps/server/web/src/components/ssh/pending-approvals.tsx new file mode 100644 index 000000000..de637b3c9 --- /dev/null +++ b/apps/server/web/src/components/ssh/pending-approvals.tsx @@ -0,0 +1,182 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + errMessage, + Fact, + Input, + Label, +} from "@internal/node-admin"; +import { Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useNodes } from "@/hooks/use-nodes"; +import { + useApproveSshGrantRequest, + useDenySshGrantRequest, + useSshGrantRequests, + useSshGrantRoster, +} from "@/hooks/use-ssh"; +import { useSubshellsList } from "@/hooks/use-subshells"; +import { grantSelectionError, type SshGrantRequest } from "@/lib/ssh"; + +/** + * The first-use approval queue (spec 2026-10-08 §6.2): every pending question + * the caller's key homes have asked, each card naming the asking pane, the + * connecting machine and the destination, with the key home's live roster to + * choose the grant's key set FROM. The selection cap is a HARD error, shown + * red the moment the ninth key is ticked: over-cap approvals are refused by + * the server, and the screen refuses before it, so a truncation can never be + * mistaken for the answer that was given. + * + * The roster loads with the card because it is what the answer is made of: + * an offline or refusing key home answers a named error and the request stays + * pending, so the card shows that sentence rather than an empty picker that + * would invite a yes against nothing. A denial needs no confirm: it writes + * only the audit row, and a later relaunch simply asks again. + */ +export function PendingApprovals() { + const { data: view } = useSshGrantRequests(); + const requests = (view?.requests ?? []).filter((r) => r.status === "pending"); + return ( + + + Pending approvals + + A launch that needs a key you have not granted yet asks here. An unanswered question waits 24 hours, then + expires. + + + + {requests.length === 0 ? ( +

Nothing is waiting for an answer.

+ ) : ( +
+ {requests.map((request) => ( + + ))} +
+ )} +
+
+ ); +} + +/** One pending question and the act that answers it. */ +function ApprovalCard({ request }: { request: SshGrantRequest }) { + const { data: nodeData } = useNodes(); + const { data: panes } = useSubshellsList(); + const roster = useSshGrantRoster(request.id, true); + const approve = useApproveSshGrantRequest(); + const deny = useDenySshGrantRequest(); + const [selected, setSelected] = useState(request.requestedFingerprints ?? []); + const [name, setName] = useState(""); + + const machineName = (id: string) => + (Array.isArray(nodeData?.nodes) ? nodeData.nodes : []).find((n) => n.id === id)?.name ?? id; + const paneName = (Array.isArray(panes) ? panes : []).find((p) => p.id === request.paneId)?.name ?? request.paneId; + + const selectionError = grantSelectionError(selected); + const toggle = (fingerprint: string) => + setSelected((prev) => + prev.includes(fingerprint) ? prev.filter((f) => f !== fingerprint) : [...prev, fingerprint], + ); + + function answerYes() { + // The guard behind the gate (substrate contract): Enter-path submits and + // render races land here even though the button is swept. + if (selectionError || selected.length === 0) return; + approve.mutate({ requestId: request.id, fingerprints: selected, name: name.trim() || undefined }); + } + + return ( +
+
+ + + {paneName} + + + {machineName(request.bNodeId)} + {machineName(request.keyHomeNodeId)} + + {request.resolvedSelector} + +
+

+ Expires {request.expiresAt.slice(0, 16).replace("T", " ")}. If this destination is trusted under an SSH + HostKeyAlias, add its host-key pin under Destination trust first; the key home cannot capture that one itself. +

+
+ + {roster.isPending && ( +

Asking the key home's agent…

+ )} + {roster.isError && ( +

+ {errMessage(roster.error, "The key home could not be reached. The request stays pending.")} +

+ )} + {roster.data && roster.data.identities.length === 0 && ( +

The key home's agent holds no keys right now.

+ )} + {roster.data && roster.data.identities.length > 0 && ( +
    + {roster.data.identities.map((identity) => ( +
  • + toggle(identity.fingerprint)} + /> +
    +
    {identity.fingerprint}
    + {identity.comment && ( +
    {identity.comment}
    + )} +
    +
  • + ))} +
+ )} + {selectionError && ( +

+ {selectionError} +

+ )} +
+
+ + setName(e.target.value)} + /> +
+ {approve.isError && ( +

+ {errMessage(approve.error, "The approval could not be sent.")} +

+ )} + {deny.isError && ( +

+ {errMessage(deny.error, "The denial could not be sent.")} +

+ )} +
+ + +
+
+ ); +} From 989193621fcaff32f0d34d44786c6ddc59a74d2c Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 8 Oct 2026 18:45:10 -0700 Subject: [PATCH 76/92] feat(ssh-relay): destination trust screen with delete and the explicit-pin door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caller's host-key pins as destination plus SHA256: fingerprint (the key line itself is display-excluded by construction), the delete that re-opens the TOFU decision, and the §9 explicit-pin dialog for a destination trusted under an SSH HostKeyAlias where the capture cannot find the row. The add form runs the substrate; a conflicting pin is the server's RED line naming the recovery. Co-Authored-By: Claude --- .../ssh/__tests__/host-pins-screen.test.tsx | 167 +++++++++++ .../src/components/ssh/host-pins-screen.tsx | 268 ++++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 apps/server/web/src/components/ssh/__tests__/host-pins-screen.test.tsx create mode 100644 apps/server/web/src/components/ssh/host-pins-screen.tsx diff --git a/apps/server/web/src/components/ssh/__tests__/host-pins-screen.test.tsx b/apps/server/web/src/components/ssh/__tests__/host-pins-screen.test.tsx new file mode 100644 index 000000000..58a18d6e1 --- /dev/null +++ b/apps/server/web/src/components/ssh/__tests__/host-pins-screen.test.tsx @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { type ConfirmOptions, setConfirmHandler } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { HostPinsScreen } from "@/components/ssh/host-pins-screen"; + +/** + * Destination trust (spec 2026-10-08 §9): the caller's host-key pins as + * destination + `SHA256:` fingerprint (the key line itself never reaches the + * screen), the delete that forces a fresh-grant TOFU re-capture, and the + * explicit-pin door the F2 HostKeyAlias gap runs out of. The add dialog's + * title is static; the destination the person types rides its body. + */ + +const FP_HOST = `SHA256:${"C".repeat(43)}`; +const DEST = "theo@build.example.com:22"; +const HOST_KEY = "build.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPinkeypinkeypinkeypinkeypin"; + +interface Sent { + method: string; + path: string; + body: unknown; +} + +function stubFetch( + restore: (undo: () => void) => void, + opts: { addStatus?: number; addMessage?: string } = {}, +): Sent[] { + const sent: Sent[] = []; + const original = globalThis.fetch; + restore(() => { + globalThis.fetch = original; + }); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + sent.push({ method, path: url.pathname, body }); + if (url.pathname === "/api/ssh/host-pins" && method === "GET") { + return new Response( + JSON.stringify({ + pins: [ + { + id: "p1", + destination: DEST, + fingerprint: FP_HOST, + createdAt: "2026-10-01T10:00:00Z", + updatedAt: "2026-10-07T09:00:00Z", + }, + ], + }), + { status: 200 }, + ); + } + if (url.pathname === "/api/ssh/host-pins" && method === "POST") { + if (opts.addStatus) { + return new Response(JSON.stringify({ message: opts.addMessage ?? "refused" }), { + status: opts.addStatus, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ pin: {} }), { status: 200 }); + } + if (method === "DELETE") { + return new Response(JSON.stringify({ deleted: true }), { status: 200 }); + } + throw new Error(`unstubbed fetch: ${method} ${url.pathname}`); + }) as typeof globalThis.fetch; + return sent; +} + +const restores: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const undo of restores.splice(0)) undo(); +}); + +function mockConfirm(answer: boolean) { + const seen: ConfirmOptions[] = []; + const previous = setConfirmHandler((options) => { + seen.push(options); + return Promise.resolve(answer); + }); + return { seen, restore: () => setConfirmHandler(previous) }; +} + +function renderScreen() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return render( + + + , + ); +} + +describe("HostPinsScreen", () => { + it("displays each pinned destination with its SHA256 fingerprint", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + expect(await screen.findByText(DEST)).toBeDefined(); + expect(screen.getByText(FP_HOST)).toBeDefined(); + }); + + it("deletes through a static-titled confirm, keyed by the destination", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + const confirm = mockConfirm(true); + restores.push(confirm.restore); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: `Remove pin for ${DEST}` })); + await waitFor(() => expect(confirm.seen.length).toBe(1)); + expect(confirm.seen[0]?.title).toBe("Remove this pin?"); + expect(confirm.seen[0]?.description).toContain(DEST); + await waitFor(() => + expect(sent.some((s) => s.method === "DELETE" && s.path === `/api/ssh/host-pins/${encodeURIComponent(DEST)}`)), + ); + }); + + it("opens a static-titled add dialog whose body carries the typed data", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Add destination pin" })); + // Static dialog title (ruling 2026-09-30): nothing typed moves the heading. + expect(await screen.findByRole("heading", { name: "Add destination pin" })).toBeDefined(); + fireEvent.change(screen.getByLabelText(/Destination/), { target: { value: DEST } }); + fireEvent.change(screen.getByLabelText(/Host key/), { target: { value: HOST_KEY } }); + fireEvent.click(screen.getByRole("button", { name: "Add pin" })); + await waitFor(() => expect(sent.some((s) => s.method === "POST" && s.path === "/api/ssh/host-pins")).toBe(true)); + expect(sent.find((s) => s.method === "POST" && s.path === "/api/ssh/host-pins")?.body).toEqual({ + destination: DEST, + hostKey: HOST_KEY, + }); + }); + + it("renders the server's refusal of an add as a red line", async () => { + stubFetch( + (undo) => { + restores.push(undo); + }, + { + addStatus: 409, + addMessage: "That destination is pinned to a different key", + }, + ); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Add destination pin" })); + fireEvent.change(await screen.findByLabelText(/Destination/), { target: { value: DEST } }); + fireEvent.change(screen.getByLabelText(/Host key/), { target: { value: HOST_KEY } }); + fireEvent.click(screen.getByRole("button", { name: "Add pin" })); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("pinned to a different key"); + expect(alert.className).toContain("text-destructive"); + }); + + it("says what removing a pin does", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + expect(await screen.findByText(/re-decide/)).toBeDefined(); + }); +}); diff --git a/apps/server/web/src/components/ssh/host-pins-screen.tsx b/apps/server/web/src/components/ssh/host-pins-screen.tsx new file mode 100644 index 000000000..5f04f08f4 --- /dev/null +++ b/apps/server/web/src/components/ssh/host-pins-screen.tsx @@ -0,0 +1,268 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + CopyableValue, + confirmAction, + errMessage, + Input, + Label, +} from "@internal/node-admin"; +import { Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { z } from "zod"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + formDialogOpenChange, +} from "@/components/ui/dialog"; +import { RequiredMark } from "@/components/ui/required-mark"; +import { Textarea } from "@/components/ui/textarea"; +import { useCreateSshHostPin, useDeleteSshHostPin, useSshHostPins } from "@/hooks/use-ssh"; +import { fieldErrorToned, makeForm, useSubmitDisabled } from "@/lib/form"; +import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone"; +import type { SshHostPin } from "@/lib/ssh"; + +/** + * Destination trust (spec 2026-10-08 §9): the caller's host-key pins, each a + * canonical `user@host:port` and the pinned key's `SHA256:` fingerprint. The + * key line itself never reaches this screen by construction; the view carries + * only the public identifier. Pins are per destination and independent of the + * grants, so this card stands beside the grants card, not inside it. + * + * Delete is the TOFU recovery's first half: removing a pin lets the next + * capture at a fresh key re-decide trust, so the confirm says that, with the + * destination in the body under a static title. The add door is the §9 "or an + * explicit pin" path - the way out for a destination whose trust lives under + * an SSH HostKeyAlias, where the key home's capture cannot find the row + * (F2/T12): the operator supplies the known_hosts line themselves. + */ +export function HostPinsScreen() { + const { data: view } = useSshHostPins(); + const pins = view?.pins ?? []; + const [addOpen, setAddOpen] = useState(false); + return ( + + +
+ Destination trust + + The host key each destination was first seen with. Removing a pin makes the next connection re-decide trust + on first sight. + +
+ +
+ + {pins.length === 0 ? ( +

+ No pinned destinations yet. A pin is captured the first time a granted key connects. +

+ ) : ( +
    + {pins.map((pin) => ( + + ))} +
+ )} + {addOpen && } +
+
+ ); +} + +/** One pinned destination and the delete that re-opens its TOFU decision. */ +function PinRow({ pin }: { pin: SshHostPin }) { + const remove = useDeleteSshHostPin(); + + async function drop() { + const ok = await confirmAction({ + // Static title; the destination rides the body (ruling 2026-09-30). + title: "Remove this pin?", + description: `"${pin.destination}" is no longer trusted. The next connection to it re-decides on first sight.`, + confirmLabel: "Remove", + danger: true, + }); + if (!ok) return; + remove.mutate(pin.destination); + } + + return ( +
  • +
    +
    {pin.destination}
    +
    + +
    +
    + Pinned {pin.createdAt.slice(0, 10)} · last match {pin.updatedAt.slice(0, 10)} +
    + {remove.isError && remove.variables === pin.destination && ( +

    + {errMessage(remove.error, "The pin could not be removed.")} +

    + )} +
    + +
  • + ); +} + +/** The add dialog's one validity rule; emptiness is a gap (gold), the server's refusal is the red one. */ +const hostPinSchema = z + .object({ + destination: z.string(), + hostKey: z.string(), + }) + .superRefine((values, ctx) => { + if (values.destination.trim() === "") { + ctx.addIssue({ + code: "custom", + path: ["destination"], + message: "A destination is required", + gap: true, + }); + } + if (values.hostKey.trim() === "") { + ctx.addIssue({ code: "custom", path: ["hostKey"], message: "A host key line is required", gap: true }); + } + }); + +/** + * `POST /api/ssh/host-pins` (the explicit-pin door). The dialog title is + * static: what the person types lands in the fields and the body, never in + * the heading. A server refusal (a bad canonical spelling, a destination + * already pinned to a DIFFERENT key) is the red line: it says "what you typed + * is wrong", and for a conflicting pin the sentence IS the recovery map - + * delete the standing pin first, nothing overwrites. + */ +function AddPinDialog({ onOpenChange }: { onOpenChange: (open: boolean) => void }) { + const create = useCreateSshHostPin(); + const [serverError, setServerError] = useState(null); + + const form = makeForm({ + defaultValues: { destination: "", hostKey: "" }, + validator: hostPinSchema, + onSubmit: async (values) => { + const destination = values.destination.trim(); + const hostKey = values.hostKey.trim(); + if (destination === "" || hostKey === "") return; + setServerError(null); + try { + await create.mutateAsync({ destination, hostKey }); + onOpenChange(false); + } catch (err) { + setServerError(errMessage(err, "The pin could not be added.")); + } + }, + }); + const disabled = useSubmitDisabled(form, create.isPending); + + return ( + + + + Add destination pin + + A host key you supply yourself. Use this when the destination is trusted under an SSH HostKeyAlias and the + key home cannot capture it. + + + + {(field) => ( +
    + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {field.state.meta.isTouched && + (() => { + const shown = fieldErrorToned(field.state.meta.errors); + return ( + shown && ( +

    + {shown.text} +

    + ) + ); + })()} +
    + )} +
    + + {(field) => ( +
    + +