device-manifest.test.ts

9.8 KB · raw

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    // Flip one byte of the signature — must fail verification.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  // Pinned byte-for-byte against peach-android's canonicalDeviceManifestBody.140  // If either side reorders a key, renames a field, or changes number141  // formatting, this string moves and the two stop verifying each other's142  // signatures — the exact silent divergence the manifest version guards.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  // Android's manifestEntryMap only inserts wsPort / relayUrl when they are185  // non-null. Match that on the browser side so the manifest signed by186  // Android verifies against the canonical bytes the browser recomputes.187  // Without this, the wire would carry "wsPort":null and the browser would188  // include it in the canonical bytes while Android would omit it — the189  // two signatures would never agree even on a legitimate manifest.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    // Build a valid manifest first, then inject wsPort:null into the body258    // to simulate what Android puts on the wire (nullable Int serialised as259    // JSON null). The canonicaliser strips null, so the signature still260    // verifies — but the validator must not throw on the raw parsed body.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});