458f39ca4f5a

device-manifest module — Ed25519 sign + verify, canonical JSON, freshness window, body tampering rejection (9 tests pass)

time
2026-05-18 02:45 UTC · 5 months ago
machine
koh2
parent
5460fcb73735
2 files src/shared/device-manifest.test.ts+135 −0src/shared/device-manifest.ts+197 −0
src/shared/device-manifest.test.ts+135 −0
@@ -0,0 +1,135 @@1+import { beforeAll, describe, expect, it } from "vitest";2+import {3+  buildSignedDeviceManifest,4+  canonicalise,5+  DeviceManifestError,6+  verifyDeviceManifest,7+} from "./device-manifest";8+import { deriveSyncSigningKeypair } from "./sync-identity";9+import {10+  DEVICE_MANIFEST_FRESHNESS_MS,11+  DEVICE_MANIFEST_VERSION,12+  type ManifestDeviceEntry,13+} from "./sync-types";14+15+const MASTER_KEY = new Uint8Array(32).fill(0xab);16+const ISSUER_DEVICE_ID = "11111111-1111-4111-8111-111111111111";17+const PEER_DEVICE_ID = "22222222-2222-4222-8222-222222222222";18+19+function 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+}29+30+describe("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+  });37+38+  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+});42+43+describe("device manifest sign+verify round trip", () => {44+  let signingPrivateKey: CryptoKey;45+  let signingPubkey: Uint8Array;46+47+  beforeAll(async () => {48+    const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);49+    signingPrivateKey = kp.privateKey;50+    signingPubkey = kp.publicKeyRaw;51+  });52+53+  it("derives a stable 32-byte Ed25519 public key", () => {54+    expect(signingPubkey.length).toBe(32);55+  });56+57+  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+  });70+71+  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+  });83+84+  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+  });100+101+  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+  });118+119+  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+  });130+131+  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+});

view file

src/shared/device-manifest.ts+197 −0
@@ -0,0 +1,197 @@1+// Device manifest — v2 brief §4.3.2+//3+// A device manifest is a list of paired-device entries, signed by the4+// issuer's long-term Ed25519 signing key (`deriveSyncSigningKeypair`).5+// Manifests are exchanged after the entry sync completes during a normal6+// `ChirpSyncSession`, encrypted under Noise XX along with everything else.7+//8+// Purpose: propagate trust between mobile devices without requiring9+// additional audio pairing sessions. The extension is the trust anchor and10+// always the issuer; mobile peers receive the manifest, verify the11+// signature against the extension's known Ed25519 public key, and upsert12+// any newly-vouched entries into their own paired-device registry. From13+// then on those mobiles can sync directly with each other.14+//15+// Security properties:16+//   - Authenticity: Ed25519 signature over a canonicalised JSON body.17+//     Receiver rejects any manifest whose signature does not verify18+//     against the issuer's stored signing pubkey.19+//   - Freshness: `issuedAt` must be within DEVICE_MANIFEST_FRESHNESS_MS20+//     of the receiver's clock; older manifests are silently discarded.21+//   - Vault binding: the manifest is exchanged over Noise XX which has22+//     already proven the peer holds the right vault-derived static key.23+24+import {25+  DEVICE_MANIFEST_FRESHNESS_MS,26+  DEVICE_MANIFEST_VERSION,27+  type DeviceManifestBody,28+  type ManifestDeviceEntry,29+  type SignedDeviceManifest,30+} from "./sync-types";31+import { signEd25519, verifyEd25519 } from "./sync-identity";32+33+export class DeviceManifestError extends Error {34+  readonly kind:35+    | "version"36+    | "freshness"37+    | "signature"38+    | "issuer_mismatch"39+    | "malformed";40+  constructor(kind: DeviceManifestError["kind"], message: string) {41+    super(message);42+    this.kind = kind;43+  }44+}45+46+/**47+ * Build + sign a device manifest with `issuedAt = now`.48+ *49+ * The body is canonicalised before signing using a stable JSON encoder50+ * (keys sorted, no insignificant whitespace) so any two implementations51+ * of this codec produce byte-identical signing input from equal inputs.52+ */53+export async function buildSignedDeviceManifest(54+  issuerDeviceId: string,55+  devices: ManifestDeviceEntry[],56+  signingPrivateKey: CryptoKey,57+  options: { now?: number } = {},58+): Promise<SignedDeviceManifest> {59+  if (!issuerDeviceId) throw new Error("issuerDeviceId is required");60+  const body: DeviceManifestBody = {61+    manifestVersion: DEVICE_MANIFEST_VERSION,62+    issuerDeviceId,63+    issuedAt: options.now ?? Date.now(),64+    devices: devices.map(normaliseEntry),65+  };66+  const canonical = canonicalise(body);67+  const signature = await signEd25519(68+    signingPrivateKey,69+    new TextEncoder().encode(canonical),70+  );71+  return { body, signatureHex: bytesToHex(signature) };72+}73+74+/**75+ * Verify a manifest's signature, version, and freshness window. Throws on76+ * any failure; returns the body on success. The caller is responsible for77+ * looking up the issuer's signing pubkey from the paired-device registry78+ * and passing it in — that lookup is what enforces "manifest from a known79+ * device" (§4.3 final paragraph).80+ */81+export async function verifyDeviceManifest(82+  manifest: SignedDeviceManifest,83+  issuerSigningPubkey: Uint8Array,84+  options: { now?: number; freshnessMs?: number } = {},85+): Promise<DeviceManifestBody> {86+  if (!manifest || typeof manifest !== "object") {87+    throw new DeviceManifestError("malformed", "manifest is not an object");88+  }89+  if (!manifest.body || !manifest.signatureHex) {90+    throw new DeviceManifestError("malformed", "manifest missing body or signature");91+  }92+  if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {93+    throw new DeviceManifestError(94+      "version",95+      `unsupported manifest version ${manifest.body.manifestVersion}`,96+    );97+  }98+  const now = options.now ?? Date.now();99+  const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;100+  if (101+    typeof manifest.body.issuedAt !== "number" ||102+    Math.abs(now - manifest.body.issuedAt) > freshness103+  ) {104+    throw new DeviceManifestError(105+      "freshness",106+      `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,107+    );108+  }109+110+  const signature = hexToBytes(manifest.signatureHex);111+  if (signature.length !== 64) {112+    throw new DeviceManifestError("signature", "signature must be 64 bytes");113+  }114+  const canonical = canonicalise(manifest.body);115+  const ok = await verifyEd25519(116+    issuerSigningPubkey,117+    new TextEncoder().encode(canonical),118+    signature,119+  );120+  if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");121+122+  // Body shape — minimum guarantees the caller relies on. We do not123+  // enforce uniqueness of deviceId here; the registry-upsert side handles124+  // duplicates by deviceId.125+  if (!Array.isArray(manifest.body.devices)) {126+    throw new DeviceManifestError("malformed", "devices is not an array");127+  }128+  for (const d of manifest.body.devices) {129+    if (typeof d.deviceId !== "string" || !d.deviceId) {130+      throw new DeviceManifestError("malformed", "device.deviceId missing");131+    }132+    if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {133+      throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");134+    }135+    if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {136+      throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");137+    }138+  }139+  return manifest.body;140+}141+142+function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {143+  return {144+    deviceId: d.deviceId,145+    deviceName: d.deviceName,146+    deviceType: d.deviceType,147+    longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),148+    signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),149+    pairedAt: d.pairedAt,150+  };151+}152+153+/**154+ * Canonical JSON serialiser. Sorts object keys, no trailing whitespace,155+ * stable number formatting. The serialiser intentionally only handles the156+ * narrow set of shapes that occur inside a `DeviceManifestBody` — extending157+ * it later means extending these branches in lockstep with the schema.158+ */159+export function canonicalise(value: unknown): string {160+  if (value === null) return "null";161+  if (typeof value === "number") {162+    if (!Number.isFinite(value)) throw new Error("non-finite number");163+    return Number.isInteger(value) ? value.toString() : value.toString();164+  }165+  if (typeof value === "string") return JSON.stringify(value);166+  if (typeof value === "boolean") return value ? "true" : "false";167+  if (Array.isArray(value)) {168+    return "[" + value.map((v) => canonicalise(v)).join(",") + "]";169+  }170+  if (typeof value === "object") {171+    const obj = value as Record<string, unknown>;172+    const keys = Object.keys(obj).sort();173+    return (174+      "{" +175+      keys.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +176+      "}"177+    );178+  }179+  throw new Error(`cannot canonicalise ${typeof value}`);180+}181+182+function bytesToHex(b: Uint8Array): string {183+  let s = "";184+  for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");185+  return s;186+}187+188+function hexToBytes(hex: string): Uint8Array {189+  if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");190+  const out = new Uint8Array(hex.length / 2);191+  for (let i = 0; i < out.length; i++) {192+    const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);193+    if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");194+    out[i] = b;195+  }196+  return out;197+}

view file