relay-adoption-bg.ts

25.6 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 {44  hasAdoptionIdentityConflict,45  markLastSyncedAt,46  upsertPairedDeviceAndClearRevocation,47} from "./paired-devices";48import { getSyncSettings, relayInForce } from "./sync-settings";49import { activateLicense, getLicenseKey, isProUser } from "./license";50import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";51import { deferAutoLockForOperation } from "./alarms";52import { computeVaultId } from "../shared/sync-identity";53import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";54import { storageRemove, storageSet } from "../shared/storage";55import {56  persistPendingAdoptionHolder,57  readPendingAdoptionHolder,58  removePendingAdoptionHolder,59} from "./pending-adoption-holder";60import { isValidPairSecret, setPairSecret } from "./pair-secrets";61import { createSyncVaultAdapter } from "./sync-vault-adapter";62import { triggerSyncIfReachable } from "./sync-trigger";63import { schedulePostAdoptionSync } from "./sync-alarm";6465const SOURCE_WINDOW_MS = 5 * 60 * 1000;6667function push(type: string, payload: unknown): void {68  try {69    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;70    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {71      (maybe as Promise<unknown>).catch(() => undefined);72    }73  } catch {74    // The popup may have closed. The pending approval fails closed on cleanup.75  }76}7778/** Race a promise against a wall-clock deadline; rejects with "timeout". */79function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {80  return new Promise<T>((resolve, reject) => {81    const timer = setTimeout(() => reject(new Error("timeout")), ms);82    promise.then(83      (value) => {84        clearTimeout(timer);85        resolve(value);86      },87      (error) => {88        clearTimeout(timer);89        reject(error);90      },91    );92  });93}9495let inFlight = false;96let activeSocket: PairingSocket | null = null;97// Outstanding source-side SAS confirmations, keyed by id. A map (vs the98// receiver's single slot) makes a stale decision from a previous source99// session impossible to land on the current one even if both run on the100// same background worker.101const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();102const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();103104export function isRelayAdoptionSourceInFlight(): boolean {105  return inFlight;106}107108/**109 * Occupy the non-buffering room before returning the invitation to the UI.110 * All long-term key material remains in the background service worker.111 */112export async function startRelayAdoptionSource(): Promise<{113  invitation: string;114}> {115  if (inFlight) throw new Error("adoption_source_in_progress");116  if (!isUnlocked()) throw new Error("vault_locked");117  inFlight = true;118  const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);119120  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;121  try {122    const settings = await getSyncSettings();123    const relayUrl = relayInForce(settings);124    local = await getLocalPairingIdentity();125    const token = generatePairingRendezvousToken();126    // Source side has the vault — caller checked isUnlocked — so the phone127    // can read the invitation and know it should receive the vault.128    const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));129    const invitation = parsePairingInvitation(encoded);130    if (!invitation) throw new Error("invitation_generation_failed");131132    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));133    activeSocket = socket;134    const readyLocal = local;135    void runSourceSession(136      socket,137      token,138      relayUrl,139      readyLocal,140    ).finally(() => {141      readyLocal.privateKey.fill(0);142      readyLocal.publicKey.fill(0);143      clearState();144      releaseAutoLock();145    });146    return { invitation: encoded };147  } catch (error) {148    local?.privateKey.fill(0);149    local?.publicKey.fill(0);150    clearState();151    releaseAutoLock();152    throw error;153  }154}155156export function cancelRelayAdoptionSource(): void {157  activeSocket?.close();158  clearState();159}160161/**162 * Onboarding pairing request — the empty extension shows a QR and the populated163 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the164 * phone reads to switch into source mode and `grantAdoption` over the165 * established Noise channel. The receiver's `runReceiverSession` accepts the166 * root key and creates a local vault wrapped with the popup-supplied password.167 *168 * `password` is the local-only password the user picked on the "Set a password169 * for this device" screen; it wraps the adopted root key at rest and has no170 * relationship to the source device's password.171 */172export async function startRelayAdoptionReceiver(173  password: string,174): Promise<{ invitation: string }> {175  if (inFlight) throw new Error("adoption_receiver_in_progress");176  if (typeof password !== "string" || password.length === 0) {177    throw new Error("ADOPT_INVALID_PASSWORD");178  }179  inFlight = true;180  try {181    const settings = await getSyncSettings();182    const relayUrl = relayInForce(settings);183    const token = generatePairingRendezvousToken();184    const ephemeral = generateKeyPair();185    const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));186    const invitation = parsePairingInvitation(encoded);187    if (!invitation) throw new Error("invitation_generation_failed");188    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));189    activeSocket = socket;190    void runReceiverSession(191      socket,192      token,193      relayUrl,194      password,195      ephemeral,196    ).finally(() => {197      ephemeral.secretKey.fill(0);198      ephemeral.publicKey.fill(0);199      clearState();200    });201    return { invitation: encoded };202  } catch (error) {203    clearState();204    throw error;205  }206}207208/**209 * Resolves the source-side SAS confirmation by id. Called by the message210 * handler when the popup taps Confirm/Cancel on the source's SAS screen.211 * Returns true if the id matched an in-flight confirmation. Unknown ids212 * (already resolved, timed out, or never existed) are a no-op so a late213 * popup click cannot crash anything.214 */215export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {216  const pending = pendingSourceSasConfirmation.get(id);217  if (!pending) return false;218  pendingSourceSasConfirmation.delete(id);219  pending.resolve(ok);220  return true;221}222223export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {224  const pending = pendingReceiverSasConfirmation.get(id);225  if (!pending) return false;226  pendingReceiverSasConfirmation.delete(id);227  pending.resolve(ok && constantTimeEqual(pending.sas, sas));228  return true;229}230231async function runSourceSession(232  socket: PairingSocket,233  expectedToken: string,234  relayUrl: string,235  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,236): Promise<void> {237  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);238  let noise: NoiseNKSession | undefined;239  try {240    const hello = decodeJson<{ type?: unknown; token?: unknown }>(241      await socket.receive(),242    );243    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||244        !constantTimeEqual(hello.token, expectedToken)) {245      throw new Error("invalid_invitation");246    }247    socket.send(encodeJson({248      type: "PEACH_HELLO_ACK",249      ephemeralPublicKeyHex: bytesToHex(local.publicKey),250    }));251252    const session = await noiseNKResponder(253      local.privateKey,254      local.publicKey,255      async (bytes) => socket.send(bytes),256      () => socket.receive(),257    );258    noise = session;259    const channel: PairingTransport = {260      async send(plaintext) { socket.send(session.encrypt(plaintext)); },261      async receive() { return session.decrypt(await socket.receive()); },262    };263    const request = await receiveFrame(channel);264    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");265266    const device = await grantAdoption(channel, request, {267      sourceIdentity: local.identity,268      pairState: {269        rendezvousSecretHex: bytesToHex(270          deriveRendezvousSecret(session.handshakeHash),271        ),272        relayUrl,273      },274      async rootKeyHex() {275        // grantAdoption calls this only after local password approval and the276        // receiver's encrypted ADOPT_CONFIRM(true).277        const value = getRootKeyHex();278        if (!value) throw new Error("vault_locked");279        return value;280      },281      async vaultId() {282        const value = getRootKeyHex();283        if (!value) throw new Error("vault_locked");284        const bytes = hexToBytes(value);285        try {286          return await computeVaultId(bytes);287        } finally {288          bytes.fill(0);289        }290      },291      async license() {292        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;293      },294      async applyLicense(license) {295        await applyTransferredLicense(license);296      },297      onConsent: (adoptRequest) => requestConsent(298        adoptRequest,299        derivePairingSAS(session.handshakeHash),300        crypto.randomUUID(),301      ),302      persistPendingHolder: persistPendingAdoptionHolder,303      clearPendingHolder: async (adoptRequest) => {304        const current = await readPendingAdoptionHolder();305        if (current?.request.device_id === adoptRequest.device_id) {306          await removePendingAdoptionHolder();307        }308      },309      async upsertPairedDevice(paired) {310        await storeAdoptedDevice(paired);311      },312      hasDeviceIdentityCollision: async (device) =>313        hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),314      rotateSourceIdentity: async () => {315        await rotateDeviceId();316        return await buildLocalIdentity();317      },318    });319    if (!device) {320      push("ADOPTION_SOURCE_RESULT", {321        status: "error",322        error: "adoption_rejected",323      });324      return;325    }326    // If the receiver advertised inline-snapshot support, send the vault now327    // over the same authenticated channel. Best-effort: any failure leaves the328    // receiver to fall back to the sync pipeline triggered below.329    if (request.snapshot_capable === true) {330      try {331        const adapter = createSyncVaultAdapter();332        const entryManifest = await adapter.buildEntryManifest();333        const entryIds = Object.keys(entryManifest);334        const entries =335          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};336        const tombstones = await adapter.buildTombstoneManifest();337        let duressSlot: string | null = null;338        try {339          duressSlot = await exportDuressSlotForAdoption();340        } catch {341          // Duress omission must not fail the whole snapshot.342        }343        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);344345        const applied = await withTimeout(receiveFrame(channel), 15_000);346        if (isAdoptSnapshotApplied(applied)) {347          console.info(348            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,349          );350        }351352        if (adapter.buildOutgoingDeviceManifest) {353          const manifest = await adapter.buildOutgoingDeviceManifest();354          if (manifest) {355            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });356            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);357          }358        }359      } catch (snapshotError) {360        if (361          snapshotError instanceof Error &&362          snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"363        ) {364          throw snapshotError;365        }366        console.info(367          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",368          snapshotError,369        );370      }371    }372    push("ADOPTION_SOURCE_RESULT", { status: "complete" });373    // The Android receiver starts its relay listener as soon as its source374    // record is durable. Immediate best-effort trigger; alarm-based retries375    // cover MV3 service-worker death (setTimeout does not survive it).376    void triggerSyncIfReachable({ reason: "manual", force: true });377    schedulePostAdoptionSync();378  } catch (error) {379    push("ADOPTION_SOURCE_RESULT", {380      status: "error",381      error: error instanceof Error ? error.message : "adoption_failed",382    });383  } finally {384    clearTimeout(timeout);385    noise?.close();386    socket.close();387    pendingSourceSasConfirmation.clear();388  }389}390391/**392 * Browser receives the vault from the phone. The protocol is the mirror of393 * the source side: phone is the Noise initiator and the source of truth for394 * the SAS gate, browser is the responder that creates the vault locally.395 */396async function runReceiverSession(397  socket: PairingSocket,398  expectedToken: string,399  relayUrl: string,400  password: string,401  invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },402): Promise<void> {403  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);404  // The invitation binds the responder static so the initiator can verify the ACK.405  const ephemeral = invitationEphemeral;406  const ephemeralPrivate = ephemeral.secretKey;407  const ephemeralPublic = ephemeral.publicKey;408  let noise: NoiseNKSession | undefined;409  let createdVault = false;410  let protocolError: string | undefined;411  try {412    const hello = decodeJson<{ type?: unknown; token?: unknown }>(413      await socket.receive(),414    );415    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||416        !constantTimeEqual(hello.token, expectedToken)) {417      throw new Error("invalid_invitation");418    }419    socket.send(encodeJson({420      type: "PEACH_HELLO_ACK",421      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),422    }));423424    const session = await noiseNKResponder(425      ephemeralPrivate,426      ephemeralPublic,427      async (bytes) => socket.send(bytes),428      () => socket.receive(),429    );430    noise = session;431    const channel: PairingTransport = {432      async send(plaintext) { socket.send(session.encrypt(plaintext)); },433      async receive() { return session.decrypt(await socket.receive()); },434    };435436    const deviceName = await getDeviceName();437438    const adoptionResult = await requestAdoption(channel, {439      deviceName,440      generateDeviceId: () => crypto.randomUUID(),441      async confirmSas() {442        const sas = derivePairingSAS(session.handshakeHash);443        const id = crypto.randomUUID();444        return waitForReceiverSasConfirmation(id, sas);445      },446      async createVaultFromRootKey(rootKeyHex) {447        // Direct call, not chrome.runtime.sendMessage — a service worker448        // calling sendMessage does not reach its own listener (returns449        // undefined), and the receiver already lives in the same SW context450        // as the vault module. Round-tripping through the message bus was451        // the root cause of "create_vault_no_response".452        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);453        createdVault = true;454        return { vaultId };455      },456      async applyLicense(license) {457        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self458        // issue as createVaultFromRootKey. SW can't reach its own listener.459        await applyTransferredLicense(license);460      },461      async license() {462        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;463      },464      async deriveLocalIdentity() {465        // Direct call for the same reason as applyLicense above.466        return await buildLocalIdentity();467      },468      async rotateLocalIdentity() {469        await rotateDeviceId();470        return await buildLocalIdentity();471      },472      async upsertPairedDevice(paired) {473        // Direct call for the same reason as applyLicense above.474        await storeAdoptedDevice(paired);475      },476      async hasSourceIdentityCollision(source) {477        return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);478      },479      pairState: {480        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),481        relayUrl,482      },483      onError(error) {484        protocolError = error;485      },486    });487488    if (!adoptionResult) {489      if (createdVault) {490        await rollbackAdoptedVault();491        createdVault = false;492      }493      push("ADOPTION_SOURCE_RESULT", {494        status: "error",495        error: protocolError ?? "adoption_rejected",496      });497      return;498    }499    const { device, snapshotCapable } = adoptionResult;500    // The pairing channel is still open and authenticated — try to pull the501    // vault snapshot inline so the popup transitions immediately instead of502    // waiting on a second sync round. Skip the wait entirely when the source503    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);504    // fall through to the existing sync pipeline below.505    let snapshotApplied = false;506    if (snapshotCapable) try {507      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);508      const adapter = createSyncVaultAdapter();509      await adapter.applyEntries(snapshot.entries);510      await adapter.applyTombstones(snapshot.tombstones);511      // Duress install is isolated: a failure here must NOT demote an512      // otherwise-successful entry snapshot to the sync fallback.513      if (snapshot.duressSlot) {514        try {515          await installAdoptedDuressSlot(snapshot.duressSlot);516        } catch (duressError) {517          console.info(518            "[Peach] adopted duress slot install failed (non-fatal):",519            duressError,520          );521        }522      }523      await sendFrame(channel, {524        type: "ADOPT_SNAPSHOT_APPLIED",525        entries_received: Object.keys(snapshot.entries).length,526        tombstones_received: Object.keys(snapshot.tombstones).length,527      });528529      // Optional device graph. Non-fatal and isolated so a manifest timeout or530      // unsupported source cannot demote an otherwise-successful entry snapshot.531      try {532        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);533        if (534          isAdoptDeviceManifestFrame(manifestFrame) &&535          adapter.applyIncomingDeviceManifest &&536          typeof device.longTermPublicKeyHex === "string" &&537          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)538        ) {539          await adapter.applyIncomingDeviceManifest(540            manifestFrame.manifest,541            hexToBytes(device.longTermPublicKeyHex),542          );543          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });544        }545      } catch {546        // The graph arrives via incremental sync instead.547      }548549      await markLastSyncedAt(device.deviceId, Date.now());550      snapshotApplied = true;551    } catch (snapshotError) {552      console.info(553        "[Peach] inline snapshot failed, falling back to sync pipeline:",554        snapshotError,555      );556    }557558    // Adoption has committed identity and vault-key material, but entries have559    // not necessarily crossed yet. Persist the exact source row before telling560    // the popup pairing is complete so a popup close/reopen cannot reveal the561    // temporarily empty receiver vault. The popup clears this only after that562    // row records a successful sync.563    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(564      () => undefined,565    );566    push("ADOPTION_SOURCE_RESULT", { status: "complete" });567    createdVault = false;568569    if (snapshotApplied) {570      // The snapshot populated the vault and marked the source synced, so the571      // pending marker's job is done — clear it instead of making the popup wait.572      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);573    } else {574      // Fallback: the existing sync pipeline fills the vault. Immediate575      // best-effort trigger plus alarm-based retries that survive SW death.576      void triggerSyncIfReachable({ reason: "manual", force: true });577      schedulePostAdoptionSync();578    }579  } catch (error) {580    if (createdVault) {581      try {582        await rollbackAdoptedVault();583        createdVault = false;584      } catch (rollbackError) {585        console.error("[Peach] rollback failed:", rollbackError);586      }587    }588    console.error("[Peach] receiver session failed:", error);589    push("ADOPTION_SOURCE_RESULT", {590      status: "error",591      error: error instanceof Error ? error.message : "adoption_failed",592    });593  } finally {594    clearTimeout(timeout);595    noise?.close();596    socket.close();597    ephemeralPrivate.fill(0);598    ephemeralPublic.fill(0);599  }600}601602/**603 * A transferred entitlement is committed only when activation succeeds and604 * the stored value can immediately be re-read as Pro. activateLicense returns605 * a normal Free result for invalid input, so callers must not ignore it.606 */607async function applyTransferredLicense(license: string): Promise<void> {608  const activated = await activateLicense(license);609  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {610    throw new Error("license_transfer_failed");611  }612}613614/**615 * Push the SAS to the popup, then block until the popup responds via the616 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on617 * timeout or cancel so the adoption aborts before any vault state changes.618 *619 * The pending deferred MUST be installed before the push: a popup-side620 * auto-approver (test mocks, future automation) can synchronously resolve621 * the deferred the moment it sees the SAS push, and a missing slot would622 * drop that approval on the floor.623 */624function waitForReceiverSasConfirmation(625  id: string,626  sas: string,627): Promise<boolean> {628  return new Promise<boolean>((resolve) => {629    const timer = setTimeout(() => {630      pendingReceiverSasConfirmation.delete(id);631      resolve(false);632    }, SOURCE_WINDOW_MS);633    pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {634      clearTimeout(timer);635      resolve(ok);636    } });637    push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });638  });639}640641function waitForSourceSasConfirmation(642  id: string,643  request: AdoptRequest,644  sas: string,645): Promise<boolean> {646  return new Promise<boolean>((resolve) => {647    const timer = setTimeout(() => {648      pendingSourceSasConfirmation.delete(id);649      resolve(false);650    }, SOURCE_WINDOW_MS);651    pendingSourceSasConfirmation.set(id, {652      resolve: (ok) => {653        clearTimeout(timer);654        resolve(ok);655      },656    });657    push("ADOPTION_SOURCE_SAS", {658      id,659      sas,660      receiverDeviceName: request.device_name,661    });662  });663}664665/**666 * Opening Add Device in an unlocked extension is the source-side authorization.667 * Announce the SAS for comparison, then let the scanning device's explicit668 * confirmation be the single commit gate. grantAdoption still refuses to send669 * the root key until the receiver returns ADOPT_CONFIRM(true).670 */671function requestConsent(672  request: AdoptRequest,673  sas: string,674  id: string,675): Promise<boolean> {676  return waitForSourceSasConfirmation(id, request, sas);677}678679function clearState(): void {680  activeSocket = null;681  inFlight = false;682  pendingSourceSasConfirmation.clear();683  pendingReceiverSasConfirmation.clear();684}685686function constantTimeEqual(a: string, b: string): boolean {687  if (a.length !== b.length) return false;688  let diff = 0;689  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);690  return diff === 0;691}692693function encodeJson(value: unknown): Uint8Array {694  return new TextEncoder().encode(JSON.stringify(value));695}696697function decodeJson<T>(value: Uint8Array): T {698  return JSON.parse(new TextDecoder().decode(value)) as T;699}700701/**702 * PB-26-213: an adopted device's secret bypasses the public registry into703 * the encrypted pair-secret record. The registry row stays secretless; when704 * the vault is somehow locked here the row still lands and the next unlock705 * cannot recover this secret — re-pair is the fail-closed path.706 */707async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {708  const secret = paired.rendezvousSecretHex;709  const pub = { ...paired };710  delete pub.rendezvousSecretHex;711  // Validate, persist, and clear the terminal tombstone under one registry712  // mutation so a concurrent revoke cannot recreate or lose the tombstone.713  await upsertPairedDeviceAndClearRevocation(pub);714  if (!isValidPairSecret(secret)) return;715  const masterKeyHex = getRootKeyHex();716  if (!masterKeyHex) return;717  const masterKey = hexToBytes(masterKeyHex);718  try {719    await setPairSecret(paired.deviceId, secret, masterKey);720  } finally {721    masterKey.fill(0);722  }723}724725function hexToBytes(hex: string): Uint8Array {726  const out = new Uint8Array(hex.length / 2);727  for (let i = 0; i < out.length; i++) {728    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);729  }730  return out;731}