device-manifest.ts

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