diff --git a/CHANGELOG.md b/CHANGELOG.md index 895c9dd8ad4..3afc9213f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/pairing: bind iOS setup codes to the intended node profile and reject first-use bootstrap redemption that asks for broader roles or scopes. Thanks @tdjackey. - Web tools/Exa: align the bundled Exa plugin with the current Exa API by supporting newer search types and richer `contents` options, while fixing the result-count cap to honor Exa's higher limit. Thanks @vincentkoc. - Plugins/Matrix: move bundled plugin `KeyedAsyncQueue` imports onto the stable `plugin-sdk/core` surface so Matrix Docker/runtime builds do not depend on the brittle keyed-async-queue subpath. Thanks @ecohash-co and @vincentkoc. - Nostr/security: enforce inbound DM policy before decrypt, route Nostr DMs through the standard reply pipeline, and add pre-crypto rate and size guards so unknown senders cannot bypass pairing or force unbounded crypto work. Thanks @kuranikaran. diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 204e8c95100..31390d5c6e6 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -149,6 +149,10 @@ describe("device-pair /pair qr", () => { const text = requireText(result); expect(pluginApiMocks.renderQrPngBase64).toHaveBeenCalledTimes(1); + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({ + roles: ["node"], + scopes: [], + }); expect(text).toContain("Scan this QR code with the OpenClaw iOS app:"); expect(text).toContain("![OpenClaw pairing QR](data:image/png;base64,ZmFrZXBuZw==)"); expect(text).toContain("- Security: single-use bootstrap token"); diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 7b7bd541807..7d15f052781 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -43,6 +43,8 @@ function formatDurationMinutes(expiresAtMs: number): string { } const DEFAULT_GATEWAY_PORT = 18789; +const SETUP_CODE_ROLES = ["node"] as const; +const SETUP_CODE_SCOPES: string[] = []; type DevicePairPluginConfig = { publicUrl?: string; @@ -515,7 +517,10 @@ function resolveQrReplyTarget(ctx: QrCommandContext): string { } async function issueSetupPayload(url: string): Promise { - const issuedBootstrap = await issueDeviceBootstrapToken(); + const issuedBootstrap = await issueDeviceBootstrapToken({ + roles: SETUP_CODE_ROLES, + scopes: SETUP_CODE_SCOPES, + }); return { url, bootstrapToken: issuedBootstrap.token, diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 6136074d4b2..1f684c840ae 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -26,8 +26,8 @@ async function verifyBootstrapToken( token, deviceId: "device-123", publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], + role: "node", + scopes: [], baseDir, ...overrides, }); @@ -58,6 +58,8 @@ describe("device bootstrap tokens", () => { token: issued.token, ts: Date.now(), issuedAtMs: Date.now(), + roles: ["node"], + scopes: [], }); }); @@ -124,6 +126,8 @@ describe("device bootstrap tokens", () => { token: issued.token, ts: issuedAtMs, issuedAtMs, + roles: ["node"], + scopes: [], }, }, null, @@ -151,6 +155,37 @@ describe("device bootstrap tokens", () => { expect(raw).toContain(issued.token); }); + it("rejects bootstrap verification when role or scopes exceed the issued profile", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect( + verifyBootstrapToken(baseDir, issued.token, { + role: "operator", + scopes: ["operator.admin"], + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + + const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8"); + expect(raw).toContain(issued.token); + }); + + it("supports explicitly bound bootstrap profiles", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ + baseDir, + roles: ["operator"], + scopes: ["operator.read"], + }); + + await expect( + verifyBootstrapToken(baseDir, issued.token, { + role: "operator", + scopes: ["operator.read"], + }), + ).resolves.toEqual({ ok: true }); + }); + it("accepts trimmed bootstrap tokens and still consumes them once", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); @@ -176,8 +211,8 @@ describe("device bootstrap tokens", () => { token: "missing-token", deviceId: "device-123", publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], + role: "node", + scopes: [], baseDir, }), ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); @@ -200,7 +235,7 @@ describe("device bootstrap tokens", () => { expect(parsed[issued.token]?.token).toBe(issued.token); }); - it("accepts legacy records that only stored issuedAtMs and prunes expired tokens", async () => { + it("fails closed for unbound legacy records and prunes expired tokens", async () => { vi.useFakeTimers(); const baseDir = await createTempDir(); const bootstrapPath = resolveBootstrapPath(baseDir); @@ -226,7 +261,10 @@ describe("device bootstrap tokens", () => { "utf8", ); - await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({ ok: true }); + await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); await expect(verifyBootstrapToken(baseDir, "expiredToken")).resolves.toEqual({ ok: false, diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index 6a38c16d1ea..c91e7b6bec8 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; import { resolvePairingPaths } from "./pairing-files.js"; import { createAsyncLock, @@ -25,6 +26,27 @@ type DeviceBootstrapStateFile = Record; const withLock = createAsyncLock(); +function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] { + if (!Array.isArray(roles)) { + return []; + } + const out = new Set(); + for (const role of roles) { + const trimmed = role.trim(); + if (trimmed) { + out.add(trimmed); + } + } + return [...out].toSorted(); +} + +function sameStringSet(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) { + return false; + } + return left.every((value, index) => value === right[index]); +} + function resolveBootstrapPath(baseDir?: string): string { return path.join(resolvePairingPaths(baseDir, "devices").dir, "bootstrap.json"); } @@ -63,15 +85,21 @@ async function persistState(state: DeviceBootstrapStateFile, baseDir?: string): export async function issueDeviceBootstrapToken( params: { baseDir?: string; + roles?: readonly 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 roles = normalizeBootstrapRoles(params.roles ?? ["node"]); + const scopes = normalizeDeviceAuthScopes(params.scopes ? [...params.scopes] : []); state[token] = { token, ts: issuedAtMs, + roles, + scopes, issuedAtMs, }; await persistState(state, params.baseDir); @@ -134,7 +162,7 @@ export async function verifyDeviceBootstrapToken(params: { if (!found) { return { ok: false, reason: "bootstrap_token_invalid" }; } - const [tokenKey] = found; + const [tokenKey, record] = found; const deviceId = params.deviceId.trim(); const publicKey = params.publicKey.trim(); @@ -142,6 +170,19 @@ export async function verifyDeviceBootstrapToken(params: { if (!deviceId || !publicKey || !role) { return { ok: false, reason: "bootstrap_token_invalid" }; } + const requestedRoles = normalizeBootstrapRoles([role]); + const requestedScopes = normalizeDeviceAuthScopes([...params.scopes]); + const allowedRoles = normalizeBootstrapRoles(record.roles); + const allowedScopes = normalizeDeviceAuthScopes(record.scopes); + // Fail closed for unbound legacy setup codes and for any attempt to redeem + // the token outside the exact role/scope profile it was issued for. + if ( + allowedRoles.length === 0 || + !sameStringSet(requestedRoles, allowedRoles) || + !sameStringSet(requestedScopes, allowedScopes) + ) { + 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/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 34cb3f1ecf3..54a5cf5ea35 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -196,7 +196,11 @@ describe("device pairing tokens", () => { 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 }); + const issued = await issueDeviceBootstrapToken({ + baseDir, + roles: ["operator"], + scopes: ["operator.read"], + }); await expect( verifyDeviceBootstrapToken({ diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index a35e9c37867..f9f3c67b2f8 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -10,6 +10,7 @@ vi.mock("../infra/device-bootstrap.js", () => ({ let encodePairingSetupCode: typeof import("./setup-code.js").encodePairingSetupCode; let resolvePairingSetupFromConfig: typeof import("./setup-code.js").resolvePairingSetupFromConfig; +let issueDeviceBootstrapTokenMock: typeof import("../infra/device-bootstrap.js").issueDeviceBootstrapToken; describe("pairing setup code", () => { type ResolvedSetup = Awaited>; @@ -53,6 +54,12 @@ describe("pairing setup code", () => { } expect(resolved.authLabel).toBe(params.authLabel); expect(resolved.payload.bootstrapToken).toBe("bootstrap-123"); + expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith( + expect.objectContaining({ + roles: ["node"], + scopes: [], + }), + ); if (params.url) { expect(resolved.payload.url).toBe(params.url); } @@ -78,6 +85,9 @@ describe("pairing setup code", () => { beforeEach(async () => { ({ encodePairingSetupCode, resolvePairingSetupFromConfig } = await import("./setup-code.js")); + ({ issueDeviceBootstrapToken: issueDeviceBootstrapTokenMock } = + await import("../infra/device-bootstrap.js")); + vi.mocked(issueDeviceBootstrapTokenMock).mockClear(); }); afterEach(() => { diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index e3847211fa7..64c99a08498 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -22,6 +22,9 @@ export type PairingSetupPayload = { bootstrapToken: string; }; +const PAIRING_SETUP_BOOTSTRAP_ROLES = ["node"] as const; +const PAIRING_SETUP_BOOTSTRAP_SCOPES: string[] = []; + export type PairingSetupCommandResult = { code: number | null; stdout: string; @@ -384,6 +387,8 @@ export async function resolvePairingSetupFromConfig( bootstrapToken: ( await issueDeviceBootstrapToken({ baseDir: options.pairingBaseDir, + roles: PAIRING_SETUP_BOOTSTRAP_ROLES, + scopes: PAIRING_SETUP_BOOTSTRAP_SCOPES, }) ).token, },