device-manifest.ts

13.5 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 random per-installation Ed25519 signing key (the local device5// identity in src/background/device-identity.ts).6// Manifests are exchanged after the entry sync completes during a normal7// `SyncSession`, encrypted under reconnect Noise KK along with everything else.8//9// Purpose: propagate trust between devices without repeating first-pair10// confirmation. Any already-admitted, Noise-authenticated mesh peer may issue11// a manifest. That makes membership a convergent pool instead of a graph whose12// updates still depend on the device that performed the original pairing.13// The receiver verifies the signature against that issuer's known Ed2551914// public key. A v3 entry that carries a valid relay route is auto-admitted as15// same-vault "vouched" state and may propagate that signed graph onward.16// See docs/mesh-vouching-authority.md for the full authority trace.17//18// Security properties:19//   - Authenticity: Ed25519 signature over a canonicalised JSON body.20//     Receiver rejects any manifest whose signature does not verify21//     against the issuer's stored signing pubkey.22//   - Freshness: `issuedAt` must be within DEVICE_MANIFEST_FRESHNESS_MS23//     of the receiver's clock; older manifests are silently discarded.24//   - Vault binding: the manifest is exchanged over Noise KK which has already25//     proven both locally-paired vault-derived static keys.2627import {28  DEVICE_MANIFEST_FRESHNESS_MS,29  DEVICE_MANIFEST_VERSION,30  DEVICE_REVOCATION_RECORD_VERSION,31  MAX_VOUCH_DEPTH,32  type DeviceManifestBody,33  type DeviceRevocation,34  type ManifestDeviceEntry,35  type SignedDeviceManifest,36} from "./sync-types";37import { signEd25519, verifyEd25519 } from "./sync-identity";38import { normalizeRelayUrl } from "./rendezvous";39import { capDeviceName } from "./sync-limits";4041export class DeviceManifestError extends Error {42  readonly kind:43    | "version"44    | "freshness"45    | "signature"46    | "issuer_mismatch"47    | "malformed";48  constructor(kind: DeviceManifestError["kind"], message: string) {49    super(message);50    this.kind = kind;51  }52}5354/**55 * Build + sign a device manifest with `issuedAt = now`.56 *57 * The body is canonicalised before signing using a stable JSON encoder58 * (keys sorted, no insignificant whitespace) so any two implementations59 * of this codec produce byte-identical signing input from equal inputs.60 */61export async function buildSignedDeviceManifest(62  issuerDeviceId: string,63  devices: ManifestDeviceEntry[],64  signingPrivateKey: CryptoKey,65  options: { now?: number; revoked?: DeviceRevocation[] } = {},66): Promise<SignedDeviceManifest> {67  if (!issuerDeviceId) throw new Error("issuerDeviceId is required");68  const body: DeviceManifestBody = {69    manifestVersion: DEVICE_MANIFEST_VERSION,70    issuerDeviceId,71    issuedAt: options.now ?? Date.now(),72    devices: devices.map(normaliseEntry),73    revoked: (options.revoked ?? []).map(normaliseRevocation),74  };75  const canonical = canonicalise(body);76  const signature = await signEd25519(77    signingPrivateKey,78    new TextEncoder().encode(canonical),79  );80  return { body, signatureHex: bytesToHex(signature) };81}82/**83 * Manifest bounds, mirrored from Android's `DeviceManifestSupport`. A correctly84 * signed manifest must be accepted or rejected identically by both clients, so85 * these are contract, not defence-in-depth.86 */87const MAX_MANIFEST_DEVICES = 256;88const MAX_MANIFEST_REVOCATIONS = 500;89const MAX_MANIFEST_STRING = 256;9091/** Canonical fields authenticated by an issuer for one revocation record. */92export function canonicalDeviceRevocation(record: DeviceRevocation): string {93  return canonicalise({94    deviceId: record.deviceId,95    longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),96    recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,97    revokedAt: record.revokedAt,98    revokedByDeviceId: record.revokedByDeviceId,99  });100}101102export async function signDeviceRevocation(103  record: DeviceRevocation,104  signingPrivateKey: CryptoKey,105): Promise<DeviceRevocation> {106  const unsigned: DeviceRevocation = {107    ...record,108    longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),109    recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,110  };111  const signature = await signEd25519(112    signingPrivateKey,113    new TextEncoder().encode(canonicalDeviceRevocation(unsigned)),114  );115  return { ...unsigned, signatureHex: bytesToHex(signature) };116}117118export async function verifyDeviceRevocation(119  record: DeviceRevocation,120  issuerSigningPubkey: Uint8Array,121): Promise<boolean> {122  if (!record.signatureHex || !/^[0-9a-f]{128}$/i.test(record.signatureHex)) return false;123  try {124    return await verifyEd25519(125      issuerSigningPubkey,126      new TextEncoder().encode(canonicalDeviceRevocation(record)),127      hexToBytes(record.signatureHex),128    );129  } catch {130    return false;131  }132}133134/**135 * Verify a manifest's signature, version, and freshness window. Throws on136 * any failure; returns the body on success. The caller is responsible for137 * looking up the issuer's signing pubkey from the paired-device registry138 * and passing it in — that lookup is what enforces "manifest from a known139 * device" (§4.3 final paragraph).140 */141export async function verifyDeviceManifest(142  manifest: SignedDeviceManifest,143  issuerSigningPubkey: Uint8Array,144  options: { now?: number; freshnessMs?: number } = {},145): Promise<DeviceManifestBody> {146  if (!manifest || typeof manifest !== "object") {147    throw new DeviceManifestError("malformed", "manifest is not an object");148  }149  if (!manifest.body || !manifest.signatureHex) {150    throw new DeviceManifestError("malformed", "manifest missing body or signature");151  }152  if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {153    throw new DeviceManifestError(154      "version",155      `unsupported manifest version ${manifest.body.manifestVersion}`,156    );157  }158  const now = options.now ?? Date.now();159  const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;160  if (161    typeof manifest.body.issuedAt !== "number" ||162    Math.abs(now - manifest.body.issuedAt) > freshness163  ) {164    throw new DeviceManifestError(165      "freshness",166      `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,167    );168  }169170  const signature = hexToBytes(manifest.signatureHex);171  if (signature.length !== 64) {172    throw new DeviceManifestError("signature", "signature must be 64 bytes");173  }174  const canonical = canonicalise(manifest.body);175  const ok = await verifyEd25519(176    issuerSigningPubkey,177    new TextEncoder().encode(canonical),178    signature,179  );180  if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");181182  // Body shape — minimum guarantees the caller relies on. We do not183  // enforce uniqueness of deviceId here; the registry-upsert side handles184  // duplicates by deviceId.185  if (!Array.isArray(manifest.body.devices)) {186    throw new DeviceManifestError("malformed", "devices is not an array");187  }188  if (189    typeof manifest.body.issuerDeviceId !== "string" ||190    manifest.body.issuerDeviceId.length === 0 ||191    manifest.body.issuerDeviceId.length > MAX_MANIFEST_STRING192  ) {193    throw new DeviceManifestError("malformed", "issuerDeviceId invalid");194  }195  if (manifest.body.devices.length > MAX_MANIFEST_DEVICES) {196    throw new DeviceManifestError("malformed", "devices exceeds MAX_MANIFEST_DEVICES");197  }198  if (!Array.isArray(manifest.body.revoked)) {199    throw new DeviceManifestError("malformed", "revoked is not an array");200  }201  if (manifest.body.revoked.length > MAX_MANIFEST_REVOCATIONS) {202    throw new DeviceManifestError("malformed", "revoked exceeds MAX_MANIFEST_REVOCATIONS");203  }204  for (const d of manifest.body.devices) {205    if (typeof d.deviceId !== "string" || !d.deviceId || d.deviceId.length > MAX_MANIFEST_STRING) {206      throw new DeviceManifestError("malformed", "device.deviceId missing");207    }208    if (typeof d.deviceName !== "string" || !d.deviceName) {209      throw new DeviceManifestError("malformed", "device.deviceName missing");210    }211    if (212      d.deviceType !== "extension" &&213      d.deviceType !== "android" &&214      d.deviceType !== "ios"215    ) {216      throw new DeviceManifestError("malformed", "device.deviceType invalid");217    }218    if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {219      throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");220    }221    if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {222      throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");223    }224    if (d.vouchDepth != null && (!Number.isInteger(d.vouchDepth) || d.vouchDepth < 0 || d.vouchDepth > MAX_VOUCH_DEPTH)) {225      throw new DeviceManifestError("malformed", "device.vouchDepth invalid");226    }227    if (typeof d.pairedAt !== "number" || !Number.isFinite(d.pairedAt) || d.pairedAt < 0) {228      throw new DeviceManifestError("malformed", "device.pairedAt invalid");229    }230    if (231      d.wsPort != null &&232      (233        typeof d.wsPort !== "number" ||234        !Number.isInteger(d.wsPort) ||235        d.wsPort < 1 ||236        d.wsPort > 65535237      )238    ) {239      throw new DeviceManifestError("malformed", "device.wsPort invalid");240    }241    if (d.relayUrl != null) {242      try {243        normalizeRelayUrl(d.relayUrl);244      } catch {245        throw new DeviceManifestError("malformed", "device.relayUrl invalid");246      }247    }248  }249250  if (!Array.isArray(manifest.body.revoked)) {251    throw new DeviceManifestError("malformed", "revoked is not an array");252  }253  for (const r of manifest.body.revoked) {254    if (255      typeof r.deviceId !== "string" ||256      !r.deviceId ||257      r.deviceId.length > MAX_MANIFEST_STRING258    ) {259      throw new DeviceManifestError("malformed", "revocation.deviceId invalid");260    }261    if (262      typeof r.longTermPublicKeyHex !== "string" ||263      !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)264    ) {265      throw new DeviceManifestError(266        "malformed",267        "revocation.longTermPublicKeyHex invalid",268      );269    }270    if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {271      throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");272    }273    if (274      typeof r.revokedByDeviceId !== "string" ||275      !r.revokedByDeviceId ||276      r.revokedByDeviceId.length > MAX_MANIFEST_STRING277    ) {278      throw new DeviceManifestError("malformed", "revocation.revokedByDeviceId invalid");279    }280    if (281      r.recordVersion != null &&282      r.recordVersion !== DEVICE_REVOCATION_RECORD_VERSION283    ) {284      throw new DeviceManifestError(285        "malformed",286        `revocation.recordVersion unsupported (${r.recordVersion})`,287      );288    }289  }290  return manifest.body;291}292293function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {294  const normalised: DeviceRevocation = {295    deviceId: r.deviceId,296    longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),297    revokedAt: r.revokedAt,298    revokedByDeviceId: r.revokedByDeviceId,299    recordVersion: r.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,300  };301  if (r.signatureHex != null) normalised.signatureHex = r.signatureHex.toLowerCase();302  return normalised;303}304305function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {306  const normalised: ManifestDeviceEntry = {307    deviceId: d.deviceId,308    deviceName: capDeviceName(d.deviceName),309    deviceType: d.deviceType,310    longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),311    signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),312    pairedAt: d.pairedAt,313    vouchDepth: d.vouchDepth ?? 0,314  };315  if (d.wsPort != null) normalised.wsPort = d.wsPort;316  if (d.relayUrl != null) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);317  return normalised;318}319320/**321 * Canonical JSON serialiser. Sorts object keys, no trailing whitespace,322 * stable number formatting. The serialiser intentionally only handles the323 * narrow set of shapes that occur inside a `DeviceManifestBody` — extending324 * it later means extending these branches in lockstep with the schema.325 */326export function canonicalise(value: unknown): string {327  if (value === null) return "null";328  if (typeof value === "number") {329    if (!Number.isFinite(value)) throw new Error("non-finite number");330    return Number.isInteger(value) ? value.toString() : value.toString();331  }332  if (typeof value === "string") return JSON.stringify(value);333  if (typeof value === "boolean") return value ? "true" : "false";334  if (Array.isArray(value)) {335    return "[" + value.map((v) => canonicalise(v)).join(",") + "]";336  }337  if (typeof value === "object") {338    const obj = value as Record<string, unknown>;339    const keys = Object.keys(obj).sort();340    // ponytail: skip null values so canonical bytes match Android's341    //            manifestEntryMap, which omits wsPort and relayUrl when null.342    const present = keys.filter((k) => obj[k] !== null);343    return (344      "{" +345      present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +346      "}"347    );348  }349  throw new Error(`cannot canonicalise ${typeof value}`);350}351352function bytesToHex(b: Uint8Array): string {353  let s = "";354  for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");355  return s;356}357358function hexToBytes(hex: string): Uint8Array {359  if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");360  const out = new Uint8Array(hex.length / 2);361  for (let i = 0; i < out.length; i++) {362    const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);363    if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");364    out[i] = b;365  }366  return out;367}