relay-adoption-bg.ts

28.0 KB · raw

1import { generateKeyPair } from "@stablelib/x25519";2import {3  createPairingInvitation,4  pairingInvitationUrl,5  parsePairingInvitation,6} from "../shared/pairing-invitation";7import {8  openRelayPairingSocket,9  type PairingSocket,10} from "../shared/pairing-dial";11import {12  noiseNKResponder,13  type NoiseNKSession,14} from "../shared/noise-js";15import {16  grantAdoption,17  isAdoptDeviceManifestFrame,18  isAdoptRequest,19  isAdoptSnapshotApplied,20  receiveFrame,21  receiveVaultSnapshot,22  sendFrame,23  sendVaultSnapshot,24  type AdoptionSourceDeps,25} from "../shared/vault-adopt";26import {27  grantAdoptionV2,28  isAdoptRequestV2,29  requestAdoptionV2,30  type AdoptionV2ReceiverDeps,31  type AdoptionV2SourceDeps,32} from "../shared/adoption-protocol";33import {34  derivePairingSAS,35  type PairingTransport,36} from "../shared/pairing-handshake";37import type { PairedDevice } from "../shared/sync-types";38import {39  bytesToHex,40  deriveRendezvousSecret,41  generatePairingRendezvousToken,42} from "../shared/rendezvous";43import {44  buildLocalIdentity,45  getDeviceName,46  getLocalPairingIdentity,47  preparePreVaultIdentity,48  readPreVaultIdentity,49  rotatePreVaultIdentity,50  rotateDeviceId,51} from "./device-identity";52import {53  browserGenerationIo,54  isPostReset,55  onDirectV2PairingEstablished,56} from "./sync-generation";57import {58  hasAdoptionIdentityConflict,59  markLastSyncedAt,60  upsertPairedDeviceAndClearRevocation,61} from "./paired-devices";62import { getSyncSettings, relayInForce } from "./sync-settings";63import { activateLicense, getLicenseKey, isProUser } from "./license";64import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";65import { deferAutoLockForOperation } from "./alarms";66import { computeVaultId } from "../shared/sync-identity";67import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";68import { storageRemove, storageSet } from "../shared/storage";69import {70  persistPendingAdoptionHolder,71  persistPendingAdoptionIdentity,72  readPendingAdoptionHolder,73  removePendingAdoptionHolder,74  completePendingAdoptionIdentity,75} from "./pending-adoption-holder";76import { isValidPairSecret, setPairSecret } from "./pair-secrets";77import { createSyncVaultAdapter } from "./sync-vault-adapter";78import { triggerSyncIfReachable } from "./sync-trigger";79import { schedulePostAdoptionSync } from "./sync-alarm";80import type { LocalIdentity } from "../shared/pairing-handshake";8182const SOURCE_WINDOW_MS = 5 * 60 * 1000;8384function push(type: string, payload: unknown): void {85  try {86    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;87    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {88      (maybe as Promise<unknown>).catch(() => undefined);89    }90  } catch {91    // The popup may have closed. The pending approval fails closed on cleanup.92  }93}9495/** Race a promise against a wall-clock deadline; rejects with "timeout". */96function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {97  return new Promise<T>((resolve, reject) => {98    const timer = setTimeout(() => reject(new Error("timeout")), ms);99    promise.then(100      (value) => {101        clearTimeout(timer);102        resolve(value);103      },104      (error) => {105        clearTimeout(timer);106        reject(error);107      },108    );109  });110}111112let inFlight = false;113let activeSocket: PairingSocket | null = null;114// Outstanding source-side SAS confirmations, keyed by id. A map (vs the115// receiver's single slot) makes a stale decision from a previous source116// session impossible to land on the current one even if both run on the117// same background worker.118const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();119const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();120121export function isRelayAdoptionSourceInFlight(): boolean {122  return inFlight;123}124125/**126 * Occupy the non-buffering room before returning the invitation to the UI.127 * All long-term key material remains in the background service worker.128 */129export async function startRelayAdoptionSource(): Promise<{130  invitation: string;131}> {132  if (inFlight) throw new Error("adoption_source_in_progress");133  if (!isUnlocked()) throw new Error("vault_locked");134  inFlight = true;135  const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);136137  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;138  try {139    const settings = await getSyncSettings();140    const relayUrl = relayInForce(settings);141    local = await getLocalPairingIdentity();142    const token = generatePairingRendezvousToken();143    // Source side has the vault — caller checked isUnlocked — so the phone144    // can read the invitation and know it should receive the vault.145    const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));146    const invitation = parsePairingInvitation(encoded);147    if (!invitation) throw new Error("invitation_generation_failed");148149    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));150    activeSocket = socket;151    const readyLocal = local;152    void runSourceSession(153      socket,154      token,155      relayUrl,156      readyLocal,157    ).finally(() => {158      readyLocal.privateKey.fill(0);159      readyLocal.publicKey.fill(0);160      clearState();161      releaseAutoLock();162    });163    return { invitation: encoded };164  } catch (error) {165    local?.privateKey.fill(0);166    local?.publicKey.fill(0);167    clearState();168    releaseAutoLock();169    throw error;170  }171}172173export function cancelRelayAdoptionSource(): void {174  activeSocket?.close();175  clearState();176}177178/**179 * Onboarding pairing request — the empty extension shows a QR and the populated180 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the181 * phone reads to switch into source mode and `grantAdoption` over the182 * established Noise channel. The receiver's `runReceiverSession` accepts the183 * root key and creates a local vault wrapped with the popup-supplied password.184 *185 * `password` is the local-only password the user picked on the "Set a password186 * for this device" screen; it wraps the adopted root key at rest and has no187 * relationship to the source device's password.188 */189export async function startRelayAdoptionReceiver(190  password: string,191): Promise<{ invitation: string }> {192  if (inFlight) throw new Error("adoption_receiver_in_progress");193  if (typeof password !== "string" || password.length === 0) {194    throw new Error("ADOPT_INVALID_PASSWORD");195  }196  inFlight = true;197  try {198    await preparePreVaultIdentity(password);199    const settings = await getSyncSettings();200    const relayUrl = relayInForce(settings);201    const token = generatePairingRendezvousToken();202    const ephemeral = generateKeyPair();203    const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));204    const invitation = parsePairingInvitation(encoded);205    if (!invitation) throw new Error("invitation_generation_failed");206    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));207    activeSocket = socket;208    void runReceiverSession(209      socket,210      token,211      relayUrl,212      password,213      ephemeral,214    ).finally(() => {215      ephemeral.secretKey.fill(0);216      ephemeral.publicKey.fill(0);217      clearState();218    });219    return { invitation: encoded };220  } catch (error) {221    clearState();222    throw error;223  }224}225226/**227 * Resolves the source-side SAS confirmation by id. Called by the message228 * handler when the popup taps Confirm/Cancel on the source's SAS screen.229 * Returns true if the id matched an in-flight confirmation. Unknown ids230 * (already resolved, timed out, or never existed) are a no-op so a late231 * popup click cannot crash anything.232 */233export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {234  const pending = pendingSourceSasConfirmation.get(id);235  if (!pending) return false;236  pendingSourceSasConfirmation.delete(id);237  pending.resolve(ok);238  return true;239}240241export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {242  const pending = pendingReceiverSasConfirmation.get(id);243  if (!pending) return false;244  pendingReceiverSasConfirmation.delete(id);245  pending.resolve(ok && constantTimeEqual(pending.sas, sas));246  return true;247}248249async function runSourceSession(250  socket: PairingSocket,251  expectedToken: string,252  relayUrl: string,253  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,254): Promise<void> {255  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);256  let noise: NoiseNKSession | undefined;257  try {258    const hello = decodeJson<{ type?: unknown; token?: unknown }>(259      await socket.receive(),260    );261    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||262        !constantTimeEqual(hello.token, expectedToken)) {263      throw new Error("invalid_invitation");264    }265    socket.send(encodeJson({266      type: "PEACH_HELLO_ACK",267      ephemeralPublicKeyHex: bytesToHex(local.publicKey),268    }));269270    const session = await noiseNKResponder(271      local.privateKey,272      local.publicKey,273      async (bytes) => socket.send(bytes),274      () => socket.receive(),275    );276    noise = session;277    const channel: PairingTransport = {278      async send(plaintext) { socket.send(session.encrypt(plaintext)); },279      async receive() { return session.decrypt(await socket.receive()); },280    };281    const request = await receiveFrame(channel);282    if (!isAdoptRequest(request) && !isAdoptRequestV2(request)) {283      throw new Error("expected_ADOPT_REQUEST");284    }285286    const sourceDeps = buildSourceSessionDeps({287      identity: local.identity,288      rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),289      relayUrl,290      sas: derivePairingSAS(session.handshakeHash),291    });292    // §11.6: after a secure reset a v1 peer is repair-required, exactly as an293    // outdated sync peer is. Refused before any grant. A new ADOPT_REJECT294    // reason is not an option — §10.3 freezes the set to the two v1 peers295    // already understand — so the refusal is local and the peer simply fails.296    if (isAdoptRequest(request) && (await isPostReset(browserGenerationIo))) {297      push("ADOPTION_SOURCE_RESULT", {298        status: "error",299        error: "peer_protocol_outdated",300      });301      return;302    }303    const device = isAdoptRequestV2(request)304      ? await grantAdoptionV2(channel, request, sourceDeps)305      : await grantAdoption(channel, request, sourceDeps);306    if (!device) {307      push("ADOPTION_SOURCE_RESULT", {308        status: "error",309        error: "adoption_rejected",310      });311      return;312    }313    // If the receiver advertised inline-snapshot support, send the vault now314    // over the same authenticated channel. Best-effort: any failure leaves the315    // receiver to fall back to the sync pipeline triggered below.316    if (request.snapshot_capable === true) {317      try {318        const adapter = createSyncVaultAdapter();319        const entryManifest = await adapter.buildEntryManifest();320        const entryIds = Object.keys(entryManifest);321        const entries =322          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};323        const tombstones = await adapter.buildTombstoneManifest();324        let duressSlot: string | null = null;325        try {326          duressSlot = await exportDuressSlotForAdoption();327        } catch {328          // Duress omission must not fail the whole snapshot.329        }330        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);331332        const applied = await withTimeout(receiveFrame(channel), 15_000);333        if (isAdoptSnapshotApplied(applied)) {334          console.info(335            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,336          );337        }338339        if (adapter.buildOutgoingDeviceManifest) {340          const manifest = await adapter.buildOutgoingDeviceManifest();341          if (manifest) {342            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });343            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);344          }345        }346      } catch (snapshotError) {347        if (348          snapshotError instanceof Error &&349          snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"350        ) {351          throw snapshotError;352        }353        console.info(354          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",355          snapshotError,356        );357      }358    }359    push("ADOPTION_SOURCE_RESULT", { status: "complete" });360    // The Android receiver starts its relay listener as soon as its source361    // record is durable. Immediate best-effort trigger; alarm-based retries362    // cover MV3 service-worker death (setTimeout does not survive it).363    void triggerSyncIfReachable({ reason: "manual", force: true });364    schedulePostAdoptionSync();365  } catch (error) {366    push("ADOPTION_SOURCE_RESULT", {367      status: "error",368      error: error instanceof Error ? error.message : "adoption_failed",369    });370  } finally {371    clearTimeout(timeout);372    noise?.close();373    socket.close();374    pendingSourceSasConfirmation.clear();375  }376}377378/**379 * Browser receives the vault from the phone. The protocol is the mirror of380 * the source side: phone is the Noise initiator and the source of truth for381 * the SAS gate, browser is the responder that creates the vault locally.382 */383async function runReceiverSession(384  socket: PairingSocket,385  expectedToken: string,386  relayUrl: string,387  password: string,388  invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },389): Promise<void> {390  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);391  // The invitation binds the responder static so the initiator can verify the ACK.392  const ephemeral = invitationEphemeral;393  const ephemeralPrivate = ephemeral.secretKey;394  const ephemeralPublic = ephemeral.publicKey;395  let noise: NoiseNKSession | undefined;396  let createdVault = false;397  let protocolError: string | undefined;398  try {399    const hello = decodeJson<{ type?: unknown; token?: unknown }>(400      await socket.receive(),401    );402    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||403        !constantTimeEqual(hello.token, expectedToken)) {404      throw new Error("invalid_invitation");405    }406    socket.send(encodeJson({407      type: "PEACH_HELLO_ACK",408      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),409    }));410411    const session = await noiseNKResponder(412      ephemeralPrivate,413      ephemeralPublic,414      async (bytes) => socket.send(bytes),415      () => socket.receive(),416    );417    noise = session;418    const channel: PairingTransport = {419      async send(plaintext) { socket.send(session.encrypt(plaintext)); },420      async receive() { return session.decrypt(await socket.receive()); },421    };422423    const deviceName = await getDeviceName();424425    const receiverDeps: AdoptionV2ReceiverDeps = {426      deviceName,427      async confirmSas() {428        const sas = derivePairingSAS(session.handshakeHash);429        const id = crypto.randomUUID();430        return waitForReceiverSasConfirmation(id, sas);431      },432      async createVaultFromRootKey(rootKeyHex) {433        // Direct call, not chrome.runtime.sendMessage — a service worker434        // calling sendMessage does not reach its own listener (returns435        // undefined), and the receiver already lives in the same SW context436        // as the vault module. Round-tripping through the message bus was437        // the root cause of "create_vault_no_response".438        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);439        createdVault = true;440        return { vaultId };441      },442      async applyLicense(license) {443        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self444        // issue as createVaultFromRootKey. SW can't reach its own listener.445        await applyTransferredLicense(license);446      },447      async license() {448        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;449      },450      async rotateLocalIdentity() {451        return await rotatePreVaultIdentity(password);452      },453      async hasSourceIdentityCollision(source) {454        return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);455      },456      pairState: {457        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),458        relayUrl,459      },460      onError(error) {461        protocolError = error;462      },463      async localIdentity() {464        return await readPreVaultIdentity(password);465      },466      async validateSource() {},467      async upsertPairedDeviceAndClearRevocation(paired: PairedDevice) {468        await storeAdoptedDevice(paired);469      },470    };471    const adoptionResult = await requestAdoptionV2(channel, receiverDeps);472473    if (!adoptionResult) {474      if (createdVault) {475        await rollbackAdoptedVault();476        createdVault = false;477      }478      push("ADOPTION_SOURCE_RESULT", {479        status: "error",480        error: protocolError ?? "adoption_rejected",481      });482      return;483    }484    const { device, snapshotCapable } = adoptionResult;485    // The pairing channel is still open and authenticated — try to pull the486    // vault snapshot inline so the popup transitions immediately instead of487    // waiting on a second sync round. Skip the wait entirely when the source488    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);489    // fall through to the existing sync pipeline below.490    let snapshotApplied = false;491    if (snapshotCapable) try {492      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);493      const adapter = createSyncVaultAdapter();494      await adapter.applyEntries(snapshot.entries);495      await adapter.applyTombstones(snapshot.tombstones);496      // Duress install is isolated: a failure here must NOT demote an497      // otherwise-successful entry snapshot to the sync fallback.498      if (snapshot.duressSlot) {499        try {500          await installAdoptedDuressSlot(snapshot.duressSlot);501        } catch (duressError) {502          console.info(503            "[Peach] adopted duress slot install failed (non-fatal):",504            duressError,505          );506        }507      }508      await sendFrame(channel, {509        type: "ADOPT_SNAPSHOT_APPLIED",510        entries_received: Object.keys(snapshot.entries).length,511        tombstones_received: Object.keys(snapshot.tombstones).length,512      });513514      // Optional device graph. Non-fatal and isolated so a manifest timeout or515      // unsupported source cannot demote an otherwise-successful entry snapshot.516      try {517        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);518        if (519          isAdoptDeviceManifestFrame(manifestFrame) &&520          adapter.applyIncomingDeviceManifest &&521          typeof device.longTermPublicKeyHex === "string" &&522          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)523        ) {524          await adapter.applyIncomingDeviceManifest(525            manifestFrame.manifest,526            hexToBytes(device.longTermPublicKeyHex),527          );528          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });529        }530      } catch {531        // The graph arrives via incremental sync instead.532      }533534      await markLastSyncedAt(device.deviceId, Date.now());535      snapshotApplied = true;536    } catch (snapshotError) {537      console.info(538        "[Peach] inline snapshot failed, falling back to sync pipeline:",539        snapshotError,540      );541    }542543    // Adoption has committed identity and vault-key material, but entries have544    // not necessarily crossed yet. Persist the exact source row before telling545    // the popup pairing is complete so a popup close/reopen cannot reveal the546    // temporarily empty receiver vault. The popup clears this only after that547    // row records a successful sync.548    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(549      () => undefined,550    );551    push("ADOPTION_SOURCE_RESULT", { status: "complete" });552    createdVault = false;553554    if (snapshotApplied) {555      // The snapshot populated the vault and marked the source synced, so the556      // pending marker's job is done — clear it instead of making the popup wait.557      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);558    } else {559      // Fallback: the existing sync pipeline fills the vault. Immediate560      // best-effort trigger plus alarm-based retries that survive SW death.561      void triggerSyncIfReachable({ reason: "manual", force: true });562      schedulePostAdoptionSync();563    }564  } catch (error) {565    if (createdVault) {566      try {567        await rollbackAdoptedVault();568        createdVault = false;569      } catch (rollbackError) {570        console.error("[Peach] rollback failed:", rollbackError);571      }572    }573    console.error("[Peach] receiver session failed:", error);574    push("ADOPTION_SOURCE_RESULT", {575      status: "error",576      error: error instanceof Error ? error.message : "adoption_failed",577    });578  } finally {579    clearTimeout(timeout);580    noise?.close();581    socket.close();582    ephemeralPrivate.fill(0);583    ephemeralPublic.fill(0);584  }585}586587/**588 * A transferred entitlement is committed only when activation succeeds and589 * the stored value can immediately be re-read as Pro. activateLicense returns590 * a normal Free result for invalid input, so callers must not ignore it.591 */592async function applyTransferredLicense(license: string): Promise<void> {593  const activated = await activateLicense(license);594  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {595    throw new Error("license_transfer_failed");596  }597}598599/**600 * Push the SAS to the popup, then block until the popup responds via the601 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on602 * timeout or cancel so the adoption aborts before any vault state changes.603 *604 * The pending deferred MUST be installed before the push: a popup-side605 * auto-approver (test mocks, future automation) can synchronously resolve606 * the deferred the moment it sees the SAS push, and a missing slot would607 * drop that approval on the floor.608 */609function waitForReceiverSasConfirmation(610  id: string,611  sas: string,612): Promise<boolean> {613  return new Promise<boolean>((resolve) => {614    const timer = setTimeout(() => {615      pendingReceiverSasConfirmation.delete(id);616      resolve(false);617    }, SOURCE_WINDOW_MS);618    pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {619      clearTimeout(timer);620      resolve(ok);621    } });622    push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });623  });624}625626function waitForSourceSasConfirmation(627  id: string,628  request: { device_name: string },629  sas: string,630): Promise<boolean> {631  return new Promise<boolean>((resolve) => {632    const timer = setTimeout(() => {633      pendingSourceSasConfirmation.delete(id);634      resolve(false);635    }, SOURCE_WINDOW_MS);636    pendingSourceSasConfirmation.set(id, {637      resolve: (ok) => {638        clearTimeout(timer);639        resolve(ok);640      },641    });642    push("ADOPTION_SOURCE_SAS", {643      id,644      sas,645      receiverDeviceName: request.device_name,646    });647  });648}649650/**651 * Opening Add Device in an unlocked extension is the source-side authorization.652 * Announce the SAS for comparison, then let the scanning device's explicit653 * confirmation be the single commit gate. grantAdoption still refuses to send654 * the root key until the receiver returns ADOPT_CONFIRM(true).655 */656function requestConsent(657  request: { device_name: string },658  sas: string,659  id: string,660): Promise<boolean> {661  return waitForSourceSasConfirmation(id, request, sas);662}663664function clearState(): void {665  activeSocket = null;666  inFlight = false;667  pendingSourceSasConfirmation.clear();668  pendingReceiverSasConfirmation.clear();669}670671function constantTimeEqual(a: string, b: string): boolean {672  if (a.length !== b.length) return false;673  let diff = 0;674  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);675  return diff === 0;676}677678function encodeJson(value: unknown): Uint8Array {679  return new TextEncoder().encode(JSON.stringify(value));680}681682/**683 * Bind a source session's callbacks to the live session facts.684 *685 * Extracted from the session function so §10.5 can be proven: the test dirties686 * every member and asserts each one reaches its sink. TypeScript already687 * requires the *non-optional* members of the union, but the optional ones --688 * `persistPendingHolder` above all, which is ordering guarantee §10.4.1 -- would689 * silently disappear from an inline literal. This is where that is caught.690 */691export function buildSourceSessionDeps(input: {692  identity: LocalIdentity;693  rendezvousSecretHex: string;694  relayUrl: string | undefined;695  sas: string;696}): AdoptionSourceDeps & AdoptionV2SourceDeps {697  const { identity, rendezvousSecretHex, relayUrl, sas } = input;698  return {699    sourceIdentity: identity,700    pairState: { rendezvousSecretHex, relayUrl },701    async rootKeyHex() {702      // grantAdoption calls this only after local password approval and the703      // receiver's encrypted ADOPT_CONFIRM(true).704      const value = getRootKeyHex();705      if (!value) throw new Error("vault_locked");706      return value;707    },708    async vaultId() {709      const value = getRootKeyHex();710      if (!value) throw new Error("vault_locked");711      const bytes = hexToBytes(value);712      try {713        return await computeVaultId(bytes);714      } finally {715        bytes.fill(0);716      }717    },718    async license() {719      return (await isProUser()) ? ((await getLicenseKey()) ?? undefined) : undefined;720    },721    async applyLicense(license) {722      await applyTransferredLicense(license);723    },724    onConsent: (adoptRequest) => requestConsent(adoptRequest, sas, crypto.randomUUID()),725    persistPendingHolder: persistPendingAdoptionHolder,726    clearPendingHolder: async (adoptRequest) => {727      const current = await readPendingAdoptionHolder();728      if (current?.request.device_id === adoptRequest.device_id) {729        await removePendingAdoptionHolder();730      }731    },732    async upsertPairedDevice(paired) {733      await storeAdoptedDevice(paired);734    },735    hasDeviceIdentityCollision: async (device) =>736      hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),737    rotateSourceIdentity: async () => {738      await rotateDeviceId();739      return await buildLocalIdentity();740    },741    persistPendingIdentity: persistPendingAdoptionIdentity,742    async completePendingIdentity(paired: PairedDevice) {743      await completePendingAdoptionIdentity(paired);744    },745    snapshotCapable: () => true,746  };747}748749function decodeJson<T>(value: Uint8Array): T {750  return JSON.parse(new TextDecoder().decode(value)) as T;751}752753/**754 * PB-26-213: an adopted device's secret bypasses the public registry into755 * the encrypted pair-secret record. The registry row stays secretless; when756 * the vault is somehow locked here the row still lands and the next unlock757 * cannot recover this secret — re-pair is the fail-closed path.758 */759async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {760  const secret = paired.rendezvousSecretHex;761  const pub = { ...paired };762  delete pub.rendezvousSecretHex;763  // Validate, persist, and clear the terminal tombstone under one registry764  // mutation so a concurrent revoke cannot recreate or lose the tombstone.765  await upsertPairedDeviceAndClearRevocation(pub);766  // §11.3 REPAIRING → V2_ACTIVE. Anything adopted here is a direct pairing: it767  // came from a grant, not from a manifest vouch, and on a device that has768  // reset it can only be v2 — the post-reset source refuses v1 at the first769  // frame. A device that never reset is untouched by this.770  await onDirectV2PairingEstablished(browserGenerationIo, true);771  if (!isValidPairSecret(secret)) return;772  const masterKeyHex = getRootKeyHex();773  if (!masterKeyHex) return;774  const masterKey = hexToBytes(masterKeyHex);775  try {776    await setPairSecret(paired.deviceId, secret, masterKey);777  } finally {778    masterKey.fill(0);779  }780}781782function hexToBytes(hex: string): Uint8Array {783  const out = new Uint8Array(hex.length / 2);784  for (let i = 0; i < out.length; i++) {785    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);786  }787  return out;788}