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 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 vouchDepth: 0,29 };30}3132describe("canonicalise", () => {33 it("sorts object keys deterministically", () => {34 const a = canonicalise({ b: 2, a: 1 });35 const b = canonicalise({ a: 1, b: 2 });36 expect(a).toBe(b);37 expect(a).toBe('{"a":1,"b":2}');38 });3940 it("serialises nested arrays of objects in order", () => {41 expect(canonicalise([{ y: 2, x: 1 }, { z: 3 }])).toBe('[{"x":1,"y":2},{"z":3}]');42 });43});4445describe("device manifest sign+verify round trip", () => {46 let signingPrivateKey: CryptoKey;47 let signingPubkey: Uint8Array;4849 beforeAll(async () => {50 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);51 signingPrivateKey = kp.privateKey;52 signingPubkey = kp.publicKeyRaw;53 });5455 it("derives a stable 32-byte Ed25519 public key", () => {56 expect(signingPubkey.length).toBe(32);57 });5859 it("signs and verifies a fresh manifest", async () => {60 const m = await buildSignedDeviceManifest(61 ISSUER_DEVICE_ID,62 [entry()],63 signingPrivateKey,64 );65 expect(m.body.manifestVersion).toBe(DEVICE_MANIFEST_VERSION);66 expect(m.body.issuerDeviceId).toBe(ISSUER_DEVICE_ID);67 expect(m.signatureHex).toMatch(/^[0-9a-f]{128}$/);68 const body = await verifyDeviceManifest(m, signingPubkey);69 expect(body.devices).toHaveLength(1);70 expect(body.devices[0].deviceId).toBe(PEER_DEVICE_ID);71 });7273 it("rejects a stale manifest", async () => {74 const now = Date.now();75 const m = await buildSignedDeviceManifest(76 ISSUER_DEVICE_ID,77 [entry()],78 signingPrivateKey,79 { now: now - DEVICE_MANIFEST_FRESHNESS_MS - 1_000 },80 );81 await expect(verifyDeviceManifest(m, signingPubkey, { now })).rejects.toMatchObject({82 kind: "freshness",83 } as DeviceManifestError);84 });8586 it("rejects a forged signature", async () => {87 const m = await buildSignedDeviceManifest(88 ISSUER_DEVICE_ID,89 [entry()],90 signingPrivateKey,91 );92 93 const sig = m.signatureHex;94 const flipped =95 sig.slice(0, 2) +96 ((parseInt(sig.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +97 sig.slice(4);98 await expect(99 verifyDeviceManifest({ ...m, signatureHex: flipped }, signingPubkey),100 ).rejects.toMatchObject({ kind: "signature" } as DeviceManifestError);101 });102103 it("rejects a body tampered after signing", async () => {104 const m = await buildSignedDeviceManifest(105 ISSUER_DEVICE_ID,106 [entry()],107 signingPrivateKey,108 );109 const tampered = {110 ...m,111 body: {112 ...m.body,113 devices: [{ ...entry(), deviceName: "different-name" }],114 },115 };116 await expect(verifyDeviceManifest(tampered, signingPubkey)).rejects.toMatchObject({117 kind: "signature",118 } as DeviceManifestError);119 });120121 it("rejects an unknown manifest version", async () => {122 const m = await buildSignedDeviceManifest(123 ISSUER_DEVICE_ID,124 [entry()],125 signingPrivateKey,126 );127 const future = { ...m, body: { ...m.body, manifestVersion: 0x99 } };128 await expect(verifyDeviceManifest(future, signingPubkey)).rejects.toMatchObject({129 kind: "version",130 } as DeviceManifestError);131 });132133 it("derives the same keypair across calls (determinism)", async () => {134 const kp2 = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);135 expect(Array.from(kp2.publicKeyRaw)).toEqual(Array.from(signingPubkey));136 });137});138139describe("manifest v0x03 canonical form — cross-platform KAT", () => {140 141 142 143 144 const CANONICAL =145 '{"devices":[{"deviceId":"peer-1","deviceName":"Peer One","deviceType":"android",' +146 '"longTermPublicKeyHex":"' + "aa".repeat(32) + '","pairedAt":1700000000001,' +147 '"relayUrl":"wss://relay.peachpasswords.com",' +148 '"signingPublicKeyHex":"' + "bb".repeat(32) + '","wsPort":49142}],' +149 '"issuedAt":1700000000000,"issuerDeviceId":"issuer-1","manifestVersion":3,' +150 '"revoked":[{"deviceId":"gone-1","longTermPublicKeyHex":"' + "cc".repeat(32) + '",' +151 '"revokedAt":1700000000002,"revokedByDeviceId":"issuer-1"}]}';152153 it("canonicalises a manifest body carrying a revocation to the pinned string", () => {154 expect(155 canonicalise({156 manifestVersion: 3,157 issuerDeviceId: "issuer-1",158 issuedAt: 1_700_000_000_000,159 devices: [160 {161 deviceId: "peer-1",162 deviceName: "Peer One",163 deviceType: "android",164 longTermPublicKeyHex: "aa".repeat(32),165 signingPublicKeyHex: "bb".repeat(32),166 pairedAt: 1_700_000_000_001,167 relayUrl: "wss://relay.peachpasswords.com",168 wsPort: 49142,169 },170 ],171 revoked: [172 {173 deviceId: "gone-1",174 longTermPublicKeyHex: "cc".repeat(32),175 revokedAt: 1_700_000_000_002,176 revokedByDeviceId: "issuer-1",177 },178 ],179 }),180 ).toBe(CANONICAL);181 });182});183184describe("canonicalise — null fields are omitted (cross-platform parity)", () => {185 186 187 188 189 190 191 it("omits null wsPort and null relayUrl at the device-entry level", () => {192 const a = canonicalise({193 deviceId: "peer-1",194 deviceName: "Peer One",195 deviceType: "extension",196 longTermPublicKeyHex: "aa".repeat(32),197 signingPublicKeyHex: "bb".repeat(32),198 pairedAt: 1_700_000_000_000,199 wsPort: null,200 relayUrl: null,201 });202 const b = canonicalise({203 deviceId: "peer-1",204 deviceName: "Peer One",205 deviceType: "extension",206 longTermPublicKeyHex: "aa".repeat(32),207 signingPublicKeyHex: "bb".repeat(32),208 pairedAt: 1_700_000_000_000,209 });210 expect(a).toBe(b);211 expect(a).not.toContain("wsPort");212 expect(a).not.toContain("relayUrl");213 });214215 it("keeps non-null wsPort and relayUrl", () => {216 const got = canonicalise({217 deviceId: "peer-1",218 deviceName: "Peer One",219 deviceType: "extension",220 longTermPublicKeyHex: "aa".repeat(32),221 signingPublicKeyHex: "bb".repeat(32),222 pairedAt: 1_700_000_000_000,223 wsPort: 49142,224 relayUrl: "wss://relay.peachpasswords.com",225 });226 expect(got).toContain('"wsPort":49142');227 expect(got).toContain('"relayUrl":"wss://relay.peachpasswords.com"');228 });229230 it("preserves wsPort:0 in canonical bytes (Android sends 0 as no-port sentinel)", () => {231 const got = canonicalise({232 deviceId: "peer-1",233 deviceName: "Peer One",234 deviceType: "extension",235 longTermPublicKeyHex: "aa".repeat(32),236 signingPublicKeyHex: "bb".repeat(32),237 pairedAt: 1_700_000_000_000,238 wsPort: 0,239 });240 expect(got).toContain('"wsPort":0');241 });242});243244describe("verifyDeviceManifest accepts wsPort:0", () => {245 it("does not reject wsPort:0 as malformed (Android Int default = no port)", async () => {246 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);247 const e: ManifestDeviceEntry = {248 ...entry(),249 wsPort: 0,250 };251 const m = await buildSignedDeviceManifest(ISSUER_DEVICE_ID, [e], kp.privateKey);252 const body = await verifyDeviceManifest(m, kp.publicKeyRaw);253 expect(body.devices[0].wsPort).toBe(0);254 });255256 it("does not reject wsPort:null on the wire (Android nullable Int → JSON null)", async () => {257 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);258 259 260 261 262 const m = await buildSignedDeviceManifest(ISSUER_DEVICE_ID, [entry()], kp.privateKey);263 (m.body.devices[0] as unknown as Record<string, unknown>).wsPort = null;264 const body = await verifyDeviceManifest(m, kp.publicKeyRaw);265 expect(body.devices).toHaveLength(1);266 });267});268269describe("device name bound", () => {270 it("caps oversized device names during build/normalise", async () => {271 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);272 const m = await buildSignedDeviceManifest(273 ISSUER_DEVICE_ID,274 [{ ...entry(), deviceName: "D".repeat(200) }],275 kp.privateKey,276 );277 const body = await verifyDeviceManifest(m, kp.publicKeyRaw);278 expect(body.devices[0].deviceName).toBe("D".repeat(64));279 });280});