1import { beforeAll, describe, expect, it } from "vitest";2import {3 buildSignedDeviceManifest,4 canonicalise,5 type DeviceManifestError,6 verifyDeviceManifest,7} from "./device-manifest";8import { deriveSyncSigningKeypair } from "./sync-identity";9import {10 DEVICE_MANIFEST_FRESHNESS_MS,11 DEVICE_MANIFEST_VERSION,12 type ManifestDeviceEntry,13} from "./sync-types";1415const MASTER_KEY = new Uint8Array(32).fill(0xab);16const ISSUER_DEVICE_ID = "11111111-1111-4111-8111-111111111111";17const PEER_DEVICE_ID = "22222222-2222-4222-8222-222222222222";1819function entry(): ManifestDeviceEntry {20 return {21 deviceId: PEER_DEVICE_ID,22 deviceName: "June's Pixel 9",23 deviceType: "android",24 longTermPublicKeyHex: "a".repeat(64),25 signingPublicKeyHex: "b".repeat(64),26 relayUrl: "wss://relay.peachpasswords.com",27 pairedAt: 1_700_000_000_000,28 };29}3031describe("canonicalise", () => {32 it("sorts object keys deterministically", () => {33 const a = canonicalise({ b: 2, a: 1 });34 const b = canonicalise({ a: 1, b: 2 });35 expect(a).toBe(b);36 expect(a).toBe('{"a":1,"b":2}');37 });3839 it("serialises nested arrays of objects in order", () => {40 expect(canonicalise([{ y: 2, x: 1 }, { z: 3 }])).toBe('[{"x":1,"y":2},{"z":3}]');41 });42});4344describe("device manifest sign+verify round trip", () => {45 let signingPrivateKey: CryptoKey;46 let signingPubkey: Uint8Array;4748 beforeAll(async () => {49 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);50 signingPrivateKey = kp.privateKey;51 signingPubkey = kp.publicKeyRaw;52 });5354 it("derives a stable 32-byte Ed25519 public key", () => {55 expect(signingPubkey.length).toBe(32);56 });5758 it("signs and verifies a fresh manifest", async () => {59 const m = await buildSignedDeviceManifest(60 ISSUER_DEVICE_ID,61 [entry()],62 signingPrivateKey,63 );64 expect(m.body.manifestVersion).toBe(DEVICE_MANIFEST_VERSION);65 expect(m.body.issuerDeviceId).toBe(ISSUER_DEVICE_ID);66 expect(m.signatureHex).toMatch(/^[0-9a-f]{128}$/);67 const body = await verifyDeviceManifest(m, signingPubkey);68 expect(body.devices).toHaveLength(1);69 expect(body.devices[0].deviceId).toBe(PEER_DEVICE_ID);70 });7172 it("rejects a stale manifest", async () => {73 const now = Date.now();74 const m = await buildSignedDeviceManifest(75 ISSUER_DEVICE_ID,76 [entry()],77 signingPrivateKey,78 { now: now - DEVICE_MANIFEST_FRESHNESS_MS - 1_000 },79 );80 await expect(verifyDeviceManifest(m, signingPubkey, { now })).rejects.toMatchObject({81 kind: "freshness",82 } as DeviceManifestError);83 });8485 it("rejects a forged signature", async () => {86 const m = await buildSignedDeviceManifest(87 ISSUER_DEVICE_ID,88 [entry()],89 signingPrivateKey,90 );91 92 const sig = m.signatureHex;93 const flipped =94 sig.slice(0, 2) +95 ((parseInt(sig.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +96 sig.slice(4);97 await expect(98 verifyDeviceManifest({ ...m, signatureHex: flipped }, signingPubkey),99 ).rejects.toMatchObject({ kind: "signature" } as DeviceManifestError);100 });101102 it("rejects a body tampered after signing", async () => {103 const m = await buildSignedDeviceManifest(104 ISSUER_DEVICE_ID,105 [entry()],106 signingPrivateKey,107 );108 const tampered = {109 ...m,110 body: {111 ...m.body,112 devices: [{ ...entry(), deviceName: "different-name" }],113 },114 };115 await expect(verifyDeviceManifest(tampered, signingPubkey)).rejects.toMatchObject({116 kind: "signature",117 } as DeviceManifestError);118 });119120 it("rejects an unknown manifest version", async () => {121 const m = await buildSignedDeviceManifest(122 ISSUER_DEVICE_ID,123 [entry()],124 signingPrivateKey,125 );126 const future = { ...m, body: { ...m.body, manifestVersion: 0x99 } };127 await expect(verifyDeviceManifest(future, signingPubkey)).rejects.toMatchObject({128 kind: "version",129 } as DeviceManifestError);130 });131132 it("derives the same keypair across calls (determinism)", async () => {133 const kp2 = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);134 expect(Array.from(kp2.publicKeyRaw)).toEqual(Array.from(signingPubkey));135 });136});137138describe("manifest v0x03 canonical form — cross-platform KAT", () => {139 140 141 142 143 const CANONICAL =144 '{"devices":[{"deviceId":"peer-1","deviceName":"Peer One","deviceType":"android",' +145 '"longTermPublicKeyHex":"' + "aa".repeat(32) + '","pairedAt":1700000000001,' +146 '"relayUrl":"wss://relay.peachpasswords.com",' +147 '"signingPublicKeyHex":"' + "bb".repeat(32) + '","wsPort":49142}],' +148 '"issuedAt":1700000000000,"issuerDeviceId":"issuer-1","manifestVersion":3,' +149 '"revoked":[{"deviceId":"gone-1","longTermPublicKeyHex":"' + "cc".repeat(32) + '",' +150 '"revokedAt":1700000000002,"revokedByDeviceId":"issuer-1"}]}';151152 it("canonicalises a manifest body carrying a revocation to the pinned string", () => {153 expect(154 canonicalise({155 manifestVersion: 3,156 issuerDeviceId: "issuer-1",157 issuedAt: 1_700_000_000_000,158 devices: [159 {160 deviceId: "peer-1",161 deviceName: "Peer One",162 deviceType: "android",163 longTermPublicKeyHex: "aa".repeat(32),164 signingPublicKeyHex: "bb".repeat(32),165 pairedAt: 1_700_000_000_001,166 relayUrl: "wss://relay.peachpasswords.com",167 wsPort: 49142,168 },169 ],170 revoked: [171 {172 deviceId: "gone-1",173 longTermPublicKeyHex: "cc".repeat(32),174 revokedAt: 1_700_000_000_002,175 revokedByDeviceId: "issuer-1",176 },177 ],178 }),179 ).toBe(CANONICAL);180 });181});182183describe("canonicalise — null fields are omitted (cross-platform parity)", () => {184 185 186 187 188 189 190 it("omits null wsPort and null relayUrl at the device-entry level", () => {191 const a = canonicalise({192 deviceId: "peer-1",193 deviceName: "Peer One",194 deviceType: "extension",195 longTermPublicKeyHex: "aa".repeat(32),196 signingPublicKeyHex: "bb".repeat(32),197 pairedAt: 1_700_000_000_000,198 wsPort: null,199 relayUrl: null,200 });201 const b = canonicalise({202 deviceId: "peer-1",203 deviceName: "Peer One",204 deviceType: "extension",205 longTermPublicKeyHex: "aa".repeat(32),206 signingPublicKeyHex: "bb".repeat(32),207 pairedAt: 1_700_000_000_000,208 });209 expect(a).toBe(b);210 expect(a).not.toContain("wsPort");211 expect(a).not.toContain("relayUrl");212 });213214 it("keeps non-null wsPort and relayUrl", () => {215 const got = canonicalise({216 deviceId: "peer-1",217 deviceName: "Peer One",218 deviceType: "extension",219 longTermPublicKeyHex: "aa".repeat(32),220 signingPublicKeyHex: "bb".repeat(32),221 pairedAt: 1_700_000_000_000,222 wsPort: 49142,223 relayUrl: "wss://relay.peachpasswords.com",224 });225 expect(got).toContain('"wsPort":49142');226 expect(got).toContain('"relayUrl":"wss://relay.peachpasswords.com"');227 });228229 it("preserves wsPort:0 in canonical bytes (Android sends 0 as no-port sentinel)", () => {230 const got = canonicalise({231 deviceId: "peer-1",232 deviceName: "Peer One",233 deviceType: "extension",234 longTermPublicKeyHex: "aa".repeat(32),235 signingPublicKeyHex: "bb".repeat(32),236 pairedAt: 1_700_000_000_000,237 wsPort: 0,238 });239 expect(got).toContain('"wsPort":0');240 });241});242243describe("verifyDeviceManifest accepts wsPort:0", () => {244 it("does not reject wsPort:0 as malformed (Android Int default = no port)", async () => {245 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);246 const e: ManifestDeviceEntry = {247 ...entry(),248 wsPort: 0,249 };250 const m = await buildSignedDeviceManifest(ISSUER_DEVICE_ID, [e], kp.privateKey);251 const body = await verifyDeviceManifest(m, kp.publicKeyRaw);252 expect(body.devices[0].wsPort).toBe(0);253 });254255 it("does not reject wsPort:null on the wire (Android nullable Int → JSON null)", async () => {256 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);257 258 259 260 261 const m = await buildSignedDeviceManifest(ISSUER_DEVICE_ID, [entry()], kp.privateKey);262 (m.body.devices[0] as unknown as Record<string, unknown>).wsPort = null;263 const body = await verifyDeviceManifest(m, kp.publicKeyRaw);264 expect(body.devices).toHaveLength(1);265 });266});267268describe("device name bound", () => {269 it("caps oversized device names during build/normalise", async () => {270 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);271 const m = await buildSignedDeviceManifest(272 ISSUER_DEVICE_ID,273 [{ ...entry(), deviceName: "D".repeat(200) }],274 kp.privateKey,275 );276 const body = await verifyDeviceManifest(m, kp.publicKeyRaw);277 expect(body.devices[0].deviceName).toBe("D".repeat(64));278 });279});