diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c778ebad1c..861d97cff61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,7 @@ Docs: https://docs.openclaw.ai - Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner. - Memory: switch default local embedding model to the QAT `embeddinggemma-300m-qat-Q8_0` variant for better quality at the same footprint. (#15429) Thanks @azade-c. - Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad. +- Security/Device pairing: generate 256-bit base64url device tokens and use byte-safe constant-time verification to avoid token-compare edge-case failures. ## 2026.2.12 diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 5604047265d..3fe0c0b8442 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -11,6 +11,25 @@ import { } from "./device-pairing.js"; describe("device pairing tokens", () => { + test("generates base64url device tokens with 256-bit entropy output length", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + const request = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.admin"], + }, + baseDir, + ); + await approveDevicePairing(request.request.requestId, baseDir); + + const paired = await getPairedDevice("device-1", baseDir); + const token = paired?.tokens?.operator?.token; + expect(token).toBeTruthy(); + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + test("preserves existing token scopes when rotating without scopes", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); const request = await requestDevicePairing( @@ -78,4 +97,31 @@ describe("device pairing tokens", () => { expect(mismatch.ok).toBe(false); expect(mismatch.reason).toBe("token-mismatch"); }); + + test("treats multibyte same-length token input as mismatch without throwing", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + const request = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.read"], + }, + baseDir, + ); + await approveDevicePairing(request.request.requestId, baseDir); + const paired = await getPairedDevice("device-1", baseDir); + const token = paired?.tokens?.operator?.token ?? ""; + + const mismatch = await verifyDeviceToken({ + deviceId: "device-1", + token: "é".repeat(token.length), + role: "operator", + scopes: ["operator.read"], + baseDir, + }); + + expect(mismatch.ok).toBe(false); + expect(mismatch.reason).toBe("token-mismatch"); + }); });