relay-adoption-bg.ts

26.3 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  hasAdoptionIdentityConflict,54  markLastSyncedAt,55  upsertPairedDeviceAndClearRevocation,56} from "./paired-devices";57import { getSyncSettings, relayInForce } from "./sync-settings";58import { activateLicense, getLicenseKey, isProUser } from "./license";59import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";60import { deferAutoLockForOperation } from "./alarms";61import { computeVaultId } from "../shared/sync-identity";62import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";63import { storageRemove, storageSet } from "../shared/storage";64import {65  persistPendingAdoptionHolder,66  persistPendingAdoptionIdentity,67  readPendingAdoptionHolder,68  removePendingAdoptionHolder,69  completePendingAdoptionIdentity,70} from "./pending-adoption-holder";71import { isValidPairSecret, setPairSecret } from "./pair-secrets";72import { createSyncVaultAdapter } from "./sync-vault-adapter";73import { triggerSyncIfReachable } from "./sync-trigger";74import { schedulePostAdoptionSync } from "./sync-alarm";7576const SOURCE_WINDOW_MS = 5 * 60 * 1000;7778function push(type: string, payload: unknown): void {79  try {80    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;81    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {82      (maybe as Promise<unknown>).catch(() => undefined);83    }84  } catch {85    // The popup may have closed. The pending approval fails closed on cleanup.86  }87}8889/** Race a promise against a wall-clock deadline; rejects with "timeout". */90function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {91  return new Promise<T>((resolve, reject) => {92    const timer = setTimeout(() => reject(new Error("timeout")), ms);93    promise.then(94      (value) => {95        clearTimeout(timer);96        resolve(value);97      },98      (error) => {99        clearTimeout(timer);100        reject(error);101      },102    );103  });104}105106let inFlight = false;107let activeSocket: PairingSocket | null = null;108// Outstanding source-side SAS confirmations, keyed by id. A map (vs the109// receiver's single slot) makes a stale decision from a previous source110// session impossible to land on the current one even if both run on the111// same background worker.112const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();113const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();114115export function isRelayAdoptionSourceInFlight(): boolean {116  return inFlight;117}118119/**120 * Occupy the non-buffering room before returning the invitation to the UI.121 * All long-term key material remains in the background service worker.122 */123export async function startRelayAdoptionSource(): Promise<{124  invitation: string;125}> {126  if (inFlight) throw new Error("adoption_source_in_progress");127  if (!isUnlocked()) throw new Error("vault_locked");128  inFlight = true;129  const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);130131  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;132  try {133    const settings = await getSyncSettings();134    const relayUrl = relayInForce(settings);135    local = await getLocalPairingIdentity();136    const token = generatePairingRendezvousToken();137    // Source side has the vault — caller checked isUnlocked — so the phone138    // can read the invitation and know it should receive the vault.139    const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));140    const invitation = parsePairingInvitation(encoded);141    if (!invitation) throw new Error("invitation_generation_failed");142143    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));144    activeSocket = socket;145    const readyLocal = local;146    void runSourceSession(147      socket,148      token,149      relayUrl,150      readyLocal,151    ).finally(() => {152      readyLocal.privateKey.fill(0);153      readyLocal.publicKey.fill(0);154      clearState();155      releaseAutoLock();156    });157    return { invitation: encoded };158  } catch (error) {159    local?.privateKey.fill(0);160    local?.publicKey.fill(0);161    clearState();162    releaseAutoLock();163    throw error;164  }165}166167export function cancelRelayAdoptionSource(): void {168  activeSocket?.close();169  clearState();170}171172/**173 * Onboarding pairing request — the empty extension shows a QR and the populated174 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the175 * phone reads to switch into source mode and `grantAdoption` over the176 * established Noise channel. The receiver's `runReceiverSession` accepts the177 * root key and creates a local vault wrapped with the popup-supplied password.178 *179 * `password` is the local-only password the user picked on the "Set a password180 * for this device" screen; it wraps the adopted root key at rest and has no181 * relationship to the source device's password.182 */183export async function startRelayAdoptionReceiver(184  password: string,185): Promise<{ invitation: string }> {186  if (inFlight) throw new Error("adoption_receiver_in_progress");187  if (typeof password !== "string" || password.length === 0) {188    throw new Error("ADOPT_INVALID_PASSWORD");189  }190  inFlight = true;191  try {192    await preparePreVaultIdentity(password);193    const settings = await getSyncSettings();194    const relayUrl = relayInForce(settings);195    const token = generatePairingRendezvousToken();196    const ephemeral = generateKeyPair();197    const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));198    const invitation = parsePairingInvitation(encoded);199    if (!invitation) throw new Error("invitation_generation_failed");200    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));201    activeSocket = socket;202    void runReceiverSession(203      socket,204      token,205      relayUrl,206      password,207      ephemeral,208    ).finally(() => {209      ephemeral.secretKey.fill(0);210      ephemeral.publicKey.fill(0);211      clearState();212    });213    return { invitation: encoded };214  } catch (error) {215    clearState();216    throw error;217  }218}219220/**221 * Resolves the source-side SAS confirmation by id. Called by the message222 * handler when the popup taps Confirm/Cancel on the source's SAS screen.223 * Returns true if the id matched an in-flight confirmation. Unknown ids224 * (already resolved, timed out, or never existed) are a no-op so a late225 * popup click cannot crash anything.226 */227export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {228  const pending = pendingSourceSasConfirmation.get(id);229  if (!pending) return false;230  pendingSourceSasConfirmation.delete(id);231  pending.resolve(ok);232  return true;233}234235export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {236  const pending = pendingReceiverSasConfirmation.get(id);237  if (!pending) return false;238  pendingReceiverSasConfirmation.delete(id);239  pending.resolve(ok && constantTimeEqual(pending.sas, sas));240  return true;241}242243async function runSourceSession(244  socket: PairingSocket,245  expectedToken: string,246  relayUrl: string,247  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,248): Promise<void> {249  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);250  let noise: NoiseNKSession | undefined;251  try {252    const hello = decodeJson<{ type?: unknown; token?: unknown }>(253      await socket.receive(),254    );255    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||256        !constantTimeEqual(hello.token, expectedToken)) {257      throw new Error("invalid_invitation");258    }259    socket.send(encodeJson({260      type: "PEACH_HELLO_ACK",261      ephemeralPublicKeyHex: bytesToHex(local.publicKey),262    }));263264    const session = await noiseNKResponder(265      local.privateKey,266      local.publicKey,267      async (bytes) => socket.send(bytes),268      () => socket.receive(),269    );270    noise = session;271    const channel: PairingTransport = {272      async send(plaintext) { socket.send(session.encrypt(plaintext)); },273      async receive() { return session.decrypt(await socket.receive()); },274    };275    const request = await receiveFrame(channel);276    if (!isAdoptRequest(request) && !isAdoptRequestV2(request)) {277      throw new Error("expected_ADOPT_REQUEST");278    }279280    const sourceDeps: AdoptionSourceDeps & AdoptionV2SourceDeps = {281      sourceIdentity: local.identity,282      pairState: {283        rendezvousSecretHex: bytesToHex(284          deriveRendezvousSecret(session.handshakeHash),285        ),286        relayUrl,287      },288      async rootKeyHex() {289        // grantAdoption calls this only after local password approval and the290        // receiver's encrypted ADOPT_CONFIRM(true).291        const value = getRootKeyHex();292        if (!value) throw new Error("vault_locked");293        return value;294      },295      async vaultId() {296        const value = getRootKeyHex();297        if (!value) throw new Error("vault_locked");298        const bytes = hexToBytes(value);299        try {300          return await computeVaultId(bytes);301        } finally {302          bytes.fill(0);303        }304      },305      async license() {306        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;307      },308      async applyLicense(license) {309        await applyTransferredLicense(license);310      },311      onConsent: (adoptRequest) => requestConsent(312        adoptRequest,313        derivePairingSAS(session.handshakeHash),314        crypto.randomUUID(),315      ),316      persistPendingHolder: persistPendingAdoptionHolder,317      clearPendingHolder: async (adoptRequest) => {318        const current = await readPendingAdoptionHolder();319        if (current?.request.device_id === adoptRequest.device_id) {320          await removePendingAdoptionHolder();321        }322      },323      async upsertPairedDevice(paired) {324        await storeAdoptedDevice(paired);325      },326      hasDeviceIdentityCollision: async (device) =>327        hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),328      rotateSourceIdentity: async () => {329        await rotateDeviceId();330        return await buildLocalIdentity();331      },332      persistPendingIdentity: persistPendingAdoptionIdentity,333      async completePendingIdentity(paired: PairedDevice) {334        await completePendingAdoptionIdentity(paired);335      },336      snapshotCapable: () => true,337    };338    const device = isAdoptRequestV2(request)339      ? await grantAdoptionV2(channel, request, sourceDeps)340      : await grantAdoption(channel, request, sourceDeps);341    if (!device) {342      push("ADOPTION_SOURCE_RESULT", {343        status: "error",344        error: "adoption_rejected",345      });346      return;347    }348    // If the receiver advertised inline-snapshot support, send the vault now349    // over the same authenticated channel. Best-effort: any failure leaves the350    // receiver to fall back to the sync pipeline triggered below.351    if (request.snapshot_capable === true) {352      try {353        const adapter = createSyncVaultAdapter();354        const entryManifest = await adapter.buildEntryManifest();355        const entryIds = Object.keys(entryManifest);356        const entries =357          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};358        const tombstones = await adapter.buildTombstoneManifest();359        let duressSlot: string | null = null;360        try {361          duressSlot = await exportDuressSlotForAdoption();362        } catch {363          // Duress omission must not fail the whole snapshot.364        }365        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);366367        const applied = await withTimeout(receiveFrame(channel), 15_000);368        if (isAdoptSnapshotApplied(applied)) {369          console.info(370            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,371          );372        }373374        if (adapter.buildOutgoingDeviceManifest) {375          const manifest = await adapter.buildOutgoingDeviceManifest();376          if (manifest) {377            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });378            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);379          }380        }381      } catch (snapshotError) {382        if (383          snapshotError instanceof Error &&384          snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"385        ) {386          throw snapshotError;387        }388        console.info(389          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",390          snapshotError,391        );392      }393    }394    push("ADOPTION_SOURCE_RESULT", { status: "complete" });395    // The Android receiver starts its relay listener as soon as its source396    // record is durable. Immediate best-effort trigger; alarm-based retries397    // cover MV3 service-worker death (setTimeout does not survive it).398    void triggerSyncIfReachable({ reason: "manual", force: true });399    schedulePostAdoptionSync();400  } catch (error) {401    push("ADOPTION_SOURCE_RESULT", {402      status: "error",403      error: error instanceof Error ? error.message : "adoption_failed",404    });405  } finally {406    clearTimeout(timeout);407    noise?.close();408    socket.close();409    pendingSourceSasConfirmation.clear();410  }411}412413/**414 * Browser receives the vault from the phone. The protocol is the mirror of415 * the source side: phone is the Noise initiator and the source of truth for416 * the SAS gate, browser is the responder that creates the vault locally.417 */418async function runReceiverSession(419  socket: PairingSocket,420  expectedToken: string,421  relayUrl: string,422  password: string,423  invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },424): Promise<void> {425  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);426  // The invitation binds the responder static so the initiator can verify the ACK.427  const ephemeral = invitationEphemeral;428  const ephemeralPrivate = ephemeral.secretKey;429  const ephemeralPublic = ephemeral.publicKey;430  let noise: NoiseNKSession | undefined;431  let createdVault = false;432  let protocolError: string | undefined;433  try {434    const hello = decodeJson<{ type?: unknown; token?: unknown }>(435      await socket.receive(),436    );437    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||438        !constantTimeEqual(hello.token, expectedToken)) {439      throw new Error("invalid_invitation");440    }441    socket.send(encodeJson({442      type: "PEACH_HELLO_ACK",443      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),444    }));445446    const session = await noiseNKResponder(447      ephemeralPrivate,448      ephemeralPublic,449      async (bytes) => socket.send(bytes),450      () => socket.receive(),451    );452    noise = session;453    const channel: PairingTransport = {454      async send(plaintext) { socket.send(session.encrypt(plaintext)); },455      async receive() { return session.decrypt(await socket.receive()); },456    };457458    const deviceName = await getDeviceName();459460    const receiverDeps: AdoptionV2ReceiverDeps = {461      deviceName,462      async confirmSas() {463        const sas = derivePairingSAS(session.handshakeHash);464        const id = crypto.randomUUID();465        return waitForReceiverSasConfirmation(id, sas);466      },467      async createVaultFromRootKey(rootKeyHex) {468        // Direct call, not chrome.runtime.sendMessage — a service worker469        // calling sendMessage does not reach its own listener (returns470        // undefined), and the receiver already lives in the same SW context471        // as the vault module. Round-tripping through the message bus was472        // the root cause of "create_vault_no_response".473        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);474        createdVault = true;475        return { vaultId };476      },477      async applyLicense(license) {478        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self479        // issue as createVaultFromRootKey. SW can't reach its own listener.480        await applyTransferredLicense(license);481      },482      async license() {483        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;484      },485      async rotateLocalIdentity() {486        return await rotatePreVaultIdentity(password);487      },488      async hasSourceIdentityCollision(source) {489        return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);490      },491      pairState: {492        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),493        relayUrl,494      },495      onError(error) {496        protocolError = error;497      },498      async localIdentity() {499        return await readPreVaultIdentity(password);500      },501      async validateSource() {},502      async upsertPairedDeviceAndClearRevocation(paired: PairedDevice) {503        await storeAdoptedDevice(paired);504      },505    };506    const adoptionResult = await requestAdoptionV2(channel, receiverDeps);507508    if (!adoptionResult) {509      if (createdVault) {510        await rollbackAdoptedVault();511        createdVault = false;512      }513      push("ADOPTION_SOURCE_RESULT", {514        status: "error",515        error: protocolError ?? "adoption_rejected",516      });517      return;518    }519    const { device, snapshotCapable } = adoptionResult;520    // The pairing channel is still open and authenticated — try to pull the521    // vault snapshot inline so the popup transitions immediately instead of522    // waiting on a second sync round. Skip the wait entirely when the source523    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);524    // fall through to the existing sync pipeline below.525    let snapshotApplied = false;526    if (snapshotCapable) try {527      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);528      const adapter = createSyncVaultAdapter();529      await adapter.applyEntries(snapshot.entries);530      await adapter.applyTombstones(snapshot.tombstones);531      // Duress install is isolated: a failure here must NOT demote an532      // otherwise-successful entry snapshot to the sync fallback.533      if (snapshot.duressSlot) {534        try {535          await installAdoptedDuressSlot(snapshot.duressSlot);536        } catch (duressError) {537          console.info(538            "[Peach] adopted duress slot install failed (non-fatal):",539            duressError,540          );541        }542      }543      await sendFrame(channel, {544        type: "ADOPT_SNAPSHOT_APPLIED",545        entries_received: Object.keys(snapshot.entries).length,546        tombstones_received: Object.keys(snapshot.tombstones).length,547      });548549      // Optional device graph. Non-fatal and isolated so a manifest timeout or550      // unsupported source cannot demote an otherwise-successful entry snapshot.551      try {552        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);553        if (554          isAdoptDeviceManifestFrame(manifestFrame) &&555          adapter.applyIncomingDeviceManifest &&556          typeof device.longTermPublicKeyHex === "string" &&557          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)558        ) {559          await adapter.applyIncomingDeviceManifest(560            manifestFrame.manifest,561            hexToBytes(device.longTermPublicKeyHex),562          );563          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });564        }565      } catch {566        // The graph arrives via incremental sync instead.567      }568569      await markLastSyncedAt(device.deviceId, Date.now());570      snapshotApplied = true;571    } catch (snapshotError) {572      console.info(573        "[Peach] inline snapshot failed, falling back to sync pipeline:",574        snapshotError,575      );576    }577578    // Adoption has committed identity and vault-key material, but entries have579    // not necessarily crossed yet. Persist the exact source row before telling580    // the popup pairing is complete so a popup close/reopen cannot reveal the581    // temporarily empty receiver vault. The popup clears this only after that582    // row records a successful sync.583    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(584      () => undefined,585    );586    push("ADOPTION_SOURCE_RESULT", { status: "complete" });587    createdVault = false;588589    if (snapshotApplied) {590      // The snapshot populated the vault and marked the source synced, so the591      // pending marker's job is done — clear it instead of making the popup wait.592      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);593    } else {594      // Fallback: the existing sync pipeline fills the vault. Immediate595      // best-effort trigger plus alarm-based retries that survive SW death.596      void triggerSyncIfReachable({ reason: "manual", force: true });597      schedulePostAdoptionSync();598    }599  } catch (error) {600    if (createdVault) {601      try {602        await rollbackAdoptedVault();603        createdVault = false;604      } catch (rollbackError) {605        console.error("[Peach] rollback failed:", rollbackError);606      }607    }608    console.error("[Peach] receiver session failed:", error);609    push("ADOPTION_SOURCE_RESULT", {610      status: "error",611      error: error instanceof Error ? error.message : "adoption_failed",612    });613  } finally {614    clearTimeout(timeout);615    noise?.close();616    socket.close();617    ephemeralPrivate.fill(0);618    ephemeralPublic.fill(0);619  }620}621622/**623 * A transferred entitlement is committed only when activation succeeds and624 * the stored value can immediately be re-read as Pro. activateLicense returns625 * a normal Free result for invalid input, so callers must not ignore it.626 */627async function applyTransferredLicense(license: string): Promise<void> {628  const activated = await activateLicense(license);629  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {630    throw new Error("license_transfer_failed");631  }632}633634/**635 * Push the SAS to the popup, then block until the popup responds via the636 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on637 * timeout or cancel so the adoption aborts before any vault state changes.638 *639 * The pending deferred MUST be installed before the push: a popup-side640 * auto-approver (test mocks, future automation) can synchronously resolve641 * the deferred the moment it sees the SAS push, and a missing slot would642 * drop that approval on the floor.643 */644function waitForReceiverSasConfirmation(645  id: string,646  sas: string,647): Promise<boolean> {648  return new Promise<boolean>((resolve) => {649    const timer = setTimeout(() => {650      pendingReceiverSasConfirmation.delete(id);651      resolve(false);652    }, SOURCE_WINDOW_MS);653    pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {654      clearTimeout(timer);655      resolve(ok);656    } });657    push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });658  });659}660661function waitForSourceSasConfirmation(662  id: string,663  request: { device_name: string },664  sas: string,665): Promise<boolean> {666  return new Promise<boolean>((resolve) => {667    const timer = setTimeout(() => {668      pendingSourceSasConfirmation.delete(id);669      resolve(false);670    }, SOURCE_WINDOW_MS);671    pendingSourceSasConfirmation.set(id, {672      resolve: (ok) => {673        clearTimeout(timer);674        resolve(ok);675      },676    });677    push("ADOPTION_SOURCE_SAS", {678      id,679      sas,680      receiverDeviceName: request.device_name,681    });682  });683}684685/**686 * Opening Add Device in an unlocked extension is the source-side authorization.687 * Announce the SAS for comparison, then let the scanning device's explicit688 * confirmation be the single commit gate. grantAdoption still refuses to send689 * the root key until the receiver returns ADOPT_CONFIRM(true).690 */691function requestConsent(692  request: { device_name: string },693  sas: string,694  id: string,695): Promise<boolean> {696  return waitForSourceSasConfirmation(id, request, sas);697}698699function clearState(): void {700  activeSocket = null;701  inFlight = false;702  pendingSourceSasConfirmation.clear();703  pendingReceiverSasConfirmation.clear();704}705706function constantTimeEqual(a: string, b: string): boolean {707  if (a.length !== b.length) return false;708  let diff = 0;709  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);710  return diff === 0;711}712713function encodeJson(value: unknown): Uint8Array {714  return new TextEncoder().encode(JSON.stringify(value));715}716717function decodeJson<T>(value: Uint8Array): T {718  return JSON.parse(new TextDecoder().decode(value)) as T;719}720721/**722 * PB-26-213: an adopted device's secret bypasses the public registry into723 * the encrypted pair-secret record. The registry row stays secretless; when724 * the vault is somehow locked here the row still lands and the next unlock725 * cannot recover this secret — re-pair is the fail-closed path.726 */727async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {728  const secret = paired.rendezvousSecretHex;729  const pub = { ...paired };730  delete pub.rendezvousSecretHex;731  // Validate, persist, and clear the terminal tombstone under one registry732  // mutation so a concurrent revoke cannot recreate or lose the tombstone.733  await upsertPairedDeviceAndClearRevocation(pub);734  if (!isValidPairSecret(secret)) return;735  const masterKeyHex = getRootKeyHex();736  if (!masterKeyHex) return;737  const masterKey = hexToBytes(masterKeyHex);738  try {739    await setPairSecret(paired.deviceId, secret, masterKey);740  } finally {741    masterKey.fill(0);742  }743}744745function hexToBytes(hex: string): Uint8Array {746  const out = new Uint8Array(hex.length / 2);747  for (let i = 0; i < out.length; i++) {748    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);749  }750  return out;751}