cfa12cd81206

feat: wire adoption receiver and finalize build (v4.0.19)

time
2026-06-19 20:23 UTC · 4 months ago
machine
koh2
agent
gpt-5
session
vault adoption integration
parent
5845cedc6119
22 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/drafts/VAULT-ADOPTION/AdoptionConsentModal.test.tsx+0 −73src/drafts/VAULT-ADOPTION/AdoptionConsentModal.tsx+0 −166src/drafts/VAULT-ADOPTION/INTEGRATION-CONTRACT.md+0 −210src/drafts/VAULT-ADOPTION/README.md+0 −45src/drafts/VAULT-ADOPTION/chirp-adopt.test.ts+0 −190src/drafts/VAULT-ADOPTION/chirp-adopt.ts+0 −214src/drafts/VAULT-ADOPTION/chirp-pair-adopt-source.test.ts+0 −126src/drafts/VAULT-ADOPTION/chirp-pair-adopt-source.ts+0 −101src/drafts/VAULT-ADOPTION/messages-CREATE_VAULT_FROM_ROOT_KEY.test.ts+0 −69src/drafts/VAULT-ADOPTION/messages-CREATE_VAULT_FROM_ROOT_KEY.ts+0 −39src/drafts/VAULT-ADOPTION/onboarding-adopt-path.test.tsx+0 −107src/drafts/VAULT-ADOPTION/onboarding-adopt-path.tsx+0 −169src/drafts/VAULT-ADOPTION/vault-createVaultFromRootKey.test.ts+0 −116src/drafts/VAULT-ADOPTION/vault-createVaultFromRootKey.ts+0 −110src/popup/App.tsx+63 −3src/popup/chirp-pair.adopt-source.test.ts+123 −3src/popup/chirp-pair.ts+100 −1src/shared/chirp-adopt.ts+9 −7src/shared/chirp-handshake.ts+34 −17
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "4.0.18",5+  "version": "4.0.19",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.18",4+  "version": "4.0.19",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.0.18",3+  "version": "4.0.19",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/drafts/VAULT-ADOPTION/AdoptionConsentModal.test.tsx+0 −73
@@ -1,73 +0,0 @@1-// @vitest-environment jsdom2-// DRAFT DESTINATION: src/popup/components/AdoptionConsentModal.test.tsx3-// T-A16..T-A20: source consent UI cannot grant without re-auth.4-5-import { describe, expect, it, vi } from "vitest";6-import { AdoptionConsentModal } from "./AdoptionConsentModal";7-import { inputText, renderView } from "../views/view-test-utils";8-9-function button(container: HTMLElement, text: string): HTMLButtonElement {10-  const found = Array.from(container.querySelectorAll("button")).find((b) =>11-    (b.textContent ?? "").includes(text),12-  );13-  if (!found) throw new Error(`button not found: ${text}`);14-  return found as HTMLButtonElement;15-}16-17-function mount(overrides: Partial<Parameters<typeof AdoptionConsentModal>[0]> = {}) {18-  const props = {19-    receiverDeviceName: "June's iPhone",20-    sas: "895245",21-    verifyMasterPassword: vi.fn(async () => true),22-    onCancel: vi.fn(),23-    onApprove: vi.fn(async () => {}),24-    ...overrides,25-  };26-  const rendered = renderView(<AdoptionConsentModal {...props} />);27-  return { ...rendered, props };28-}29-30-describe("AdoptionConsentModal", () => {31-  it("T-A16: renders receiver device name and consequence copy", () => {32-    const { container } = mount();33-    expect(container.textContent).toContain('"June\'s iPhone" wants to join this vault.');34-    expect(container.textContent).toContain("copies your entire vault including all passwords and Pro");35-    expect(container.textContent).toContain("8 9 5 2 4 5");36-  });37-38-  it("T-A17: Share vault button is disabled until a password is entered", () => {39-    const { container } = mount();40-    const share = button(container, "Share vault")!;41-    expect(share.disabled).toBe(true);42-    inputText(container.querySelector("#adoption-consent-password") as HTMLInputElement, "pw");43-    expect(share.disabled).toBe(false);44-  });45-46-  it("T-A18: wrong password shows inline error and does not approve", async () => {47-    const { container, props } = mount({ verifyMasterPassword: vi.fn(async () => false) });48-    inputText(container.querySelector("#adoption-consent-password") as HTMLInputElement, "wrong");49-    button(container, "Share vault").click();50-    await Promise.resolve();51-52-    expect(container.textContent).toContain("Incorrect password");53-    expect(props.onApprove).not.toHaveBeenCalled();54-  });55-56-  it("T-A19: correct password calls onApprove", async () => {57-    const { container, props } = mount({ verifyMasterPassword: vi.fn(async () => true) });58-    inputText(container.querySelector("#adoption-consent-password") as HTMLInputElement, "correct");59-    button(container, "Share vault").click();60-    await Promise.resolve();61-62-    expect(props.verifyMasterPassword).toHaveBeenCalledWith("correct");63-    expect(props.onApprove).toHaveBeenCalledTimes(1);64-  });65-66-  it("T-A20: cancel closes without approval", () => {67-    const { container, props } = mount();68-    button(container, "Cancel").click();69-    expect(props.onCancel).toHaveBeenCalledTimes(1);70-    expect(props.onApprove).not.toHaveBeenCalled();71-  });72-});73-

view file at the parent

src/drafts/VAULT-ADOPTION/AdoptionConsentModal.tsx+0 −166
@@ -1,166 +0,0 @@1-// DRAFT DESTINATION: src/popup/components/AdoptionConsentModal.tsx2-// Phase 4: source-device consent modal for granting full vault adoption.3-4-import { Show, createSignal } from "solid-js";5-6-interface AdoptionConsentModalProps {7-  receiverDeviceName: string;8-  sas: string;9-  verifyMasterPassword(password: string): Promise<boolean>;10-  onCancel(): void;11-  onApprove(): Promise<void>;12-}13-14-export function AdoptionConsentModal(props: AdoptionConsentModalProps) {15-  const [password, setPassword] = createSignal("");16-  const [error, setError] = createSignal("");17-  const [busy, setBusy] = createSignal(false);18-19-  async function submit() {20-    if (!password() || busy()) return;21-    setBusy(true);22-    setError("");23-    const ok = await props.verifyMasterPassword(password());24-    if (!ok) {25-      setPassword("");26-      setError("Incorrect password");27-      setBusy(false);28-      queueMicrotask(() => document.getElementById("adoption-consent-password")?.focus());29-      return;30-    }31-    await props.onApprove();32-    setBusy(false);33-  }34-35-  return (36-    <div class="peach-adoption-consent-backdrop" role="presentation">37-      <section38-        class="peach-adoption-consent"39-        role="dialog"40-        aria-modal="true"41-        aria-labelledby="adoption-consent-title"42-      >43-        <div class="peach-adoption-consent-icon" aria-hidden="true">!</div>44-        <h2 id="adoption-consent-title">Share vault with new device?</h2>45-        <p class="peach-adoption-consent-device">46-          "{props.receiverDeviceName}" wants to join this vault.47-        </p>48-        <p class="peach-adoption-consent-warning">49-          This copies your entire vault including all passwords and Pro to that device.50-        </p>51-        <div class="peach-adoption-consent-sas">52-          <span>Confirm the code matches:</span>53-          <strong>{props.sas.replace(/(\d)(?=\d)/g, "$1 ")}</strong>54-        </div>55-        <label class="peach-label" for="adoption-consent-password">56-          Enter your master password to approve57-        </label>58-        <input59-          id="adoption-consent-password"60-          class="peach-underline-input"61-          type="password"62-          autocomplete="current-password"63-          value={password()}64-          disabled={busy()}65-          onInput={(event) => setPassword((event.target as HTMLInputElement).value)}66-          onKeyDown={(event) => {67-            if (event.key === "Enter") void submit();68-            if (event.key === "Escape") props.onCancel();69-          }}70-        />71-        <Show when={error()}>72-          <p class="peach-hint peach-hint-error">{error()}</p>73-        </Show>74-        <div class="peach-adoption-consent-actions">75-          <button76-            type="button"77-            class="peach-btn peach-btn--ghost peach-btn--md"78-            disabled={busy()}79-            onClick={props.onCancel}80-          >81-            Cancel82-          </button>83-          <button84-            type="button"85-            class="peach-btn peach-btn--accent peach-btn--md peach-adoption-consent-share"86-            disabled={!password() || busy()}87-            onClick={() => void submit()}88-          >89-            {busy() ? "Sharing..." : "Share vault ->"}90-          </button>91-        </div>92-      </section>93-    </div>94-  );95-}96-97-// DRAFT DESTINATION: src/popup/popup.css98-// Add amber/warning styling. Keep it visually distinct from standard confirm.99-export const ADOPTION_CONSENT_CSS = `100-.peach-adoption-consent-backdrop {101-  position: fixed;102-  inset: 0;103-  z-index: 1000;104-  display: grid;105-  place-items: center;106-  padding: 18px;107-  background: rgba(15, 10, 5, 0.72);108-}109-.peach-adoption-consent {110-  width: min(100%, 340px);111-  border: 1px solid color-mix(in srgb, #f59e0b 54%, var(--peach-border));112-  border-radius: 8px;113-  padding: 18px;114-  background: color-mix(in srgb, #2c1b08 84%, var(--peach-surface));115-  color: var(--peach-t0);116-  box-shadow: 0 18px 60px rgba(0, 0, 0, 0.42);117-}118-.peach-adoption-consent-icon {119-  width: 32px;120-  height: 32px;121-  display: grid;122-  place-items: center;123-  border-radius: 50%;124-  background: #f59e0b;125-  color: #1f1304;126-  font-weight: 800;127-}128-.peach-adoption-consent h2 {129-  margin: 12px 0 8px;130-  font-size: 1.22rem;131-  line-height: 1.15;132-}133-.peach-adoption-consent-device,134-.peach-adoption-consent-warning {135-  margin: 0 0 12px;136-  color: var(--peach-t1);137-  line-height: 1.35;138-}139-.peach-adoption-consent-warning {140-  color: #ffd79a;141-}142-.peach-adoption-consent-sas {143-  display: flex;144-  justify-content: space-between;145-  align-items: center;146-  gap: 12px;147-  margin: 14px 0;148-  padding: 10px;149-  border-radius: 6px;150-  background: rgba(245, 158, 11, 0.12);151-}152-.peach-adoption-consent-sas strong {153-  font-variant-numeric: tabular-nums;154-  letter-spacing: 0;155-}156-.peach-adoption-consent-actions {157-  display: flex;158-  justify-content: space-between;159-  gap: 10px;160-  margin-top: 16px;161-}162-.peach-adoption-consent-actions > button {163-  flex: 1;164-}165-`;166-

view file at the parent

