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  MAX_VOUCH_DEPTH,31  type DeviceManifestBody,32  type DeviceRevocation,33  type ManifestDeviceEntry,34  type SignedDeviceManifest,35} from "./sync-types";36import { signEd25519, verifyEd25519 } from "./sync-identity";37import { normalizeRelayUrl } from "./rendezvous";38import { capDeviceName } from "./sync-limits";3940export class DeviceManifestError extends Error {41  readonly kind:42    | "version"43    | "freshness"44    | "signature"45    | "issuer_mismatch"46    | "malformed";47  constructor(kind: DeviceManifestError["kind"], message: string) {48    super(message);49    this.kind = kind;50  }51}5253/**54 * Build + sign a device manifest with `issuedAt = now`.55 *56 * The body is canonicalised before signing using a stable JSON encoder57 * (keys sorted, no insignificant whitespace) so any two implementations58 * of this codec produce byte-identical signing input from equal inputs.59 */60export async function buildSignedDeviceManifest(61  issuerDeviceId: string,62  devices: ManifestDeviceEntry[],63  signingPrivateKey: CryptoKey,64  options: { now?: number; revoked?: DeviceRevocation[] } = {},65): Promise<SignedDeviceManifest> {66  if (!issuerDeviceId) throw new Error("issuerDeviceId is required");67  const body: DeviceManifestBody = {68    manifestVersion: DEVICE_MANIFEST_VERSION,69    issuerDeviceId,70    issuedAt: options.now ?? Date.now(),71    devices: devices.map(normaliseEntry),72    revoked: (options.revoked ?? []).map(normaliseRevocation),73  };74  const canonical = canonicalise(body);75  const signature = await signEd25519(76    signingPrivateKey,77    new TextEncoder().encode(canonical),78  );79  return { body, signatureHex: bytesToHex(signature) };80}81/** Canonical fields authenticated by an issuer for one revocation record. */82export function canonicalDeviceRevocation(record: DeviceRevocation): string {83  return canonicalise({84    deviceId: record.deviceId,85    longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),86    revokedAt: record.revokedAt,87    revokedByDeviceId: record.revokedByDeviceId,88    issuerSigningKeyVersion: record.issuerSigningKeyVersion ?? 1,89    issuerSigningPublicKeyHex: record.issuerSigningPublicKeyHex?.toLowerCase() ?? "",90  });91}9293export async function signDeviceRevocation(94  record: DeviceRevocation,95  signingPrivateKey: CryptoKey,96  issuerSigningPublicKeyHex: string,97  issuerSigningKeyVersion = 1,98): Promise<DeviceRevocation> {99  const signed = {100    ...record,101    issuerSigningKeyVersion,102    issuerSigningPublicKeyHex: issuerSigningPublicKeyHex.toLowerCase(),103  };104  const signature = await signEd25519(105    signingPrivateKey,106    new TextEncoder().encode(canonicalDeviceRevocation(signed)),107  );108  return { ...signed, signatureHex: bytesToHex(signature) };109}110111export async function verifyDeviceRevocation(112  record: DeviceRevocation,113  issuerSigningPubkey: Uint8Array,114): Promise<boolean> {115  if (!record.signatureHex || !/^[0-9a-f]{128}$/i.test(record.signatureHex)) return false;116  try {117    return await verifyEd25519(118      issuerSigningPubkey,119      new TextEncoder().encode(canonicalDeviceRevocation(record)),120      hexToBytes(record.signatureHex),121    );122  } catch {123    return false;124  }125}126127/**128 * Verify a manifest's signature, version, and freshness window. Throws on129 * any failure; returns the body on success. The caller is responsible for130 * looking up the issuer's signing pubkey from the paired-device registry131 * and passing it in — that lookup is what enforces "manifest from a known132 * device" (§4.3 final paragraph).133 */134export async function verifyDeviceManifest(135  manifest: SignedDeviceManifest,136  issuerSigningPubkey: Uint8Array,137  options: { now?: number; freshnessMs?: number } = {},138): Promise<DeviceManifestBody> {139  if (!manifest || typeof manifest !== "object") {140    throw new DeviceManifestError("malformed", "manifest is not an object");141  }142  if (!manifest.body || !manifest.signatureHex) {143    throw new DeviceManifestError("malformed", "manifest missing body or signature");144  }145  if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {146    throw new DeviceManifestError(147      "version",148      `unsupported manifest version ${manifest.body.manifestVersion}`,149    );150  }151  const now = options.now ?? Date.now();152  const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;153  if (154    typeof manifest.body.issuedAt !== "number" ||155    Math.abs(now - manifest.body.issuedAt) > freshness156  ) {157    throw new DeviceManifestError(158      "freshness",159      `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,160    );161  }162163  const signature = hexToBytes(manifest.signatureHex);164  if (signature.length !== 64) {165    throw new DeviceManifestError("signature", "signature must be 64 bytes");166  }167  const canonical = canonicalise(manifest.body);168  const ok = await verifyEd25519(169    issuerSigningPubkey,170    new TextEncoder().encode(canonical),171    signature,172  );173  if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");174175  // Body shape — minimum guarantees the caller relies on. We do not176  // enforce uniqueness of deviceId here; the registry-upsert side handles177  // duplicates by deviceId.178  if (!Array.isArray(manifest.body.devices)) {179    throw new DeviceManifestError("malformed", "devices is not an array");180  }181  for (const d of manifest.body.devices) {182    if (typeof d.deviceId !== "string" || !d.deviceId) {183      throw new DeviceManifestError("malformed", "device.deviceId missing");184    }185    if (typeof d.deviceName !== "string" || !d.deviceName) {186      throw new DeviceManifestError("malformed", "device.deviceName missing");187    }188    if (189      d.deviceType !== "extension" &&190      d.deviceType !== "android" &&191      d.deviceType !== "ios"192    ) {193      throw new DeviceManifestError("malformed", "device.deviceType invalid");194    }195    if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {196      throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");197    }198    if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {199      throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");200    }201    if (d.vouchDepth != null && (!Number.isInteger(d.vouchDepth) || d.vouchDepth < 0 || d.vouchDepth > MAX_VOUCH_DEPTH)) {202      throw new DeviceManifestError("malformed", "device.vouchDepth invalid");203    }204    if (205      d.wsPort != null &&206      (207        typeof d.wsPort !== "number" ||208        !Number.isInteger(d.wsPort) ||209        d.wsPort < 0 ||210        d.wsPort > 65535211      )212    ) {213      throw new DeviceManifestError("malformed", "device.wsPort invalid");214    }215    if (d.relayUrl != null) {216      try {217        normalizeRelayUrl(d.relayUrl);218      } catch {219        throw new DeviceManifestError("malformed", "device.relayUrl invalid");220      }221    }222  }223224  if (!Array.isArray(manifest.body.revoked)) {225    throw new DeviceManifestError("malformed", "revoked is not an array");226  }227  for (const r of manifest.body.revoked) {228    if (typeof r.deviceId !== "string" || !r.deviceId) {229      throw new DeviceManifestError("malformed", "revocation.deviceId missing");230    }231    if (232      typeof r.longTermPublicKeyHex !== "string" ||233      !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)234    ) {235      throw new DeviceManifestError(236        "malformed",237        "revocation.longTermPublicKeyHex invalid",238      );239    }240    if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {241      throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");242    }243    if (typeof r.revokedByDeviceId !== "string" || !r.revokedByDeviceId) {244      throw new DeviceManifestError(245        "malformed",246        "revocation.revokedByDeviceId missing",247      );248    }249  }250  return manifest.body;251}252253function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {254  const normalised: DeviceRevocation = {255    deviceId: r.deviceId,256    longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),257    revokedAt: r.revokedAt,258    revokedByDeviceId: r.revokedByDeviceId,259  };260  if (r.issuerSigningKeyVersion != null) normalised.issuerSigningKeyVersion = r.issuerSigningKeyVersion;261  if (r.issuerSigningPublicKeyHex != null) normalised.issuerSigningPublicKeyHex = r.issuerSigningPublicKeyHex.toLowerCase();262  if (r.signatureHex != null) normalised.signatureHex = r.signatureHex.toLowerCase();263  return normalised;264}265266function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {267  const normalised: ManifestDeviceEntry = {268    deviceId: d.deviceId,269    deviceName: capDeviceName(d.deviceName),270    deviceType: d.deviceType,271    longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),272    signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),273    pairedAt: d.pairedAt,274    vouchDepth: d.vouchDepth ?? 0,275  };276  if (d.wsPort != null) normalised.wsPort = d.wsPort;277  if (d.relayUrl != null) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);278  return normalised;279}280281/**282 * Canonical JSON serialiser. Sorts object keys, no trailing whitespace,283 * stable number formatting. The serialiser intentionally only handles the284 * narrow set of shapes that occur inside a `DeviceManifestBody` — extending285 * it later means extending these branches in lockstep with the schema.286 */287export function canonicalise(value: unknown): string {288  if (value === null) return "null";289  if (typeof value === "number") {290    if (!Number.isFinite(value)) throw new Error("non-finite number");291    return Number.isInteger(value) ? value.toString() : value.toString();292  }293  if (typeof value === "string") return JSON.stringify(value);294  if (typeof value === "boolean") return value ? "true" : "false";295  if (Array.isArray(value)) {296    return "[" + value.map((v) => canonicalise(v)).join(",") + "]";297  }298  if (typeof value === "object") {299    const obj = value as Record<string, unknown>;300    const keys = Object.keys(obj).sort();301    // ponytail: skip null values so canonical bytes match Android's302    //            manifestEntryMap, which omits wsPort and relayUrl when null.303    const present = keys.filter((k) => obj[k] !== null);304    return (305      "{" +306      present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +307      "}"308    );309  }310  throw new Error(`cannot canonicalise ${typeof value}`);311}312313function bytesToHex(b: Uint8Array): string {314  let s = "";315  for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");316  return s;317}318319function hexToBytes(hex: string): Uint8Array {320  if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");321  const out = new Uint8Array(hex.length / 2);322  for (let i = 0; i < out.length; i++) {323    const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);324    if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");325    out[i] = b;326  }327  return out;328}