diff --git a/CHANGELOG.md b/CHANGELOG.md index 48290d3389f..c64548aa5a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Slack/probe: keep `auth.test()` bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss. - Dashboard/chat UI: restore the `chat-new-messages` class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han. - Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`. +- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey. ## 2026.3.12 diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index e20aafab9b6..a8206f30b02 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -24,7 +24,23 @@ afterEach(async () => { }); describe("device bootstrap tokens", () => { - it("binds the first successful verification to a device identity", async () => { + it("accepts the first successful verification", async () => { + const baseDir = await createBaseDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-1", + publicKey: "pub-1", + role: "node", + scopes: ["node.invoke"], + baseDir, + }), + ).resolves.toEqual({ ok: true }); + }); + + it("rejects replay after the first successful verification", async () => { const baseDir = await createBaseDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); @@ -48,10 +64,10 @@ describe("device bootstrap tokens", () => { scopes: ["operator.read"], baseDir, }), - ).resolves.toEqual({ ok: true }); + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); }); - it("rejects reuse from a different device after binding", async () => { + it("rejects reuse from a different device after consumption", async () => { const baseDir = await createBaseDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index 9f763b50cb3..50a4e53ffd2 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -25,29 +25,6 @@ type DeviceBootstrapStateFile = Record; const withLock = createAsyncLock(); -function mergeRoles(existing: string[] | undefined, role: string): string[] { - const out = new Set(existing ?? []); - const trimmed = role.trim(); - if (trimmed) { - out.add(trimmed); - } - return [...out]; -} - -function mergeScopes( - existing: string[] | undefined, - scopes: readonly string[], -): string[] | undefined { - const out = new Set(existing ?? []); - for (const scope of scopes) { - const trimmed = scope.trim(); - if (trimmed) { - out.add(trimmed); - } - } - return out.size > 0 ? [...out] : undefined; -} - function resolveBootstrapPath(baseDir?: string): string { return path.join(resolvePairingPaths(baseDir, "devices").dir, "bootstrap.json"); } @@ -116,19 +93,9 @@ export async function verifyDeviceBootstrapToken(params: { return { ok: false, reason: "bootstrap_token_invalid" }; } - if (entry.deviceId && entry.deviceId !== deviceId) { - return { ok: false, reason: "bootstrap_token_invalid" }; - } - if (entry.publicKey && entry.publicKey !== publicKey) { - return { ok: false, reason: "bootstrap_token_invalid" }; - } - - entry.deviceId = deviceId; - entry.publicKey = publicKey; - entry.roles = mergeRoles(entry.roles, role); - entry.scopes = mergeScopes(entry.scopes, params.scopes); - entry.lastUsedAtMs = Date.now(); - state[entry.token] = entry; + // Bootstrap setup codes are single-use. Consume the record before returning + // success so the same token cannot be replayed to mutate a pending request. + delete state[entry.token]; await persistState(state, params.baseDir); return { ok: true }; }); diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 17f03df089a..ddf0826d048 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; +import { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } from "./device-bootstrap.js"; import { approveDevicePairing, clearDevicePairing, @@ -146,6 +147,49 @@ describe("device pairing tokens", () => { expect(paired?.scopes).toEqual(["operator.read", "operator.write"]); }); + test("rejects bootstrap token replay before pending scope escalation can be approved", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.read"], + baseDir, + }), + ).resolves.toEqual({ ok: true }); + + const first = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.read"], + }, + baseDir, + ); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.admin"], + baseDir, + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + + await approveDevicePairing(first.request.requestId, baseDir); + const paired = await getPairedDevice("device-1", baseDir); + expect(paired?.scopes).toEqual(["operator.read"]); + expect(paired?.approvedScopes).toEqual(["operator.read"]); + expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]); + }); + test("generates base64url device tokens with 256-bit entropy output length", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); await setupPairedOperatorDevice(baseDir, ["operator.admin"]);