device-manifest.test.ts

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