src/drafts/VAULT-ADOPTION/INTEGRATION-CONTRACT.md+0 −210
@@ -1,210 +0,0 @@1-# VAULT-ADOPTION Integration Contract2-3-Source of truth for integrating `/Users/june/Downloads/peach-vault-adoption-brief.md`.4-Where this contract and an individual draft disagree, this contract wins.5-6-## Scope7-8-Draft only. No production source was modified in this pass. Integrate in9-browser first, then coordinate Android. `peach-sync` has no adoption work.10-11-Current browser drift from the brief:12-- Brief base says v4.0.3; current dev is v4.0.13.13-- `PAIR_IDENTITY` already supports optional `vault_id` and Pro `license`.14-- Sync pairing is already usable by non-Pro devices; at least one device needs15-  Pro and license transfer already rides normal pairing.16-17-## Files in `src/drafts/VAULT-ADOPTION/`18-19-| Draft file | Destination / role |20-|---|---|21-| `README.md` | Draft index; keep or trash after integration. |22-| `vault-createVaultFromRootKey.ts` | Code hunks for `src/background/vault.ts`. |23-| `vault-createVaultFromRootKey.test.ts` | Destination `src/background/vault-createVaultFromRootKey.test.ts`; T-A01..T-A05. |24-| `messages-CREATE_VAULT_FROM_ROOT_KEY.ts` | Code hunks for `src/shared/types.ts` and `src/background/messages.ts`. |25-| `messages-CREATE_VAULT_FROM_ROOT_KEY.test.ts` | Destination `src/background/messages.create-vault-from-root-key.test.ts`; T-A06..T-A09. |26-| `chirp-adopt.ts` | Destination `src/shared/chirp-adopt.ts`. |27-| `chirp-adopt.test.ts` | Destination `src/shared/chirp-adopt.test.ts`; T-A10..T-A15. |28-| `AdoptionConsentModal.tsx` | Destination `src/popup/components/AdoptionConsentModal.tsx` plus CSS hunk to `src/popup/popup.css`. |29-| `AdoptionConsentModal.test.tsx` | Destination `src/popup/components/AdoptionConsentModal.test.tsx`; T-A16..T-A20. |30-| `onboarding-adopt-path.tsx` | Destination `src/popup/views/AdoptVaultView.tsx` plus hunks for `Onboarding.tsx`, `Lock.tsx`, `App.tsx`. |31-| `onboarding-adopt-path.test.tsx` | Destination `src/popup/views/AdoptVaultView.test.tsx` plus one contracts test; T-A21..T-A25. |32-| `chirp-pair-adopt-source.ts` | Hunk for `src/popup/chirp-pair.ts`; requires a supporting responder-open-frame export from `src/shared/chirp-handshake.ts`. |33-| `chirp-pair-adopt-source.test.ts` | Destination `src/popup/chirp-pair.adopt-source.test.ts`; T-A26..T-A29. |34-| `INTEGRATION-CONTRACT.md` | This file. |35-36-## Integration Order37-38-Follow phases 1 -> 2 -> 3 -> 4 -> 5 -> 6. Save each phase separately.39-40-1. **Phase 1: vault root-key adoption**41-   - Add `wrapExistingRootKeyForRecovery` and `createVaultFromRootKey` to42-     `src/background/vault.ts`.43-   - Import `computeVaultId`.44-   - Run `npx tsc --noEmit`, relevant vault tests, then save.45-46-2. **Phase 2: message handler**47-   - Add `CREATE_VAULT_FROM_ROOT_KEY` to `MessageType`.48-   - Add handler beside `CREATE_VAULT`.49-   - Handler is vault-less allowed and returns `{ ok: true, vaultId }`.50-   - Run message tests and save.51-52-3. **Phase 3: adoption sub-protocol**53-   - Add `src/shared/chirp-adopt.ts`.54-   - Add tests T-A10..T-A15.55-   - Important correction from the brief: receiver computes and checks56-     `computeVaultId(root_key_hex)` before creating the vault. The brief's57-     original order would allow partial local creation before detecting a58-     mismatch.59-60-4. **Phase 4: source consent UI**61-   - Add `AdoptionConsentModal`.62-   - Add amber CSS to `popup.css`.63-   - Wire the modal through the source pairing UI event path.64-   - Re-auth uses `VERIFY_MAIN_PASSWORD` or the existing `vault.verifyMainPassword`65-     background path before allowing `grantAdoption`.66-67-5. **Phase 5: receiver onboarding**68-   - Add `"adopting"` to `App.tsx` view state.69-   - Add `AdoptVaultView`.70-   - Add final onboarding action: `I already have Peach ->`.71-   - Receiver password is collected before chirp. Use `CREATE_VAULT_FROM_ROOT_KEY`72-     only after SAS confirm and `ADOPT_GRANT` validation.73-74-6. **Phase 6: source routing**75-   - Refactor `chirp-handshake.ts` so responder can inspect the first encrypted76-     JSON frame as `PAIR_IDENTITY` or `ADOPT_REQUEST`.77-   - Normal pairing remains unchanged for `PAIR_IDENTITY`.78-   - `ADOPT_REQUEST` runs `grantAdoption` only after SAS and consent pass.79-80-7. **Version/build save**81-   - Bump browser version from current dev version by at least PATCH.82-   - Run `npm run build` after `tsc`/vitest.83-84-## New Message Types85-86-Add to `src/shared/types.ts`:87-88-```ts89-| "CREATE_VAULT_FROM_ROOT_KEY"90-```91-92-Payload:93-94-```ts95-{96-  password: string;97-  rootKeyHex: string;98-}99-```100-101-Response:102-103-```ts104-{ ok: true; vaultId: string } | { ok: false; error: string }105-```106-107-Suggested error strings:108-- `invalid_payload`109-- `ADOPT_INVALID_PASSWORD`110-- `ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars`111-- `ADOPT_VAULT_ID_MISMATCH`112-113-## New Frame Types114-115-Add in `src/shared/chirp-adopt.ts`:116-117-```ts118-ADOPT_REQUEST119-ADOPT_GRANT120-ADOPT_IDENTITY121-```122-123-Do not overload `PAIR_IDENTITY`. Adoption is a sub-protocol that runs on the124-same Noise/SAS channel after chirp, with its own two-phase persistence rules.125-126-## New View State127-128-Add to `App.tsx`:129-130-```ts131-| "adopting"132-```133-134-`"adopting"` is vault-less allowed. If adoption succeeds:135-- Refresh `HAS_VAULT`.136-- Mark onboarding complete.137-- Open the normal unlocked app shell.138-- Trigger an immediate sync attempt if a paired source was persisted.139-140-## Estimated Test Count141-142-29 specs:143-- T-A01..T-A05: vault creation from adopted root key.144-- T-A06..T-A09: message handler.145-- T-A10..T-A15: adoption sub-protocol.146-- T-A16..T-A20: source consent modal.147-- T-A21..T-A25: receiver onboarding path.148-- T-A26..T-A29: source routing.149-150-## Cross-Platform KAT Extensions151-152-Add a fixed adoption KAT shared with Android:153-- Fixed chirp ephemeral inputs.154-- Fixed source root key: 32 bytes.155-- Fixed source identity keys.156-- Expected SAS.157-- Expected `vault_id`.158-- Expected frame sequence:159-  `ADOPT_REQUEST -> ADOPT_GRANT -> ADOPT_IDENTITY`.160-161-The KAT should prove Android and browser serialize the same snake_case frame162-fields and derive the same vault id from the same root key bytes.163-164-## Android Work Required165-166-Do not implement in this browser draft, but the Android integration pass needs:167-- Pairing protocol layer: add `ADOPT_REQUEST`, `ADOPT_GRANT`, `ADOPT_IDENTITY`.168-- Vault layer: create local vault from imported root key with local password.169-- Source consent UI: warning modal with master-password re-auth.170-- Receiver onboarding: "I already have Peach" path, local password first.171-- License transfer: send/activate optional `license` in adoption frames, matching172-  browser's normal pairing transfer behavior.173-- Tests mirroring T-A01..T-A29 plus the cross-platform KAT.174-175-Likely files in peach-android (names may differ):176-- pairing/chirp protocol module177-- vault repository / crypto storage module178-- onboarding Compose screens179-- source consent Compose dialog180-- paired-device repository181-- license activation path182-183-## Security Checklist184-185-- [x] `root_key_hex` is validated as exactly 64 lowercase hex chars before use.186-- [x] Decoded root key bytes are zeroed after `createVaultFromRootKey` returns.187-- [x] Drafts never log `root_key_hex`.188-- [x] `ADOPT_GRANT` is sent only after the regular SAS gate.189-- [x] `ADOPT_GRANT` is sent only after source master-password re-auth succeeds.190-- [x] Source does not persist the receiver until `ADOPT_IDENTITY` is received.191-- [x] Receiver aborts before creating a vault if `vault_id` does not match the192-      granted root key.193-- [x] Receiver stays vault-less on invalid root key, unexpected frame, SAS194-      cancel, source consent reject, and vault-id mismatch.195-- [x] `ADOPT_REQUEST` from a peer without adoption support results in an196-      unexpected-frame/error path, not a grant.197-- [x] Consent modal copy says "copies your entire vault including all passwords198-      and Pro".199-200-## Known Integration Corrections201-202-- The brief suggests `rootKeyHex.split('').fill('0')`; that does not zero the203-  original immutable string. Integrate byte-buffer zeroing and keep string scope204-  narrow.205-- `createVaultFromRootKey` must set `rootKey = new Uint8Array(rootKeyBytes)`,206-  not `rootKey = rootKeyBytes`, because the input buffer is wiped in `finally`.207-- The current `runPairingResponder` cannot handle adoption as-is because it208-  decodes the first encrypted frame as `PAIR_IDENTITY`. Refactor around an209-  open-frame responder primitive, then rebuild normal pairing on top.210-

view file at the parent

src/drafts/VAULT-ADOPTION/README.md+0 −45
@@ -1,45 +0,0 @@1-# Vault Adoption Drafts2-3-Draft-only browser implementation plan for `/Users/june/Downloads/peach-vault-adoption-brief.md`.4-5-Nothing in this folder is active code. `src/drafts/**` is excluded from both6-`tsconfig.json` and `vitest.config.ts`, so these files are inert until an7-integration pass copies the destination-form code/tests into real source.8-9-Drafted against current dev state, not the brief's older base:10-- Browser is already v4.0.13, not v4.0.3.11-- `PAIR_IDENTITY` already carries optional `vault_id` and Pro `license`.12-- Sync is already ungated for non-Pro pairing attempts.13-- Settings already exposes phone/tablet and desktop pairing options directly.14-15-## Files16-17-| Draft file | Purpose |18-|---|---|19-| `vault-createVaultFromRootKey.ts` | Phase 1 destination-form vault code for adopting an existing root key. |20-| `vault-createVaultFromRootKey.test.ts` | T-A01 through T-A05. |21-| `messages-CREATE_VAULT_FROM_ROOT_KEY.ts` | Phase 2 message type and handler draft. |22-| `messages-CREATE_VAULT_FROM_ROOT_KEY.test.ts` | T-A06 through T-A09. |23-| `chirp-adopt.ts` | Phase 3 adoption sub-protocol module. |24-| `chirp-adopt.test.ts` | T-A10 through T-A15. |25-| `AdoptionConsentModal.tsx` | Phase 4 source-device consent modal. |26-| `AdoptionConsentModal.test.tsx` | T-A16 through T-A20. |27-| `onboarding-adopt-path.tsx` | Phase 5 receiver onboarding path and `AdoptVaultView` sketch. |28-| `onboarding-adopt-path.test.tsx` | T-A21 through T-A25. |29-| `chirp-pair-adopt-source.ts` | Phase 6 source-side `ADOPT_REQUEST` routing. |30-| `chirp-pair-adopt-source.test.ts` | T-A26 through T-A29. |31-| `INTEGRATION-CONTRACT.md` | Source of truth for the later integration pass. |32-33-## Security Notes34-35-- `root_key_hex` is a crown-jewel secret. It is validated before use, copied36-  only into wrapped vault storage and the in-memory unlocked-session key, and37-  zeroed as bytes after creation.38-- `ADOPT_GRANT` is sent only after the regular SAS gate and the source-side39-  master-password re-auth gate both pass.40-- The receiver creates a local password before chirp/SAS, so the source root41-  key is not held while prompting for a password.42-- Source persistence is two-phase: do not save the receiver as paired until43-  `ADOPT_IDENTITY` arrives after the receiver has successfully created the44-  adopted vault.45-

