relay-adoption-bg.ts

27.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  browserGenerationIo,54  isPostReset,55  onDirectV2PairingEstablished,56} from "./sync-generation";57import {58  hasAdoptionIdentityConflict,59  markLastSyncedAt,60  upsertPairedDeviceAndClearRevocation,61} from "./paired-devices";62import { getSyncSettings, relayInForce } from "./sync-settings";63import { activateLicense, getLicenseKey, isProUser } from "./license";64import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";65import { deferAutoLockForOperation } from "./alarms";66import { computeVaultId } from "../shared/sync-identity";67import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";68import { storageRemove, storageSet } from "../shared/storage";69import {70  persistPendingAdoptionHolder,71  persistPendingAdoptionIdentity,72  readPendingAdoptionHolder,73  removePendingAdoptionHolder,74  completePendingAdoptionIdentity,75} from "./pending-adoption-holder";76import { isValidPairSecret, setPairSecret } from "./pair-secrets";77import { createSyncVaultAdapter } from "./sync-vault-adapter";78import { triggerSyncIfReachable } from "./sync-trigger";79import { schedulePostAdoptionSync } from "./sync-alarm";8081const SOURCE_WINDOW_MS = 5 * 60 * 1000;8283function push(type: string, payload: unknown): void {84  try {85    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;86    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {87      (maybe as Promise<unknown>).catch(() => undefined);88    }89  } catch {90    // The popup may have closed. The pending approval fails closed on cleanup.91  }92}9394/** Race a promise against a wall-clock deadline; rejects with "timeout". */95function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {96  return new Promise<T>((resolve, reject) => {97    const timer = setTimeout(() => reject(new Error("timeout")), ms);98    promise.then(99      (value) => {100        clearTimeout(timer);101        resolve(value);102      },103      (error) => {104        clearTimeout(timer);105        reject(error);106      },107    );108  });109}110111let inFlight = false;112let activeSocket: PairingSocket | null = null;113// Outstanding source-side SAS confirmations, keyed by id. A map (vs the114// receiver's single slot) makes a stale decision from a previous source115// session impossible to land on the current one even if both run on the116// same background worker.117const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();118const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();119120export function isRelayAdoptionSourceInFlight(): boolean {121  return inFlight;122}123124/**125 * Occupy the non-buffering room before returning the invitation to the UI.126 * All long-term key material remains in the background service worker.127 */128export async function startRelayAdoptionSource(): Promise<{129  invitation: string;130}> {131  if (inFlight) throw new Error("adoption_source_in_progress");132  if (!isUnlocked()) throw new Error("vault_locked");133  inFlight = true;134  const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);135136  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;137  try {138    const settings = await getSyncSettings();139    const relayUrl = relayInForce(settings);140    local = await getLocalPairingIdentity();141    const token = generatePairingRendezvousToken();142    // Source side has the vault — caller checked isUnlocked — so the phone143    // can read the invitation and know it should receive the vault.144    const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));145    const invitation = parsePairingInvitation(encoded);146    if (!invitation) throw new Error("invitation_generation_failed");147148    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));149    activeSocket = socket;150    const readyLocal = local;151    void runSourceSession(152      socket,153      token,154      relayUrl,155      readyLocal,156    ).finally(() => {157      readyLocal.privateKey.fill(0);158      readyLocal.publicKey.fill(0);159      clearState();160      releaseAutoLock();161    });162    return { invitation: encoded };163  } catch (error) {164    local?.privateKey.fill(0);165    local?.publicKey.fill(0);166    clearState();167    releaseAutoLock();168    throw error;169  }170}171172export function cancelRelayAdoptionSource(): void {173  activeSocket?.close();174  clearState();175}176177/**178 * Onboarding pairing request — the empty extension shows a QR and the populated179 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the180 * phone reads to switch into source mode and `grantAdoption` over the181 * established Noise channel. The receiver's `runReceiverSession` accepts the182 * root key and creates a local vault wrapped with the popup-supplied password.183 *184 * `password` is the local-only password the user picked on the "Set a password185 * for this device" screen; it wraps the adopted root key at rest and has no186 * relationship to the source device's password.187 */188export async function startRelayAdoptionReceiver(189  password: string,190): Promise<{ invitation: string }> {191  if (inFlight) throw new Error("adoption_receiver_in_progress");192  if (typeof password !== "string" || password.length === 0) {193    throw new Error("ADOPT_INVALID_PASSWORD");194  }195  inFlight = true;196  try {197    await preparePreVaultIdentity(password);198    const settings = await getSyncSettings();199    const relayUrl = relayInForce(settings);200    const token = generatePairingRendezvousToken();201    const ephemeral = generateKeyPair();202    const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));203    const invitation = parsePairingInvitation(encoded);204    if (!invitation) throw new Error("invitation_generation_failed");205    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));206    activeSocket = socket;207    void runReceiverSession(208      socket,209      token,210      relayUrl,211      password,212      ephemeral,213    ).finally(() => {214      ephemeral.secretKey.fill(0);215      ephemeral.publicKey.fill(0);216      clearState();217    });218    return { invitation: encoded };219  } catch (error) {220    clearState();221    throw error;222  }223}224225/**226 * Resolves the source-side SAS confirmation by id. Called by the message227 * handler when the popup taps Confirm/Cancel on the source's SAS screen.228 * Returns true if the id matched an in-flight confirmation. Unknown ids229 * (already resolved, timed out, or never existed) are a no-op so a late230 * popup click cannot crash anything.231 */232export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {233  const pending = pendingSourceSasConfirmation.get(id);234  if (!pending) return false;235  pendingSourceSasConfirmation.delete(id);236  pending.resolve(ok);237  return true;238}239240export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {241  const pending = pendingReceiverSasConfirmation.get(id);242  if (!pending) return false;243  pendingReceiverSasConfirmation.delete(id);244  pending.resolve(ok && constantTimeEqual(pending.sas, sas));245  return true;246}247248async function runSourceSession(249  socket: PairingSocket,250  expectedToken: string,251  relayUrl: string,252  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,253): Promise<void> {254  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);255  let noise: NoiseNKSession | undefined;256  try {257    const hello = decodeJson<{ type?: unknown; token?: unknown }>(258      await socket.receive(),259    );260    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||261        !constantTimeEqual(hello.token, expectedToken)) {262      throw new Error("invalid_invitation");263    }264    socket.send(encodeJson({265      type: "PEACH_HELLO_ACK",266      ephemeralPublicKeyHex: bytesToHex(local.publicKey),267    }));268269    const session = await noiseNKResponder(270      local.privateKey,271      local.publicKey,272      async (bytes) => socket.send(bytes),273      () => socket.receive(),274    );275    noise = session;276    const channel: PairingTransport = {277      async send(plaintext) { socket.send(session.encrypt(plaintext)); },278      async receive() { return session.decrypt(await socket.receive()); },279    };280    const request = await receiveFrame(channel);281    if (!isAdoptRequest(request) && !isAdoptRequestV2(request)) {282      throw new Error("expected_ADOPT_REQUEST");283    }284285    const sourceDeps: AdoptionSourceDeps & AdoptionV2SourceDeps = {286      sourceIdentity: local.identity,287      pairState: {288        rendezvousSecretHex: bytesToHex(289          deriveRendezvousSecret(session.handshakeHash),290        ),291        relayUrl,292      },293      async rootKeyHex() {294        // grantAdoption calls this only after local password approval and the295        // receiver's encrypted ADOPT_CONFIRM(true).296        const value = getRootKeyHex();297        if (!value) throw new Error("vault_locked");298        return value;299      },300      async vaultId() {301        const value = getRootKeyHex();302        if (!value) throw new Error("vault_locked");303        const bytes = hexToBytes(value);304        try {305          return await computeVaultId(bytes);306        } finally {307          bytes.fill(0);308        }309      },310      async license() {311        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;312      },313      async applyLicense(license) {314        await applyTransferredLicense(license);315      },316      onConsent: (adoptRequest) => requestConsent(317        adoptRequest,318        derivePairingSAS(session.handshakeHash),319        crypto.randomUUID(),320      ),321      persistPendingHolder: persistPendingAdoptionHolder,322      clearPendingHolder: async (adoptRequest) => {323        const current = await readPendingAdoptionHolder();324        if (current?.request.device_id === adoptRequest.device_id) {325          await removePendingAdoptionHolder();326        }327      },328      async upsertPairedDevice(paired) {329        await storeAdoptedDevice(paired);330      },331      hasDeviceIdentityCollision: async (device) =>332        hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),333      rotateSourceIdentity: async () => {334        await rotateDeviceId();335        return await buildLocalIdentity();336      },337      persistPendingIdentity: persistPendingAdoptionIdentity,338      async completePendingIdentity(paired: PairedDevice) {339        await completePendingAdoptionIdentity(paired);340      },341      snapshotCapable: () => true,342    };343    // §11.6: after a secure reset a v1 peer is repair-required, exactly as an344    // outdated sync peer is. Refused before any grant. A new ADOPT_REJECT345    // reason is not an option — §10.3 freezes the set to the two v1 peers346    // already understand — so the refusal is local and the peer simply fails.347    if (isAdoptRequest(request) && (await isPostReset(browserGenerationIo))) {348      push("ADOPTION_SOURCE_RESULT", {349        status: "error",350        error: "peer_protocol_outdated",351      });352      return;353    }354    const device = isAdoptRequestV2(request)355      ? await grantAdoptionV2(channel, request, sourceDeps)356      : await grantAdoption(channel, request, sourceDeps);357    if (!device) {358      push("ADOPTION_SOURCE_RESULT", {359        status: "error",360        error: "adoption_rejected",361      });362      return;363    }364    // If the receiver advertised inline-snapshot support, send the vault now365    // over the same authenticated channel. Best-effort: any failure leaves the366    // receiver to fall back to the sync pipeline triggered below.367    if (request.snapshot_capable === true) {368      try {369        const adapter = createSyncVaultAdapter();370        const entryManifest = await adapter.buildEntryManifest();371        const entryIds = Object.keys(entryManifest);372        const entries =373          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};374        const tombstones = await adapter.buildTombstoneManifest();375        let duressSlot: string | null = null;376        try {377          duressSlot = await exportDuressSlotForAdoption();378        } catch {379          // Duress omission must not fail the whole snapshot.380        }381        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);382383        const applied = await withTimeout(receiveFrame(channel), 15_000);384        if (isAdoptSnapshotApplied(applied)) {385          console.info(386            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,387          );388        }389390        if (adapter.buildOutgoingDeviceManifest) {391          const manifest = await adapter.buildOutgoingDeviceManifest();392          if (manifest) {393            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });394            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);395          }396        }397      } catch (snapshotError) {398        if (399          snapshotError instanceof Error &&400          snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"401        ) {402          throw snapshotError;403        }404        console.info(405          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",406          snapshotError,407        );408      }409    }410    push("ADOPTION_SOURCE_RESULT", { status: "complete" });411    // The Android receiver starts its relay listener as soon as its source412    // record is durable. Immediate best-effort trigger; alarm-based retries413    // cover MV3 service-worker death (setTimeout does not survive it).414    void triggerSyncIfReachable({ reason: "manual", force: true });415    schedulePostAdoptionSync();416  } catch (error) {417    push("ADOPTION_SOURCE_RESULT", {418      status: "error",419      error: error instanceof Error ? error.message : "adoption_failed",420    });421  } finally {422    clearTimeout(timeout);423    noise?.close();424    socket.close();425    pendingSourceSasConfirmation.clear();426  }427}428429/**430 * Browser receives the vault from the phone. The protocol is the mirror of431 * the source side: phone is the Noise initiator and the source of truth for432 * the SAS gate, browser is the responder that creates the vault locally.433 */434async function runReceiverSession(435  socket: PairingSocket,436  expectedToken: string,437  relayUrl: string,438  password: string,439  invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },440): Promise<void> {441  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);442  // The invitation binds the responder static so the initiator can verify the ACK.443  const ephemeral = invitationEphemeral;444  const ephemeralPrivate = ephemeral.secretKey;445  const ephemeralPublic = ephemeral.publicKey;446  let noise: NoiseNKSession | undefined;447  let createdVault = false;448  let protocolError: string | undefined;449  try {450    const hello = decodeJson<{ type?: unknown; token?: unknown }>(451      await socket.receive(),452    );453    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||454        !constantTimeEqual(hello.token, expectedToken)) {455      throw new Error("invalid_invitation");456    }457    socket.send(encodeJson({458      type: "PEACH_HELLO_ACK",459      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),460    }));461462    const session = await noiseNKResponder(463      ephemeralPrivate,464      ephemeralPublic,465      async (bytes) => socket.send(bytes),466      () => socket.receive(),467    );468    noise = session;469    const channel: PairingTransport = {470      async send(plaintext) { socket.send(session.encrypt(plaintext)); },471      async receive() { return session.decrypt(await socket.receive()); },472    };473474    const deviceName = await getDeviceName();475476    const receiverDeps: AdoptionV2ReceiverDeps = {477      deviceName,478      async confirmSas() {479        const sas = derivePairingSAS(session.handshakeHash);480        const id = crypto.randomUUID();481        return waitForReceiverSasConfirmation(id, sas);482      },483      async createVaultFromRootKey(rootKeyHex) {484        // Direct call, not chrome.runtime.sendMessage — a service worker485        // calling sendMessage does not reach its own listener (returns486        // undefined), and the receiver already lives in the same SW context487        // as the vault module. Round-tripping through the message bus was488        // the root cause of "create_vault_no_response".489        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);490        createdVault = true;491        return { vaultId };492      },493      async applyLicense(license) {494        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self495        // issue as createVaultFromRootKey. SW can't reach its own listener.496        await applyTransferredLicense(license);497      },498      async license() {499        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;500      },501      async rotateLocalIdentity() {502        return await rotatePreVaultIdentity(password);503      },504      async hasSourceIdentityCollision(source) {505        return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);506      },507      pairState: {508        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),509        relayUrl,510      },511      onError(error) {512        protocolError = error;513      },514      async localIdentity() {515        return await readPreVaultIdentity(password);516      },517      async validateSource() {},518      async upsertPairedDeviceAndClearRevocation(paired: PairedDevice) {519        await storeAdoptedDevice(paired);520      },521    };522    const adoptionResult = await requestAdoptionV2(channel, receiverDeps);523524    if (!adoptionResult) {525      if (createdVault) {526        await rollbackAdoptedVault();527        createdVault = false;528      }529      push("ADOPTION_SOURCE_RESULT", {530        status: "error",531        error: protocolError ?? "adoption_rejected",532      });533      return;534    }535    const { device, snapshotCapable } = adoptionResult;536    // The pairing channel is still open and authenticated — try to pull the537    // vault snapshot inline so the popup transitions immediately instead of538    // waiting on a second sync round. Skip the wait entirely when the source539    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);540    // fall through to the existing sync pipeline below.541    let snapshotApplied = false;542    if (snapshotCapable) try {543      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);544      const adapter = createSyncVaultAdapter();545      await adapter.applyEntries(snapshot.entries);546      await adapter.applyTombstones(snapshot.tombstones);547      // Duress install is isolated: a failure here must NOT demote an548      // otherwise-successful entry snapshot to the sync fallback.549      if (snapshot.duressSlot) {550        try {551          await installAdoptedDuressSlot(snapshot.duressSlot);552        } catch (duressError) {553          console.info(554            "[Peach] adopted duress slot install failed (non-fatal):",555            duressError,556          );557        }558      }559      await sendFrame(channel, {560        type: "ADOPT_SNAPSHOT_APPLIED",561        entries_received: Object.keys(snapshot.entries).length,562        tombstones_received: Object.keys(snapshot.tombstones).length,563      });564565      // Optional device graph. Non-fatal and isolated so a manifest timeout or566      // unsupported source cannot demote an otherwise-successful entry snapshot.567      try {568        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);569        if (570          isAdoptDeviceManifestFrame(manifestFrame) &&571          adapter.applyIncomingDeviceManifest &&572          typeof device.longTermPublicKeyHex === "string" &&573          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)574        ) {575          await adapter.applyIncomingDeviceManifest(576            manifestFrame.manifest,577            hexToBytes(device.longTermPublicKeyHex),578          );579          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });580        }581      } catch {582        // The graph arrives via incremental sync instead.583      }584585      await markLastSyncedAt(device.deviceId, Date.now());586      snapshotApplied = true;587    } catch (snapshotError) {588      console.info(589        "[Peach] inline snapshot failed, falling back to sync pipeline:",590        snapshotError,591      );592    }593594    // Adoption has committed identity and vault-key material, but entries have595    // not necessarily crossed yet. Persist the exact source row before telling596    // the popup pairing is complete so a popup close/reopen cannot reveal the597    // temporarily empty receiver vault. The popup clears this only after that598    // row records a successful sync.599    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(600      () => undefined,601    );602    push("ADOPTION_SOURCE_RESULT", { status: "complete" });603    createdVault = false;604605    if (snapshotApplied) {606      // The snapshot populated the vault and marked the source synced, so the607      // pending marker's job is done — clear it instead of making the popup wait.608      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);609    } else {610      // Fallback: the existing sync pipeline fills the vault. Immediate611      // best-effort trigger plus alarm-based retries that survive SW death.612      void triggerSyncIfReachable({ reason: "manual", force: true });613      schedulePostAdoptionSync();614    }615  } catch (error) {616    if (createdVault) {617      try {618        await rollbackAdoptedVault();619        createdVault = false;620      } catch (rollbackError) {621        console.error("[Peach] rollback failed:", rollbackError);622      }623    }624    console.error("[Peach] receiver session failed:", error);625    push("ADOPTION_SOURCE_RESULT", {626      status: "error",627      error: error instanceof Error ? error.message : "adoption_failed",628    });629  } finally {630    clearTimeout(timeout);631    noise?.close();632    socket.close();633    ephemeralPrivate.fill(0);634    ephemeralPublic.fill(0);635  }636}637638/**639 * A transferred entitlement is committed only when activation succeeds and640 * the stored value can immediately be re-read as Pro. activateLicense returns641 * a normal Free result for invalid input, so callers must not ignore it.642 */643async function applyTransferredLicense(license: string): Promise<void> {644  const activated = await activateLicense(license);645  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {646    throw new Error("license_transfer_failed");647  }648}649650/**651 * Push the SAS to the popup, then block until the popup responds via the652 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on653 * timeout or cancel so the adoption aborts before any vault state changes.654 *655 * The pending deferred MUST be installed before the push: a popup-side656 * auto-approver (test mocks, future automation) can synchronously resolve657 * the deferred the moment it sees the SAS push, and a missing slot would658 * drop that approval on the floor.659 */660function waitForReceiverSasConfirmation(661  id: string,662  sas: string,663): Promise<boolean> {664  return new Promise<boolean>((resolve) => {665    const timer = setTimeout(() => {666      pendingReceiverSasConfirmation.delete(id);667      resolve(false);668    }, SOURCE_WINDOW_MS);669    pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {670      clearTimeout(timer);671      resolve(ok);672    } });673    push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });674  });675}676677function waitForSourceSasConfirmation(678  id: string,679  request: { device_name: string },680  sas: string,681): Promise<boolean> {682  return new Promise<boolean>((resolve) => {683    const timer = setTimeout(() => {684      pendingSourceSasConfirmation.delete(id);685      resolve(false);686    }, SOURCE_WINDOW_MS);687    pendingSourceSasConfirmation.set(id, {688      resolve: (ok) => {689        clearTimeout(timer);690        resolve(ok);691      },692    });693    push("ADOPTION_SOURCE_SAS", {694      id,695      sas,696      receiverDeviceName: request.device_name,697    });698  });699}700701/**702 * Opening Add Device in an unlocked extension is the source-side authorization.703 * Announce the SAS for comparison, then let the scanning device's explicit704 * confirmation be the single commit gate. grantAdoption still refuses to send705 * the root key until the receiver returns ADOPT_CONFIRM(true).706 */707function requestConsent(708  request: { device_name: string },709  sas: string,710  id: string,711): Promise<boolean> {712  return waitForSourceSasConfirmation(id, request, sas);713}714715function clearState(): void {716  activeSocket = null;717  inFlight = false;718  pendingSourceSasConfirmation.clear();719  pendingReceiverSasConfirmation.clear();720}721722function constantTimeEqual(a: string, b: string): boolean {723  if (a.length !== b.length) return false;724  let diff = 0;725  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);726  return diff === 0;727}728729function encodeJson(value: unknown): Uint8Array {730  return new TextEncoder().encode(JSON.stringify(value));731}732733function decodeJson<T>(value: Uint8Array): T {734  return JSON.parse(new TextDecoder().decode(value)) as T;735}736737/**738 * PB-26-213: an adopted device's secret bypasses the public registry into739 * the encrypted pair-secret record. The registry row stays secretless; when740 * the vault is somehow locked here the row still lands and the next unlock741 * cannot recover this secret — re-pair is the fail-closed path.742 */743async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {744  const secret = paired.rendezvousSecretHex;745  const pub = { ...paired };746  delete pub.rendezvousSecretHex;747  // Validate, persist, and clear the terminal tombstone under one registry748  // mutation so a concurrent revoke cannot recreate or lose the tombstone.749  await upsertPairedDeviceAndClearRevocation(pub);750  // §11.3 REPAIRING → V2_ACTIVE. Anything adopted here is a direct pairing: it751  // came from a grant, not from a manifest vouch, and on a device that has752  // reset it can only be v2 — the post-reset source refuses v1 at the first753  // frame. A device that never reset is untouched by this.754  await onDirectV2PairingEstablished(browserGenerationIo, true);755  if (!isValidPairSecret(secret)) return;756  const masterKeyHex = getRootKeyHex();757  if (!masterKeyHex) return;758  const masterKey = hexToBytes(masterKeyHex);759  try {760    await setPairSecret(paired.deviceId, secret, masterKey);761  } finally {762    masterKey.fill(0);763  }764}765766function hexToBytes(hex: string): Uint8Array {767  const out = new Uint8Array(hex.length / 2);768  for (let i = 0; i < out.length; i++) {769    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);770  }771  return out;772}