8d7e39854a6b
feat: draft vault adoption implementation contract — createVaultFromRootKey, CREATE_VAULT_FROM_ROOT_KEY, ADOPT_REQUEST/GRANT/IDENTITY, consent UI, onboarding receiver flow, source routing tests
- time
- 2026-06-19 19:19 UTC · 4 months ago
- machine
- koh2
- agent
- gpt-5
- session
- vault adoption drafts
- parent
- cc2414dc0ac2
↓ peach-browser-8d7e3985.tar.gzatlas at this save
files changed14 files
src/drafts/VAULT-ADOPTION/AdoptionConsentModal.test.tsx+73 −0src/drafts/VAULT-ADOPTION/AdoptionConsentModal.tsx+166 −0src/drafts/VAULT-ADOPTION/INTEGRATION-CONTRACT.md+210 −0src/drafts/VAULT-ADOPTION/README.md+45 −0src/drafts/VAULT-ADOPTION/chirp-adopt.test.ts+190 −0src/drafts/VAULT-ADOPTION/chirp-adopt.ts+214 −0src/drafts/VAULT-ADOPTION/chirp-pair-adopt-source.test.ts+126 −0src/drafts/VAULT-ADOPTION/chirp-pair-adopt-source.ts+101 −0src/drafts/VAULT-ADOPTION/messages-CREATE_VAULT_FROM_ROOT_KEY.test.ts+69 −0src/drafts/VAULT-ADOPTION/messages-CREATE_VAULT_FROM_ROOT_KEY.ts+39 −0src/drafts/VAULT-ADOPTION/onboarding-adopt-path.test.tsx+107 −0src/drafts/VAULT-ADOPTION/onboarding-adopt-path.tsx+169 −0src/drafts/VAULT-ADOPTION/vault-createVaultFromRootKey.test.ts+116 −0src/drafts/VAULT-ADOPTION/vault-createVaultFromRootKey.ts+110 −0src/drafts/VAULT-ADOPTION/AdoptionConsentModal.test.tsx+73 −0
@@ -0,0 +1,73 @@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+src/drafts/VAULT-ADOPTION/AdoptionConsentModal.tsx+166 −0
@@ -0,0 +1,166 @@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+src/drafts/VAULT-ADOPTION/INTEGRATION-CONTRACT.md+210 −0
@@ -0,0 +1,210 @@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+src/drafts/VAULT-ADOPTION/README.md+45 −0
@@ -0,0 +1,45 @@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+src/drafts/VAULT-ADOPTION/chirp-adopt.test.ts+190 −0
@@ -0,0 +1,190 @@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+src/drafts/VAULT-ADOPTION/chirp-adopt.ts+214 −0
@@ -0,0 +1,214 @@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+src/drafts/VAULT-ADOPTION/chirp-pair-adopt-source.test.ts+126 −0
@@ -0,0 +1,126 @@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+src/drafts/VAULT-ADOPTION/chirp-pair-adopt-source.ts+101 −0
@@ -0,0 +1,101 @@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+src/drafts/VAULT-ADOPTION/messages-CREATE_VAULT_FROM_ROOT_KEY.test.ts+69 −0
@@ -0,0 +1,69 @@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+src/drafts/VAULT-ADOPTION/messages-CREATE_VAULT_FROM_ROOT_KEY.ts+39 −0
@@ -0,0 +1,39 @@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+src/drafts/VAULT-ADOPTION/onboarding-adopt-path.test.tsx+107 −0
@@ -0,0 +1,107 @@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+src/drafts/VAULT-ADOPTION/onboarding-adopt-path.tsx+169 −0
@@ -0,0 +1,169 @@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+src/drafts/VAULT-ADOPTION/vault-createVaultFromRootKey.test.ts+116 −0
@@ -0,0 +1,116 @@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+src/drafts/VAULT-ADOPTION/vault-createVaultFromRootKey.ts+110 −0
@@ -0,0 +1,110 @@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+