device-manifest.ts

8.1 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// `SyncSession`, 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.deviceName !== "string" || !d.deviceName) {133      throw new DeviceManifestError("malformed", "device.deviceName missing");134    }135    if (136      d.deviceType !== "extension" &&137      d.deviceType !== "android" &&138      d.deviceType !== "ios" &&139      d.deviceType !== "desktop"140    ) {141      throw new DeviceManifestError("malformed", "device.deviceType invalid");142    }143    if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {144      throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");145    }146    if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {147      throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");148    }149    if (150      d.wsPort !== undefined &&151      (152        typeof d.wsPort !== "number" ||153        !Number.isInteger(d.wsPort) ||154        d.wsPort < 1 ||155        d.wsPort > 65535156      )157    ) {158      throw new DeviceManifestError("malformed", "device.wsPort invalid");159    }160  }161  return manifest.body;162}163164function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {165  const normalised: ManifestDeviceEntry = {166    deviceId: d.deviceId,167    deviceName: d.deviceName,168    deviceType: d.deviceType,169    longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),170    signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),171    pairedAt: d.pairedAt,172  };173  if (d.wsPort !== undefined) normalised.wsPort = d.wsPort;174  return normalised;175}176177/**178 * Canonical JSON serialiser. Sorts object keys, no trailing whitespace,179 * stable number formatting. The serialiser intentionally only handles the180 * narrow set of shapes that occur inside a `DeviceManifestBody` — extending181 * it later means extending these branches in lockstep with the schema.182 */183export function canonicalise(value: unknown): string {184  if (value === null) return "null";185  if (typeof value === "number") {186    if (!Number.isFinite(value)) throw new Error("non-finite number");187    return Number.isInteger(value) ? value.toString() : value.toString();188  }189  if (typeof value === "string") return JSON.stringify(value);190  if (typeof value === "boolean") return value ? "true" : "false";191  if (Array.isArray(value)) {192    return "[" + value.map((v) => canonicalise(v)).join(",") + "]";193  }194  if (typeof value === "object") {195    const obj = value as Record<string, unknown>;196    const keys = Object.keys(obj).sort();197    return (198      "{" +199      keys.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +200      "}"201    );202  }203  throw new Error(`cannot canonicalise ${typeof value}`);204}205206function bytesToHex(b: Uint8Array): string {207  let s = "";208  for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");209  return s;210}211212function hexToBytes(hex: string): Uint8Array {213  if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");214  const out = new Uint8Array(hex.length / 2);215  for (let i = 0; i < out.length; i++) {216    const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);217    if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");218    out[i] = b;219  }220  return out;221}