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";3637export class DeviceManifestError extends Error {38  readonly kind:39    | "version"40    | "freshness"41    | "signature"42    | "issuer_mismatch"43    | "malformed";44  constructor(kind: DeviceManifestError["kind"], message: string) {45    super(message);46    this.kind = kind;47  }48}4950/**51 * Build + sign a device manifest with `issuedAt = now`.52 *53 * The body is canonicalised before signing using a stable JSON encoder54 * (keys sorted, no insignificant whitespace) so any two implementations55 * of this codec produce byte-identical signing input from equal inputs.56 */57export async function buildSignedDeviceManifest(58  issuerDeviceId: string,59  devices: ManifestDeviceEntry[],60  signingPrivateKey: CryptoKey,61  options: { now?: number; revoked?: DeviceRevocation[] } = {},62): Promise<SignedDeviceManifest> {63  if (!issuerDeviceId) throw new Error("issuerDeviceId is required");64  const body: DeviceManifestBody = {65    manifestVersion: DEVICE_MANIFEST_VERSION,66    issuerDeviceId,67    issuedAt: options.now ?? Date.now(),68    devices: devices.map(normaliseEntry),69    revoked: (options.revoked ?? []).map(normaliseRevocation),70  };71  const canonical = canonicalise(body);72  const signature = await signEd25519(73    signingPrivateKey,74    new TextEncoder().encode(canonical),75  );76  return { body, signatureHex: bytesToHex(signature) };77}7879/**80 * Verify a manifest's signature, version, and freshness window. Throws on81 * any failure; returns the body on success. The caller is responsible for82 * looking up the issuer's signing pubkey from the paired-device registry83 * and passing it in — that lookup is what enforces "manifest from a known84 * device" (§4.3 final paragraph).85 */86export async function verifyDeviceManifest(87  manifest: SignedDeviceManifest,88  issuerSigningPubkey: Uint8Array,89  options: { now?: number; freshnessMs?: number } = {},90): Promise<DeviceManifestBody> {91  if (!manifest || typeof manifest !== "object") {92    throw new DeviceManifestError("malformed", "manifest is not an object");93  }94  if (!manifest.body || !manifest.signatureHex) {95    throw new DeviceManifestError("malformed", "manifest missing body or signature");96  }97  if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {98    throw new DeviceManifestError(99      "version",100      `unsupported manifest version ${manifest.body.manifestVersion}`,101    );102  }103  const now = options.now ?? Date.now();104  const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;105  if (106    typeof manifest.body.issuedAt !== "number" ||107    Math.abs(now - manifest.body.issuedAt) > freshness108  ) {109    throw new DeviceManifestError(110      "freshness",111      `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,112    );113  }114115  const signature = hexToBytes(manifest.signatureHex);116  if (signature.length !== 64) {117    throw new DeviceManifestError("signature", "signature must be 64 bytes");118  }119  const canonical = canonicalise(manifest.body);120  const ok = await verifyEd25519(121    issuerSigningPubkey,122    new TextEncoder().encode(canonical),123    signature,124  );125  if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");126127  // Body shape — minimum guarantees the caller relies on. We do not128  // enforce uniqueness of deviceId here; the registry-upsert side handles129  // duplicates by deviceId.130  if (!Array.isArray(manifest.body.devices)) {131    throw new DeviceManifestError("malformed", "devices is not an array");132  }133  for (const d of manifest.body.devices) {134    if (typeof d.deviceId !== "string" || !d.deviceId) {135      throw new DeviceManifestError("malformed", "device.deviceId missing");136    }137    if (typeof d.deviceName !== "string" || !d.deviceName) {138      throw new DeviceManifestError("malformed", "device.deviceName missing");139    }140    if (141      d.deviceType !== "extension" &&142      d.deviceType !== "android" &&143      d.deviceType !== "ios"144    ) {145      throw new DeviceManifestError("malformed", "device.deviceType invalid");146    }147    if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {148      throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");149    }150    if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {151      throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");152    }153    if (154      d.wsPort !== undefined &&155      (156        typeof d.wsPort !== "number" ||157        !Number.isInteger(d.wsPort) ||158        d.wsPort < 0 ||159        d.wsPort > 65535160      )161    ) {162      throw new DeviceManifestError("malformed", "device.wsPort invalid");163    }164    if (d.relayUrl !== undefined) {165      try {166        normalizeRelayUrl(d.relayUrl);167      } catch {168        throw new DeviceManifestError("malformed", "device.relayUrl invalid");169      }170    }171  }172173  if (!Array.isArray(manifest.body.revoked)) {174    throw new DeviceManifestError("malformed", "revoked is not an array");175  }176  for (const r of manifest.body.revoked) {177    if (typeof r.deviceId !== "string" || !r.deviceId) {178      throw new DeviceManifestError("malformed", "revocation.deviceId missing");179    }180    if (181      typeof r.longTermPublicKeyHex !== "string" ||182      !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)183    ) {184      throw new DeviceManifestError(185        "malformed",186        "revocation.longTermPublicKeyHex invalid",187      );188    }189    if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {190      throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");191    }192    if (typeof r.revokedByDeviceId !== "string" || !r.revokedByDeviceId) {193      throw new DeviceManifestError(194        "malformed",195        "revocation.revokedByDeviceId missing",196      );197    }198  }199  return manifest.body;200}201202function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {203  return {204    deviceId: r.deviceId,205    longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),206    revokedAt: r.revokedAt,207    revokedByDeviceId: r.revokedByDeviceId,208  };209}210211function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {212  const normalised: ManifestDeviceEntry = {213    deviceId: d.deviceId,214    deviceName: d.deviceName,215    deviceType: d.deviceType,216    longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),217    signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),218    pairedAt: d.pairedAt,219  };220  if (d.wsPort !== undefined) normalised.wsPort = d.wsPort;221  if (d.relayUrl !== undefined) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);222  return normalised;223}224225/**226 * Canonical JSON serialiser. Sorts object keys, no trailing whitespace,227 * stable number formatting. The serialiser intentionally only handles the228 * narrow set of shapes that occur inside a `DeviceManifestBody` — extending229 * it later means extending these branches in lockstep with the schema.230 */231export function canonicalise(value: unknown): string {232  if (value === null) return "null";233  if (typeof value === "number") {234    if (!Number.isFinite(value)) throw new Error("non-finite number");235    return Number.isInteger(value) ? value.toString() : value.toString();236  }237  if (typeof value === "string") return JSON.stringify(value);238  if (typeof value === "boolean") return value ? "true" : "false";239  if (Array.isArray(value)) {240    return "[" + value.map((v) => canonicalise(v)).join(",") + "]";241  }242  if (typeof value === "object") {243    const obj = value as Record<string, unknown>;244    const keys = Object.keys(obj).sort();245    // ponytail: skip null values so canonical bytes match Android's246    //            manifestEntryMap, which omits wsPort and relayUrl when null.247    const present = keys.filter((k) => obj[k] !== null);248    return (249      "{" +250      present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +251      "}"252    );253  }254  throw new Error(`cannot canonicalise ${typeof value}`);255}256257function bytesToHex(b: Uint8Array): string {258  let s = "";259  for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");260  return s;261}262263function hexToBytes(hex: string): Uint8Array {264  if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");265  const out = new Uint8Array(hex.length / 2);266  for (let i = 0; i < out.length; i++) {267    const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);268    if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");269    out[i] = b;270  }271  return out;272}