view file at the parent

src/drafts/VAULT-ADOPTION/chirp-adopt.test.ts+0 −190
@@ -1,190 +0,0 @@1-// DRAFT DESTINATION: src/shared/chirp-adopt.test.ts2-// T-A10..T-A15: adoption frame KAT and failure behavior.3-4-import { describe, expect, it, vi } from "vitest";5-import { computeVaultId } from "./sync-identity";6-import {7-  grantAdoption,8-  requestAdoption,9-  sendFrame,10-  type AdoptRequest,11-  type AdoptionReceiverDeps,12-  type AdoptionSourceDeps,13-} from "./chirp-adopt";14-import type { PairingTransport } from "./chirp-handshake";15-16-function memoryPair(): [PairingTransport, PairingTransport] {17-  const a: Uint8Array[] = [];18-  const b: Uint8Array[] = [];19-  const ar: Array<(v: Uint8Array) => void> = [];20-  const br: Array<(v: Uint8Array) => void> = [];21-  function make(inbox: Uint8Array[], outbox: Uint8Array[], readers: Array<(v: Uint8Array) => void>, outReaders: Array<(v: Uint8Array) => void>): PairingTransport {22-    return {23-      async send(msg) {24-        const reader = outReaders.shift();25-        if (reader) reader(msg);26-        else outbox.push(msg);27-      },28-      receive() {29-        const msg = inbox.shift();30-        if (msg) return Promise.resolve(msg);31-        return new Promise((resolve) => readers.push(resolve));32-      },33-    };34-  }35-  return [make(a, b, ar, br), make(b, a, br, ar)];36-}37-38-function identity(name: string) {39-  return {40-    deviceId: `${name}-id`,41-    deviceName: name,42-    deviceType: "extension" as const,43-    longTermPublicKeyHex: "ab".repeat(32),44-    signingPublicKeyHex: "cd".repeat(32),45-  };46-}47-48-describe("vault adoption sub-protocol", () => {49-  it("T-A10: completes ADOPT_REQUEST/ADOPT_GRANT/ADOPT_IDENTITY KAT", async () => {50-    const [receiverChannel, sourceChannel] = memoryPair();51-    const rootKeyHex = "11".repeat(32);52-    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));53-    const sourceUpsert = vi.fn();54-    const receiverUpsert = vi.fn();55-    const receiverDeps: AdoptionReceiverDeps = {56-      deviceName: "Receiver",57-      generateDeviceId: () => "receiver-ephemeral",58-      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),59-      deriveLocalIdentity: vi.fn(async () => identity("receiver")),60-      upsertPairedDevice: receiverUpsert,61-      applyLicense: vi.fn(),62-      onError: vi.fn(),63-    };64-    const sourceDeps: AdoptionSourceDeps = {65-      sourceIdentity: identity("source"),66-      rootKeyHex: vi.fn(async () => rootKeyHex),67-      vaultId: vi.fn(async () => vaultId),68-      license: vi.fn(async () => "PPK-test"),69-      onConsent: vi.fn(async () => true),70-      upsertPairedDevice: sourceUpsert,71-    };72-73-    await Promise.all([74-      requestAdoption(receiverChannel, receiverDeps),75-      (async () => {76-        const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;77-        await grantAdoption(sourceChannel, request, sourceDeps);78-      })(),79-    ]);80-81-    expect(receiverDeps.createVaultFromRootKey).toHaveBeenCalledWith(rootKeyHex);82-    expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "source-id" }));83-    expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-id" }));84-    expect(receiverDeps.applyLicense).toHaveBeenCalledWith("PPK-test");85-  });86-87-  it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {88-    const onConsent = vi.fn();89-    const channel = memoryPair()[0];90-    await expect(Promise.reject(new Error("sas_rejected"))).rejects.toThrow("sas_rejected");91-    expect(onConsent).not.toHaveBeenCalled();92-    void channel;93-  });94-95-  it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {96-    const [receiverChannel, sourceChannel] = memoryPair();97-    const createVaultFromRootKey = vi.fn();98-    const errors: string[] = [];99-    const receiver = requestAdoption(receiverChannel, {100-      deviceName: "Receiver",101-      generateDeviceId: () => "receiver-ephemeral",102-      createVaultFromRootKey,103-      deriveLocalIdentity: vi.fn(),104-      upsertPairedDevice: vi.fn(),105-      onError: (err) => errors.push(err),106-    });107-    const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;108-    await grantAdoption(sourceChannel, request, {109-      sourceIdentity: identity("source"),110-      rootKeyHex: vi.fn(),111-      vaultId: vi.fn(),112-      onConsent: vi.fn(async () => false),113-      upsertPairedDevice: vi.fn(),114-    });115-    await Promise.race([receiver, Promise.resolve()]);116-117-    expect(createVaultFromRootKey).not.toHaveBeenCalled();118-  });119-120-  it("T-A13: invalid root_key_hex aborts gracefully", async () => {121-    const [receiverChannel, sourceChannel] = memoryPair();122-    const onError = vi.fn();123-    const createVaultFromRootKey = vi.fn();124-    const receiver = requestAdoption(receiverChannel, {125-      deviceName: "Receiver",126-      generateDeviceId: () => "receiver-ephemeral",127-      createVaultFromRootKey,128-      deriveLocalIdentity: vi.fn(),129-      upsertPairedDevice: vi.fn(),130-      onError,131-    });132-    await sourceChannel.receive();133-    await sendFrame(sourceChannel, {134-      type: "ADOPT_GRANT",135-      root_key_hex: "bad",136-      vault_id: "00".repeat(32),137-      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },138-    } as never);139-    await receiver;140-141-    expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME");142-    expect(createVaultFromRootKey).not.toHaveBeenCalled();143-  });144-145-  it("T-A14: vault_id mismatch aborts before vault creation", async () => {146-    const [receiverChannel, sourceChannel] = memoryPair();147-    const onError = vi.fn();148-    const createVaultFromRootKey = vi.fn();149-    const receiver = requestAdoption(receiverChannel, {150-      deviceName: "Receiver",151-      generateDeviceId: () => "receiver-ephemeral",152-      createVaultFromRootKey,153-      deriveLocalIdentity: vi.fn(),154-      upsertPairedDevice: vi.fn(),155-      onError,156-    });157-    await sourceChannel.receive();158-    await sendFrame(sourceChannel, {159-      type: "ADOPT_GRANT",160-      root_key_hex: "22".repeat(32),161-      vault_id: "00".repeat(32),162-      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },163-    });164-    await receiver;165-166-    expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH");167-    expect(createVaultFromRootKey).not.toHaveBeenCalled();168-  });169-170-  it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {171-    const channel = memoryPair()[0];172-    const upsert = vi.fn();173-    const promise = grantAdoption(channel, {174-      type: "ADOPT_REQUEST",175-      device_id: "receiver-ephemeral",176-      device_name: "Receiver",177-      device_type: "extension",178-    }, {179-      sourceIdentity: identity("source"),180-      rootKeyHex: vi.fn(async () => "33".repeat(32)),181-      vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),182-      onConsent: vi.fn(async () => true),183-      upsertPairedDevice: upsert,184-    });185-186-    await Promise.race([promise, Promise.resolve()]);187-    expect(upsert).not.toHaveBeenCalled();188-  });189-});190-

view file at the parent

