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
↓ peach-browser-458f39ca.tar.gzatlas at this save
files changed2 files
src/shared/device-manifest.test.ts+135 −0src/shared/device-manifest.ts+197 −0src/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+});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+}