import { beforeAll, describe, expect, it } from "vitest"; import { buildSignedDeviceManifest, canonicalise, type DeviceManifestError, verifyDeviceManifest, } from "./device-manifest"; import { deriveSyncSigningKeypair } from "./legacy-identity-derivation.test-support"; import { DEVICE_MANIFEST_FRESHNESS_MS, DEVICE_MANIFEST_VERSION, DEVICE_REVOCATION_RECORD_VERSION, type ManifestDeviceEntry, } from "./sync-types"; const MASTER_KEY = new Uint8Array(32).fill(0xab); const ISSUER_DEVICE_ID = "11111111-1111-4111-8111-111111111111"; const PEER_DEVICE_ID = "22222222-2222-4222-8222-222222222222"; function entry(): ManifestDeviceEntry { return { deviceId: PEER_DEVICE_ID, deviceName: "June's Pixel 9", deviceType: "android", longTermPublicKeyHex: "a".repeat(64), signingPublicKeyHex: "b".repeat(64), relayUrl: "wss://relay.peachpasswords.com", pairedAt: 1_700_000_000_000, vouchDepth: 0, }; } describe("canonicalise", () => { it("sorts object keys deterministically", () => { const a = canonicalise({ b: 2, a: 1 }); const b = canonicalise({ a: 1, b: 2 }); expect(a).toBe(b); expect(a).toBe('{"a":1,"b":2}'); }); it("serialises nested arrays of objects in order", () => { expect(canonicalise([{ y: 2, x: 1 }, { z: 3 }])).toBe('[{"x":1,"y":2},{"z":3}]'); }); }); describe("device manifest sign+verify round trip", () => { let signingPrivateKey: CryptoKey; let signingPubkey: Uint8Array; beforeAll(async () => { const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID); signingPrivateKey = kp.privateKey; signingPubkey = kp.publicKeyRaw; }); it("derives a stable 32-byte Ed25519 public key", () => { expect(signingPubkey.length).toBe(32); }); it("signs and verifies a fresh manifest", async () => { const m = await buildSignedDeviceManifest( ISSUER_DEVICE_ID, [entry()], signingPrivateKey, ); expect(m.body.manifestVersion).toBe(DEVICE_MANIFEST_VERSION); expect(m.body.issuerDeviceId).toBe(ISSUER_DEVICE_ID); expect(m.signatureHex).toMatch(/^[0-9a-f]{128}$/); const body = await verifyDeviceManifest(m, signingPubkey); expect(body.devices).toHaveLength(1); expect(body.devices[0].deviceId).toBe(PEER_DEVICE_ID); }); it("rejects a stale manifest", async () => { const now = Date.now(); const m = await buildSignedDeviceManifest( ISSUER_DEVICE_ID, [entry()], signingPrivateKey, { now: now - DEVICE_MANIFEST_FRESHNESS_MS - 1_000 }, ); await expect(verifyDeviceManifest(m, signingPubkey, { now })).rejects.toMatchObject({ kind: "freshness", } as DeviceManifestError); }); it("rejects a forged signature", async () => { const m = await buildSignedDeviceManifest( ISSUER_DEVICE_ID, [entry()], signingPrivateKey, ); // Flip one byte of the signature — must fail verification. const sig = m.signatureHex; const flipped = sig.slice(0, 2) + ((parseInt(sig.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) + sig.slice(4); await expect( verifyDeviceManifest({ ...m, signatureHex: flipped }, signingPubkey), ).rejects.toMatchObject({ kind: "signature" } as DeviceManifestError); }); it("rejects a body tampered after signing", async () => { const m = await buildSignedDeviceManifest( ISSUER_DEVICE_ID, [entry()], signingPrivateKey, ); const tampered = { ...m, body: { ...m.body, devices: [{ ...entry(), deviceName: "different-name" }], }, }; await expect(verifyDeviceManifest(tampered, signingPubkey)).rejects.toMatchObject({ kind: "signature", } as DeviceManifestError); }); it("rejects an unknown manifest version", async () => { const m = await buildSignedDeviceManifest( ISSUER_DEVICE_ID, [entry()], signingPrivateKey, ); const future = { ...m, body: { ...m.body, manifestVersion: 0x99 } }; await expect(verifyDeviceManifest(future, signingPubkey)).rejects.toMatchObject({ kind: "version", } as DeviceManifestError); }); it("rejects oversized arrays before signature verification", async () => { const m = await buildSignedDeviceManifest( ISSUER_DEVICE_ID, [entry()], signingPrivateKey, ); const oversized = { ...m, body: { ...m.body, devices: new Array(257).fill(entry()) }, }; await expect(verifyDeviceManifest(oversized, signingPubkey)).rejects.toMatchObject({ kind: "malformed", } as DeviceManifestError); }); it("rejects unknown nested structures before canonicalization", async () => { const m = await buildSignedDeviceManifest( ISSUER_DEVICE_ID, [entry()], signingPrivateKey, ); const cycle: Record = {}; cycle.self = cycle; const body = { ...m.body, unexpected: cycle } as unknown as typeof m.body; await expect( verifyDeviceManifest({ ...m, body }, signingPubkey), ).rejects.toMatchObject({ kind: "malformed" } as DeviceManifestError); }); it("rejects invalid numeric ranges before signature verification", async () => { const m = await buildSignedDeviceManifest( ISSUER_DEVICE_ID, [entry()], signingPrivateKey, ); const outOfRange = { ...m, body: { ...m.body, devices: [{ ...entry(), wsPort: 65_536 }], }, }; await expect(verifyDeviceManifest(outOfRange, signingPubkey)).rejects.toMatchObject({ kind: "malformed", } as DeviceManifestError); }); it.each([ ["device name", { deviceName: "é".repeat(129) }], ["relay URL", { relayUrl: `wss://${"a".repeat(2_049)}.example` }], ])("rejects an over-budget UTF-8 %s before signature verification", async (_label, fields) => { const m = await buildSignedDeviceManifest( ISSUER_DEVICE_ID, [entry()], signingPrivateKey, ); const oversized = { ...m, body: { ...m.body, devices: [{ ...entry(), ...fields }], }, }; await expect(verifyDeviceManifest(oversized, signingPubkey)).rejects.toMatchObject({ kind: "malformed", } as DeviceManifestError); }); it("rejects oversized revocation identifiers", async () => { const m = await buildSignedDeviceManifest( ISSUER_DEVICE_ID, [entry()], signingPrivateKey, { revoked: [ { deviceId: "x".repeat(257), longTermPublicKeyHex: "c".repeat(64), revokedAt: Date.now(), revokedByDeviceId: ISSUER_DEVICE_ID, recordVersion: DEVICE_REVOCATION_RECORD_VERSION, }, ], }, ); await expect(verifyDeviceManifest(m, signingPubkey)).rejects.toMatchObject({ kind: "malformed", } as DeviceManifestError); }); it("rejects oversized revocation issuer identifiers", async () => { const m = await buildSignedDeviceManifest( ISSUER_DEVICE_ID, [entry()], signingPrivateKey, { revoked: [ { deviceId: PEER_DEVICE_ID, longTermPublicKeyHex: "c".repeat(64), revokedAt: Date.now(), revokedByDeviceId: "x".repeat(257), recordVersion: DEVICE_REVOCATION_RECORD_VERSION, }, ], }, ); await expect(verifyDeviceManifest(m, signingPubkey)).rejects.toMatchObject({ kind: "malformed", } as DeviceManifestError); }); it("derives the same keypair across calls (determinism)", async () => { const kp2 = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID); expect(Array.from(kp2.publicKeyRaw)).toEqual(Array.from(signingPubkey)); }); }); describe("manifest canonical form — cross-platform KAT", () => { // Pinned byte-for-byte against peach-android's // `canonicalDeviceManifestBody`. If either side reorders a key, renames a // field, or changes number formatting, this string moves and the two stop // verifying each other's signatures — the exact silent divergence the // manifest version guards. Keep this identical to the Android vector. const CANONICAL = '{"devices":[{"deviceId":"peer-1","deviceName":"Peer One","deviceType":"android",' + '"longTermPublicKeyHex":"' + "aa".repeat(32) + '","pairedAt":1700000000001,' + '"relayUrl":"wss://relay.peachpasswords.com",' + '"signingPublicKeyHex":"' + "bb".repeat(32) + '","vouchDepth":0,"wsPort":49142}],' + '"issuedAt":1700000000000,"issuerDeviceId":"issuer-1","manifestVersion":5,' + '"revoked":[{"deviceId":"gone-1","longTermPublicKeyHex":"' + "cc".repeat(32) + '",' + '"recordVersion":2,"revokedAt":1700000000002,"revokedByDeviceId":"issuer-1",' + '"signatureHex":""}]}'; it("canonicalises a manifest body carrying a revocation to the pinned string", () => { expect( canonicalise({ manifestVersion: DEVICE_MANIFEST_VERSION, issuerDeviceId: "issuer-1", issuedAt: 1_700_000_000_000, devices: [ { deviceId: "peer-1", deviceName: "Peer One", deviceType: "android", longTermPublicKeyHex: "aa".repeat(32), signingPublicKeyHex: "bb".repeat(32), pairedAt: 1_700_000_000_001, relayUrl: "wss://relay.peachpasswords.com", wsPort: 49142, vouchDepth: 0, }, ], revoked: [ { deviceId: "gone-1", longTermPublicKeyHex: "cc".repeat(32), recordVersion: DEVICE_REVOCATION_RECORD_VERSION, revokedAt: 1_700_000_000_002, revokedByDeviceId: "issuer-1", signatureHex: "", }, ], }), ).toBe(CANONICAL); }); }); describe("canonicalise — null fields are omitted (cross-platform parity)", () => { // Android's manifestEntryMap only inserts wsPort / relayUrl when they are // non-null. Match that on the browser side so the manifest signed by // Android verifies against the canonical bytes the browser recomputes. // Without this, the wire would carry "wsPort":null and the browser would // include it in the canonical bytes while Android would omit it — the // two signatures would never agree even on a legitimate manifest. it("omits null wsPort and null relayUrl at the device-entry level", () => { const a = canonicalise({ deviceId: "peer-1", deviceName: "Peer One", deviceType: "extension", longTermPublicKeyHex: "aa".repeat(32), signingPublicKeyHex: "bb".repeat(32), pairedAt: 1_700_000_000_000, wsPort: null, relayUrl: null, }); const b = canonicalise({ deviceId: "peer-1", deviceName: "Peer One", deviceType: "extension", longTermPublicKeyHex: "aa".repeat(32), signingPublicKeyHex: "bb".repeat(32), pairedAt: 1_700_000_000_000, }); expect(a).toBe(b); expect(a).not.toContain("wsPort"); expect(a).not.toContain("relayUrl"); }); it("keeps non-null wsPort and relayUrl", () => { const got = canonicalise({ deviceId: "peer-1", deviceName: "Peer One", deviceType: "extension", longTermPublicKeyHex: "aa".repeat(32), signingPublicKeyHex: "bb".repeat(32), pairedAt: 1_700_000_000_000, wsPort: 49142, relayUrl: "wss://relay.peachpasswords.com", }); expect(got).toContain('"wsPort":49142'); expect(got).toContain('"relayUrl":"wss://relay.peachpasswords.com"'); }); it("preserves wsPort:0 in canonical bytes (Android sends 0 as no-port sentinel)", () => { const got = canonicalise({ deviceId: "peer-1", deviceName: "Peer One", deviceType: "extension", longTermPublicKeyHex: "aa".repeat(32), signingPublicKeyHex: "bb".repeat(32), pairedAt: 1_700_000_000_000, wsPort: 0, }); expect(got).toContain('"wsPort":0'); }); }); describe("verifyDeviceManifest accepts wsPort:null", () => { it("does not reject wsPort:null on the wire (Android nullable Int → JSON null)", async () => { const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID); // Build a valid manifest first, then inject wsPort:null into the body // to simulate what Android puts on the wire (nullable Int serialised as // JSON null). The canonicaliser strips null, so the signature still // verifies — but the validator must not throw on the raw parsed body. const m = await buildSignedDeviceManifest(ISSUER_DEVICE_ID, [entry()], kp.privateKey); (m.body.devices[0] as unknown as Record).wsPort = null; const body = await verifyDeviceManifest(m, kp.publicKeyRaw); expect(body.devices).toHaveLength(1); }); }); describe("device name bound", () => { it("caps oversized device names during build/normalise", async () => { const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID); const m = await buildSignedDeviceManifest( ISSUER_DEVICE_ID, [{ ...entry(), deviceName: "D".repeat(200) }], kp.privateKey, ); const body = await verifyDeviceManifest(m, kp.publicKeyRaw); expect(body.devices[0].deviceName).toBe("D".repeat(64)); }); });