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
14 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 −0
src/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+

view file

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+

view file

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+

view file

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+

view file

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+

view file

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+

view file

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+

view file

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+

view file

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+

view file

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+

view file

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+

view file

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+

view file

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+

view file

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+

view file