From c71fb8cda07c0ff5d4831cc1b4ffcbeecbd52463 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 14 Mar 2026 20:04:15 -0700 Subject: [PATCH] Device pairing: bind setup codes to node approvals --- extensions/device-pair/index.ts | 7 +++- src/infra/device-bootstrap.test.ts | 56 ++++++++++++++++++++++++++++++ src/infra/device-bootstrap.ts | 27 +++++++++++++- src/pairing/setup-code.ts | 2 ++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 7ba88842a7a..1987e865ab7 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -407,7 +407,12 @@ export default function register(api: OpenClawPluginApi) { const payload: SetupPayload = { url: urlResult.url, - bootstrapToken: (await issueDeviceBootstrapToken()).token, + bootstrapToken: ( + await issueDeviceBootstrapToken({ + role: "node", + scopes: [], + }) + ).token, }; if (action === "qr") { diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 45fef0a8d84..8337a68cf87 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -43,6 +43,22 @@ describe("device bootstrap tokens", () => { }); }); + it("persists an intended role and scopes when requested", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ + baseDir, + role: "node", + scopes: [], + }); + + const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8"); + const parsed = JSON.parse(raw) as Record; + expect(parsed[issued.token]).toMatchObject({ + roles: ["node"], + scopes: [], + }); + }); + it("verifies valid bootstrap tokens once and deletes them after success", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); @@ -201,4 +217,44 @@ describe("device bootstrap tokens", () => { }), ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); }); + + it("rejects a role that does not match the issued pairing profile", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ + baseDir, + role: "node", + scopes: [], + }); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-123", + publicKey: "public-key-123", + role: "operator", + scopes: [], + baseDir, + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + }); + + it("rejects scopes that do not match the issued pairing profile", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ + baseDir, + role: "node", + scopes: [], + }); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-123", + publicKey: "public-key-123", + role: "node", + scopes: ["operator.admin"], + baseDir, + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + }); }); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index d4d2d6ed526..ff8509555e7 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "../shared/device-auth.js"; import { resolvePairingPaths } from "./pairing-files.js"; import { createAsyncLock, @@ -63,16 +64,24 @@ async function persistState(state: DeviceBootstrapStateFile, baseDir?: string): export async function issueDeviceBootstrapToken( params: { baseDir?: string; + role?: string; + scopes?: readonly string[]; } = {}, ): Promise<{ token: string; expiresAtMs: number }> { return await withLock(async () => { const state = await loadState(params.baseDir); const token = generatePairingToken(); const issuedAtMs = Date.now(); + const role = params.role?.trim(); + const scopes = normalizeDeviceAuthScopes( + Array.isArray(params.scopes) ? [...params.scopes] : undefined, + ); state[token] = { token, ts: issuedAtMs, issuedAtMs, + ...(role ? { roles: [normalizeDeviceAuthRole(role)] } : {}), + ...(scopes.length > 0 || Array.isArray(params.scopes) ? { scopes } : {}), }; await persistState(state, params.baseDir); return { token, expiresAtMs: issuedAtMs + DEVICE_BOOTSTRAP_TOKEN_TTL_MS }; @@ -102,10 +111,26 @@ export async function verifyDeviceBootstrapToken(params: { const deviceId = params.deviceId.trim(); const publicKey = params.publicKey.trim(); - const role = params.role.trim(); + const role = normalizeDeviceAuthRole(params.role); + const requestedScopes = normalizeDeviceAuthScopes([...params.scopes]); if (!deviceId || !publicKey || !role) { return { ok: false, reason: "bootstrap_token_invalid" }; } + const allowedRoles = Array.isArray(entry.roles) + ? entry.roles.map((value) => normalizeDeviceAuthRole(String(value))).filter(Boolean) + : []; + if (allowedRoles.length > 0 && !allowedRoles.includes(role)) { + return { ok: false, reason: "bootstrap_token_invalid" }; + } + if (Array.isArray(entry.scopes)) { + const allowedScopes = normalizeDeviceAuthScopes(entry.scopes); + if ( + allowedScopes.length !== requestedScopes.length || + allowedScopes.some((value, index) => value !== requestedScopes[index]) + ) { + return { ok: false, reason: "bootstrap_token_invalid" }; + } + } // Bootstrap setup codes are single-use. Consume the record before returning // success so the same token cannot be replayed to mutate a pending request. diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index e241af8c5ed..701b63f21ec 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -400,6 +400,8 @@ export async function resolvePairingSetupFromConfig( bootstrapToken: ( await issueDeviceBootstrapToken({ baseDir: options.pairingBaseDir, + role: "node", + scopes: [], }) ).token, },