device-manifest.test.ts

4.2 KB · raw

1import { beforeAll, describe, expect, it } from "vitest";2import {3  buildSignedDeviceManifest,4  canonicalise,5  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    pairedAt: 1_700_000_000_000,27  };28}2930describe("canonicalise", () => {31  it("sorts object keys deterministically", () => {32    const a = canonicalise({ b: 2, a: 1 });33    const b = canonicalise({ a: 1, b: 2 });34    expect(a).toBe(b);35    expect(a).toBe('{"a":1,"b":2}');36  });3738  it("serialises nested arrays of objects in order", () => {39    expect(canonicalise([{ y: 2, x: 1 }, { z: 3 }])).toBe('[{"x":1,"y":2},{"z":3}]');40  });41});4243describe("device manifest sign+verify round trip", () => {44  let signingPrivateKey: CryptoKey;45  let signingPubkey: Uint8Array;4647  beforeAll(async () => {48    const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);49    signingPrivateKey = kp.privateKey;50    signingPubkey = kp.publicKeyRaw;51  });5253  it("derives a stable 32-byte Ed25519 public key", () => {54    expect(signingPubkey.length).toBe(32);55  });5657  it("signs and verifies a fresh manifest", async () => {58    const m = await buildSignedDeviceManifest(59      ISSUER_DEVICE_ID,60      [entry()],61      signingPrivateKey,62    );63    expect(m.body.manifestVersion).toBe(DEVICE_MANIFEST_VERSION);64    expect(m.body.issuerDeviceId).toBe(ISSUER_DEVICE_ID);65    expect(m.signatureHex).toMatch(/^[0-9a-f]{128}$/);66    const body = await verifyDeviceManifest(m, signingPubkey);67    expect(body.devices).toHaveLength(1);68    expect(body.devices[0].deviceId).toBe(PEER_DEVICE_ID);69  });7071  it("rejects a stale manifest", async () => {72    const now = Date.now();73    const m = await buildSignedDeviceManifest(74      ISSUER_DEVICE_ID,75      [entry()],76      signingPrivateKey,77      { now: now - DEVICE_MANIFEST_FRESHNESS_MS - 1_000 },78    );79    await expect(verifyDeviceManifest(m, signingPubkey, { now })).rejects.toMatchObject({80      kind: "freshness",81    } as DeviceManifestError);82  });8384  it("rejects a forged signature", async () => {85    const m = await buildSignedDeviceManifest(86      ISSUER_DEVICE_ID,87      [entry()],88      signingPrivateKey,89    );90    // Flip one byte of the signature — must fail verification.91    const sig = m.signatureHex;92    const flipped =93      sig.slice(0, 2) +94      ((parseInt(sig.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +95      sig.slice(4);96    await expect(97      verifyDeviceManifest({ ...m, signatureHex: flipped }, signingPubkey),98    ).rejects.toMatchObject({ kind: "signature" } as DeviceManifestError);99  });100101  it("rejects a body tampered after signing", async () => {102    const m = await buildSignedDeviceManifest(103      ISSUER_DEVICE_ID,104      [entry()],105      signingPrivateKey,106    );107    const tampered = {108      ...m,109      body: {110        ...m.body,111        devices: [{ ...entry(), deviceName: "different-name" }],112      },113    };114    await expect(verifyDeviceManifest(tampered, signingPubkey)).rejects.toMatchObject({115      kind: "signature",116    } as DeviceManifestError);117  });118119  it("rejects an unknown manifest version", async () => {120    const m = await buildSignedDeviceManifest(121      ISSUER_DEVICE_ID,122      [entry()],123      signingPrivateKey,124    );125    const future = { ...m, body: { ...m.body, manifestVersion: 0x99 } };126    await expect(verifyDeviceManifest(future, signingPubkey)).rejects.toMatchObject({127      kind: "version",128    } as DeviceManifestError);129  });130131  it("derives the same keypair across calls (determinism)", async () => {132    const kp2 = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);133    expect(Array.from(kp2.publicKeyRaw)).toEqual(Array.from(signingPubkey));134  });135});