device-manifest.ts

16.3 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/** Entry counts and identifier lengths mirror Android's manifest contract. */83const MAX_MANIFEST_DEVICES = 256;84const MAX_MANIFEST_REVOCATIONS = 500;85const MAX_MANIFEST_STRING = 256;86// Three UTF-8 bytes per UTF-16 code unit preserves the existing identifier limit.87const MAX_MANIFEST_IDENTIFIER_BYTES = MAX_MANIFEST_STRING * 3;88const MAX_MANIFEST_DEVICE_NAME_BYTES = 256;89const MAX_MANIFEST_RELAY_URL_BYTES = 2_048;90const MAX_REVOCATION_SIGNATURE_BYTES = 128;91const UTF8_ENCODER = new TextEncoder();92const MANIFEST_FIELDS = new Set(["body", "signatureHex"]);93const MANIFEST_BODY_FIELDS = new Set([94  "manifestVersion",95  "issuerDeviceId",96  "issuedAt",97  "devices",98  "revoked",99]);100const MANIFEST_DEVICE_FIELDS = new Set([101  "deviceId",102  "deviceName",103  "deviceType",104  "longTermPublicKeyHex",105  "signingPublicKeyHex",106  "relayUrl",107  "wsPort",108  "pairedAt",109  "vouchDepth",110]);111const MANIFEST_REVOCATION_FIELDS = new Set([112  "deviceId",113  "longTermPublicKeyHex",114  "revokedAt",115  "revokedByDeviceId",116  "recordVersion",117  "signatureHex",118]);119120/** Canonical fields authenticated by an issuer for one revocation record. */121export function canonicalDeviceRevocation(record: DeviceRevocation): string {122  return canonicalise({123    deviceId: record.deviceId,124    longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),125    recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,126    revokedAt: record.revokedAt,127    revokedByDeviceId: record.revokedByDeviceId,128  });129}130131export async function signDeviceRevocation(132  record: DeviceRevocation,133  signingPrivateKey: CryptoKey,134): Promise<DeviceRevocation> {135  const unsigned: DeviceRevocation = {136    ...record,137    longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),138    recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,139  };140  const signature = await signEd25519(141    signingPrivateKey,142    new TextEncoder().encode(canonicalDeviceRevocation(unsigned)),143  );144  return { ...unsigned, signatureHex: bytesToHex(signature) };145}146147export async function verifyDeviceRevocation(148  record: DeviceRevocation,149  issuerSigningPubkey: Uint8Array,150): Promise<boolean> {151  if (!record.signatureHex || !/^[0-9a-f]{128}$/i.test(record.signatureHex)) return false;152  try {153    return await verifyEd25519(154      issuerSigningPubkey,155      new TextEncoder().encode(canonicalDeviceRevocation(record)),156      hexToBytes(record.signatureHex),157    );158  } catch {159    return false;160  }161}162163function isRecord(value: unknown): value is Record<string, unknown> {164  return value !== null && typeof value === "object" && !Array.isArray(value);165}166167function assertAllowedFields(168  value: Record<string, unknown>,169  allowed: ReadonlySet<string>,170  label: string,171): void {172  for (const field in value) {173    if (Object.hasOwn(value, field) && !allowed.has(field)) {174      throw new DeviceManifestError("malformed", `${label}.${field} is not allowed`);175    }176  }177}178179function fitsUtf8Budget(value: string, maxBytes: number): boolean {180  const buffer = new Uint8Array(maxBytes + 1);181  const { read, written } = UTF8_ENCODER.encodeInto(value, buffer);182  return read === value.length && written <= maxBytes;183}184185function isBoundedIdentifier(value: unknown): value is string {186  return (187    typeof value === "string" &&188    value.length > 0 &&189    value.length <= MAX_MANIFEST_STRING &&190    fitsUtf8Budget(value, MAX_MANIFEST_IDENTIFIER_BYTES)191  );192}193194function preflightDeviceManifest(195  manifest: SignedDeviceManifest,196  now: number,197  freshness: number,198): void {199  if (!isRecord(manifest)) {200    throw new DeviceManifestError("malformed", "manifest is not an object");201  }202  assertAllowedFields(manifest, MANIFEST_FIELDS, "manifest");203  if (!isRecord(manifest.body) || typeof manifest.signatureHex !== "string") {204    throw new DeviceManifestError("malformed", "manifest missing body or signature");205  }206  if (manifest.signatureHex.length !== 128 || !/^[0-9a-f]+$/i.test(manifest.signatureHex)) {207    throw new DeviceManifestError("signature", "signature must be 64-byte hex");208  }209  assertAllowedFields(manifest.body, MANIFEST_BODY_FIELDS, "manifest.body");210  if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {211    throw new DeviceManifestError(212      "version",213      `unsupported manifest version ${manifest.body.manifestVersion}`,214    );215  }216  if (217    typeof manifest.body.issuedAt !== "number" ||218    !Number.isFinite(manifest.body.issuedAt) ||219    Math.abs(now - manifest.body.issuedAt) > freshness220  ) {221    throw new DeviceManifestError(222      "freshness",223      `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,224    );225  }226  if (!isBoundedIdentifier(manifest.body.issuerDeviceId)) {227    throw new DeviceManifestError("malformed", "issuerDeviceId invalid");228  }229  if (!Array.isArray(manifest.body.devices)) {230    throw new DeviceManifestError("malformed", "devices is not an array");231  }232  if (manifest.body.devices.length > MAX_MANIFEST_DEVICES) {233    throw new DeviceManifestError("malformed", "devices exceeds MAX_MANIFEST_DEVICES");234  }235  if (!Array.isArray(manifest.body.revoked)) {236    throw new DeviceManifestError("malformed", "revoked is not an array");237  }238  if (manifest.body.revoked.length > MAX_MANIFEST_REVOCATIONS) {239    throw new DeviceManifestError("malformed", "revoked exceeds MAX_MANIFEST_REVOCATIONS");240  }241242  for (const device of manifest.body.devices) {243    if (!isRecord(device)) {244      throw new DeviceManifestError("malformed", "device is not an object");245    }246    assertAllowedFields(device, MANIFEST_DEVICE_FIELDS, "device");247    if (!isBoundedIdentifier(device.deviceId)) {248      throw new DeviceManifestError("malformed", "device.deviceId missing");249    }250    if (251      typeof device.deviceName !== "string" ||252      device.deviceName.length === 0 ||253      !fitsUtf8Budget(device.deviceName, MAX_MANIFEST_DEVICE_NAME_BYTES)254    ) {255      throw new DeviceManifestError("malformed", "device.deviceName invalid");256    }257    if (258      device.deviceType !== "extension" &&259      device.deviceType !== "android" &&260      device.deviceType !== "ios"261    ) {262      throw new DeviceManifestError("malformed", "device.deviceType invalid");263    }264    if (265      typeof device.longTermPublicKeyHex !== "string" ||266      device.longTermPublicKeyHex.length !== 64267    ) {268      throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");269    }270    if (271      typeof device.signingPublicKeyHex !== "string" ||272      device.signingPublicKeyHex.length !== 64273    ) {274      throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");275    }276    if (277      device.vouchDepth != null &&278      (279        !Number.isInteger(device.vouchDepth) ||280        (device.vouchDepth as number) < 0 ||281        (device.vouchDepth as number) > MAX_VOUCH_DEPTH282      )283    ) {284      throw new DeviceManifestError("malformed", "device.vouchDepth invalid");285    }286    if (287      typeof device.pairedAt !== "number" ||288      !Number.isFinite(device.pairedAt) ||289      device.pairedAt < 0290    ) {291      throw new DeviceManifestError("malformed", "device.pairedAt invalid");292    }293    if (294      device.wsPort != null &&295      (296        typeof device.wsPort !== "number" ||297        !Number.isInteger(device.wsPort) ||298        device.wsPort < 1 ||299        device.wsPort > 65_535300      )301    ) {302      throw new DeviceManifestError("malformed", "device.wsPort invalid");303    }304    if (device.relayUrl != null) {305      if (306        typeof device.relayUrl !== "string" ||307        !fitsUtf8Budget(device.relayUrl, MAX_MANIFEST_RELAY_URL_BYTES)308      ) {309        throw new DeviceManifestError("malformed", "device.relayUrl invalid");310      }311      try {312        normalizeRelayUrl(device.relayUrl);313      } catch {314        throw new DeviceManifestError("malformed", "device.relayUrl invalid");315      }316    }317  }318319  for (const revocation of manifest.body.revoked) {320    if (!isRecord(revocation)) {321      throw new DeviceManifestError("malformed", "revocation is not an object");322    }323    assertAllowedFields(revocation, MANIFEST_REVOCATION_FIELDS, "revocation");324    if (!isBoundedIdentifier(revocation.deviceId)) {325      throw new DeviceManifestError("malformed", "revocation.deviceId invalid");326    }327    if (328      typeof revocation.longTermPublicKeyHex !== "string" ||329      !/^[0-9a-fA-F]{64}$/.test(revocation.longTermPublicKeyHex)330    ) {331      throw new DeviceManifestError(332        "malformed",333        "revocation.longTermPublicKeyHex invalid",334      );335    }336    if (typeof revocation.revokedAt !== "number" || !Number.isFinite(revocation.revokedAt)) {337      throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");338    }339    if (!isBoundedIdentifier(revocation.revokedByDeviceId)) {340      throw new DeviceManifestError("malformed", "revocation.revokedByDeviceId invalid");341    }342    if (343      revocation.recordVersion != null &&344      revocation.recordVersion !== DEVICE_REVOCATION_RECORD_VERSION345    ) {346      throw new DeviceManifestError(347        "malformed",348        `revocation.recordVersion unsupported (${revocation.recordVersion})`,349      );350    }351    if (352      revocation.signatureHex != null &&353      (354        typeof revocation.signatureHex !== "string" ||355        !fitsUtf8Budget(revocation.signatureHex, MAX_REVOCATION_SIGNATURE_BYTES)356      )357    ) {358      throw new DeviceManifestError("malformed", "revocation.signatureHex invalid");359    }360  }361}362363/**364 * Verify a manifest's signature, version, and freshness window. Throws on365 * any failure; returns the body on success. The caller is responsible for366 * looking up the issuer's signing pubkey from the paired-device registry367 * and passing it in — that lookup is what enforces "manifest from a known368 * device" (§4.3 final paragraph).369 */370export async function verifyDeviceManifest(371  manifest: SignedDeviceManifest,372  issuerSigningPubkey: Uint8Array,373  options: { now?: number; freshnessMs?: number } = {},374): Promise<DeviceManifestBody> {375  const now = options.now ?? Date.now();376  const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;377  preflightDeviceManifest(manifest, now, freshness);378379  const signature = hexToBytes(manifest.signatureHex);380  const canonical = canonicalise(manifest.body);381  const ok = await verifyEd25519(382    issuerSigningPubkey,383    new TextEncoder().encode(canonical),384    signature,385  );386  if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");387  return manifest.body;388}389390function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {391  const normalised: DeviceRevocation = {392    deviceId: r.deviceId,393    longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),394    revokedAt: r.revokedAt,395    revokedByDeviceId: r.revokedByDeviceId,396    recordVersion: r.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,397  };398  if (r.signatureHex != null) normalised.signatureHex = r.signatureHex.toLowerCase();399  return normalised;400}401402function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {403  const normalised: ManifestDeviceEntry = {404    deviceId: d.deviceId,405    deviceName: capDeviceName(d.deviceName),406    deviceType: d.deviceType,407    longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),408    signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),409    pairedAt: d.pairedAt,410    vouchDepth: d.vouchDepth ?? 0,411  };412  if (d.wsPort != null) normalised.wsPort = d.wsPort;413  if (d.relayUrl != null) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);414  return normalised;415}416417/**418 * Canonical JSON serialiser. Sorts object keys, no trailing whitespace,419 * stable number formatting. The serialiser intentionally only handles the420 * narrow set of shapes that occur inside a `DeviceManifestBody` — extending421 * it later means extending these branches in lockstep with the schema.422 */423export function canonicalise(value: unknown): string {424  if (value === null) return "null";425  if (typeof value === "number") {426    if (!Number.isFinite(value)) throw new Error("non-finite number");427    return Number.isInteger(value) ? value.toString() : value.toString();428  }429  if (typeof value === "string") return JSON.stringify(value);430  if (typeof value === "boolean") return value ? "true" : "false";431  if (Array.isArray(value)) {432    return "[" + value.map((v) => canonicalise(v)).join(",") + "]";433  }434  if (typeof value === "object") {435    const obj = value as Record<string, unknown>;436    const keys = Object.keys(obj).sort();437    // ponytail: skip null values so canonical bytes match Android's438    //            manifestEntryMap, which omits wsPort and relayUrl when null.439    const present = keys.filter((k) => obj[k] !== null);440    return (441      "{" +442      present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +443      "}"444    );445  }446  throw new Error(`cannot canonicalise ${typeof value}`);447}448449function bytesToHex(b: Uint8Array): string {450  let s = "";451  for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");452  return s;453}454455function hexToBytes(hex: string): Uint8Array {456  if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");457  const out = new Uint8Array(hex.length / 2);458  for (let i = 0; i < out.length; i++) {459    const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);460    if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");461    out[i] = b;462  }463  return out;464}