1import { beforeAll, describe, expect, it } from "vitest";2import {3 buildSignedDeviceManifest,4 canonicalise,5 type DeviceManifestError,6 verifyDeviceManifest,7} from "./device-manifest";8import { deriveSyncSigningKeypair } from "./legacy-identity-derivation.test-support";9import {10 DEVICE_MANIFEST_FRESHNESS_MS,11 DEVICE_MANIFEST_VERSION,12 DEVICE_REVOCATION_RECORD_VERSION,13 type ManifestDeviceEntry,14} from "./sync-types";1516const MASTER_KEY = new Uint8Array(32).fill(0xab);17const ISSUER_DEVICE_ID = "11111111-1111-4111-8111-111111111111";18const PEER_DEVICE_ID = "22222222-2222-4222-8222-222222222222";1920function entry(): ManifestDeviceEntry {21 return {22 deviceId: PEER_DEVICE_ID,23 deviceName: "June's Pixel 9",24 deviceType: "android",25 longTermPublicKeyHex: "a".repeat(64),26 signingPublicKeyHex: "b".repeat(64),27 relayUrl: "wss://relay.peachpasswords.com",28 pairedAt: 1_700_000_000_000,29 vouchDepth: 0,30 };31}3233describe("canonicalise", () => {34 it("sorts object keys deterministically", () => {35 const a = canonicalise({ b: 2, a: 1 });36 const b = canonicalise({ a: 1, b: 2 });37 expect(a).toBe(b);38 expect(a).toBe('{"a":1,"b":2}');39 });4041 it("serialises nested arrays of objects in order", () => {42 expect(canonicalise([{ y: 2, x: 1 }, { z: 3 }])).toBe('[{"x":1,"y":2},{"z":3}]');43 });44});4546describe("device manifest sign+verify round trip", () => {47 let signingPrivateKey: CryptoKey;48 let signingPubkey: Uint8Array;4950 beforeAll(async () => {51 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);52 signingPrivateKey = kp.privateKey;53 signingPubkey = kp.publicKeyRaw;54 });5556 it("derives a stable 32-byte Ed25519 public key", () => {57 expect(signingPubkey.length).toBe(32);58 });5960 it("signs and verifies a fresh manifest", async () => {61 const m = await buildSignedDeviceManifest(62 ISSUER_DEVICE_ID,63 [entry()],64 signingPrivateKey,65 );66 expect(m.body.manifestVersion).toBe(DEVICE_MANIFEST_VERSION);67 expect(m.body.issuerDeviceId).toBe(ISSUER_DEVICE_ID);68 expect(m.signatureHex).toMatch(/^[0-9a-f]{128}$/);69 const body = await verifyDeviceManifest(m, signingPubkey);70 expect(body.devices).toHaveLength(1);71 expect(body.devices[0].deviceId).toBe(PEER_DEVICE_ID);72 });7374 it("rejects a stale manifest", async () => {75 const now = Date.now();76 const m = await buildSignedDeviceManifest(77 ISSUER_DEVICE_ID,78 [entry()],79 signingPrivateKey,80 { now: now - DEVICE_MANIFEST_FRESHNESS_MS - 1_000 },81 );82 await expect(verifyDeviceManifest(m, signingPubkey, { now })).rejects.toMatchObject({83 kind: "freshness",84 } as DeviceManifestError);85 });8687 it("rejects a forged signature", async () => {88 const m = await buildSignedDeviceManifest(89 ISSUER_DEVICE_ID,90 [entry()],91 signingPrivateKey,92 );93 94 const sig = m.signatureHex;95 const flipped =96 sig.slice(0, 2) +97 ((parseInt(sig.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +98 sig.slice(4);99 await expect(100 verifyDeviceManifest({ ...m, signatureHex: flipped }, signingPubkey),101 ).rejects.toMatchObject({ kind: "signature" } as DeviceManifestError);102 });103104 it("rejects a body tampered after signing", async () => {105 const m = await buildSignedDeviceManifest(106 ISSUER_DEVICE_ID,107 [entry()],108 signingPrivateKey,109 );110 const tampered = {111 ...m,112 body: {113 ...m.body,114 devices: [{ ...entry(), deviceName: "different-name" }],115 },116 };117 await expect(verifyDeviceManifest(tampered, signingPubkey)).rejects.toMatchObject({118 kind: "signature",119 } as DeviceManifestError);120 });121122 it("rejects an unknown manifest version", async () => {123 const m = await buildSignedDeviceManifest(124 ISSUER_DEVICE_ID,125 [entry()],126 signingPrivateKey,127 );128 const future = { ...m, body: { ...m.body, manifestVersion: 0x99 } };129 await expect(verifyDeviceManifest(future, signingPubkey)).rejects.toMatchObject({130 kind: "version",131 } as DeviceManifestError);132 });133134 it("rejects oversized arrays before signature verification", async () => {135 const m = await buildSignedDeviceManifest(136 ISSUER_DEVICE_ID,137 [entry()],138 signingPrivateKey,139 );140 const oversized = {141 ...m,142 body: { ...m.body, devices: new Array(257).fill(entry()) },143 };144145 await expect(verifyDeviceManifest(oversized, signingPubkey)).rejects.toMatchObject({146 kind: "malformed",147 } as DeviceManifestError);148 });149150 it("rejects unknown nested structures before canonicalization", async () => {151 const m = await buildSignedDeviceManifest(152 ISSUER_DEVICE_ID,153 [entry()],154 signingPrivateKey,155 );156 const cycle: Record<string, unknown> = {};157 cycle.self = cycle;158 const body = { ...m.body, unexpected: cycle } as unknown as typeof m.body;159160 await expect(161 verifyDeviceManifest({ ...m, body }, signingPubkey),162 ).rejects.toMatchObject({ kind: "malformed" } as DeviceManifestError);163 });164165 it("rejects invalid numeric ranges before signature verification", async () => {166 const m = await buildSignedDeviceManifest(167 ISSUER_DEVICE_ID,168 [entry()],169 signingPrivateKey,170 );171 const outOfRange = {172 ...m,173 body: {174 ...m.body,175 devices: [{ ...entry(), wsPort: 65_536 }],176 },177 };178179 await expect(verifyDeviceManifest(outOfRange, signingPubkey)).rejects.toMatchObject({180 kind: "malformed",181 } as DeviceManifestError);182 });183184 it.each([185 ["device name", { deviceName: "é".repeat(129) }],186 ["relay URL", { relayUrl: `wss://${"a".repeat(2_049)}.example` }],187 ])("rejects an over-budget UTF-8 %s before signature verification", async (_label, fields) => {188 const m = await buildSignedDeviceManifest(189 ISSUER_DEVICE_ID,190 [entry()],191 signingPrivateKey,192 );193 const oversized = {194 ...m,195 body: {196 ...m.body,197 devices: [{ ...entry(), ...fields }],198 },199 };200201 await expect(verifyDeviceManifest(oversized, signingPubkey)).rejects.toMatchObject({202 kind: "malformed",203 } as DeviceManifestError);204 });205206 it("rejects oversized revocation identifiers", async () => {207 const m = await buildSignedDeviceManifest(208 ISSUER_DEVICE_ID,209 [entry()],210 signingPrivateKey,211 {212 revoked: [213 {214 deviceId: "x".repeat(257),215 longTermPublicKeyHex: "c".repeat(64),216 revokedAt: Date.now(),217 revokedByDeviceId: ISSUER_DEVICE_ID,218 recordVersion: DEVICE_REVOCATION_RECORD_VERSION,219 },220 ],221 },222 );223 await expect(verifyDeviceManifest(m, signingPubkey)).rejects.toMatchObject({224 kind: "malformed",225 } as DeviceManifestError);226 });227228 it("rejects oversized revocation issuer identifiers", async () => {229 const m = await buildSignedDeviceManifest(230 ISSUER_DEVICE_ID,231 [entry()],232 signingPrivateKey,233 {234 revoked: [235 {236 deviceId: PEER_DEVICE_ID,237 longTermPublicKeyHex: "c".repeat(64),238 revokedAt: Date.now(),239 revokedByDeviceId: "x".repeat(257),240 recordVersion: DEVICE_REVOCATION_RECORD_VERSION,241 },242 ],243 },244 );245 await expect(verifyDeviceManifest(m, signingPubkey)).rejects.toMatchObject({246 kind: "malformed",247 } as DeviceManifestError);248 });249 it("derives the same keypair across calls (determinism)", async () => {250 const kp2 = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);251 expect(Array.from(kp2.publicKeyRaw)).toEqual(Array.from(signingPubkey));252 });253});254255describe("manifest canonical form — cross-platform KAT", () => {256 257 258 259 260 261 const CANONICAL =262 '{"devices":[{"deviceId":"peer-1","deviceName":"Peer One","deviceType":"android",' +263 '"longTermPublicKeyHex":"' + "aa".repeat(32) + '","pairedAt":1700000000001,' +264 '"relayUrl":"wss://relay.peachpasswords.com",' +265 '"signingPublicKeyHex":"' + "bb".repeat(32) + '","vouchDepth":0,"wsPort":49142}],' +266 '"issuedAt":1700000000000,"issuerDeviceId":"issuer-1","manifestVersion":5,' +267 '"revoked":[{"deviceId":"gone-1","longTermPublicKeyHex":"' + "cc".repeat(32) + '",' +268 '"recordVersion":2,"revokedAt":1700000000002,"revokedByDeviceId":"issuer-1",' +269 '"signatureHex":""}]}';270271 it("canonicalises a manifest body carrying a revocation to the pinned string", () => {272 expect(273 canonicalise({274 manifestVersion: DEVICE_MANIFEST_VERSION,275 issuerDeviceId: "issuer-1",276 issuedAt: 1_700_000_000_000,277 devices: [278 {279 deviceId: "peer-1",280 deviceName: "Peer One",281 deviceType: "android",282 longTermPublicKeyHex: "aa".repeat(32),283 signingPublicKeyHex: "bb".repeat(32),284 pairedAt: 1_700_000_000_001,285 relayUrl: "wss://relay.peachpasswords.com",286 wsPort: 49142,287 vouchDepth: 0,288 },289 ],290 revoked: [291 {292 deviceId: "gone-1",293 longTermPublicKeyHex: "cc".repeat(32),294 recordVersion: DEVICE_REVOCATION_RECORD_VERSION,295 revokedAt: 1_700_000_000_002,296 revokedByDeviceId: "issuer-1",297 signatureHex: "",298 },299 ],300 }),301 ).toBe(CANONICAL);302 });303304});305describe("canonicalise — null fields are omitted (cross-platform parity)", () => {306 307 308 309 310 311 312 it("omits null wsPort and null relayUrl at the device-entry level", () => {313 const a = canonicalise({314 deviceId: "peer-1",315 deviceName: "Peer One",316 deviceType: "extension",317 longTermPublicKeyHex: "aa".repeat(32),318 signingPublicKeyHex: "bb".repeat(32),319 pairedAt: 1_700_000_000_000,320 wsPort: null,321 relayUrl: null,322 });323 const b = canonicalise({324 deviceId: "peer-1",325 deviceName: "Peer One",326 deviceType: "extension",327 longTermPublicKeyHex: "aa".repeat(32),328 signingPublicKeyHex: "bb".repeat(32),329 pairedAt: 1_700_000_000_000,330 });331 expect(a).toBe(b);332 expect(a).not.toContain("wsPort");333 expect(a).not.toContain("relayUrl");334 });335336 it("keeps non-null wsPort and relayUrl", () => {337 const got = canonicalise({338 deviceId: "peer-1",339 deviceName: "Peer One",340 deviceType: "extension",341 longTermPublicKeyHex: "aa".repeat(32),342 signingPublicKeyHex: "bb".repeat(32),343 pairedAt: 1_700_000_000_000,344 wsPort: 49142,345 relayUrl: "wss://relay.peachpasswords.com",346 });347 expect(got).toContain('"wsPort":49142');348 expect(got).toContain('"relayUrl":"wss://relay.peachpasswords.com"');349 });350351 it("preserves wsPort:0 in canonical bytes (Android sends 0 as no-port sentinel)", () => {352 const got = canonicalise({353 deviceId: "peer-1",354 deviceName: "Peer One",355 deviceType: "extension",356 longTermPublicKeyHex: "aa".repeat(32),357 signingPublicKeyHex: "bb".repeat(32),358 pairedAt: 1_700_000_000_000,359 wsPort: 0,360 });361 expect(got).toContain('"wsPort":0');362 });363});364365describe("verifyDeviceManifest accepts wsPort:null", () => {366 it("does not reject wsPort:null on the wire (Android nullable Int → JSON null)", async () => {367 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);368 369 370 371 372 const m = await buildSignedDeviceManifest(ISSUER_DEVICE_ID, [entry()], kp.privateKey);373 (m.body.devices[0] as unknown as Record<string, unknown>).wsPort = null;374 const body = await verifyDeviceManifest(m, kp.publicKeyRaw);375 expect(body.devices).toHaveLength(1);376 });377});378379describe("device name bound", () => {380 it("caps oversized device names during build/normalise", async () => {381 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);382 const m = await buildSignedDeviceManifest(383 ISSUER_DEVICE_ID,384 [{ ...entry(), deviceName: "D".repeat(200) }],385 kp.privateKey,386 );387 const body = await verifyDeviceManifest(m, kp.publicKeyRaw);388 expect(body.devices[0].deviceName).toBe("D".repeat(64));389 });390});