src/drafts/VAULT-ADOPTION/chirp-adopt.ts+0 −214
@@ -1,214 +0,0 @@1-// DRAFT DESTINATION: src/shared/chirp-adopt.ts2-// Phase 3: adoption sub-protocol frames and source/receiver helpers.3-4-import { computeVaultId } from "./sync-identity";5-import type { DeviceType, PairedDevice } from "./sync-types";6-import type { LocalIdentity, PairingTransport } from "./chirp-handshake";7-8-export interface AdoptRequest {9-  type: "ADOPT_REQUEST";10-  device_id: string;11-  device_name: string;12-  device_type: "extension" | "android";13-}14-15-export interface AdoptGrant {16-  type: "ADOPT_GRANT";17-  root_key_hex: string;18-  vault_id: string;19-  license?: string;20-  source: {21-    device_id: string;22-    device_name: string;23-    device_type: DeviceType;24-    long_term_public_key_hex: string;25-    signing_public_key_hex?: string;26-    ws_port?: number;27-  };28-}29-30-export interface AdoptIdentity {31-  type: "ADOPT_IDENTITY";32-  device_id: string;33-  device_name: string;34-  device_type: DeviceType;35-  long_term_public_key_hex: string;36-  signing_public_key_hex?: string;37-}38-39-export type AdoptionFrame = AdoptRequest | AdoptGrant | AdoptIdentity;40-41-export interface AdoptionReceiverDeps {42-  deviceName: string;43-  generateDeviceId(): string;44-  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;45-  deriveLocalIdentity(): Promise<LocalIdentity>;46-  upsertPairedDevice(device: PairedDevice): Promise<void>;47-  applyLicense?(license: string): Promise<void>;48-  onError(error: string): void;49-}50-51-export interface AdoptionSourceDeps {52-  sourceIdentity: LocalIdentity;53-  rootKeyHex(): Promise<string>;54-  vaultId(): Promise<string>;55-  license?(): Promise<string | undefined>;56-  onConsent(request: AdoptRequest): Promise<boolean>;57-  upsertPairedDevice(device: PairedDevice): Promise<void>;58-}59-60-export function isAdoptRequest(frame: unknown): frame is AdoptRequest {61-  const value = frame as Partial<AdoptRequest> | null;62-  return value?.type === "ADOPT_REQUEST" &&63-    typeof value.device_id === "string" &&64-    value.device_id.length > 0 &&65-    typeof value.device_name === "string" &&66-    (value.device_type === "extension" || value.device_type === "android");67-}68-69-export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {70-  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);71-}72-73-export async function requestAdoption(74-  channel: PairingTransport,75-  deps: AdoptionReceiverDeps,76-): Promise<void> {77-  await sendFrame(channel, {78-    type: "ADOPT_REQUEST",79-    device_id: deps.generateDeviceId(),80-    device_name: deps.deviceName,81-    device_type: "extension",82-  });83-84-  const frame = await receiveFrame(channel);85-  if (!isAdoptGrant(frame)) {86-    deps.onError("ADOPT_UNEXPECTED_FRAME");87-    return;88-  }89-90-  if (!validateRootKeyHex(frame.root_key_hex)) {91-    deps.onError("ADOPT_INVALID_ROOT_KEY");92-    return;93-  }94-95-  const rootKeyBytes = hexToBytes(frame.root_key_hex);96-  try {97-    const computedVaultId = await computeVaultId(rootKeyBytes);98-    if (computedVaultId !== frame.vault_id) {99-      deps.onError("ADOPT_VAULT_ID_MISMATCH");100-      return;101-    }102-  } finally {103-    rootKeyBytes.fill(0);104-  }105-106-  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);107-  if (vaultId !== frame.vault_id) {108-    deps.onError("ADOPT_VAULT_ID_MISMATCH");109-    return;110-  }111-112-  const identity = await deps.deriveLocalIdentity();113-  await sendFrame(channel, {114-    type: "ADOPT_IDENTITY",115-    device_id: identity.deviceId,116-    device_name: deps.deviceName,117-    device_type: identity.deviceType,118-    long_term_public_key_hex: identity.longTermPublicKeyHex,119-    signing_public_key_hex: identity.signingPublicKeyHex,120-  });121-122-  await deps.upsertPairedDevice({123-    deviceId: frame.source.device_id,124-    deviceName: frame.source.device_name,125-    deviceType: frame.source.device_type,126-    longTermPublicKeyHex: frame.source.long_term_public_key_hex,127-    signingPublicKeyHex: frame.source.signing_public_key_hex,128-    trustSource: "direct",129-    wsPort: frame.source.ws_port,130-    pairedAt: Date.now(),131-  });132-133-  if (frame.license) await deps.applyLicense?.(frame.license);134-}135-136-export async function grantAdoption(137-  channel: PairingTransport,138-  request: AdoptRequest,139-  deps: AdoptionSourceDeps,140-): Promise<void> {141-  const approved = await deps.onConsent(request);142-  if (!approved) return;143-144-  const rootKeyHex = await deps.rootKeyHex();145-  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");146-147-  try {148-    await sendFrame(channel, {149-      type: "ADOPT_GRANT",150-      root_key_hex: rootKeyHex,151-      vault_id: await deps.vaultId(),152-      license: await deps.license?.(),153-      source: {154-        device_id: deps.sourceIdentity.deviceId,155-        device_name: deps.sourceIdentity.deviceName,156-        device_type: deps.sourceIdentity.deviceType,157-        long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,158-        signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,159-        ws_port: deps.sourceIdentity.wsPort,160-      },161-    });162-  } finally {163-    // Strings are immutable; callers should prefer byte buffers for real164-    // zeroing. This hook at least centralizes where source cleanup belongs.165-  }166-167-  const identity = await receiveFrame(channel);168-  if (!isAdoptIdentity(identity)) return;169-170-  await deps.upsertPairedDevice({171-    deviceId: identity.device_id,172-    deviceName: identity.device_name,173-    deviceType: identity.device_type,174-    longTermPublicKeyHex: identity.long_term_public_key_hex,175-    signingPublicKeyHex: identity.signing_public_key_hex,176-    trustSource: "direct",177-    pairedAt: Date.now(),178-  });179-}180-181-export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {182-  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));183-}184-185-export async function receiveFrame(channel: PairingTransport): Promise<unknown> {186-  return JSON.parse(new TextDecoder().decode(await channel.receive()));187-}188-189-function isAdoptGrant(frame: unknown): frame is AdoptGrant {190-  const value = frame as Partial<AdoptGrant> | null;191-  return value?.type === "ADOPT_GRANT" &&192-    validateRootKeyHex(value.root_key_hex) &&193-    typeof value.vault_id === "string" &&194-    /^[0-9a-f]{64}$/.test(value.vault_id) &&195-    typeof value.source === "object" &&196-    value.source !== null;197-}198-199-function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {200-  const value = frame as Partial<AdoptIdentity> | null;201-  return value?.type === "ADOPT_IDENTITY" &&202-    typeof value.device_id === "string" &&203-    typeof value.device_name === "string" &&204-    typeof value.device_type === "string" &&205-    typeof value.long_term_public_key_hex === "string" &&206-    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);207-}208-209-function hexToBytes(hex: string): Uint8Array {210-  const out = new Uint8Array(hex.length / 2);211-  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);212-  return out;213-}214-

view file at the parent

src/drafts/VAULT-ADOPTION/chirp-pair-adopt-source.test.ts+0 −126
@@ -1,126 +0,0 @@1-// DRAFT DESTINATION: src/popup/chirp-pair.adopt-source.test.ts2-// T-A26..T-A29: source routing after the SAS gate.3-4-import { describe, expect, it, vi } from "vitest";5-import { computeVaultId } from "../shared/sync-identity";6-import { routeSourcePostSASFrame } from "./chirp-pair";7-import type { LocalIdentity, PairingTransport } from "../shared/chirp-handshake";8-9-const localIdentity: LocalIdentity = {10-  deviceId: "source-device",11-  deviceName: "Source",12-  deviceType: "extension",13-  longTermPublicKeyHex: "ab".repeat(32),14-  signingPublicKeyHex: "cd".repeat(32),15-};16-17-function channel(): PairingTransport & { sent: Uint8Array[]; close: ReturnType<typeof vi.fn> } {18-  const sent: Uint8Array[] = [];19-  return {20-    sent,21-    close: vi.fn(async () => {}),22-    async send(msg) { sent.push(msg); },23-    async receive() {24-      return new TextEncoder().encode(JSON.stringify({25-        type: "ADOPT_IDENTITY",26-        device_id: "receiver-device",27-        device_name: "Receiver",28-        device_type: "extension",29-        long_term_public_key_hex: "ef".repeat(32),30-      }));31-    },32-  };33-}34-35-describe("source-side ADOPT_REQUEST routing", () => {36-  it("T-A26: routes ADOPT_REQUEST to grantAdoption after SAS confirmation", async () => {37-    const rootKeyHex = "77".repeat(32);38-    const ch = channel();39-    const upsertPairedDevice = vi.fn();40-    const result = await routeSourcePostSASFrame(41-      {42-        sas: "895245",43-        frame: {44-          type: "ADOPT_REQUEST",45-          device_id: "receiver-ephemeral",46-          device_name: "Receiver",47-          device_type: "extension",48-        },49-        channel: ch,50-      },51-      localIdentity,52-      {53-        confirmSAS: vi.fn(async () => true),54-        completePairing: vi.fn(),55-        grantConsent: vi.fn(async () => true),56-        fetchRootKeyHex: vi.fn(async () => rootKeyHex),57-        fetchLicense: vi.fn(async () => "PPK-test"),58-        deriveVaultIdentity: vi.fn(async () => localIdentity),59-        upsertPairedDevice,60-      },61-    );62-63-    const grant = JSON.parse(new TextDecoder().decode(ch.sent[0]));64-    expect(result).toBeNull();65-    expect(grant).toMatchObject({66-      type: "ADOPT_GRANT",67-      root_key_hex: rootKeyHex,68-      vault_id: await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))),69-      license: "PPK-test",70-    });71-    expect(upsertPairedDevice).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-device" }));72-  });73-74-  it("T-A27: routes PAIR_IDENTITY to the existing pairing path", async () => {75-    // Integration replaces handlePairIdentityFrame with the current PAIR_IDENTITY76-    // decoder/send-local-identity path. This test pins the branch selection.77-    const frame = { type: "PAIR_IDENTITY", device_id: "phone" };78-    expect((frame as { type: string }).type).toBe("PAIR_IDENTITY");79-  });80-81-  it("T-A28: unknown frame closes the channel gracefully", async () => {82-    const ch = channel();83-    await expect(routeSourcePostSASFrame(84-      { sas: "895245", frame: { type: "WHAT_IS_THIS" }, channel: ch },85-      localIdentity,86-      {87-        confirmSAS: vi.fn(async () => true),88-        completePairing: vi.fn(),89-        grantConsent: vi.fn(),90-        fetchRootKeyHex: vi.fn(),91-        deriveVaultIdentity: vi.fn(async () => localIdentity),92-        upsertPairedDevice: vi.fn(),93-      },94-    )).rejects.toThrow("unexpected_pairing_frame");95-96-    expect(ch.close).toHaveBeenCalledTimes(1);97-  });98-99-  it("T-A29: source root key bytes are zeroed after grantAdoption regardless of outcome", async () => {100-    const fillSpy = vi.spyOn(Uint8Array.prototype, "fill");101-    await routeSourcePostSASFrame(102-      {103-        sas: "895245",104-        frame: {105-          type: "ADOPT_REQUEST",106-          device_id: "receiver-ephemeral",107-          device_name: "Receiver",108-          device_type: "extension",109-        },110-        channel: channel(),111-      },112-      localIdentity,113-      {114-        confirmSAS: vi.fn(async () => true),115-        completePairing: vi.fn(),116-        grantConsent: vi.fn(async () => true),117-        fetchRootKeyHex: vi.fn(async () => "88".repeat(32)),118-        deriveVaultIdentity: vi.fn(async () => localIdentity),119-        upsertPairedDevice: vi.fn(),120-      },121-    );122-123-    expect(fillSpy).toHaveBeenCalledWith(0);124-  });125-});126-

view file at the parent

src/drafts/VAULT-ADOPTION/chirp-pair-adopt-source.ts+0 −101
@@ -1,101 +0,0 @@1-// DRAFT DESTINATION: src/popup/chirp-pair.ts plus a supporting export from2-// src/shared/chirp-handshake.ts.3-// Phase 6: source-side ADOPT_REQUEST routing.4-5-import { computeVaultId } from "../shared/sync-identity";6-import { grantAdoption, isAdoptRequest, type AdoptRequest } from "../shared/chirp-adopt";7-import type { LocalIdentity, PairingHandshakeResult, PairingTransport } from "../shared/chirp-handshake";8-import type { PairedDevice } from "../shared/sync-types";9-10-interface SourceAdoptionDeps {11-  confirmSAS(sas: string): Promise<boolean>;12-  completePairing(device: PairedDevice): Promise<void>;13-  grantConsent(request: AdoptRequest, sas: string): Promise<boolean>;14-  fetchRootKeyHex(): Promise<string>;15-  fetchLicense?(): Promise<string | undefined>;16-  deriveVaultIdentity(): Promise<LocalIdentity>;17-  upsertPairedDevice(device: PairedDevice): Promise<void>;18-}19-20-interface PostNoiseFrame {21-  sas: string;22-  frame: unknown;23-  channel: PairingTransport;24-}25-26-// DRAFT DESTINATION: src/shared/chirp-handshake.ts27-// Export a responder primitive that completes Noise NK, receives exactly one28-// encrypted JSON frame, and leaves the encrypted channel open for the caller.29-// Existing runPairingResponder can be rebuilt on top of this by requiring30-// frame.type === "PAIR_IDENTITY" and sending the local PAIR_IDENTITY response.31-declare function runPairingResponderOpenFrame(32-  ourEphemeralPrivateKey: Uint8Array,33-  ourEphemeralPublicKey: Uint8Array,34-  ourIdentity: LocalIdentity,35-  transport: PairingTransport,36-): Promise<PostNoiseFrame>;37-38-declare function handlePairIdentityFrame(39-  frame: unknown,40-  channel: PairingTransport,41-  localIdentity: LocalIdentity,42-): Promise<PairingHandshakeResult>;43-44-export async function routeSourcePostSASFrame(45-  postNoise: PostNoiseFrame,46-  localIdentity: LocalIdentity,47-  deps: SourceAdoptionDeps,48-): Promise<PairedDevice | null> {49-  const sasConfirmed = await deps.confirmSAS(postNoise.sas);50-  if (!sasConfirmed) {51-    await closeIfPossible(postNoise.channel);52-    throw new Error("sas_rejected");53-  }54-55-  if (isAdoptRequest(postNoise.frame)) {56-    const rootKeyHex = await deps.fetchRootKeyHex();57-    if (!/^[0-9a-f]{64}$/.test(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");58-    const rootKeyBytes = hexToBytes(rootKeyHex);59-    try {60-      const vaultId = await computeVaultId(rootKeyBytes);61-      await grantAdoption(postNoise.channel, postNoise.frame, {62-        sourceIdentity: localIdentity,63-        rootKeyHex: async () => rootKeyHex,64-        vaultId: async () => vaultId,65-        license: deps.fetchLicense,66-        onConsent: (request) => deps.grantConsent(request, postNoise.sas),67-        upsertPairedDevice: deps.upsertPairedDevice,68-      });69-      return null;70-    } finally {71-      rootKeyBytes.fill(0);72-      // rootKeyHex is an immutable JS string; do not log it and keep the73-      // variable scoped to this block so it becomes collectible immediately.74-    }75-  }76-77-  if (isPairIdentityFrame(postNoise.frame)) {78-    const result = await handlePairIdentityFrame(postNoise.frame, postNoise.channel, localIdentity);79-    await deps.completePairing(result.device);80-    return result.device;81-  }82-83-  await closeIfPossible(postNoise.channel);84-  throw new Error("unexpected_pairing_frame");85-}86-87-function isPairIdentityFrame(frame: unknown): boolean {88-  return (frame as { type?: unknown } | null)?.type === "PAIR_IDENTITY";89-}90-91-async function closeIfPossible(channel: PairingTransport): Promise<void> {92-  const maybe = channel as PairingTransport & { close?: () => Promise<void> };93-  await maybe.close?.();94-}95-96-function hexToBytes(hex: string): Uint8Array {97-  const out = new Uint8Array(hex.length / 2);98-  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);99-  return out;100-}101-

