device-manifest.ts

12.2 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/** Canonical fields authenticated by an issuer for one revocation record. */83export function canonicalDeviceRevocation(record: DeviceRevocation): string {84  return canonicalise({85    deviceId: record.deviceId,86    longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),87    recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,88    revokedAt: record.revokedAt,89    revokedByDeviceId: record.revokedByDeviceId,90  });91}9293export async function signDeviceRevocation(94  record: DeviceRevocation,95  signingPrivateKey: CryptoKey,96): Promise<DeviceRevocation> {97  const unsigned: DeviceRevocation = {98    ...record,99    longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),100    recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,101  };102  const signature = await signEd25519(103    signingPrivateKey,104    new TextEncoder().encode(canonicalDeviceRevocation(unsigned)),105  );106  return { ...unsigned, signatureHex: bytesToHex(signature) };107}108109export async function verifyDeviceRevocation(110  record: DeviceRevocation,111  issuerSigningPubkey: Uint8Array,112): Promise<boolean> {113  if (!record.signatureHex || !/^[0-9a-f]{128}$/i.test(record.signatureHex)) return false;114  try {115    return await verifyEd25519(116      issuerSigningPubkey,117      new TextEncoder().encode(canonicalDeviceRevocation(record)),118      hexToBytes(record.signatureHex),119    );120  } catch {121    return false;122  }123}124125/**126 * Verify a manifest's signature, version, and freshness window. Throws on127 * any failure; returns the body on success. The caller is responsible for128 * looking up the issuer's signing pubkey from the paired-device registry129 * and passing it in — that lookup is what enforces "manifest from a known130 * device" (§4.3 final paragraph).131 */132export async function verifyDeviceManifest(133  manifest: SignedDeviceManifest,134  issuerSigningPubkey: Uint8Array,135  options: { now?: number; freshnessMs?: number } = {},136): Promise<DeviceManifestBody> {137  if (!manifest || typeof manifest !== "object") {138    throw new DeviceManifestError("malformed", "manifest is not an object");139  }140  if (!manifest.body || !manifest.signatureHex) {141    throw new DeviceManifestError("malformed", "manifest missing body or signature");142  }143  if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {144    throw new DeviceManifestError(145      "version",146      `unsupported manifest version ${manifest.body.manifestVersion}`,147    );148  }149  const now = options.now ?? Date.now();150  const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;151  if (152    typeof manifest.body.issuedAt !== "number" ||153    Math.abs(now - manifest.body.issuedAt) > freshness154  ) {155    throw new DeviceManifestError(156      "freshness",157      `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,158    );159  }160161  const signature = hexToBytes(manifest.signatureHex);162  if (signature.length !== 64) {163    throw new DeviceManifestError("signature", "signature must be 64 bytes");164  }165  const canonical = canonicalise(manifest.body);166  const ok = await verifyEd25519(167    issuerSigningPubkey,168    new TextEncoder().encode(canonical),169    signature,170  );171  if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");172173  // Body shape — minimum guarantees the caller relies on. We do not174  // enforce uniqueness of deviceId here; the registry-upsert side handles175  // duplicates by deviceId.176  if (!Array.isArray(manifest.body.devices)) {177    throw new DeviceManifestError("malformed", "devices is not an array");178  }179  for (const d of manifest.body.devices) {180    if (typeof d.deviceId !== "string" || !d.deviceId) {181      throw new DeviceManifestError("malformed", "device.deviceId missing");182    }183    if (typeof d.deviceName !== "string" || !d.deviceName) {184      throw new DeviceManifestError("malformed", "device.deviceName missing");185    }186    if (187      d.deviceType !== "extension" &&188      d.deviceType !== "android" &&189      d.deviceType !== "ios"190    ) {191      throw new DeviceManifestError("malformed", "device.deviceType invalid");192    }193    if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {194      throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");195    }196    if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {197      throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");198    }199    if (d.vouchDepth != null && (!Number.isInteger(d.vouchDepth) || d.vouchDepth < 0 || d.vouchDepth > MAX_VOUCH_DEPTH)) {200      throw new DeviceManifestError("malformed", "device.vouchDepth invalid");201    }202    if (203      d.wsPort != null &&204      (205        typeof d.wsPort !== "number" ||206        !Number.isInteger(d.wsPort) ||207        d.wsPort < 0 ||208        d.wsPort > 65535209      )210    ) {211      throw new DeviceManifestError("malformed", "device.wsPort invalid");212    }213    if (d.relayUrl != null) {214      try {215        normalizeRelayUrl(d.relayUrl);216      } catch {217        throw new DeviceManifestError("malformed", "device.relayUrl invalid");218      }219    }220  }221222  if (!Array.isArray(manifest.body.revoked)) {223    throw new DeviceManifestError("malformed", "revoked is not an array");224  }225  for (const r of manifest.body.revoked) {226    if (typeof r.deviceId !== "string" || !r.deviceId) {227      throw new DeviceManifestError("malformed", "revocation.deviceId missing");228    }229    if (230      typeof r.longTermPublicKeyHex !== "string" ||231      !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)232    ) {233      throw new DeviceManifestError(234        "malformed",235        "revocation.longTermPublicKeyHex invalid",236      );237    }238    if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {239      throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");240    }241    if (typeof r.revokedByDeviceId !== "string" || !r.revokedByDeviceId) {242      throw new DeviceManifestError(243        "malformed",244        "revocation.revokedByDeviceId missing",245      );246    }247    if (248      r.recordVersion != null &&249      r.recordVersion !== DEVICE_REVOCATION_RECORD_VERSION250    ) {251      throw new DeviceManifestError(252        "malformed",253        `revocation.recordVersion unsupported (${r.recordVersion})`,254      );255    }256  }257  return manifest.body;258}259260function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {261  const normalised: DeviceRevocation = {262    deviceId: r.deviceId,263    longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),264    revokedAt: r.revokedAt,265    revokedByDeviceId: r.revokedByDeviceId,266    recordVersion: r.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,267  };268  if (r.signatureHex != null) normalised.signatureHex = r.signatureHex.toLowerCase();269  return normalised;270}271272function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {273  const normalised: ManifestDeviceEntry = {274    deviceId: d.deviceId,275    deviceName: capDeviceName(d.deviceName),276    deviceType: d.deviceType,277    longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),278    signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),279    pairedAt: d.pairedAt,280    vouchDepth: d.vouchDepth ?? 0,281  };282  if (d.wsPort != null) normalised.wsPort = d.wsPort;283  if (d.relayUrl != null) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);284  return normalised;285}286287/**288 * Canonical JSON serialiser. Sorts object keys, no trailing whitespace,289 * stable number formatting. The serialiser intentionally only handles the290 * narrow set of shapes that occur inside a `DeviceManifestBody` — extending291 * it later means extending these branches in lockstep with the schema.292 */293export function canonicalise(value: unknown): string {294  if (value === null) return "null";295  if (typeof value === "number") {296    if (!Number.isFinite(value)) throw new Error("non-finite number");297    return Number.isInteger(value) ? value.toString() : value.toString();298  }299  if (typeof value === "string") return JSON.stringify(value);300  if (typeof value === "boolean") return value ? "true" : "false";301  if (Array.isArray(value)) {302    return "[" + value.map((v) => canonicalise(v)).join(",") + "]";303  }304  if (typeof value === "object") {305    const obj = value as Record<string, unknown>;306    const keys = Object.keys(obj).sort();307    // ponytail: skip null values so canonical bytes match Android's308    //            manifestEntryMap, which omits wsPort and relayUrl when null.309    const present = keys.filter((k) => obj[k] !== null);310    return (311      "{" +312      present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +313      "}"314    );315  }316  throw new Error(`cannot canonicalise ${typeof value}`);317}318319function bytesToHex(b: Uint8Array): string {320  let s = "";321  for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");322  return s;323}324325function hexToBytes(hex: string): Uint8Array {326  if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");327  const out = new Uint8Array(hex.length / 2);328  for (let i = 0; i < out.length; i++) {329    const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);330    if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");331    out[i] = b;332  }333  return out;334}