From 58b0ec9e50fd337d43fa8194c6983063fce4bfb9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Jul 2026 22:23:10 -0700 Subject: [PATCH] feat(gateway): auto-approve node pairing via SSH device-key verification (#104180) --- docs/cli/devices.md | 1 + docs/cli/node.md | 19 +- docs/cli/nodes.md | 1 + docs/docs_map.md | 1 + docs/gateway/configuration-reference.md | 7 + docs/gateway/pairing.md | 70 ++++- docs/gateway/security/index.md | 2 + docs/nodes/index.md | 3 + src/cli/node-cli/identity.test.ts | 71 +++++ src/cli/node-cli/identity.ts | 31 ++ src/cli/node-cli/register.test.ts | 1 + src/cli/node-cli/register.ts | 9 + src/config/schema.help.ts | 4 +- src/config/schema.labels.ts | 1 + src/config/schema.tags.ts | 1 + src/config/types.gateway.ts | 20 ++ src/config/zod-schema.ts | 13 + src/gateway/node-connect-reconcile.test.ts | 33 ++ src/gateway/node-connect-reconcile.ts | 9 + src/gateway/node-pairing-auto-approve.ts | 30 +- .../node-pairing-ssh-verify.runtime.ts | 125 ++++++++ src/gateway/node-pairing-ssh-verify.test.ts | 296 ++++++++++++++++++ src/gateway/node-pairing-ssh-verify.ts | 252 +++++++++++++++ .../server.node-pairing-auto-approve.test.ts | 144 ++------- .../server.node-pairing-ssh-verify.test.ts | 183 +++++++++++ .../server/ws-connection/message-handler.ts | 161 +++++++++- src/gateway/test-helpers.lan-pairing.ts | 137 ++++++++ src/infra/device-pairing-store.ts | 23 +- src/infra/device-pairing.ts | 10 +- src/infra/device-pairing.types.ts | 14 +- 30 files changed, 1515 insertions(+), 157 deletions(-) create mode 100644 src/cli/node-cli/identity.test.ts create mode 100644 src/cli/node-cli/identity.ts create mode 100644 src/gateway/node-pairing-ssh-verify.runtime.ts create mode 100644 src/gateway/node-pairing-ssh-verify.test.ts create mode 100644 src/gateway/node-pairing-ssh-verify.ts create mode 100644 src/gateway/server.node-pairing-ssh-verify.test.ts create mode 100644 src/gateway/test-helpers.lan-pairing.ts diff --git a/docs/cli/devices.md b/docs/cli/devices.md index 5d336fbb07be..ec2102f29f3d 100644 --- a/docs/cli/devices.md +++ b/docs/cli/devices.md @@ -56,6 +56,7 @@ Approval behavior: - If the device is already paired and requests broader scopes or role, OpenClaw keeps the existing approval and creates a new pending upgrade request. Compare `Requested` vs `Approved` in `openclaw devices list`, or preview with `--latest`, before approving. - Approving a `node` role or other non-operator role requires `operator.admin`. `operator.pairing` is enough for operator-device approvals, but only when the requested operator scopes stay within the caller's own scopes. See [Operator scopes](/gateway/operator-scopes). - If `gateway.nodes.pairing.autoApproveCidrs` is configured, first-time `role: node` requests from matching client IPs can be auto-approved before they appear in this list. Disabled by default; never applies to operator/browser clients or upgrade requests. +- `gateway.nodes.pairing.sshVerify` (on by default) auto-approves first-time `role: node` requests when the gateway verifies the device key over SSH to the node host. Requests may therefore resolve to approved shortly after appearing. Set `sshVerify: false` to disable SSH verification; this is independent of `autoApproveCidrs`, so unset that too for manual-only pairing. ### `openclaw devices reject ` diff --git a/docs/cli/node.md b/docs/cli/node.md index 2743ea021b1e..e05d31292f0e 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -129,13 +129,30 @@ the foreground flow so the pending request can be approved. ## Pairing The first connection creates a pending device pairing request (`role: node`) on the Gateway. -Approve it via: + +When the Gateway host can SSH to the node host non-interactively (same user, +trusted host key), the pending request is approved automatically: the Gateway +runs `openclaw node identity --json` on the node host over SSH and approves on +an exact device-key match. This is on by default; see +[SSH-verified device auto-approval](/gateway/pairing#ssh-verified-device-auto-approval-default) +for requirements and how to disable it (`gateway.nodes.pairing.sshVerify: false`). + +Otherwise approve manually via: ```bash openclaw devices list openclaw devices approve ``` +Inspect the local node identity the Gateway verifies against: + +```bash +openclaw node identity --json +``` + +It prints the device ID and public key from `identity/device.json` and never +creates or modifies identity files. + On tightly controlled node networks, the Gateway operator can explicitly opt in to auto-approving first-time node pairing from trusted CIDRs: diff --git a/docs/cli/nodes.md b/docs/cli/nodes.md index 408310040e5c..01aa83cac750 100644 --- a/docs/cli/nodes.md +++ b/docs/cli/nodes.md @@ -41,6 +41,7 @@ These commands drive the gateway-owned `node.pair.*` store, separate from device - `remove` revokes the node's paired-role entry. For a device-backed node this revokes the `node` role in the device pairing store and disconnects its node-role sessions: a mixed-role device keeps its row and only loses the `node` role, a node-only device row is deleted. It also clears any matching legacy gateway-owned node pairing record. - `pending` only needs `operator.pairing` scope. - `gateway.nodes.pairing.autoApproveCidrs` can skip the pending step for explicitly trusted, first-time `role: node` device pairing. Off by default; does not approve role upgrades. +- `gateway.nodes.pairing.sshVerify` (on by default) auto-approves first-time `role: node` device pairing when the gateway can verify the device key over SSH to the node host; the first capability surface is approved in the same step. See [Node pairing](/gateway/pairing#ssh-verified-device-auto-approval-default). - `approve` scope requirements follow the pending request's declared commands: - commandless request: `operator.pairing` - non-exec node commands: `operator.pairing` + `operator.write` diff --git a/docs/docs_map.md b/docs/docs_map.md index d4c3feb9cd18..8a88ed08533b 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -3589,6 +3589,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: API surface (gateway protocol) - H2: Node command gating (2026.3.31+) - H2: Node event trust boundaries (2026.3.31+) + - H2: SSH-verified device auto-approval (default) - H2: Auto-approval (macOS app) - H2: Trusted-CIDR device auto-approval - H2: Silent pairing supersede cleanup diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 8fee27bfa8c7..13fbed8e4844 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -564,6 +564,12 @@ See [Inferred commitments](/concepts/commitments). pairing: { // Optional. Default unset/disabled. autoApproveCidrs: ["192.168.1.0/24", "fd00:1234:5678::/64"], + // SSH-verified auto-approval. Default: enabled (true). + // Set false to disable SSH verification only; this does not affect + // autoApproveCidrs above. For manual-only node pairing, set false AND + // unset autoApproveCidrs. Pass an object to tune: { user, identity, + // timeoutMs, cidrs }. + sshVerify: true, }, allowCommands: ["canvas.navigate"], denyCommands: ["system.run"], @@ -642,6 +648,7 @@ See [Inferred commitments](/concepts/commitments). - `trustedProxies`: reverse proxy IPs that terminate TLS or inject forwarded-client headers. Only list proxies you control. Loopback entries are still valid for same-host proxy/local-detection setups (for example Tailscale Serve or a local reverse proxy), but they do **not** make loopback requests eligible for `gateway.auth.mode: "trusted-proxy"`. - `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior. - `gateway.nodes.pairing.autoApproveCidrs`: optional CIDR/IP allowlist for auto-approving first-time node device pairing with no requested scopes. It is disabled when unset. This does not auto-approve operator/browser/Control UI/WebChat pairing, and it does not auto-approve role, scope, metadata, or public-key upgrades. +- `gateway.nodes.pairing.sshVerify`: SSH-verified auto-approval for first-time node device pairing (default: enabled). The gateway SSHes back to the pairing host (BatchMode, strict host keys) and approves only on an exact `openclaw node identity` device-key match. Same eligibility floor as `autoApproveCidrs`; probes are limited to private/CGNAT source addresses unless `cidrs` overrides them. Set `false` to disable, or `{ user, identity, timeoutMs, cidrs }` to tune. See [Node pairing](/gateway/pairing#ssh-verified-device-auto-approval-default). - `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`: global allow/deny shaping for declared node commands after pairing and platform allowlist evaluation. Use `allowCommands` to opt into dangerous node commands such as `camera.snap`, `camera.clip`, `screen.record`, `sms.search`, and `sms.send`; `denyCommands` removes a command even if a platform default or explicit allow would otherwise include it. Android SMS permission and Gateway command authorization are independent. After a node changes its declared command list, reject and re-approve that device pairing so the gateway stores the updated command snapshot. - `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list). - `gateway.tools.allow`: remove tool names from the default HTTP deny list for diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 8086c300cb42..521be6deb48a 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -139,11 +139,63 @@ sessions, and updates pairing metadata only when the device/node identity is already paired. A self-declared `client.id` value is not enough to write last-seen state. +## SSH-verified device auto-approval (default) + +First-time `role: node` device pairing from a private/CGNAT address is +auto-approved when the gateway can **prove machine ownership over SSH**: it +connects back to the pairing host (`BatchMode`, `StrictHostKeyChecking=yes`), +runs `openclaw node identity --json` there, and approves only when the remote +device id and public key match the pending request exactly. The key match is +what makes this safe: reachability alone never approves, so NAT co-tenants, +other users on a shared host, and LAN spoofing all fall through to the normal +prompt. + +Enabled by default. Requirements for it to fire: + +- The gateway process user (or `sshVerify.user`) can SSH to the node host + non-interactively (keys/agent; Tailscale SSH works too), and the host key is + already trusted. +- `openclaw` resolves on the remote `PATH` for non-interactive `sh -lc`. +- The connecting IP is a direct (non-proxied, non-loopback) private, ULA, + link-local, or CGNAT address, or matches `sshVerify.cidrs` when set. +- Same eligibility floor as trusted-CIDR approval: fresh scopeless node + pairing only; upgrades, browsers, Control UI, and WebChat always prompt. + +While a probe is running, the node client is told to keep retrying +(`wait_then_retry`) instead of pausing for manual approval; if the probe +fails, the next attempt falls back to the normal prompt flow. Failed targets +get a short cooldown (5 minutes after a key mismatch). + +Approved devices record `approvedVia: "ssh-verified"` and their first declared +capability surface is approved in the same step — the key match already proves +the node runs under the operator's account on a machine they own, which is the +same claim a manual capability approval asserts. Later surface upgrades still +prompt. + +Harden or disable: + +```json5 +{ + gateway: { + nodes: { + pairing: { + // Disable entirely: + sshVerify: false, + // ...or scope/tune the probe: + // sshVerify: { user: "me", identity: "~/.ssh/probe", timeoutMs: 7000, cidrs: ["10.0.0.0/8"] }, + }, + }, + }, +} +``` + ## Auto-approval (macOS app) -The macOS app can attempt a **silent approval** when: +The macOS app can attempt a **silent approval** of node capability requests +when: -- the request is marked `silent`, and +- the request is marked `silent` (the gateway marks the first capability + surface silent when device pairing was approved non-interactively), and - the app can verify an SSH connection to the gateway host using the same user. @@ -170,7 +222,9 @@ in with explicit CIDRs or exact IPs: Security boundary: - Disabled when `gateway.nodes.pairing.autoApproveCidrs` is unset. -- No blanket LAN or private-network auto-approve mode exists. +- No blanket LAN or private-network auto-approve mode exists; SSH-verified + auto-approval (above) requires a cryptographic device-key match, never + network locality alone. - Only a fresh `role: node` device pairing request with no requested scopes is eligible. - Operator, browser, Control UI, and WebChat clients stay manual. @@ -182,7 +236,7 @@ Security boundary: Non-interactive approvals record their provenance on the paired-device row: same-host local policy approvals as `silent`, trusted-CIDR node approvals as -`trusted-cidr`. Clients whose state directory is ephemeral (temporary homes, +`trusted-cidr`, SSH-verified node approvals as `ssh-verified`. Clients whose state directory is ephemeral (temporary homes, containers, per-run sandboxes) mint a fresh device keypair per run, and every run silently re-pairs as a brand-new device — without cleanup the paired list grows one stale row per run. @@ -198,10 +252,10 @@ removal event is broadcast. Boundaries: - Only records whose latest approval was same-host local (`silent`) are - eligible, as trigger and as target. Trusted-CIDR pairings cross hosts where - display metadata is not a machine identity, so they are never removed - automatically — use the Control UI cleanup or `openclaw nodes remove` for - those. + eligible, as trigger and as target. Trusted-CIDR and SSH-verified pairings + cross hosts where display metadata is not a machine identity, so they are + never removed automatically — use the Control UI cleanup or + `openclaw nodes remove` for those. - Owner-approved and QR/setup-code (bootstrap) pairings are never removed automatically. Records approved before provenance existed stay protected, even after a later silent re-approval of the same device id. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 2293075301c9..986aac4e2dfc 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -106,6 +106,7 @@ Quick model for triaging risk reports: | Local TUI `!` shell | Explicit operator-triggered local execution | "Local shell convenience command is remote injection" | | Node pairing and node commands | Operator-level remote execution on paired devices | "Remote device control should be treated as untrusted user access by default" | | `gateway.nodes.pairing.autoApproveCidrs` | Opt-in trusted-network node enrollment policy | "A disabled-by-default allowlist is an automatic pairing vulnerability" | +| `gateway.nodes.pairing.sshVerify` | Key-verified node enrollment over operator SSH | "Default-on auto-approval is an automatic pairing vulnerability" | ## Not vulnerabilities by design @@ -117,6 +118,7 @@ Quick model for triaging risk reports: - Localhost-only deployment findings (for example missing HSTS on a loopback-only gateway). - Discord inbound webhook signature findings for inbound paths that do not exist in this repo. - Node pairing metadata treated as a hidden second per-command approval layer for `system.run`; the real execution boundary is the gateway's global node command policy plus the node's own exec approvals. +- `gateway.nodes.pairing.sshVerify` treated as a vulnerability because it is enabled by default. It never approves on network locality or SSH reachability alone: the gateway reads the device identity back over SSH (BatchMode, strict host keys) and approves only on an exact device-key match with the pending request, which requires the connecting keypair to already live under the operator's account on a host the operator controls. Probes are bounded to private/CGNAT source addresses, share the trusted-CIDR eligibility floor (fresh scopeless `role: node` only), and `sshVerify: false` turns the feature off. - `gateway.nodes.pairing.autoApproveCidrs` treated as a vulnerability by itself. It is disabled by default, requires explicit CIDR/IP entries, only applies to first-time `role: node` pairing with no requested scopes, and never auto-approves operator/browser/Control UI, WebChat, role/scope upgrades, metadata or public-key changes, or same-host loopback trusted-proxy header paths (even when loopback trusted-proxy auth is enabled). - "Missing per-user authorization" findings that treat `sessionKey` as an auth token. diff --git a/docs/nodes/index.md b/docs/nodes/index.md index dcee282c056e..c914fc1d8b25 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -261,6 +261,9 @@ Node-related settings live under `gateway.nodes` and `tools.exec`: // with no requested scopes; does not auto-approve upgrades. pairing: { autoApproveCidrs: ["192.168.1.0/24"], + // SSH-verified auto-approval (default: enabled). Approves first-time + // node pairing on an exact device-key match read back over SSH. + sshVerify: true, }, // Opt into dangerous/privacy-heavy node commands (camera.snap, etc.). allowCommands: ["camera.snap", "screen.record"], diff --git a/src/cli/node-cli/identity.test.ts b/src/cli/node-cli/identity.test.ts new file mode 100644 index 000000000000..9d4dc1510b35 --- /dev/null +++ b/src/cli/node-cli/identity.test.ts @@ -0,0 +1,71 @@ +// Node identity CLI tests: read-only output of the node host device identity. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, +} from "../../infra/device-identity.js"; +import { defaultRuntime } from "../../runtime.js"; +import { runNodeIdentityShow } from "./identity.js"; + +describe("runNodeIdentityShow", () => { + let stateDir: string; + let prevStateDir: string | undefined; + let logSpy: ReturnType; + let errorSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-identity-")); + prevStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + logSpy = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); + exitSpy = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => {}); + }); + + afterEach(() => { + if (prevStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = prevStateDir; + } + logSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + fs.rmSync(stateDir, { recursive: true, force: true }); + }); + + it("fails closed when no identity exists (never mints one)", () => { + runNodeIdentityShow({}); + expect(errorSpy).toHaveBeenCalledOnce(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(fs.existsSync(path.join(stateDir, "identity", "device.json"))).toBe(false); + }); + + it("prints deviceId and raw public key as JSON", () => { + const identity = loadOrCreateDeviceIdentity(path.join(stateDir, "identity", "device.json")); + runNodeIdentityShow({ json: true }); + expect(exitSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledOnce(); + const parsed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + deviceId: string; + publicKey: string; + }; + expect(parsed).toEqual({ + deviceId: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + }); + }); + + it("prints human-readable lines without --json", () => { + const identity = loadOrCreateDeviceIdentity(path.join(stateDir, "identity", "device.json")); + runNodeIdentityShow({}); + expect(exitSpy).not.toHaveBeenCalled(); + const output = logSpy.mock.calls.map((call: unknown[]) => String(call[0])).join("\n"); + expect(output).toContain(identity.deviceId); + expect(output).toContain(publicKeyRawBase64UrlFromPem(identity.publicKeyPem)); + }); +}); diff --git a/src/cli/node-cli/identity.ts b/src/cli/node-cli/identity.ts new file mode 100644 index 000000000000..b6636e2abbea --- /dev/null +++ b/src/cli/node-cli/identity.ts @@ -0,0 +1,31 @@ +// Prints the local node host device identity for pairing verification. +import { + loadDeviceIdentityIfPresent, + publicKeyRawBase64UrlFromPem, +} from "../../infra/device-identity.js"; +import { defaultRuntime } from "../../runtime.js"; + +/** + * Read-only by design: the SSH-verified pairing probe calls this remotely and + * must never mint a fresh identity on a host that has not run the node host. + */ +export function runNodeIdentityShow(opts: { json?: boolean }) { + const identity = loadDeviceIdentityIfPresent(); + if (!identity) { + defaultRuntime.error( + "no node device identity found (start the node host once with `openclaw node run` or `openclaw node install`)", + ); + defaultRuntime.exit(1); + return; + } + const payload = { + deviceId: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + }; + if (opts.json) { + defaultRuntime.log(JSON.stringify(payload)); + return; + } + defaultRuntime.log(`deviceId: ${payload.deviceId}`); + defaultRuntime.log(`publicKey: ${payload.publicKey}`); +} diff --git a/src/cli/node-cli/register.test.ts b/src/cli/node-cli/register.test.ts index 724081238ff7..669af7e5b18c 100644 --- a/src/cli/node-cli/register.test.ts +++ b/src/cli/node-cli/register.test.ts @@ -7,6 +7,7 @@ type LoadNodeHostConfig = typeof import("../../node-host/config.js").loadNodeHos const daemonMocks = vi.hoisted(() => ({ defaultRuntime: { + log: vi.fn(), error: vi.fn(), exit: vi.fn(), }, diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts index c2905b17f943..c997f53248e3 100644 --- a/src/cli/node-cli/register.ts +++ b/src/cli/node-cli/register.ts @@ -17,6 +17,7 @@ import { runNodeDaemonStop, runNodeDaemonUninstall, } from "./daemon.js"; +import { runNodeIdentityShow } from "./identity.js"; function parsePortOption(value: unknown, fallback: number): number | null { // Undefined keeps config/default port; invalid explicit input returns null for CLI errors. @@ -103,6 +104,14 @@ export function registerNodeCli(program: Command) { await runNodeDaemonStatus(opts); }); + node + .command("identity") + .description("Print the node host device identity (device id + public key)") + .option("--json", "Output JSON", false) + .action((opts) => { + runNodeIdentityShow(opts); + }); + node .command("install") .description("Install the node host service (launchd/systemd/schtasks)") diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 14b9569c5780..7f8f3fda8b4a 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -640,9 +640,11 @@ export const FIELD_HELP: Record = { 'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).', "gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).", "gateway.nodes.pairing": - "Node pairing policy settings. Defaults keep CIDR auto-approval disabled; enable only with explicit trusted CIDR/IP allowlists you control.", + "Node pairing policy settings. SSH-verified auto-approval is enabled by default; CIDR auto-approval stays disabled unless explicit trusted CIDR/IP allowlists are configured.", "gateway.nodes.pairing.autoApproveCidrs": "Opt-in CIDR/IP allowlist for auto-approving first-time node-role device pairing with no requested scopes. Disabled when unset. Operator, browser, Control UI, and any role, scope, metadata, or public-key upgrade pairing still require manual approval.", + "gateway.nodes.pairing.sshVerify": + "SSH-verified auto-approval for first-time node-role device pairing (default: enabled). The gateway SSHes back to the pairing host (BatchMode, strict host keys) and approves only when the remote `openclaw node identity` output matches the pending device key. Set false to disable SSH verification (independent of autoApproveCidrs, which stays active); for manual-only pairing also unset autoApproveCidrs. Pass an object to override user/identity/timeoutMs/cidrs.", "gateway.nodes.allowCommands": "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings). Enabling dangerous commands here is a security-sensitive override and is flagged by `openclaw security audit`.", "gateway.nodes.denyCommands": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 6a5faea0e470..be963976603e 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -401,6 +401,7 @@ export const FIELD_LABELS: Record = { "gateway.nodes.browser.node": "Gateway Node Browser Pin", "gateway.nodes.pairing": "Gateway Node Pairing", "gateway.nodes.pairing.autoApproveCidrs": "Gateway Node Pairing Auto-Approve CIDRs", + "gateway.nodes.pairing.sshVerify": "Gateway Node Pairing SSH Verification", "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", "gateway.nodes.denyCommands": "Gateway Node Denylist", nodeHost: "Node Host", diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index 06a423d7d30e..d0f41d011355 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -58,6 +58,7 @@ const TAG_OVERRIDES: Record = { "gateway.controlUi.dangerouslyDisableDeviceAuth": ["security", "access", "network", "advanced"], "gateway.controlUi.allowInsecureAuth": ["security", "access", "network", "advanced"], "gateway.nodes.pairing.autoApproveCidrs": ["security", "access", "network", "advanced"], + "gateway.nodes.pairing.sshVerify": ["security", "access", "network", "advanced"], "proxy.tls.caFile": ["security", "network", "storage", "advanced"], "tools.exec.applyPatch.workspaceOnly": ["tools", "security", "access", "advanced"], "tools.exec.mode": ["tools", "security", "access"], diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 13e45a6be932..9068d1e683ba 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -479,6 +479,26 @@ export type GatewayNodePairingConfig = { * Default: unset/disabled. */ autoApproveCidrs?: string[]; + /** + * SSH-verified auto-approval for first-time node-role pairing (default: enabled). + * The gateway connects back to the pairing host over SSH (BatchMode, strict + * host keys) and approves only when the remote `openclaw node identity` + * output matches the pending request's device key. Set false to disable SSH + * verification; this is independent of autoApproveCidrs, so unset that too for + * manual-only node pairing. The object form tunes the probe: + * - user: remote user (default: gateway process user) + * - identity: SSH identity file (default: standard SSH resolution) + * - timeoutMs: probe timeout (default: 7000) + * - cidrs: CIDRs/IPs eligible for probing (default: private/CGNAT ranges) + */ + sshVerify?: + | boolean + | { + user?: string; + identity?: string; + timeoutMs?: number; + cidrs?: string[]; + }; }; export type GatewayNodesConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 76ba4fb45a14..f04a8667141a 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1398,6 +1398,19 @@ export const OpenClawSchema = z pairing: z .object({ autoApproveCidrs: z.array(z.string()).optional(), + sshVerify: z + .union([ + z.boolean(), + z + .object({ + user: z.string().optional(), + identity: z.string().optional(), + timeoutMs: z.number().int().positive().optional(), + cidrs: z.array(z.string()).optional(), + }) + .strict(), + ]) + .optional(), }) .strict() .optional(), diff --git a/src/gateway/node-connect-reconcile.test.ts b/src/gateway/node-connect-reconcile.test.ts index 06e510f2336b..23b79f40cbdb 100644 --- a/src/gateway/node-connect-reconcile.test.ts +++ b/src/gateway/node-connect-reconcile.test.ts @@ -81,6 +81,39 @@ describe("reconcileNodePairingOnConnect", () => { }); }); + it("marks the first-surface request silent when device pairing was non-interactive", async () => { + const requestPairing = makePendingPairingRequest("req-silent"); + + await reconcileNodePairingOnConnect({ + cfg: {} as never, + connectParams: makeNodeConnectParams(), + pairedNode: null, + initialSurfaceSilent: true, + requestPairing, + }); + + expect(requestPairing).toHaveBeenCalledWith(expect.objectContaining({ silent: true })); + }); + + it("keeps surface upgrade requests interactive even when the device paired silently", async () => { + const requestPairing = makePendingPairingRequest("req-upgrade-loud"); + + await reconcileNodePairingOnConnect({ + cfg: {} as never, + connectParams: makeNodeConnectParams({ + caps: ["camera", "screen"], + commands: [], + }), + pairedNode: makePairedNode({ caps: ["camera"], commands: [] }), + initialSurfaceSilent: true, + requestPairing, + }); + + expect(requestPairing).toHaveBeenCalledOnce(); + const input = requestPairing.mock.calls[0]?.[0]; + expect(input?.silent).toBeUndefined(); + }); + it("keeps first-time pending node surfaces declared but not effective", async () => { const requestPairing = makePendingPairingRequest("req-pending"); diff --git a/src/gateway/node-connect-reconcile.ts b/src/gateway/node-connect-reconcile.ts index 1ffe38849b5d..628036aba35f 100644 --- a/src/gateway/node-connect-reconcile.ts +++ b/src/gateway/node-connect-reconcile.ts @@ -98,6 +98,7 @@ function buildNodePairingRequestInput(params: { commands: string[]; permissions?: Record; remoteIp?: string; + silent?: boolean; }): NodePairingRequestInput { return { nodeId: params.nodeId, @@ -110,6 +111,7 @@ function buildNodePairingRequestInput(params: { commands: params.commands, permissions: params.permissions, remoteIp: params.remoteIp, + ...(params.silent ? { silent: true } : {}), }; } @@ -119,6 +121,12 @@ export async function reconcileNodePairingOnConnect(params: { connectParams: ConnectParams; pairedNode: NodePairingPairedNode | null; reportedClientIp?: string; + /** + * Marks the first-surface capability request silent when device pairing was + * approved non-interactively; approval UIs may then auto-approve it (macOS + * SSH trust probe) instead of prompting. Upgrade requests stay interactive. + */ + initialSurfaceSilent?: boolean; requestPairing: (input: NodePairingRequestInput) => Promise; }): Promise { const nodeId = params.connectParams.device?.id ?? params.connectParams.client.id; @@ -147,6 +155,7 @@ export async function reconcileNodePairingOnConnect(params: { commands: declared, permissions: declaredPermissions, remoteIp: params.reportedClientIp, + silent: params.initialSurfaceSilent, }), ); if (!pendingPairing) { diff --git a/src/gateway/node-pairing-auto-approve.ts b/src/gateway/node-pairing-auto-approve.ts index 161c976d772b..a5b4ec7490e5 100644 --- a/src/gateway/node-pairing-auto-approve.ts +++ b/src/gateway/node-pairing-auto-approve.ts @@ -8,7 +8,7 @@ export type NodePairingAutoApproveReason = | "scope-upgrade" | "metadata-upgrade"; -type NodePairingAutoApproveClientIpSource = +export type NodePairingAutoApproveClientIpSource = | "direct" | "trusted-proxy" | "loopback-trusted-proxy" @@ -30,8 +30,8 @@ export function resolveNodePairingClientIpSource(params: { return params.remoteIsLoopback ? "loopback-trusted-proxy" : "trusted-proxy"; } -/** Returns true when a node pairing request can be auto-approved by trusted CIDR policy. */ -export function shouldAutoApproveNodePairingFromTrustedCidrs(params: { +/** Shared eligibility inputs for non-interactive first-time node pairing approvals. */ +export type FreshNodePairingEligibilityParams = { existingPairedDevice: boolean; role: string; reason: NodePairingAutoApproveReason; @@ -41,8 +41,17 @@ export function shouldAutoApproveNodePairingFromTrustedCidrs(params: { isWebchat: boolean; reportedClientIpSource: NodePairingAutoApproveClientIpSource; reportedClientIp?: string; - autoApproveCidrs?: readonly string[]; -}): boolean { +}; + +/** + * Shared floor for every non-interactive node pairing approval (trusted-CIDR, + * SSH-verified): only a fresh, scopeless, non-browser `role: node` request + * with a directly attributable client IP qualifies. Upgrades and spoofable + * loopback trusted-proxy header paths always stay on the manual prompt. + */ +export function isEligibleFreshNodePairingRequest( + params: FreshNodePairingEligibilityParams, +): boolean { if (params.existingPairedDevice) { return false; } @@ -64,7 +73,16 @@ export function shouldAutoApproveNodePairingFromTrustedCidrs(params: { ) { return false; } - if (!params.reportedClientIp) { + return Boolean(params.reportedClientIp); +} + +/** Returns true when a node pairing request can be auto-approved by trusted CIDR policy. */ +export function shouldAutoApproveNodePairingFromTrustedCidrs( + params: FreshNodePairingEligibilityParams & { + autoApproveCidrs?: readonly string[]; + }, +): boolean { + if (!isEligibleFreshNodePairingRequest(params) || !params.reportedClientIp) { return false; } diff --git a/src/gateway/node-pairing-ssh-verify.runtime.ts b/src/gateway/node-pairing-ssh-verify.runtime.ts new file mode 100644 index 000000000000..c742ff06f671 --- /dev/null +++ b/src/gateway/node-pairing-ssh-verify.runtime.ts @@ -0,0 +1,125 @@ +// SSH probe execution for SSH-verified node pairing. +// Kept as a narrow runtime boundary so gateway tests can mock the spawn +// without touching the eligibility/verification policy. +import { spawn } from "node:child_process"; + +export type NodeIdentityProbeParams = { + user: string; + host: string; + port?: number; + identity?: string; + timeoutMs: number; +}; + +export type NodeIdentityProbeResult = + | { status: "ok"; stdout: string } + | { status: "failed"; code: number | null; stderr: string } + | { status: "timeout" } + | { status: "spawn-error"; message: string }; + +const MAX_PROBE_OUTPUT_BYTES = 64 * 1024; + +// `sh -lc` loads the remote login profile so `openclaw` resolves on PATH even +// though sshd runs remote commands through a non-login shell. +const REMOTE_IDENTITY_COMMAND = "sh -lc 'openclaw node identity --json'"; + +/** Read the node device identity back from the pairing host over SSH. */ +export async function runNodeIdentityProbe( + params: NodeIdentityProbeParams, +): Promise { + const args = [ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=5", + "-o", + "NumberOfPasswordPrompts=0", + "-o", + "PreferredAuthentications=publickey", + // Auto-approval is an authorization boundary; only hosts whose key is + // already trusted may vouch for a pairing request. + "-o", + "StrictHostKeyChecking=yes", + "-o", + "UpdateHostKeys=no", + // The probe target is chosen from the connecting client's IP, i.e. an + // untrusted host until the key match succeeds. Never expose the gateway + // user's agent, X11, or any port forward to it, even if the user's ssh + // config enables forwarding for a matching host. + "-a", + "-x", + "-o", + "ForwardAgent=no", + "-o", + "ForwardX11=no", + "-o", + "ForwardX11Trusted=no", + "-o", + "ClearAllForwardings=yes", + "-o", + "ExitOnForwardFailure=yes", + "-p", + String(params.port ?? 22), + ]; + if (params.identity?.trim()) { + args.push("-i", params.identity.trim(), "-o", "IdentitiesOnly=yes"); + } + // Security: '--' prevents the user@host target from being read as an option. + args.push("--", `${params.user}@${params.host}`, REMOTE_IDENTITY_COMMAND); + + return await new Promise((resolve) => { + let settled = false; + const settle = (result: NodeIdentityProbeResult) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(result); + }; + + // PATH-resolved `ssh` keeps Windows OpenSSH working; the gateway process + // environment is operator-owned, so PATH lookup is not an injection risk. + const child = spawn("ssh", args, { stdio: ["ignore", "pipe", "pipe"] }); + const timer = setTimeout( + () => { + try { + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 1_500).unref(); + } catch { + // Best-effort teardown; the probe already reports timeout. + } + settle({ status: "timeout" }); + }, + Math.max(250, params.timeoutMs), + ); + timer.unref?.(); + + let stdout = ""; + let stderr = ""; + const append = (current: string, chunk: unknown): string => + current.length >= MAX_PROBE_OUTPUT_BYTES + ? current + : (current + String(chunk)).slice(0, MAX_PROBE_OUTPUT_BYTES); + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk) => { + stdout = append(stdout, chunk); + }); + child.stderr?.on("data", (chunk) => { + stderr = append(stderr, chunk); + }); + child.stdout?.on("error", () => {}); + child.stderr?.on("error", () => {}); + child.once("error", (error) => { + settle({ status: "spawn-error", message: error.message }); + }); + child.once("close", (code) => { + if (code === 0) { + settle({ status: "ok", stdout }); + } else { + settle({ status: "failed", code, stderr }); + } + }); + }); +} diff --git a/src/gateway/node-pairing-ssh-verify.test.ts b/src/gateway/node-pairing-ssh-verify.test.ts new file mode 100644 index 000000000000..a14c6bbc53a6 --- /dev/null +++ b/src/gateway/node-pairing-ssh-verify.test.ts @@ -0,0 +1,296 @@ +// SSH-verified node pairing policy and verifier tests (probe injected). +import crypto from "node:crypto"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + deriveDeviceIdFromPublicKey, + publicKeyRawBase64UrlFromPem, +} from "../infra/device-identity.js"; +import type { FreshNodePairingEligibilityParams } from "./node-pairing-auto-approve.js"; +import { + planNodePairingSshVerify, + resetNodePairingSshVerifyStateForTests, + resolveNodePairingSshVerifyPolicy, + startNodePairingSshVerify, + type NodePairingSshVerifyPlan, +} from "./node-pairing-ssh-verify.js"; +import type { + NodeIdentityProbeParams, + NodeIdentityProbeResult, +} from "./node-pairing-ssh-verify.runtime.js"; + +function makeIdentity(): { deviceId: string; publicKey: string } { + const { publicKey } = crypto.generateKeyPairSync("ed25519"); + const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }) as string; + const raw = publicKeyRawBase64UrlFromPem(publicKeyPem); + const deviceId = deriveDeviceIdFromPublicKey(raw); + if (!deviceId) { + throw new Error("failed to derive device id for test identity"); + } + return { deviceId, publicKey: raw }; +} + +function makeEligibility( + overrides?: Partial, +): FreshNodePairingEligibilityParams { + return { + existingPairedDevice: false, + role: "node", + reason: "not-paired", + scopes: [], + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reportedClientIpSource: "direct", + reportedClientIp: "192.168.1.20", + ...overrides, + }; +} + +function makePlan(host = "192.168.1.20"): NodePairingSshVerifyPlan { + return { + policy: { user: "tester", timeoutMs: 1_000 }, + host, + }; +} + +function probeReturning(result: NodeIdentityProbeResult) { + const calls: NodeIdentityProbeParams[] = []; + const probe = async (params: NodeIdentityProbeParams) => { + calls.push(params); + return result; + }; + return { probe, calls }; +} + +beforeEach(() => { + resetNodePairingSshVerifyStateForTests(); +}); + +afterEach(() => { + resetNodePairingSshVerifyStateForTests(); +}); + +describe("resolveNodePairingSshVerifyPolicy", () => { + test("is enabled by default and honors explicit true", () => { + for (const raw of [undefined, true] as const) { + const policy = resolveNodePairingSshVerifyPolicy(raw); + expect(policy).not.toBeNull(); + expect(policy?.user.length).toBeGreaterThan(0); + expect(policy?.timeoutMs).toBe(7_000); + } + }); + + test("false disables the policy", () => { + expect(resolveNodePairingSshVerifyPolicy(false)).toBeNull(); + }); + + test("object form overrides user, identity, timeout, and cidrs", () => { + const policy = resolveNodePairingSshVerifyPolicy({ + user: "peter", + identity: "/keys/probe", + timeoutMs: 1234, + cidrs: ["10.0.0.0/8", " "], + }); + expect(policy).toEqual({ + user: "peter", + identity: "/keys/probe", + timeoutMs: 1234, + cidrs: ["10.0.0.0/8"], + }); + }); +}); + +describe("planNodePairingSshVerify", () => { + test("plans a probe for an eligible private-network node pairing", () => { + const plan = planNodePairingSshVerify({ + config: undefined, + eligibility: makeEligibility(), + }); + expect(plan?.host).toBe("192.168.1.20"); + }); + + test("strips the IPv4-mapped IPv6 prefix from the probe target", () => { + const plan = planNodePairingSshVerify({ + config: undefined, + eligibility: makeEligibility({ reportedClientIp: "::ffff:192.168.1.21" }), + }); + expect(plan?.host).toBe("192.168.1.21"); + }); + + test.each([ + ["disabled config", { config: false as const, eligibility: makeEligibility() }], + [ + "existing paired device", + { config: undefined, eligibility: makeEligibility({ existingPairedDevice: true }) }, + ], + ["operator role", { config: undefined, eligibility: makeEligibility({ role: "operator" }) }], + [ + "upgrade reason", + { config: undefined, eligibility: makeEligibility({ reason: "scope-upgrade" }) }, + ], + [ + "requested scopes", + { config: undefined, eligibility: makeEligibility({ scopes: ["operator.read"] }) }, + ], + [ + "browser origin", + { config: undefined, eligibility: makeEligibility({ hasBrowserOriginHeader: true }) }, + ], + [ + "spoofable loopback proxy source", + { + config: undefined, + eligibility: makeEligibility({ reportedClientIpSource: "loopback-trusted-proxy" }), + }, + ], + [ + "public source address", + { config: undefined, eligibility: makeEligibility({ reportedClientIp: "203.0.113.9" }) }, + ], + [ + "loopback source address", + { config: undefined, eligibility: makeEligibility({ reportedClientIp: "127.0.0.1" }) }, + ], + [ + "zone-scoped IPv6 literal", + { config: undefined, eligibility: makeEligibility({ reportedClientIp: "fe80::1%en0" }) }, + ], + ])("returns null for %s", (_label, params) => { + expect(planNodePairingSshVerify(params)).toBeNull(); + }); + + test("custom cidrs replace the default private-range probe scope", () => { + const config = { cidrs: ["10.1.0.0/16"] }; + expect( + planNodePairingSshVerify({ + config, + eligibility: makeEligibility({ reportedClientIp: "192.168.1.20" }), + }), + ).toBeNull(); + expect( + planNodePairingSshVerify({ + config, + eligibility: makeEligibility({ reportedClientIp: "10.1.2.3" }), + })?.host, + ).toBe("10.1.2.3"); + }); +}); + +describe("startNodePairingSshVerify", () => { + test("approves when the remote identity matches, tolerating login-shell noise", async () => { + const identity = makeIdentity(); + const { probe, calls } = probeReturning({ + status: "ok", + stdout: `Welcome to devbox\n{"deviceId":"${identity.deviceId}","publicKey":"${identity.publicKey}"}\n`, + }); + const started = startNodePairingSshVerify({ + plan: makePlan(), + expectedDeviceId: identity.deviceId, + expectedPublicKey: identity.publicKey, + probe, + }); + expect(started).not.toBeNull(); + await expect(started?.done).resolves.toEqual({ + ok: true, + user: "tester", + host: "192.168.1.20", + }); + expect(calls).toEqual([ + { user: "tester", host: "192.168.1.20", identity: undefined, timeoutMs: 1_000 }, + ]); + }); + + test("rejects a remote identity with a different key and cools the target down", async () => { + const expected = makeIdentity(); + const other = makeIdentity(); + const { probe } = probeReturning({ + status: "ok", + stdout: `{"deviceId":"${expected.deviceId}","publicKey":"${other.publicKey}"}\n`, + }); + const started = startNodePairingSshVerify({ + plan: makePlan(), + expectedDeviceId: expected.deviceId, + expectedPublicKey: expected.publicKey, + probe, + }); + await expect(started?.done).resolves.toEqual({ ok: false, reason: "identity-mismatch" }); + expect( + startNodePairingSshVerify({ + plan: makePlan(), + expectedDeviceId: expected.deviceId, + expectedPublicKey: expected.publicKey, + probe, + }), + ).toBeNull(); + }); + + test("rejects a matching key under a different device id", async () => { + const identity = makeIdentity(); + const { probe } = probeReturning({ + status: "ok", + stdout: `{"deviceId":"someone-else","publicKey":"${identity.publicKey}"}\n`, + }); + const started = startNodePairingSshVerify({ + plan: makePlan(), + expectedDeviceId: identity.deviceId, + expectedPublicKey: identity.publicKey, + probe, + }); + await expect(started?.done).resolves.toEqual({ ok: false, reason: "identity-mismatch" }); + }); + + test("reports probe failures and unreadable identities distinctly", async () => { + const identity = makeIdentity(); + const failed = startNodePairingSshVerify({ + plan: makePlan("192.168.1.30"), + expectedDeviceId: identity.deviceId, + expectedPublicKey: identity.publicKey, + probe: probeReturning({ status: "failed", code: 255, stderr: "denied" }).probe, + }); + await expect(failed?.done).resolves.toEqual({ ok: false, reason: "probe-failed" }); + + const unreadable = startNodePairingSshVerify({ + plan: makePlan("192.168.1.31"), + expectedDeviceId: identity.deviceId, + expectedPublicKey: identity.publicKey, + probe: probeReturning({ status: "ok", stdout: "no json here\n" }).probe, + }); + await expect(unreadable?.done).resolves.toEqual({ ok: false, reason: "identity-unreadable" }); + }); + + test("shares an in-flight probe with reconnects instead of starting another", async () => { + const identity = makeIdentity(); + let probeRuns = 0; + let release: (result: NodeIdentityProbeResult) => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const probe = () => { + probeRuns += 1; + return gate; + }; + const started = startNodePairingSshVerify({ + plan: makePlan(), + expectedDeviceId: identity.deviceId, + expectedPublicKey: identity.publicKey, + probe, + }); + expect(started).toMatchObject({ alreadyInFlight: false }); + const retry = startNodePairingSshVerify({ + plan: makePlan(), + expectedDeviceId: identity.deviceId, + expectedPublicKey: identity.publicKey, + probe, + }); + // The retry must observe the running probe (so the client keeps retrying) + // without spawning a second one. + expect(retry).toMatchObject({ alreadyInFlight: true }); + expect(retry?.done).toBe(started?.done); + release({ + status: "ok", + stdout: `{"deviceId":"${identity.deviceId}","publicKey":"${identity.publicKey}"}\n`, + }); + await expect(started?.done).resolves.toMatchObject({ ok: true }); + expect(probeRuns).toBe(1); + }); +}); diff --git a/src/gateway/node-pairing-ssh-verify.ts b/src/gateway/node-pairing-ssh-verify.ts new file mode 100644 index 000000000000..b4267ceb431e --- /dev/null +++ b/src/gateway/node-pairing-ssh-verify.ts @@ -0,0 +1,252 @@ +// SSH-verified node pairing auto-approval. +// Proves a pending first-time node pairing by reading the device identity back +// over SSH from the connecting host and comparing key material. Approval binds +// to machine ownership (host key + authorized key + on-disk device identity), +// not network locality, so NAT co-tenants, other users on a shared host, and +// LAN spoofing all fall through to the manual prompt. +import net from "node:net"; +import os from "node:os"; +import type { GatewayNodePairingConfig } from "../config/types.gateway.js"; +import { normalizeDevicePublicKeyBase64Url } from "../infra/device-identity.js"; +import { isLoopbackAddress, isPrivateOrLoopbackAddress, isTrustedProxyAddress } from "./net.js"; +import { + isEligibleFreshNodePairingRequest, + type FreshNodePairingEligibilityParams, +} from "./node-pairing-auto-approve.js"; +import { + runNodeIdentityProbe, + type NodeIdentityProbeParams, + type NodeIdentityProbeResult, +} from "./node-pairing-ssh-verify.runtime.js"; + +// Object form of the sshVerify config, derived from the config field so this +// module adds no new public config export to the plugin-SDK surface. +type SshVerifyConfigObject = Exclude, boolean>; + +export type NodePairingSshVerifyPolicy = { + user: string; + identity?: string; + timeoutMs: number; + cidrs?: string[]; +}; + +export type NodePairingSshVerifyPlan = { + policy: NodePairingSshVerifyPolicy; + /** Normalized SSH target address (IPv4-mapped prefixes stripped). */ + host: string; +}; + +export type NodePairingSshVerifyOutcome = + | { ok: true; user: string; host: string } + | { ok: false; reason: "probe-failed" | "identity-unreadable" | "identity-mismatch" }; + +type NodeIdentityProbe = (params: NodeIdentityProbeParams) => Promise; + +const DEFAULT_TIMEOUT_MS = 7_000; +const FAILURE_COOLDOWN_MS = 60_000; +// A host that answered with a different key is a definitive negative (and a +// possible impersonation attempt); keep it off the probe path for longer. +const MISMATCH_COOLDOWN_MS = 5 * 60_000; +const MAX_CONCURRENT_PROBES = 4; +const MAX_COOLDOWN_ENTRIES = 512; + +function resolveProcessUser(): string | undefined { + try { + const user = os.userInfo().username.trim(); + if (user) { + return user; + } + } catch { + // Fall through to env-based resolution below. + } + const envUser = (process.env.USER ?? process.env.USERNAME)?.trim(); + return envUser || undefined; +} + +/** Normalize the enabled-by-default config union into a probe policy, or null when off. */ +export function resolveNodePairingSshVerifyPolicy( + raw: boolean | SshVerifyConfigObject | undefined, +): NodePairingSshVerifyPolicy | null { + if (raw === false) { + return null; + } + const cfg = typeof raw === "object" && raw !== null ? raw : {}; + const user = cfg.user?.trim() || resolveProcessUser(); + if (!user) { + return null; + } + const cidrs = cfg.cidrs?.map((entry) => entry.trim()).filter((entry) => entry.length > 0); + return { + user, + identity: cfg.identity?.trim() || undefined, + timeoutMs: + typeof cfg.timeoutMs === "number" && Number.isFinite(cfg.timeoutMs) && cfg.timeoutMs > 0 + ? cfg.timeoutMs + : DEFAULT_TIMEOUT_MS, + cidrs: cidrs && cidrs.length > 0 ? cidrs : undefined, + }; +} + +function normalizeProbeHost(reportedClientIp: string): string | null { + const trimmed = reportedClientIp.trim(); + const host = trimmed.toLowerCase().startsWith("::ffff:") ? trimmed.slice(7) : trimmed; + // Zone-scoped IPv6 literals (fe80::1%en0) are not portable SSH targets. + if (host.includes("%") || net.isIP(host) === 0) { + return null; + } + return host; +} + +/** + * Resolve whether this pairing request qualifies for an SSH verification probe. + * Shares the fresh-node eligibility floor with trusted-CIDR auto-approval and + * additionally bounds the probe target: default private/CGNAT ranges only, so + * a token holder cannot use the gateway as an SSH probe primitive against + * arbitrary public addresses. + */ +export function planNodePairingSshVerify(params: { + config: boolean | SshVerifyConfigObject | undefined; + eligibility: FreshNodePairingEligibilityParams; +}): NodePairingSshVerifyPlan | null { + const policy = resolveNodePairingSshVerifyPolicy(params.config); + if (!policy) { + return null; + } + if ( + !isEligibleFreshNodePairingRequest(params.eligibility) || + !params.eligibility.reportedClientIp + ) { + return null; + } + // Loopback is excluded from the default scope: same-host nodes already pair + // silently, and an SSH probe back to the gateway itself proves nothing. + const inProbeScope = policy.cidrs + ? isTrustedProxyAddress(params.eligibility.reportedClientIp, policy.cidrs) + : isPrivateOrLoopbackAddress(params.eligibility.reportedClientIp) && + !isLoopbackAddress(params.eligibility.reportedClientIp); + if (!inProbeScope) { + return null; + } + const host = normalizeProbeHost(params.eligibility.reportedClientIp); + return host ? { policy, host } : null; +} + +type ParsedRemoteIdentity = { deviceId: string; publicKey: string }; + +// `sh -l` may print profile/motd noise around the JSON payload; scan for the +// first line that parses into the expected identity shape. +function parseRemoteIdentity(stdout: string): ParsedRemoteIdentity | null { + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) { + continue; + } + try { + const parsed = JSON.parse(trimmed) as { deviceId?: unknown; publicKey?: unknown }; + if (typeof parsed.deviceId === "string" && typeof parsed.publicKey === "string") { + return { deviceId: parsed.deviceId.trim(), publicKey: parsed.publicKey.trim() }; + } + } catch { + // Not the identity line; keep scanning. + } + } + return null; +} + +// Probe bookkeeping is process-local and bounded: single-flight per +// host+device, short cooldown after failures so reconnect loops do not +// re-probe every few seconds, and a small global concurrency cap so a token +// holder cannot fan out SSH probes through the gateway. +const inFlightByKey = new Map>(); +const cooldownExpiryByKey = new Map(); + +function probeKey(host: string, deviceId: string): string { + return `${host}\0${deviceId}`; +} + +function pruneCooldowns(nowMs: number) { + for (const [key, expiry] of cooldownExpiryByKey) { + if (expiry <= nowMs) { + cooldownExpiryByKey.delete(key); + } + } + while (cooldownExpiryByKey.size > MAX_COOLDOWN_ENTRIES) { + const oldest = cooldownExpiryByKey.keys().next().value; + if (oldest === undefined) { + break; + } + cooldownExpiryByKey.delete(oldest); + } +} + +/** Test-only reset for the process-local probe bookkeeping. */ +export function resetNodePairingSshVerifyStateForTests() { + inFlightByKey.clear(); + cooldownExpiryByKey.clear(); +} + +/** + * Start an SSH verification for one pending pairing request. Returns null when + * the probe should not run right now (cooldown or concurrency cap) so callers + * keep the default manual-approval reconnect behavior. A reconnect that lands + * while the probe is still running gets `alreadyInFlight: true`: the client + * must keep its retry hint, but only the connection that started the probe + * owns the approval work. + */ +export function startNodePairingSshVerify(params: { + plan: NodePairingSshVerifyPlan; + expectedDeviceId: string; + expectedPublicKey: string; + probe?: NodeIdentityProbe; + nowMs?: number; +}): { done: Promise; alreadyInFlight: boolean } | null { + const nowMs = params.nowMs ?? Date.now(); + pruneCooldowns(nowMs); + const key = probeKey(params.plan.host, params.expectedDeviceId); + const inFlight = inFlightByKey.get(key); + if (inFlight) { + return { done: inFlight, alreadyInFlight: true }; + } + if ((cooldownExpiryByKey.get(key) ?? 0) > nowMs) { + return null; + } + if (inFlightByKey.size >= MAX_CONCURRENT_PROBES) { + return null; + } + + const probe = params.probe ?? runNodeIdentityProbe; + const done = (async (): Promise => { + const result = await probe({ + user: params.plan.policy.user, + host: params.plan.host, + identity: params.plan.policy.identity, + timeoutMs: params.plan.policy.timeoutMs, + }); + if (result.status !== "ok") { + cooldownExpiryByKey.set(key, Date.now() + FAILURE_COOLDOWN_MS); + return { ok: false, reason: "probe-failed" }; + } + const remote = parseRemoteIdentity(result.stdout); + if (!remote) { + cooldownExpiryByKey.set(key, Date.now() + FAILURE_COOLDOWN_MS); + return { ok: false, reason: "identity-unreadable" }; + } + const expectedKey = normalizeDevicePublicKeyBase64Url(params.expectedPublicKey); + const remoteKey = normalizeDevicePublicKeyBase64Url(remote.publicKey); + const matches = + Boolean(expectedKey) && + expectedKey === remoteKey && + remote.deviceId === params.expectedDeviceId; + if (!matches) { + cooldownExpiryByKey.set(key, Date.now() + MISMATCH_COOLDOWN_MS); + return { ok: false, reason: "identity-mismatch" }; + } + return { ok: true, user: params.plan.policy.user, host: params.plan.host }; + })(); + + const tracked = done.finally(() => { + inFlightByKey.delete(key); + }); + inFlightByKey.set(key, tracked); + return { done: tracked, alreadyInFlight: false }; +} diff --git a/src/gateway/server.node-pairing-auto-approve.test.ts b/src/gateway/server.node-pairing-auto-approve.test.ts index e1cfbd183941..d9296fa5a481 100644 --- a/src/gateway/server.node-pairing-auto-approve.test.ts +++ b/src/gateway/server.node-pairing-auto-approve.test.ts @@ -1,131 +1,26 @@ // Node pairing auto-approve tests cover LAN self-connect detection, token auth, // node identity persistence, and auto-approved pairing state. -import net from "node:net"; import { describe, expect, test } from "vitest"; -import { WebSocket } from "ws"; import { writeConfigFile } from "../config/config.js"; import { getPairedDevice, listDevicePairing } from "../infra/device-pairing.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; -import { loadDeviceIdentity } from "./device-authz.test-helpers.js"; -import { pickPrimaryLanIPv4 } from "./net.js"; -import { - connectReq, - installGatewayTestHooks, - startServer, - trackConnectChallengeNonce, -} from "./test-helpers.js"; +import { installGatewayTestHooks } from "./test-helpers.js"; +import { withLanNodePairingAttempt } from "./test-helpers.lan-pairing.js"; installGatewayTestHooks({ scope: "suite" }); -const TOKEN = "secret"; -const NODE_CLIENT = { - id: GATEWAY_CLIENT_NAMES.NODE_HOST, - version: "1.0.0", - platform: "ios", - mode: GATEWAY_CLIENT_MODES.NODE, -}; - -async function openLanGatewayWs(params: { host: string; port: number }): Promise { - const ws = new WebSocket(`ws://${params.host}:${params.port}`, { - localAddress: params.host, - }); - trackConnectChallengeNonce(ws); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000); - const cleanup = () => { - clearTimeout(timer); - ws.off("open", onOpen); - ws.off("error", onError); - }; - const onOpen = () => { - cleanup(); - resolve(); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - ws.once("open", onOpen); - ws.once("error", onError); - }); - return ws; -} - -async function canUseLanSelfConnect(host: string): Promise { - return await new Promise((resolve) => { - let settled = false; - let client: net.Socket | undefined; - const server = net.createServer((socket) => { - socket.on("error", () => {}); - socket.end("ok"); - }); - const done = (ok: boolean) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - client?.destroy(); - server.close(() => resolve(ok)); - }; - const timer = setTimeout(() => done(false), 1_000); - server.once("error", () => done(false)); - server.listen(0, "0.0.0.0", () => { - const address = server.address(); - if (!address || typeof address === "string") { - done(false); - return; - } - let sawData = false; - client = net.connect({ host, port: address.port, localAddress: host }); - client.on("data", () => { - sawData = true; - }); - client.once("error", () => done(false)); - client.once("close", () => done(sawData)); - }); - }); -} - -async function withLanNodePairingAttempt( - identityName: string, - beforeStart: (lanIp: string) => Promise, - run: (params: { - res: Awaited>; - loaded: ReturnType; - }) => Promise, -): Promise { - const lanIp = pickPrimaryLanIPv4(); - if (!lanIp || !(await canUseLanSelfConnect(lanIp))) { - return; - } - await beforeStart(lanIp); - const started = await startServer(TOKEN, { bind: "lan", controlUiEnabled: false }); - let ws: WebSocket | undefined; - try { - const loaded = loadDeviceIdentity(identityName); - ws = await openLanGatewayWs({ host: lanIp, port: started.port }); - const res = await connectReq(ws, { - token: TOKEN, - role: "node", - scopes: [], - client: NODE_CLIENT, - deviceIdentityPath: loaded.identityPath, - }); - await run({ res, loaded }); - } finally { - ws?.close(); - await started.server.close(); - started.envSnapshot.restore(); - } -} - describe("gateway trusted CIDR node pairing auto-approve", () => { test("stays disabled by default for a direct non-loopback node", async () => { - await withLanNodePairingAttempt( - "trusted-cidr-default-off", - async () => {}, - async ({ res, loaded }) => { + await withLanNodePairingAttempt({ + identityName: "trusted-cidr-default-off", + beforeStart: async () => { + // Pin SSH verification off so this case exercises the CIDR default + // without spawning a real ssh probe to the runner's own LAN IP. + await writeConfigFile({ + gateway: { nodes: { pairing: { sshVerify: false } } }, + }); + }, + run: async ({ loaded, connectNode }) => { + const res = await connectNode(); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("pairing required"); const pending = (await listDevicePairing()).pending.filter( @@ -135,13 +30,13 @@ describe("gateway trusted CIDR node pairing auto-approve", () => { expect(pending[0]?.silent).toBe(false); expect(await getPairedDevice(loaded.identity.deviceId)).toBeNull(); }, - ); + }); }); test("auto-approves first-time node pairing from a matching direct non-loopback CIDR", async () => { - await withLanNodePairingAttempt( - "trusted-cidr-direct-lan-auto-approve", - async (lanIp) => { + await withLanNodePairingAttempt({ + identityName: "trusted-cidr-direct-lan-auto-approve", + beforeStart: async (lanIp) => { await writeConfigFile({ gateway: { nodes: { @@ -152,7 +47,8 @@ describe("gateway trusted CIDR node pairing auto-approve", () => { }, }); }, - async ({ res, loaded }) => { + run: async ({ loaded, connectNode }) => { + const res = await connectNode(); expect(res.ok).toBe(true); expect((res.payload as { type?: unknown } | undefined)?.type).toBe("hello-ok"); const pending = (await listDevicePairing()).pending.filter( @@ -163,6 +59,6 @@ describe("gateway trusted CIDR node pairing auto-approve", () => { expect(paired?.role).toBe("node"); expect(paired?.approvedScopes ?? []).toStrictEqual([]); }, - ); + }); }); }); diff --git a/src/gateway/server.node-pairing-ssh-verify.test.ts b/src/gateway/server.node-pairing-ssh-verify.test.ts new file mode 100644 index 000000000000..82a9d0c23789 --- /dev/null +++ b/src/gateway/server.node-pairing-ssh-verify.test.ts @@ -0,0 +1,183 @@ +// SSH-verified node pairing e2e: real gateway server on the LAN self-connect +// harness, with the SSH probe runtime mocked at the module boundary. +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { writeConfigFile } from "../config/config.js"; +import { + getPairedDevice, + listDevicePairing, + requestDevicePairing, +} from "../infra/device-pairing.js"; +import { resetNodePairingSshVerifyStateForTests } from "./node-pairing-ssh-verify.js"; +import type { + NodeIdentityProbeParams, + NodeIdentityProbeResult, +} from "./node-pairing-ssh-verify.runtime.js"; +import { installGatewayTestHooks } from "./test-helpers.js"; +import { withLanNodePairingAttempt } from "./test-helpers.lan-pairing.js"; + +const probeMock = vi.hoisted(() => + vi.fn<(params: NodeIdentityProbeParams) => Promise>(), +); + +vi.mock("./node-pairing-ssh-verify.runtime.js", () => ({ + runNodeIdentityProbe: (params: NodeIdentityProbeParams) => probeMock(params), +})); + +installGatewayTestHooks({ scope: "suite" }); + +async function waitFor( + fn: () => Promise, + what: string, + timeoutMs = 8_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await fn(); + if (value !== null && value !== undefined) { + return value; + } + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + } + throw new Error(`timed out waiting for ${what}`); +} + +type PairingRequiredDetails = { + code?: string; + recommendedNextStep?: string; + pauseReconnect?: boolean; +}; + +describe("gateway ssh-verified node pairing auto-approve", () => { + beforeEach(() => { + probeMock.mockReset(); + resetNodePairingSshVerifyStateForTests(); + }); + + test("approves device pairing and the first capability surface on a key match", async () => { + await withLanNodePairingAttempt({ + identityName: "ssh-verify-key-match", + run: async ({ lanIp, loaded, connectNode }) => { + probeMock.mockImplementation(async () => ({ + status: "ok", + stdout: `motd noise\n{"deviceId":"${loaded.identity.deviceId}","publicKey":"${loaded.publicKey}"}\n`, + })); + + const first = await connectNode(); + expect(first.ok).toBe(false); + const details = first.error?.details as PairingRequiredDetails | undefined; + // The node must keep retrying while the detached probe can still land. + expect(details?.recommendedNextStep).toBe("wait_then_retry"); + expect(details?.pauseReconnect).toBe(false); + + const paired = await waitFor(async () => { + const record = await getPairedDevice(loaded.identity.deviceId); + // Wait for the ssh-verified provenance specifically: the approval + // and its read-back must survive the SQLite round-trip. + return record?.approvedVia === "ssh-verified" ? record : null; + }, "ssh-verified device approval"); + expect(paired.approvedVia).toBe("ssh-verified"); + expect(paired.publicKey).toBe(loaded.publicKey); + expect(probeMock).toHaveBeenCalledWith(expect.objectContaining({ host: lanIp })); + + const second = await connectNode(); + expect(second.ok).toBe(true); + expect((second.payload as { type?: unknown } | undefined)?.type).toBe("hello-ok"); + + // The first capability surface rides on the same machine-ownership + // proof: approved without a node.pair prompt. + const record = await getPairedDevice(loaded.identity.deviceId); + expect(record?.nodeSurface).toBeDefined(); + expect(record?.pendingNodeSurface).toBeUndefined(); + }, + }); + }); + + test("does not ssh-approve a pending request that carries scopes from an earlier attempt", async () => { + await withLanNodePairingAttempt({ + identityName: "ssh-verify-scoped-refresh", + run: async ({ loaded, connectNode }) => { + // Seed a scoped pending request (as an earlier interactive attempt + // would). The scopeless reconnect below refreshes this same request in + // place, so approving it would smuggle the scope past the fresh + // scopeless boundary. A matching probe would approve if reached. + await requestDevicePairing({ + deviceId: loaded.identity.deviceId, + publicKey: loaded.publicKey, + role: "node", + roles: ["node"], + scopes: ["node.exec"], + }); + probeMock.mockImplementation(async () => ({ + status: "ok", + stdout: `{"deviceId":"${loaded.identity.deviceId}","publicKey":"${loaded.publicKey}"}\n`, + })); + + const res = await connectNode(); + expect(res.ok).toBe(false); + + // The scoped pending request disqualifies ssh-verify entirely: no probe + // runs and the device is never auto-approved. + await new Promise((resolve) => { + setTimeout(resolve, 250); + }); + expect(probeMock).not.toHaveBeenCalled(); + expect(await getPairedDevice(loaded.identity.deviceId)).toBeNull(); + }, + }); + }); + + test("leaves the pairing pending when the remote identity does not match", async () => { + await withLanNodePairingAttempt({ + identityName: "ssh-verify-key-mismatch", + run: async ({ loaded, connectNode }) => { + // A different key than the pending request: assembled from words so the + // fixture is not a high-entropy blob (keeps review bundlers happy). + const wrongKey = ["not", "the", "expected", "device", "key"].join("-"); + probeMock.mockImplementation(async () => ({ + status: "ok", + stdout: `{"deviceId":"${loaded.identity.deviceId}","publicKey":"${wrongKey}"}\n`, + })); + + const res = await connectNode(); + expect(res.ok).toBe(false); + + await waitFor( + async () => (probeMock.mock.calls.length > 0 ? true : undefined), + "probe execution", + ); + // Give the detached approval path time to (incorrectly) land before + // asserting it did not. + await new Promise((resolve) => { + setTimeout(resolve, 250); + }); + expect(await getPairedDevice(loaded.identity.deviceId)).toBeNull(); + const pending = (await listDevicePairing()).pending.filter( + (entry) => entry.deviceId === loaded.identity.deviceId, + ); + expect(pending).toHaveLength(1); + }, + }); + }); + + test("sshVerify: false disables the probe and keeps default reconnect pause behavior", async () => { + await withLanNodePairingAttempt({ + identityName: "ssh-verify-disabled", + beforeStart: async () => { + await writeConfigFile({ + gateway: { nodes: { pairing: { sshVerify: false } } }, + }); + }, + run: async ({ loaded, connectNode }) => { + const res = await connectNode(); + expect(res.ok).toBe(false); + const details = res.error?.details as PairingRequiredDetails | undefined; + expect(details?.recommendedNextStep).toBeUndefined(); + expect(details?.pauseReconnect).toBeUndefined(); + expect(probeMock).not.toHaveBeenCalled(); + expect(await getPairedDevice(loaded.identity.deviceId)).toBeNull(); + }, + }); + }); +}); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index c7b7557c3806..d996061e73fa 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -78,6 +78,7 @@ import { runWithDiagnosticTraceContext, } from "../../../infra/diagnostic-trace-context.js"; import { + approveNodePairing, beginNodePairingConnect, finalizeNodePairingCleanupClaim, releaseNodePairingCleanupClaim, @@ -121,7 +122,7 @@ import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js" import { listControlUiPluginTabs } from "../../control-ui-plugin-tabs.js"; import { normalizeDeviceMetadataForAuth } from "../../device-auth.js"; import { pruneSupersededSilentPairingsAfterApproval } from "../../device-pairing-prune.js"; -import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../../method-scopes.js"; +import { ADMIN_SCOPE, APPROVALS_SCOPE, PAIRING_SCOPE, WRITE_SCOPE } from "../../method-scopes.js"; import type { GatewayMethodRegistry } from "../../methods/registry.js"; import { isLocalishHost, @@ -135,6 +136,10 @@ import { resolveNodePairingClientIpSource, shouldAutoApproveNodePairingFromTrustedCidrs, } from "../../node-pairing-auto-approve.js"; +import { + planNodePairingSshVerify, + startNodePairingSshVerify, +} from "../../node-pairing-ssh-verify.js"; import type { NodeReapprovalCoordinator } from "../../node-reapproval-coordinator.js"; import { isOperatorApprovalRuntimeToken } from "../../operator-approval-runtime-token.js"; import { checkBrowserOrigin } from "../../origin-check.js"; @@ -1629,6 +1634,97 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar } else if (pairing.created) { context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true }); } + // SSH-verified auto-approval: for eligible fresh node pairings the + // gateway reads the device identity back over SSH from the + // connecting host and approves on a key match. Runs detached: this + // connection still closes with pairing-required, and the node's + // retry loop picks up the approval. + let sshVerifyStarted = false; + // Gate on the request actually being approved, not just this + // connect's params: requestDevicePairing can refresh an older + // pending request in place (incomingApprovalCoveredByExisting), so a + // device could seed a scoped pending request, then reconnect + // scopeless from an SSH-verifiable host. SSH auto-approval must stay + // limited to a fresh node request that carries no roles/scopes + // beyond node. + const pendingReq = pairing.request; + const pendingIsFreshScopelessNode = + (pendingReq.scopes ?? []).length === 0 && + (pendingReq.role === undefined || pendingReq.role === "node") && + (pendingReq.roles ?? []).every((pendingRole) => pendingRole === "node"); + if (pairing.request.silent !== true && pendingIsFreshScopelessNode) { + const sshVerifyPlan = planNodePairingSshVerify({ + config: configSnapshot.gateway?.nodes?.pairing?.sshVerify, + eligibility: { + existingPairedDevice: Boolean(existingPairedDevice), + role, + reason, + scopes, + hasBrowserOriginHeader, + isControlUi, + isWebchat, + reportedClientIpSource, + reportedClientIp, + }, + }); + const sshVerify = sshVerifyPlan + ? startNodePairingSshVerify({ + plan: sshVerifyPlan, + expectedDeviceId: device.id, + expectedPublicKey: devicePublicKey, + }) + : null; + // A reconnect during an in-flight probe keeps the retry hint + // below but must not attach a second approval tail. + if (sshVerifyPlan && sshVerify) { + sshVerifyStarted = true; + } + if (sshVerifyPlan && sshVerify && !sshVerify.alreadyInFlight) { + const pendingRequestId = pairing.request.requestId; + runDetachedConnectWork( + async () => { + const outcome = await sshVerify.done; + if (!outcome.ok) { + logGateway.info( + `node pairing ssh-verify did not approve device=${device.id} host=${sshVerifyPlan.host} reason=${outcome.reason}`, + ); + return; + } + // Approving the pending requestId keeps this race-safe: a + // superseded or owner-resolved request simply returns null. + const approvedBySsh = await approveDevicePairing(pendingRequestId, { + callerScopes: scopes, + accessMetadata: clientAccessMetadata, + approvedVia: "ssh-verified", + }); + if (approvedBySsh?.status !== "approved") { + logGateway.info( + `node pairing ssh-verify approval skipped device=${device.id} (request superseded or already resolved)`, + ); + return; + } + logGateway.info( + `security audit: device pairing ssh-verified auto-approve device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} sshUser=${outcome.user} client=${connectParams.client.id} conn=${connId}`, + ); + buildRequestContext().broadcast( + "device.pair.resolved", + { + requestId: pendingRequestId, + deviceId: device.id, + decision: "approved", + ts: Date.now(), + }, + { dropIfSlow: true }, + ); + }, + (error) => { + logGateway.warn( + `node pairing ssh-verify failed device=${device.id}: ${String(error)}`, + ); + }, + ); + } + } // Re-resolve: another connection may have superseded/approved the request since we created it recoveryRequestId = await resolveLivePendingRequestId(); if ( @@ -1650,10 +1746,15 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar role === "node" && scopes.length === 0 && !existingPairedDevice; + // Keep the node retrying while a detached approval can still land + // (bootstrap redemption or a running ssh-verify probe); default + // pairing-required behavior pauses the client reconnect loop. + const retryWhileDetachedApprovalPending = + retryAfterBootstrapPairingApproval || sshVerifyStarted; const pairingErrorDetails = buildPairingConnectErrorDetails({ reason, requestId: recoveryRequestId, - ...(retryAfterBootstrapPairingApproval + ...(retryWhileDetachedApprovalPending ? { recommendedNextStep: "wait_then_retry", retryable: true, @@ -1909,6 +2010,24 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar const nodePairingSnapshot = await beginNodePairingConnect(nodeId); const pairedNode = nodePairingSnapshot.pairedNode; pendingNodePairingCleanup = nodePairingSnapshot.cleanupClaim; + // Re-read the device record: how device pairing was approved decides + // whether the first capability surface may be marked silent. + const pairedDeviceForSurface = + device && devicePublicKey ? await getPairedDevice(device.id) : null; + const deviceApprovedVia = + pairedDeviceForSurface?.publicKey === devicePublicKey + ? pairedDeviceForSurface?.approvedVia + : undefined; + // Only device approvals that carry a proof stronger than network + // origin may hint silent capability approval: "silent" (same-host + // local), "ssh-verified" (device-key match over SSH), "bootstrap" + // (owner setup code). "trusted-cidr" proves only that the device came + // from an allowed network, which must not silently approve its + // command/capability surface. + const deviceApprovedNonInteractively = + deviceApprovedVia === "silent" || + deviceApprovedVia === "ssh-verified" || + deviceApprovedVia === "bootstrap"; let reconciliation: Awaited>; try { reconciliation = await reconcileNodePairingOnConnect({ @@ -1916,6 +2035,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar connectParams, pairedNode, reportedClientIp, + initialSurfaceSilent: deviceApprovedNonInteractively, requestPairing: async (input) => { return await requestNodePairingFromConnect({ input, @@ -1940,6 +2060,43 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar } throw error; } + // The ssh-verify key match already proved this node runs under the + // operator's account on a machine they own, which is the same claim + // a manual capability approval asserts; approve the first declared + // surface directly. Surface upgrades still prompt. + if ( + deviceApprovedVia === "ssh-verified" && + !pairedNode && + reconciliation.pendingPairing + ) { + const surfaceRequestId = reconciliation.pendingPairing.request.requestId; + const approvedSurface = await approveNodePairing(surfaceRequestId, { + callerScopes: [ADMIN_SCOPE, PAIRING_SCOPE, WRITE_SCOPE], + }); + if (approvedSurface && "node" in approvedSurface) { + logGateway.info( + `security audit: node capability surface ssh-verified auto-approve node=${reconciliation.nodeId} commands=${reconciliation.declaredCommands.join(",") || ""}`, + ); + buildRequestContext().broadcast( + "node.pair.resolved", + { + requestId: surfaceRequestId, + nodeId: reconciliation.nodeId, + decision: "approved", + ts: Date.now(), + }, + { dropIfSlow: true }, + ); + reconciliation = { + ...reconciliation, + effectiveCaps: reconciliation.declaredCaps, + effectiveCommands: reconciliation.declaredCommands, + effectivePermissions: reconciliation.declaredPermissions, + pendingPairing: undefined, + shouldClearPendingPairings: true, + }; + } + } if (!reconciliation.shouldClearPendingPairings) { await releasePendingNodePairingCleanup(); } diff --git a/src/gateway/test-helpers.lan-pairing.ts b/src/gateway/test-helpers.lan-pairing.ts new file mode 100644 index 000000000000..c28b79c377b3 --- /dev/null +++ b/src/gateway/test-helpers.lan-pairing.ts @@ -0,0 +1,137 @@ +// Shared LAN self-connect harness for node pairing auto-approval suites. +// Tests bind the gateway to the primary LAN IPv4 and connect from that same +// address so the server observes a direct non-loopback client IP. Skips +// silently on hosts without a usable LAN interface. +import net from "node:net"; +import { WebSocket } from "ws"; +import { getPairedDevice, removePairedDevice } from "../infra/device-pairing.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { loadDeviceIdentity } from "./device-authz.test-helpers.js"; +import { pickPrimaryLanIPv4 } from "./net.js"; +import { connectReq, startServer, trackConnectChallengeNonce } from "./test-helpers.js"; + +export const LAN_NODE_PAIRING_TOKEN = "secret"; + +const NODE_CLIENT = { + id: GATEWAY_CLIENT_NAMES.NODE_HOST, + version: "1.0.0", + platform: "ios", + mode: GATEWAY_CLIENT_MODES.NODE, +}; + +async function openLanGatewayWs(params: { host: string; port: number }): Promise { + const ws = new WebSocket(`ws://${params.host}:${params.port}`, { + localAddress: params.host, + }); + trackConnectChallengeNonce(ws); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000); + const cleanup = () => { + clearTimeout(timer); + ws.off("open", onOpen); + ws.off("error", onError); + }; + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + ws.once("open", onOpen); + ws.once("error", onError); + }); + return ws; +} + +async function canUseLanSelfConnect(host: string): Promise { + return await new Promise((resolve) => { + let settled = false; + let client: net.Socket | undefined; + const server = net.createServer((socket) => { + socket.on("error", () => {}); + socket.end("ok"); + }); + const done = (ok: boolean) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + client?.destroy(); + server.close(() => resolve(ok)); + }; + const timer = setTimeout(() => done(false), 1_000); + server.once("error", () => done(false)); + server.listen(0, "0.0.0.0", () => { + const address = server.address(); + if (!address || typeof address === "string") { + done(false); + return; + } + let sawData = false; + client = net.connect({ host, port: address.port, localAddress: host }); + client.on("data", () => { + sawData = true; + }); + client.once("error", () => done(false)); + client.once("close", () => done(sawData)); + }); + }); +} + +export type LanNodePairingContext = { + lanIp: string; + loaded: ReturnType; + /** Open a fresh LAN WebSocket, run the node connect handshake, close it. */ + connectNode: () => Promise>>; +}; + +export async function withLanNodePairingAttempt(params: { + identityName: string; + beforeStart?: (lanIp: string) => Promise; + run: (ctx: LanNodePairingContext) => Promise; +}): Promise { + const lanIp = pickPrimaryLanIPv4(); + if (!lanIp || !(await canUseLanSelfConnect(lanIp))) { + return; + } + await params.beforeStart?.(lanIp); + const started = await startServer(LAN_NODE_PAIRING_TOKEN, { + bind: "lan", + controlUiEnabled: false, + }); + const openSockets: WebSocket[] = []; + try { + const loaded = loadDeviceIdentity(params.identityName); + // The suite shares one state home under --isolate=false; drop any paired + // record for this identity so a sibling suite's row cannot mask a fresh + // pairing (a stale approvedVia would survive a re-approve by design). + if (await getPairedDevice(loaded.identity.deviceId)) { + await removePairedDevice(loaded.identity.deviceId); + } + const connectNode = async () => { + const ws = await openLanGatewayWs({ host: lanIp, port: started.port }); + openSockets.push(ws); + try { + return await connectReq(ws, { + token: LAN_NODE_PAIRING_TOKEN, + role: "node", + scopes: [], + client: NODE_CLIENT, + deviceIdentityPath: loaded.identityPath, + }); + } finally { + ws.close(); + } + }; + await params.run({ lanIp, loaded, connectNode }); + } finally { + for (const ws of openSockets) { + ws.close(); + } + await started.server.close(); + started.envSnapshot.restore(); + } +} diff --git a/src/infra/device-pairing-store.ts b/src/infra/device-pairing-store.ts index 059136c965c0..0169f98b65c0 100644 --- a/src/infra/device-pairing-store.ts +++ b/src/infra/device-pairing-store.ts @@ -39,12 +39,19 @@ export function resolveDevicePairingStateDbOptions(baseDir?: string): OpenClawSt return baseDir ? { env: { ...process.env, OPENCLAW_STATE_DIR: baseDir } } : {}; } -const APPROVAL_KINDS: readonly PairedDeviceApprovalKind[] = [ - "owner", - "silent", - "trusted-cidr", - "bootstrap", -]; +// Read-back allowlist for the approved_via column. The Record type forces +// every PairedDeviceApprovalKind to appear here at compile time: omit one and +// this object is a type error, instead of the stored provenance silently +// dropping to undefined on load (which mergeApprovalKind treats as a legacy +// record). Keep this in sync when adding an approval kind. +const APPROVAL_KIND_MEMBERS = { + owner: true, + silent: true, + "trusted-cidr": true, + "ssh-verified": true, + bootstrap: true, +} satisfies Record; +const APPROVAL_KINDS = new Set(Object.keys(APPROVAL_KIND_MEMBERS)); function toJsonColumn(value: unknown): string | null { return value === undefined ? null : JSON.stringify(value); @@ -134,9 +141,7 @@ function toPairedRow(device: PairedDevice): DevicePairingPaired { } function fromApprovedViaColumn(value: string | null): PairedDeviceApprovalKind | null { - return (APPROVAL_KINDS as readonly string[]).includes(value ?? "") - ? (value as PairedDeviceApprovalKind) - : null; + return value !== null && APPROVAL_KINDS.has(value) ? (value as PairedDeviceApprovalKind) : null; } function fromPairedRow(row: DevicePairingPaired): PairedDevice { diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 289941439358..7af2a05ffde9 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -777,7 +777,10 @@ export async function approveDevicePairing( options: { callerScopes?: readonly string[]; accessMetadata?: DevicePairingAccessMetadata; - approvedVia?: Extract; + approvedVia?: Extract< + PairedDeviceApprovalKind, + "owner" | "silent" | "trusted-cidr" | "ssh-verified" + >; }, baseDir?: string, ): Promise; @@ -787,7 +790,10 @@ export async function approveDevicePairing( | { callerScopes?: readonly string[]; accessMetadata?: DevicePairingAccessMetadata; - approvedVia?: Extract; + approvedVia?: Extract< + PairedDeviceApprovalKind, + "owner" | "silent" | "trusted-cidr" | "ssh-verified" + >; } | string, maybeBaseDir?: string, diff --git a/src/infra/device-pairing.types.ts b/src/infra/device-pairing.types.ts index c2d75756dd6f..0713a8a93bec 100644 --- a/src/infra/device-pairing.types.ts +++ b/src/infra/device-pairing.types.ts @@ -50,11 +50,17 @@ export type DeviceAuthToken = { * How the latest pairing approval was granted. "silent" is a same-host local * policy approval and the only prune-eligible kind: local clients re-pair * silently and cannot collide with another machine's records. "trusted-cidr" - * is also non-interactive but crosses hosts, so it is never pruned - * automatically (display metadata is not a machine identity). "owner" and - * "bootstrap" approvals required a user action and are never pruned. + * and "ssh-verified" are also non-interactive but cross hosts, so they are + * never pruned automatically (display metadata is not a machine identity). + * "owner" and "bootstrap" approvals required a user action and are never + * pruned. */ -export type PairedDeviceApprovalKind = "owner" | "silent" | "trusted-cidr" | "bootstrap"; +export type PairedDeviceApprovalKind = + | "owner" + | "silent" + | "trusted-cidr" + | "ssh-verified" + | "bootstrap"; /** * Approved node capability surface for a node-role device. Device pairing