device-manifest.ts

9.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// `SyncSession`, encrypted under reconnect Noise KK 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 KK which has already22//     proven both locally-paired vault-derived static keys.2324import {25  DEVICE_MANIFEST_FRESHNESS_MS,26  DEVICE_MANIFEST_VERSION,27  type DeviceManifestBody,28  type DeviceRevocation,29  type ManifestDeviceEntry,30  type SignedDeviceManifest,31} from "./sync-types";32import { signEd25519, verifyEd25519 } from "./sync-identity";3334export class DeviceManifestError extends Error {35  readonly kind:36    | "version"37    | "freshness"38    | "signature"39    | "issuer_mismatch"40    | "malformed";41  constructor(kind: DeviceManifestError["kind"], message: string) {42    super(message);43    this.kind = kind;44  }45}4647/**48 * Build + sign a device manifest with `issuedAt = now`.49 *50 * The body is canonicalised before signing using a stable JSON encoder51 * (keys sorted, no insignificant whitespace) so any two implementations52 * of this codec produce byte-identical signing input from equal inputs.53 */54export async function buildSignedDeviceManifest(55  issuerDeviceId: string,56  devices: ManifestDeviceEntry[],57  signingPrivateKey: CryptoKey,58  options: { now?: number; revoked?: DeviceRevocation[] } = {},59): Promise<SignedDeviceManifest> {60  if (!issuerDeviceId) throw new Error("issuerDeviceId is required");61  const body: DeviceManifestBody = {62    manifestVersion: DEVICE_MANIFEST_VERSION,63    issuerDeviceId,64    issuedAt: options.now ?? Date.now(),65    devices: devices.map(normaliseEntry),66    revoked: (options.revoked ?? []).map(normaliseRevocation),67  };68  const canonical = canonicalise(body);69  const signature = await signEd25519(70    signingPrivateKey,71    new TextEncoder().encode(canonical),72  );73  return { body, signatureHex: bytesToHex(signature) };74}7576/**77 * Verify a manifest's signature, version, and freshness window. Throws on78 * any failure; returns the body on success. The caller is responsible for79 * looking up the issuer's signing pubkey from the paired-device registry80 * and passing it in — that lookup is what enforces "manifest from a known81 * device" (§4.3 final paragraph).82 */83export async function verifyDeviceManifest(84  manifest: SignedDeviceManifest,85  issuerSigningPubkey: Uint8Array,86  options: { now?: number; freshnessMs?: number } = {},87): Promise<DeviceManifestBody> {88  if (!manifest || typeof manifest !== "object") {89    throw new DeviceManifestError("malformed", "manifest is not an object");90  }91  if (!manifest.body || !manifest.signatureHex) {92    throw new DeviceManifestError("malformed", "manifest missing body or signature");93  }94  if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {95    throw new DeviceManifestError(96      "version",97      `unsupported manifest version ${manifest.body.manifestVersion}`,98    );99  }100  const now = options.now ?? Date.now();101  const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;102  if (103    typeof manifest.body.issuedAt !== "number" ||104    Math.abs(now - manifest.body.issuedAt) > freshness105  ) {106    throw new DeviceManifestError(107      "freshness",108      `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,109    );110  }111112  const signature = hexToBytes(manifest.signatureHex);113  if (signature.length !== 64) {114    throw new DeviceManifestError("signature", "signature must be 64 bytes");115  }116  const canonical = canonicalise(manifest.body);117  const ok = await verifyEd25519(118    issuerSigningPubkey,119    new TextEncoder().encode(canonical),120    signature,121  );122  if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");123124  // Body shape — minimum guarantees the caller relies on. We do not125  // enforce uniqueness of deviceId here; the registry-upsert side handles126  // duplicates by deviceId.127  if (!Array.isArray(manifest.body.devices)) {128    throw new DeviceManifestError("malformed", "devices is not an array");129  }130  for (const d of manifest.body.devices) {131    if (typeof d.deviceId !== "string" || !d.deviceId) {132      throw new DeviceManifestError("malformed", "device.deviceId missing");133    }134    if (typeof d.deviceName !== "string" || !d.deviceName) {135      throw new DeviceManifestError("malformed", "device.deviceName missing");136    }137    if (138      d.deviceType !== "extension" &&139      d.deviceType !== "android" &&140      d.deviceType !== "ios"141    ) {142      throw new DeviceManifestError("malformed", "device.deviceType invalid");143    }144    if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {145      throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");146    }147    if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {148      throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");149    }150    if (151      d.wsPort !== undefined &&152      (153        typeof d.wsPort !== "number" ||154        !Number.isInteger(d.wsPort) ||155        d.wsPort < 1 ||156        d.wsPort > 65535157      )158    ) {159      throw new DeviceManifestError("malformed", "device.wsPort invalid");160    }161  }162163  if (!Array.isArray(manifest.body.revoked)) {164    throw new DeviceManifestError("malformed", "revoked is not an array");165  }166  for (const r of manifest.body.revoked) {167    if (typeof r.deviceId !== "string" || !r.deviceId) {168      throw new DeviceManifestError("malformed", "revocation.deviceId missing");169    }170    if (171      typeof r.longTermPublicKeyHex !== "string" ||172      !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)173    ) {174      throw new DeviceManifestError(175        "malformed",176        "revocation.longTermPublicKeyHex invalid",177      );178    }179    if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {180      throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");181    }182    if (typeof r.revokedByDeviceId !== "string" || !r.revokedByDeviceId) {183      throw new DeviceManifestError(184        "malformed",185        "revocation.revokedByDeviceId missing",186      );187    }188  }189  return manifest.body;190}191192function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {193  return {194    deviceId: r.deviceId,195    longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),196    revokedAt: r.revokedAt,197    revokedByDeviceId: r.revokedByDeviceId,198  };199}200201function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {202  const normalised: ManifestDeviceEntry = {203    deviceId: d.deviceId,204    deviceName: d.deviceName,205    deviceType: d.deviceType,206    longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),207    signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),208    pairedAt: d.pairedAt,209  };210  if (d.wsPort !== undefined) normalised.wsPort = d.wsPort;211  return normalised;212}213214/**215 * Canonical JSON serialiser. Sorts object keys, no trailing whitespace,216 * stable number formatting. The serialiser intentionally only handles the217 * narrow set of shapes that occur inside a `DeviceManifestBody` — extending218 * it later means extending these branches in lockstep with the schema.219 */220export function canonicalise(value: unknown): string {221  if (value === null) return "null";222  if (typeof value === "number") {223    if (!Number.isFinite(value)) throw new Error("non-finite number");224    return Number.isInteger(value) ? value.toString() : value.toString();225  }226  if (typeof value === "string") return JSON.stringify(value);227  if (typeof value === "boolean") return value ? "true" : "false";228  if (Array.isArray(value)) {229    return "[" + value.map((v) => canonicalise(v)).join(",") + "]";230  }231  if (typeof value === "object") {232    const obj = value as Record<string, unknown>;233    const keys = Object.keys(obj).sort();234    return (235      "{" +236      keys.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +237      "}"238    );239  }240  throw new Error(`cannot canonicalise ${typeof value}`);241}242243function bytesToHex(b: Uint8Array): string {244  let s = "";245  for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");246  return s;247}248249function hexToBytes(hex: string): Uint8Array {250  if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");251  const out = new Uint8Array(hex.length / 2);252  for (let i = 0; i < out.length; i++) {253    const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);254    if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");255    out[i] = b;256  }257  return out;258}