view file at the parent

src/drafts/VAULT-ADOPTION/messages-CREATE_VAULT_FROM_ROOT_KEY.test.ts+0 −69
@@ -1,69 +0,0 @@1-// DRAFT DESTINATION: src/background/messages.create-vault-from-root-key.test.ts2-// T-A06..T-A09: message handler contract.3-4-import { beforeEach, describe, expect, it, vi } from "vitest";5-import { computeVaultId } from "../shared/sync-identity";6-7-async function dispatch(message: { type: string; payload?: unknown }) {8-  vi.resetModules();9-  const messages = await import("./messages");10-  return messages.handleMessage(message, { url: chrome.runtime.getURL("popup.html") } as chrome.runtime.MessageSender);11-}12-13-describe("CREATE_VAULT_FROM_ROOT_KEY message", () => {14-  beforeEach(() => {15-    vi.stubGlobal("chrome", {16-      runtime: { id: "ext", getURL: (path = "") => `chrome-extension://ext/${path}` },17-      storage: {18-        local: { get: vi.fn(async () => ({})), set: vi.fn(), remove: vi.fn() },19-        session: { get: vi.fn(async () => ({})), set: vi.fn(), remove: vi.fn() },20-      },21-      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },22-      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },23-    });24-  });25-26-  it("T-A06: succeeds with a valid receiver password and root key", async () => {27-    const res = await dispatch({28-      type: "CREATE_VAULT_FROM_ROOT_KEY",29-      payload: { password: "receiver-local-passphrase-456", rootKeyHex: "44".repeat(32) },30-    }) as { ok: boolean; vaultId?: string };31-32-    expect(res.ok).toBe(true);33-    expect(res.vaultId).toMatch(/^[0-9a-f]{64}$/);34-  });35-36-  it("T-A07: fails gracefully for invalid rootKeyHex", async () => {37-    const res = await dispatch({38-      type: "CREATE_VAULT_FROM_ROOT_KEY",39-      payload: { password: "receiver-local-passphrase-456", rootKeyHex: "nope" },40-    }) as { ok: boolean; error?: string };41-42-    expect(res.ok).toBe(false);43-    expect(res.error).toContain("ADOPT_INVALID_ROOT_KEY");44-  });45-46-  it("T-A08: is vault-less allowed", async () => {47-    const hasBefore = await dispatch({ type: "HAS_VAULT" }) as { ok: boolean; data?: boolean };48-    expect(hasBefore.data).toBe(false);49-50-    const create = await dispatch({51-      type: "CREATE_VAULT_FROM_ROOT_KEY",52-      payload: { password: "receiver-local-passphrase-456", rootKeyHex: "55".repeat(32) },53-    }) as { ok: boolean };54-55-    expect(create.ok).toBe(true);56-  });57-58-  it("T-A09: returns the vault id computed from the adopted root key", async () => {59-    const rootKeyHex = "66".repeat(32);60-    const expected = await computeVaultId(new Uint8Array(Buffer.from(rootKeyHex, "hex")));61-    const res = await dispatch({62-      type: "CREATE_VAULT_FROM_ROOT_KEY",63-      payload: { password: "receiver-local-passphrase-456", rootKeyHex },64-    }) as { ok: boolean; vaultId?: string };65-66-    expect(res).toEqual({ ok: true, vaultId: expected });67-  });68-});69-

view file at the parent

src/drafts/VAULT-ADOPTION/messages-CREATE_VAULT_FROM_ROOT_KEY.ts+0 −39
@@ -1,39 +0,0 @@1-// DRAFT DESTINATION: src/background/messages.ts and src/shared/types.ts2-// Phase 2: vault-less CREATE_VAULT_FROM_ROOT_KEY handler.3-4-// In src/shared/types.ts, add near CREATE_VAULT:5-//   | "CREATE_VAULT_FROM_ROOT_KEY"6-//7-// Optional documented payload type:8-export interface CreateVaultFromRootKeyPayload {9-  password: string;10-  rootKeyHex: string;11-}12-13-// In src/shared/types.ts ErrorCode, add:14-//   | "adopt_invalid_root_key"15-//   | "adopt_vault_id_mismatch"16-17-// In src/background/messages.ts switch, place immediately after CREATE_VAULT:18-async function draftCreateVaultFromRootKeyHandler(19-  msg: { payload?: unknown },20-): Promise<{ ok: true; vaultId: string } | { ok: false; error: string }> {21-  const { password, rootKeyHex } = (msg.payload ?? {}) as Partial<CreateVaultFromRootKeyPayload>;22-  if (typeof password !== "string" || password.length === 0 || typeof rootKeyHex !== "string") {23-    return { ok: false, error: "invalid_payload" };24-  }25-26-  try {27-    const { vaultId } = await vault.createVaultFromRootKey(password, rootKeyHex);28-    await setupAutoLock();29-    broadcastToContentScripts({ type: "VAULT_UNLOCKED" });30-    onVaultUnlocked();31-    return { ok: true, vaultId };32-  } catch (err) {33-    const message = err instanceof Error ? err.message : String(err);34-    return { ok: false, error: message };35-  }36-}37-38-void draftCreateVaultFromRootKeyHandler;39-

view file at the parent

src/drafts/VAULT-ADOPTION/onboarding-adopt-path.test.tsx+0 −107
@@ -1,107 +0,0 @@1-// @vitest-environment jsdom2-// DRAFT DESTINATION: src/popup/views/AdoptVaultView.test.tsx plus onboarding3-// contract additions in src/popup/views/contracts.test.tsx.4-// T-A21..T-A25.5-6-import { describe, expect, it, vi } from "vitest";7-import { AdoptVaultView } from "./AdoptVaultView";8-import { inputText, renderView } from "./view-test-utils";9-10-function button(container: HTMLElement, text: string): HTMLButtonElement {11-  const found = Array.from(container.querySelectorAll("button")).find((b) =>12-    (b.textContent ?? "").includes(text),13-  );14-  if (!found) throw new Error(`button not found: ${text}`);15-  return found as HTMLButtonElement;16-}17-18-describe("vault adoption onboarding", () => {19-  it("T-A21: Onboarding offers Create vault, Restore from backup, and I already have Peach", () => {20-    // Integrate into contracts.test.tsx where Onboarding + LockView are mounted together.21-    document.body.innerHTML = `22-      <button>Create vault</button>23-      <button>Restore from backup</button>24-      <button>I already have Peach -></button>25-    `;26-    expect(document.body.textContent).toContain("Create vault");27-    expect(document.body.textContent).toContain("Restore from backup");28-    expect(document.body.textContent).toContain("I already have Peach");29-  });30-31-  it("T-A22: password screen uses the same minimum-length gate as create-vault", () => {32-    const view = renderView(<AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={vi.fn()} />);33-    const continueButton = button(view.container, "Continue");34-    expect(continueButton.disabled).toBe(true);35-    inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "short");36-    inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "short");37-    expect(view.container.textContent).toContain("need 7 more characters");38-    expect(continueButton.disabled).toBe(true);39-  });40-41-  it("T-A23: adoption flow reaches the SAS screen", async () => {42-    const startAdoption = vi.fn(async ({ confirmSAS }: { confirmSAS(sas: string): Promise<boolean> }) => {43-      await confirmSAS("895245");44-    });45-    const view = renderView(<AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={startAdoption} />);46-    inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "receiver-passphrase-123");47-    inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "receiver-passphrase-123");48-    button(view.container, "Continue").click();49-    button(view.container, "Pair a device").click();50-    await Promise.resolve();51-52-    expect(view.container.textContent).toContain("Confirm this code matches");53-    expect(view.container.textContent).toContain("8 9 5 2 4 5");54-  });55-56-  it("T-A24: success leaves a vault and opens Peach", async () => {57-    const onDone = vi.fn();58-    const view = renderView(59-      <AdoptVaultView60-        onCancel={() => {}}61-        onDone={onDone}62-        startAdoption={vi.fn(async () => {})}63-      />,64-    );65-    inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "receiver-passphrase-123");66-    inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "receiver-passphrase-123");67-    button(view.container, "Continue").click();68-    button(view.container, "Pair a device").click();69-    await Promise.resolve();70-71-    expect(view.container.textContent).toContain("Joined your vault.");72-    button(view.container, "Open Peach").click();73-    expect(onDone).toHaveBeenCalledTimes(1);74-  });75-76-  it("T-A25: SAS cancel keeps receiver vault-less and returns to the password step", async () => {77-    let resolveSas: ((ok: boolean) => void) | null = null;78-    const createVault = vi.fn();79-    const view = renderView(80-      <AdoptVaultView81-        onCancel={() => {}}82-        onDone={() => {}}83-        startAdoption={vi.fn(async ({ confirmSAS }) => {84-          await new Promise<void>((resolve) => {85-            void confirmSAS("895245").then((ok) => {86-              if (ok) createVault();87-              resolve();88-            });89-            resolveSas = (ok) => { void ok; resolve(); };90-          });91-        })}92-      />,93-    );94-    inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "receiver-passphrase-123");95-    inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "receiver-passphrase-123");96-    button(view.container, "Continue").click();97-    button(view.container, "Pair a device").click();98-    await Promise.resolve();99-    button(view.container, "Cancel").click();100-    resolveSas?.(false);101-    await Promise.resolve();102-103-    expect(createVault).not.toHaveBeenCalled();104-    expect(view.container.textContent).toContain("Set a password for this device.");105-  });106-});107-

view file at the parent

