device-manifest.ts

9.4 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 reconnect Noise KK along with everything else.7//8// Purpose: propagate trust between devices without requiring additional audio9// pairing sessions. Any DIRECTLY paired device may issue a manifest — the10// browser cannot listen for connections, so the peer it joins the mesh through11// is a phone, and that phone is the one holding the peer list to hand over.12// The receiver verifies the signature against that issuer's known Ed2551913// public key and queues any newly-vouched entries for user confirmation.14// See docs/mesh-vouching-authority.md for the full authority trace.15//16// Security properties:17//   - Authenticity: Ed25519 signature over a canonicalised JSON body.18//     Receiver rejects any manifest whose signature does not verify19//     against the issuer's stored signing pubkey.20//   - Freshness: `issuedAt` must be within DEVICE_MANIFEST_FRESHNESS_MS21//     of the receiver's clock; older manifests are silently discarded.22//   - Vault binding: the manifest is exchanged over Noise KK which has already23//     proven both locally-paired vault-derived static keys.2425import {26  DEVICE_MANIFEST_FRESHNESS_MS,27  DEVICE_MANIFEST_VERSION,28  type DeviceManifestBody,29  type DeviceRevocation,30  type ManifestDeviceEntry,31  type SignedDeviceManifest,32} from "./sync-types";33import { signEd25519, verifyEd25519 } from "./sync-identity";3435export class DeviceManifestError extends Error {36  readonly kind:37    | "version"38    | "freshness"39    | "signature"40    | "issuer_mismatch"41    | "malformed";42  constructor(kind: DeviceManifestError["kind"], message: string) {43    super(message);44    this.kind = kind;45  }46}4748/**49 * Build + sign a device manifest with `issuedAt = now`.50 *51 * The body is canonicalised before signing using a stable JSON encoder52 * (keys sorted, no insignificant whitespace) so any two implementations53 * of this codec produce byte-identical signing input from equal inputs.54 */55export async function buildSignedDeviceManifest(56  issuerDeviceId: string,57  devices: ManifestDeviceEntry[],58  signingPrivateKey: CryptoKey,59  options: { now?: number; revoked?: DeviceRevocation[] } = {},60): Promise<SignedDeviceManifest> {61  if (!issuerDeviceId) throw new Error("issuerDeviceId is required");62  const body: DeviceManifestBody = {63    manifestVersion: DEVICE_MANIFEST_VERSION,64    issuerDeviceId,65    issuedAt: options.now ?? Date.now(),66    devices: devices.map(normaliseEntry),67    revoked: (options.revoked ?? []).map(normaliseRevocation),68  };69  const canonical = canonicalise(body);70  const signature = await signEd25519(71    signingPrivateKey,72    new TextEncoder().encode(canonical),73  );74  return { body, signatureHex: bytesToHex(signature) };75}7677/**78 * Verify a manifest's signature, version, and freshness window. Throws on79 * any failure; returns the body on success. The caller is responsible for80 * looking up the issuer's signing pubkey from the paired-device registry81 * and passing it in — that lookup is what enforces "manifest from a known82 * device" (§4.3 final paragraph).83 */84export async function verifyDeviceManifest(85  manifest: SignedDeviceManifest,86  issuerSigningPubkey: Uint8Array,87  options: { now?: number; freshnessMs?: number } = {},88): Promise<DeviceManifestBody> {89  if (!manifest || typeof manifest !== "object") {90    throw new DeviceManifestError("malformed", "manifest is not an object");91  }92  if (!manifest.body || !manifest.signatureHex) {93    throw new DeviceManifestError("malformed", "manifest missing body or signature");94  }95  if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {96    throw new DeviceManifestError(97      "version",98      `unsupported manifest version ${manifest.body.manifestVersion}`,99    );100  }101  const now = options.now ?? Date.now();102  const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;103  if (104    typeof manifest.body.issuedAt !== "number" ||105    Math.abs(now - manifest.body.issuedAt) > freshness106  ) {107    throw new DeviceManifestError(108      "freshness",109      `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,110    );111  }112113  const signature = hexToBytes(manifest.signatureHex);114  if (signature.length !== 64) {115    throw new DeviceManifestError("signature", "signature must be 64 bytes");116  }117  const canonical = canonicalise(manifest.body);118  const ok = await verifyEd25519(119    issuerSigningPubkey,120    new TextEncoder().encode(canonical),121    signature,122  );123  if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");124125  // Body shape — minimum guarantees the caller relies on. We do not126  // enforce uniqueness of deviceId here; the registry-upsert side handles127  // duplicates by deviceId.128  if (!Array.isArray(manifest.body.devices)) {129    throw new DeviceManifestError("malformed", "devices is not an array");130  }131  for (const d of manifest.body.devices) {132    if (typeof d.deviceId !== "string" || !d.deviceId) {133      throw new DeviceManifestError("malformed", "device.deviceId missing");134    }135    if (typeof d.deviceName !== "string" || !d.deviceName) {136      throw new DeviceManifestError("malformed", "device.deviceName missing");137    }138    if (139      d.deviceType !== "extension" &&140      d.deviceType !== "android" &&141      d.deviceType !== "ios"142    ) {143      throw new DeviceManifestError("malformed", "device.deviceType invalid");144    }145    if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {146      throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");147    }148    if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {149      throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");150    }151    if (152      d.wsPort !== undefined &&153      (154        typeof d.wsPort !== "number" ||155        !Number.isInteger(d.wsPort) ||156        d.wsPort < 1 ||157        d.wsPort > 65535158      )159    ) {160      throw new DeviceManifestError("malformed", "device.wsPort invalid");161    }162  }163164  if (!Array.isArray(manifest.body.revoked)) {165    throw new DeviceManifestError("malformed", "revoked is not an array");166  }167  for (const r of manifest.body.revoked) {168    if (typeof r.deviceId !== "string" || !r.deviceId) {169      throw new DeviceManifestError("malformed", "revocation.deviceId missing");170    }171    if (172      typeof r.longTermPublicKeyHex !== "string" ||173      !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)174    ) {175      throw new DeviceManifestError(176        "malformed",177        "revocation.longTermPublicKeyHex invalid",178      );179    }180    if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {181      throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");182    }183    if (typeof r.revokedByDeviceId !== "string" || !r.revokedByDeviceId) {184      throw new DeviceManifestError(185        "malformed",186        "revocation.revokedByDeviceId missing",187      );188    }189  }190  return manifest.body;191}192193function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {194  return {195    deviceId: r.deviceId,196    longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),197    revokedAt: r.revokedAt,198    revokedByDeviceId: r.revokedByDeviceId,199  };200}201202function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {203  const normalised: ManifestDeviceEntry = {204    deviceId: d.deviceId,205    deviceName: d.deviceName,206    deviceType: d.deviceType,207    longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),208    signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),209    pairedAt: d.pairedAt,210  };211  if (d.wsPort !== undefined) normalised.wsPort = d.wsPort;212  return normalised;213}214215/**216 * Canonical JSON serialiser. Sorts object keys, no trailing whitespace,217 * stable number formatting. The serialiser intentionally only handles the218 * narrow set of shapes that occur inside a `DeviceManifestBody` — extending219 * it later means extending these branches in lockstep with the schema.220 */221export function canonicalise(value: unknown): string {222  if (value === null) return "null";223  if (typeof value === "number") {224    if (!Number.isFinite(value)) throw new Error("non-finite number");225    return Number.isInteger(value) ? value.toString() : value.toString();226  }227  if (typeof value === "string") return JSON.stringify(value);228  if (typeof value === "boolean") return value ? "true" : "false";229  if (Array.isArray(value)) {230    return "[" + value.map((v) => canonicalise(v)).join(",") + "]";231  }232  if (typeof value === "object") {233    const obj = value as Record<string, unknown>;234    const keys = Object.keys(obj).sort();235    return (236      "{" +237      keys.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +238      "}"239    );240  }241  throw new Error(`cannot canonicalise ${typeof value}`);242}243244function bytesToHex(b: Uint8Array): string {245  let s = "";246  for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");247  return s;248}249250function hexToBytes(hex: string): Uint8Array {251  if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");252  const out = new Uint8Array(hex.length / 2);253  for (let i = 0; i < out.length; i++) {254    const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);255    if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");256    out[i] = b;257  }258  return out;259}