// Device manifest — v2 brief §4.3. // // A device manifest is a list of paired-device entries, signed by the // issuer's long-term Ed25519 signing key (`deriveSyncSigningKeypair`). // Manifests are exchanged after the entry sync completes during a normal // `ChirpSyncSession`, encrypted under Noise XX along with everything else. // // Purpose: propagate trust between mobile devices without requiring // additional audio pairing sessions. The extension is the trust anchor and // always the issuer; mobile peers receive the manifest, verify the // signature against the extension's known Ed25519 public key, and upsert // any newly-vouched entries into their own paired-device registry. From // then on those mobiles can sync directly with each other. // // Security properties: // - Authenticity: Ed25519 signature over a canonicalised JSON body. // Receiver rejects any manifest whose signature does not verify // against the issuer's stored signing pubkey. // - Freshness: `issuedAt` must be within DEVICE_MANIFEST_FRESHNESS_MS // of the receiver's clock; older manifests are silently discarded. // - Vault binding: the manifest is exchanged over Noise XX which has // already proven the peer holds the right vault-derived static key. import { DEVICE_MANIFEST_FRESHNESS_MS, DEVICE_MANIFEST_VERSION, type DeviceManifestBody, type ManifestDeviceEntry, type SignedDeviceManifest, } from "./sync-types"; import { signEd25519, verifyEd25519 } from "./sync-identity"; export class DeviceManifestError extends Error { readonly kind: | "version" | "freshness" | "signature" | "issuer_mismatch" | "malformed"; constructor(kind: DeviceManifestError["kind"], message: string) { super(message); this.kind = kind; } } /** * Build + sign a device manifest with `issuedAt = now`. * * The body is canonicalised before signing using a stable JSON encoder * (keys sorted, no insignificant whitespace) so any two implementations * of this codec produce byte-identical signing input from equal inputs. */ export async function buildSignedDeviceManifest( issuerDeviceId: string, devices: ManifestDeviceEntry[], signingPrivateKey: CryptoKey, options: { now?: number } = {}, ): Promise { if (!issuerDeviceId) throw new Error("issuerDeviceId is required"); const body: DeviceManifestBody = { manifestVersion: DEVICE_MANIFEST_VERSION, issuerDeviceId, issuedAt: options.now ?? Date.now(), devices: devices.map(normaliseEntry), }; const canonical = canonicalise(body); const signature = await signEd25519( signingPrivateKey, new TextEncoder().encode(canonical), ); return { body, signatureHex: bytesToHex(signature) }; } /** * Verify a manifest's signature, version, and freshness window. Throws on * any failure; returns the body on success. The caller is responsible for * looking up the issuer's signing pubkey from the paired-device registry * and passing it in — that lookup is what enforces "manifest from a known * device" (§4.3 final paragraph). */ export async function verifyDeviceManifest( manifest: SignedDeviceManifest, issuerSigningPubkey: Uint8Array, options: { now?: number; freshnessMs?: number } = {}, ): Promise { if (!manifest || typeof manifest !== "object") { throw new DeviceManifestError("malformed", "manifest is not an object"); } if (!manifest.body || !manifest.signatureHex) { throw new DeviceManifestError("malformed", "manifest missing body or signature"); } if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) { throw new DeviceManifestError( "version", `unsupported manifest version ${manifest.body.manifestVersion}`, ); } const now = options.now ?? Date.now(); const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS; if ( typeof manifest.body.issuedAt !== "number" || Math.abs(now - manifest.body.issuedAt) > freshness ) { throw new DeviceManifestError( "freshness", `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`, ); } const signature = hexToBytes(manifest.signatureHex); if (signature.length !== 64) { throw new DeviceManifestError("signature", "signature must be 64 bytes"); } const canonical = canonicalise(manifest.body); const ok = await verifyEd25519( issuerSigningPubkey, new TextEncoder().encode(canonical), signature, ); if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify"); // Body shape — minimum guarantees the caller relies on. We do not // enforce uniqueness of deviceId here; the registry-upsert side handles // duplicates by deviceId. if (!Array.isArray(manifest.body.devices)) { throw new DeviceManifestError("malformed", "devices is not an array"); } for (const d of manifest.body.devices) { if (typeof d.deviceId !== "string" || !d.deviceId) { throw new DeviceManifestError("malformed", "device.deviceId missing"); } if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) { throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid"); } if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) { throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid"); } } return manifest.body; } function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry { return { deviceId: d.deviceId, deviceName: d.deviceName, deviceType: d.deviceType, longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(), signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(), pairedAt: d.pairedAt, }; } /** * Canonical JSON serialiser. Sorts object keys, no trailing whitespace, * stable number formatting. The serialiser intentionally only handles the * narrow set of shapes that occur inside a `DeviceManifestBody` — extending * it later means extending these branches in lockstep with the schema. */ export function canonicalise(value: unknown): string { if (value === null) return "null"; if (typeof value === "number") { if (!Number.isFinite(value)) throw new Error("non-finite number"); return Number.isInteger(value) ? value.toString() : value.toString(); } if (typeof value === "string") return JSON.stringify(value); if (typeof value === "boolean") return value ? "true" : "false"; if (Array.isArray(value)) { return "[" + value.map((v) => canonicalise(v)).join(",") + "]"; } if (typeof value === "object") { const obj = value as Record; const keys = Object.keys(obj).sort(); return ( "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") + "}" ); } throw new Error(`cannot canonicalise ${typeof value}`); } function bytesToHex(b: Uint8Array): string { let s = ""; for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0"); return s; } function hexToBytes(hex: string): Uint8Array { if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex"); const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) { const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16); if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex"); out[i] = b; } return out; }