relay-adoption-bg.ts

25.8 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  requestAdoption,23  sendFrame,24  sendVaultSnapshot,25  type AdoptRequest,26} from "../shared/vault-adopt";27import {28  derivePairingSAS,29  type PairingTransport,30} from "../shared/pairing-handshake";31import type { PairedDevice } from "../shared/sync-types";32import {33  bytesToHex,34  deriveRendezvousSecret,35  generatePairingRendezvousToken,36} from "../shared/rendezvous";37import {38  buildLocalIdentity,39  getDeviceName,40  getLocalPairingIdentity,41  rotateDeviceId,42} from "./device-identity";43import { getSyncSettings, relayInForce } from "./sync-settings";44import { activateLicense, getLicenseKey, isProUser } from "./license";45import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";46import { deferAutoLockForOperation } from "./alarms";47import { computeVaultId } from "../shared/sync-identity";48import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";49import { storageRemove, storageSet } from "../shared/storage";50import { markLastSyncedAt, upsertPairedDeviceAndClearRevocation, hasAdoptionIdentityConflict } from "./paired-devices";51import { isValidPairSecret, setPairSecret } from "./pair-secrets";52import { createSyncVaultAdapter } from "./sync-vault-adapter";53import { triggerSyncIfReachable } from "./sync-trigger";54import { schedulePostAdoptionSync } from "./sync-alarm";5556const SOURCE_WINDOW_MS = 5 * 60 * 1000;5758function push(type: string, payload: unknown): void {59  try {60    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;61    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {62      (maybe as Promise<unknown>).catch(() => undefined);63    }64  } catch {65    // The popup may have closed. The pending approval fails closed on cleanup.66  }67}6869/** Race a promise against a wall-clock deadline; rejects with "timeout". */70function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {71  return new Promise<T>((resolve, reject) => {72    const timer = setTimeout(() => reject(new Error("timeout")), ms);73    promise.then(74      (value) => {75        clearTimeout(timer);76        resolve(value);77      },78      (error) => {79        clearTimeout(timer);80        reject(error);81      },82    );83  });84}8586let inFlight = false;87let activeSocket: PairingSocket | null = null;88// Outstanding source-side SAS confirmations, keyed by id. A map (vs the89// receiver's single slot) makes a stale decision from a previous source90// session impossible to land on the current one even if both run on the91// same background worker.92const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();93const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();9495export function isRelayAdoptionSourceInFlight(): boolean {96  return inFlight;97}9899/**100 * Occupy the non-buffering room before returning the invitation to the UI.101 * All long-term key material remains in the background service worker.102 */103export async function startRelayAdoptionSource(): Promise<{104  invitation: string;105}> {106  if (inFlight) throw new Error("adoption_source_in_progress");107  if (!isUnlocked()) throw new Error("vault_locked");108  inFlight = true;109  const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);110111  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;112  try {113    const settings = await getSyncSettings();114    const relayUrl = relayInForce(settings);115    local = await getLocalPairingIdentity();116    const token = generatePairingRendezvousToken();117    // Source side has the vault — caller checked isUnlocked — so the phone118    // can read the invitation and know it should receive the vault.119    const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));120    const invitation = parsePairingInvitation(encoded);121    if (!invitation) throw new Error("invitation_generation_failed");122123    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));124    activeSocket = socket;125    const readyLocal = local;126    void runSourceSession(127      socket,128      token,129      relayUrl,130      readyLocal,131    ).finally(() => {132      readyLocal.privateKey.fill(0);133      readyLocal.publicKey.fill(0);134      clearState();135      releaseAutoLock();136    });137    return { invitation: encoded };138  } catch (error) {139    local?.privateKey.fill(0);140    local?.publicKey.fill(0);141    clearState();142    releaseAutoLock();143    throw error;144  }145}146147export function cancelRelayAdoptionSource(): void {148  activeSocket?.close();149  clearState();150}151152/**153 * Onboarding pairing request — the empty extension shows a QR and the populated154 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the155 * phone reads to switch into source mode and `grantAdoption` over the156 * established Noise channel. The receiver's `runReceiverSession` accepts the157 * root key and creates a local vault wrapped with the popup-supplied password.158 *159 * `password` is the local-only password the user picked on the "Set a password160 * for this device" screen; it wraps the adopted root key at rest and has no161 * relationship to the source device's password.162 */163export async function startRelayAdoptionReceiver(164  password: string,165): Promise<{ invitation: string }> {166  if (inFlight) throw new Error("adoption_receiver_in_progress");167  if (typeof password !== "string" || password.length === 0) {168    throw new Error("ADOPT_INVALID_PASSWORD");169  }170  inFlight = true;171  try {172    const settings = await getSyncSettings();173    const relayUrl = relayInForce(settings);174    const token = generatePairingRendezvousToken();175    const ephemeral = generateKeyPair();176    const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));177    const invitation = parsePairingInvitation(encoded);178    if (!invitation) throw new Error("invitation_generation_failed");179    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));180    activeSocket = socket;181    void runReceiverSession(182      socket,183      token,184      relayUrl,185      password,186      ephemeral,187    ).finally(() => {188      ephemeral.secretKey.fill(0);189      ephemeral.publicKey.fill(0);190      clearState();191    });192    return { invitation: encoded };193  } catch (error) {194    clearState();195    throw error;196  }197}198199/**200 * Resolves the source-side SAS confirmation by id. Called by the message201 * handler when the popup taps Confirm/Cancel on the source's SAS screen.202 * Returns true if the id matched an in-flight confirmation. Unknown ids203 * (already resolved, timed out, or never existed) are a no-op so a late204 * popup click cannot crash anything.205 */206export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {207  const pending = pendingSourceSasConfirmation.get(id);208  if (!pending) return false;209  pendingSourceSasConfirmation.delete(id);210  pending.resolve(ok);211  return true;212}213214export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {215  const pending = pendingReceiverSasConfirmation.get(id);216  if (!pending) return false;217  pendingReceiverSasConfirmation.delete(id);218  pending.resolve(ok && constantTimeEqual(pending.sas, sas));219  return true;220}221222async function runSourceSession(223  socket: PairingSocket,224  expectedToken: string,225  relayUrl: string,226  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,227): Promise<void> {228  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);229  let noise: NoiseNKSession | undefined;230  try {231    const hello = decodeJson<{ type?: unknown; token?: unknown }>(232      await socket.receive(),233    );234    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||235        !constantTimeEqual(hello.token, expectedToken)) {236      throw new Error("invalid_invitation");237    }238    socket.send(encodeJson({239      type: "PEACH_HELLO_ACK",240      ephemeralPublicKeyHex: bytesToHex(local.publicKey),241    }));242243    const session = await noiseNKResponder(244      local.privateKey,245      local.publicKey,246      async (bytes) => socket.send(bytes),247      () => socket.receive(),248    );249    noise = session;250    const channel: PairingTransport = {251      async send(plaintext) { socket.send(session.encrypt(plaintext)); },252      async receive() { return session.decrypt(await socket.receive()); },253    };254    const request = await receiveFrame(channel);255    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");256257    const device = await grantAdoption(channel, request, {258      sourceIdentity: local.identity,259      pairState: {260        rendezvousSecretHex: bytesToHex(261          deriveRendezvousSecret(session.handshakeHash),262        ),263        relayUrl,264      },265      async rootKeyHex() {266        // grantAdoption calls this only after local password approval and the267        // receiver's encrypted ADOPT_CONFIRM(true).268        const value = getRootKeyHex();269        if (!value) throw new Error("vault_locked");270        return value;271      },272      async vaultId() {273        const value = getRootKeyHex();274        if (!value) throw new Error("vault_locked");275        const bytes = hexToBytes(value);276        try {277          return await computeVaultId(bytes);278        } finally {279          bytes.fill(0);280        }281      },282      async license() {283        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;284      },285      async applyLicense(license) {286        await applyTransferredLicense(license);287      },288      onConsent: (adoptRequest) => requestConsent(289        adoptRequest,290        derivePairingSAS(session.handshakeHash),291        crypto.randomUUID(),292      ),293      upsertPairedDevice: storeAdoptedDevice,294      hasDeviceIdentityCollision: async (device) =>295        hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),296      rotateSourceIdentity: async () => {297        await rotateDeviceId();298        const masterKeyHex = getRootKeyHex();299        if (!masterKeyHex) throw new Error("identity_unavailable");300        const masterKey = hexToBytes(masterKeyHex);301        try {302          return await buildLocalIdentity(masterKey);303        } finally {304          masterKey.fill(0);305        }306      },307    });308    if (!device) {309      push("ADOPTION_SOURCE_RESULT", {310        status: "error",311        error: "adoption_rejected",312      });313      return;314    }315    // If the receiver advertised inline-snapshot support, send the vault now316    // over the same authenticated channel. Best-effort: any failure leaves the317    // receiver to fall back to the sync pipeline triggered below.318    if (request.snapshot_capable === true) {319      try {320        const adapter = createSyncVaultAdapter();321        const entryManifest = await adapter.buildEntryManifest();322        const entryIds = Object.keys(entryManifest);323        const entries =324          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};325        const tombstones = await adapter.buildTombstoneManifest();326        let duressSlot: string | null = null;327        try {328          duressSlot = await exportDuressSlotForAdoption();329        } catch {330          // Duress omission must not fail the whole snapshot.331        }332        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);333334        const applied = await withTimeout(receiveFrame(channel), 15_000);335        if (isAdoptSnapshotApplied(applied)) {336          console.info(337            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,338          );339        }340341        if (adapter.buildOutgoingDeviceManifest) {342          const manifest = await adapter.buildOutgoingDeviceManifest();343          if (manifest) {344            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });345            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);346          }347        }348      } catch (snapshotError) {349        if (350          snapshotError instanceof Error &&351          snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"352        ) {353          throw snapshotError;354        }355        console.info(356          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",357          snapshotError,358        );359      }360    }361    push("ADOPTION_SOURCE_RESULT", { status: "complete" });362    // The Android receiver starts its relay listener as soon as its source363    // record is durable. Immediate best-effort trigger; alarm-based retries364    // cover MV3 service-worker death (setTimeout does not survive it).365    void triggerSyncIfReachable({ reason: "manual", force: true });366    schedulePostAdoptionSync();367  } catch (error) {368    push("ADOPTION_SOURCE_RESULT", {369      status: "error",370      error: error instanceof Error ? error.message : "adoption_failed",371    });372  } finally {373    clearTimeout(timeout);374    noise?.close();375    socket.close();376    pendingSourceSasConfirmation.clear();377  }378}379380/**381 * Browser receives the vault from the phone. The protocol is the mirror of382 * the source side: phone is the Noise initiator and the source of truth for383 * the SAS gate, browser is the responder that creates the vault locally.384 */385async function runReceiverSession(386  socket: PairingSocket,387  expectedToken: string,388  relayUrl: string,389  password: string,390  invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },391): Promise<void> {392  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);393  // The invitation binds the responder static so the initiator can verify the ACK.394  const ephemeral = invitationEphemeral;395  const ephemeralPrivate = ephemeral.secretKey;396  const ephemeralPublic = ephemeral.publicKey;397  let noise: NoiseNKSession | undefined;398  let createdVault = false;399  let protocolError: string | undefined;400  try {401    const hello = decodeJson<{ type?: unknown; token?: unknown }>(402      await socket.receive(),403    );404    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||405        !constantTimeEqual(hello.token, expectedToken)) {406      throw new Error("invalid_invitation");407    }408    socket.send(encodeJson({409      type: "PEACH_HELLO_ACK",410      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),411    }));412413    const session = await noiseNKResponder(414      ephemeralPrivate,415      ephemeralPublic,416      async (bytes) => socket.send(bytes),417      () => socket.receive(),418    );419    noise = session;420    const channel: PairingTransport = {421      async send(plaintext) { socket.send(session.encrypt(plaintext)); },422      async receive() { return session.decrypt(await socket.receive()); },423    };424425    const deviceName = await getDeviceName();426427    const adoptionResult = await requestAdoption(channel, {428      deviceName,429      generateDeviceId: () => crypto.randomUUID(),430      async confirmSas() {431        const sas = derivePairingSAS(session.handshakeHash);432        const id = crypto.randomUUID();433        return waitForReceiverSasConfirmation(id, sas);434      },435      async createVaultFromRootKey(rootKeyHex) {436        // Direct call, not chrome.runtime.sendMessage — a service worker437        // calling sendMessage does not reach its own listener (returns438        // undefined), and the receiver already lives in the same SW context439        // as the vault module. Round-tripping through the message bus was440        // the root cause of "create_vault_no_response".441        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);442        createdVault = true;443        return { vaultId };444      },445      async applyLicense(license) {446        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self447        // issue as createVaultFromRootKey. SW can't reach its own listener.448        await applyTransferredLicense(license);449      },450      async license() {451        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;452      },453      async deriveLocalIdentity() {454        // Direct call for the same reason as applyLicense above.455        const masterKeyHex = getRootKeyHex();456        if (!masterKeyHex) throw new Error("identity_unavailable");457        const masterKey = hexToBytes(masterKeyHex);458        try {459          return await buildLocalIdentity(masterKey);460        } finally {461          masterKey.fill(0);462        }463      },464      async rotateLocalIdentity() {465        await rotateDeviceId();466        const masterKeyHex = getRootKeyHex();467        if (!masterKeyHex) throw new Error("identity_unavailable");468        const masterKey = hexToBytes(masterKeyHex);469        try {470          return await buildLocalIdentity(masterKey);471        } finally {472          masterKey.fill(0);473        }474      },475      async upsertPairedDevice(paired) {476        // Direct call for the same reason as applyLicense above.477        await storeAdoptedDevice(paired);478      },479      async hasSourceIdentityCollision(source) {480        return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);481      },482      pairState: {483        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),484        relayUrl,485      },486      onError(error) {487        protocolError = error;488      },489    });490491    if (!adoptionResult) {492      if (createdVault) {493        await rollbackAdoptedVault();494        createdVault = false;495      }496      push("ADOPTION_SOURCE_RESULT", {497        status: "error",498        error: protocolError ?? "adoption_rejected",499      });500      return;501    }502    const { device, snapshotCapable } = adoptionResult;503    // The pairing channel is still open and authenticated — try to pull the504    // vault snapshot inline so the popup transitions immediately instead of505    // waiting on a second sync round. Skip the wait entirely when the source506    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);507    // fall through to the existing sync pipeline below.508    let snapshotApplied = false;509    if (snapshotCapable) try {510      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);511      const adapter = createSyncVaultAdapter();512      await adapter.applyEntries(snapshot.entries);513      await adapter.applyTombstones(snapshot.tombstones);514      // Duress install is isolated: a failure here must NOT demote an515      // otherwise-successful entry snapshot to the sync fallback.516      if (snapshot.duressSlot) {517        try {518          await installAdoptedDuressSlot(snapshot.duressSlot);519        } catch (duressError) {520          console.info(521            "[Peach] adopted duress slot install failed (non-fatal):",522            duressError,523          );524        }525      }526      await sendFrame(channel, {527        type: "ADOPT_SNAPSHOT_APPLIED",528        entries_received: Object.keys(snapshot.entries).length,529        tombstones_received: Object.keys(snapshot.tombstones).length,530      });531532      // Optional device graph. Non-fatal and isolated so a manifest timeout or533      // unsupported source cannot demote an otherwise-successful entry snapshot.534      try {535        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);536        if (537          isAdoptDeviceManifestFrame(manifestFrame) &&538          adapter.applyIncomingDeviceManifest &&539          typeof device.longTermPublicKeyHex === "string" &&540          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)541        ) {542          await adapter.applyIncomingDeviceManifest(543            manifestFrame.manifest,544            hexToBytes(device.longTermPublicKeyHex),545          );546          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });547        }548      } catch {549        // The graph arrives via incremental sync instead.550      }551552      await markLastSyncedAt(device.deviceId, Date.now());553      snapshotApplied = true;554    } catch (snapshotError) {555      console.info(556        "[Peach] inline snapshot failed, falling back to sync pipeline:",557        snapshotError,558      );559    }560561    // Adoption has committed identity and vault-key material, but entries have562    // not necessarily crossed yet. Persist the exact source row before telling563    // the popup pairing is complete so a popup close/reopen cannot reveal the564    // temporarily empty receiver vault. The popup clears this only after that565    // row records a successful sync.566    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(567      () => undefined,568    );569    push("ADOPTION_SOURCE_RESULT", { status: "complete" });570    createdVault = false;571572    if (snapshotApplied) {573      // The snapshot populated the vault and marked the source synced, so the574      // pending marker's job is done — clear it instead of making the popup wait.575      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);576    } else {577      // Fallback: the existing sync pipeline fills the vault. Immediate578      // best-effort trigger plus alarm-based retries that survive SW death.579      void triggerSyncIfReachable({ reason: "manual", force: true });580      schedulePostAdoptionSync();581    }582  } catch (error) {583    if (createdVault) {584      try {585        await rollbackAdoptedVault();586        createdVault = false;587      } catch (rollbackError) {588        console.error("[Peach] rollback failed:", rollbackError);589      }590    }591    console.error("[Peach] receiver session failed:", error);592    push("ADOPTION_SOURCE_RESULT", {593      status: "error",594      error: error instanceof Error ? error.message : "adoption_failed",595    });596  } finally {597    clearTimeout(timeout);598    noise?.close();599    socket.close();600    ephemeralPrivate.fill(0);601    ephemeralPublic.fill(0);602  }603}604605/**606 * A transferred entitlement is committed only when activation succeeds and607 * the stored value can immediately be re-read as Pro. activateLicense returns608 * a normal Free result for invalid input, so callers must not ignore it.609 */610async function applyTransferredLicense(license: string): Promise<void> {611  const activated = await activateLicense(license);612  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {613    throw new Error("license_transfer_failed");614  }615}616617/**618 * Push the SAS to the popup, then block until the popup responds via the619 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on620 * timeout or cancel so the adoption aborts before any vault state changes.621 *622 * The pending deferred MUST be installed before the push: a popup-side623 * auto-approver (test mocks, future automation) can synchronously resolve624 * the deferred the moment it sees the SAS push, and a missing slot would625 * drop that approval on the floor.626 */627function waitForReceiverSasConfirmation(628  id: string,629  sas: string,630): Promise<boolean> {631  return new Promise<boolean>((resolve) => {632    const timer = setTimeout(() => {633      pendingReceiverSasConfirmation.delete(id);634      resolve(false);635    }, SOURCE_WINDOW_MS);636    pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {637      clearTimeout(timer);638      resolve(ok);639    } });640    push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });641  });642}643644function waitForSourceSasConfirmation(645  id: string,646  request: AdoptRequest,647  sas: string,648): Promise<boolean> {649  return new Promise<boolean>((resolve) => {650    const timer = setTimeout(() => {651      pendingSourceSasConfirmation.delete(id);652      resolve(false);653    }, SOURCE_WINDOW_MS);654    pendingSourceSasConfirmation.set(id, {655      resolve: (ok) => {656        clearTimeout(timer);657        resolve(ok);658      },659    });660    push("ADOPTION_SOURCE_SAS", {661      id,662      sas,663      receiverDeviceName: request.device_name,664    });665  });666}667668/**669 * Opening Add Device in an unlocked extension is the source-side authorization.670 * Announce the SAS for comparison, then let the scanning device's explicit671 * confirmation be the single commit gate. grantAdoption still refuses to send672 * the root key until the receiver returns ADOPT_CONFIRM(true).673 */674function requestConsent(675  request: AdoptRequest,676  sas: string,677  id: string,678): Promise<boolean> {679  return waitForSourceSasConfirmation(id, request, sas);680}681682function clearState(): void {683  activeSocket = null;684  inFlight = false;685  pendingSourceSasConfirmation.clear();686  pendingReceiverSasConfirmation.clear();687}688689function constantTimeEqual(a: string, b: string): boolean {690  if (a.length !== b.length) return false;691  let diff = 0;692  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);693  return diff === 0;694}695696function encodeJson(value: unknown): Uint8Array {697  return new TextEncoder().encode(JSON.stringify(value));698}699700function decodeJson<T>(value: Uint8Array): T {701  return JSON.parse(new TextDecoder().decode(value)) as T;702}703704/**705 * PB-26-213: an adopted device's secret bypasses the public registry into706 * the encrypted pair-secret record. The registry row stays secretless; when707 * the vault is somehow locked here the row still lands and the next unlock708 * cannot recover this secret — re-pair is the fail-closed path.709 */710async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {711  const secret = paired.rendezvousSecretHex;712  const pub = { ...paired };713  delete pub.rendezvousSecretHex;714  // Validate, persist, and clear the terminal tombstone under one registry715  // mutation so a concurrent revoke cannot recreate or lose the tombstone.716  await upsertPairedDeviceAndClearRevocation(pub);717  if (!isValidPairSecret(secret)) return;718  const masterKeyHex = getRootKeyHex();719  if (!masterKeyHex) return;720  const masterKey = hexToBytes(masterKeyHex);721  try {722    await setPairSecret(paired.deviceId, secret, masterKey);723  } finally {724    masterKey.fill(0);725  }726}727728function hexToBytes(hex: string): Uint8Array {729  const out = new Uint8Array(hex.length / 2);730  for (let i = 0; i < out.length; i++) {731    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);732  }733  return out;734}