device-manifest.ts

10.0 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 repeating first-pair9// confirmation. Any already-admitted, Noise-authenticated mesh peer may issue10// a manifest. That makes membership a convergent pool instead of a graph whose11// updates still depend on the device that performed the original pairing.12// The receiver verifies the signature against that issuer's known Ed2551913// public key. A v3 entry that carries a valid relay route is auto-admitted as14// same-vault "vouched" state and may propagate that signed graph onward.15// See docs/mesh-vouching-authority.md for the full authority trace.16//17// Security properties:18//   - Authenticity: Ed25519 signature over a canonicalised JSON body.19//     Receiver rejects any manifest whose signature does not verify20//     against the issuer's stored signing pubkey.21//   - Freshness: `issuedAt` must be within DEVICE_MANIFEST_FRESHNESS_MS22//     of the receiver's clock; older manifests are silently discarded.23//   - Vault binding: the manifest is exchanged over Noise KK which has already24//     proven both locally-paired vault-derived static keys.2526import {27  DEVICE_MANIFEST_FRESHNESS_MS,28  DEVICE_MANIFEST_VERSION,29  type DeviceManifestBody,30  type DeviceRevocation,31  type ManifestDeviceEntry,32  type SignedDeviceManifest,33} from "./sync-types";34import { signEd25519, verifyEd25519 } from "./sync-identity";35import { normalizeRelayUrl } from "./rendezvous";36import { capDeviceName } from "./sync-limits";3738export class DeviceManifestError extends Error {39  readonly kind:40    | "version"41    | "freshness"42    | "signature"43    | "issuer_mismatch"44    | "malformed";45  constructor(kind: DeviceManifestError["kind"], message: string) {46    super(message);47    this.kind = kind;48  }49}5051/**52 * Build + sign a device manifest with `issuedAt = now`.53 *54 * The body is canonicalised before signing using a stable JSON encoder55 * (keys sorted, no insignificant whitespace) so any two implementations56 * of this codec produce byte-identical signing input from equal inputs.57 */58export async function buildSignedDeviceManifest(59  issuerDeviceId: string,60  devices: ManifestDeviceEntry[],61  signingPrivateKey: CryptoKey,62  options: { now?: number; revoked?: DeviceRevocation[] } = {},63): Promise<SignedDeviceManifest> {64  if (!issuerDeviceId) throw new Error("issuerDeviceId is required");65  const body: DeviceManifestBody = {66    manifestVersion: DEVICE_MANIFEST_VERSION,67    issuerDeviceId,68    issuedAt: options.now ?? Date.now(),69    devices: devices.map(normaliseEntry),70    revoked: (options.revoked ?? []).map(normaliseRevocation),71  };72  const canonical = canonicalise(body);73  const signature = await signEd25519(74    signingPrivateKey,75    new TextEncoder().encode(canonical),76  );77  return { body, signatureHex: bytesToHex(signature) };78}7980/**81 * Verify a manifest's signature, version, and freshness window. Throws on82 * any failure; returns the body on success. The caller is responsible for83 * looking up the issuer's signing pubkey from the paired-device registry84 * and passing it in — that lookup is what enforces "manifest from a known85 * device" (§4.3 final paragraph).86 */87export async function verifyDeviceManifest(88  manifest: SignedDeviceManifest,89  issuerSigningPubkey: Uint8Array,90  options: { now?: number; freshnessMs?: number } = {},91): Promise<DeviceManifestBody> {92  if (!manifest || typeof manifest !== "object") {93    throw new DeviceManifestError("malformed", "manifest is not an object");94  }95  if (!manifest.body || !manifest.signatureHex) {96    throw new DeviceManifestError("malformed", "manifest missing body or signature");97  }98  if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {99    throw new DeviceManifestError(100      "version",101      `unsupported manifest version ${manifest.body.manifestVersion}`,102    );103  }104  const now = options.now ?? Date.now();105  const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;106  if (107    typeof manifest.body.issuedAt !== "number" ||108    Math.abs(now - manifest.body.issuedAt) > freshness109  ) {110    throw new DeviceManifestError(111      "freshness",112      `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,113    );114  }115116  const signature = hexToBytes(manifest.signatureHex);117  if (signature.length !== 64) {118    throw new DeviceManifestError("signature", "signature must be 64 bytes");119  }120  const canonical = canonicalise(manifest.body);121  const ok = await verifyEd25519(122    issuerSigningPubkey,123    new TextEncoder().encode(canonical),124    signature,125  );126  if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");127128  // Body shape — minimum guarantees the caller relies on. We do not129  // enforce uniqueness of deviceId here; the registry-upsert side handles130  // duplicates by deviceId.131  if (!Array.isArray(manifest.body.devices)) {132    throw new DeviceManifestError("malformed", "devices is not an array");133  }134  for (const d of manifest.body.devices) {135    if (typeof d.deviceId !== "string" || !d.deviceId) {136      throw new DeviceManifestError("malformed", "device.deviceId missing");137    }138    if (typeof d.deviceName !== "string" || !d.deviceName) {139      throw new DeviceManifestError("malformed", "device.deviceName missing");140    }141    if (142      d.deviceType !== "extension" &&143      d.deviceType !== "android" &&144      d.deviceType !== "ios"145    ) {146      throw new DeviceManifestError("malformed", "device.deviceType invalid");147    }148    if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {149      throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");150    }151    if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {152      throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");153    }154    if (155      d.wsPort != null &&156      (157        typeof d.wsPort !== "number" ||158        !Number.isInteger(d.wsPort) ||159        d.wsPort < 0 ||160        d.wsPort > 65535161      )162    ) {163      throw new DeviceManifestError("malformed", "device.wsPort invalid");164    }165    if (d.relayUrl != null) {166      try {167        normalizeRelayUrl(d.relayUrl);168      } catch {169        throw new DeviceManifestError("malformed", "device.relayUrl invalid");170      }171    }172  }173174  if (!Array.isArray(manifest.body.revoked)) {175    throw new DeviceManifestError("malformed", "revoked is not an array");176  }177  for (const r of manifest.body.revoked) {178    if (typeof r.deviceId !== "string" || !r.deviceId) {179      throw new DeviceManifestError("malformed", "revocation.deviceId missing");180    }181    if (182      typeof r.longTermPublicKeyHex !== "string" ||183      !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)184    ) {185      throw new DeviceManifestError(186        "malformed",187        "revocation.longTermPublicKeyHex invalid",188      );189    }190    if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {191      throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");192    }193    if (typeof r.revokedByDeviceId !== "string" || !r.revokedByDeviceId) {194      throw new DeviceManifestError(195        "malformed",196        "revocation.revokedByDeviceId missing",197      );198    }199  }200  return manifest.body;201}202203function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {204  return {205    deviceId: r.deviceId,206    longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),207    revokedAt: r.revokedAt,208    revokedByDeviceId: r.revokedByDeviceId,209  };210}211212function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {213  const normalised: ManifestDeviceEntry = {214    deviceId: d.deviceId,215    deviceName: capDeviceName(d.deviceName),216    deviceType: d.deviceType,217    longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),218    signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),219    pairedAt: d.pairedAt,220  };221  if (d.wsPort != null) normalised.wsPort = d.wsPort;222  if (d.relayUrl != null) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);223  return normalised;224}225226/**227 * Canonical JSON serialiser. Sorts object keys, no trailing whitespace,228 * stable number formatting. The serialiser intentionally only handles the229 * narrow set of shapes that occur inside a `DeviceManifestBody` — extending230 * it later means extending these branches in lockstep with the schema.231 */232export function canonicalise(value: unknown): string {233  if (value === null) return "null";234  if (typeof value === "number") {235    if (!Number.isFinite(value)) throw new Error("non-finite number");236    return Number.isInteger(value) ? value.toString() : value.toString();237  }238  if (typeof value === "string") return JSON.stringify(value);239  if (typeof value === "boolean") return value ? "true" : "false";240  if (Array.isArray(value)) {241    return "[" + value.map((v) => canonicalise(v)).join(",") + "]";242  }243  if (typeof value === "object") {244    const obj = value as Record<string, unknown>;245    const keys = Object.keys(obj).sort();246    // ponytail: skip null values so canonical bytes match Android's247    //            manifestEntryMap, which omits wsPort and relayUrl when null.248    const present = keys.filter((k) => obj[k] !== null);249    return (250      "{" +251      present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +252      "}"253    );254  }255  throw new Error(`cannot canonicalise ${typeof value}`);256}257258function bytesToHex(b: Uint8Array): string {259  let s = "";260  for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");261  return s;262}263264function hexToBytes(hex: string): Uint8Array {265  if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");266  const out = new Uint8Array(hex.length / 2);267  for (let i = 0; i < out.length; i++) {268    const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);269    if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");270    out[i] = b;271  }272  return out;273}