device-manifest.test.ts

13.0 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  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    // Flip one byte of the signature — must fail verification.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("rejects oversized arrays before signature verification", async () => {135    const m = await buildSignedDeviceManifest(136      ISSUER_DEVICE_ID,137      [entry()],138      signingPrivateKey,139    );140    const oversized = {141      ...m,142      body: { ...m.body, devices: new Array(257).fill(entry()) },143    };144145    await expect(verifyDeviceManifest(oversized, signingPubkey)).rejects.toMatchObject({146      kind: "malformed",147    } as DeviceManifestError);148  });149150  it("rejects unknown nested structures before canonicalization", async () => {151    const m = await buildSignedDeviceManifest(152      ISSUER_DEVICE_ID,153      [entry()],154      signingPrivateKey,155    );156    const cycle: Record<string, unknown> = {};157    cycle.self = cycle;158    const body = { ...m.body, unexpected: cycle } as unknown as typeof m.body;159160    await expect(161      verifyDeviceManifest({ ...m, body }, signingPubkey),162    ).rejects.toMatchObject({ kind: "malformed" } as DeviceManifestError);163  });164165  it("rejects invalid numeric ranges before signature verification", async () => {166    const m = await buildSignedDeviceManifest(167      ISSUER_DEVICE_ID,168      [entry()],169      signingPrivateKey,170    );171    const outOfRange = {172      ...m,173      body: {174        ...m.body,175        devices: [{ ...entry(), wsPort: 65_536 }],176      },177    };178179    await expect(verifyDeviceManifest(outOfRange, signingPubkey)).rejects.toMatchObject({180      kind: "malformed",181    } as DeviceManifestError);182  });183184  it.each([185    ["device name", { deviceName: "é".repeat(129) }],186    ["relay URL", { relayUrl: `wss://${"a".repeat(2_049)}.example` }],187  ])("rejects an over-budget UTF-8 %s before signature verification", async (_label, fields) => {188    const m = await buildSignedDeviceManifest(189      ISSUER_DEVICE_ID,190      [entry()],191      signingPrivateKey,192    );193    const oversized = {194      ...m,195      body: {196        ...m.body,197        devices: [{ ...entry(), ...fields }],198      },199    };200201    await expect(verifyDeviceManifest(oversized, signingPubkey)).rejects.toMatchObject({202      kind: "malformed",203    } as DeviceManifestError);204  });205206  it("rejects oversized revocation identifiers", async () => {207    const m = await buildSignedDeviceManifest(208      ISSUER_DEVICE_ID,209      [entry()],210      signingPrivateKey,211      {212        revoked: [213          {214            deviceId: "x".repeat(257),215            longTermPublicKeyHex: "c".repeat(64),216            revokedAt: Date.now(),217            revokedByDeviceId: ISSUER_DEVICE_ID,218            recordVersion: DEVICE_REVOCATION_RECORD_VERSION,219          },220        ],221      },222    );223    await expect(verifyDeviceManifest(m, signingPubkey)).rejects.toMatchObject({224      kind: "malformed",225    } as DeviceManifestError);226  });227228  it("rejects oversized revocation issuer identifiers", async () => {229    const m = await buildSignedDeviceManifest(230      ISSUER_DEVICE_ID,231      [entry()],232      signingPrivateKey,233      {234        revoked: [235          {236            deviceId: PEER_DEVICE_ID,237            longTermPublicKeyHex: "c".repeat(64),238            revokedAt: Date.now(),239            revokedByDeviceId: "x".repeat(257),240            recordVersion: DEVICE_REVOCATION_RECORD_VERSION,241          },242        ],243      },244    );245    await expect(verifyDeviceManifest(m, signingPubkey)).rejects.toMatchObject({246      kind: "malformed",247    } as DeviceManifestError);248  });249  it("derives the same keypair across calls (determinism)", async () => {250    const kp2 = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);251    expect(Array.from(kp2.publicKeyRaw)).toEqual(Array.from(signingPubkey));252  });253});254255describe("manifest canonical form — cross-platform KAT", () => {256  // Pinned byte-for-byte against peach-android's257  // `canonicalDeviceManifestBody`. If either side reorders a key, renames a258  // field, or changes number formatting, this string moves and the two stop259  // verifying each other's signatures — the exact silent divergence the260  // manifest version guards. Keep this identical to the Android vector.261  const CANONICAL =262    '{"devices":[{"deviceId":"peer-1","deviceName":"Peer One","deviceType":"android",' +263    '"longTermPublicKeyHex":"' + "aa".repeat(32) + '","pairedAt":1700000000001,' +264    '"relayUrl":"wss://relay.peachpasswords.com",' +265    '"signingPublicKeyHex":"' + "bb".repeat(32) + '","vouchDepth":0,"wsPort":49142}],' +266    '"issuedAt":1700000000000,"issuerDeviceId":"issuer-1","manifestVersion":5,' +267    '"revoked":[{"deviceId":"gone-1","longTermPublicKeyHex":"' + "cc".repeat(32) + '",' +268    '"recordVersion":2,"revokedAt":1700000000002,"revokedByDeviceId":"issuer-1",' +269    '"signatureHex":""}]}';270271  it("canonicalises a manifest body carrying a revocation to the pinned string", () => {272    expect(273      canonicalise({274        manifestVersion: DEVICE_MANIFEST_VERSION,275        issuerDeviceId: "issuer-1",276        issuedAt: 1_700_000_000_000,277        devices: [278          {279            deviceId: "peer-1",280            deviceName: "Peer One",281            deviceType: "android",282            longTermPublicKeyHex: "aa".repeat(32),283            signingPublicKeyHex: "bb".repeat(32),284            pairedAt: 1_700_000_000_001,285            relayUrl: "wss://relay.peachpasswords.com",286            wsPort: 49142,287            vouchDepth: 0,288          },289        ],290        revoked: [291          {292            deviceId: "gone-1",293            longTermPublicKeyHex: "cc".repeat(32),294            recordVersion: DEVICE_REVOCATION_RECORD_VERSION,295            revokedAt: 1_700_000_000_002,296            revokedByDeviceId: "issuer-1",297            signatureHex: "",298          },299        ],300      }),301    ).toBe(CANONICAL);302  });303304});305describe("canonicalise — null fields are omitted (cross-platform parity)", () => {306  // Android's manifestEntryMap only inserts wsPort / relayUrl when they are307  // non-null. Match that on the browser side so the manifest signed by308  // Android verifies against the canonical bytes the browser recomputes.309  // Without this, the wire would carry "wsPort":null and the browser would310  // include it in the canonical bytes while Android would omit it — the311  // two signatures would never agree even on a legitimate manifest.312  it("omits null wsPort and null relayUrl at the device-entry level", () => {313    const a = canonicalise({314      deviceId: "peer-1",315      deviceName: "Peer One",316      deviceType: "extension",317      longTermPublicKeyHex: "aa".repeat(32),318      signingPublicKeyHex: "bb".repeat(32),319      pairedAt: 1_700_000_000_000,320      wsPort: null,321      relayUrl: null,322    });323    const b = canonicalise({324      deviceId: "peer-1",325      deviceName: "Peer One",326      deviceType: "extension",327      longTermPublicKeyHex: "aa".repeat(32),328      signingPublicKeyHex: "bb".repeat(32),329      pairedAt: 1_700_000_000_000,330    });331    expect(a).toBe(b);332    expect(a).not.toContain("wsPort");333    expect(a).not.toContain("relayUrl");334  });335336  it("keeps non-null wsPort and relayUrl", () => {337    const got = canonicalise({338      deviceId: "peer-1",339      deviceName: "Peer One",340      deviceType: "extension",341      longTermPublicKeyHex: "aa".repeat(32),342      signingPublicKeyHex: "bb".repeat(32),343      pairedAt: 1_700_000_000_000,344      wsPort: 49142,345      relayUrl: "wss://relay.peachpasswords.com",346    });347    expect(got).toContain('"wsPort":49142');348    expect(got).toContain('"relayUrl":"wss://relay.peachpasswords.com"');349  });350351  it("preserves wsPort:0 in canonical bytes (Android sends 0 as no-port sentinel)", () => {352    const got = canonicalise({353      deviceId: "peer-1",354      deviceName: "Peer One",355      deviceType: "extension",356      longTermPublicKeyHex: "aa".repeat(32),357      signingPublicKeyHex: "bb".repeat(32),358      pairedAt: 1_700_000_000_000,359      wsPort: 0,360    });361    expect(got).toContain('"wsPort":0');362  });363});364365describe("verifyDeviceManifest accepts wsPort:null", () => {366  it("does not reject wsPort:null on the wire (Android nullable Int → JSON null)", async () => {367    const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);368    // Build a valid manifest first, then inject wsPort:null into the body369    // to simulate what Android puts on the wire (nullable Int serialised as370    // JSON null). The canonicaliser strips null, so the signature still371    // verifies — but the validator must not throw on the raw parsed body.372    const m = await buildSignedDeviceManifest(ISSUER_DEVICE_ID, [entry()], kp.privateKey);373    (m.body.devices[0] as unknown as Record<string, unknown>).wsPort = null;374    const body = await verifyDeviceManifest(m, kp.publicKeyRaw);375    expect(body.devices).toHaveLength(1);376  });377});378379describe("device name bound", () => {380  it("caps oversized device names during build/normalise", async () => {381    const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);382    const m = await buildSignedDeviceManifest(383      ISSUER_DEVICE_ID,384      [{ ...entry(), deviceName: "D".repeat(200) }],385      kp.privateKey,386    );387    const body = await verifyDeviceManifest(m, kp.publicKeyRaw);388    expect(body.devices[0].deviceName).toBe("D".repeat(64));389  });390});