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  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        const masterKeyHex = getRootKeyHex();317        if (!masterKeyHex) throw new Error("identity_unavailable");318        const masterKey = hexToBytes(masterKeyHex);319        try {320          return await buildLocalIdentity(masterKey);321        } finally {322          masterKey.fill(0);323        }324      },325    });326    if (!device) {327      push("ADOPTION_SOURCE_RESULT", {328        status: "error",329        error: "adoption_rejected",330      });331      return;332    }333    // If the receiver advertised inline-snapshot support, send the vault now334    // over the same authenticated channel. Best-effort: any failure leaves the335    // receiver to fall back to the sync pipeline triggered below.336    if (request.snapshot_capable === true) {337      try {338        const adapter = createSyncVaultAdapter();339        const entryManifest = await adapter.buildEntryManifest();340        const entryIds = Object.keys(entryManifest);341        const entries =342          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};343        const tombstones = await adapter.buildTombstoneManifest();344        let duressSlot: string | null = null;345        try {346          duressSlot = await exportDuressSlotForAdoption();347        } catch {348          // Duress omission must not fail the whole snapshot.349        }350        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);351352        const applied = await withTimeout(receiveFrame(channel), 15_000);353        if (isAdoptSnapshotApplied(applied)) {354          console.info(355            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,356          );357        }358359        if (adapter.buildOutgoingDeviceManifest) {360          const manifest = await adapter.buildOutgoingDeviceManifest();361          if (manifest) {362            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });363            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);364          }365        }366      } catch (snapshotError) {367        if (368          snapshotError instanceof Error &&369          snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"370        ) {371          throw snapshotError;372        }373        console.info(374          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",375          snapshotError,376        );377      }378    }379    push("ADOPTION_SOURCE_RESULT", { status: "complete" });380    // The Android receiver starts its relay listener as soon as its source381    // record is durable. Immediate best-effort trigger; alarm-based retries382    // cover MV3 service-worker death (setTimeout does not survive it).383    void triggerSyncIfReachable({ reason: "manual", force: true });384    schedulePostAdoptionSync();385  } catch (error) {386    push("ADOPTION_SOURCE_RESULT", {387      status: "error",388      error: error instanceof Error ? error.message : "adoption_failed",389    });390  } finally {391    clearTimeout(timeout);392    noise?.close();393    socket.close();394    pendingSourceSasConfirmation.clear();395  }396}397398/**399 * Browser receives the vault from the phone. The protocol is the mirror of400 * the source side: phone is the Noise initiator and the source of truth for401 * the SAS gate, browser is the responder that creates the vault locally.402 */403async function runReceiverSession(404  socket: PairingSocket,405  expectedToken: string,406  relayUrl: string,407  password: string,408  invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },409): Promise<void> {410  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);411  // The invitation binds the responder static so the initiator can verify the ACK.412  const ephemeral = invitationEphemeral;413  const ephemeralPrivate = ephemeral.secretKey;414  const ephemeralPublic = ephemeral.publicKey;415  let noise: NoiseNKSession | undefined;416  let createdVault = false;417  let protocolError: string | undefined;418  try {419    const hello = decodeJson<{ type?: unknown; token?: unknown }>(420      await socket.receive(),421    );422    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||423        !constantTimeEqual(hello.token, expectedToken)) {424      throw new Error("invalid_invitation");425    }426    socket.send(encodeJson({427      type: "PEACH_HELLO_ACK",428      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),429    }));430431    const session = await noiseNKResponder(432      ephemeralPrivate,433      ephemeralPublic,434      async (bytes) => socket.send(bytes),435      () => socket.receive(),436    );437    noise = session;438    const channel: PairingTransport = {439      async send(plaintext) { socket.send(session.encrypt(plaintext)); },440      async receive() { return session.decrypt(await socket.receive()); },441    };442443    const deviceName = await getDeviceName();444445    const adoptionResult = await requestAdoption(channel, {446      deviceName,447      generateDeviceId: () => crypto.randomUUID(),448      async confirmSas() {449        const sas = derivePairingSAS(session.handshakeHash);450        const id = crypto.randomUUID();451        return waitForReceiverSasConfirmation(id, sas);452      },453      async createVaultFromRootKey(rootKeyHex) {454        // Direct call, not chrome.runtime.sendMessage — a service worker455        // calling sendMessage does not reach its own listener (returns456        // undefined), and the receiver already lives in the same SW context457        // as the vault module. Round-tripping through the message bus was458        // the root cause of "create_vault_no_response".459        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);460        createdVault = true;461        return { vaultId };462      },463      async applyLicense(license) {464        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self465        // issue as createVaultFromRootKey. SW can't reach its own listener.466        await applyTransferredLicense(license);467      },468      async license() {469        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;470      },471      async deriveLocalIdentity() {472        // Direct call for the same reason as applyLicense above.473        const masterKeyHex = getRootKeyHex();474        if (!masterKeyHex) throw new Error("identity_unavailable");475        const masterKey = hexToBytes(masterKeyHex);476        try {477          return await buildLocalIdentity(masterKey);478        } finally {479          masterKey.fill(0);480        }481      },482      async rotateLocalIdentity() {483        await rotateDeviceId();484        const masterKeyHex = getRootKeyHex();485        if (!masterKeyHex) throw new Error("identity_unavailable");486        const masterKey = hexToBytes(masterKeyHex);487        try {488          return await buildLocalIdentity(masterKey);489        } finally {490          masterKey.fill(0);491        }492      },493      async upsertPairedDevice(paired) {494        // Direct call for the same reason as applyLicense above.495        await storeAdoptedDevice(paired);496      },497      async hasSourceIdentityCollision(source) {498        return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);499      },500      pairState: {501        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),502        relayUrl,503      },504      onError(error) {505        protocolError = error;506      },507    });508509    if (!adoptionResult) {510      if (createdVault) {511        await rollbackAdoptedVault();512        createdVault = false;513      }514      push("ADOPTION_SOURCE_RESULT", {515        status: "error",516        error: protocolError ?? "adoption_rejected",517      });518      return;519    }520    const { device, snapshotCapable } = adoptionResult;521    // The pairing channel is still open and authenticated — try to pull the522    // vault snapshot inline so the popup transitions immediately instead of523    // waiting on a second sync round. Skip the wait entirely when the source524    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);525    // fall through to the existing sync pipeline below.526    let snapshotApplied = false;527    if (snapshotCapable) try {528      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);529      const adapter = createSyncVaultAdapter();530      await adapter.applyEntries(snapshot.entries);531      await adapter.applyTombstones(snapshot.tombstones);532      // Duress install is isolated: a failure here must NOT demote an533      // otherwise-successful entry snapshot to the sync fallback.534      if (snapshot.duressSlot) {535        try {536          await installAdoptedDuressSlot(snapshot.duressSlot);537        } catch (duressError) {538          console.info(539            "[Peach] adopted duress slot install failed (non-fatal):",540            duressError,541          );542        }543      }544      await sendFrame(channel, {545        type: "ADOPT_SNAPSHOT_APPLIED",546        entries_received: Object.keys(snapshot.entries).length,547        tombstones_received: Object.keys(snapshot.tombstones).length,548      });549550      // Optional device graph. Non-fatal and isolated so a manifest timeout or551      // unsupported source cannot demote an otherwise-successful entry snapshot.552      try {553        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);554        if (555          isAdoptDeviceManifestFrame(manifestFrame) &&556          adapter.applyIncomingDeviceManifest &&557          typeof device.longTermPublicKeyHex === "string" &&558          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)559        ) {560          await adapter.applyIncomingDeviceManifest(561            manifestFrame.manifest,562            hexToBytes(device.longTermPublicKeyHex),563          );564          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });565        }566      } catch {567        // The graph arrives via incremental sync instead.568      }569570      await markLastSyncedAt(device.deviceId, Date.now());571      snapshotApplied = true;572    } catch (snapshotError) {573      console.info(574        "[Peach] inline snapshot failed, falling back to sync pipeline:",575        snapshotError,576      );577    }578579    // Adoption has committed identity and vault-key material, but entries have580    // not necessarily crossed yet. Persist the exact source row before telling581    // the popup pairing is complete so a popup close/reopen cannot reveal the582    // temporarily empty receiver vault. The popup clears this only after that583    // row records a successful sync.584    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(585      () => undefined,586    );587    push("ADOPTION_SOURCE_RESULT", { status: "complete" });588    createdVault = false;589590    if (snapshotApplied) {591      // The snapshot populated the vault and marked the source synced, so the592      // pending marker's job is done — clear it instead of making the popup wait.593      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);594    } else {595      // Fallback: the existing sync pipeline fills the vault. Immediate596      // best-effort trigger plus alarm-based retries that survive SW death.597      void triggerSyncIfReachable({ reason: "manual", force: true });598      schedulePostAdoptionSync();599    }600  } catch (error) {601    if (createdVault) {602      try {603        await rollbackAdoptedVault();604        createdVault = false;605      } catch (rollbackError) {606        console.error("[Peach] rollback failed:", rollbackError);607      }608    }609    console.error("[Peach] receiver session failed:", error);610    push("ADOPTION_SOURCE_RESULT", {611      status: "error",612      error: error instanceof Error ? error.message : "adoption_failed",613    });614  } finally {615    clearTimeout(timeout);616    noise?.close();617    socket.close();618    ephemeralPrivate.fill(0);619    ephemeralPublic.fill(0);620  }621}622623/**624 * A transferred entitlement is committed only when activation succeeds and625 * the stored value can immediately be re-read as Pro. activateLicense returns626 * a normal Free result for invalid input, so callers must not ignore it.627 */628async function applyTransferredLicense(license: string): Promise<void> {629  const activated = await activateLicense(license);630  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {631    throw new Error("license_transfer_failed");632  }633}634635/**636 * Push the SAS to the popup, then block until the popup responds via the637 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on638 * timeout or cancel so the adoption aborts before any vault state changes.639 *640 * The pending deferred MUST be installed before the push: a popup-side641 * auto-approver (test mocks, future automation) can synchronously resolve642 * the deferred the moment it sees the SAS push, and a missing slot would643 * drop that approval on the floor.644 */645function waitForReceiverSasConfirmation(646  id: string,647  sas: string,648): Promise<boolean> {649  return new Promise<boolean>((resolve) => {650    const timer = setTimeout(() => {651      pendingReceiverSasConfirmation.delete(id);652      resolve(false);653    }, SOURCE_WINDOW_MS);654    pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {655      clearTimeout(timer);656      resolve(ok);657    } });658    push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });659  });660}661662function waitForSourceSasConfirmation(663  id: string,664  request: AdoptRequest,665  sas: string,666): Promise<boolean> {667  return new Promise<boolean>((resolve) => {668    const timer = setTimeout(() => {669      pendingSourceSasConfirmation.delete(id);670      resolve(false);671    }, SOURCE_WINDOW_MS);672    pendingSourceSasConfirmation.set(id, {673      resolve: (ok) => {674        clearTimeout(timer);675        resolve(ok);676      },677    });678    push("ADOPTION_SOURCE_SAS", {679      id,680      sas,681      receiverDeviceName: request.device_name,682    });683  });684}685686/**687 * Opening Add Device in an unlocked extension is the source-side authorization.688 * Announce the SAS for comparison, then let the scanning device's explicit689 * confirmation be the single commit gate. grantAdoption still refuses to send690 * the root key until the receiver returns ADOPT_CONFIRM(true).691 */692function requestConsent(693  request: AdoptRequest,694  sas: string,695  id: string,696): Promise<boolean> {697  return waitForSourceSasConfirmation(id, request, sas);698}699700function clearState(): void {701  activeSocket = null;702  inFlight = false;703  pendingSourceSasConfirmation.clear();704  pendingReceiverSasConfirmation.clear();705}706707function constantTimeEqual(a: string, b: string): boolean {708  if (a.length !== b.length) return false;709  let diff = 0;710  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);711  return diff === 0;712}713714function encodeJson(value: unknown): Uint8Array {715  return new TextEncoder().encode(JSON.stringify(value));716}717718function decodeJson<T>(value: Uint8Array): T {719  return JSON.parse(new TextDecoder().decode(value)) as T;720}721722/**723 * PB-26-213: an adopted device's secret bypasses the public registry into724 * the encrypted pair-secret record. The registry row stays secretless; when725 * the vault is somehow locked here the row still lands and the next unlock726 * cannot recover this secret — re-pair is the fail-closed path.727 */728async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {729  const secret = paired.rendezvousSecretHex;730  const pub = { ...paired };731  delete pub.rendezvousSecretHex;732  // Validate, persist, and clear the terminal tombstone under one registry733  // mutation so a concurrent revoke cannot recreate or lose the tombstone.734  await upsertPairedDeviceAndClearRevocation(pub);735  if (!isValidPairSecret(secret)) return;736  const masterKeyHex = getRootKeyHex();737  if (!masterKeyHex) return;738  const masterKey = hexToBytes(masterKeyHex);739  try {740    await setPairSecret(paired.deviceId, secret, masterKey);741  } finally {742    masterKey.fill(0);743  }744}745746function hexToBytes(hex: string): Uint8Array {747  const out = new Uint8Array(hex.length / 2);748  for (let i = 0; i < out.length; i++) {749    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);750  }751  return out;752}