diff --git a/src/infra/device-identity.test.ts b/src/infra/device-identity.test.ts new file mode 100644 index 00000000000..adcf77ab5e9 --- /dev/null +++ b/src/infra/device-identity.test.ts @@ -0,0 +1,61 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempDir } from "../test-utils/temp-dir.js"; +import { + deriveDeviceIdFromPublicKey, + loadOrCreateDeviceIdentity, + normalizeDevicePublicKeyBase64Url, + publicKeyRawBase64UrlFromPem, + signDevicePayload, + verifyDeviceSignature, +} from "./device-identity.js"; + +async function withIdentity( + run: (identity: ReturnType) => void, +) { + await withTempDir("openclaw-device-identity-", async (dir) => { + const identity = loadOrCreateDeviceIdentity(path.join(dir, "device.json")); + run(identity); + }); +} + +describe("device identity crypto helpers", () => { + it("derives the same canonical raw key and device id from pem and encoded public keys", async () => { + await withIdentity((identity) => { + const publicKeyRaw = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); + const paddedBase64 = `${publicKeyRaw.replaceAll("-", "+").replaceAll("_", "/")}==`; + + expect(normalizeDevicePublicKeyBase64Url(identity.publicKeyPem)).toBe(publicKeyRaw); + expect(normalizeDevicePublicKeyBase64Url(paddedBase64)).toBe(publicKeyRaw); + expect(deriveDeviceIdFromPublicKey(identity.publicKeyPem)).toBe(identity.deviceId); + expect(deriveDeviceIdFromPublicKey(publicKeyRaw)).toBe(identity.deviceId); + }); + }); + + it("signs payloads that verify against pem and raw public key forms", async () => { + await withIdentity((identity) => { + const payload = JSON.stringify({ + action: "system.run", + ts: 1234, + }); + const signature = signDevicePayload(identity.privateKeyPem, payload); + const publicKeyRaw = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); + + expect(verifyDeviceSignature(identity.publicKeyPem, payload, signature)).toBe(true); + expect(verifyDeviceSignature(publicKeyRaw, payload, signature)).toBe(true); + expect(verifyDeviceSignature(publicKeyRaw, `${payload}!`, signature)).toBe(false); + }); + }); + + it("fails closed for invalid public keys and signatures", async () => { + await withIdentity((identity) => { + const payload = "hello"; + const signature = signDevicePayload(identity.privateKeyPem, payload); + + expect(normalizeDevicePublicKeyBase64Url("-----BEGIN PUBLIC KEY-----broken")).toBeNull(); + expect(deriveDeviceIdFromPublicKey("%%%")).toBeNull(); + expect(verifyDeviceSignature("%%%invalid%%%", payload, signature)).toBe(false); + expect(verifyDeviceSignature(identity.publicKeyPem, payload, "%%%invalid%%%")).toBe(false); + }); + }); +}); diff --git a/src/infra/device-identity.ts b/src/infra/device-identity.ts index 1fa1659c659..f541e3b4093 100644 --- a/src/infra/device-identity.ts +++ b/src/infra/device-identity.ts @@ -134,6 +134,9 @@ export function normalizeDevicePublicKeyBase64Url(publicKey: string): string | n return base64UrlEncode(derivePublicKeyRaw(publicKey)); } const raw = base64UrlDecode(publicKey); + if (raw.length === 0) { + return null; + } return base64UrlEncode(raw); } catch { return null; @@ -145,6 +148,9 @@ export function deriveDeviceIdFromPublicKey(publicKey: string): string | null { const raw = publicKey.includes("BEGIN") ? derivePublicKeyRaw(publicKey) : base64UrlDecode(publicKey); + if (raw.length === 0) { + return null; + } return crypto.createHash("sha256").update(raw).digest("hex"); } catch { return null;