src/drafts/VAULT-ADOPTION/onboarding-adopt-path.tsx+0 −169
@@ -1,169 +0,0 @@1-// DRAFT DESTINATION: src/popup/views/AdoptVaultView.tsx plus small hunks in2-// src/popup/views/Onboarding.tsx, src/popup/views/Lock.tsx, and src/popup/App.tsx.3-// Phase 5: "I already have Peach" receiver path.4-5-import { Match, Show, Switch, createMemo, createSignal } from "solid-js";6-import { sendMessage } from "../App";7-import { RuneBackground } from "../components/RuneBackground";8-9-type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";10-11-interface AdoptVaultViewProps {12-  onCancel(): void;13-  onDone(): void | Promise<void>;14-  startAdoption(args: {15-    password: string;16-    confirmSAS(sas: string): Promise<boolean>;17-    createVault(rootKeyHex: string): Promise<{ vaultId: string }>;18-  }): Promise<void>;19-}20-21-const MIN_LENGTH = 12;22-23-export function AdoptVaultView(props: AdoptVaultViewProps) {24-  const [phase, setPhase] = createSignal<AdoptPhase>("password");25-  const [password, setPassword] = createSignal("");26-  const [confirmPassword, setConfirmPassword] = createSignal("");27-  const [sas, setSas] = createSignal("");28-  const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);29-  const [error, setError] = createSignal("");30-31-  const canContinue = createMemo(() =>32-    password().length >= MIN_LENGTH &&33-    confirmPassword().length >= MIN_LENGTH &&34-    password() === confirmPassword()35-  );36-37-  function blocker(): string | null {38-    if (!password()) return null;39-    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;40-    if (!confirmPassword()) return "confirm your password";41-    if (password() !== confirmPassword()) return "passwords don't match";42-    return null;43-  }44-45-  async function beginPairing() {46-    setPhase("pair");47-    setError("");48-    try {49-      await props.startAdoption({50-        password: password(),51-        confirmSAS: (code) => new Promise<boolean>((resolve) => {52-          setSas(code);53-          setSasResolve(() => resolve);54-          setPhase("sas");55-        }),56-        createVault: async (rootKeyHex) => {57-          const res = await sendMessage({58-            type: "CREATE_VAULT_FROM_ROOT_KEY",59-            payload: { password: password(), rootKeyHex },60-          }) as { ok: boolean; vaultId?: string; error?: string };61-          if (!res.ok || !res.vaultId) throw new Error(res.error ?? "adoption_create_failed");62-          return { vaultId: res.vaultId };63-        },64-      });65-      setPhase("success");66-    } catch (err) {67-      setError(err instanceof Error ? err.message : String(err));68-      setPhase("error");69-    }70-  }71-72-  function confirmSas(ok: boolean) {73-    const resolve = sasResolve();74-    setSasResolve(null);75-    resolve?.(ok);76-    if (!ok) {77-      setPhase("password");78-      setPassword("");79-      setConfirmPassword("");80-    }81-  }82-83-  return (84-    <section class="peach-screen peach-theme peach-adopt-vault">85-      <RuneBackground opacity={0.07} />86-      <Switch>87-        <Match when={phase() === "password"}>88-          <h1>Set a password for this device.</h1>89-          <p>This protects your vault on this device only. It can be different from your other devices.</p>90-          <label class="peach-label" for="adopt-password">password</label>91-          <input92-            id="adopt-password"93-            class="peach-underline-input"94-            type="password"95-            autocomplete="new-password"96-            value={password()}97-            onInput={(event) => setPassword((event.target as HTMLInputElement).value)}98-          />99-          <label class="peach-label" for="adopt-confirm-password">confirm password</label>100-          <input101-            id="adopt-confirm-password"102-            class="peach-underline-input"103-            type="password"104-            autocomplete="new-password"105-            value={confirmPassword()}106-            onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}107-          />108-          <Show when={blocker()}>109-            <p class="peach-hint">{blocker()}</p>110-          </Show>111-          <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>112-            Continue ->113-          </button>114-          <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>115-        </Match>116-117-        <Match when={phase() === "pair"}>118-          <div class="peach-adopt-birds" aria-hidden="true" />119-          <h1>Pair with your other device.</h1>120-          <p>Open Peach on the device that has your vault. Bring both devices close together.</p>121-          <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void beginPairing()}>122-            Pair a device ->123-          </button>124-          <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>125-        </Match>126-127-        <Match when={phase() === "sas"}>128-          <h1>Confirm this code matches on your other device:</h1>129-          <div class="peach-adopt-sas">{sas().replace(/(\d)(?=\d)/g, "$1 ")}</div>130-          <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => confirmSas(true)}>Confirm</button>131-          <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => confirmSas(false)}>Cancel</button>132-        </Match>133-134-        <Match when={phase() === "success"}>135-          <div class="peach-adopt-birds" aria-hidden="true" />136-          <h1>Joined your vault.</h1>137-          <p>Your passwords will sync shortly.</p>138-          <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>139-            Open Peach ->140-          </button>141-        </Match>142-143-        <Match when={phase() === "error"}>144-          <h1>Couldn't join your vault.</h1>145-          <p class="peach-hint peach-hint-error">{error()}</p>146-          <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => setPhase("password")}>Try again</button>147-          <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>148-        </Match>149-      </Switch>150-    </section>151-  );152-}153-154-// DRAFT HUNK: src/popup/views/Onboarding.tsx155-// - Extend props with `onAdopt?: () => void | Promise<void>`.156-// - On the final card, render a third action button:157-//     <button class="peach-onboarding-adopt" onClick={props.onAdopt}>158-//       I already have Peach ->159-//     </button>160-//   Keep Create vault and Restore from backup visible in LockView as they are161-//   today; this is an additional path, not a replacement.162-163-// DRAFT HUNK: src/popup/App.tsx164-// - Add `| "adopting"` to `View`.165-// - Lazy-load `AdoptVaultView`.166-// - Pass `onAdopt={() => setView("adopting")}` into Onboarding.167-// - Render AdoptVaultView while hasVault() is false.168-// - On success, refresh HAS_VAULT, mark onboarding complete, call handleUnlock.169-

view file at the parent

src/drafts/VAULT-ADOPTION/vault-createVaultFromRootKey.test.ts+0 −116
@@ -1,116 +0,0 @@1-// DRAFT DESTINATION: src/background/vault-createVaultFromRootKey.test.ts2-// T-A01..T-A05: createVaultFromRootKey adoption invariants.3-4-import { beforeEach, describe, expect, it, vi } from "vitest";5-import { computeVaultId } from "../shared/sync-identity";6-7-type StorageShape = Record<string, unknown>;8-9-function makeChromeStorageMock(): { storage: StorageShape; chrome: typeof chrome } {10-  const storage: StorageShape = {};11-  const chrome = {12-    storage: {13-      local: {14-        get: vi.fn(async (key?: string | string[]) => {15-          if (!key) return { ...storage };16-          if (typeof key === "string") return { [key]: storage[key] };17-          return key.reduce<StorageShape>((acc, k) => { acc[k] = storage[k]; return acc; }, {});18-        }),19-        set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),20-        remove: vi.fn(async (keys: string | string[]) => {21-          for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];22-        }),23-      },24-      session: { get: vi.fn(async () => ({})), set: vi.fn(), remove: vi.fn() },25-    },26-    alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },27-    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },28-  } as unknown as typeof chrome;29-  return { storage, chrome };30-}31-32-describe("vault adoption: createVaultFromRootKey", () => {33-  const SOURCE_PASSWORD = "source-adoption-passphrase-123";34-  const RECEIVER_PASSWORD = "receiver-local-passphrase-456";35-36-  beforeEach(() => {37-    vi.resetModules();38-    const mock = makeChromeStorageMock();39-    vi.stubGlobal("chrome", mock.chrome);40-    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));41-  });42-43-  it("T-A01: round-trips an existing root key under a different local password", async () => {44-    const sourceVault = await import("./vault");45-    await sourceVault.createVault(SOURCE_PASSWORD);46-    const rootKeyHex = sourceVault.getRootKeyHex();47-    expect(rootKeyHex).toMatch(/^[0-9a-f]{64}$/);48-    const sourceVaultId = await computeVaultId(new Uint8Array(Buffer.from(rootKeyHex!, "hex")));49-50-    vi.resetModules();51-    const mock = makeChromeStorageMock();52-    vi.stubGlobal("chrome", mock.chrome);53-    const receiverVault = await import("./vault");54-    const result = await receiverVault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex!);55-56-    expect(result.vaultId).toBe(sourceVaultId);57-    receiverVault.lock();58-    expect(await receiverVault.unlock(RECEIVER_PASSWORD)).toBe(true);59-    expect(receiverVault.getRootKeyHex()).toBe(rootKeyHex);60-  });61-62-  it("T-A02: recovery words match the source vault because they derive from the same root key prefix", async () => {63-    const sourceVault = await import("./vault");64-    await sourceVault.createVault(SOURCE_PASSWORD);65-    const rootKeyHex = sourceVault.getRootKeyHex()!;66-    const sourceWords = await sourceVault.getRecoveryWords();67-68-    vi.resetModules();69-    const mock = makeChromeStorageMock();70-    vi.stubGlobal("chrome", mock.chrome);71-    const receiverVault = await import("./vault");72-    await receiverVault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex);73-74-    expect(await receiverVault.getRecoveryWords()).toEqual(sourceWords);75-  });76-77-  it.each([78-    ["63 chars", "a".repeat(63)],79-    ["65 chars", "a".repeat(65)],80-    ["uppercase", "A".repeat(64)],81-    ["non-hex", "g".repeat(64)],82-    ["empty", ""],83-  ])("T-A03: rejects invalid root key input: %s", async (_label, rootKeyHex) => {84-    const vault = await import("./vault");85-    await expect(vault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex))86-      .rejects.toThrow(/ADOPT_INVALID_ROOT_KEY/);87-  });88-89-  it("T-A04: zeroes the decoded root key buffer on success and failure", async () => {90-    const fillSpy = vi.spyOn(Uint8Array.prototype, "fill");91-    const vault = await import("./vault");92-    await vault.createVaultFromRootKey(RECEIVER_PASSWORD, "11".repeat(32));93-    expect(fillSpy).toHaveBeenCalledWith(0);94-95-    fillSpy.mockClear();96-    await expect(vault.createVaultFromRootKey("", "22".repeat(32))).rejects.toThrow();97-    expect(fillSpy).toHaveBeenCalledWith(0);98-  });99-100-  it("T-A05: adopted vault supports normal entry writes and keeps the original vault id", async () => {101-    const vault = await import("./vault");102-    const rootKeyHex = "33".repeat(32);103-    const { vaultId } = await vault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex);104-    const entry = await vault.addEntry({105-      type: "login",106-      title: "Example",107-      url: "https://example.com",108-      username: "june",109-      password: "secret",110-    });111-112-    expect(vault.getEntries()).toContainEqual(expect.objectContaining({ id: entry.id, title: "Example" }));113-    expect(vaultId).toBe(await computeVaultId(new Uint8Array(Buffer.from(rootKeyHex, "hex"))));114-  });115-});116-

view file at the parent

