adoption-protocol.ts

12.3 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}27export interface AdoptPrepare {28  type: "ADOPT_PREPARE";29  device_id: string;30  device_name: string;31  device_type: DeviceType;32  long_term_public_key_hex: string;33  signing_public_key_hex: string;34  vault_id: string;35  ws_port?: number;36  relay_url?: string;37  license?: string;38  snapshot_capable?: boolean;39}40export interface AdoptReady {41  type: "ADOPT_READY";42}43export interface AdoptGrantV2 {44  type: "ADOPT_GRANT";45  root_key_hex: string;46  vault_id: string;47  license?: string;48}49export interface AdoptApplied {50  type: "ADOPT_APPLIED";51}52export interface AdoptComplete {53  type: "ADOPT_COMPLETE";54}55export interface AdoptRejectV2 {56  type: "ADOPT_REJECT";57  reason: AdoptionRejectReason;58}5960const hex = (v: unknown): v is string => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);61const device = (v: unknown): v is DeviceType => v === "extension" || v === "android" || v === "ios";62const relay = (v: unknown): v is string => {63  if (typeof v !== "string") return false;64  try {65    return new URL(v).protocol === "wss:";66  } catch {67    return false;68  }69};70export function isAdoptRequestV2(v: unknown): v is AdoptRequestV2 {71  const x = v as Partial<AdoptRequestV2> | null;72  return !!x && x.type === "ADOPT_REQUEST_V2" && x.adoptionVersion === 2 &&73    typeof x.device_id === "string" && x.device_id.length > 0 &&74    typeof x.device_name === "string" && device(x.device_type) &&75    hex(x.long_term_public_key_hex) && hex(x.signing_public_key_hex) &&76    (x.license === undefined || typeof x.license === "string") &&77    (x.snapshot_capable === undefined || typeof x.snapshot_capable === "boolean");78}79export function isAdoptPrepare(v: unknown): v is AdoptPrepare {80  const x = v as Partial<AdoptPrepare> | null;81  return !!x && x.type === "ADOPT_PREPARE" && typeof x.device_id === "string" &&82    x.device_id.length > 0 &&83    typeof x.device_name === "string" && device(x.device_type) && hex(x.long_term_public_key_hex) &&84    hex(x.signing_public_key_hex) && hex(x.vault_id) &&85    (x.ws_port === undefined ||86      (Number.isInteger(x.ws_port) && (x.ws_port as number) >= 1 && (x.ws_port as number) <= 65_535)) &&87    (x.relay_url === undefined || relay(x.relay_url)) &&88    (x.license === undefined || typeof x.license === "string") &&89    (x.snapshot_capable === undefined || typeof x.snapshot_capable === "boolean");90}91export function isAdoptRejectV2(v: unknown): v is AdoptRejectV2 {92  const x = v as Partial<AdoptRejectV2> | null;93  return !!x && x.type === "ADOPT_REJECT" &&94    (x.reason === "ADOPT_PRO_REQUIRED" || x.reason === "ADOPT_DEVICE_ID_COLLISION");95}9697export interface AdoptionV2ReceiverDeps {98  deviceName: string;99  localIdentity(): Promise<LocalIdentity>;100  rotateLocalIdentity(): Promise<LocalIdentity>;101  license(): Promise<string | undefined>;102  confirmSas(): Promise<boolean>;103  validateSource(prepare: AdoptPrepare): Promise<void>;104  hasSourceIdentityCollision(prepare: AdoptPrepare): Promise<boolean>;105  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;106  applyLicense(license: string): Promise<void>;107  upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise<void>;108  pairState?: { relayUrl?: string; rendezvousSecretHex?: string };109  onError(error: string): void;110}111export interface AdoptionV2SourceDeps {112  sourceIdentity: LocalIdentity;113  rootKeyHex(): Promise<string>;114  vaultId(): Promise<string>;115  license(): Promise<string | undefined>;116  applyLicense(license: string): Promise<void>;117  onConsent(request: AdoptRequestV2): Promise<boolean>;118  persistPendingIdentity(device: PairedDevice, nowMs: number): Promise<boolean>;119  completePendingIdentity(device: PairedDevice): Promise<void>;120  hasDeviceIdentityCollision(device: PairedDevice): Promise<boolean>;121  rotateSourceIdentity(): Promise<LocalIdentity>;122  upsertPairedDevice(device: PairedDevice): Promise<void>;123  snapshotCapable(): boolean;124  pairState?: { relayUrl?: string; rendezvousSecretHex?: string };125}126127function toDevice(128  i: {129    device_id: string;130    device_name: string;131    device_type: DeviceType;132    long_term_public_key_hex: string;133    signing_public_key_hex: string;134  },135  deps: { pairState?: { relayUrl?: string; rendezvousSecretHex?: string } },136): PairedDevice {137  return {138    deviceId: i.device_id,139    deviceName: capDeviceName(i.device_name),140    deviceType: i.device_type,141    longTermPublicKeyHex: i.long_term_public_key_hex,142    signingPublicKeyHex: i.signing_public_key_hex,143    trustSource: "direct",144    relayUrl: deps.pairState?.relayUrl,145    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,146    pairedAt: Date.now(),147  };148}149function requestFrame(i: LocalIdentity, license?: string): AdoptRequestV2 {150  return {151    type: "ADOPT_REQUEST_V2",152    adoptionVersion: 2,153    device_id: i.deviceId,154    device_name: i.deviceName,155    device_type: i.deviceType,156    long_term_public_key_hex: i.longTermPublicKeyHex,157    signing_public_key_hex: i.signingPublicKeyHex ?? "",158    license,159    snapshot_capable: true,160  };161}162async function sendV2(channel: PairingTransport, frame: unknown): Promise<void> {163  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));164}165function hexToBytes(hexValue: string): Uint8Array {166  const bytes = new Uint8Array(hexValue.length / 2);167  for (let i = 0; i < bytes.length; i++) {168    bytes[i] = Number.parseInt(hexValue.slice(i * 2, i * 2 + 2), 16);169  }170  return bytes;171}172173export async function requestAdoptionV2(174  channel: PairingTransport,175  deps: AdoptionV2ReceiverDeps,176): Promise<{ device: PairedDevice; snapshotCapable: boolean } | null> {177  let identity = await deps.localIdentity();178  const ownLicense = await deps.license();179  await sendV2(channel, requestFrame(identity, ownLicense));180  if ((await receiveFrame(channel) as Partial<AdoptConfirmRequired> | null)?.type !== "ADOPT_CONFIRM_REQUIRED") {181    deps.onError("ADOPT_UNEXPECTED_FRAME");182    return null;183  }184  const confirmed = await deps.confirmSas();185  await sendV2(channel, { type: "ADOPT_CONFIRM", confirmed });186  if (!confirmed) {187    deps.onError("ADOPT_SAS_REJECTED");188    return null;189  }190  let frame = await receiveFrame(channel);191  if (isAdoptRejectV2(frame) && frame.reason === "ADOPT_DEVICE_ID_COLLISION") {192    identity = await deps.rotateLocalIdentity();193    await sendV2(channel, requestFrame(identity, ownLicense));194    frame = await receiveFrame(channel);195    if (isAdoptRejectV2(frame)) {196      deps.onError(frame.reason);197      return null;198    }199  }200  if (isAdoptRejectV2(frame)) {201    deps.onError(frame.reason);202    return null;203  }204  if (!isAdoptPrepare(frame)) {205    deps.onError("ADOPT_UNEXPECTED_FRAME");206    return null;207  }208  await deps.validateSource(frame);209  if (await deps.hasSourceIdentityCollision(frame)) {210    await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });211    const retry = await receiveFrame(channel);212    if (!isAdoptPrepare(retry) || await deps.hasSourceIdentityCollision(retry)) {213      await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });214      deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");215      return null;216    }217    await deps.validateSource(retry);218    frame = retry;219  }220  if (!isAdoptPrepare(frame)) {221    deps.onError("ADOPT_UNEXPECTED_FRAME");222    return null;223  }224  if (!ownLicense && !frame.license) {225    deps.onError("ADOPT_PRO_REQUIRED");226    return null;227  }228  await sendV2(channel, { type: "ADOPT_READY" });229  const grant = await receiveFrame(channel);230  if (!grant || (grant as AdoptGrantV2).type !== "ADOPT_GRANT") {231    deps.onError("ADOPT_UNEXPECTED_FRAME");232    return null;233  }234  const g = grant as AdoptGrantV2;235  if (!hex(g.root_key_hex) || !hex(g.vault_id)) {236    deps.onError("ADOPT_UNEXPECTED_FRAME");237    return null;238  }239  const rootBytes = hexToBytes(g.root_key_hex);240  let expected: string;241  try {242    expected = await computeVaultId(rootBytes);243  } finally {244    rootBytes.fill(0);245  }246  if (expected !== g.vault_id || g.vault_id !== frame.vault_id) {247    deps.onError("ADOPT_VAULT_ID_MISMATCH");248    return null;249  }250  const created = await deps.createVaultFromRootKey(g.root_key_hex);251  if (created.vaultId !== g.vault_id) {252    deps.onError("ADOPT_VAULT_ID_MISMATCH");253    return null;254  }255  if (!ownLicense && g.license) await deps.applyLicense(g.license);256  const source = toDevice(frame, deps);257  await deps.upsertPairedDeviceAndClearRevocation(source);258  await sendV2(channel, { type: "ADOPT_APPLIED" });259  if (!((await receiveFrame(channel) as AdoptComplete).type === "ADOPT_COMPLETE")) {260    deps.onError("ADOPT_COMPLETION_MISSING");261    return null;262  }263  return { device: source, snapshotCapable: frame.snapshot_capable === true };264}265266export async function grantAdoptionV2(267  channel: PairingTransport,268  request: AdoptRequestV2,269  deps: AdoptionV2SourceDeps,270): Promise<PairedDevice | null> {271  let receiver = toDevice(request, deps);272  await sendV2(channel, { type: "ADOPT_CONFIRM_REQUIRED" });273  if (!await deps.onConsent(request)) return null;274  const confirmation = await receiveFrame(channel);275  if (276    !confirmation || (confirmation as AdoptConfirm).type !== "ADOPT_CONFIRM" ||277    !(confirmation as AdoptConfirm).confirmed278  ) return null;279  if (await deps.hasDeviceIdentityCollision(receiver)) {280    await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });281    const retry = await receiveFrame(channel);282    if (!isAdoptRequestV2(retry)) return null;283    receiver = toDevice(retry, deps);284    if (await deps.hasDeviceIdentityCollision(receiver)) {285      await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });286      return null;287    }288    return grantAfterIdentity(channel, retry, receiver, deps);289  }290  return grantAfterIdentity(channel, request, receiver, deps);291}292async function grantAfterIdentity(293  channel: PairingTransport,294  request: AdoptRequestV2,295  receiver: PairedDevice,296  deps: AdoptionV2SourceDeps,297): Promise<PairedDevice | null> {298  let license = await deps.license();299  if (!license && request.license) {300    try {301      await deps.applyLicense(request.license);302      license = await deps.license();303    } catch {304      license = undefined;305    }306  }307  if (!license) {308    await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" });309    return null;310  }311  if (!await deps.persistPendingIdentity(receiver, Date.now())) return null;312  const root = await deps.rootKeyHex();313  if (!hex(root)) return null;314  let source = deps.sourceIdentity;315  for (let attempt = 0; attempt < 2; attempt++) {316    await sendV2(channel, {317      type: "ADOPT_PREPARE",318      device_id: source.deviceId,319      device_name: source.deviceName,320      device_type: source.deviceType,321      long_term_public_key_hex: source.longTermPublicKeyHex,322      signing_public_key_hex: source.signingPublicKeyHex ?? "",323      vault_id: await deps.vaultId(),324      relay_url: deps.pairState?.relayUrl,325      ws_port: source.wsPort,326      license,327      snapshot_capable: deps.snapshotCapable(),328    });329    const reply = await receiveFrame(channel);330    if (isAdoptRejectV2(reply) && reply.reason === "ADOPT_DEVICE_ID_COLLISION" && attempt === 0) {331      source = await deps.rotateSourceIdentity();332      continue;333    }334    if ((reply as Partial<AdoptReady> | null)?.type !== "ADOPT_READY") return null;335    break;336  }337  await sendV2(channel, {338    type: "ADOPT_GRANT",339    root_key_hex: root,340    vault_id: await deps.vaultId(),341    license,342  });343  if ((await receiveFrame(channel) as Partial<AdoptApplied> | null)?.type !== "ADOPT_APPLIED") {344    return null;345  }346  await deps.upsertPairedDevice(receiver);347  await deps.completePendingIdentity(receiver);348  await sendV2(channel, { type: "ADOPT_COMPLETE" });349  return receiver;350}