diff --git a/.changeset/ssh-relay-milestone-2.md b/.changeset/ssh-relay-milestone-2.md new file mode 100644 index 000000000..5851f0842 --- /dev/null +++ b/.changeset/ssh-relay-milestone-2.md @@ -0,0 +1,17 @@ +--- +"@internal/server": major +"@internal/node": major +"@internal/subshell-protocol": major +"@internal/pane-runtime": major +"@internal/server-web": major +"@internal/node-web": major +"@internal/node-admin": major +"@internal/backend-errors": major +"@internal/mobile": major +--- + +SSH sealed agent relay (milestone 2): a machine that holds no keys can open an SSH-terminal pane anywhere its operator's key home can reach. The connecting machine's node binds a per-pane ssh-agent proxy socket (the pane's scoped `SSH_AUTH_SOCK`, unbound at teardown); every agent request is sealed machine-to-machine over the plane, which brokers the pairing and routes opaque envelopes by a routing ref and nothing else. The key home's responder probes the live agent's numbering (OpenSSH classic vs RFC 9987, learned per session, never assumed), forwards only `REQUEST_IDENTITIES` and `SIGN_REQUEST`, and scopes both to the grant's selected fingerprints. Origin rides an ES256 signature inside the sealed envelope; endpoint-contributed nonces and a monotonic per-direction sequence make every relayed byte replay-inert. Grants (`ssh_key_grants`) bind a key home to a destination selector; the first use of a new pair asks the key home's owner to approve (a durable `ssh_grant_requests` row plus an owner notification, a loud fail-fast refusal, never a hung pane), the approval records the chosen public fingerprints and captures the destination's host-key pin from the key home's own `known_hosts`, and revocation is instant both ways. Panes verify D against a per-session pinned `known_hosts` delivered on the signed relay-open (`StrictHostKeyChecking yes`, system-wide file disabled); a changed destination host key is a hard block and delete-plus-fresh-capture is the operator's TOFU recovery. Machine identity: every agent node carries an ES256 signing keypair reported at enroll, peers pin each other's signing AND encryption halves byte-equal in a store separate from channel pins, and the node's loopback dashboard and the machine trust card show the fingerprints for the out-of-band comparison. "Set up Subshell here" turns an open pane's destination into an enrolled node by driving the ordinary install one-liner over a separate non-interactive ssh exec, so the minted setup key never reaches the pane screen or its log; an installer line carrying a key is redacted twice over. The grants screen, the destination trust screen, and the launcher's key-home picker complete the surface. + +Deployment: update the server before the nodes. The link gate is exact-match at protocol 18, so an agent that has not crossed the bump is held and takes no relay traffic. Migrations 0049 through 0052 add the signing-key slots, the grants, the pending-approval rows, the host-key pins, and the pane's key-home column; never run them against a live database without a full archive. Nothing changes for anyone until a node's SSH gate is enabled and a launch names a key home. + +Proof: e2e spec 23 (the crown jewel) drives the whole path against a real `ssh-agent`, a real `sshd`, real node daemons, and the real broker, with a plane-side capture asserting only opaque envelopes cross, and the 10.x numbering, the fingerprint scope, the revoke cut, the lifetime cap, the setup-key redaction, and the changed-host-key block plus TOFU recovery all asserted end to end. diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 29215d351..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|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; 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/client/mobile/src/__tests__/push-routing.test.ts b/apps/client/mobile/src/__tests__/push-routing.test.ts new file mode 100644 index 000000000..ebaccd651 --- /dev/null +++ b/apps/client/mobile/src/__tests__/push-routing.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { decidePushAction, SETTINGS_HREF } from "../lib/push-routing"; + +/** + * The tap/action decision behind a push (PR #338 review, Important 1): a + * `grant_approval` push carries the grant REQUEST uuid as `sid`, so the old + * blind push to `/subshell/` was a 404 dead-end and its Silence bell + * PATCHed a nonexistent subshell. `push-bridge` keeps the side effects; this + * pure helper owns the choice, which is all the regression needs pinning. + */ +describe("decidePushAction", () => { + it("grant_approval tap routes to the settings tab, never a phantom pane", () => { + const a = decidePushAction({ sid: "req-uuid", kind: "grant_approval", origin: "https://a" }, undefined); + expect(a).toEqual({ action: "route", href: SETTINGS_HREF }); + expect(JSON.stringify(a)).not.toContain("/subshell/"); + // The settings tab must be the spelled group route the app itself uses. + expect(SETTINGS_HREF).toBe("/(tabs)/settings"); + }); + + it("grant_approval silence is ignored: no PATCH intent against a request id", () => { + const a = decidePushAction({ sid: "req-uuid", kind: "grant_approval", origin: "https://a" }, "silence"); + expect(a).toEqual({ action: "ignore" }); + }); + + it("pane kinds tap to /subshell/, encoded, unchanged", () => { + const a = decidePushAction({ sid: "sess-1", kind: "turn_complete", origin: "https://a" }, undefined); + expect(a).toEqual({ action: "route", href: "/subshell/sess-1" }); + expect(decidePushAction({ sid: "a b", kind: "needs_attention" }, "open")).toEqual({ + action: "route", + href: "/subshell/a%20b", + }); + }); + + it("pane kinds silence asks for the bell PATCH with the sid", () => { + expect(decidePushAction({ sid: "sess-1", kind: "needs_attention" }, "silence")).toEqual({ + action: "silence", + sid: "sess-1", + }); + }); + + it("a pane payload with no sid is ignored (the pre-existing guard)", () => { + expect(decidePushAction({ kind: "turn_complete", origin: "https://a" }, undefined)).toEqual({ + action: "ignore", + }); + expect(decidePushAction({ kind: "exited" }, "silence")).toEqual({ action: "ignore" }); + }); +}); diff --git a/apps/client/mobile/src/lib/notif-data.ts b/apps/client/mobile/src/lib/notif-data.ts index b4ceb9755..3b0a0b849 100644 --- a/apps/client/mobile/src/lib/notif-data.ts +++ b/apps/client/mobile/src/lib/notif-data.ts @@ -4,10 +4,17 @@ * handler share a shape. */ export interface SubshellNotifData { - /** Subshell uuid — the only identifier that crosses the relay */ + /** Subshell uuid (a `grant_approval` push carries the grant REQUEST uuid) */ sid?: string; - /** Event class driving the generic body copy */ - kind?: "turn_complete" | "needs_attention" | "exited" | "crashed" | "crashed_final"; + /** Event class driving the generic body copy and the tap's destination (the server's NotifyKind, spelled here) */ + kind?: + | "turn_complete" + | "needs_attention" + | "exited" + | "crashed" + | "crashed_final" + | "maintenance" + | "grant_approval"; /** Origin that sent it — future multi-instance routing hint */ origin?: string; } diff --git a/apps/client/mobile/src/lib/push-routing.ts b/apps/client/mobile/src/lib/push-routing.ts new file mode 100644 index 000000000..4da3eff13 --- /dev/null +++ b/apps/client/mobile/src/lib/push-routing.ts @@ -0,0 +1,38 @@ +import type { SubshellNotifData } from "@/lib/notif-data"; + +/** + * The tap/action decision behind a push response, kept pure so the routing + * rule is testable without the native listener (push-bridge owns the side + * effects: origin switching, the PATCH, the navigation). + */ + +/** The settings tab, spelled like the app spells its group routes (`/(tabs)`). */ +export const SETTINGS_HREF = "/(tabs)/settings"; + +/** What the app should DO about a push, decided from the opaque payload. */ +export type PushAction = + /** Navigate to `href` (after any instance switch push-bridge performs). */ + | { action: "route"; href: string } + /** The lock-screen Silence bell: one `setNotify(sid, false)` PATCH. */ + | { action: "silence"; sid: string } + /** Do nothing at all: no navigation, no request. */ + | { action: "ignore" }; + +/** + * `grant_approval` pushes name the grant REQUEST uuid in `sid`, not a pane: + * their tap lands on the settings tab (the real surface) and their silence + * action is ignored (there is no subshell bell to PATCH). Every other kind + * behaves exactly as it always has: tap to `/subshell/`, silence to the + * PATCH (PR 338 review, Important 1). + * @param data - The opaque payload (sid + kind + origin) + * @param actionIdentifier - The response's action id; only "silence" acts + */ +export function decidePushAction(data: SubshellNotifData, actionIdentifier?: string): PushAction { + const silence = actionIdentifier === "silence"; + if (data.kind === "grant_approval") { + return silence ? { action: "ignore" } : { action: "route", href: SETTINGS_HREF }; + } + if (!data.sid) return { action: "ignore" }; + if (silence) return { action: "silence", sid: data.sid }; + return { action: "route", href: `/subshell/${encodeURIComponent(data.sid)}` }; +} diff --git a/apps/client/mobile/src/native/push.ts b/apps/client/mobile/src/native/push.ts index 3526aeffe..574e044b9 100644 --- a/apps/client/mobile/src/native/push.ts +++ b/apps/client/mobile/src/native/push.ts @@ -51,6 +51,17 @@ export function configureNotifications(): void { options: { opensAppToForeground: false, isAuthenticationRequired: true }, }, ]); + // The action-free twin, named by `grant_approval` payloads server-side: a + // remote push surfaces actions only for a REGISTERED category, and that + // kind's sid is a grant REQUEST id, so the Silence bell has nothing to + // PATCH (PR 338 review, Important 1). Plain Open only. + void Notifications.setNotificationCategoryAsync("subshell-plain", [ + { + identifier: "open", + buttonTitle: "Open", + options: { opensAppToForeground: true, isAuthenticationRequired: true }, + }, + ]); } /** diff --git a/apps/client/mobile/src/providers/push-bridge.tsx b/apps/client/mobile/src/providers/push-bridge.tsx index e2fb87e00..f5060b6c1 100644 --- a/apps/client/mobile/src/providers/push-bridge.tsx +++ b/apps/client/mobile/src/providers/push-bridge.tsx @@ -6,6 +6,7 @@ import { SUBSHELLS_KEY } from "@/hooks/query-keys"; import { useIconBadge } from "@/hooks/use-icon-badge"; import { useApp } from "@/lib/app-state"; import type { SubshellNotifData } from "@/lib/notif-data"; +import { decidePushAction } from "@/lib/push-routing"; import { configureNotifications, enrollPush } from "@/native/push"; import { clientForOrigin } from "@/native/subshell-client-factory"; import { useSubshell } from "@/providers/subshell-provider"; @@ -24,7 +25,10 @@ import { useSubshell } from "@/providers/subshell-provider"; * route itself is the biometric gate, §Security notes), "Silence bell" → * one PATCH against the ORIGIN's client + list refresh, without * foregrounding. An origin this phone no longer knows (forgotten instance) - * is ignored rather than opened on the wrong server. + * is ignored rather than opened on the wrong server. A `grant_approval` + * push switches instances the same way but lands on the settings tab, and + * its Silence action is ignored (its sid names a grant request, not a pane; + * PR 338 review, Important 1). * * The response listener mounts ONCE and reads the registry via * `useApp.getState()` at call time: an earlier version kept activeId/instances @@ -53,13 +57,18 @@ export function PushBridge() { const { activeId, instances, setActive } = useApp.getState(); const origin = data.origin && data.origin !== activeId ? data.origin : null; if (origin && !instances.some((i) => i.id === origin)) return; // unknown/forgotten instance + // The helper decides (pure, pinned in src/lib/push-routing.ts); this + // closure only performs. A `grant_approval` push routes to settings and + // ignores the Silence bell: its sid is a grant request, not a pane. + const action = decidePushAction(data, response.actionIdentifier); + if (action.action === "ignore") return; // The action belongs to the ORIGIN's instance, whatever is active now. const actor = origin ? clientForOrigin(origin) : client; - if (response.actionIdentifier === "silence") { + if (action.action === "silence") { // Background action: one quick PATCH; the app never opens. if (!actor) return; try { - await actor.setNotify(data.sid, false); + await actor.setNotify(action.sid, false); await qc.invalidateQueries({ queryKey: SUBSHELLS_KEY }); } catch { /* signed-out mid-flight: the bell state re-converges on next open */ @@ -67,7 +76,7 @@ export function PushBridge() { return; } if (origin) setActive(origin); // provider clears the query cache on switch - router.push(`/subshell/${encodeURIComponent(data.sid)}`); + router.push(action.href); }; const sub = Notifications.addNotificationResponseReceivedListener((r) => void respond(r)); // Cold start from a notification tap must route too, not just live taps. 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..3f1b89b32 --- /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: Partial) => 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 } as SshProcessResult); + }); + } + 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/__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..2687e27e0 --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-host-key.test.ts @@ -0,0 +1,249 @@ +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)); + 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 () => { + 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-identity.test.ts b/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts new file mode 100644 index 000000000..b2704dd70 --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-identity.test.ts @@ -0,0 +1,351 @@ +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 { + parseNodeSshAgentIdentities, + parseNodeSshIdentity, + SSH_ROSTER_MAX_IDENTITIES, +} 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 + * 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"); + }); +}); + +/* ------------------------------------------------------------------ */ +/* 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("a roster past SSH_ROSTER_MAX_IDENTITIES is refused by name, never truncated; the bound itself answers", async () => { + // PR #338 review round 2: the parse takes whatever count the agent + // declares, so the COMMAND refuses an oversized roster. The no-silent- + // truncation law: cutting the list to fit would show the operator a + // partial roster as the whole truth. The refusal names the cause and + // carries no fingerprint, comment, or blob data. + const many = (n: number) => + Array.from({ length: n }, (_, i) => ({ + blob: Buffer.concat([sshStr("ssh-ed25519"), sshStr(`OVER-BLOB-${i}`)]), + comment: `over-comment-${i}`, + })); + // Classic scheme: the probe's positive confirmation and the roster read + // are the same byte-13 ask, so every ask gets the scripted answer. + const oversized = scriptedAgent(() => identitiesAnswer(CLASSIC_SCHEME, many(SSH_ROSTER_MAX_IDENTITIES + 1))); + const result = await execSshAgentIdentities(makeCtx(gatedDir("oversized")), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: oversized.requestAgent, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + // The COMMAND's own named refusal, not the generic malformed-answer + // path: the operator-facing cause is an oversized roster. + expect(result.error).toInclude("oversized"); + expect(result.error).not.toInclude("malformed"); + expect(result.error).not.toInclude("over-comment-0"); + expect(result.error).not.toInclude("OVER-BLOB-0"); + expect(result.error).not.toInclude("SHA256:"); + } + // Boundary: exactly at the bound, the roster answers UNCHANGED. + const atBound = many(SSH_ROSTER_MAX_IDENTITIES); + const boundAgent = scriptedAgent(() => identitiesAnswer(CLASSIC_SCHEME, atBound)); + const bounded = await execSshAgentIdentities(makeCtx(gatedDir("at-bound")), { + resolveAgentSocket: () => "/fake/agent.sock", + requestAgent: boundAgent.requestAgent, + }); + expect(bounded.ok).toBe(true); + const parsed = parseNodeSshAgentIdentities(bounded.ok ? bounded.data : null); + expect(parsed?.identities).toHaveLength(SSH_ROSTER_MAX_IDENTITIES); + expect(parsed?.identities[3]).toEqual({ fingerprint: fp(atBound[3].blob), comment: "over-comment-3" }); + }); + + 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/__tests__/commands-ssh-machine-pin-repair.test.ts b/apps/node/agent/src/__tests__/commands-ssh-machine-pin-repair.test.ts new file mode 100644 index 000000000..41f0c22a1 --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-machine-pin-repair.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { SshMachinePinRepairCommand } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { execSshMachinePinRepair } from "../commands/ssh-machine-pin-repair.js"; +import { MachinePinStore, machinePinPath } from "../machine-pin-store.js"; +import { writeSshEnabled } from "../ssh-enabled.js"; + +/** + * The §4.5 re-pair arm (spec 2026-10-08 §4.5, Task 17). Posture copied from + * commands-ssh-relay.test.ts: a real temp data dir, the store read back + * through its own class, refusals asserted by `ok:false` + message. + * + * What THIS file owns: + * - the dispatch arm exists (never the switch's `unsupported`) and the write + * lands: after a valid repair, `check()` for the delivered pair is `ok` + * (the §13 block-clears keystone, at the COMMAND layer; the store-level + * pair lives in machine-pin-store.test.ts). + * - the arm is UNGATED by design (the `ssh_register_identity` precedent): a + * machine whose ssh-enabled mirror is OFF still repairs its trust record, + * because the recovery must reach a machine whose pairing is blocked. + * - deep key validation BEFORE any write: a private (`d`-bearing) JWK in + * either half is refused with the store untouched - the base64 encryption + * half is where the grammar cannot see, so this handler is the station + * that refuses it. + * - replace-or-add: a peer with no entry gets one (a lost store re-pairs). + * - the ack carries {repaired, peerNodeId} and NOTHING else - no key bytes + * ride the result frame, and no audit row exists on this machine (the + * trail is the plane's `node.ssh_machine_pin.repair`, ids only). + */ + +const SELF = "node-self"; +const PEER = "node-peer"; + +/** Real-length P-256 coordinate text (bytesOfJwk demands 32 decoded bytes). */ +const coord = (fill: number): string => Buffer.alloc(32, fill).toString("base64url"); +const publicJwk = (fill: number, extra: Record = {}): string => + JSON.stringify({ kty: "EC", crv: "P-256", x: coord(fill), y: coord(fill + 10), ...extra }); +const b64 = (jwkJson: string): string => Buffer.from(jwkJson, "utf8").toString("base64"); + +const SIGN_OK = publicJwk(1); +const ENC_OK = publicJwk(50); +/** A serialized PRIVATE JWK of the same public shape: the top-level `d`. */ +const SIGN_PRIVATE = publicJwk(1, { d: coord(99) }); +const ENC_PRIVATE = publicJwk(50, { d: coord(77) }); + +let base: string | null = null; +function freshDataDir(tag: string, opts: { gateOn?: boolean } = {}): string { + base ??= tmpdir(); + const dir = mkdtempSync(join(base, `subshell-repair-${tag}-`)); + if (opts.gateOn) writeSshEnabled(dir, { on: true, changedAt: "2026-10-08T10:00:00.000Z" }); + return dir; +} + +function makeCtx(dataDir: string, nodeId: string = SELF): CommandContext { + return { config: { dataDir, nodeId } } as unknown as CommandContext; +} + +function repairCmd(over: Partial = {}): SshMachinePinRepairCommand { + return { + type: "ssh_machine_pin_repair", + peerNodeId: PEER, + peerSigningPublicKey: SIGN_OK, + peerEncryptPublicKey: b64(ENC_OK), + ...over, + }; +} + +describe("ssh_machine_pin_repair dispatch (the arm exists, the write lands)", () => { + it("a valid re-pair replaces the blocked entry and check() clears, through the real switch", async () => { + const dir = freshDataDir("write", { gateOn: true }); + const store = new MachinePinStore(dir); + // The blocked state first: the peer's key moved, byte-strict refuses. + store.pin(PEER, { signing: publicJwk(90), encryption: publicJwk(110) }); + expect(store.check(PEER, { signing: SIGN_OK, encryption: ENC_OK })).toBe("changed"); + const res = await dispatchCommand(makeCtx(dir), repairCmd()); + expect(res).toEqual({ ok: true, data: { repaired: true, peerNodeId: PEER } }); + expect(store.check(PEER, { signing: SIGN_OK, encryption: ENC_OK })).toBe("ok"); + // Bytes are stored EXACTLY as delivered (the registration spelling): + // the pin round-trips byte-for-byte, not a re-serialization. + expect(store.get(PEER)).toEqual({ signing: SIGN_OK, encryption: ENC_OK }); + }); + + it("a peer with no stored entry gets one (a repair after a lost store)", async () => { + const dir = freshDataDir("add", { gateOn: true }); + const store = new MachinePinStore(dir); + expect(store.get(PEER)).toBe(null); + expect(await dispatchCommand(makeCtx(dir), repairCmd())).toEqual({ + ok: true, + data: { repaired: true, peerNodeId: PEER }, + }); + expect(store.check(PEER, { signing: SIGN_OK, encryption: ENC_OK })).toBe("ok"); + rmSync(dir, { recursive: true, force: true }); + }); + + it("the gate does NOT speak here: a machine with the mirror OFF still repairs its trust record", async () => { + // Deliberate, the `ssh_register_identity` doctrine: re-pair is a + // trust-record act on this machine's own store, and §4.5 recovery must + // reach a machine whose pairing is currently blocked. A GATED arm would + // make the repair of a relationship depend on the relationship. + const dir = freshDataDir("ungated"); // no writeSshEnabled: mirror absent + const res = await dispatchCommand(makeCtx(dir), repairCmd()); + expect(res.ok).toBe(true); + expect(new MachinePinStore(dir).get(PEER)).not.toBe(null); + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe("deep key validation before any write (the grammar cannot see inside base64)", () => { + function refused(dir: string, cmd: SshMachinePinRepairCommand): void { + const res = execSshMachinePinRepair(makeCtx(dir), cmd); + expect(res.ok).toBe(false); + if (res.ok === false) expect(res.error).toContain("peer key rejected"); + } + + it("refuses a PRIVATE encryption JWK carried in base64, with the store untouched", () => { + // THE deep refusal this arm owns: the outer grammar proves the base64 + // shape but cannot read the JSON inside, so `d` inside the delivered + // encryption half is caught HERE - and nothing was written. + const dir = freshDataDir("enc-private"); + refused(dir, repairCmd({ peerEncryptPublicKey: b64(ENC_PRIVATE) })); + expect(new MachinePinStore(dir).get(PEER)).toBe(null); + rmSync(dir, { recursive: true, force: true }); + }); + + it("refuses a `d`-bearing signing JWK and foreign-curve / junk halves alike", () => { + for (const [tag, over] of [ + ["sign-d", { peerSigningPublicKey: SIGN_PRIVATE }], + ["sign-junk", { peerSigningPublicKey: "not json" }], + ["sign-short-coords", { peerSigningPublicKey: '{"kty":"EC","crv":"P-256","x":"AX","y":"AY"}' }], + ["sign-wrong-kty", { peerSigningPublicKey: '{"kty":"OKP","crv":"Ed25519","x":"AX"}' }], + ["enc-junk", { peerEncryptPublicKey: b64("hello") }], + [ + "enc-wrong-curve", + { peerEncryptPublicKey: b64(`{"kty":"EC","crv":"P-384","x":"${coord(3)}","y":"${coord(4)}"}`) }, + ], + ] as const) { + const dir = freshDataDir(tag); + refused(dir, repairCmd(over as Partial)); + expect(new MachinePinStore(dir).get(PEER)).toBe(null); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("a refusal writes NOTHING: an existing pin for the peer survives verbatim", () => { + const dir = freshDataDir("no-partial"); + const store = new MachinePinStore(dir); + store.pin(PEER, { signing: SIGN_OK, encryption: ENC_OK }); + refused(dir, repairCmd({ peerEncryptPublicKey: b64(ENC_PRIVATE) })); + expect(store.get(PEER)).toEqual({ signing: SIGN_OK, encryption: ENC_OK }); + rmSync(dir, { recursive: true, force: true }); + }); + + it("a corrupt pin store fail-closes the repair instead of silently rewriting it", () => { + const dir = freshDataDir("corrupt"); + writeFileSync(machinePinPath(dir), "{ not json"); + const res = execSshMachinePinRepair(makeCtx(dir), repairCmd()); + expect(res.ok).toBe(false); + if (res.ok === false) expect(res.error).toContain("machine pin repair refused"); + // The store's quarantine doctrine ran (throws, moves the file aside); + // the delivered pair never landed as a fresh store. + expect(new MachinePinStore(dir).get(PEER)).toBe(null); + rmSync(dir, { recursive: true, force: true }); + }); + + it("refuses a repair naming THIS machine as its own peer", () => { + const dir = freshDataDir("self-peer"); + const res = execSshMachinePinRepair(makeCtx(dir), repairCmd({ peerNodeId: SELF })); + expect(res.ok).toBe(false); + if (res.ok === false) expect(res.error).toContain("never its own relay peer"); + expect(new MachinePinStore(dir).get(SELF)).toBe(null); + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe("the ack carries ids only (no key material, no audit here)", () => { + it("the success data is exactly {repaired, peerNodeId}; the pin bytes appear nowhere in it", () => { + const dir = freshDataDir("ack-shape", { gateOn: true }); + const res = execSshMachinePinRepair(makeCtx(dir), repairCmd()); + expect(res.ok).toBe(true); + if (res.ok !== true) return; + const serialized = JSON.stringify(res.data); + expect(res.data).toEqual({ repaired: true, peerNodeId: PEER }); + // Belt for the no-key-material constraint: the delivered halves (raw and + // base64 spellings) cannot appear in the answer the plane receives. + expect(serialized).not.toContain(SIGN_OK); + expect(serialized).not.toContain(b64(ENC_OK)); + expect(serialized).not.toContain(coord(1)); + // The bytes live exactly one place on this machine: the pin file itself. + expect(readFileSync(machinePinPath(dir), "utf8")).toContain(coord(1)); + }); + + it("a refusal names the problem without echoing the delivered key material", () => { + const dir = freshDataDir("refusal-hygiene"); + const res = execSshMachinePinRepair(makeCtx(dir), repairCmd({ peerEncryptPublicKey: b64(ENC_PRIVATE) })); + expect(res.ok).toBe(false); + if (res.ok === false) { + // The error rides the RESULT channel and the plane may log it: the node + // refuses by SHAPE, never by quoting what was refused (T8 posture). + expect(res.error).not.toContain(ENC_PRIVATE); + expect(res.error).not.toContain(b64(ENC_PRIVATE)); + expect(res.error).not.toContain(coord(60)); + } + rmSync(dir, { recursive: true, force: true }); + }); +}); 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..831369f74 --- /dev/null +++ b/apps/node/agent/src/__tests__/commands-ssh-relay.test.ts @@ -0,0 +1,317 @@ +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, + 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 BRelaySessionArgs, 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, + // 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, + }; +} + +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: BRelaySessionArgs[] = []; + const seams: RelayOpenSeams = { + openA: async () => { + throw new Error("must not run"); + }, + openB: async (args) => { + seenArgs.push(args); + 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("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); + }); + + 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", () => { + 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__/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/node/agent/src/__tests__/daemon.test.ts b/apps/node/agent/src/__tests__/daemon.test.ts index 9b8577ee2..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, @@ -26,6 +27,7 @@ import { type NodeEvent, type NodeRuntimeReport, parseNodeEvent, + type RelayFrame, signCommand, } from "@internal/subshell-protocol"; import { @@ -37,6 +39,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 { @@ -49,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"; @@ -467,6 +471,7 @@ async function startDaemon( | "runtime" | "retentionMs" | "retentionPass" + | "relaySessions" > > & { config?: Partial; @@ -2231,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. @@ -2580,3 +2654,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__/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/__tests__/machine-pin-store.test.ts b/apps/node/agent/src/__tests__/machine-pin-store.test.ts new file mode 100644 index 000000000..b0107f8ce --- /dev/null +++ b/apps/node/agent/src/__tests__/machine-pin-store.test.ts @@ -0,0 +1,291 @@ +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("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); + 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; the re-pair route 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. + // The replace route is `repair`: pin() now refuses an already-pinned peer + // (see the next test), so the re-pair writes through the sanctioned path. + reopened.repair("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("pin refuses a peer that ALREADY has an entry: a changed peer is a block, not a re-pin", () => { + // The structural guard (PR #338 review round 2): `pin` used to delegate + // straight to `repair`, so a call-site ordering bug could silently + // OVERWRITE a pinned peer. It now refuses when an entry exists; `repair` + // stays the ONLY sanctioned replace path (§4.5's re-pair route). + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + const before = readFileSync(machinePinPath(dir), "utf8"); + expect(() => store.pin("node-1", keyB)).toThrow( + "machine pin exists for node-1: a changed peer is a block, not a re-pin", + ); + // The refusal is inert: the file is byte-identical and the stored pin is + // still keyA. A moved peer stays BLOCKED (check says changed), never + // silently re-pinned by whatever asked second. + expect(readFileSync(machinePinPath(dir), "utf8")).toBe(before); + expect(store.get("node-1")).toEqual(keyA); + expect(store.check("node-1", keyB)).toBe("changed"); + // The message names the peer and the cause, never key bytes: the error + // string is log-safe material. + let message = ""; + try { + store.pin("node-1", keyB); + } catch (err) { + message = String(err); + } + expect(message).toContain("node-1"); + expect(message).not.toContain(keyA.signing); + expect(message).not.toContain(keyA.encryption); + expect(message).not.toContain(keyB.signing); + expect(message).not.toContain(keyB.encryption); + // `repair` still replaces the same peer's entry: the guard refuses pin(), + // not the re-pair route. + store.repair("node-1", keyB); + expect(store.get("node-1")).toEqual(keyB); + expect(store.check("node-1", keyB)).toBe("ok"); +}); + +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("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 + // 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/); +}); + +test("§4.5 keystone: repair replaces a blocked peer's entry and the block clears; an UN-repaired peer's block persists", () => { + // The design's whole recovery story in one test (spec 2026-10-08 §4.5, + // Task 17): after a peer's key genuinely rotated, byte-strict check() + // BLOCKS forever unless the owner re-pairs. `repair` is the only sanctioned + // replacement - and it touches exactly ONE peer's entry. + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + store.pin("node-2", keyA); + // The blocked state: both peers now present the rotated keyB; check refuses. + expect(store.check("node-1", keyB)).toBe("changed"); + expect(store.check("node-2", keyB)).toBe("changed"); + // The owner re-pairs peer node-1 only. + store.repair("node-1", keyB); + // ...whose block clears: the new key now checks ok, byte-equal. + expect(store.check("node-1", keyB)).toBe("ok"); + expect(store.get("node-1")).toEqual(keyB); + // ...and the UN-repaired peer's block PERSISTS: recovery is per-peer, and + // nothing here re-authorizes a peer the owner did not act on. + expect(store.check("node-2", keyB)).toBe("changed"); + expect(store.get("node-2")).toEqual(keyA); +}); + +test("repair adds an entry when the store holds none for that peer (a repair after a lost store)", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + expect(store.get("node-9")).toBe(null); + store.repair("node-9", keyB); + expect(store.get("node-9")).toEqual(keyB); + expect(store.check("node-9", keyB)).toBe("ok"); +}); + +test("repair keeps the file discipline: 0600, other peers untouched, durable across instances, corrupt file fail-closed", () => { + const dir = freshDir(); + const store = new MachinePinStore(dir); + store.pin("node-1", keyA); + store.pin("node-2", keyA); + chmodSync(machinePinPath(dir), 0o644); + store.repair("node-1", keyB); + expect(statSync(machinePinPath(dir)).mode & 0o777).toBe(0o600); + expect(new MachinePinStore(dir).get("node-1")).toEqual(keyB); + expect(new MachinePinStore(dir).get("node-2")).toEqual(keyA); + // Fail closed like every other read: an unreadable pin set is quarantined, + // never silently rewritten by a repair. + writeFileSync(machinePinPath(dir), "{ nope"); + expect(() => store.repair("node-1", keyA)).toThrow(); + // Inherited Object members stay ordinary data (the emptyPinMap rule). + const reopened = new MachinePinStore(freshDir()); + reopened.repair("toString", keyB); + expect(reopened.get("toString")).toEqual(keyB); +}); 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..d8b97a988 --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-agent-scheme.test.ts @@ -0,0 +1,325 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + type AgentScheme, + CLASSIC_SCHEME, + filterIdentitiesAnswer, + fingerprintAgentBlob, + OPENSSH_10X_SCHEME, + parseIdentitiesAnswer, + parseSignRequest, + parseSignResponse, + 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(); +}); + +/* ---------------- 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. */ +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 new file mode 100644 index 000000000..8262d93c7 --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-proxy.test.ts @@ -0,0 +1,694 @@ +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. + */ + +/** + * 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 SAMPLE_AGENT_REQUEST_A = 13; +const SAMPLE_AGENT_REQUEST_B = 15; +const SAMPLE_AGENT_ANSWER_A = 12; + +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", + /** 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[] = []; + 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) => { + if (send) { + send(frame); + return; + } + 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([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 + + 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([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(); + } 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([SAMPLE_AGENT_REQUEST_A])); + 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: [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([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([SAMPLE_AGENT_REQUEST_B, 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([SAMPLE_AGENT_REQUEST_A])); + await waitUntil(() => f.frames.length === 1, "request frame out"); + const first = await openAndVerifyB2A(f, f.frames[0]); + + 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"); + + 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([SAMPLE_AGENT_REQUEST_A])); + 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: [SAMPLE_AGENT_ANSWER_A], + 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([SAMPLE_AGENT_REQUEST_A])); + 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: [SAMPLE_AGENT_ANSWER_A], + 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([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: [SAMPLE_AGENT_ANSWER_A], + }); + 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([SAMPLE_AGENT_REQUEST_A])); + 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([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); + 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([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. + 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(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 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([SAMPLE_AGENT_REQUEST_A])); + 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([SAMPLE_AGENT_REQUEST_B, 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([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] }); + 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([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 + + // 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([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([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); + 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 */ +/* ------------------------------------------------------------------ */ + +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("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 { + 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/__tests__/relay-responder.test.ts b/apps/node/agent/src/__tests__/relay-responder.test.ts new file mode 100644 index 000000000..fab847686 --- /dev/null +++ b/apps/node/agent/src/__tests__/relay-responder.test.ts @@ -0,0 +1,1507 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +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"; +import { open as mcpOpen, seal as mcpSeal } from "@internal/mcp-core"; +import { + base64UrlNoPad, + newNonce, + openRelayEnvelope, + parseRelayFrame, + type RelayFrame, + SSH_RELAY_MAX_PER_NODE, + 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 { type AgentScheme, CLASSIC_SCHEME, OPENSSH_10X_SCHEME } from "../relay-agent-scheme.js"; +import { buildAgentSocketPath } from "../relay-proxy.js"; +import { startRelayResponder } from "../relay-responder.js"; + +/** + * 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, spelled by hand */ +/* ------------------------------------------------------------------ */ + +/** 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(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)]), + ]); +} + +/** 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). */ +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 agents: Unix sockets speaking one scheme's bytes */ +/* ------------------------------------------------------------------ */ + +interface StubAgent { + path: string; + /** 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; +} + +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) => { + 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) { + 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([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, + 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())), + }; +} + +/* ------------------------------------------------------------------ */ +/* 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 + 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"))]); +// 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" }, +]; + +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. 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; + 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 = opts.unprobeable ? await startUnprobeableAgent() : await startStubAgent(opts.roster ?? ROSTER, scheme); + 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, + 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({ + relay, + dataDir, + selfNodeId: "a-node", + cmd, + sendRelayFrame: + opts.send ?? + ((frame: RelayFrame): void => { + frames.push(frame); + }), + 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(); + }, + }; +} + +/** 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); + } +} + +/* ------------------------------------------------------------------ */ +/* the open-time probe (the 2026-10-08 numbering ruling) */ +/* ------------------------------------------------------------------ */ + +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(); + // 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"); + + 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(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.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. + // 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([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(); + } +}); + +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 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([5]); // SSH2_AGENT_FAILURE + await sleep(30); + 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(); + } +}); + +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(); + // 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, { + 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([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.forwarded()).toEqual([]); // nothing reached A's agent + } finally { + await f.cleanup(); + } +}); + +test("under the 10.x scheme malformed sign bodies are refused and never forwarded", async () => { + const f = await openFixture(); + try { + const nB = newNonce(); + 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, "the refusal"); + const reply = await decodeA2B(f, f.frames[0], { nB }); + expect([...reply.agentBytes]).toEqual([5]); + await sleep(30); + 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 classic scheme: identities 13/answer 14, sign 15/response 16 */ +/* ------------------------------------------------------------------ */ + +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([13]), seq: 0, nB }); + f.relay.onInboundRelayFrame(req); + 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(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("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 + // 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(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 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([11]), + seq: 0, + nB: newNonce(), + signerPrivateJwk: evil.privateJwk, + }); + f.relay.onInboundRelayFrame(req); + await sleep(50); + expect(f.frames.length).toBe(0); + expect(f.forwarded()).toEqual([]); + } 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([11]), + 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([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([11]), + 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.forwarded().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([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 }); + + // 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([11]), seq: 0, nB, nA: first.nA }); + f.relay.onInboundRelayFrame(rewind); + await sleep(50); + expect(f.frames.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([11]), 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([11]), + 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([11]), + seq: 0, + nB, + direction: "A2B", + }); + f.relay.onInboundRelayFrame(echo); + await sleep(50); + expect(f.frames.length).toBe(0); + expect(f.forwarded()).toEqual([]); + } 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, + paneId: "pane-a-test", + hostPin: HOST_PIN, + }; + // 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: () => {}, + resolveAgentSocket: () => null, + }), + ).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: () => {}, + resolveAgentSocket: () => null, + }), + ).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, + 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( + 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: () => {}, + resolveAgentSocket: () => null, + }), + ).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: () => {}, + resolveAgentSocket: () => null, + }), + ).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, + paneId: "pane-a-test", + hostPin: HOST_PIN, + }, + sendRelayFrame: () => {}, + resolveAgentSocket: () => null, + }), + ).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([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.forwarded()).toEqual([]); + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* the live agent socket seam */ +/* ------------------------------------------------------------------ */ + +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([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([5]); + expect(f.stub.received.length).toBe(0); // the stub socket was never connected + } finally { + await f.cleanup(); + } +}); + +/* ------------------------------------------------------------------ */ +/* 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", + hostPin: HOST_PIN, + ...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 */ +/* ------------------------------------------------------------------ */ + +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: [], + agentScheme: OPENSSH_10X_SCHEME, + seal: mcpSeal, + open: mcpOpen, + sendRelayFrame: () => {}, + }), + ).toThrow(/relay responder: pinned peer key rejected/); +}); + +/* ------------------------------------------------------------------ */ +/* Task 12: the B branch writes the destination's pinned host-key file */ +/* ------------------------------------------------------------------ */ + +test("openBRelaySession writes the delivered pin to the pane's 0600 known_hosts before binding", async () => { + 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/__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/__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/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 55342ee8c..bcd354bc6 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -29,6 +29,11 @@ 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 { execSshMachinePinRepair } from "./ssh-machine-pin-repair.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"; @@ -51,9 +56,33 @@ 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), 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), + * 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, + * 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), + * and the §4.5 recovery `ssh_machine_pin_repair` (Task 17 - replaces ONE + * peer's stored machine pin with the peer's registered public pair the plane + * re-delivers; deliberately UNGATED like `ssh_register_identity`, because it + * is a trust-record act on this machine's own store and the recovery must + * reach a machine whose pairing is blocked; deep public-only key validation + * before any write, audit stays plane-side). + * Any * unknown type still answers `unsupported` — the integration * contract that lets the backend and agent tracks move independently. * @@ -134,6 +163,22 @@ 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 "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": + return execSshRelayClose(ctx, cmd); + case "ssh_exec": + return await execSshExec(ctx, cmd); + case "ssh_exec_status": + return await execSshExecStatus(ctx, cmd); + case "ssh_machine_pin_repair": + return execSshMachinePinRepair(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/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..10fe06426 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-host-key.ts @@ -0,0 +1,146 @@ +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 reports); 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". 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; + } + 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-identity.ts b/apps/node/agent/src/commands/ssh-identity.ts new file mode 100644 index 000000000..809d3ca25 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-identity.ts @@ -0,0 +1,140 @@ +import { + type JsonValue, + parseNodeSshAgentIdentities, + parseNodeSshIdentity, + SSH_ROSTER_MAX_IDENTITIES, +} 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 + * 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 }; +} + +/** Test seams for {@link execSshAgentIdentities} (production omits both). */ +export interface AgentIdentitiesSeams { + /** Where A's live agent socket is; defaults to the real `SSH_AUTH_SOCK` lookup. */ + resolveAgentSocket?: () => 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, or a roster past + * {@link SSH_ROSTER_MAX_IDENTITIES} each answer `ok:false` with a named + * error; an oversized roster is refused, never truncated down to the bound. + * 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" }; + } + if (entries.length > SSH_ROSTER_MAX_IDENTITIES) { + // The count bound (PR #338 review round 2): the parse takes whatever + // count the agent declares, so the refusal is here. Never a silent + // truncation - a roster cut to fit would read to the operator as the + // agent's whole truth. The error names the cause and the counts only: + // no fingerprint, comment, or blob byte. + return { + ok: false, + error: `the agent reported ${entries.length} identities, past the ${SSH_ROSTER_MAX_IDENTITIES}-identity roster bound; refusing to answer an oversized 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 }; +} diff --git a/apps/node/agent/src/commands/ssh-machine-pin-repair.ts b/apps/node/agent/src/commands/ssh-machine-pin-repair.ts new file mode 100644 index 000000000..07c53cc32 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-machine-pin-repair.ts @@ -0,0 +1,79 @@ +import { + bytesOfJwk, + type JsonValue, + parseNodeSshMachinePinRepair, + type SshMachinePinRepairCommand, +} from "@internal/subshell-protocol"; +import { MachinePinStore } from "../machine-pin-store.js"; +import type { CommandContext, CommandResult } from "./context.js"; +import { decodePeerEncryptionJwk } from "./ssh-shared.js"; + +/** + * `ssh_machine_pin_repair` (spec 2026-10-08 §4.5, Task 17): replace ONE + * peer's entry in this machine's pin store with the peer's registered public + * pair, which the plane re-delivers in the command. This is the recovery for + * a genuinely rotated peer key (a re-enroll or a replaced data dir, §4.2) - + * the ONLY sanctioned way a pinned entry changes, and the design has no + * "trust anyway" escape beside it: byte-strict {@link MachinePinStore.check} + * still governs every NORMAL pairing, so an UN-repaired peer stays blocked. + * + * The three rules this arm keeps: + * - **Validation before any write.** The grammar already refused a top-level + * `d`; this is the deep half (the relay-open branches' own standard, beside + * the import): `bytesOfJwk` on the signing half, decode + `bytesOfJwk` on + * the encryption half. A JWK that is not an importable PUBLIC P-256 key - + * private material anywhere, a foreign curve, junk - refuses with nothing + * written. The store keeps its single job: byte-faithful persistence. + * - **The gate does NOT speak here** (deliberate, the `ssh_register_identity` + * precedent restated): re-pair is a trust-record act on this machine's own + * store, not an SSH act through the connecting account - it spawns nothing, + * reads no config, touches no agent. And the recovery must reach a machine + * whose pairing is currently BLOCKED; requiring the gate to be open would + * make the repair of a stuck relationship depend on the relationship. The + * authorization is the plane's owner-of-A gate plus the command signature; + * the mirror is not consulted and the store's always-strict doctrine + * (`SUBSHELL_CHANNEL_PIN` ignored) is unchanged. + * - **No audit row here, no key bytes anywhere near a log line.** The audit + * is the plane's `node.ssh_machine_pin.repair`, ids only (docs/security.md + * §10); the ack echoes the peer id and nothing else. The refusal strings + * name the peer id and the shape of the problem, never the delivered + * material (node refusals ride the RESULT channel and the plane may log + * them - the T8 posture inherited). + */ +export function execSshMachinePinRepair(ctx: CommandContext, cmd: SshMachinePinRepairCommand): CommandResult { + // A machine pinning ITSELF is a contradiction the pairing design has no + // word for; the plane's own service refuses it upstream, and the machine + // re-checks because the transport that got the command here is the only + // witness to which machine it was addressed to. + if (cmd.peerNodeId === ctx.config.nodeId) { + return { ok: false, error: "machine pin repair refused: a machine is never its own relay peer" }; + } + let pin: { signing: string; encryption: string }; + try { + pin = { + signing: cmd.peerSigningPublicKey, + encryption: decodePeerEncryptionJwk(cmd.peerEncryptPublicKey), + }; + bytesOfJwk(pin.signing); // deep public-only validity on the signing half too + } catch (err) { + return { + ok: false, + error: `machine pin repair refused: peer key rejected: ${err instanceof Error ? err.message : String(err)}`, + }; + } + const pins = new MachinePinStore(ctx.config.dataDir); + try { + pins.repair(cmd.peerNodeId, pin); + } catch (err) { + // The store's fail-closed read (a corrupt file quarantines and THROWS): + // the repair of one peer must never ride out over an unreadable pin set. + return { + ok: false, + error: `machine pin repair refused: ${err instanceof Error ? err.message : String(err)}`, + }; + } + const validated = parseNodeSshMachinePinRepair({ repaired: true, peerNodeId: cmd.peerNodeId }); + if (validated === null) return { ok: false, error: "malformed repair ack" }; + // 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-exec.ts b/apps/node/agent/src/commands/ssh-relay-exec.ts new file mode 100644 index 000000000..d657503a8 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-relay-exec.ts @@ -0,0 +1,135 @@ +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, 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) }; + } +} + +/** + * 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. 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; + 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 new file mode 100644 index 000000000..11c5dc765 --- /dev/null +++ b/apps/node/agent/src/commands/ssh-relay.ts @@ -0,0 +1,517 @@ +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, + type SshRelayOpenCommand, +} from "@internal/subshell-protocol"; +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 { startRelayResponder } from "../relay-responder.js"; +import { decodePeerEncryptionJwk } from "./ssh-shared.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: 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 + * 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. + * + * 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). */ +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; +} + +/** + * 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 + * 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. + * + * 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, 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). */ + get size(): number { + return this.#byRef.size; + } + + /** + * 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 + * 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. 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})`, + ); + } + 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); + } + + /** + * 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 + * 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 - 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) { + 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); + } 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); + } + + /** + * 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. */ +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: the command's `paneId` (grammar (b)). */ + 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. 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; +} + +/** + * 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), 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 + * 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. + */ +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). 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) { + 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)`, + ); + } + + // 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, + 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 }), + }); + + 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 err; + } + 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; 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. */ + 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), 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, 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. + * + * 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)`, + ); + } + + // 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, + 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: resolveAgent, + ...(args.log === undefined ? {} : { log: args.log }), + }); + + 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 err; + } + return { relayId: cmd.relayId }; +} diff --git a/apps/node/agent/src/commands/ssh-shared.ts b/apps/node/agent/src/commands/ssh-shared.ts index b6d895798..edff61c2c 100644 --- a/apps/node/agent/src/commands/ssh-shared.ts +++ b/apps/node/agent/src/commands/ssh-shared.ts @@ -1,5 +1,6 @@ import { homedir } from "node:os"; import { findBinary } from "@internal/pane-runtime"; +import { bytesOfJwk } from "@internal/subshell-protocol"; /** * Shared plumbing for the SSH command arms (spec 2026-10-07 §5). @@ -23,9 +24,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,7 +51,43 @@ 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(); } + +/** + * Decode a relay command's base64 spelling of the peer's registered ECDH-ES + * public key into the raw public JWK string the machine pin store holds (the + * §4.2 registration spelling; the grammar's `BASE64_RE` gate already proved + * the outer layer). The deep public-only check runs HERE, beside the import: + * `bytesOfJwk` throws on private material, a foreign curve, or junk, which is + * why both pin-writing arms - the relay-open pairing (ssh-relay.ts) and the + * §4.5 re-pair (ssh-machine-pin-repair.ts) - share this one definition of "a + * peer encryption key that may enter a pin store". Throws; callers wrap. + */ +export 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; +} diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index a2e9fc255..070f40789 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"; @@ -37,10 +40,12 @@ 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"; 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"; @@ -205,6 +210,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 { @@ -443,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 @@ -451,6 +487,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 @@ -486,6 +529,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 @@ -826,14 +879,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; @@ -1022,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 @@ -1150,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/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..720213e1b 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, 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 + * 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/node/agent/src/machine-pin-store.ts b/apps/node/agent/src/machine-pin-store.ts new file mode 100644 index 000000000..f6c72587d --- /dev/null +++ b/apps/node/agent/src/machine-pin-store.ts @@ -0,0 +1,220 @@ +/** + * 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"; +} + +/** + * 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 + * 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 a peer's pin at FIRST pairing (spec 2026-10-08 §4.4: the + * plane-delivered keys become the pin). Refuses when an entry for that + * peer already exists: a changed peer is a block, not a re-pin, and §4.5 + * makes {@link repair} the ONLY sanctioned replace route. The two relay + * branches already check the null-pinned case before calling this (and + * hard-block on a moved one); the refusal here is the structural guard + * beneath that call-site discipline, so no ordering bug can ever + * silently overwrite a pinned peer. The error names the peer id and the + * cause, never key bytes. + */ + pin(nodeId: string, pin: MachinePin): void { + if (this.loadAll()[nodeId] !== undefined) { + throw new Error(`machine pin exists for ${nodeId}: a changed peer is a block, not a re-pin`); + } + this.repair(nodeId, pin); + } + + /** + * §4.5's re-pair write: replace the stored entry for THAT peer byte-for-byte + * with the delivered pair - the ONLY sanctioned way a pinned entry ever + * changes. Normal pairing checks stay byte-strict ({@link check}): nothing + * here relaxes or consults a comparison, and an UN-repaired peer's block + * persists until its owner acts. When the store holds no entry for the peer + * the write adds one (a re-pair after a lost store is the same act). Other + * peers' entries ride through untouched; the atomic tmp+rename 0600 + * discipline and the corrupt-file fail-closed read are the store's own, + * unchanged. Validating the delivered JWKs (public-only, no `d`) is the + * CALLER's duty before this write - the `ssh_machine_pin_repair` handler + * re-runs `bytesOfJwk` on both halves - which keeps the store's job one + * thing: byte-faithful persistence. + */ + repair(nodeId: string, pin: MachinePin): void { + const all = this.loadAll(); + all[nodeId] = { signing: pin.signing, encryption: pin.encryption }; + 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 + * 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 emptyPinMap(); + 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 = 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`)); + } + all[peerId] = { signing: value.signing, encryption: value.encryption }; + } + return all; + } + + /** + * 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 = 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 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); + } + + /** 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/apps/node/agent/src/relay-agent-scheme.ts b/apps/node/agent/src/relay-agent-scheme.ts new file mode 100644 index 000000000..c4e2b632e --- /dev/null +++ b/apps/node/agent/src/relay-agent-scheme.ts @@ -0,0 +1,304 @@ +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. + * + * The base64url spelling is DELIBERATELY internal and differs from what + * `ssh-keygen -l` prints (standard base64: different alphabet, padding): + * these fingerprints are compared ONLY against this spelling (grant rows, + * roster answers, selection scoping), never against ssh-keygen output, and + * the UI never invites that cross-comparison (the out-of-band comparison + * story is about machine keys on the trust card, §4.6). Do NOT "normalize" + * this to standard base64: stored grant fingerprints are these strings, and + * a silent alphabet swap would invalidate every standing grant. + */ +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 }; +} + +/* ------------------------------------------------------------------ */ +/* 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) */ +/* ------------------------------------------------------------------ */ + +/** 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-proxy.ts b/apps/node/agent/src/relay-proxy.ts new file mode 100644 index 000000000..5e0fe15d9 --- /dev/null +++ b/apps/node/agent/src/relay-proxy.ts @@ -0,0 +1,521 @@ +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. + * + * 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 + * 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). 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; +} + +/** 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. */ +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 + * 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 - and it + // parses the string itself, so a malformed pin is caught HERE and surfaces + // as the named refusal, never as a raw SyntaxError). 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 proxy: 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 }; + + // 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: 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 }[] = []; + 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 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: reqSeq, + 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; + } + try { + args.sendRelayFrame({ + type: "relay", + ref: args.ref, + seq: transportSeq, + direction: "B2A", + blob, + }); + } catch (err) { + // DELIVER-OR-THROW (the callback's JSDoc): a thrown pump means the + // frame never reached A, so no reply will ever come for it. Taking the + // slot anyway would park a phantom request until an external teardown; + // mirror the seal-failure posture instead: cost the request its + // connection and drain the queue. No seq is consumed - A never saw + // this request, so the next one lawfully re-uses the numbers. + say(`relay session ${args.ref}: the link pump refused a request: ${String(err)}`); + sealedInFlight = false; + failConn(conn); + void pumpQueue(); + return; + } + signedSeq += 1; + transportSeq += 1; + sealedInFlight = false; + // The slot HOLDS the delivered frame's reply: further requests queue + // until it lands - or the connection dies, which BURNS the slot (see the + // close handler) so the owed reply is absorbed, never handed past it. + outstanding = { conn, signedSeq: reqSeq, awaitingAbsorb: false }; + 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. A death with the frame ALREADY DELIVERED burns + // the slot instead of freeing it: A's genuine reply to the dead + // request is still coming, and release-and-immediately-promote would + // write that reply onto the next connection's socket - a wrong agent + // answer into a live ssh stream. The burn is absorbed in deliverReply, + // and only then does the slot release and the queue promote. + if (outstanding?.conn === conn && !sealedInFlight && !outstanding.awaitingAbsorb) { + outstanding.awaitingAbsorb = true; + } + }; + 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 - or absorb it if its + * connection died (the burned slot consumes it). 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 slot = outstanding; + if (!slot) { + say(`relay session ${args.ref}: a reply with no outstanding request was dropped`); + return; + } + if (slot.awaitingAbsorb) { + // The ABSORB path: the connection that made this request died after its + // frame was delivered, so this verified reply is the dead request's + // answer. It passed the full checklist (nothing unverified gets this + // far) and is then discarded: written to NO socket. The burn ends here + // - the slot releases and the queue promotes only now, so the live + // connection behind it waits for its OWN reply, in order. + say(`relay session ${args.ref}: absorbed the verified reply to a dead agent connection (seq ${slot.signedSeq})`); + outstanding = null; + void pumpQueue(); + return; + } + outstanding = null; + try { + slot.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 */ + } + }, + }; +} diff --git a/apps/node/agent/src/relay-responder.ts b/apps/node/agent/src/relay-responder.ts new file mode 100644 index 000000000..8cbb4d751 --- /dev/null +++ b/apps/node/agent/src/relay-responder.ts @@ -0,0 +1,436 @@ +import { + base64UrlNoPad, + bytesOfJwk, + newNonce, + openRelayEnvelope, + type RelayFrame, + type RelayOpenFn, + type RelaySealFn, + SeqGate, + sealRelayEnvelope, + verifyRelayEnvelope, +} from "@internal/subshell-protocol"; +import { log } from "./log.js"; +import { + type AgentScheme, + filterIdentitiesAnswer, + fingerprintAgentBlob, + parseSignRequest, + parseSignResponse, + 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 + * 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 **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 three rules of §5.4, in the order they run: + * + * 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 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 + * 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 single-byte refusal every refused request is answered with (5 in both schemes). */ +const FAILURE = Buffer.from([SSH2_AGENT_FAILURE]); + +/* ------------------------------------------------------------------ */ +/* the responder */ +/* ------------------------------------------------------------------ */ + +/** 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; + /** 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 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`. */ + 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 `liveAgentSocketPath` + * from relay-agent-socket.ts). A test seam and the operator override land + * 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. */ + 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 `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; + 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. `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; + 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) { + say(`relay session ${args.ref}: refused an agent request with no type byte; not forwarded`); + return FAILURE; + } + 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, scheme)); + } catch { + say(`relay session ${args.ref}: refused a malformed SIGN_REQUEST body; not forwarded`); + return FAILURE; + } + // §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; + } + 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 + // 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; + } + + 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. */ + 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})` : ""}`); + }, + }; +} 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))); + }; +} 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 }; +} 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; 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..46d44c0a3 --- /dev/null +++ b/apps/node/web/src/routes/__tests__/status-page.test.tsx @@ -0,0 +1,122 @@ +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, + canRetire: 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/__tests__/install-script.test.ts b/apps/server/api/src/api/__tests__/install-script.test.ts new file mode 100644 index 000000000..456e59417 --- /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/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/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 ;; *) 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/__tests__/nodes-crud-route.test.ts b/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts index 6cc555bd3..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 @@ -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,62 @@ 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); + } 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__/repair-node-machine-pin.route.test.ts b/apps/server/api/src/api/nodes/__tests__/repair-node-machine-pin.route.test.ts new file mode 100644 index 000000000..50742a336 --- /dev/null +++ b/apps/server/api/src/api/nodes/__tests__/repair-node-machine-pin.route.test.ts @@ -0,0 +1,263 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { nodesRoutes } from "@/api/nodes/index.js"; +import { db } from "@/db/index.js"; +import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; +import { setSshMachinePinsDepsForTests } from "@/services/ssh-machine-pins.service.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +/** + * `POST /api/nodes/:id/machine-pins/:peerNodeId/repair` (spec 2026-10-08 + * §4.5, Task 17) - the §4.5 machine trust re-pair's HTTP door. The + * posture this file pins is the ONE the design makes load-bearing: + * + * - EXACT owner only, on BOTH machines' facts: `gate.row.ownerUserId === + * user.id`. An `edit` grantee is 403 (re-authorizing a peer's key on + * someone else's machine is not a share level confers); the ADMIN is 403 + * on a foreign agent (the instance-wide boost stops at a trust act, and + * the `canManage` local exception must not leak here); `local` never + * repairs (its owner is the system service user, so every human hits the + * owner door); a stranger is 404 (an invisible node stays invisible); + * a bearer is refused (cookie-only like every node write). + * - The success path names the acting human in exactly one + * `node.ssh_machine_pin.repair` row carrying `{nodeIdOfA, peerNodeId}` - + * the serialized row asserts free of the key sentinels. + * - A offline answers 409 NODE_OFFLINE and writes NO audit row (the service + * audited nothing, which is the no-success-audit rule at the HTTP edge). + * + * The command delivery is the service seam; nothing here touches a socket. + */ + +const pw = "repair-1"; +const ownerEmail = `rp-owner-${crypto.randomUUID()}@subshell.local`; +const editorEmail = `rp-editor-${crypto.randomUUID()}@subshell.local`; +const otherEmail = `rp-other-${crypto.randomUUID()}@subshell.local`; +const adminEmail = `rp-admin-${crypto.randomUUID()}@subshell.local`; +let ownerId: string; +let editorId: string; +let ownerCookie: string; +let editorCookie: string; +let otherCookie: string; +let adminCookie: string; +let subshellKey = ""; + +const NODE_A = `n-rp-a-${crypto.randomUUID()}`; +const PEER_B = `n-rp-b-${crypto.randomUUID()}`; +const PEER_NAKED = `n-rp-k-${crypto.randomUUID()}`; + +const PEER_SIGNING = '{"kty":"EC","crv":"P-256","x":"RPSIGN-SENTINEL","y":"RPSIGN-SENTINEL2"}'; +const PEER_ENCRYPT = '{"kty":"EC","crv":"P-256","x":"RPENC-SENTINEL","y":"RPENC-SENTINEL2"}'; +const PEER_ENCRYPT_B64 = Buffer.from(PEER_ENCRYPT, "utf8").toString("base64"); + +let sentCmds: unknown[] = []; +let sendBehavior: (nodeId: string, cmd: unknown) => Promise<{ repaired: true; peerNodeId: string }> = async ( + _n, + cmd, +) => ({ + repaired: true, + peerNodeId: (cmd as { peerNodeId: string }).peerNodeId, +}); + +async function repairRows(): Promise<{ actorUserId: string | null; metadataJson: string | null }[]> { + return await db + .selectFrom("auditEvents") + .select(["actorUserId", "metadataJson"]) + .where("action", "=", "node.ssh_machine_pin.repair") + .where("targetId", "=", NODE_A) + .execute(); +} + +function post(nodeId: string, peerId: string, cookie: string | null, bearer?: string) { + const headers: Record = { "content-type": "application/json" }; + if (cookie) headers.cookie = `better-auth.session_token=${cookie}`; + if (bearer) headers.authorization = `Bearer ${bearer}`; + return nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${nodeId}/machine-pins/${peerId}/repair`, { method: "POST", headers }), + ); +} + +beforeAll(async () => { + await setupAuthTables(); + ownerId = await new UsersRepository(db).createUser({ + email: ownerEmail, + name: ownerEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + editorId = await new UsersRepository(db).createUser({ + email: editorEmail, + name: editorEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + await new UsersRepository(db).createUser({ + email: otherEmail, + name: otherEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + await new UsersRepository(db).createUser({ + email: adminEmail, + name: adminEmail, + passwordHash: await hashPassword(pw), + role: "admin", + }); + ownerCookie = await signIn(ownerEmail, pw); + editorCookie = await signIn(editorEmail, pw); + otherCookie = await signIn(otherEmail, pw); + adminCookie = await signIn(adminEmail, pw); + await ensureLocalNode(db); + + const now = new Date().toISOString(); + for (const [id, kind] of [ + [NODE_A, "agent"], + [PEER_B, "agent"], + [PEER_NAKED, "agent"], + ] as const) { + await db + .insertInto("nodes") + .values({ id, ownerUserId: ownerId, name: id, kind, status: "offline", createdAt: now, updatedAt: now } as never) + .execute(); + } + await new NodeSharesRepository(db).replaceForNode(NODE_A, [{ granteeUserId: editorId, permission: "edit" }], ownerId); + await new IdentitiesRepository(db).register({ + principalId: `node:${PEER_B}`, + publicKey: PEER_ENCRYPT, + signingPublicKey: PEER_SIGNING, + displayName: null, + }); + + // A pane row of the owner's, for the bearer refusal: a machine credential + // may not speak to this door at all. + const paneId = crypto.randomUUID(); + await db + .insertInto("subshells") + .values({ + id: paneId, + userId: ownerId, + name: "rp-probe", + harnessId: "claude-code", + workingDir: "/tmp", + tmuxSocket: "subshell-rp-probe", + nodeId: NODE_A, + status: "terminated", + alive: 0, + } as never) + .execute(); + subshellKey = await issueSubshellToken(paneId, ownerId); +}); + +afterAll(async () => { + setSshMachinePinsDepsForTests(null); + await db.deleteFrom("subshells").where("nodeId", "=", NODE_A).execute(); + await db.deleteFrom("nodes").where("id", "in", [NODE_A, PEER_B, PEER_NAKED]).execute(); + await db.deleteFrom("identities").where("principalId", "=", `node:${PEER_B}`).execute(); + for (const email of [ownerEmail, editorEmail, otherEmail, adminEmail]) await deleteUserByEmailOrId(email); +}); + +beforeEach(() => { + sentCmds = []; + sendBehavior = async (_n, cmd) => ({ repaired: true, peerNodeId: (cmd as { peerNodeId: string }).peerNodeId }); + setSshMachinePinsDepsForTests({ + sendRepair: async (nodeId, cmd) => { + sentCmds.push(cmd); + return sendBehavior(nodeId, cmd); + }, + }); +}); + +describe("the door: EXACTLY the owner", () => { + it("the owner re-pairs: 200, the registered pair sent, one ids-only audit row", async () => { + const res = await post(NODE_A, PEER_B, ownerCookie); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ repaired: true }); + expect(sentCmds).toEqual([ + { + type: "ssh_machine_pin_repair", + peerNodeId: PEER_B, + peerSigningPublicKey: PEER_SIGNING, + peerEncryptPublicKey: PEER_ENCRYPT_B64, + }, + ]); + const rows = await repairRows(); + expect(rows).toHaveLength(1); + expect(rows[0].actorUserId).toBe(ownerId); // the human, wired at the route + expect(rows[0].metadataJson).toBe(JSON.stringify({ nodeIdOfA: NODE_A, peerNodeId: PEER_B })); + const serialized = JSON.stringify(rows); + for (const secret of [PEER_SIGNING, PEER_ENCRYPT, PEER_ENCRYPT_B64, "SENTINEL"]) { + expect(serialized).not.toContain(secret); // §10: ids only, no key bytes in the row + } + }); + + it("an edit grantee is 403: a share level does not re-authorize trust on another machine", async () => { + const before = (await repairRows()).length; + expect((await post(NODE_A, PEER_B, editorCookie)).status).toBe(403); + expect(sentCmds).toHaveLength(0); + expect((await repairRows()).length).toBe(before); + }); + + it("an admin is 403 on a foreign agent, and 403 on local (never a repair target)", async () => { + // The exact-owner rule, not `canManage`: the admin boost (effective edit) + // and the seeded-local manage exception both stop at a trust act. + const before = (await repairRows()).length; + expect((await post(NODE_A, PEER_B, adminCookie)).status).toBe(403); + expect((await post(LOCAL_NODE_ID, PEER_B, adminCookie)).status).toBe(403); + expect((await post(LOCAL_NODE_ID, PEER_B, ownerCookie)).status).toBe(403); + expect(sentCmds).toHaveLength(0); + expect((await repairRows()).length).toBe(before); + }); + + it("a stranger is 404 and an unknown node is 404: invisible stays invisible", async () => { + expect((await post(NODE_A, PEER_B, otherCookie)).status).toBe(404); + expect((await post(`n-rp-ghost-${crypto.randomUUID()}`, PEER_B, ownerCookie)).status).toBe(404); + }); + + it("a bearer machine credential is refused outright (cookie-only, like every node write)", async () => { + const before = (await repairRows()).length; + expect((await post(NODE_A, PEER_B, null, subshellKey)).status).toBe(403); + expect((await post(NODE_A, PEER_B, null)).status).toBe(401); + expect(sentCmds).toHaveLength(0); + expect((await repairRows()).length).toBe(before); + }); +}); + +describe("the service doors the route renders", () => { + it("A offline: 409 NODE_OFFLINE, no command audited as a success", async () => { + sendBehavior = async () => { + throw new SshRpcError("offline", "no live socket", NODE_A); + }; + const before = (await repairRows()).length; + const res = await post(NODE_A, PEER_B, ownerCookie); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string }; + expect(body.code).toBe("NODE_OFFLINE"); + expect((await repairRows()).length).toBe(before); + }); + + it("a peer without its registered signing half: 409 SSH_RELAY_IDENTITY_MISSING", async () => { + const res = await post(NODE_A, PEER_NAKED, ownerCookie); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string }; + expect(body.code).toBe("SSH_RELAY_IDENTITY_MISSING"); + }); + + it("same-node is a 400, and a machine refusal is 502 SSH_NODE_REFUSED", async () => { + expect((await post(NODE_A, NODE_A, ownerCookie)).status).toBe(400); + sendBehavior = async () => { + throw new SshRpcError("refused", "peer key rejected", NODE_A, "peer key rejected"); + }; + const res = await post(NODE_A, PEER_B, ownerCookie); + expect(res.status).toBe(502); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe("SSH_NODE_REFUSED"); + // The node's words stay in the service log, not in the human's refusal - + // and no key material could ride either way (this copy is fixed). + expect(body.message).not.toContain("SENTINEL"); + }); +}); 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..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 @@ -456,4 +456,94 @@ 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); + // 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(); + } + }); + + 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/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/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/index.ts b/apps/server/api/src/api/nodes/index.ts index 8d51a6280..c0e3cf6a2 100644 --- a/apps/server/api/src/api/nodes/index.ts +++ b/apps/server/api/src/api/nodes/index.ts @@ -11,6 +11,7 @@ import { nodeLoggingRoute } from "@/api/nodes/node-logging.route.js"; import { nodeLogsRoute } from "@/api/nodes/node-logs.route.js"; import { recheckNodeRoute } from "@/api/nodes/recheck-node.route.js"; import { renameNodeRoute } from "@/api/nodes/rename-node.route.js"; +import { repairNodeMachinePinRoute } from "@/api/nodes/repair-node-machine-pin.route.js"; import { reregisterNodeRoute } from "@/api/nodes/reregister-node.route.js"; import { rotateNodeKeyRoute } from "@/api/nodes/rotate-node-key.route.js"; import { serviceNodeRoute } from "@/api/nodes/service-node.route.js"; @@ -48,6 +49,7 @@ export const nodesRoutes = new Elysia({ prefix: "/api/nodes" }) .use(setNodeAllowedDirsRoute) .use(setNodeMaintenanceRoute) .use(setNodeSshEnabledRoute) + .use(repairNodeMachinePinRoute) .use(rotateNodeKeyRoute) .use(reregisterNodeRoute) .use(recheckNodeRoute) 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/repair-node-machine-pin.route.ts b/apps/server/api/src/api/nodes/repair-node-machine-pin.route.ts new file mode 100644 index 000000000..98e4d106f --- /dev/null +++ b/apps/server/api/src/api/nodes/repair-node-machine-pin.route.ts @@ -0,0 +1,84 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { authGuard, ForbiddenError, requireCookieActor } from "@/api/auth-guard.js"; +import { loadNodeGate } from "@/api/nodes/node-gate.js"; +import { apiErrorBody } from "@/lib/api-error.js"; +import { apiModels } from "@/schema/index.js"; +import { + machinePinRepairRefusal, + repairMachinePin, + SshMachinePinRepairError, +} from "@/services/ssh-machine-pins.service.js"; + +const RepairedSchema = t.Object({ + repaired: t.Boolean({ description: "Always true on success: the machine's ack confirmed the write" }), +}); + +/** + * `POST /api/nodes/:id/machine-pins/:peerNodeId/repair` — the §4.5 re-pair + * (spec 2026-10-08 §4.5, Task 17): replace ONE peer's stored machine trust + * pin on one machine with that peer's CURRENT registered public pair, which + * the plane re-delivers to the machine over its live link. Cookie-only, and + * OWNER-only in the exact sense: the caller must equal the node row's + * `ownerUserId` — an `edit` grantee may not re-authorize a peer's key on + * someone else's machine, the admin's instance-wide edit does not reach a + * trust act either, and `local` holds no machine store to repair (the + * service names both doors; the route answers the invisible/foreign pair with + * the same 404 so ids cannot be probed). + * + * The route performs no write and records no audit itself: + * `repairMachinePin` is the one act-site (read the peer's registered keys → + * signed command to an online A → ids-only audit), so no caller can perform + * half of it — and an offline A or a refused delivery audits NOTHING (no + * success-as-audit on a failed act). + */ +export const repairNodeMachinePinRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .post( + "/:id/machine-pins/:peerNodeId/repair", + async ({ params, user, actor, status }) => { + requireCookieActor(actor, "Machine trust re-pairing is restricted to browser sessions"); + const gate = await loadNodeGate(user.id, params.id); + if (!gate) { + return status(404, apiErrorBody({ code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Node not found" })); + } + // EXACTLY the owner: `canManage` opens `local` to admins, and a trust + // act must not depend on the local exception or the admin boost. The + // service re-checks the same fact; the route answers the human's 403. + if (gate.row.ownerUserId !== user.id) { + throw new ForbiddenError(); + } + try { + await repairMachinePin({ actorUserId: user.id, nodeId: gate.row.id, peerNodeId: params.peerNodeId }); + } catch (err) { + if (err instanceof SshMachinePinRepairError) { + const refusal = machinePinRepairRefusal(err); + return status(refusal.status, apiErrorBody({ code: refusal.code, message: refusal.message })); + } + throw err; + } + return { repaired: true }; + }, + { + params: t.Object({ + id: t.String({ description: "Node id (the machine whose pin store is repaired)" }), + peerNodeId: t.String({ description: "Peer node id (the stored pin being replaced)" }), + }), + response: { + 200: RepairedSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "repairNodeMachinePin", + tags: ["nodes"], + description: + "Re-pair one peer's machine trust pin on a machine you own: the plane re-delivers the peer's registered public keys to it, replacing that peer's stored pin (spec 2026-10-08 §4.5). The machine must be online; the act is audited naming the peer id 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..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 @@ -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,28 @@ 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. 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 }) + .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/api/public-jwk.ts b/apps/server/api/src/api/public-jwk.ts index f0bcfe32b..8fc2421e6 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/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..13a5381d5 --- /dev/null +++ b/apps/server/api/src/api/ssh/__tests__/grants-route.test.ts @@ -0,0 +1,821 @@ +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 { 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"; +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 { 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"; + +/** + * 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; + hostPin?: string; +}[] = []; +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[] = []; + +/** 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); + 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), + fetchAgentIdentities: async (nodeId: string) => { + rosterCalls.push(nodeId); + return rosterAnswer(); + }, + }); +} + +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); + 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(); + 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", + destination: "git.example.test:22", + 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", + destination: "second.example.test:22", + 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: "" }), + // Task 18's roster-by-node read answers the same doctrine. + await sshFetch(`/api/ssh/grants/identities?node=${NODE_A}`, { cookie: "" }), + await sshFetch("/api/ssh/grants", { bearer: key }), + await sshFetch("/api/ssh/grant-requests", { bearer: key }), + await sshFetch(`/api/ssh/grants/identities?node=${NODE_A}`, { 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); + // 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(); + } + }); + + 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(); + } + }); +}); + +/* ------------------------------------------------------------------ */ +/* 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", + destination: "git.example.test:22", + 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([]); + }); +}); + +/* ------------------------------------------------------------------ */ +/* GET /api/ssh/grants/identities?node= (Task 18: the roster-by-node) */ +/* ------------------------------------------------------------------ */ + +describe("GET /api/ssh/grants/identities (the roster behind the create picker)", () => { + beforeEach(() => { + installFakeDeps(); // reset the scripted roster RPC and its call log between cases + rosterCalls = []; + }); + + it("reads the key home's roster with NO pending request: the roster rides home and nothing is written", async () => { + // The service suite pins this on a clean tier (no request row at all); + // this route's tier is shared with the queue describes, so the write + // silence is pinned as a before/after count: NO audit row, and no + // request row created or answered by the read. + const auditsBefore = (await db.selectFrom("auditEvents").select("id").execute()).length; + const pendingBefore = ( + await db.selectFrom("sshGrantRequests").select("id").where("status", "=", "pending").execute() + ).length; + rosterAnswer = () => ({ identities: [{ fingerprint: `SHA256:${"D".repeat(43)}`, comment: "work laptop" }] }); + const res = await sshFetch(`/api/ssh/grants/identities?node=${NODE_A}`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = (await res.json()) as { identities: Record[] }; + expect(body.identities).toEqual([{ fingerprint: `SHA256:${"D".repeat(43)}`, comment: "work laptop" }]); + // The entry shape is exactly the two public fields (T11's guarantee carried here too). + for (const entry of body.identities) expect(Object.keys(entry).sort()).toEqual(["comment", "fingerprint"]); + expect(rosterCalls).toEqual([NODE_A]); // the KEY HOME asked, exactly once + expect((await db.selectFrom("auditEvents").select("id").execute()).length).toBe(auditsBefore); + expect( + (await db.selectFrom("sshGrantRequests").select("id").where("status", "=", "pending").execute()).length, + ).toBe(pendingBefore); + }); + + it("an honest empty from a live agent is a 200 empty list; an offline key home is the named 409 with NO identities arm", async () => { + rosterAnswer = () => ({ identities: [] }); + const empty = await sshFetch(`/api/ssh/grants/identities?node=${NODE_A}`, { cookie: ownerCookie }); + expect(empty.status).toBe(200); + expect(((await empty.json()) as { identities: unknown[] }).identities).toEqual([]); + + rosterAnswer = () => { + throw new SshRpcError("offline", "node has no live connection", NODE_A); + }; + const offline = await sshFetch(`/api/ssh/grants/identities?node=${NODE_A}`, { cookie: ownerCookie }); + expect(offline.status).toBe(409); + const body = (await offline.json()) as Record; + expect(body.code).toBe(BackendErrorCodes.NODE_OFFLINE); + // The offline answer is an error envelope, never an `identities: []` the + // picker could misread as "A holds no keys" (T11's fabrication rule). + expect(body.identities).toBeUndefined(); + }); + + it("a foreign viewer and a vanished node collapse to the same 404, and the machine is never asked", async () => { + const foreign = await sshFetch(`/api/ssh/grants/identities?node=${NODE_A}`, { cookie: otherCookie }); + expect(foreign.status).toBe(404); + const ghost = await sshFetch(`/api/ssh/grants/identities?node=node-does-not-exist`, { cookie: ownerCookie }); + expect(ghost.status).toBe(404); + expect(rosterCalls).toEqual([]); + }); + + it("a key home with SSH switched off answers the gate's 403 before anything is asked", async () => { + const res = await sshFetch(`/api/ssh/grants/identities?node=${NODE_OFF}`, { cookie: ownerCookie }); + expect(res.status).toBe(403); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_GATE_OFF); + expect(rosterCalls).toEqual([]); + }); + + it("a missing or empty ?node= is the declared 400 INPUT_VALIDATION_ERROR, and no machine is asked", async () => { + // The route's schema pins node as required with minLength 1; the 400 arm + // is declared but nothing else on this describe exercises it. Absent and + // empty are the two spells of "nothing chosen", and both must answer as + // the error envelope, never reach a machine. + const missing = await sshFetch("/api/ssh/grants/identities", { cookie: ownerCookie }); + expect(missing.status).toBe(400); + expect(((await missing.json()) as { code: string }).code).toBe(BackendErrorCodes.INPUT_VALIDATION_ERROR); + const empty = await sshFetch("/api/ssh/grants/identities?node=", { cookie: ownerCookie }); + expect(empty.status).toBe(400); + expect(((await empty.json()) as { code: string }).code).toBe(BackendErrorCodes.INPUT_VALIDATION_ERROR); + expect(rosterCalls).toEqual([]); + }); +}); 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/__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..70b453e4a --- /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 } = {}) { + 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/__tests__/ssh-routes.test.ts b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts index b2c7056b3..9a12b66bb 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,36 @@ 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("the roster-by-node read (Task 18) refuses `local` at the SAME door as the create: the 409 before anything is asked", async () => { + await nodes.setSshEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-07T09:00:00.000Z" }); + // No roster seam is installed in this suite: reaching the fetch would run + // the production RPC against `local`, which has no node link at all. The + // kind check must land first, so this 409 is also the proof nothing asked. + const res = await sshFetch(`/api/ssh/grants/identities?node=${LOCAL_NODE_ID}`, { cookie: adminCookie }); + expect(res.status).toBe(409); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_RELAY_OPEN_FAILED); + }); + 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 new file mode 100644 index 000000000..0333cdfae --- /dev/null +++ b/apps/server/api/src/api/ssh/approvals.route.ts @@ -0,0 +1,158 @@ +import { normalizeLabel } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { + SshAgentIdentityViewSchema, + SshGrantRequestViewSchema, + SshGrantViewSchema, + throwCodedRefusal, +} from "@/api/ssh/ssh-views.js"; +import { apiModels } from "@/schema/index.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, 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 + * `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", + }, + }, + ) + .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(SshAgentIdentityViewSchema, { + 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 }) => { + 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 naming the chosen fingerprints (public SHA256: identifiers) and the destination, per the durable selection record in docs/security.md §10", + }, + }, + ) + .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..b3c598f36 --- /dev/null +++ b/apps/server/api/src/api/ssh/grants.route.ts @@ -0,0 +1,219 @@ +import { normalizeLabel } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; +import { SshAgentIdentityViewSchema, 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 { listNodeAgentIdentities, 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 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). + * + * `GET /grants/identities?node=` is the roster-BY-NODE read (spec §8, Task + * 18): the create picker's candidate list, the same §5.4 roster command the + * approval screen asks, taken against a directly-chosen key home with NO + * pending request standing. It writes nothing (not even an audit row), gates + * through the key home's own gate before the machine is asked, and fails + * closed by name when the key home is unreachable - an empty list means the + * live agent holds nothing, never "we could not ask". + */ +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", + }, + }, + ) + .get( + "/grants/identities", + async ({ query, user, actor }) => { + requireCookieActor(actor, "SSH key grants are restricted to browser sessions"); + const answer = await listNodeAgentIdentities({ viewerId: user.id, aNodeId: query.node }); + if (!answer.ok) return throwCodedRefusal(answer.refusal); + return { identities: answer.value.identities }; + }, + { + query: t.Object({ + node: t.String({ + minLength: 1, + description: "Key home machine whose live agent roster to read (an agent node you own, SSH switched on)", + }), + }), + response: { + 200: t.Object({ + identities: t.Array(SshAgentIdentityViewSchema, { + description: + "The key home's WHOLE public roster with the key blobs withheld; the picker selects the grant's subset (cap enforced on create, never by truncating this list)", + }), + }), + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + detail: { + operationId: "listSshNodeAgentIdentities", + tags: ["ssh"], + description: + "Enumerates a chosen key home's live agent identities for the create picker (fingerprints plus comments, blobs withheld, no pending request needed); an offline or refusing key home answers a named error, never a fabricated empty roster, and the read writes no audit row", + }, + }, + ) + .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: { + // 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", + }, + 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..7e6988758 100644 --- a/apps/server/api/src/api/ssh/index.ts +++ b/apps/server/api/src/api/ssh/index.ts @@ -1,8 +1,12 @@ 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"; +import { sshSetupHereRoute } from "@/api/ssh/setup-here.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): @@ -17,4 +21,20 @@ 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) + // 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) + // 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/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/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/apps/server/api/src/api/ssh/ssh-views.ts b/apps/server/api/src/api/ssh/ssh-views.ts index 2489168cb..eb1936b2e 100644 --- a/apps/server/api/src/api/ssh/ssh-views.ts +++ b/apps/server/api/src/api/ssh/ssh-views.ts @@ -127,3 +127,63 @@ 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 public agent identity from the key home's live roster (spec 2026-10-08 + * §5.4, Tasks 11/18): the shape BOTH roster reads answer with - the + * request-scoped one behind the approval screen and the roster-by-node read + * behind the create picker. The two fields are the whole of it, by + * construction: the key BLOBS are withheld on the machine, and this schema is + * where the wire restates that there is nowhere for one to ride. + */ +export const SshAgentIdentityViewSchema = 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)" }), +}); + +/** 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/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/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 6acba4c3c..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 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 - // the strip set moves with it in the same commit. + // 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 ('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','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home') 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; 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( - "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; 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 ('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','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home')", ) .all(), ).toEqual([]); @@ -430,18 +430,18 @@ 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','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home') 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" }, + { 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(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 +453,17 @@ 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", + }); + 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 e47050718..cc71e22fe 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -48,6 +48,10 @@ 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"; +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. @@ -140,6 +144,21 @@ 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, + // 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, + // 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/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/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/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/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__/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..1c0042abf --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0049-ssh-relay-identity.test.ts @@ -0,0 +1,140 @@ +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 () => { + // 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({ + 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/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/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/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__/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/__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/identities.repository.ts b/apps/server/api/src/db/repositories/identities.repository.ts index 1779f874b..7e77ef604 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'))`, }), @@ -33,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/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/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/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/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/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/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/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/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..5e731431c --- /dev/null +++ b/apps/server/api/src/db/types/ssh-grants.db-types.ts @@ -0,0 +1,144 @@ +/** + * 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): 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) */ + 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; + /** + * 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 */ + 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 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; + /** 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/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/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/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/__tests__/expo-push.test.ts b/apps/server/api/src/services/__tests__/expo-push.test.ts index 154770e28..c759e820d 100644 --- a/apps/server/api/src/services/__tests__/expo-push.test.ts +++ b/apps/server/api/src/services/__tests__/expo-push.test.ts @@ -36,6 +36,30 @@ describe("buildExpoMessages", () => { expect(m._channelId).toBe("subshell-subshells"); // legacy twin, always the same value }); + it("grant_approval names the action-free category: its sid is a request, not a pane", () => { + // The `subshell` category advertises Silence bell, which PATCHes a + // subshell, meaningless for a grant_approval whose sid is the grant + // REQUEST uuid. `subshell-plain` (registered beside it in the app) shows + // plain Open; the app routes that tap to settings (PR #338 review, + // Important 1). The payload already carried `kind`; the tap handler reads it. + const [m] = buildExpoMessages(["ExponentPushToken[a]"], "req-1", "grant_approval", 0); + expect(m.categoryId).toBe("subshell-plain"); + expect(m.data.kind).toBe("grant_approval"); + expect(m.data.sid).toBe("req-1"); + // Every pane-attached kind keeps the full-action category, byte-for-byte. + for (const kind of [ + "turn_complete", + "needs_attention", + "exited", + "crashed", + "crashed_final", + "maintenance", + ] as const) { + const [k] = buildExpoMessages(["t"], "s", kind, 0); + expect(k.categoryId).toBe("subshell"); + } + }); + it("builds one opaque message per token — never a name, path or operator text", () => { const msgs = buildExpoMessages(["ExponentPushToken[a]", "ExponentPushToken[b"], "sess-1", "needs_attention", 4); expect(msgs).toHaveLength(2); 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..2808bf3c0 --- /dev/null +++ b/apps/server/api/src/services/__tests__/helpers/agent-relay-globals.d.ts @@ -0,0 +1,41 @@ +/** + * 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` (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). */ +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/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..c2085f7fe --- /dev/null +++ b/apps/server/api/src/services/__tests__/helpers/ssh-relay-stack.ts @@ -0,0 +1,619 @@ +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 RelayEnvelopeOpen, + 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; +} + +/** + * 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[] = []; + 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[] = []; + // 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 => { + 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" && swallowArmed) { + 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, + // 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 }, + 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)), + ], + // 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 = []; + 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 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 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, + 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-agent-identities.test.ts b/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts new file mode 100644 index 000000000..5bfdcd31d --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-agent-identities.test.ts @@ -0,0 +1,261 @@ +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 { setSshHostPinsDepsForTests } from "@/services/ssh-host-pins.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"; +/** 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"); +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 = []; + // 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: () => + ({ + 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, + destination: `${HOST}:22`, + 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); + setSshHostPinsDepsForTests(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/__tests__/ssh-grants.service.test.ts b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts new file mode 100644 index 000000000..b3d3a89aa --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-grants.service.test.ts @@ -0,0 +1,1053 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { type NodeSshAgentIdentitiesResult, 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 { SshRpcError } from "@/services/nodes/ssh-rpc.js"; +import { + approveGrant, + createGrant, + denyGrant, + listGrantRequests, + matchGrant, + normalizeSshGrantSelector, + prepareRelayLeg, + requestFirstUse, + revokeGrant, + type SshGrantView, + setSshGrantsDepsForTests, + sshGrantSelectorMatches, + sweepExpiredGrantRequests, + updateGrant, +} from "@/services/ssh-grants.service.js"; +import { captureHostPin, hostKeyFingerprints, setSshHostPinsDepsForTests } from "@/services/ssh-host-pins.service.js"; +import { listNodeAgentIdentities } from "@/services/ssh-launch.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 + * 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` 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. + * - 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"; +/** SSH switched OFF on this one: the key-home gate's negative fixture (Task 18 roster read). */ +const NODE_OFF = "gsvc-node-off"; +const nodes = new NodesRepository(db); + +/** 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"); +const nowIso = () => new Date(clockMs).toISOString(); + +interface FakeCall { + grantId: string; + reason: string; +} +let openCalls: unknown[] = []; +let closeGrantCalls: FakeCall[] = []; +let notifyCalls: { ownerUserId: string; requestId: string }[] = []; +/** Scripted roster RPC (Task 11 seam, Task 18 caller): a canned answer, plus the call log. */ +let rosterAnswer: () => NodeSshAgentIdentitiesResult = () => ({ identities: [] }); +let rosterCalls: string[] = []; + +function installFakeDeps(brokerOverrides: Partial = {}) { + pinFetchCalls = []; + rosterCalls = []; + 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); + 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 }); + }, + fetchAgentIdentities: async (nodeId: string) => { + rosterCalls.push(nodeId); + return rosterAnswer(); + }, + }); +} + +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() }); + // Task 18's gate negative: an owned agent whose SSH mirror is OFF (the default). + await nodes.create({ id: NODE_OFF, ownerUserId: owner, name: "ssh off", kind: "agent", status: "offline" }); + 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); + setSshHostPinsDepsForTests(null); + await db.deleteFrom("sshHostPins").execute(); + 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, NODE_OFF]) 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 = []; + rosterAnswer = () => ({ identities: [] }); + installFakeDeps(); +}); + +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> { + 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, + destination: DEST, + 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, + destination: DEST, + 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, + destination: DEST, + 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, + destination: DEST, + 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, + destination: DEST, + paneId: "p1", + }); + expect(after.ok).toBe(true); + if (!after.ok) return; + expect(after.value.requestId).toBe(first.value.requestId); + expect(notifyCalls).toHaveLength(0); + }); + + it("normalizes case: a mixed-case ask stores the lowercased selector and dedupes across spellings", async () => { + const first = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: "Git.Example.TEST", + destination: "Git.Example.TEST:22", + paneId: "p-case", + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + // The row is stored lowercased: the matcher compares against the + // lowercased resolved host, so an un-normalized row is a grant the + // matcher could never re-satisfy. + const row = await repo.getRequest(owner, first.value.requestId); + expect(row?.resolvedSelector).toBe(HOST); + clockMs += 60_000; + // A relaunch with the other case FINDS the pending row: no second + // question, no second notification (same no-double-request shape). + const again = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + destination: DEST, + paneId: "p-case-2", + }); + 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); + }); + + it("IPv6-literal first use: the row stores the bracketed host lowercased, approve mints that exact grant, and it re-matches", async () => { + // The first-use door is not grammar-gated (PR #338 review round 5): the + // resolver's HOST_RE admits the bracketed spelling with uppercase hex, + // so this is the real shape of an IPv6 ask. Lowercasing is exactly what + // makes the minted grant re-match instead of re-asking forever. + const asked = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: "[2001:DB8::1]", + destination: "[2001:DB8::1]:22", + paneId: "p-v6", + }); + expect(asked.ok).toBe(true); + if (!asked.ok) return; + const row = await repo.getRequest(owner, asked.value.requestId); + expect(row?.resolvedSelector).toBe("[2001:db8::1]"); + const approved = await approveGrant({ + ownerUserId: owner, + requestId: asked.value.requestId, + fingerprints: FPS, + }); + expect(approved.ok).toBe(true); + if (!approved.ok) return; + expect(approved.value.grant.resolvedSelector).toBe("[2001:db8::1]"); + expect(approved.value.grant.createdVia).toBe("first-use"); + // A subsequent matcher pass over the same destination is silent: the + // exact arm, against the lowercased host. + expect(sshGrantSelectorMatches(approved.value.grant.resolvedSelector, "[2001:db8::1]")).toBe(true); + }); + }); + + describe("approval cap and shape", () => { + let requestRef = ""; + beforeEach(async () => { + const asked = await requestFirstUse({ + ownerUserId: owner, + aNodeId: NODE_A, + bNodeId: NODE_B, + resolvedSelector: HOST, + destination: DEST, + 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 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); + } + }); + + 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, + destination: DEST, + 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, + destination: DEST, + 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, + destination: DEST, + 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, + destination: DEST, + 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, + destination: DEST, + 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, + destination: DEST, + 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, + destination: DEST, + 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("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"); + 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 + // IPv6-literal shape, pinned (PR #338 review rounds 3/5), by DOOR: the + // manual doors (create/edit, grammar-gated) refuse an exact selector + // naming a bracketed IPv6 host; the first-use door -> approve mints one + // (its request selector is lowercased exactly so the minted grant + // re-matches, the exact arm below); `*` spans brackets and colons, so a + // wildcard still matches such a destination incidentally. + expect(normalizeSshGrantSelector("[::1]")).toBeNull(); + expect(sshGrantSelectorMatches("*1*", "[::1]")).toBe(true); + expect(sshGrantSelectorMatches("*.example.test", "[2001:db8::1]")).toBe(false); + // The first-use mint's spelling: lowercased bracketed host on the exact + // arm, and the host lowercasing keeps a later uppercase spelling too. + expect(sshGrantSelectorMatches("[2001:db8::1]", "[2001:db8::1]")).toBe(true); + expect(sshGrantSelectorMatches("[2001:db8::1]", "[2001:DB8::1]")).toBe(true); + }); + + 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 + // 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); // 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 () => { + 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, + destination: DEST, + paneId: "p", + }); + expect((await listGrantRequests({ ownerUserId: owner })).length).toBe(1); + expect((await listGrantRequests({ ownerUserId: stranger })).length).toBe(0); + }); + }); + + describe("listNodeAgentIdentities (the roster-by-node read, Task 18)", () => { + it("an owner reads A's live roster with NO pending request: the roster rides home and nothing is written", async () => { + // The whole point of T18: the create picker needs the roster BEFORE any + // first-use question exists. No request row stands, and none is created. + rosterAnswer = () => ({ + identities: [ + { fingerprint: FP(1), comment: "work laptop" }, + { fingerprint: FP(2), comment: "" }, + ], + }); + const answer = await listNodeAgentIdentities({ viewerId: owner, aNodeId: NODE_A }); + expect(answer.ok).toBe(true); + if (!answer.ok) return; + expect(answer.value.identities).toEqual([ + { fingerprint: FP(1), comment: "work laptop" }, + { fingerprint: FP(2), comment: "" }, + ]); + expect(rosterCalls).toEqual([NODE_A]); // the KEY HOME asked, exactly once + // A question, not an answer: no request, no grant, no audit row anywhere. + expect(await db.selectFrom("sshGrantRequests").selectAll().execute()).toHaveLength(0); + expect(await db.selectFrom("sshKeyGrants").selectAll().execute()).toHaveLength(0); + expect(await db.selectFrom("auditEvents").selectAll().execute()).toHaveLength(0); + }); + + it("an empty roster from a LIVE agent is the honest answer, kept distinct from every failure", async () => { + rosterAnswer = () => ({ identities: [] }); + const answer = await listNodeAgentIdentities({ viewerId: owner, aNodeId: NODE_A }); + expect(answer.ok).toBe(true); + if (!answer.ok) return; + expect(answer.value.identities).toEqual([]); + }); + + it("a foreign viewer is the invisible-node 404 and the machine is never asked", async () => { + rosterAnswer = () => { + throw new Error("a foreign read must not reach the machine"); + }; + const answer = await listNodeAgentIdentities({ viewerId: stranger, aNodeId: NODE_A }); + expect(answer.ok).toBe(false); + if (answer.ok) return; + expect(answer.refusal.status).toBe(404); + expect(rosterCalls).toEqual([]); + }); + + it("an absent node is the same 404, no id oracle between the two", async () => { + const answer = await listNodeAgentIdentities({ viewerId: owner, aNodeId: "node-does-not-exist" }); + expect(answer.ok).toBe(false); + if (answer.ok) return; + expect(answer.refusal.status).toBe(404); + expect(rosterCalls).toEqual([]); + }); + + it("an offline key home answers the named NODE_OFFLINE 409, never a fabricated empty roster, and writes no audit row", async () => { + rosterAnswer = () => { + throw new SshRpcError("offline", "node has no live connection", NODE_A); + }; + const answer = await listNodeAgentIdentities({ viewerId: owner, aNodeId: NODE_A }); + expect(answer.ok).toBe(false); // NOT an ok-with-empty-identities: fabrication is the failure mode + if (answer.ok) return; + expect(answer.refusal).toMatchObject({ status: 409, code: BackendErrorCodes.NODE_OFFLINE }); + expect(await db.selectFrom("auditEvents").selectAll().execute()).toHaveLength(0); + }); + + it("a key home that REFUSES the roster (e.g. the oversized bound) surfaces as SSH_NODE_REFUSED, never an empty roster", async () => { + // PR #338 review round 2 pinned the propagation the picker relies on: + // the node's named `ok:false` refusal (the T11 command refuses an + // oversized roster by name) travels as the refused arm, and the grant + // create picker answers a refusal the operator can act on - NOT a + // fabricated empty roster, which would read as "this agent holds no + // keys". The agent's own text rides the log only, never the response. + rosterAnswer = () => { + throw new SshRpcError( + "refused", + "the agent answered ok:false", + NODE_A, + "past the identity roster bound; refusing to answer an oversized roster", + ); + }; + const answer = await listNodeAgentIdentities({ viewerId: owner, aNodeId: NODE_A }); + expect(answer.ok).toBe(false); + if (answer.ok) return; + expect(answer.refusal).toMatchObject({ status: 502, code: BackendErrorCodes.SSH_NODE_REFUSED }); + expect(answer.refusal.message).not.toInclude("oversized"); + expect(await db.selectFrom("auditEvents").selectAll().execute()).toHaveLength(0); + }); + + it("a key home whose SSH is switched off is the gate's 403 BEFORE the machine is asked", async () => { + const answer = await listNodeAgentIdentities({ viewerId: owner, aNodeId: NODE_OFF }); + expect(answer.ok).toBe(false); + if (answer.ok) return; + expect(answer.refusal).toMatchObject({ status: 403, code: BackendErrorCodes.SSH_GATE_OFF }); + expect(rosterCalls).toEqual([]); + }); + }); +}); 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..19e88cd04 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-host-pins.service.test.ts @@ -0,0 +1,390 @@ +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 { 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, + 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("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 }); + 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/__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-machine-pins.service.test.ts b/apps/server/api/src/services/__tests__/ssh-machine-pins.service.test.ts new file mode 100644 index 000000000..720fb98a3 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-machine-pins.service.test.ts @@ -0,0 +1,269 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +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 { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID, type NodeKind } from "@/db/types/nodes.db-types.js"; +import { SshRpcError } from "@/services/nodes/ssh-rpc.js"; +import { + repairMachinePin, + SshMachinePinRepairError, + type SshMachinePinRepairFailure, + setSshMachinePinsDepsForTests, +} from "@/services/ssh-machine-pins.service.js"; + +/** + * The §4.5 re-pair service (spec 2026-10-08 §4.5, Task 17). The properties + * this suite exists to pin: + * + * - The delivered pair is the peer's REGISTERED public half from the + * identities store (signing + encryption), sent in the relay-open's own + * base64 carriage - never from the caller. + * - OWNER, exactly: a non-owner actor (an admin included) refuses BEFORE any + * command leaves, and nothing is audited. + * - A offline is the named `offline` refusal with NO audit row - the audit + * lands only after A's ack confirms the write (no success-as-audit). + * - A ack mismatch (or a machine refusal) refuses and audits nothing. + * - `local` refuses as A and as peer; `same-node` refuses; an absent peer or + * a peer with no registered signing half refuses by name before any send. + * - The audit row is `node.ssh_machine_pin.repair` naming the TWO NODE IDS + * ONLY - the serialized row is asserted free of every key sentinel. + */ + +const pw = "mp-repair-1"; +const OWNER_EMAIL = `mp-owner-${crypto.randomUUID()}@subshell.local`; +const ADMIN_EMAIL = `mp-admin-${crypto.randomUUID()}@subshell.local`; +let ownerId: string; +let adminId: string; + +const NODE_A = `n-a-${crypto.randomUUID()}`; +const PEER_B = `n-b-${crypto.randomUUID()}`; + +/** Sentinel-bearing public JWK text: if these bytes reach an audit row, the sentinel assert names them. */ +const PEER_SIGNING = '{"kty":"EC","crv":"P-256","x":"SIGN-SENTINEL-aaaa","y":"SIGN-SENTINEL-bbbb"}'; +const PEER_ENCRYPT = '{"kty":"EC","crv":"P-256","x":"ENCL-SENTINEL-cccc","y":"ENCL-SENTINEL-dddd"}'; +const PEER_ENCRYPT_B64 = Buffer.from(PEER_ENCRYPT, "utf8").toString("base64"); + +type SentCmd = { nodeId: string; cmd: unknown }; +let sent: SentCmd[] = []; +let sendBehavior: (nodeId: string, cmd: unknown) => Promise<{ repaired: true; peerNodeId: string }> = async ( + _n, + cmd, +) => { + const c = cmd as { peerNodeId: string }; + return { repaired: true, peerNodeId: c.peerNodeId }; +}; + +function installDeps() { + sent = []; + setSshMachinePinsDepsForTests({ + sendRepair: async (nodeId, cmd) => { + sent.push({ nodeId, cmd }); + return sendBehavior(nodeId, cmd); + }, + }); +} + +async function repairRows(): Promise< + { id: string; actorUserId: string | null; targetId: string | null; metadataJson: string | null }[] +> { + return await db + .selectFrom("auditEvents") + .select(["id", "actorUserId", "targetId", "metadataJson"]) + .where("action", "=", "node.ssh_machine_pin.repair") + .where("targetId", "=", NODE_A) + .execute(); +} + +async function mkNode(id: string, ownerUserId: string, kind: NodeKind): Promise { + const now = new Date().toISOString(); + await db + .insertInto("nodes") + .values({ id, ownerUserId, name: id, kind, status: "offline", createdAt: now, updatedAt: now } as never) + .execute(); +} + +beforeAll(async () => { + await ensureMigratedTestDb(); + await setupAuthTables(); + ownerId = await new UsersRepository(db).createUser({ + email: OWNER_EMAIL, + name: OWNER_EMAIL, + passwordHash: await hashPassword(pw), + role: "user", + }); + adminId = await new UsersRepository(db).createUser({ + email: ADMIN_EMAIL, + name: ADMIN_EMAIL, + passwordHash: await hashPassword(pw), + role: "admin", + }); + await mkNode(NODE_A, ownerId, "agent"); + await mkNode(PEER_B, ownerId, "agent"); + await new IdentitiesRepository(db).register({ + principalId: `node:${PEER_B}`, + publicKey: PEER_ENCRYPT, + signingPublicKey: PEER_SIGNING, + displayName: null, + }); +}); + +afterAll(async () => { + setSshMachinePinsDepsForTests(null); + await db.deleteFrom("nodes").where("id", "in", [NODE_A, PEER_B]).execute(); + await db.deleteFrom("identities").where("principalId", "=", `node:${PEER_B}`).execute(); + for (const email of [OWNER_EMAIL, ADMIN_EMAIL]) await deleteUserByEmailOrId(email); +}); + +beforeEach(() => installDeps()); + +describe("the happy path: registered pair out, ids-only audit in", () => { + it("sends the peer's REGISTERED public pair in the relay-open carriage and audits ids only", async () => { + await repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: PEER_B }); + expect(sent).toHaveLength(1); + expect(sent[0].nodeId).toBe(NODE_A); + expect(sent[0].cmd).toEqual({ + type: "ssh_machine_pin_repair", + peerNodeId: PEER_B, + peerSigningPublicKey: PEER_SIGNING, // byte-identical to the store (§4.4's byte-equality depends on it) + peerEncryptPublicKey: PEER_ENCRYPT_B64, // acceptance (h): base64 of the UTF-8 JSON + }); + const rows = await repairRows(); + expect(rows).toHaveLength(1); + expect(rows[0].actorUserId).toBe(ownerId); // the acting human, not null + expect(rows[0].metadataJson).toBe(JSON.stringify({ nodeIdOfA: NODE_A, peerNodeId: PEER_B })); + // The §10 rule, asserted against the SERIALIZED row, not the intent: + // no key bytes, no base64 carriage, no sentinel coordinates - ids only. + const serialized = JSON.stringify(rows[0]); + for (const secret of [PEER_SIGNING, PEER_ENCRYPT, PEER_ENCRYPT_B64, "SIGN-SENTINEL", "ENCL-SENTINEL"]) { + expect(serialized).not.toContain(secret); + } + }); +}); + +describe("the owner door (exact, not manage, not admin)", () => { + async function expectRefusal(code: SshMachinePinRepairFailure, run: () => Promise): Promise { + const before = (await repairRows()).length; + const err = await run().then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(SshMachinePinRepairError); + expect((err as SshMachinePinRepairError).code).toBe(code); + expect(sent).toHaveLength(0); // refused BEFORE any command + expect((await repairRows()).length).toBe(before); // and nothing audited + } + + it("a non-owner actor refuses not-owner - an admin included", async () => { + await expectRefusal("not-owner", () => + repairMachinePin({ actorUserId: adminId, nodeId: NODE_A, peerNodeId: PEER_B }), + ); + }); + + it("the owner's OWN re-pair still answers when the peer is owned by someone else", async () => { + // The peer's ownership gates NOTHING: the act re-delivers what the + // peer's own enrollment registered, into a store A's owner commands. + const foreign = `n-f-${crypto.randomUUID()}`; + await mkNode(foreign, adminId, "agent"); + await new IdentitiesRepository(db).register({ + principalId: `node:${foreign}`, + publicKey: PEER_ENCRYPT, + signingPublicKey: PEER_SIGNING, + displayName: null, + }); + await repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: foreign }); + expect(sent).toHaveLength(1); + expect(sent[0].cmd).toMatchObject({ peerNodeId: foreign }); + await db.deleteFrom("nodes").where("id", "=", foreign).execute(); + await db.deleteFrom("identities").where("principalId", "=", `node:${foreign}`).execute(); + }); + + it("same-node refuses: a machine is never its own relay peer", async () => { + await expectRefusal("same-node", () => + repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: NODE_A }), + ); + }); + + it("local refuses as A and as peer: no agent store exists either way", async () => { + await expectRefusal("local-node", () => + repairMachinePin({ actorUserId: ownerId, nodeId: LOCAL_NODE_ID, peerNodeId: PEER_B }), + ); + await expectRefusal("local-node", () => + repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: LOCAL_NODE_ID }), + ); + }); + + it("an absent A refuses no-node; an absent peer refuses no-peer", async () => { + const ghost = `n-ghost-${crypto.randomUUID()}`; + await expectRefusal("no-node", () => repairMachinePin({ actorUserId: ownerId, nodeId: ghost, peerNodeId: PEER_B })); + await expectRefusal("no-peer", () => repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: ghost })); + }); +}); + +describe("the peer-identity door", () => { + it("a peer with no registered signing half refuses peer-identity-missing, without asking A", async () => { + const keyless = `n-k-${crypto.randomUUID()}`; + await mkNode(keyless, ownerId, "agent"); + const before = (await repairRows()).length; + const err = await repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: keyless }).then( + () => null, + (e: unknown) => e, + ); + expect((err as SshMachinePinRepairError).code).toBe("peer-identity-missing"); + expect(sent).toHaveLength(0); + expect((await repairRows()).length).toBe(before); + await db.deleteFrom("nodes").where("id", "=", keyless).execute(); + }); +}); + +describe("A online by command: every delivery failure audits NOTHING (no success-as-audit)", () => { + async function expectSendFailure(code: SshMachinePinRepairFailure, boom: Error): Promise { + sendBehavior = async () => { + throw boom; + }; + const before = (await repairRows()).length; + const err = await repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: PEER_B }).then( + () => null, + (e: unknown) => e, + ); + expect((err as SshMachinePinRepairError).code).toBe(code); + expect((await repairRows()).length).toBe(before); + } + + it("offline refuses by name and audits nothing", async () => { + await expectSendFailure("offline", new SshRpcError("offline", "no live socket", NODE_A)); + }); + it("unsupported (old agent) refuses and audits nothing", async () => { + await expectSendFailure("unsupported", new SshRpcError("unsupported", "unknown command", NODE_A)); + }); + it("timeout refuses and audits nothing", async () => { + await expectSendFailure("timeout", new SshRpcError("timeout", "deadline", NODE_A)); + }); + it("a machine refusal refuses and audits nothing", async () => { + await expectSendFailure("refused", new SshRpcError("refused", "peer key rejected", NODE_A, "peer key rejected")); + }); + + it("an ack about the WRONG peer is malformed, loudly, with no audit", async () => { + sendBehavior = async () => ({ repaired: true, peerNodeId: "someone-else" }); + const before = (await repairRows()).length; + const err = await repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: PEER_B }).then( + () => null, + (e: unknown) => e, + ); + expect((err as SshMachinePinRepairError).code).toBe("malformed"); + expect((await repairRows()).length).toBe(before); + }); + + it("a thrown non-RPC error is not swallowed (it leaves as itself)", async () => { + sendBehavior = async () => { + throw new Error("db exploded"); + }; + const before = (await repairRows()).length; + await expect(repairMachinePin({ actorUserId: ownerId, nodeId: NODE_A, peerNodeId: PEER_B })).rejects.toThrow( + "db exploded", + ); + expect((await repairRows()).length).toBe(before); // and it audited nothing either + }); +}); 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..401ecdf3d --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-relay.integration.test.ts @@ -0,0 +1,664 @@ +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, + decodeB2A, + fp, + framed, + KEY_IN, + KEY_OUT, + openB2A, + openStack, + PANE_ID, + parseAnswer, + ROSTER, + type Stack, + sealA2B, + sealB2A, + sleep, + sshStr, + startStubAgent, + 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) 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. */ +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 + expect(producedByA(s)).toBe(1); // the [5] is A's own synthesized refusal: one A2B left A's socket + } 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, + }); + // 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 + 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, + }); + // 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); + 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, + hostPin: `git.example.test ssh-ed25519 ${"A".repeat(51)}`, + }); + 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 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); + 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, + }); + // 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); + 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: answered with no grant, no relay session */ +/* ------------------------------------------------------------------ */ + +// 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 }); + } +}); 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..2119ca09e --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts @@ -0,0 +1,658 @@ +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 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"}'; +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 }, + // 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, + }), + }; +} + +/* ------------------------------------------------------------------ */ +/* 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"); + // 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({ + 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" }, + }); + 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" }); + // 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); + }); + + 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.type === "ssh_relay_open" ? 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/__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..7a98af825 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-setup-here.service.test.ts @@ -0,0 +1,643 @@ +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 { 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"; +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 { 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"; + +/** + * 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]; +} + +/** 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") + .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(coded(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; + 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); + 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. + const exec = pair.kick(); + if (!exec) throw new Error("the act never kicked an exec"); + expect(openCalls).toHaveLength(1); + 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(); + 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; + 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 + } 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; + }, + }); + // `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 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 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); + 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. + 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(); + 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_"); + seenD()?.handle.detach(); + await nodes.deleteById(nodeId).catch(() => {}); + } finally { + seenD()?.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; + 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(); + 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, + }); + // 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(); + 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); + seenD()?.handle.detach(); + await nodes.deleteById(seenD()?.id ?? "").catch(() => {}); + } finally { + await clearUpgradeRows(); + 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/__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/__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/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 284d9d864..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 = "0048-ssh-launch-and-saved-hosts"; +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/expo-push.ts b/apps/server/api/src/services/expo-push.ts index 260ed22f0..dbb8a1ac2 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", }; /** @@ -121,9 +125,14 @@ export function buildExpoMessages( threadId: subshellId, tag: subshellId, collapseId: subshellId, - // Names registered in apps/client/mobile/src/native/push.ts — keep the three in - // sync or the lock-screen actions silently vanish on real devices. - categoryId: "subshell", + // Category and channel NAMES as registered in + // apps/client/mobile/src/native/push.ts (`subshell`, `subshell-plain`, the + // `subshell-subshells` channel); keep these strings in sync with what that + // file registers or the lock-screen actions silently vanish on real devices. + // grant_approval names the action-free category: its sid is a grant + // REQUEST uuid, so the Silence bell (a PATCH against a subshell) would be + // a dead end on the lock screen (PR #338 review, Important 1). + categoryId: kind === "grant_approval" ? "subshell-plain" : "subshell", channelId: "subshell-subshells", _channelId: "subshell-subshells", data: { sid: subshellId, kind, origin: APP_BASE_URL }, 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..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 @@ -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, @@ -20,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"; @@ -48,6 +51,7 @@ import { type NodeWsDeps, type NodeWsSocket, } from "../node-ws-handler.js"; +import { relayFramesSeenFor, relayOverCapsSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; import { beginUpdate, readView, resetForTests, updateSwapped } from "../update-tracker.js"; /* ---------------------------- fakes ----------------------------- */ @@ -98,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 */ @@ -121,6 +127,7 @@ function makeHarness(): Harness { disabledOwners: new Set(), ready: [], inventories: [], + sshFingerprints: [], touched: [], statuses: [], results: [], @@ -160,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 }); @@ -619,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 @@ -1391,6 +1459,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); + } + }); }); /** @@ -1748,6 +1857,104 @@ 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 refused by name", 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 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, 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). + 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, lastRef: "r-11", lastDirection: "A2B" }); + 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 () => { + 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..2d69dce76 --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/relay-frames.test.ts @@ -0,0 +1,154 @@ +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, + relayOverCapsSeenFor, + resetRelayFramesForTests, +} from "../relay-frames.js"; + +/** + * 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 = { + type: "relay", + ref: "r-4f2a", + seq: 0, + direction: "B2A", + blob: "QUJDk5+ToQ==", +}; + +/** 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(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 routing facts", () => { + const { broker } = spyBroker(); + setRelayBrokerForTests(broker); + resetRelayFramesForTests(); + onRelayFrame("n-b", frame); + 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("a throwing broker is CONTAINED (the handler's frame chain survives)", () => { + const { broker } = spyBroker({ + routeRelayFrame: () => { + throw new Error("broker exploded"); + }, + }); + setRelayBrokerForTests(broker); + resetRelayFramesForTests(); + expect(() => onRelayFrame("n-c", frame)).not.toThrow(); + expect(relayFramesSeenFor("n-c")).toEqual({ frames: 1, lastRef: "r-4f2a", lastDirection: "B2A" }); + }); +}); + +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"); + 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 summary. + expect(relayOverCapsSeenFor("n-e")).toBeUndefined(); + expect(Object.keys(relayOverCapsSeenFor("n-d") ?? {})).toEqual(["overCaps", "lastRef"]); + }); + + 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 + 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(); + }); +}); + +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/__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/__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..fe41ffba1 --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/ssh-identity-register.test.ts @@ -0,0 +1,245 @@ +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); + }); + + 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)", () => { + 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..1e6100280 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"; @@ -29,6 +30,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"; @@ -46,6 +48,7 @@ import { releaseHeld, } from "./node-registry.js"; import { binaryPayload, failConnPendings, resolveResult } from "./node-rpc.js"; +import { onNodeSocketClosed, onRelayFrame, onRelayFrameOverCap } from "./relay-frames.js"; import { recordNodeDisconnect, recordNodeReady } from "./update-tracker.js"; /** @@ -182,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. */ @@ -744,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; } @@ -864,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, @@ -980,6 +1006,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": @@ -1064,6 +1098,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; + } } } @@ -1132,6 +1177,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 new file mode 100644 index 000000000..edfd398dc --- /dev/null +++ b/apps/server/api/src/services/nodes/relay-frames.ts @@ -0,0 +1,155 @@ +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 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. + * + * 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). + * + * 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). + * + * {@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 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 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 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 routedByNode = new Map(); +const overCapByNode = new Map(); + +/** + * 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 = routedByNode.get(nodeId); + if (record) { + record.frames += 1; + record.lastRef = frame.ref; + record.lastDirection = frame.direction; + } else { + 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)}`); + } +} + +/** + * 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 + */ +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 }); + } + 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 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): RelayFrameRouteRecord | undefined { + return routedByNode.get(nodeId); +} + +/** + * 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): RelayOverCapRecord | undefined { + return overCapByNode.get(nodeId); +} + +/** + * 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 { + routedByNode.clear(); + overCapByNode.clear(); +} 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 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..0e78f3302 --- /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 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); + * 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..3f30725a9 100644 --- a/apps/server/api/src/services/nodes/ssh-rpc.ts +++ b/apps/server/api/src/services/nodes/ssh-rpc.ts @@ -1,14 +1,28 @@ import { + type NodeSshAgentIdentitiesResult, type NodeSshAliasListResult, + type NodeSshExecKickResult, + type NodeSshExecStatusResult, + type NodeSshHostKeyResult, + type NodeSshMachinePinRepairResult, type NodeSshResolveOutcomeWire, + parseNodeSshAgentIdentities, parseNodeSshAliasList, + parseNodeSshExecKick, + parseNodeSshExecStatus, + parseNodeSshHostKey, + parseNodeSshIdentity, + parseNodeSshMachinePinRepair, parseNodeSshResolveOutcome, + type SshMachinePinRepairCommand, } 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 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. * @@ -96,3 +110,173 @@ 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; +} + +/** + * 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 + * 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; +} + +/** + * Kick one machine's non-interactive setup run (spec 2026-10-08 §7, Task 14): + * the `ssh_exec` command carries the rendered config, the composed flag tail, + * and the installer one-liner - the SETUP KEY rides inside `command`, and + * this layer is the only place it exists plane-side. The ack says the run + * started; nothing here retains or re-speaks the command. + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshExec( + nodeId: string, + args: { + execId: string; + configPath: string; + fileContent: string; + presetFlags: string[]; + command: string; + relay: boolean; + agentSocketPath: string | null; + timeoutMs: number; + }, +): 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 { + // 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; + } + const parsed = parseNodeSshExecStatus(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed exec status`, nodeId); + } + return parsed; +} + +/** + * Re-deliver ONE peer's registered public pair to a machine and have it + * replace that peer's stored pin (spec 2026-10-08 §4.5, Task 17). The command + * is the re-pair; the ack is `{repaired: true, peerNodeId}` - the plane's + * service matches the echo by equality and treats every other shape as the + * `malformed` refusal. The peer-key pair is sent as READ from the identities + * store (the registered public halves); this layer adds the transport, the + * signature, and the grammar's `d`-refusal on receipt - it does not + * reinterpret the pair. + * @throws {SshRpcError} on any transport/wire failure, per the kinds above + */ +export async function sshMachinePinRepair( + nodeId: string, + cmd: SshMachinePinRepairCommand, +): Promise { + let data: unknown; + try { + data = await sendCommand(nodeId, cmd); + } catch (err) { + if (err instanceof NodeRpcError) throw mapRpcError(nodeId, err); + throw err; + } + const parsed = parseNodeSshMachinePinRepair(data); + if (!parsed) { + throw new SshRpcError("malformed", `node "${nodeId}" answered a malformed pin-repair ack`, nodeId); + } + return parsed; +} 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/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..5e15b90e0 --- /dev/null +++ b/apps/server/api/src/services/ssh-grants.service.ts @@ -0,0 +1,951 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +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 { 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"; + +/** + * 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 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, 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). */ +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; + /** + * 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; + +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. + * + * KNOWN LIMITATION (PR #338 review rounds 2/3/5, documented not fixed): by + * DOOR. The MANUAL doors (create/update, through this function) refuse an + * exact IPv6-literal selector - no `:` or bracket in the grammar, so the + * grants screen cannot type one. The FIRST-USE door is not grammar-gated: it + * records the bracketed host lowercased ({@link requestFirstUse}) and + * approval mints that exact standing grant, which then re-matches the same + * destination silently; a wildcard also matches IPv6 hosts incidentally + * (`*` spans brackets and colons). Widening the grammar to colons and + * brackets is a design decision (it changes what a stored grant line can + * match), not a bug fix; the pinning test lives in the matcher suite. + * + * @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 { + /** 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; +} + +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. 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 }; + +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, the CHOSEN fingerprints on approve/create (§10), */ +/* counts elsewhere - never key material, challenge, or signature */ +/* ------------------------------------------------------------------ */ + +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; + /** + * 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(); + await repo.sweepExpiredRequests(now); + // Lowercase the selector for BOTH the dedupe lookup and the stored row: + // the matcher compares against the lowercased resolved host, and the manual + // doors lowercase through normalizeSshGrantSelector. An un-normalized row + // (e.g. the uppercase-hex bracketed IPv6 spelling the resolver's grammar + // admits) would mint a grant the matcher can never re-satisfy, so every + // later launch re-asks forever. Hostnames are case-insensitive; bracketed + // IPv6 hex case carries no meaning. + const selector = args.resolvedSelector.toLowerCase(); + const existing = await repo.findPendingRequest(args.ownerUserId, args.aNodeId, selector, now); + if (existing) return granted({ requestId: existing.id, reused: true }); + const request: NewSshGrantRequest = { + id: crypto.randomUUID(), + ownerUserId: args.ownerUserId, + keyHomeNodeId: args.aNodeId, + resolvedSelector: selector, + destination: args.destination, + 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: selector, // the same normalized value the row and the minted grant carry + }); + 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 - 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; + 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); + // 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 = { + 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, + 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, + fingerprints: keys.value, + 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, + fingerprints: keys.value, // the same selection record the approved first use writes (§10) + 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 roster fetch: the choice list behind BOTH grant doors (§5.4, */ +/* Task 11's approval screen; Task 18's create picker reads it by node) */ +/* ------------------------------------------------------------------ */ + +/** + * 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); + return await fetchAgentRoster(row.keyHomeNodeId); +} + +/** + * Fetch one machine's public roster over the node link and map every failure + * to the grant-surface refusal (Task 18: the shared half of BOTH roster + * reads - the request-scoped one above and the roster-by-node read behind + * the create picker, which reaches it with a directly-chosen key home and no + * request row at all). Same command, same grammar, same fail-closed doors. + */ +export async function fetchAgentRoster( + aNodeId: string, +): Promise> { + try { + const fetcher = grantsDeps().fetchAgentIdentities ?? sshAgentIdentities; // the `??` is the production wiring + const roster = await fetcher(aNodeId); + 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 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) */ +/* ------------------------------------------------------------------ */ + +/** + * 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; + /** + * 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({ + 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, + destination: args.destination, + 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); + } + // 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 { + const opened = await broker.openRelay({ + grantId: grant.id, + fingerprints: grant.fingerprints, + paneId: args.paneId, + aNode: args.aNode.id, + bNode: args.bNodeId, + aPeer, + bPeer, + hostPin, + }); + 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."; + case "bad-host-pin": + return "The relay refused the destination host pin 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/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..df86bcd72 --- /dev/null +++ b/apps/server/api/src/services/ssh-host-pins.service.ts @@ -0,0 +1,464 @@ +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); + +/** + * 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. */ + 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); +} + +/** + * 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 + * 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) return await acceptStandingPin(ownerUserId, existing, fps, destination); + const now = pinsDeps().nowIso(); + const row: SshHostPinTable = { + id: crypto.randomUUID(), + ownerUserId, + destination, + hostKey: line, + createdAt: now, + updatedAt: now, + }; + // 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) + 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/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index ca2d9840d..ddfdcd89c 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, @@ -13,6 +14,7 @@ import { sshOptionTokens, } from "@internal/pane-runtime"; import { + type NodeSshAgentIdentity, type NodeSshAliasListResult, type NodeSshResolveOutcomeWire, parseNodeSshAliasList, @@ -35,6 +37,14 @@ 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, + fetchAgentRoster, + 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"; @@ -123,6 +133,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; @@ -132,7 +153,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 } @@ -199,8 +223,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); @@ -239,8 +268,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, @@ -322,8 +356,8 @@ async function nodeResolve(row: NodeTable, destination: string): Promise; }): Promise> { @@ -402,29 +449,94 @@ export async function sshLaunch(args: { ); } const subshellId = crypto.randomUUID(); - const composed = composeSshLaunch({ snapshot, targetDataDir: dataDir, subshellId }); + // 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. 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( + 409, + 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, + // 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, + // 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 + // 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, + // A relay pane remembers its A on the row (migration 0052); the act + // that RE-OPENS the pairing later reads only this. + ...(relay ? { keyHomeNodeId: args.keyHomeNodeId as string } : {}), + }, + 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 +556,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 +605,94 @@ export async function sshRemoveSavedHost(viewerId: string, id: string): Promise< return await new SshSavedHostsRepository(db).remove(viewerId, id); } +/** + * The key home's gate, shared by BOTH grant doors (Task 18): the create below + * and the roster-by-node read behind its picker. 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 BEFORE anything is stored or asked so the screen cannot point an + * authorization (or a machine ask) 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 or fetched. + */ +async function gateKeyHome(viewerId: string, aNodeId: string): Promise> { + const gate = await gateSshNode(viewerId, 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: { + // 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.", + }, + }; + } + return { ok: true, value: gate.value }; +} + +/** + * `POST /api/ssh/grants` - create a standing grant from the screen (spec + * 2026-10-08 §8), gated through {@link gateKeyHome} first. + */ +export async function sshCreateGrant(args: { + viewerId: string; + aNodeId: string; + name: string; + selector: string; + fingerprints: readonly string[]; +}): Promise> { + const gate = await gateKeyHome(args.viewerId, args.aNodeId); + if (!gate.ok) return { ok: false, refusal: gate.refusal }; + return await createGrant({ + ownerUserId: args.viewerId, + aNodeId: args.aNodeId, + name: args.name, + selector: args.selector, + fingerprints: args.fingerprints, + }); +} + +/** + * `GET /api/ssh/grants/identities?node=` - the roster-by-node read behind the + * grants screen's CREATE picker (spec 2026-10-08 §8, Task 18): the same + * T11 roster command and the same fail-closed doors as the approval screen's + * request-scoped read, taken against a directly-chosen key home so an + * operator can build a grant WITHOUT a pending first-use request standing. + * The gate runs before the machine is asked (a machine the caller may not + * SSH through never sees a frame), and like every roster read this is a + * question, not an answer: NO audit row is written, no row changes, an + * offline or refusing key home answers the named error rather than the + * empty roster a live agent honestly reports. + */ +export async function listNodeAgentIdentities(args: { + viewerId: string; + aNodeId: string; +}): Promise> { + const gate = await gateKeyHome(args.viewerId, args.aNodeId); + if (!gate.ok) return { ok: false, refusal: gate.refusal }; + return await fetchAgentRoster(args.aNodeId); +} + /** The GET payload of the launcher screen: the owner's rows plus their default machine. */ export async function sshSavedHostsView(viewerId: string): Promise<{ saved: SshSavedHostTable[]; diff --git a/apps/server/api/src/services/ssh-machine-pins.service.ts b/apps/server/api/src/services/ssh-machine-pins.service.ts new file mode 100644 index 000000000..3c66792ff --- /dev/null +++ b/apps/server/api/src/services/ssh-machine-pins.service.ts @@ -0,0 +1,282 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import type { SshMachinePinRepairCommand } from "@internal/subshell-protocol"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { type AuditEventInput, audit } from "@/services/audit.js"; +import { SshRpcError, sshMachinePinRepair } from "@/services/nodes/ssh-rpc.js"; +import { base64OfJwk } from "@/services/ssh-relay.service.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The §4.5 machine trust re-pair (spec 2026-10-08 §4.5, Task 17): the ONE + * sanctioned way a pinned entry changes. When a peer's machine key genuinely + * rotated (a re-enroll or a replaced data dir, §4.2), byte-strict pairing + * blocks the relationship permanently - the design has no "trust anyway" + * escape. The peer's OWNER (of the machine whose store is edited) re-pairs: + * the plane reads the PEER's CURRENT registered public pair from the + * identities store (signing §4.2/§4.3 + encryption, the same two halves + * §4.4 pins and the relay-open carries), sends the signed + * `ssh_machine_pin_repair` command to A over its live link, and audits + * `node.ssh_machine_pin.repair` naming the TWO NODE IDS ONLY. + * + * **The posture, in order:** + * - OWNER, exactly: the actor must be `nodes.ownerUserId` of A (the route + * enforces it as the HTTP refusal; this service re-checks because a trust + * act's enforcement should never live only at one door - an `edit` grantee + * may not re-authorize a peer's key on someone else's machine, and the + * admin's instance-wide edit does not reach here either). + * - A ONLINE: the act sends a signed command, like relay-open. An offline A + * is the named `offline` refusal, and NOTHING was written or audited - + * the audit lands only after A's own ack confirms the write ("fails closed + * if A offline: no audit-as-success"). + * - `local` never: not as A and not as the peer (no agent store to repair, + * and `local` has no relay identity; the LOCAL_NODE_ID guard is the cheap + * first read and the kind check is the general one). + * - The delivered pair is the peer's REGISTERED identity, read from the + * store - never from the caller, never re-reported by A. Deep key hygiene + * (public-only, no `d`, importability) was enforced when those bytes were + * enrolled/registered, and A's handler re-validates before writing; this + * layer transports signed, it does not launder. + * - No key bytes enter a log line, the audit row, or a refusal message + * (Global Constraints; docs/security.md §10). The metadata is exactly + * `{ nodeIdOfA, peerNodeId }`. + */ + +/** Why a re-pair refused; each code names its own door (the route renders the copy). */ +export type SshMachinePinRepairFailure = + | "not-owner" + | "no-node" + | "local-node" + | "same-node" + | "no-peer" + | "peer-identity-missing" + | "offline" + | "unsupported" + | "timeout" + | "refused" + | "malformed"; + +/** The loud refusal every re-pair raises. Nothing is written or audited after one. */ +export class SshMachinePinRepairError extends Error { + readonly code: SshMachinePinRepairFailure; + /** The node the refusal is about (A or the peer, depending on the door). */ + readonly nodeId: string; + + constructor(code: SshMachinePinRepairFailure, message: string, nodeId: string) { + super(message); + this.name = "SshMachinePinRepairError"; + this.code = code; + this.nodeId = nodeId; + } +} + +/** The service's world. Production at {@link defaultRepairDeps}; tests install a fake. */ +export interface SshMachinePinsDeps { + /** + * Deliver the signed re-pair command to A (default: `ssh-rpc.sshMachinePinRepair`). + * A test seam so the service's fail-closed behavior never touches a socket. + */ + sendRepair?(nodeId: string, cmd: SshMachinePinRepairCommand): Promise<{ repaired: true; peerNodeId: string }>; +} + +let depsOverride: SshMachinePinsDeps | null = null; + +function repairDeps(): SshMachinePinsDeps { + return depsOverride ?? {}; +} + +/** + * Install (or with null, drop) the whole seam. + * @internal test-only - the suite pins owner/offline/audit behavior without a node. + */ +export function setSshMachinePinsDepsForTests(deps: SshMachinePinsDeps | null): void { + depsOverride = deps; +} + +/** + * Re-pair one peer on one machine. The full act: owner + eligibility checks, + * read the peer's registered public pair, send the signed command (A online, + * ack echoed by equality), audit ids-only. Raises {@link + * SshMachinePinRepairError} with the named cause on every fail-closed door, + * writing NOTHING on any of them. + * + * @param actorUserId - the cookie human performing the act; must OWN A + * @param nodeId - A: the machine whose pin store is repaired + * @param peerNodeId - the peer whose stored entry the command re-delivers for + */ +export async function repairMachinePin(args: { + actorUserId: string; + nodeId: string; + peerNodeId: string; +}): Promise { + const { actorUserId, nodeId, peerNodeId } = args; + const { repos } = getRequestlessContext(); + + // A first, and EXACTLY its owner (admin included not - a trust act on + // someone else's machine is nobody else's decision). + if (nodeId === LOCAL_NODE_ID) { + throw new SshMachinePinRepairError( + "local-node", + "the control-plane host keeps no machine trust store to repair", + nodeId, + ); + } + const aRow = await repos.nodes.findById(nodeId); + if (!aRow) throw new SshMachinePinRepairError("no-node", `node "${nodeId}" has no row`, nodeId); + if (aRow.ownerUserId !== actorUserId) { + throw new SshMachinePinRepairError("not-owner", `node "${nodeId}" is not this actor's machine`, nodeId); + } + if (aRow.kind !== "agent") { + throw new SshMachinePinRepairError( + "local-node", + `"${nodeId}" is not an agent machine; it has no pin store`, + nodeId, + ); + } + if (peerNodeId === nodeId) { + // A machine pinning ITSELF is a contradiction the pairing design has no + // word for (the node refuses it too - both doors name it before a byte + // moves). + throw new SshMachinePinRepairError("same-node", "a machine is never its own relay peer", nodeId); + } + + // The peer: an existing AGENT node with a complete registered identity. + // Foreign ownership of the peer is no obstacle - the act re-delivers what + // the peer's own enrollment registered, to a store its owner commands. + if (peerNodeId === LOCAL_NODE_ID) { + throw new SshMachinePinRepairError( + "local-node", + "the control-plane host is never a machine pin's peer", + peerNodeId, + ); + } + const peerRow = await repos.nodes.findById(peerNodeId); + if (!peerRow) throw new SshMachinePinRepairError("no-peer", `peer node "${peerNodeId}" has no row`, peerNodeId); + if (peerRow.kind !== "agent") { + throw new SshMachinePinRepairError("local-node", `peer "${peerNodeId}" is not an agent machine`, peerNodeId); + } + const identity = await repos.identities.findByPrincipal(`node:${peerNodeId}`); + if (!identity || identity.signingPublicKey === null) { + throw new SshMachinePinRepairError( + "peer-identity-missing", + `peer "${peerNodeId}" has not registered its relay identity`, + peerNodeId, + ); + } + + // The delivered pair is the peer's REGISTERED public halves, read here and + // nowhere else, in the relay-open's own carriage (acceptance (h)). + const cmd: SshMachinePinRepairCommand = { + type: "ssh_machine_pin_repair", + peerNodeId, + peerSigningPublicKey: identity.signingPublicKey, + peerEncryptPublicKey: base64OfJwk(identity.publicKey), + }; + + // A online: the act sends a signed command, like relay-open. Every RPC + // failure leaves as the named door; nothing has been audited yet, and + // nothing will be on these paths (no audit-as-success on a refusal). + const sender = repairDeps().sendRepair ?? sshMachinePinRepair; // the `??` is the production wiring + let ack: { repaired: true; peerNodeId: string }; + try { + ack = await sender(nodeId, cmd); + } catch (err) { + if (err instanceof SshRpcError) { + if (err.kind === "refused" || err.kind === "malformed") { + // The machine's own words go to the LOG (they may carry its reasons; + // they never carry OUR key material - the node refuses by shape), + // and the refusal the human reads names the door, not the bytes. + logger + .withError(err) + .warn(`ssh machine-pin repair ${err.kind} against node ${err.nodeId}: ${err.detail ?? err.message}`); + } + throw new SshMachinePinRepairError( + err.kind, + `re-pair of "${peerNodeId}" on "${nodeId}" failed: ${err.kind}`, + nodeId, + ); + } + throw err; + } + if (!ack.repaired || ack.peerNodeId !== peerNodeId) { + // The ack must match the act by equality (the relay-open's byte-check + // doctrine, applied to the repair): an ack about a different peer, or a + // soft no in a success envelope, is a protocol violation refused loudly. + throw new SshMachinePinRepairError("malformed", `node "${nodeId}" answered a mismatched pin-repair ack`, nodeId); + } + + // The success half: audit ids ONLY - never key bytes, never fingerprints, + // never the delivered pair (§10; Global Constraints). + const event: AuditEventInput = { + actorUserId, + action: "node.ssh_machine_pin.repair", + targetType: "node", + targetId: nodeId, + metadataJson: JSON.stringify({ nodeIdOfA: nodeId, peerNodeId }), + }; + await audit(event); +} + +/** Coded refusals for the route (the host-pin mapper's shape, one per door). */ +export type SshMachinePinRepairRefusal = { + status: 400 | 403 | 404 | 409 | 502; + code: BackendErrorCodes; + message: string; +}; + +/** Map the service's named refusal onto the HTTP shape the route renders. */ +export function machinePinRepairRefusal(err: SshMachinePinRepairError): SshMachinePinRepairRefusal { + switch (err.code) { + case "not-owner": + return { + status: 403, + code: BackendErrorCodes.SSH_OWNER_INPUT_ONLY, + message: "Re-pairing a machine trust pin is restricted to the machine's owner.", + }; + case "no-node": + case "no-peer": + return { status: 404, code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Node not found" }; + case "local-node": + return { + status: 400, + code: BackendErrorCodes.BAD_REQUEST, + message: "Machine trust re-pairing applies to agent machines only; the control-plane host holds no such store.", + }; + case "same-node": + return { status: 400, code: BackendErrorCodes.BAD_REQUEST, message: "A machine is never its own relay peer." }; + case "peer-identity-missing": + return { + status: 409, + code: BackendErrorCodes.SSH_RELAY_IDENTITY_MISSING, + message: + "That peer has not registered its relay identity yet. Re-enroll it, or wait for its next check-in to deliver the key.", + }; + case "offline": + return { + status: 409, + code: BackendErrorCodes.NODE_OFFLINE, + message: + "That machine has no live connection right now; a re-pair is delivered to it directly. Bring it online and ask again.", + }; + case "unsupported": + return { + status: 409, + code: BackendErrorCodes.NODE_OUTDATED, + message: + "The Subshell app on that machine is too old to repair a machine pin. Update it from its machine page.", + }; + case "timeout": + return { + status: 409, + code: BackendErrorCodes.NODE_UNREACHABLE, + message: "That machine did not answer the re-pair in time; check its connection and ask again.", + }; + case "refused": + case "malformed": + return { + status: 502, + code: BackendErrorCodes.SSH_NODE_REFUSED, + message: "The machine refused the re-pair. Verify the peer's registered key out of band before asking again.", + }; + } +} 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..df8710d04 --- /dev/null +++ b/apps/server/api/src/services/ssh-relay.service.ts @@ -0,0 +1,700 @@ +import { buildAgentSocketPath } from "@internal/pane-runtime"; +import { + isSshGrantFingerprints, + isSshKnownHostsPinLine, + 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" + | "bad-host-pin" + | "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; + /** + * 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. */ +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, carried untouched to `cancel`. */ +export type TimerHandle = unknown; + +/** 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; +} + +/** + * Acceptance (h): base64 of the UTF-8 bytes of the JSON public JWK - the ONE + * spelling the node decodes back into the byte-equal pin string. Exported for + * the §4.5 re-pair service, which re-delivers the same pair in the same + * carriage (ssh-machine-pins.service.ts); two carriers of one canonical + * spelling must not be two implementations of it. + */ +export function base64OfJwk(jwkJson: string): 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, + hostPin: input.hostPin, + }; +} + +/** 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", + ); + } + // 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. + 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; +} diff --git a/apps/server/api/src/services/ssh-setup-here.service.ts b/apps/server/api/src/services/ssh-setup-here.service.ts new file mode 100644 index 000000000..ff0af51f5 --- /dev/null +++ b/apps/server/api/src/services/ssh-setup-here.service.ts @@ -0,0 +1,504 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { buildSshKnownHostsPath, shellQuote } from "@internal/pane-runtime"; +import { + parseSshConnectionSnapshot, + redactSshSetupKeyLines, + type SshConnectionSnapshotWire, +} from "@internal/subshell-protocol"; +import { APP_BASE_URL } from "@/constants.js"; +import { db } from "@/db/index.js"; +import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; +import { sshCanonicalDestination } from "@/db/types/ssh-saved-hosts.db-types.js"; +import { audit } from "@/services/audit.js"; +import { getLive } from "@/services/nodes/node-registry.js"; +import { SshRpcError, sshExec, sshExecStatus } from "@/services/nodes/ssh-rpc.js"; +import { prepareRelayLeg } from "@/services/ssh-grants.service.js"; +import { + composeSshLaunch, + gateSshNode, + rpcRefusal, + type SshAnswer, + targetDataDir, +} from "@/services/ssh-launch.service.js"; +import { getRelayBroker, type RelayBroker } from "@/services/ssh-relay.service.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The "Set up Subshell here" act (spec 2026-10-08 §7, Task 14): turn the + * destination D of an open SSH-terminal pane into an enrolled node by running + * the ORDINARY enrollment install on D, over a SEPARATE non-interactive + * `ssh D ''` 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 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. */ +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 } }; +} + +/** + * 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; +} + +/** + * 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}`); + } + }; + /** + * 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; + 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; + } + await auditInternalFailure(); + 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); + } + await auditInternalFailure(); + 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), + // 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( + "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 1c6b7fa51..29467577c 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"; @@ -145,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; } /** @@ -492,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 @@ -568,6 +579,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 +1068,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 +1126,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 +1461,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 +1657,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 }; } 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": { diff --git a/apps/server/web/src/components/__tests__/app-sidebar.test.ts b/apps/server/web/src/components/__tests__/app-sidebar.test.ts index b1fbef7fe..b0577d05e 100644 --- a/apps/server/web/src/components/__tests__/app-sidebar.test.ts +++ b/apps/server/web/src/components/__tests__/app-sidebar.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { Cable, ServerCog, Settings, SlidersHorizontal } from "lucide-react"; +import { Cable, Fingerprint, ServerCog, Settings, SlidersHorizontal } from "lucide-react"; import { isNavGroup, visibleNavEntries, visibleNavItems } from "@/components/sidebar/sidebar-nav"; describe("sidebar nav icons (spec 2026-09-02 §3, 2026-09-11 §3.1)", () => { @@ -26,6 +26,18 @@ describe("sidebar nav icons (spec 2026-09-02 §3, 2026-09-11 §3.1)", () => { expect(items.find((i) => i.to === "/connect")?.icon).toBe(Cable); }); + it("SSH takes Fingerprint, in the personal Settings group (its ledger is per-owner)", () => { + // /settings/ssh rides the member's own group, not Server Settings: the + // grants ledger is the caller's (spec 2026-10-08 §8), and a non-admin + // must reach it. The path under /settings/ is deliberate; General lights + // exact-match only, so the two groups cannot both claim the page. + expect(items.find((i) => i.to === "/settings/ssh")?.icon).toBe(Fingerprint); + const personal = entries.filter(isNavGroup).find((g) => g.id === "personal-settings"); + expect(personal?.children.some((c) => c.to === "/settings/ssh")).toBe(true); + // And it is visible to a member: the WHOLE personal group is ungated. + expect(visibleNavItems(false).some((i) => i.to === "/settings/ssh")).toBe(true); + }); + it("no two visible icons are the same — group headers included", () => { // Icons are how the collapsed rail names a page, so a duplicate there is // two rows that read as one. The group's own icon counts: it sits in the 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..faa1e4207 --- /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 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__/sidebar-nav.test.ts b/apps/server/web/src/components/__tests__/sidebar-nav.test.ts index 37a79cec2..bc30b93ac 100644 --- a/apps/server/web/src/components/__tests__/sidebar-nav.test.ts +++ b/apps/server/web/src/components/__tests__/sidebar-nav.test.ts @@ -36,7 +36,10 @@ describe("visibleNavItems", () => { for (const flag of [false, undefined]) { const paths = visibleNavItems(flag).map((i) => i.to); for (const page of GROUP_PAGES) expect(paths).not.toContain(page); - expect(paths).toEqual(["/", "/connect", "/workspaces", "/nodes", "/presets", "/prompts"]); + // `/settings/ssh` rides the PERSONAL group: its path is under + // /settings but the page is the caller's own ledger (spec 2026-10-08 + // §8), so a member reaches it and the gate never hides it. + expect(paths).toEqual(["/", "/connect", "/workspaces", "/nodes", "/presets", "/prompts", "/settings/ssh"]); } }); @@ -48,6 +51,7 @@ describe("visibleNavItems", () => { "/nodes", "/presets", "/prompts", + "/settings/ssh", ...GROUP_PAGES, ]); }); 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..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 @@ -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/sidebar/sidebar-nav.ts b/apps/server/web/src/components/sidebar/sidebar-nav.ts index 946af9fb8..752fc6a1d 100644 --- a/apps/server/web/src/components/sidebar/sidebar-nav.ts +++ b/apps/server/web/src/components/sidebar/sidebar-nav.ts @@ -3,6 +3,7 @@ import { ArrowUpCircle, Cable, DatabaseBackup, + Fingerprint, KeyRound, LayoutDashboard, type LucideIcon, @@ -77,6 +78,11 @@ const NAV_ENTRIES: NavEntry[] = [ { to: "/presets", label: "Presets", icon: SlidersHorizontal, short: "Preset" }, // Beside Presets: both are saved launch material, one is settings, one is text. { to: "/prompts", label: "Prompts", icon: MessageSquareText, short: "Prompts" }, + // The owner's SSH relay ledger (spec 2026-10-08 §8): per-owner surface, + // so it sits in the personal group even though its path is under + // /settings - General lights only on an exact match, so a member's page + // never illuminates the admin group (sidebar-nav's own comment). + { to: "/settings/ssh", label: "SSH", icon: Fingerprint, short: "SSH" }, ], }, // On the label (spec 2026-09-11 §2.1). The single entry here used to read 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..0e648c1d1 --- /dev/null +++ b/apps/server/web/src/components/ssh/__tests__/grants-screen.test.tsx @@ -0,0 +1,427 @@ +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, the name/selector edit, 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. + * The edit dialog is pinned too: it PATCHes only name and selector, never the + * fingerprint set the server declares immutable. + */ + +const FP_ONE = `SHA256:${"A".repeat(43)}`; +const FP_TWO = `SHA256:${"B".repeat(43)}`; +// Roster fingerprints kept distinct from the standing grant's: the create-flow +// tests assert on these texts while the list rows render FP_ONE/FP_TWO. +const ROSTER_ONE = `SHA256:${"D".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", +}; + +/** + * The picker's candidates: vault is the one create-eligible key home (agent, + * SSH on, owner); `plane` is the control host (never a key home), `locked` + * has SSH off, and `borrowed` is a shared machine (SSH is owner-reserved). + */ +const NODES = [ + { + id: "nodeA", + name: "vault", + kind: "agent", + sshEnabled: true, + access: "owner", + status: "online", + os: null, + arch: null, + maintenance: false, + }, + { + id: "nodeL", + name: "plane", + kind: "local", + sshEnabled: true, + access: "owner", + status: "online", + os: null, + arch: null, + maintenance: false, + }, + { + id: "nodeOff", + name: "locked", + kind: "agent", + sshEnabled: false, + access: "owner", + status: "online", + os: null, + arch: null, + maintenance: false, + }, + { + id: "nodeShare", + name: "borrowed", + kind: "agent", + sshEnabled: true, + access: "edit", + status: "online", + os: null, + arch: null, + maintenance: false, + }, +]; + +interface Sent { + method: string; + path: string; + body?: unknown; +} + +function stubFetch( + restore: (undo: () => void) => void, + opts: { + revokeStatus?: number; + revokeMessage?: string; + /** The scripted key-home roster for the create picker (Task 18). */ + roster?: { fingerprint: string; comment: string }[]; + /** Status the roster read answers instead of 200 (the named-failure case). */ + rosterStatus?: number; + rosterMessage?: 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, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + if (url.pathname === "/api/ssh/grants" && method === "POST") { + const body = (sent.at(-1)?.body ?? {}) as Record; + return new Response( + JSON.stringify({ + grant: { ...GRANT, id: "g-new", createdVia: "manual", ...body, resolvedSelector: body.selector }, + }), + { status: 201 }, + ); + } + if (url.pathname === "/api/ssh/grants/identities") { + if (opts.rosterStatus) { + return new Response(JSON.stringify({ message: opts.rosterMessage ?? "unreachable" }), { + status: opts.rosterStatus, + headers: { "content-type": "application/json" }, + }); + } + return new Response( + JSON.stringify({ identities: opts.roster ?? [{ fingerprint: ROSTER_ONE, comment: "work laptop" }] }), + { + status: 200, + }, + ); + } + 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: NODES }), { status: 200 }); + } + if (url.pathname === "/api/ssh/grants/g1" && method === "PATCH") { + return new Response(JSON.stringify({ grant: { ...GRANT, ...(sent.at(-1)?.body as object) } }), { 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("edits name and selector through PATCH, sending no fingerprints", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Edit grant prod keys" })); + // Static dialog title (ruling 2026-09-30): the grant's name rides the body, never the heading. + expect(await screen.findByRole("heading", { name: "Edit grant" })).toBeDefined(); + expect(screen.getByRole("dialog").textContent).toContain("prod keys"); + // The fields open pre-filled with what the row shows. + expect((screen.getByLabelText(/Grant name/) as HTMLInputElement).value).toBe("prod keys"); + expect((screen.getByLabelText(/Destination selector/) as HTMLInputElement).value).toBe("*.prod.example.com"); + fireEvent.change(screen.getByLabelText(/Grant name/), { target: { value: "staging keys" } }); + fireEvent.change(screen.getByLabelText(/Destination selector/), { target: { value: "*.staging.example.com" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(sent.some((s) => s.method === "PATCH" && s.path === "/api/ssh/grants/g1")).toBe(true)); + const patch = sent.find((s) => s.method === "PATCH"); + // The body is exactly the two editable fields: which keys serve is immutable, + // so fingerprints must never ride this PATCH (toEqual pins its absence). + expect(patch?.body).toEqual({ name: "staging keys", selector: "*.staging.example.com" }); + }); + + 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"); + }); + + /* ---------------------------------------------------------------- */ + /* manual create (spec §8, Task 18): the roster-by-node picker */ + /* ---------------------------------------------------------------- */ + + /** Opens the create dialog and picks `vault`; returns once a row named `first` rendered. */ + async function openCreateAndPickKeyHome(first: string): Promise { + fireEvent.click(await screen.findByRole("button", { name: "Create grant" })); + const combo = screen.getByRole("combobox", { name: /Key home/ }); + fireEvent.mouseDown(combo); + fireEvent.click(combo); + const option = await screen.findByRole("option", { name: /vault/ }); + // Base UI options answer the pointer triple; plain click never reaches onValueChange here. + fireEvent.pointerDown(option); + fireEvent.pointerUp(option); + fireEvent.click(option); + await screen.findByText(first); + } + + it("creates a grant: static title, the create-eligible key home only, and the POST carries node/name/selector/fingerprints", async () => { + const sent = stubFetch((undo) => { + restores.push(undo); + }); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Create grant" })); + // Static title (ruling 2026-09-30): the act is the heading, data rides the body. + expect(await screen.findByRole("heading", { name: "Create grant" })).toBeDefined(); + // The picker offers ONLY machines the create gate will accept: agent + SSH on + // + owner. The control host, the SSH-off machine, and the shared one stay out. + const combo = screen.getByRole("combobox", { name: /Key home/ }); + fireEvent.mouseDown(combo); + fireEvent.click(combo); + const options = await screen.findAllByRole("option"); + expect(options.map((o) => o.textContent)).toEqual(["vault"]); + const [vault] = options; + if (!vault) throw new Error("the picker rendered no option"); + fireEvent.pointerDown(vault); + fireEvent.pointerUp(vault); + fireEvent.click(vault); + + // A's roster arrives from the roster-by-node read, not from any request. + expect(await screen.findByText(ROSTER_ONE)).toBeDefined(); + expect(screen.getByText("work laptop")).toBeDefined(); + const rosterRead = sent.find((s) => s.path === "/api/ssh/grants/identities"); + expect(rosterRead?.method).toBe("GET"); + + fireEvent.click(screen.getByRole("checkbox", { name: ROSTER_ONE })); + fireEvent.change(screen.getByLabelText(/Grant name/), { target: { value: "by hand" } }); + fireEvent.change(screen.getByLabelText(/Destination selector/), { target: { value: "*.git.example.test" } }); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + await waitFor(() => expect(sent.some((s) => s.method === "POST" && s.path === "/api/ssh/grants")).toBe(true)); + // The body is exactly the existing create contract (grants.route.ts POST /grants). + expect(sent.find((s) => s.method === "POST")?.body).toEqual({ + node: "nodeA", + name: "by hand", + selector: "*.git.example.test", + fingerprints: [ROSTER_ONE], + }); + }); + + it("tick past the cap is a red hard error and sends nothing (never a truncation)", async () => { + // One past SSH_MAX_GRANT_FINGERPRINTS (8), first entry the waitable + // ROSTER_ONE so the helper can pin the roster landed before ticking. + const nine = [ + { fingerprint: ROSTER_ONE, comment: "" }, + ...Array.from({ length: 8 }, (_, i) => ({ + fingerprint: `SHA256:${String(i + 1).padStart(43, "0")}`, + comment: "", + })), + ]; + const sent = stubFetch( + (undo) => { + restores.push(undo); + }, + { roster: nine }, + ); + renderScreen(); + await openCreateAndPickKeyHome(ROSTER_ONE); + // The two text fields are filled FIRST (the create-flow test's own fills). + // With them empty the button is already disabled by form validation, and a + // disabled-at-9 assertion would pin the validator, not the cap: this test + // must isolate the cap as the SOLE gate. Filled, exactly 8 ticks leave the + // button ENABLED, so the 9th tick's disable can only be the cap. + fireEvent.change(screen.getByLabelText(/Grant name/), { target: { value: "by hand" } }); + fireEvent.change(screen.getByLabelText(/Destination selector/), { target: { value: "*.git.example.test" } }); + const createButton = () => screen.getByRole("button", { name: "Create" }) as HTMLButtonElement; + for (const identity of nine.slice(0, 8)) { + fireEvent.click(screen.getByRole("checkbox", { name: identity.fingerprint })); + } + // At exactly the cap the form says yes: name, selector and eight ticks are + // a complete draft. The generous timeout covers the async onChange + // validator under full-suite parallelism, never the cap's own flip. + await waitFor(() => expect(createButton().disabled).toBe(false), { timeout: 4000 }); + const ninth = nine[8]; + if (!ninth) throw new Error("the roster fixture lost its ninth entry"); + fireEvent.click(screen.getByRole("checkbox", { name: ninth.fingerprint })); + // The cap bites: the red line names the limit, and the button falls back. + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("at most 8"); + expect(alert.className).toContain("text-destructive"); + await waitFor(() => expect(createButton().disabled).toBe(true)); + // The button is swept and the guard behind it holds: nothing goes out. + fireEvent.click(createButton()); + expect(sent.some((s) => s.method === "POST" && s.path === "/api/ssh/grants")).toBe(false); + }); + + it("an unreachable key home reads as its NAMED error, never as an empty roster", async () => { + stubFetch( + (undo) => { + restores.push(undo); + }, + { + rosterStatus: 409, + rosterMessage: "The key home has no live connection right now; bring its Subshell app online and ask again.", + }, + ); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Create grant" })); + const combo = screen.getByRole("combobox", { name: /Key home/ }); + fireEvent.mouseDown(combo); + fireEvent.click(combo); + const option = await screen.findByRole("option", { name: /vault/ }); + fireEvent.pointerDown(option); + fireEvent.pointerUp(option); + fireEvent.click(option); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("no live connection"); + // The fabrication guard rendered as UI: an unreachable machine must not + // look like "holds no keys", the honest empty answer's sentence. + expect(alert.textContent).not.toMatch(/holds no keys/); + }); + + it("an honest empty roster says so and leaves Create disabled (nothing to select)", async () => { + stubFetch( + (undo) => { + restores.push(undo); + }, + { roster: [] }, + ); + renderScreen(); + fireEvent.click(await screen.findByRole("button", { name: "Create grant" })); + const combo = screen.getByRole("combobox", { name: /Key home/ }); + fireEvent.mouseDown(combo); + fireEvent.click(combo); + const option = await screen.findByRole("option", { name: /vault/ }); + fireEvent.pointerDown(option); + fireEvent.pointerUp(option); + fireEvent.click(option); + expect(await screen.findByText(/holds no keys/)).toBeDefined(); + expect((screen.getByRole("button", { name: "Create" }) as HTMLButtonElement).disabled).toBe(true); + }); +}); 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/__tests__/pending-approvals.test.tsx b/apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx new file mode 100644 index 000000000..a5f33f2d7 --- /dev/null +++ b/apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, it, spyOn } 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. A pre-selection the roster no longer + * carries is surfaced as a disabled row and never rides the approve POST (the + * server validates shape, not roster membership; this screen is the fence). + * The 24 h deadline renders localized, like the sibling house queue. + */ + +const fp = (i: number) => `SHA256:${i.toString().padStart(43, "0")}`; +const PRE = fp(1); +const STALE = `SHA256:${"9".repeat(43)}`; + +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; +} + +interface StubOptions { + /** Override the agent roster (the stale-key case: the roster no longer carries a pre-selected fingerprint) */ + identities?: { fingerprint: string; comment: string }[]; + /** Override the queued request (e.g. a requestedFingerprints entry missing from the roster) */ + request?: Partial; +} + +function stubFetch(restore: (undo: () => void) => void, opts: StubOptions = {}): Sent[] { + const sent: Sent[] = []; + const original = globalThis.fetch; + restore(() => { + globalThis.fetch = original; + }); + const request = { ...REQUEST, ...opts.request }; + const identities = opts.identities ?? ROSTER; + 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 }), { 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("surfaces a pre-selected key the agent no longer holds and never sends it", async () => { + const sent = stubFetch( + (undo) => { + restores.push(undo); + }, + { + // The request pre-selected STALE, but the key home's live roster no longer carries it. + request: { requestedFingerprints: [STALE, PRE] }, + identities: [ + { fingerprint: PRE, comment: "key 1" }, + { fingerprint: fp(2), comment: "key 2" }, + ], + }, + ); + renderScreen(); + // The stale pre-selection is VISIBLE, as a disabled row that says what happened: + // it can be neither seen past nor ticked back on. + expect(await screen.findByText(STALE)).toBeDefined(); + expect(await screen.findByText(/No longer present in vault/)).toBeDefined(); + // The row's checkbox is visibly disabled (Base UI's data-disabled, the attribute its opacity style binds to). + expect(screen.getByRole("checkbox", { name: STALE }).hasAttribute("data-disabled")).toBe(true); + // And the approve body carries only the key the operator can actually see: + // the server validates shape, not roster membership, so this screen is the fence. + 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] }); + }); + + it("renders the 24 h deadline localized, like the sibling house queue", async () => { + stubFetch((undo) => { + restores.push(undo); + }); + // Same precedent as pending-users-table: `new Date(...).toLocaleString()`. + const stamp = "10/9/2026, 10:00:00 AM"; + const timeSpy = spyOn(Date.prototype, "toLocaleString").mockReturnValue(stamp); + restores.push(() => timeSpy.mockRestore()); + renderScreen(); + const line = await screen.findByText(/^Expires/); + expect(line.textContent).toContain(stamp); + // The raw UTC ISO spelling must not remain on the card. + expect(line.textContent).not.toContain("2026-10-09"); + }); + + 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/__tests__/trust-section.test.tsx b/apps/server/web/src/components/ssh/__tests__/trust-section.test.tsx new file mode 100644 index 000000000..08d9f219e --- /dev/null +++ b/apps/server/web/src/components/ssh/__tests__/trust-section.test.tsx @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { NodeDetail } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { SshTrustSection } from "@/components/ssh/trust-section"; + +/** + * The trust section of a node's page (spec 2026-10-08 §4.5-§4.6): the shared + * machine trust card (owner/`edit` on an agent node, never `view`, never + * `local`), the plane's honesty line (this rendering is for DISPLAY only, + * the §4.6 check happens on the two machines' own dashboards), and the + * owner-only re-pair act on each pinned peer (Task 17: the route exists now, + * so the button is LIVE - it POSTs the repair for exactly its own peer and + * reports the outcome on itself). + * + * The gate pinned here is the EXACT owner: the server resolves an admin (or + * any grantee) on a foreign agent to `access: "edit"`, and the edit-grantee + * case below is the same shape the admin sees - no act, only the card. + */ + +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: "vault", + kind: "agent", + os: "linux", + arch: "x64", + hostname: "vault", + status: "online", + lastSeenAt: null, + agentVersion: "1.0.0", + protocolVersion: 18, + access: "owner", + canManage: true, + canRetire: true, + canLaunch: true, + capabilities: [], + allowedDirs: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + sshEnabled: true, + held: null, + sshTrust: { + own: { signing: FP_OWN_SIGN, encryption: FP_OWN_ENC }, + peers: [{ nodeId: PEER_ID, signing: FP_PEER_SIGN, encryption: FP_PEER_ENC }], + stale: false, + }, + ...over, + }; +} + +interface RecordedCall { + url: string; + method: string; +} + +const restores: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const undo of restores.splice(0)) undo(); +}); + +/** + * Stub fetch: the node-name read answers the machine list (names, never ids, + * on the button), and every other call - the repair POST - is recorded and + * answered with `mode`. + */ +function renderSection(n: NodeDetail, mode: "ok" | "fail" = "ok") { + const calls: RecordedCall[] = []; + const original = globalThis.fetch; + restores.push(() => { + globalThis.fetch = original; + }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); + const method = (init?.method ?? "GET").toUpperCase(); + if (method === "GET") { + return new Response(JSON.stringify({ nodes: [{ id: PEER_ID, name: "desk" }] }), { status: 200 }); + } + calls.push({ url, method }); + if (mode === "ok") return new Response(JSON.stringify({ repaired: true }), { status: 200 }); + return new Response( + JSON.stringify({ + errId: "e1", + code: "NODE_OFFLINE", + message: "That machine has no live connection right now.", + statusCode: 409, + }), + { status: 409 }, + ); + }) as typeof globalThis.fetch; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = render( + + + , + ); + return { ...view, calls }; +} + +describe("SshTrustSection", () => { + it("renders nothing for a view grantee, even against a payload that arrived anyway", () => { + const { container } = renderSection(node({ access: "view", canManage: false })); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing on local", () => { + const { container } = renderSection(node({ kind: "local" })); + expect(container.firstChild).toBeNull(); + }); + + it("renders the shared card for an edit grantee but no re-pair act", () => { + // An `edit` grantee AND an admin on a foreign agent both arrive here as + // access:"edit" (the resolver ranks admins at edit, never owner): the + // trust is read, never re-decided (§4.5 owner-only). + renderSection(node({ access: "edit", canManage: false })); + expect(screen.getByText("Machine trust")).toBeDefined(); + expect(screen.getByText(FP_PEER_SIGN)).toBeDefined(); + expect(screen.queryByRole("button", { name: /Re-pair/ })).toBeNull(); + }); + + it("offers the owner a live re-pair per pinned peer that posts the repair for exactly its peer", async () => { + const { calls } = renderSection(node()); + // The peer label settles once the machine-name read lands (names, never ids). + const button = (await screen.findByRole("button", { name: "Re-pair desk" })) as HTMLButtonElement; + expect(button.disabled).toBe(false); + fireEvent.click(button); + await screen.findByText(/Pin replaced for desk/); + expect(calls).toEqual([{ url: `/api/nodes/node1/machine-pins/${PEER_ID}/repair`, method: "POST" }]); + }); + + it("renders a refusal on the button that failed, in the machine's words, without key material", async () => { + renderSection(node(), "fail"); + const button = (await screen.findByRole("button", { name: "Re-pair desk" })) as HTMLButtonElement; + fireEvent.click(button); + const note = await screen.findByText(/no live connection/); + expect(note.className).toContain("text-destructive"); + expect(screen.queryByText(/Pin replaced/)).toBeNull(); + }); + + it("names the audit consequence: a re-pair replaces the stored pin, audited by peer", () => { + renderSection(node()); + expect(screen.getByText(/replaces the stored pin/)).toBeDefined(); + expect(screen.getByText(/names the peer/)).toBeDefined(); + }); + + it("marks its own rendering for display only, pointing at the two dashboards", () => { + renderSection(node()); + expect(screen.getByText(/for display only/)).toBeDefined(); + expect(screen.getByText(/own dashboard/)).toBeDefined(); + }); +}); 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..4197d7844 --- /dev/null +++ b/apps/server/web/src/components/ssh/grants-screen.tsx @@ -0,0 +1,517 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + confirmAction, + errMessage, + Input, + Label, +} from "@internal/node-admin"; +import { Pencil, Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { z } from "zod"; +import { Checkbox } from "@/components/ui/checkbox"; +import { type ComboboxOption, SearchableSelect } from "@/components/ui/combobox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + formDialogOpenChange, +} from "@/components/ui/dialog"; +import { RequiredMark } from "@/components/ui/required-mark"; +import { useNodes } from "@/hooks/use-nodes"; +import { + useCreateSshGrant, + useRevokeSshGrant, + useSshGrants, + useSshNodeRoster, + useUpdateSshGrant, +} from "@/hooks/use-ssh"; +import { fieldErrorToned, makeForm, useSubmitDisabled } from "@/lib/form"; +import { nodeOptionLabel } from "@/lib/node-label"; +import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone"; +import { grantSelectionError, 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, the name/selector + * edit, 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 the edit dialog touches only what the + * PATCH route accepts - name and destination selector - and has no key + * picker; the way to change a standing selection is revoke and create again. + * The KEY PICKER lives where a selection is BORN: the create dialog (spec §8, + * Task 18) reads the chosen key home's live roster and POSTs the same create + * path an approved first use writes. 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 [editingGrant, setEditingGrant] = useState(null); + const [creating, setCreating] = useState(false); + + 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. + +
+ {/* §8's create door (Task 18): the same row an approved first use writes, built from A's live roster. */} + +
+ + {grants.length === 0 ? ( +

+ No key grants yet. Answer a first-use approval yes, or create one here from a key home's roster. +

+ ) : ( +
    + {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.")} +

    + )} +
    +
    + + +
    +
  • + ))} +
+ )} + {editingGrant && ( + { + if (!open) setEditingGrant(null); + }} + /> + )} + {creating && } +
+
+ ); +} + +/** Create's two text fields; emptiness is a gap (gold), the server's refusal stays red. */ +const grantCreateSchema = z.object({ name: z.string(), selector: z.string() }).superRefine((values, ctx) => { + if (values.name.trim() === "") { + ctx.addIssue({ code: "custom", path: ["name"], message: "A name is required", gap: true }); + } + if (values.selector.trim() === "") { + ctx.addIssue({ code: "custom", path: ["selector"], message: "A destination selector is required", gap: true }); + } +}); + +/** + * §8's manual create, surfaced in Task 18: the picker chooses a key home, + * reads ITS live roster through the roster-by-node route (no pending request + * needed - that absence was the whole gap), and POSTs the existing create + * contract, so the row is identical to the one an approved first use writes. + * + * The dialog title is static (ruling 2026-09-30). The key home list mirrors + * the create gate's own doors (an agent node you own with SSH switched on); + * the roster fetch keeps the approval screen's honesty split - an unreachable + * key home shows ITS named error, and only an answer from a live agent may + * render "holds no keys". The cap is a red hard error the moment the ninth + * key is ticked: the server refuses over-cap outright, and this screen + * refuses before it, so a truncation can never be mistaken for the selection. + */ +function CreateGrantDialog({ onOpenChange }: { onOpenChange: (open: boolean) => void }) { + const { data: nodeData } = useNodes(); + const create = useCreateSshGrant(); + const [serverError, setServerError] = useState(null); + const [keyHomeId, setKeyHomeId] = useState(""); + const [selected, setSelected] = useState([]); + const roster = useSshNodeRoster(keyHomeId === "" ? null : keyHomeId); + + // SSH is owner-reserved on agent machines and `local` is never a key home + // (acceptance (d)); the picker offers exactly the machines the route's gate + // accepts, so a pick here is a machine that can answer. + const keyHomeOptions: ComboboxOption[] = (Array.isArray(nodeData?.nodes) ? nodeData.nodes : []) + .filter((n) => n.kind === "agent" && n.sshEnabled && n.access === "owner") + .map((n) => ({ value: n.id, label: nodeOptionLabel(n) })); + + // The roster is the WHOLE candidate set (the approval card's fence): the + // body may only carry fingerprints the live agent still reports. + const rosterHeld = roster.data ? new Set(roster.data.identities.map((identity) => identity.fingerprint)) : null; + const submitted = rosterHeld ? selected.filter((fingerprint) => rosterHeld.has(fingerprint)) : []; + const selectionError = grantSelectionError(submitted); + + const form = makeForm({ + defaultValues: { name: "", selector: "" }, + validator: grantCreateSchema, + onSubmit: async (values) => { + // The guard behind the gate (substrate contract): Enter-path submits + // and render races land here even though the button is swept. + const name = values.name.trim(); + const selector = values.selector.trim(); + if (name === "" || selector === "" || keyHomeId === "" || !roster.data) return; + if (submitted.length === 0 || selectionError) return; + setServerError(null); + try { + await create.mutateAsync({ nodeId: keyHomeId, name, selector, fingerprints: submitted }); + onOpenChange(false); + } catch (err) { + setServerError(errMessage(err, "The grant could not be created.")); + } + }, + }); + const disabled = + useSubmitDisabled(form, create.isPending) || !roster.data || submitted.length === 0 || !!selectionError; + + return ( + + + + Create grant + + Pick the key home, choose the keys its agent may sign with, and name the destinations they unlock. The + machine answers its roster live, so its SSH must be switched on. + + +
+ + { + setKeyHomeId(value); + setSelected([]); // a new key home answers a NEW roster; old ticks are not its keys + }} + placeholder="Choose a key home" + options={keyHomeOptions} + emptyText="No machine you own has SSH switched on yet. Switch it on from a machine's settings first." + /> +
+ {keyHomeId !== "" && ( +
+ + {roster.isPending && ( +

Asking the key home's agent…

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

+ {errMessage(roster.error, "The key home could not be reached. Nothing was saved.")} +

+ )} + {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) => ( +
  • + + setSelected((prev) => + prev.includes(identity.fingerprint) + ? prev.filter((f) => f !== identity.fingerprint) + : [...prev, identity.fingerprint], + ) + } + /> +
    +
    {identity.fingerprint}
    + {identity.comment && ( +
    {identity.comment}
    + )} +
    +
  • + ))} +
+ )} + {selectionError && ( +

+ {selectionError} +

+ )} +
+ )} + + {(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) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {field.state.meta.isTouched && + (() => { + const shown = fieldErrorToned(field.state.meta.errors); + return ( + shown && ( +

+ {shown.text} +

+ ) + ); + })()} +
+ )} +
+ {serverError && ( +

+ {serverError} +

+ )} + + + + +
+
+ ); +} + +/** The edit's two fields; emptiness is a gap (gold), the server's refusal is the red line. */ +const grantEditSchema = z.object({ name: z.string(), selector: z.string() }).superRefine((values, ctx) => { + if (values.name.trim() === "") { + ctx.addIssue({ code: "custom", path: ["name"], message: "A name is required", gap: true }); + } + if (values.selector.trim() === "") { + ctx.addIssue({ code: "custom", path: ["selector"], message: "A selector is required", gap: true }); + } +}); + +/** + * §8's name/selector edit over `PATCH /api/ssh/grants/:id`. The dialog title + * is static (ruling 2026-09-30): the grant's name rides the body. The dialog + * says what it does NOT do: which keys serve is immutable, so no fingerprint + * field exists here and the mutation gives them no way onto the wire - change + * the key set by revoking and granting again. + */ +function EditGrantDialog({ grant, onOpenChange }: { grant: SshGrant; onOpenChange: (open: boolean) => void }) { + const update = useUpdateSshGrant(); + const [serverError, setServerError] = useState(null); + + const form = makeForm({ + defaultValues: { name: grant.name, selector: grant.resolvedSelector }, + validator: grantEditSchema, + onSubmit: async (values) => { + const name = values.name.trim(); + const selector = values.selector.trim(); + if (name === "" || selector === "") return; + setServerError(null); + try { + await update.mutateAsync({ grantId: grant.id, name, selector }); + onOpenChange(false); + } catch (err) { + setServerError(errMessage(err, "The grant could not be updated.")); + } + }, + }); + const disabled = useSubmitDisabled(form, update.isPending); + + return ( + + + + Edit grant + + "{grant.name}" keeps the same keys. This dialog changes only the name and destination; to change which keys + serve, revoke and grant again. + + + + {(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) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {field.state.meta.isTouched && + (() => { + const shown = fieldErrorToned(field.state.meta.errors); + return ( + shown && ( +

+ {shown.text} +

+ ) + ); + })()} +
+ )} +
+ {serverError && ( +

+ {serverError} +

+ )} + + + + +
+
+ ); +} 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) => ( +
    + +