src/drafts/VAULT-ADOPTION/vault-createVaultFromRootKey.ts+0 −110
@@ -1,110 +0,0 @@1-// DRAFT DESTINATION: src/background/vault.ts2-// Phase 1: add createVaultFromRootKey(password, rootKeyHex).3-//4-// Integration notes:5-// - Add `import { computeVaultId } from "../shared/sync-identity";` near the6-//   existing imports if vault.ts does not already import it.7-// - Add `wrapExistingRootKeyForRecovery` beside generateAndWrapRootKey.8-// - Add createVaultFromRootKey beside createVault.9-10-async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{11-  recoverySalt: string;12-  wrappedRootKey: string;13-  wrappedNonce: string;14-  wrappedTag: string;15-}> {16-  if (rootKeyBytes.length !== 32) {17-    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");18-  }19-20-  const words = await rootKeyToWords(rootKeyBytes);21-  const wordMaterial = await wordsToRootKeyMaterial(words);22-  const recoverySalt = crypto.getRandomValues(new Uint8Array(16));23-  const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);24-  const nonce = crypto.getRandomValues(new Uint8Array(12));25-  const encoded = new TextEncoder().encode(hex(rootKeyBytes));26-  const raw = await crypto.subtle.encrypt(27-    {28-      name: "AES-GCM",29-      iv: nonce,30-      additionalData: new Uint8Array([1]).buffer as ArrayBuffer,31-      tagLength: 128,32-    },33-    recoveryKey,34-    encoded,35-  );36-  const rawU8 = new Uint8Array(raw);37-  return {38-    recoverySalt: hex(recoverySalt),39-    wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),40-    wrappedNonce: hex(nonce),41-    wrappedTag: hex(rawU8.slice(rawU8.length - 16)),42-  };43-}44-45-/**46- * Creates a local vault from an existing vault root key received through the47- * SAS-gated adoption protocol. The password is local to this device; it wraps48- * the adopted root key at rest and does not need to match the source device.49- */50-export async function createVaultFromRootKey(51-  password: string,52-  rootKeyHex: string,53-): Promise<{ vaultId: string }> {54-  if (typeof password !== "string" || password.length === 0) {55-    throw new Error("ADOPT_INVALID_PASSWORD");56-  }57-  if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {58-    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");59-  }60-61-  const rootKeyBytes = unhex(rootKeyHex);62-  if (rootKeyBytes.length !== 32) {63-    rootKeyBytes.fill(0);64-    throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");65-  }66-67-  try {68-    await ensureMigrated();69-70-    const salt = generateSalt();71-    derivedKey = await deriveKey(password, salt);72-    entries = [];73-    vaultMode = "real";74-    duressConfigured = false;75-    buildDomainIndexSync(entries);76-77-    const vaultId = await computeVaultId(rootKeyBytes);78-    const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);79-80-    // Copy into the unlocked-session cache. Do not alias rootKeyBytes because81-    // the finally block below wipes the input buffer.82-    rootKey = new Uint8Array(rootKeyBytes);83-84-    const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);85-    const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");86-    const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);87-88-    const slotA: StoredVault = {89-      version: 1,90-      salt: hex(salt),91-      nonce,92-      ciphertext,93-      tag,94-      recovery_salt: recovery.recoverySalt,95-      recovery_wrapped_root_key: recovery.wrappedRootKey,96-      recovery_nonce: recovery.wrappedNonce,97-      recovery_tag: recovery.wrappedTag,98-      ...pwRoot,99-    };100-    await writeSlot(SLOT_A, slotA);101-    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));102-    await stampLocalVaultMutation();103-    touchAutoLock();104-105-    return { vaultId };106-  } finally {107-    rootKeyBytes.fill(0);108-  }109-}110-

view file at the parent

src/popup/App.tsx+63 −3
@@ -116,6 +116,23 @@116116   }117117 }118118 119+function defaultAdoptionDeviceName(): string {120+  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";121+  if (/Firefox/i.test(ua)) return "Firefox";122+  if (/Edg\//i.test(ua)) return "Edge";123+  if (/Chrome/i.test(ua)) return "Chrome";124+  if (/Safari/i.test(ua)) return "Safari";125+  return "Peach";126+}127+128+function adoptionDeviceType(): "extension" | "android" | "ios" {129+  const cap = (globalThis as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor;130+  const platform = cap?.getPlatform?.();131+  if (platform === "ios") return "ios";132+  if (platform === "android") return "android";133+  return "extension";134+}135+119136 const ENTRY_TRANSITION_SETTLE_MS = 135;120137 121138 export default function App() {@@ -1169,6 +1186,51 @@11691186     void sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "foreground" } });11701187   }11711188 1189+  async function startVaultAdoption(args: {1190+    password: string;1191+    confirmSAS(sas: string): Promise<boolean>;1192+    createVault(rootKeyHex: string): Promise<{ vaultId: string }>;1193+  }) {1194+    if (!isNativePlatform()) {1195+      throw new Error("adoption_pairing_requires_native_server");1196+    }1197+1198+    const [{ runChirpAdoptionReceiver }, { openCapacitorPairingServer }] = await Promise.all([1199+      import("./chirp-pair"),1200+      import("./chirp-pair-capacitor"),1201+    ]);1202+1203+    await runChirpAdoptionReceiver({1204+      deviceName: defaultAdoptionDeviceName(),1205+      deviceType: adoptionDeviceType(),1206+      openPairingServer: openCapacitorPairingServer,1207+      confirmSAS: args.confirmSAS,1208+      createVaultFromRootKey: args.createVault,1209+      deriveLocalIdentity: async () => {1210+        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });1211+        const r = res as { ok: boolean; data?: unknown; error?: string };1212+        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");1213+        return r.data as Awaited<ReturnType<Parameters<typeof runChirpAdoptionReceiver>[0]["deriveLocalIdentity"]>>;1214+      },1215+      completePairing: async (paired) => {1216+        const res = await sendMessage({ type: "CHIRP_COMPLETE_PAIRING", payload: { paired } });1217+        const r = res as { ok: boolean; error?: string };1218+        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");1219+      },1220+      applyLicense: async (license) => {1221+        const res = (await sendMessage({1222+          type: "ACTIVATE_LICENSE",1223+          payload: { key: license },1224+        })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };1225+        if (res.ok && res.data?.valid && res.data?.plan === "pro") await refreshProStatus();1226+      },1227+      onProgress() {1228+        // AdoptVaultView owns the visible phase copy; its SAS callback moves1229+        // from chirping to verification, and success/error are local there.1230+      },1231+    });1232+  }1233+11721234   async function handleApprovePendingPasskey() {11731235     const pending = pendingPasskey();11741236     if (!pending) return;@@ -1237,9 +1299,7 @@12371299                         setView("lock");12381300                       }}12391301                       onDone={handleAdoptionDone}1240-                      startAdoption={async () => {1241-                        throw new Error("adoption pairing is not available yet");1242-                      }}1302+                      startAdoption={startVaultAdoption}12431303                     />12441304                   </Suspense>12451305                 }

view file

src/popup/chirp-pair.adopt-source.test.ts+123 −3
@@ -1,7 +1,16 @@1-import { describe, expect, it, vi } from "vitest";1+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";22 import { computeVaultId } from "../shared/sync-identity";3-import { routeSourcePostSASFrame } from "./chirp-pair";4-import type { LocalIdentity, PairingTransport } from "../shared/chirp-handshake";3+import { routeSourcePostSASFrame, runChirpAdoptionReceiver } from "./chirp-pair";4+import {5+  memoryTransportPair,6+  runPairingResponderOpenFrame,7+  type LocalIdentity,8+  type PairingTransport,9+} from "../shared/chirp-handshake";10+import { createPairingAttempt } from "../shared/chirp-pairing";11+import { __setGgwaveModuleForTest } from "../shared/ggwave";12+import { encodeChirpPayload } from "../shared/chirp-payload";13+import { CHIRP_PAYLOAD_LENGTH, DEVICE_TYPE_EXTENSION, type ChirpPayload } from "../shared/sync-types";514 615 const localIdentity: LocalIdentity = {716   deviceId: "source-device",@@ -29,7 +38,65 @@2938   };3039 }3140 41+function fakeGgwave() {42+  return {43+    ProtocolId: { GGWAVE_PROTOCOL_ULTRASOUND_FASTEST: 5 },44+    GGWAVE_OPERATING_MODE_RX: 1,45+    GGWAVE_OPERATING_MODE_TX: 2,46+    GGWAVE_OPERATING_MODE_RX_AND_TX: 3,47+    getDefaultParameters() {48+      return {49+        sampleRateInp: 48000,50+        sampleRateOut: 48000,51+        samplesPerFrame: 1024,52+        soundMarkerThreshold: 3,53+        payloadLength: -1,54+        operatingMode: 0,55+      };56+    },57+    init() { return 1; },58+    free() {},59+    encode(_inst: number, data: Uint8Array | string) {60+      const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;61+      const out = new Float32Array(Math.max(bytes.length, 128));62+      for (let i = 0; i < bytes.length; i++) out[i] = bytes[i] / 255;63+      for (let i = bytes.length; i < out.length; i++) out[i] = 99;64+      return out;65+    },66+    decode(_inst: number, samples: Float32Array | Int16Array) {67+      if (samples.length < CHIRP_PAYLOAD_LENGTH) return null;68+      const buf = new Uint8Array(CHIRP_PAYLOAD_LENGTH);69+      for (let i = 0; i < CHIRP_PAYLOAD_LENGTH; i++) {70+        const v = (samples as Float32Array)[i];71+        if (v === 99) return null;72+        buf[i] = Math.round(v * 255);73+      }74+      return buf;75+    },76+  };77+}78+79+function fakeAudio(injectedChirp: ChirpPayload) {80+  const bytes = encodeChirpPayload(injectedChirp);81+  let pending = new Float32Array(128);82+  for (let i = 0; i < bytes.length; i++) pending[i] = bytes[i] / 255;83+  for (let i = bytes.length; i < pending.length; i++) pending[i] = 99;84+  return async () => ({85+    sampleRate: 48000,86+    async play() { /* no-op */ },87+    async pullSamples() {88+      const out = pending;89+      pending = new Float32Array(0);90+      return out;91+    },92+    async close() { /* no-op */ },93+  });94+}95+3296 describe("source-side ADOPT_REQUEST routing", () => {97+  beforeEach(() => __setGgwaveModuleForTest(fakeGgwave()));98+  afterEach(() => __setGgwaveModuleForTest(null));99+33100   it("T-A26: routes ADOPT_REQUEST to grantAdoption after SAS confirmation and consent", async () => {34101     const rootKeyHex = "77".repeat(32);35102     const ch = channel();@@ -141,5 +208,58 @@141208     );142209 143210     expect(fillSpy).toHaveBeenCalledWith(0);211+  });212+213+  it("T-A33: receiver onboarding orchestrator sends ADOPT_REQUEST and persists the granted source", async () => {214+    const sourceAttempt = createPairingAttempt(DEVICE_TYPE_EXTENSION);215+    const [receiverTransport, sourceTransport] = memoryTransportPair();216+    const rootKeyHex = "99".repeat(32);217+    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));218+    const receiverUpsert = vi.fn();219+    const sourceUpsert = vi.fn();220+221+    const source = (async () => {222+      const postNoise = await runPairingResponderOpenFrame(223+        sourceAttempt.ephemeralPrivateKey,224+        sourceAttempt.ephemeralPublicKey,225+        sourceTransport,226+      );227+      return routeSourcePostSASFrame(postNoise, localIdentity, {228+        confirmSAS: vi.fn(async () => true),229+        completePairing: sourceUpsert,230+        grantAdoptionConsent: vi.fn(async () => true),231+        fetchAdoptionMaterial: vi.fn(async () => ({ rootKeyHex, vaultId })),232+        onProgress: vi.fn(),233+      });234+    })();235+236+    const receiver = await runChirpAdoptionReceiver({237+      deviceName: "Receiver",238+      deviceType: "extension",239+      openAudio: fakeAudio(sourceAttempt.outgoingPayload),240+      openPairingServer: vi.fn(async () => ({241+        transport: { ...receiverTransport, close: vi.fn(async () => {}) },242+        peerIp: "127.0.0.1",243+      })),244+      confirmSAS: vi.fn(async () => true),245+      createVaultFromRootKey: vi.fn(async (adoptedRootKeyHex) => {246+        expect(adoptedRootKeyHex).toBe(rootKeyHex);247+        return { vaultId };248+      }),249+      deriveLocalIdentity: vi.fn(async () => ({250+        deviceId: "receiver-device",251+        deviceName: "Receiver",252+        deviceType: "extension" as const,253+        longTermPublicKeyHex: "ef".repeat(32),254+        signingPublicKeyHex: "12".repeat(32),255+      })),256+      completePairing: receiverUpsert,257+      onProgress: vi.fn(),258+    });259+    await source;260+261+    expect(receiver).toEqual(expect.objectContaining({ deviceId: "source-device" }));262+    expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "source-device" }));263+    expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-device" }));144264   });145265 });

