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("derives the same keypair across calls (determinism)", async () => {135 const kp2 = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);136 expect(Array.from(kp2.publicKeyRaw)).toEqual(Array.from(signingPubkey));137 });138});139140describe("manifest canonical form — cross-platform KAT", () => {141 142 143 144 145 146 const CANONICAL =147 '{"devices":[{"deviceId":"peer-1","deviceName":"Peer One","deviceType":"android",' +148 '"longTermPublicKeyHex":"' + "aa".repeat(32) + '","pairedAt":1700000000001,' +149 '"relayUrl":"wss://relay.peachpasswords.com",' +150 '"signingPublicKeyHex":"' + "bb".repeat(32) + '","vouchDepth":0,"wsPort":49142}],' +151 '"issuedAt":1700000000000,"issuerDeviceId":"issuer-1","manifestVersion":5,' +152 '"revoked":[{"deviceId":"gone-1","longTermPublicKeyHex":"' + "cc".repeat(32) + '",' +153 '"recordVersion":2,"revokedAt":1700000000002,"revokedByDeviceId":"issuer-1",' +154 '"signatureHex":""}]}';155156 it("canonicalises a manifest body carrying a revocation to the pinned string", () => {157 expect(158 canonicalise({159 manifestVersion: DEVICE_MANIFEST_VERSION,160 issuerDeviceId: "issuer-1",161 issuedAt: 1_700_000_000_000,162 devices: [163 {164 deviceId: "peer-1",165 deviceName: "Peer One",166 deviceType: "android",167 longTermPublicKeyHex: "aa".repeat(32),168 signingPublicKeyHex: "bb".repeat(32),169 pairedAt: 1_700_000_000_001,170 relayUrl: "wss://relay.peachpasswords.com",171 wsPort: 49142,172 vouchDepth: 0,173 },174 ],175 revoked: [176 {177 deviceId: "gone-1",178 longTermPublicKeyHex: "cc".repeat(32),179 recordVersion: DEVICE_REVOCATION_RECORD_VERSION,180 revokedAt: 1_700_000_000_002,181 revokedByDeviceId: "issuer-1",182 signatureHex: "",183 },184 ],185 }),186 ).toBe(CANONICAL);187 });188});189190describe("canonicalise — null fields are omitted (cross-platform parity)", () => {191 192 193 194 195 196 197 it("omits null wsPort and null relayUrl at the device-entry level", () => {198 const a = canonicalise({199 deviceId: "peer-1",200 deviceName: "Peer One",201 deviceType: "extension",202 longTermPublicKeyHex: "aa".repeat(32),203 signingPublicKeyHex: "bb".repeat(32),204 pairedAt: 1_700_000_000_000,205 wsPort: null,206 relayUrl: null,207 });208 const b = canonicalise({209 deviceId: "peer-1",210 deviceName: "Peer One",211 deviceType: "extension",212 longTermPublicKeyHex: "aa".repeat(32),213 signingPublicKeyHex: "bb".repeat(32),214 pairedAt: 1_700_000_000_000,215 });216 expect(a).toBe(b);217 expect(a).not.toContain("wsPort");218 expect(a).not.toContain("relayUrl");219 });220221 it("keeps non-null wsPort and relayUrl", () => {222 const got = canonicalise({223 deviceId: "peer-1",224 deviceName: "Peer One",225 deviceType: "extension",226 longTermPublicKeyHex: "aa".repeat(32),227 signingPublicKeyHex: "bb".repeat(32),228 pairedAt: 1_700_000_000_000,229 wsPort: 49142,230 relayUrl: "wss://relay.peachpasswords.com",231 });232 expect(got).toContain('"wsPort":49142');233 expect(got).toContain('"relayUrl":"wss://relay.peachpasswords.com"');234 });235236 it("preserves wsPort:0 in canonical bytes (Android sends 0 as no-port sentinel)", () => {237 const got = canonicalise({238 deviceId: "peer-1",239 deviceName: "Peer One",240 deviceType: "extension",241 longTermPublicKeyHex: "aa".repeat(32),242 signingPublicKeyHex: "bb".repeat(32),243 pairedAt: 1_700_000_000_000,244 wsPort: 0,245 });246 expect(got).toContain('"wsPort":0');247 });248});249250describe("verifyDeviceManifest accepts wsPort:0", () => {251 it("does not reject wsPort:0 as malformed (Android Int default = no port)", async () => {252 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);253 const e: ManifestDeviceEntry = {254 ...entry(),255 wsPort: 0,256 };257 const m = await buildSignedDeviceManifest(ISSUER_DEVICE_ID, [e], kp.privateKey);258 const body = await verifyDeviceManifest(m, kp.publicKeyRaw);259 expect(body.devices[0].wsPort).toBe(0);260 });261262 it("does not reject wsPort:null on the wire (Android nullable Int → JSON null)", async () => {263 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);264 265 266 267 268 const m = await buildSignedDeviceManifest(ISSUER_DEVICE_ID, [entry()], kp.privateKey);269 (m.body.devices[0] as unknown as Record<string, unknown>).wsPort = null;270 const body = await verifyDeviceManifest(m, kp.publicKeyRaw);271 expect(body.devices).toHaveLength(1);272 });273});274275describe("device name bound", () => {276 it("caps oversized device names during build/normalise", async () => {277 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);278 const m = await buildSignedDeviceManifest(279 ISSUER_DEVICE_ID,280 [{ ...entry(), deviceName: "D".repeat(200) }],281 kp.privateKey,282 );283 const body = await verifyDeviceManifest(m, kp.publicKeyRaw);284 expect(body.devices[0].deviceName).toBe("D".repeat(64));285 });286});