adoption-protocol.ts

13.0 KB · raw

1import { computeVaultId } from "./sync-identity";2import { capDeviceName } from "./sync-limits";3import type { DeviceType, PairedDevice } from "./sync-types";4import type { LocalIdentity, PairingTransport } from "./pairing-handshake";5import { receiveFrame } from "./vault-adopt";67export const ADOPTION_VERSION = 2;8export type AdoptionRejectReason = "ADOPT_PRO_REQUIRED" | "ADOPT_DEVICE_ID_COLLISION";9export interface AdoptRequestV2 {10  type: "ADOPT_REQUEST_V2";11  adoptionVersion: 2;12  device_id: string;13  device_name: string;14  device_type: DeviceType;15  long_term_public_key_hex: string;16  signing_public_key_hex: string;17  license?: string;18  snapshot_capable?: boolean;19}20export interface AdoptConfirmRequired {21  type: "ADOPT_CONFIRM_REQUIRED";22}23export interface AdoptConfirm {24  type: "ADOPT_CONFIRM";25  confirmed: boolean;26  /**27   * The receiver's Pro key. It rides the confirm frame — sent only after the28   * SAS comparison — rather than the first request: a peer that controls the29   * scanned invitation could otherwise read the key and walk away without ever30   * being authenticated (PB-26-121). Mirrors Android's AdoptionFrame.Confirm.31   */32  license?: string;33}34export interface AdoptPrepare {35  type: "ADOPT_PREPARE";36  device_id: string;37  device_name: string;38  device_type: DeviceType;39  long_term_public_key_hex: string;40  signing_public_key_hex: string;41  vault_id: string;42  ws_port?: number;43  relay_url?: string;44  license?: string;45  snapshot_capable?: boolean;46}47export interface AdoptReady {48  type: "ADOPT_READY";49}50export interface AdoptGrantV2 {51  type: "ADOPT_GRANT";52  root_key_hex: string;53  vault_id: string;54  license?: string;55}56export interface AdoptApplied {57  type: "ADOPT_APPLIED";58}59export interface AdoptComplete {60  type: "ADOPT_COMPLETE";61}62export interface AdoptRejectV2 {63  type: "ADOPT_REJECT";64  reason: AdoptionRejectReason;65}6667const hex = (v: unknown): v is string => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);68const device = (v: unknown): v is DeviceType => v === "extension" || v === "android" || v === "ios";69const relay = (v: unknown): v is string => {70  if (typeof v !== "string") return false;71  try {72    return new URL(v).protocol === "wss:";73  } catch {74    return false;75  }76};77export function isAdoptRequestV2(v: unknown): v is AdoptRequestV2 {78  const x = v as Partial<AdoptRequestV2> | null;79  return !!x && x.type === "ADOPT_REQUEST_V2" && x.adoptionVersion === 2 &&80    typeof x.device_id === "string" && x.device_id.length > 0 &&81    typeof x.device_name === "string" && device(x.device_type) &&82    hex(x.long_term_public_key_hex) && hex(x.signing_public_key_hex) &&83    (x.license === undefined || typeof x.license === "string") &&84    (x.snapshot_capable === undefined || typeof x.snapshot_capable === "boolean");85}86export function isAdoptPrepare(v: unknown): v is AdoptPrepare {87  const x = v as Partial<AdoptPrepare> | null;88  return !!x && x.type === "ADOPT_PREPARE" && typeof x.device_id === "string" &&89    x.device_id.length > 0 &&90    typeof x.device_name === "string" && device(x.device_type) && hex(x.long_term_public_key_hex) &&91    hex(x.signing_public_key_hex) && hex(x.vault_id) &&92    (x.ws_port === undefined ||93      (Number.isInteger(x.ws_port) && (x.ws_port as number) >= 1 && (x.ws_port as number) <= 65_535)) &&94    (x.relay_url === undefined || relay(x.relay_url)) &&95    (x.license === undefined || typeof x.license === "string") &&96    (x.snapshot_capable === undefined || typeof x.snapshot_capable === "boolean");97}98export function isAdoptRejectV2(v: unknown): v is AdoptRejectV2 {99  const x = v as Partial<AdoptRejectV2> | null;100  return !!x && x.type === "ADOPT_REJECT" &&101    (x.reason === "ADOPT_PRO_REQUIRED" || x.reason === "ADOPT_DEVICE_ID_COLLISION");102}103104export interface AdoptionV2ReceiverDeps {105  deviceName: string;106  localIdentity(): Promise<LocalIdentity>;107  rotateLocalIdentity(): Promise<LocalIdentity>;108  license(): Promise<string | undefined>;109  confirmSas(): Promise<boolean>;110  validateSource(prepare: AdoptPrepare): Promise<void>;111  hasSourceIdentityCollision(prepare: AdoptPrepare): Promise<boolean>;112  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;113  applyLicense(license: string): Promise<void>;114  upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise<void>;115  pairState?: { relayUrl?: string; rendezvousSecretHex?: string };116  onError(error: string): void;117}118export interface AdoptionV2SourceDeps {119  sourceIdentity: LocalIdentity;120  rootKeyHex(): Promise<string>;121  vaultId(): Promise<string>;122  license(): Promise<string | undefined>;123  applyLicense(license: string): Promise<void>;124  onConsent(request: AdoptRequestV2): Promise<boolean>;125  persistPendingIdentity(device: PairedDevice, nowMs: number): Promise<boolean>;126  completePendingIdentity(device: PairedDevice): Promise<void>;127  hasDeviceIdentityCollision(device: PairedDevice): Promise<boolean>;128  rotateSourceIdentity(): Promise<LocalIdentity>;129  upsertPairedDevice(device: PairedDevice): Promise<void>;130  snapshotCapable(): boolean;131  pairState?: { relayUrl?: string; rendezvousSecretHex?: string };132}133134function toDevice(135  i: {136    device_id: string;137    device_name: string;138    device_type: DeviceType;139    long_term_public_key_hex: string;140    signing_public_key_hex: string;141  },142  deps: { pairState?: { relayUrl?: string; rendezvousSecretHex?: string } },143): PairedDevice {144  return {145    deviceId: i.device_id,146    deviceName: capDeviceName(i.device_name),147    deviceType: i.device_type,148    longTermPublicKeyHex: i.long_term_public_key_hex,149    signingPublicKeyHex: i.signing_public_key_hex,150    trustSource: "direct",151    relayUrl: deps.pairState?.relayUrl,152    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,153    pairedAt: Date.now(),154  };155}156function requestFrame(i: LocalIdentity): AdoptRequestV2 {157  return {158    type: "ADOPT_REQUEST_V2",159    adoptionVersion: 2,160    device_id: i.deviceId,161    device_name: i.deviceName,162    device_type: i.deviceType,163    long_term_public_key_hex: i.longTermPublicKeyHex,164    signing_public_key_hex: i.signingPublicKeyHex ?? "",165    snapshot_capable: true,166  };167}168async function sendV2(channel: PairingTransport, frame: unknown): Promise<void> {169  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));170}171function hexToBytes(hexValue: string): Uint8Array {172  const bytes = new Uint8Array(hexValue.length / 2);173  for (let i = 0; i < bytes.length; i++) {174    bytes[i] = Number.parseInt(hexValue.slice(i * 2, i * 2 + 2), 16);175  }176  return bytes;177}178179export async function requestAdoptionV2(180  channel: PairingTransport,181  deps: AdoptionV2ReceiverDeps,182): Promise<{ device: PairedDevice; snapshotCapable: boolean } | null> {183  let identity = await deps.localIdentity();184  const ownLicense = await deps.license();185  await sendV2(channel, requestFrame(identity));186  if ((await receiveFrame(channel) as Partial<AdoptConfirmRequired> | null)?.type !== "ADOPT_CONFIRM_REQUIRED") {187    deps.onError("ADOPT_UNEXPECTED_FRAME");188    return null;189  }190  const confirmed = await deps.confirmSas();191  // The Pro key travels only after the SAS decision (PB-26-121).192  await sendV2(channel, { type: "ADOPT_CONFIRM", confirmed, license: ownLicense });193  if (!confirmed) {194    deps.onError("ADOPT_SAS_REJECTED");195    return null;196  }197  let frame = await receiveFrame(channel);198  if (isAdoptRejectV2(frame) && frame.reason === "ADOPT_DEVICE_ID_COLLISION") {199    identity = await deps.rotateLocalIdentity();200    await sendV2(channel, requestFrame(identity));201    frame = await receiveFrame(channel);202    if (isAdoptRejectV2(frame)) {203      deps.onError(frame.reason);204      return null;205    }206  }207  if (isAdoptRejectV2(frame)) {208    deps.onError(frame.reason);209    return null;210  }211  if (!isAdoptPrepare(frame)) {212    deps.onError("ADOPT_UNEXPECTED_FRAME");213    return null;214  }215  await deps.validateSource(frame);216  if (await deps.hasSourceIdentityCollision(frame)) {217    await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });218    const retry = await receiveFrame(channel);219    if (!isAdoptPrepare(retry) || await deps.hasSourceIdentityCollision(retry)) {220      await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });221      deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");222      return null;223    }224    await deps.validateSource(retry);225    frame = retry;226  }227  if (!isAdoptPrepare(frame)) {228    deps.onError("ADOPT_UNEXPECTED_FRAME");229    return null;230  }231  if (!ownLicense && !frame.license) {232    deps.onError("ADOPT_PRO_REQUIRED");233    return null;234  }235  await sendV2(channel, { type: "ADOPT_READY" });236  const grant = await receiveFrame(channel);237  if (!grant || (grant as AdoptGrantV2).type !== "ADOPT_GRANT") {238    deps.onError("ADOPT_UNEXPECTED_FRAME");239    return null;240  }241  const g = grant as AdoptGrantV2;242  if (!hex(g.root_key_hex) || !hex(g.vault_id)) {243    deps.onError("ADOPT_UNEXPECTED_FRAME");244    return null;245  }246  const rootBytes = hexToBytes(g.root_key_hex);247  let expected: string;248  try {249    expected = await computeVaultId(rootBytes);250  } finally {251    rootBytes.fill(0);252  }253  if (expected !== g.vault_id || g.vault_id !== frame.vault_id) {254    deps.onError("ADOPT_VAULT_ID_MISMATCH");255    return null;256  }257  const created = await deps.createVaultFromRootKey(g.root_key_hex);258  if (created.vaultId !== g.vault_id) {259    deps.onError("ADOPT_VAULT_ID_MISMATCH");260    return null;261  }262  if (!ownLicense && g.license) await deps.applyLicense(g.license);263  const source = toDevice(frame, deps);264  await deps.upsertPairedDeviceAndClearRevocation(source);265  await sendV2(channel, { type: "ADOPT_APPLIED" });266  if (!((await receiveFrame(channel) as AdoptComplete).type === "ADOPT_COMPLETE")) {267    deps.onError("ADOPT_COMPLETION_MISSING");268    return null;269  }270  return { device: source, snapshotCapable: frame.snapshot_capable === true };271}272273export async function grantAdoptionV2(274  channel: PairingTransport,275  request: AdoptRequestV2,276  deps: AdoptionV2SourceDeps,277): Promise<PairedDevice | null> {278  let receiver = toDevice(request, deps);279  await sendV2(channel, { type: "ADOPT_CONFIRM_REQUIRED" });280  if (!await deps.onConsent(request)) return null;281  const confirmation = await receiveFrame(channel);282  if (283    !confirmation || (confirmation as AdoptConfirm).type !== "ADOPT_CONFIRM" ||284    !(confirmation as AdoptConfirm).confirmed285  ) return null;286  const confirmed = confirmation as AdoptConfirm;287  if (await deps.hasDeviceIdentityCollision(receiver)) {288    await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });289    const retry = await receiveFrame(channel);290    if (!isAdoptRequestV2(retry)) return null;291    receiver = toDevice(retry, deps);292    if (await deps.hasDeviceIdentityCollision(receiver)) {293      await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });294      return null;295    }296    return grantAfterIdentity(channel, retry, receiver, deps, confirmed);297  }298  return grantAfterIdentity(channel, request, receiver, deps, confirmed);299}300async function grantAfterIdentity(301  channel: PairingTransport,302  request: AdoptRequestV2,303  receiver: PairedDevice,304  deps: AdoptionV2SourceDeps,305  confirmation: AdoptConfirm,306): Promise<PairedDevice | null> {307  let license = await deps.license();308  // The receiver's key arrives on the confirm frame, after both SAS decisions309  // (PB-26-121). request.license is read only for a peer built before that change.310  const receiverLicense = confirmation.license ?? request.license;311  if (!license && receiverLicense) {312    try {313      await deps.applyLicense(receiverLicense);314      license = await deps.license();315    } catch {316      license = undefined;317    }318  }319  if (!license) {320    await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" });321    return null;322  }323  if (!await deps.persistPendingIdentity(receiver, Date.now())) return null;324  const root = await deps.rootKeyHex();325  if (!hex(root)) return null;326  let source = deps.sourceIdentity;327  for (let attempt = 0; attempt < 2; attempt++) {328    await sendV2(channel, {329      type: "ADOPT_PREPARE",330      device_id: source.deviceId,331      device_name: source.deviceName,332      device_type: source.deviceType,333      long_term_public_key_hex: source.longTermPublicKeyHex,334      signing_public_key_hex: source.signingPublicKeyHex ?? "",335      vault_id: await deps.vaultId(),336      relay_url: deps.pairState?.relayUrl,337      ws_port: source.wsPort,338      license,339      snapshot_capable: deps.snapshotCapable(),340    });341    const reply = await receiveFrame(channel);342    if (isAdoptRejectV2(reply) && reply.reason === "ADOPT_DEVICE_ID_COLLISION" && attempt === 0) {343      source = await deps.rotateSourceIdentity();344      continue;345    }346    if ((reply as Partial<AdoptReady> | null)?.type !== "ADOPT_READY") return null;347    break;348  }349  await sendV2(channel, {350    type: "ADOPT_GRANT",351    root_key_hex: root,352    vault_id: await deps.vaultId(),353    license,354  });355  if ((await receiveFrame(channel) as Partial<AdoptApplied> | null)?.type !== "ADOPT_APPLIED") {356    return null;357  }358  await deps.upsertPairedDevice(receiver);359  await deps.completePendingIdentity(receiver);360  await sendV2(channel, { type: "ADOPT_COMPLETE" });361  return receiver;362}