device-manifest.ts

7.3 KB · raw

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.2324import {25  DEVICE_MANIFEST_FRESHNESS_MS,26  DEVICE_MANIFEST_VERSION,27  type DeviceManifestBody,28  type ManifestDeviceEntry,29  type SignedDeviceManifest,30} from "./sync-types";31import { signEd25519, verifyEd25519 } from "./sync-identity";3233export 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}4546/**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 */53export 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}7374/**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 */81export 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  }109110  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");121122  // 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}141142function 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}152153/**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 */159export 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}181182function 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}187188function 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}