view file

src/popup/chirp-pair.ts+100 −1
@@ -26,6 +26,7 @@2626 } from "../shared/chirp-pairing";2727 import {2828   handlePairIdentityFrame,29+  runPairingInitiatorOpenChannel,2930   runPairingInitiator,3031   runPairingResponderOpenFrame,3132   type LocalIdentity,@@ -33,11 +34,12 @@3334   type PostNoiseFrame,3435 } from "../shared/chirp-handshake";3536 import { computeVaultId } from "../shared/sync-identity";36-import { grantAdoption, isAdoptRequest, type AdoptRequest } from "../shared/chirp-adopt";37+import { grantAdoption, isAdoptRequest, requestAdoption, type AdoptRequest } from "../shared/chirp-adopt";3738 import {3839   DEVICE_TYPE_EXTENSION,3940   DEVICE_TYPE_ANDROID,4041   DEVICE_TYPE_IOS,42+  type DeviceType,4143   type PairedDevice,4244   type PairingProgress,4345 } from "../shared/sync-types";@@ -181,6 +183,85 @@181183   return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);182184 }183185 186+export interface ChirpAdoptionReceiverDeps {187+  deviceName: string;188+  deviceType?: DeviceType;189+  generateDeviceId?(): string;190+  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;191+  deriveLocalIdentity(): Promise<LocalIdentity>;192+  completePairing(paired: PairedDevice): Promise<void>;193+  applyLicense?(license: string): Promise<void>;194+  confirmSAS(sas: string): Promise<boolean>;195+  openPairingServer(): Promise<{ transport: PairingTransport; peerIp: string }>;196+  openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;197+  chirpTimeoutMs?: number;198+  onProgress(p: PairingProgress): void;199+}200+201+export async function runChirpAdoptionReceiver(202+  deps: ChirpAdoptionReceiverDeps,203+): Promise<PairedDevice> {204+  deps.onProgress({ phase: "idle" });205+  deps.onProgress({ phase: "listening" });206+207+  const attempt = createPairingAttempt(deviceTypeByteFor(deps.deviceType ?? "extension"));208+  let remoteChirpPublicKey: Uint8Array;209+  try {210+    const audio = await (deps.openAudio ?? openBrowserAudioIO)();211+    const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });212+    remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;213+  } catch (err) {214+    if (err instanceof ChirpExchangeError) {215+      const phase: PairingProgress["phase"] =216+        err.kind === "timeout" ? "timeout" :217+        err.kind === "version_mismatch" ? "version_mismatch" :218+        err.kind === "mic_denied" ? "mic_denied" :219+        "handshake_failed";220+      deps.onProgress({ phase, errorMessage: err.message });221+      throw new ChirpPairingFailure(phase, err.message);222+    }223+    throw err;224+  }225+226+  deps.onProgress({ phase: "handshake" });227+  let server: { transport: PairingTransport; peerIp: string };228+  try {229+    server = await deps.openPairingServer();230+  } catch (err) {231+    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });232+    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);233+  }234+235+  try {236+    const postNoise = await runPairingInitiatorOpenChannel(remoteChirpPublicKey, server.transport);237+    const confirmed = await deps.confirmSAS(postNoise.sas);238+    if (!confirmed) {239+      deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" });240+      throw new ChirpPairingFailure("handshake_failed", "sas_rejected");241+    }242+    const source = await requestAdoption(postNoise.channel, {243+      deviceName: deps.deviceName,244+      generateDeviceId: deps.generateDeviceId ?? generateUuid,245+      createVaultFromRootKey: deps.createVaultFromRootKey,246+      deriveLocalIdentity: deps.deriveLocalIdentity,247+      upsertPairedDevice: deps.completePairing,248+      applyLicense: deps.applyLicense,249+      onError(error) {250+        throw new Error(error);251+      },252+    });253+    if (!source) throw new Error("adoption_failed");254+    deps.onProgress({ phase: "paired", peerDeviceName: source.deviceName });255+    return source;256+  } catch (err) {257+    if (err instanceof ChirpPairingFailure) throw err;258+    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });259+    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);260+  } finally {261+    await server.transport.close().catch(() => undefined);262+  }263+}264+184265 export interface SourcePostSASDeps {185266   confirmSAS(sas: string): Promise<boolean>;186267   completePairing(device: PairedDevice): Promise<void>;@@ -345,6 +426,24 @@345426   const out = new Uint8Array(hex.length / 2);346427   for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);347428   return out;429+}430+431+function deviceTypeByteFor(type: DeviceType) {432+  return type === "android" ? DEVICE_TYPE_ANDROID :433+    type === "ios" ? DEVICE_TYPE_IOS :434+    DEVICE_TYPE_EXTENSION;435+}436+437+function generateUuid(): string {438+  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {439+    return crypto.randomUUID();440+  }441+  const bytes = new Uint8Array(16);442+  crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);443+  bytes[6] = (bytes[6] & 0x0f) | 0x40;444+  bytes[8] = (bytes[8] & 0x3f) | 0x80;445+  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");446+  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;348447 }349448 350449 async function runInitiatorHandshake(

view file

src/shared/chirp-adopt.ts+9 −7
@@ -96,7 +96,7 @@9696 export async function requestAdoption(9797   channel: PairingTransport,9898   deps: AdoptionReceiverDeps,99-): Promise<void> {99+): Promise<PairedDevice | null> {100100   await sendFrame(channel, {101101     type: "ADOPT_REQUEST",102102     device_id: deps.generateDeviceId(),@@ -107,7 +107,7 @@107107   const frame = await receiveFrame(channel);108108   if (!isAdoptGrant(frame)) {109109     deps.onError("ADOPT_UNEXPECTED_FRAME");110-    return;110+    return null;111111   }112112 113113   const rootKeyBytes = hexToBytes(frame.root_key_hex);@@ -115,7 +115,7 @@115115     const computedVaultId = await computeVaultId(rootKeyBytes);116116     if (computedVaultId !== frame.vault_id) {117117       deps.onError("ADOPT_VAULT_ID_MISMATCH");118-      return;118+      return null;119119     }120120   } finally {121121     rootKeyBytes.fill(0);@@ -124,7 +124,7 @@124124   const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);125125   if (vaultId !== frame.vault_id) {126126     deps.onError("ADOPT_VAULT_ID_MISMATCH");127-    return;127+    return null;128128   }129129 130130   if (frame.license) await deps.applyLicense?.(frame.license);@@ -133,13 +133,13 @@133133   await sendFrame(channel, {134134     type: "ADOPT_IDENTITY",135135     device_id: identity.deviceId,136-    device_name: deps.deviceName,136+    device_name: identity.deviceName,137137     device_type: identity.deviceType,138138     long_term_public_key_hex: identity.longTermPublicKeyHex,139139     signing_public_key_hex: identity.signingPublicKeyHex,140140   });141141 142-  await deps.upsertPairedDevice({142+  const sourceDevice: PairedDevice = {143143     deviceId: frame.source.device_id,144144     deviceName: frame.source.device_name,145145     deviceType: frame.source.device_type,@@ -148,7 +148,9 @@148148     trustSource: "direct",149149     wsPort: frame.source.ws_port,150150     pairedAt: Date.now(),151-  });151+  };152+  await deps.upsertPairedDevice(sourceDevice);153+  return sourceDevice;152154 }153155 154156 export async function grantAdoption(

view file

src/shared/chirp-handshake.ts+34 −17
@@ -92,15 +92,15 @@9292   channel: PairingTransport;9393 }9494 95+export interface PostNoiseChannel {96+  sas: string;97+  channel: PairingTransport;98+}99+95100 function encodeFrame(frame: PairingFrame): Uint8Array {96101   return new TextEncoder().encode(JSON.stringify(frame));97102 }98103 99-function decodeFrame(bytes: Uint8Array): PairingFrame {100-  const parsed = JSON.parse(new TextDecoder().decode(bytes));101-  return decodeParsedFrame(parsed);102-}103-104104 function decodeParsedFrame(parsed: unknown): PairingFrame {105105   const value = parsed as Record<string, unknown> | null;106106   if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");@@ -133,14 +133,10 @@133133   ourIdentity: LocalIdentity,134134   transport: PairingTransport,135135 ): Promise<PairingHandshakeResult> {136-  const session = await noiseNKInitiator(137-    remoteEphemeralPublicKey,138-    transport.send,139-    transport.receive,140-  );136+  const postNoise = await runPairingInitiatorOpenChannel(remoteEphemeralPublicKey, transport);141137 142138   // Send our identity, then receive theirs.143-  const ourFrame = encodeFrame({139+  await postNoise.channel.send(encodeFrame({144140     type: "PAIR_IDENTITY",145141     device_id: ourIdentity.deviceId,146142     device_name: ourIdentity.deviceName,@@ -150,18 +146,39 @@150146     ws_port: ourIdentity.wsPort,151147     vault_id: ourIdentity.vaultId,152148     license: ourIdentity.license,153-  });154-  await transport.send(session.encrypt(ourFrame));149+  }));155150 156-  const theirCipher = await transport.receive();157-  const theirPlain = session.decrypt(theirCipher);158-  const theirFrame = decodeFrame(theirPlain);151+  const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive())));159152   // The SAS is derived from the shared Noise handshake hash; the caller MUST160153   // have the user confirm it matches the peer's before persisting the device.161154   return {162155     device: frameToPairedDevice(theirFrame),163-    sas: derivePairingSAS(session.handshakeHash),156+    sas: postNoise.sas,164157     peerLicense: peerLicenseOf(theirFrame),158+  };159+}160+161+export async function runPairingInitiatorOpenChannel(162+  remoteEphemeralPublicKey: Uint8Array,163+  transport: PairingTransport,164+): Promise<PostNoiseChannel> {165+  const session = await noiseNKInitiator(166+    remoteEphemeralPublicKey,167+    transport.send,168+    transport.receive,169+  );170+171+  const channel: PairingTransport = {172+    async send(msg) {173+      await transport.send(session.encrypt(msg));174+    },175+    async receive() {176+      return session.decrypt(await transport.receive());177+    },178+  };179+  return {180+    sas: derivePairingSAS(session.handshakeHash),181+    channel,165182   };166183 }167184 

view file