feat(gateway): auto-approve node pairing via SSH device-key verification (#104180)

This commit is contained in:
Peter Steinberger
2026-07-10 22:23:10 -07:00
committed by GitHub
parent dc42c893c3
commit 58b0ec9e50
30 changed files with 1515 additions and 157 deletions
+1
View File
@@ -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 <requestId>`
+18 -1
View File
@@ -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 <requestId>
```
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:
+1
View File
@@ -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`
+1
View File
@@ -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
+7
View File
@@ -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
+62 -8
View File
@@ -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.
+2
View File
@@ -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.
+3
View File
@@ -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"],
+71
View File
@@ -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<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
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));
});
});
+31
View File
@@ -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}`);
}
+1
View File
@@ -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(),
},
+9
View File
@@ -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)")
+3 -1
View File
@@ -640,9 +640,11 @@ export const FIELD_HELP: Record<string, string> = {
'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":
+1
View File
@@ -401,6 +401,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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",
+1
View File
@@ -58,6 +58,7 @@ const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
"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"],
+20
View File
@@ -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 = {
+13
View File
@@ -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(),
@@ -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");
+9
View File
@@ -98,6 +98,7 @@ function buildNodePairingRequestInput(params: {
commands: string[];
permissions?: Record<string, boolean>;
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<RequestNodePairingResult | null>;
}): Promise<NodeConnectPairingReconcileResult> {
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) {
+24 -6
View File
@@ -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;
}
@@ -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<NodeIdentityProbeResult> {
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<NodeIdentityProbeResult>((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 });
}
});
});
}
+296
View File
@@ -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>,
): 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<NodeIdentityProbeResult>((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);
});
});
+252
View File
@@ -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<NonNullable<GatewayNodePairingConfig["sshVerify"]>, 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<NodeIdentityProbeResult>;
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<string, Promise<NodePairingSshVerifyOutcome>>();
const cooldownExpiryByKey = new Map<string, number>();
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<NodePairingSshVerifyOutcome>; 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<NodePairingSshVerifyOutcome> => {
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 };
}
@@ -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<WebSocket> {
const ws = new WebSocket(`ws://${params.host}:${params.port}`, {
localAddress: params.host,
});
trackConnectChallengeNonce(ws);
await new Promise<void>((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<boolean> {
return await new Promise<boolean>((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<void>,
run: (params: {
res: Awaited<ReturnType<typeof connectReq>>;
loaded: ReturnType<typeof loadDeviceIdentity>;
}) => Promise<void>,
): Promise<void> {
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([]);
},
);
});
});
});
@@ -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<NodeIdentityProbeResult>>(),
);
vi.mock("./node-pairing-ssh-verify.runtime.js", () => ({
runNodeIdentityProbe: (params: NodeIdentityProbeParams) => probeMock(params),
}));
installGatewayTestHooks({ scope: "suite" });
async function waitFor<T>(
fn: () => Promise<T | null | undefined>,
what: string,
timeoutMs = 8_000,
): Promise<T> {
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();
},
});
});
});
@@ -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<ReturnType<typeof reconcileNodePairingOnConnect>>;
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(",") || "<none>"}`,
);
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();
}
+137
View File
@@ -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<WebSocket> {
const ws = new WebSocket(`ws://${params.host}:${params.port}`, {
localAddress: params.host,
});
trackConnectChallengeNonce(ws);
await new Promise<void>((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<boolean> {
return await new Promise<boolean>((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<typeof loadDeviceIdentity>;
/** Open a fresh LAN WebSocket, run the node connect handshake, close it. */
connectNode: () => Promise<Awaited<ReturnType<typeof connectReq>>>;
};
export async function withLanNodePairingAttempt(params: {
identityName: string;
beforeStart?: (lanIp: string) => Promise<void>;
run: (ctx: LanNodePairingContext) => Promise<void>;
}): Promise<void> {
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();
}
}
+14 -9
View File
@@ -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<PairedDeviceApprovalKind, true>;
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 {
+8 -2
View File
@@ -777,7 +777,10 @@ export async function approveDevicePairing(
options: {
callerScopes?: readonly string[];
accessMetadata?: DevicePairingAccessMetadata;
approvedVia?: Extract<PairedDeviceApprovalKind, "owner" | "silent" | "trusted-cidr">;
approvedVia?: Extract<
PairedDeviceApprovalKind,
"owner" | "silent" | "trusted-cidr" | "ssh-verified"
>;
},
baseDir?: string,
): Promise<ApproveDevicePairingResult>;
@@ -787,7 +790,10 @@ export async function approveDevicePairing(
| {
callerScopes?: readonly string[];
accessMetadata?: DevicePairingAccessMetadata;
approvedVia?: Extract<PairedDeviceApprovalKind, "owner" | "silent" | "trusted-cidr">;
approvedVia?: Extract<
PairedDeviceApprovalKind,
"owner" | "silent" | "trusted-cidr" | "ssh-verified"
>;
}
| string,
maybeBaseDir?: string,
+10 -4
View File
@@ -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