cfa12cd81206
feat: wire adoption receiver and finalize build (v4.0.19)
- time
- 2026-06-19 20:23 UTC · 4 months ago
- machine
- koh2
- agent
- gpt-5
- session
- vault adoption integration
- parent
- 5845cedc6119
↓ peach-browser-cfa12cd8.tar.gzatlas at this save
files changed22 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/drafts/VAULT-ADOPTION/AdoptionConsentModal.test.tsx+0 −73src/drafts/VAULT-ADOPTION/AdoptionConsentModal.tsx+0 −166src/drafts/VAULT-ADOPTION/INTEGRATION-CONTRACT.md+0 −210src/drafts/VAULT-ADOPTION/README.md+0 −45src/drafts/VAULT-ADOPTION/chirp-adopt.test.ts+0 −190src/drafts/VAULT-ADOPTION/chirp-adopt.ts+0 −214src/drafts/VAULT-ADOPTION/chirp-pair-adopt-source.test.ts+0 −126src/drafts/VAULT-ADOPTION/chirp-pair-adopt-source.ts+0 −101src/drafts/VAULT-ADOPTION/messages-CREATE_VAULT_FROM_ROOT_KEY.test.ts+0 −69src/drafts/VAULT-ADOPTION/messages-CREATE_VAULT_FROM_ROOT_KEY.ts+0 −39src/drafts/VAULT-ADOPTION/onboarding-adopt-path.test.tsx+0 −107src/drafts/VAULT-ADOPTION/onboarding-adopt-path.tsx+0 −169src/drafts/VAULT-ADOPTION/vault-createVaultFromRootKey.test.ts+0 −116src/drafts/VAULT-ADOPTION/vault-createVaultFromRootKey.ts+0 −110src/popup/App.tsx+63 −3src/popup/chirp-pair.adopt-source.test.ts+123 −3src/popup/chirp-pair.ts+100 −1src/shared/chirp-adopt.ts+9 −7src/shared/chirp-handshake.ts+34 −17manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "4.0.18",5+ "version": "4.0.19",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.0.18",4+ "version": "4.0.19",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.0.18",3+ "version": "4.0.19",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/drafts/VAULT-ADOPTION/AdoptionConsentModal.test.tsx+0 −73
@@ -1,73 +0,0 @@1-// @vitest-environment jsdom2-// DRAFT DESTINATION: src/popup/components/AdoptionConsentModal.test.tsx3-// T-A16..T-A20: source consent UI cannot grant without re-auth.4-5-import { describe, expect, it, vi } from "vitest";6-import { AdoptionConsentModal } from "./AdoptionConsentModal";7-import { inputText, renderView } from "../views/view-test-utils";8-9-function button(container: HTMLElement, text: string): HTMLButtonElement {10- const found = Array.from(container.querySelectorAll("button")).find((b) =>11- (b.textContent ?? "").includes(text),12- );13- if (!found) throw new Error(`button not found: ${text}`);14- return found as HTMLButtonElement;15-}16-17-function mount(overrides: Partial<Parameters<typeof AdoptionConsentModal>[0]> = {}) {18- const props = {19- receiverDeviceName: "June's iPhone",20- sas: "895245",21- verifyMasterPassword: vi.fn(async () => true),22- onCancel: vi.fn(),23- onApprove: vi.fn(async () => {}),24- ...overrides,25- };26- const rendered = renderView(<AdoptionConsentModal {...props} />);27- return { ...rendered, props };28-}29-30-describe("AdoptionConsentModal", () => {31- it("T-A16: renders receiver device name and consequence copy", () => {32- const { container } = mount();33- expect(container.textContent).toContain('"June\'s iPhone" wants to join this vault.');34- expect(container.textContent).toContain("copies your entire vault including all passwords and Pro");35- expect(container.textContent).toContain("8 9 5 2 4 5");36- });37-38- it("T-A17: Share vault button is disabled until a password is entered", () => {39- const { container } = mount();40- const share = button(container, "Share vault")!;41- expect(share.disabled).toBe(true);42- inputText(container.querySelector("#adoption-consent-password") as HTMLInputElement, "pw");43- expect(share.disabled).toBe(false);44- });45-46- it("T-A18: wrong password shows inline error and does not approve", async () => {47- const { container, props } = mount({ verifyMasterPassword: vi.fn(async () => false) });48- inputText(container.querySelector("#adoption-consent-password") as HTMLInputElement, "wrong");49- button(container, "Share vault").click();50- await Promise.resolve();51-52- expect(container.textContent).toContain("Incorrect password");53- expect(props.onApprove).not.toHaveBeenCalled();54- });55-56- it("T-A19: correct password calls onApprove", async () => {57- const { container, props } = mount({ verifyMasterPassword: vi.fn(async () => true) });58- inputText(container.querySelector("#adoption-consent-password") as HTMLInputElement, "correct");59- button(container, "Share vault").click();60- await Promise.resolve();61-62- expect(props.verifyMasterPassword).toHaveBeenCalledWith("correct");63- expect(props.onApprove).toHaveBeenCalledTimes(1);64- });65-66- it("T-A20: cancel closes without approval", () => {67- const { container, props } = mount();68- button(container, "Cancel").click();69- expect(props.onCancel).toHaveBeenCalledTimes(1);70- expect(props.onApprove).not.toHaveBeenCalled();71- });72-});73-src/drafts/VAULT-ADOPTION/AdoptionConsentModal.tsx+0 −166
@@ -1,166 +0,0 @@1-// DRAFT DESTINATION: src/popup/components/AdoptionConsentModal.tsx2-// Phase 4: source-device consent modal for granting full vault adoption.3-4-import { Show, createSignal } from "solid-js";5-6-interface AdoptionConsentModalProps {7- receiverDeviceName: string;8- sas: string;9- verifyMasterPassword(password: string): Promise<boolean>;10- onCancel(): void;11- onApprove(): Promise<void>;12-}13-14-export function AdoptionConsentModal(props: AdoptionConsentModalProps) {15- const [password, setPassword] = createSignal("");16- const [error, setError] = createSignal("");17- const [busy, setBusy] = createSignal(false);18-19- async function submit() {20- if (!password() || busy()) return;21- setBusy(true);22- setError("");23- const ok = await props.verifyMasterPassword(password());24- if (!ok) {25- setPassword("");26- setError("Incorrect password");27- setBusy(false);28- queueMicrotask(() => document.getElementById("adoption-consent-password")?.focus());29- return;30- }31- await props.onApprove();32- setBusy(false);33- }34-35- return (36- <div class="peach-adoption-consent-backdrop" role="presentation">37- <section38- class="peach-adoption-consent"39- role="dialog"40- aria-modal="true"41- aria-labelledby="adoption-consent-title"42- >43- <div class="peach-adoption-consent-icon" aria-hidden="true">!</div>44- <h2 id="adoption-consent-title">Share vault with new device?</h2>45- <p class="peach-adoption-consent-device">46- "{props.receiverDeviceName}" wants to join this vault.47- </p>48- <p class="peach-adoption-consent-warning">49- This copies your entire vault including all passwords and Pro to that device.50- </p>51- <div class="peach-adoption-consent-sas">52- <span>Confirm the code matches:</span>53- <strong>{props.sas.replace(/(\d)(?=\d)/g, "$1 ")}</strong>54- </div>55- <label class="peach-label" for="adoption-consent-password">56- Enter your master password to approve57- </label>58- <input59- id="adoption-consent-password"60- class="peach-underline-input"61- type="password"62- autocomplete="current-password"63- value={password()}64- disabled={busy()}65- onInput={(event) => setPassword((event.target as HTMLInputElement).value)}66- onKeyDown={(event) => {67- if (event.key === "Enter") void submit();68- if (event.key === "Escape") props.onCancel();69- }}70- />71- <Show when={error()}>72- <p class="peach-hint peach-hint-error">{error()}</p>73- </Show>74- <div class="peach-adoption-consent-actions">75- <button76- type="button"77- class="peach-btn peach-btn--ghost peach-btn--md"78- disabled={busy()}79- onClick={props.onCancel}80- >81- Cancel82- </button>83- <button84- type="button"85- class="peach-btn peach-btn--accent peach-btn--md peach-adoption-consent-share"86- disabled={!password() || busy()}87- onClick={() => void submit()}88- >89- {busy() ? "Sharing..." : "Share vault ->"}90- </button>91- </div>92- </section>93- </div>94- );95-}96-97-// DRAFT DESTINATION: src/popup/popup.css98-// Add amber/warning styling. Keep it visually distinct from standard confirm.99-export const ADOPTION_CONSENT_CSS = `100-.peach-adoption-consent-backdrop {101- position: fixed;102- inset: 0;103- z-index: 1000;104- display: grid;105- place-items: center;106- padding: 18px;107- background: rgba(15, 10, 5, 0.72);108-}109-.peach-adoption-consent {110- width: min(100%, 340px);111- border: 1px solid color-mix(in srgb, #f59e0b 54%, var(--peach-border));112- border-radius: 8px;113- padding: 18px;114- background: color-mix(in srgb, #2c1b08 84%, var(--peach-surface));115- color: var(--peach-t0);116- box-shadow: 0 18px 60px rgba(0, 0, 0, 0.42);117-}118-.peach-adoption-consent-icon {119- width: 32px;120- height: 32px;121- display: grid;122- place-items: center;123- border-radius: 50%;124- background: #f59e0b;125- color: #1f1304;126- font-weight: 800;127-}128-.peach-adoption-consent h2 {129- margin: 12px 0 8px;130- font-size: 1.22rem;131- line-height: 1.15;132-}133-.peach-adoption-consent-device,134-.peach-adoption-consent-warning {135- margin: 0 0 12px;136- color: var(--peach-t1);137- line-height: 1.35;138-}139-.peach-adoption-consent-warning {140- color: #ffd79a;141-}142-.peach-adoption-consent-sas {143- display: flex;144- justify-content: space-between;145- align-items: center;146- gap: 12px;147- margin: 14px 0;148- padding: 10px;149- border-radius: 6px;150- background: rgba(245, 158, 11, 0.12);151-}152-.peach-adoption-consent-sas strong {153- font-variant-numeric: tabular-nums;154- letter-spacing: 0;155-}156-.peach-adoption-consent-actions {157- display: flex;158- justify-content: space-between;159- gap: 10px;160- margin-top: 16px;161-}162-.peach-adoption-consent-actions > button {163- flex: 1;164-}165-`;166-src/drafts/VAULT-ADOPTION/INTEGRATION-CONTRACT.md+0 −210
@@ -1,210 +0,0 @@1-# VAULT-ADOPTION Integration Contract2-3-Source of truth for integrating `/Users/june/Downloads/peach-vault-adoption-brief.md`.4-Where this contract and an individual draft disagree, this contract wins.5-6-## Scope7-8-Draft only. No production source was modified in this pass. Integrate in9-browser first, then coordinate Android. `peach-sync` has no adoption work.10-11-Current browser drift from the brief:12-- Brief base says v4.0.3; current dev is v4.0.13.13-- `PAIR_IDENTITY` already supports optional `vault_id` and Pro `license`.14-- Sync pairing is already usable by non-Pro devices; at least one device needs15- Pro and license transfer already rides normal pairing.16-17-## Files in `src/drafts/VAULT-ADOPTION/`18-19-| Draft file | Destination / role |20-|---|---|21-| `README.md` | Draft index; keep or trash after integration. |22-| `vault-createVaultFromRootKey.ts` | Code hunks for `src/background/vault.ts`. |23-| `vault-createVaultFromRootKey.test.ts` | Destination `src/background/vault-createVaultFromRootKey.test.ts`; T-A01..T-A05. |24-| `messages-CREATE_VAULT_FROM_ROOT_KEY.ts` | Code hunks for `src/shared/types.ts` and `src/background/messages.ts`. |25-| `messages-CREATE_VAULT_FROM_ROOT_KEY.test.ts` | Destination `src/background/messages.create-vault-from-root-key.test.ts`; T-A06..T-A09. |26-| `chirp-adopt.ts` | Destination `src/shared/chirp-adopt.ts`. |27-| `chirp-adopt.test.ts` | Destination `src/shared/chirp-adopt.test.ts`; T-A10..T-A15. |28-| `AdoptionConsentModal.tsx` | Destination `src/popup/components/AdoptionConsentModal.tsx` plus CSS hunk to `src/popup/popup.css`. |29-| `AdoptionConsentModal.test.tsx` | Destination `src/popup/components/AdoptionConsentModal.test.tsx`; T-A16..T-A20. |30-| `onboarding-adopt-path.tsx` | Destination `src/popup/views/AdoptVaultView.tsx` plus hunks for `Onboarding.tsx`, `Lock.tsx`, `App.tsx`. |31-| `onboarding-adopt-path.test.tsx` | Destination `src/popup/views/AdoptVaultView.test.tsx` plus one contracts test; T-A21..T-A25. |32-| `chirp-pair-adopt-source.ts` | Hunk for `src/popup/chirp-pair.ts`; requires a supporting responder-open-frame export from `src/shared/chirp-handshake.ts`. |33-| `chirp-pair-adopt-source.test.ts` | Destination `src/popup/chirp-pair.adopt-source.test.ts`; T-A26..T-A29. |34-| `INTEGRATION-CONTRACT.md` | This file. |35-36-## Integration Order37-38-Follow phases 1 -> 2 -> 3 -> 4 -> 5 -> 6. Save each phase separately.39-40-1. **Phase 1: vault root-key adoption**41- - Add `wrapExistingRootKeyForRecovery` and `createVaultFromRootKey` to42- `src/background/vault.ts`.43- - Import `computeVaultId`.44- - Run `npx tsc --noEmit`, relevant vault tests, then save.45-46-2. **Phase 2: message handler**47- - Add `CREATE_VAULT_FROM_ROOT_KEY` to `MessageType`.48- - Add handler beside `CREATE_VAULT`.49- - Handler is vault-less allowed and returns `{ ok: true, vaultId }`.50- - Run message tests and save.51-52-3. **Phase 3: adoption sub-protocol**53- - Add `src/shared/chirp-adopt.ts`.54- - Add tests T-A10..T-A15.55- - Important correction from the brief: receiver computes and checks56- `computeVaultId(root_key_hex)` before creating the vault. The brief's57- original order would allow partial local creation before detecting a58- mismatch.59-60-4. **Phase 4: source consent UI**61- - Add `AdoptionConsentModal`.62- - Add amber CSS to `popup.css`.63- - Wire the modal through the source pairing UI event path.64- - Re-auth uses `VERIFY_MAIN_PASSWORD` or the existing `vault.verifyMainPassword`65- background path before allowing `grantAdoption`.66-67-5. **Phase 5: receiver onboarding**68- - Add `"adopting"` to `App.tsx` view state.69- - Add `AdoptVaultView`.70- - Add final onboarding action: `I already have Peach ->`.71- - Receiver password is collected before chirp. Use `CREATE_VAULT_FROM_ROOT_KEY`72- only after SAS confirm and `ADOPT_GRANT` validation.73-74-6. **Phase 6: source routing**75- - Refactor `chirp-handshake.ts` so responder can inspect the first encrypted76- JSON frame as `PAIR_IDENTITY` or `ADOPT_REQUEST`.77- - Normal pairing remains unchanged for `PAIR_IDENTITY`.78- - `ADOPT_REQUEST` runs `grantAdoption` only after SAS and consent pass.79-80-7. **Version/build save**81- - Bump browser version from current dev version by at least PATCH.82- - Run `npm run build` after `tsc`/vitest.83-84-## New Message Types85-86-Add to `src/shared/types.ts`:87-88-```ts89-| "CREATE_VAULT_FROM_ROOT_KEY"90-```91-92-Payload:93-94-```ts95-{96- password: string;97- rootKeyHex: string;98-}99-```100-101-Response:102-103-```ts104-{ ok: true; vaultId: string } | { ok: false; error: string }105-```106-107-Suggested error strings:108-- `invalid_payload`109-- `ADOPT_INVALID_PASSWORD`110-- `ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars`111-- `ADOPT_VAULT_ID_MISMATCH`112-113-## New Frame Types114-115-Add in `src/shared/chirp-adopt.ts`:116-117-```ts118-ADOPT_REQUEST119-ADOPT_GRANT120-ADOPT_IDENTITY121-```122-123-Do not overload `PAIR_IDENTITY`. Adoption is a sub-protocol that runs on the124-same Noise/SAS channel after chirp, with its own two-phase persistence rules.125-126-## New View State127-128-Add to `App.tsx`:129-130-```ts131-| "adopting"132-```133-134-`"adopting"` is vault-less allowed. If adoption succeeds:135-- Refresh `HAS_VAULT`.136-- Mark onboarding complete.137-- Open the normal unlocked app shell.138-- Trigger an immediate sync attempt if a paired source was persisted.139-140-## Estimated Test Count141-142-29 specs:143-- T-A01..T-A05: vault creation from adopted root key.144-- T-A06..T-A09: message handler.145-- T-A10..T-A15: adoption sub-protocol.146-- T-A16..T-A20: source consent modal.147-- T-A21..T-A25: receiver onboarding path.148-- T-A26..T-A29: source routing.149-150-## Cross-Platform KAT Extensions151-152-Add a fixed adoption KAT shared with Android:153-- Fixed chirp ephemeral inputs.154-- Fixed source root key: 32 bytes.155-- Fixed source identity keys.156-- Expected SAS.157-- Expected `vault_id`.158-- Expected frame sequence:159- `ADOPT_REQUEST -> ADOPT_GRANT -> ADOPT_IDENTITY`.160-161-The KAT should prove Android and browser serialize the same snake_case frame162-fields and derive the same vault id from the same root key bytes.163-164-## Android Work Required165-166-Do not implement in this browser draft, but the Android integration pass needs:167-- Pairing protocol layer: add `ADOPT_REQUEST`, `ADOPT_GRANT`, `ADOPT_IDENTITY`.168-- Vault layer: create local vault from imported root key with local password.169-- Source consent UI: warning modal with master-password re-auth.170-- Receiver onboarding: "I already have Peach" path, local password first.171-- License transfer: send/activate optional `license` in adoption frames, matching172- browser's normal pairing transfer behavior.173-- Tests mirroring T-A01..T-A29 plus the cross-platform KAT.174-175-Likely files in peach-android (names may differ):176-- pairing/chirp protocol module177-- vault repository / crypto storage module178-- onboarding Compose screens179-- source consent Compose dialog180-- paired-device repository181-- license activation path182-183-## Security Checklist184-185-- [x] `root_key_hex` is validated as exactly 64 lowercase hex chars before use.186-- [x] Decoded root key bytes are zeroed after `createVaultFromRootKey` returns.187-- [x] Drafts never log `root_key_hex`.188-- [x] `ADOPT_GRANT` is sent only after the regular SAS gate.189-- [x] `ADOPT_GRANT` is sent only after source master-password re-auth succeeds.190-- [x] Source does not persist the receiver until `ADOPT_IDENTITY` is received.191-- [x] Receiver aborts before creating a vault if `vault_id` does not match the192- granted root key.193-- [x] Receiver stays vault-less on invalid root key, unexpected frame, SAS194- cancel, source consent reject, and vault-id mismatch.195-- [x] `ADOPT_REQUEST` from a peer without adoption support results in an196- unexpected-frame/error path, not a grant.197-- [x] Consent modal copy says "copies your entire vault including all passwords198- and Pro".199-200-## Known Integration Corrections201-202-- The brief suggests `rootKeyHex.split('').fill('0')`; that does not zero the203- original immutable string. Integrate byte-buffer zeroing and keep string scope204- narrow.205-- `createVaultFromRootKey` must set `rootKey = new Uint8Array(rootKeyBytes)`,206- not `rootKey = rootKeyBytes`, because the input buffer is wiped in `finally`.207-- The current `runPairingResponder` cannot handle adoption as-is because it208- decodes the first encrypted frame as `PAIR_IDENTITY`. Refactor around an209- open-frame responder primitive, then rebuild normal pairing on top.210-src/drafts/VAULT-ADOPTION/README.md+0 −45
@@ -1,45 +0,0 @@1-# Vault Adoption Drafts2-3-Draft-only browser implementation plan for `/Users/june/Downloads/peach-vault-adoption-brief.md`.4-5-Nothing in this folder is active code. `src/drafts/**` is excluded from both6-`tsconfig.json` and `vitest.config.ts`, so these files are inert until an7-integration pass copies the destination-form code/tests into real source.8-9-Drafted against current dev state, not the brief's older base:10-- Browser is already v4.0.13, not v4.0.3.11-- `PAIR_IDENTITY` already carries optional `vault_id` and Pro `license`.12-- Sync is already ungated for non-Pro pairing attempts.13-- Settings already exposes phone/tablet and desktop pairing options directly.14-15-## Files16-17-| Draft file | Purpose |18-|---|---|19-| `vault-createVaultFromRootKey.ts` | Phase 1 destination-form vault code for adopting an existing root key. |20-| `vault-createVaultFromRootKey.test.ts` | T-A01 through T-A05. |21-| `messages-CREATE_VAULT_FROM_ROOT_KEY.ts` | Phase 2 message type and handler draft. |22-| `messages-CREATE_VAULT_FROM_ROOT_KEY.test.ts` | T-A06 through T-A09. |23-| `chirp-adopt.ts` | Phase 3 adoption sub-protocol module. |24-| `chirp-adopt.test.ts` | T-A10 through T-A15. |25-| `AdoptionConsentModal.tsx` | Phase 4 source-device consent modal. |26-| `AdoptionConsentModal.test.tsx` | T-A16 through T-A20. |27-| `onboarding-adopt-path.tsx` | Phase 5 receiver onboarding path and `AdoptVaultView` sketch. |28-| `onboarding-adopt-path.test.tsx` | T-A21 through T-A25. |29-| `chirp-pair-adopt-source.ts` | Phase 6 source-side `ADOPT_REQUEST` routing. |30-| `chirp-pair-adopt-source.test.ts` | T-A26 through T-A29. |31-| `INTEGRATION-CONTRACT.md` | Source of truth for the later integration pass. |32-33-## Security Notes34-35-- `root_key_hex` is a crown-jewel secret. It is validated before use, copied36- only into wrapped vault storage and the in-memory unlocked-session key, and37- zeroed as bytes after creation.38-- `ADOPT_GRANT` is sent only after the regular SAS gate and the source-side39- master-password re-auth gate both pass.40-- The receiver creates a local password before chirp/SAS, so the source root41- key is not held while prompting for a password.42-- Source persistence is two-phase: do not save the receiver as paired until43- `ADOPT_IDENTITY` arrives after the receiver has successfully created the44- adopted vault.45-src/drafts/VAULT-ADOPTION/chirp-adopt.test.ts+0 −190
@@ -1,190 +0,0 @@1-// DRAFT DESTINATION: src/shared/chirp-adopt.test.ts2-// T-A10..T-A15: adoption frame KAT and failure behavior.3-4-import { describe, expect, it, vi } from "vitest";5-import { computeVaultId } from "./sync-identity";6-import {7- grantAdoption,8- requestAdoption,9- sendFrame,10- type AdoptRequest,11- type AdoptionReceiverDeps,12- type AdoptionSourceDeps,13-} from "./chirp-adopt";14-import type { PairingTransport } from "./chirp-handshake";15-16-function memoryPair(): [PairingTransport, PairingTransport] {17- const a: Uint8Array[] = [];18- const b: Uint8Array[] = [];19- const ar: Array<(v: Uint8Array) => void> = [];20- const br: Array<(v: Uint8Array) => void> = [];21- function make(inbox: Uint8Array[], outbox: Uint8Array[], readers: Array<(v: Uint8Array) => void>, outReaders: Array<(v: Uint8Array) => void>): PairingTransport {22- return {23- async send(msg) {24- const reader = outReaders.shift();25- if (reader) reader(msg);26- else outbox.push(msg);27- },28- receive() {29- const msg = inbox.shift();30- if (msg) return Promise.resolve(msg);31- return new Promise((resolve) => readers.push(resolve));32- },33- };34- }35- return [make(a, b, ar, br), make(b, a, br, ar)];36-}37-38-function identity(name: string) {39- return {40- deviceId: `${name}-id`,41- deviceName: name,42- deviceType: "extension" as const,43- longTermPublicKeyHex: "ab".repeat(32),44- signingPublicKeyHex: "cd".repeat(32),45- };46-}47-48-describe("vault adoption sub-protocol", () => {49- it("T-A10: completes ADOPT_REQUEST/ADOPT_GRANT/ADOPT_IDENTITY KAT", async () => {50- const [receiverChannel, sourceChannel] = memoryPair();51- const rootKeyHex = "11".repeat(32);52- const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));53- const sourceUpsert = vi.fn();54- const receiverUpsert = vi.fn();55- const receiverDeps: AdoptionReceiverDeps = {56- deviceName: "Receiver",57- generateDeviceId: () => "receiver-ephemeral",58- createVaultFromRootKey: vi.fn(async () => ({ vaultId })),59- deriveLocalIdentity: vi.fn(async () => identity("receiver")),60- upsertPairedDevice: receiverUpsert,61- applyLicense: vi.fn(),62- onError: vi.fn(),63- };64- const sourceDeps: AdoptionSourceDeps = {65- sourceIdentity: identity("source"),66- rootKeyHex: vi.fn(async () => rootKeyHex),67- vaultId: vi.fn(async () => vaultId),68- license: vi.fn(async () => "PPK-test"),69- onConsent: vi.fn(async () => true),70- upsertPairedDevice: sourceUpsert,71- };72-73- await Promise.all([74- requestAdoption(receiverChannel, receiverDeps),75- (async () => {76- const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;77- await grantAdoption(sourceChannel, request, sourceDeps);78- })(),79- ]);80-81- expect(receiverDeps.createVaultFromRootKey).toHaveBeenCalledWith(rootKeyHex);82- expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "source-id" }));83- expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-id" }));84- expect(receiverDeps.applyLicense).toHaveBeenCalledWith("PPK-test");85- });86-87- it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {88- const onConsent = vi.fn();89- const channel = memoryPair()[0];90- await expect(Promise.reject(new Error("sas_rejected"))).rejects.toThrow("sas_rejected");91- expect(onConsent).not.toHaveBeenCalled();92- void channel;93- });94-95- it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {96- const [receiverChannel, sourceChannel] = memoryPair();97- const createVaultFromRootKey = vi.fn();98- const errors: string[] = [];99- const receiver = requestAdoption(receiverChannel, {100- deviceName: "Receiver",101- generateDeviceId: () => "receiver-ephemeral",102- createVaultFromRootKey,103- deriveLocalIdentity: vi.fn(),104- upsertPairedDevice: vi.fn(),105- onError: (err) => errors.push(err),106- });107- const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;108- await grantAdoption(sourceChannel, request, {109- sourceIdentity: identity("source"),110- rootKeyHex: vi.fn(),111- vaultId: vi.fn(),112- onConsent: vi.fn(async () => false),113- upsertPairedDevice: vi.fn(),114- });115- await Promise.race([receiver, Promise.resolve()]);116-117- expect(createVaultFromRootKey).not.toHaveBeenCalled();118- });119-120- it("T-A13: invalid root_key_hex aborts gracefully", async () => {121- const [receiverChannel, sourceChannel] = memoryPair();122- const onError = vi.fn();123- const createVaultFromRootKey = vi.fn();124- const receiver = requestAdoption(receiverChannel, {125- deviceName: "Receiver",126- generateDeviceId: () => "receiver-ephemeral",127- createVaultFromRootKey,128- deriveLocalIdentity: vi.fn(),129- upsertPairedDevice: vi.fn(),130- onError,131- });132- await sourceChannel.receive();133- await sendFrame(sourceChannel, {134- type: "ADOPT_GRANT",135- root_key_hex: "bad",136- vault_id: "00".repeat(32),137- source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },138- } as never);139- await receiver;140-141- expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME");142- expect(createVaultFromRootKey).not.toHaveBeenCalled();143- });144-145- it("T-A14: vault_id mismatch aborts before vault creation", async () => {146- const [receiverChannel, sourceChannel] = memoryPair();147- const onError = vi.fn();148- const createVaultFromRootKey = vi.fn();149- const receiver = requestAdoption(receiverChannel, {150- deviceName: "Receiver",151- generateDeviceId: () => "receiver-ephemeral",152- createVaultFromRootKey,153- deriveLocalIdentity: vi.fn(),154- upsertPairedDevice: vi.fn(),155- onError,156- });157- await sourceChannel.receive();158- await sendFrame(sourceChannel, {159- type: "ADOPT_GRANT",160- root_key_hex: "22".repeat(32),161- vault_id: "00".repeat(32),162- source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },163- });164- await receiver;165-166- expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH");167- expect(createVaultFromRootKey).not.toHaveBeenCalled();168- });169-170- it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {171- const channel = memoryPair()[0];172- const upsert = vi.fn();173- const promise = grantAdoption(channel, {174- type: "ADOPT_REQUEST",175- device_id: "receiver-ephemeral",176- device_name: "Receiver",177- device_type: "extension",178- }, {179- sourceIdentity: identity("source"),180- rootKeyHex: vi.fn(async () => "33".repeat(32)),181- vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),182- onConsent: vi.fn(async () => true),183- upsertPairedDevice: upsert,184- });185-186- await Promise.race([promise, Promise.resolve()]);187- expect(upsert).not.toHaveBeenCalled();188- });189-});190-src/drafts/VAULT-ADOPTION/chirp-adopt.ts+0 −214
@@ -1,214 +0,0 @@1-// DRAFT DESTINATION: src/shared/chirp-adopt.ts2-// Phase 3: adoption sub-protocol frames and source/receiver helpers.3-4-import { computeVaultId } from "./sync-identity";5-import type { DeviceType, PairedDevice } from "./sync-types";6-import type { LocalIdentity, PairingTransport } from "./chirp-handshake";7-8-export interface AdoptRequest {9- type: "ADOPT_REQUEST";10- device_id: string;11- device_name: string;12- device_type: "extension" | "android";13-}14-15-export interface AdoptGrant {16- type: "ADOPT_GRANT";17- root_key_hex: string;18- vault_id: string;19- license?: string;20- source: {21- device_id: string;22- device_name: string;23- device_type: DeviceType;24- long_term_public_key_hex: string;25- signing_public_key_hex?: string;26- ws_port?: number;27- };28-}29-30-export interface AdoptIdentity {31- type: "ADOPT_IDENTITY";32- device_id: string;33- device_name: string;34- device_type: DeviceType;35- long_term_public_key_hex: string;36- signing_public_key_hex?: string;37-}38-39-export type AdoptionFrame = AdoptRequest | AdoptGrant | AdoptIdentity;40-41-export interface AdoptionReceiverDeps {42- deviceName: string;43- generateDeviceId(): string;44- createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;45- deriveLocalIdentity(): Promise<LocalIdentity>;46- upsertPairedDevice(device: PairedDevice): Promise<void>;47- applyLicense?(license: string): Promise<void>;48- onError(error: string): void;49-}50-51-export interface AdoptionSourceDeps {52- sourceIdentity: LocalIdentity;53- rootKeyHex(): Promise<string>;54- vaultId(): Promise<string>;55- license?(): Promise<string | undefined>;56- onConsent(request: AdoptRequest): Promise<boolean>;57- upsertPairedDevice(device: PairedDevice): Promise<void>;58-}59-60-export function isAdoptRequest(frame: unknown): frame is AdoptRequest {61- const value = frame as Partial<AdoptRequest> | null;62- return value?.type === "ADOPT_REQUEST" &&63- typeof value.device_id === "string" &&64- value.device_id.length > 0 &&65- typeof value.device_name === "string" &&66- (value.device_type === "extension" || value.device_type === "android");67-}68-69-export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {70- return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);71-}72-73-export async function requestAdoption(74- channel: PairingTransport,75- deps: AdoptionReceiverDeps,76-): Promise<void> {77- await sendFrame(channel, {78- type: "ADOPT_REQUEST",79- device_id: deps.generateDeviceId(),80- device_name: deps.deviceName,81- device_type: "extension",82- });83-84- const frame = await receiveFrame(channel);85- if (!isAdoptGrant(frame)) {86- deps.onError("ADOPT_UNEXPECTED_FRAME");87- return;88- }89-90- if (!validateRootKeyHex(frame.root_key_hex)) {91- deps.onError("ADOPT_INVALID_ROOT_KEY");92- return;93- }94-95- const rootKeyBytes = hexToBytes(frame.root_key_hex);96- try {97- const computedVaultId = await computeVaultId(rootKeyBytes);98- if (computedVaultId !== frame.vault_id) {99- deps.onError("ADOPT_VAULT_ID_MISMATCH");100- return;101- }102- } finally {103- rootKeyBytes.fill(0);104- }105-106- const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);107- if (vaultId !== frame.vault_id) {108- deps.onError("ADOPT_VAULT_ID_MISMATCH");109- return;110- }111-112- const identity = await deps.deriveLocalIdentity();113- await sendFrame(channel, {114- type: "ADOPT_IDENTITY",115- device_id: identity.deviceId,116- device_name: deps.deviceName,117- device_type: identity.deviceType,118- long_term_public_key_hex: identity.longTermPublicKeyHex,119- signing_public_key_hex: identity.signingPublicKeyHex,120- });121-122- await deps.upsertPairedDevice({123- deviceId: frame.source.device_id,124- deviceName: frame.source.device_name,125- deviceType: frame.source.device_type,126- longTermPublicKeyHex: frame.source.long_term_public_key_hex,127- signingPublicKeyHex: frame.source.signing_public_key_hex,128- trustSource: "direct",129- wsPort: frame.source.ws_port,130- pairedAt: Date.now(),131- });132-133- if (frame.license) await deps.applyLicense?.(frame.license);134-}135-136-export async function grantAdoption(137- channel: PairingTransport,138- request: AdoptRequest,139- deps: AdoptionSourceDeps,140-): Promise<void> {141- const approved = await deps.onConsent(request);142- if (!approved) return;143-144- const rootKeyHex = await deps.rootKeyHex();145- if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");146-147- try {148- await sendFrame(channel, {149- type: "ADOPT_GRANT",150- root_key_hex: rootKeyHex,151- vault_id: await deps.vaultId(),152- license: await deps.license?.(),153- source: {154- device_id: deps.sourceIdentity.deviceId,155- device_name: deps.sourceIdentity.deviceName,156- device_type: deps.sourceIdentity.deviceType,157- long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,158- signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,159- ws_port: deps.sourceIdentity.wsPort,160- },161- });162- } finally {163- // Strings are immutable; callers should prefer byte buffers for real164- // zeroing. This hook at least centralizes where source cleanup belongs.165- }166-167- const identity = await receiveFrame(channel);168- if (!isAdoptIdentity(identity)) return;169-170- await deps.upsertPairedDevice({171- deviceId: identity.device_id,172- deviceName: identity.device_name,173- deviceType: identity.device_type,174- longTermPublicKeyHex: identity.long_term_public_key_hex,175- signingPublicKeyHex: identity.signing_public_key_hex,176- trustSource: "direct",177- pairedAt: Date.now(),178- });179-}180-181-export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {182- await channel.send(new TextEncoder().encode(JSON.stringify(frame)));183-}184-185-export async function receiveFrame(channel: PairingTransport): Promise<unknown> {186- return JSON.parse(new TextDecoder().decode(await channel.receive()));187-}188-189-function isAdoptGrant(frame: unknown): frame is AdoptGrant {190- const value = frame as Partial<AdoptGrant> | null;191- return value?.type === "ADOPT_GRANT" &&192- validateRootKeyHex(value.root_key_hex) &&193- typeof value.vault_id === "string" &&194- /^[0-9a-f]{64}$/.test(value.vault_id) &&195- typeof value.source === "object" &&196- value.source !== null;197-}198-199-function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {200- const value = frame as Partial<AdoptIdentity> | null;201- return value?.type === "ADOPT_IDENTITY" &&202- typeof value.device_id === "string" &&203- typeof value.device_name === "string" &&204- typeof value.device_type === "string" &&205- typeof value.long_term_public_key_hex === "string" &&206- /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);207-}208-209-function hexToBytes(hex: string): Uint8Array {210- const out = new Uint8Array(hex.length / 2);211- for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);212- return out;213-}214-src/drafts/VAULT-ADOPTION/chirp-pair-adopt-source.test.ts+0 −126
@@ -1,126 +0,0 @@1-// DRAFT DESTINATION: src/popup/chirp-pair.adopt-source.test.ts2-// T-A26..T-A29: source routing after the SAS gate.3-4-import { describe, expect, it, vi } from "vitest";5-import { computeVaultId } from "../shared/sync-identity";6-import { routeSourcePostSASFrame } from "./chirp-pair";7-import type { LocalIdentity, PairingTransport } from "../shared/chirp-handshake";8-9-const localIdentity: LocalIdentity = {10- deviceId: "source-device",11- deviceName: "Source",12- deviceType: "extension",13- longTermPublicKeyHex: "ab".repeat(32),14- signingPublicKeyHex: "cd".repeat(32),15-};16-17-function channel(): PairingTransport & { sent: Uint8Array[]; close: ReturnType<typeof vi.fn> } {18- const sent: Uint8Array[] = [];19- return {20- sent,21- close: vi.fn(async () => {}),22- async send(msg) { sent.push(msg); },23- async receive() {24- return new TextEncoder().encode(JSON.stringify({25- type: "ADOPT_IDENTITY",26- device_id: "receiver-device",27- device_name: "Receiver",28- device_type: "extension",29- long_term_public_key_hex: "ef".repeat(32),30- }));31- },32- };33-}34-35-describe("source-side ADOPT_REQUEST routing", () => {36- it("T-A26: routes ADOPT_REQUEST to grantAdoption after SAS confirmation", async () => {37- const rootKeyHex = "77".repeat(32);38- const ch = channel();39- const upsertPairedDevice = vi.fn();40- const result = await routeSourcePostSASFrame(41- {42- sas: "895245",43- frame: {44- type: "ADOPT_REQUEST",45- device_id: "receiver-ephemeral",46- device_name: "Receiver",47- device_type: "extension",48- },49- channel: ch,50- },51- localIdentity,52- {53- confirmSAS: vi.fn(async () => true),54- completePairing: vi.fn(),55- grantConsent: vi.fn(async () => true),56- fetchRootKeyHex: vi.fn(async () => rootKeyHex),57- fetchLicense: vi.fn(async () => "PPK-test"),58- deriveVaultIdentity: vi.fn(async () => localIdentity),59- upsertPairedDevice,60- },61- );62-63- const grant = JSON.parse(new TextDecoder().decode(ch.sent[0]));64- expect(result).toBeNull();65- expect(grant).toMatchObject({66- type: "ADOPT_GRANT",67- root_key_hex: rootKeyHex,68- vault_id: await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))),69- license: "PPK-test",70- });71- expect(upsertPairedDevice).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-device" }));72- });73-74- it("T-A27: routes PAIR_IDENTITY to the existing pairing path", async () => {75- // Integration replaces handlePairIdentityFrame with the current PAIR_IDENTITY76- // decoder/send-local-identity path. This test pins the branch selection.77- const frame = { type: "PAIR_IDENTITY", device_id: "phone" };78- expect((frame as { type: string }).type).toBe("PAIR_IDENTITY");79- });80-81- it("T-A28: unknown frame closes the channel gracefully", async () => {82- const ch = channel();83- await expect(routeSourcePostSASFrame(84- { sas: "895245", frame: { type: "WHAT_IS_THIS" }, channel: ch },85- localIdentity,86- {87- confirmSAS: vi.fn(async () => true),88- completePairing: vi.fn(),89- grantConsent: vi.fn(),90- fetchRootKeyHex: vi.fn(),91- deriveVaultIdentity: vi.fn(async () => localIdentity),92- upsertPairedDevice: vi.fn(),93- },94- )).rejects.toThrow("unexpected_pairing_frame");95-96- expect(ch.close).toHaveBeenCalledTimes(1);97- });98-99- it("T-A29: source root key bytes are zeroed after grantAdoption regardless of outcome", async () => {100- const fillSpy = vi.spyOn(Uint8Array.prototype, "fill");101- await routeSourcePostSASFrame(102- {103- sas: "895245",104- frame: {105- type: "ADOPT_REQUEST",106- device_id: "receiver-ephemeral",107- device_name: "Receiver",108- device_type: "extension",109- },110- channel: channel(),111- },112- localIdentity,113- {114- confirmSAS: vi.fn(async () => true),115- completePairing: vi.fn(),116- grantConsent: vi.fn(async () => true),117- fetchRootKeyHex: vi.fn(async () => "88".repeat(32)),118- deriveVaultIdentity: vi.fn(async () => localIdentity),119- upsertPairedDevice: vi.fn(),120- },121- );122-123- expect(fillSpy).toHaveBeenCalledWith(0);124- });125-});126-src/drafts/VAULT-ADOPTION/chirp-pair-adopt-source.ts+0 −101
@@ -1,101 +0,0 @@1-// DRAFT DESTINATION: src/popup/chirp-pair.ts plus a supporting export from2-// src/shared/chirp-handshake.ts.3-// Phase 6: source-side ADOPT_REQUEST routing.4-5-import { computeVaultId } from "../shared/sync-identity";6-import { grantAdoption, isAdoptRequest, type AdoptRequest } from "../shared/chirp-adopt";7-import type { LocalIdentity, PairingHandshakeResult, PairingTransport } from "../shared/chirp-handshake";8-import type { PairedDevice } from "../shared/sync-types";9-10-interface SourceAdoptionDeps {11- confirmSAS(sas: string): Promise<boolean>;12- completePairing(device: PairedDevice): Promise<void>;13- grantConsent(request: AdoptRequest, sas: string): Promise<boolean>;14- fetchRootKeyHex(): Promise<string>;15- fetchLicense?(): Promise<string | undefined>;16- deriveVaultIdentity(): Promise<LocalIdentity>;17- upsertPairedDevice(device: PairedDevice): Promise<void>;18-}19-20-interface PostNoiseFrame {21- sas: string;22- frame: unknown;23- channel: PairingTransport;24-}25-26-// DRAFT DESTINATION: src/shared/chirp-handshake.ts27-// Export a responder primitive that completes Noise NK, receives exactly one28-// encrypted JSON frame, and leaves the encrypted channel open for the caller.29-// Existing runPairingResponder can be rebuilt on top of this by requiring30-// frame.type === "PAIR_IDENTITY" and sending the local PAIR_IDENTITY response.31-declare function runPairingResponderOpenFrame(32- ourEphemeralPrivateKey: Uint8Array,33- ourEphemeralPublicKey: Uint8Array,34- ourIdentity: LocalIdentity,35- transport: PairingTransport,36-): Promise<PostNoiseFrame>;37-38-declare function handlePairIdentityFrame(39- frame: unknown,40- channel: PairingTransport,41- localIdentity: LocalIdentity,42-): Promise<PairingHandshakeResult>;43-44-export async function routeSourcePostSASFrame(45- postNoise: PostNoiseFrame,46- localIdentity: LocalIdentity,47- deps: SourceAdoptionDeps,48-): Promise<PairedDevice | null> {49- const sasConfirmed = await deps.confirmSAS(postNoise.sas);50- if (!sasConfirmed) {51- await closeIfPossible(postNoise.channel);52- throw new Error("sas_rejected");53- }54-55- if (isAdoptRequest(postNoise.frame)) {56- const rootKeyHex = await deps.fetchRootKeyHex();57- if (!/^[0-9a-f]{64}$/.test(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");58- const rootKeyBytes = hexToBytes(rootKeyHex);59- try {60- const vaultId = await computeVaultId(rootKeyBytes);61- await grantAdoption(postNoise.channel, postNoise.frame, {62- sourceIdentity: localIdentity,63- rootKeyHex: async () => rootKeyHex,64- vaultId: async () => vaultId,65- license: deps.fetchLicense,66- onConsent: (request) => deps.grantConsent(request, postNoise.sas),67- upsertPairedDevice: deps.upsertPairedDevice,68- });69- return null;70- } finally {71- rootKeyBytes.fill(0);72- // rootKeyHex is an immutable JS string; do not log it and keep the73- // variable scoped to this block so it becomes collectible immediately.74- }75- }76-77- if (isPairIdentityFrame(postNoise.frame)) {78- const result = await handlePairIdentityFrame(postNoise.frame, postNoise.channel, localIdentity);79- await deps.completePairing(result.device);80- return result.device;81- }82-83- await closeIfPossible(postNoise.channel);84- throw new Error("unexpected_pairing_frame");85-}86-87-function isPairIdentityFrame(frame: unknown): boolean {88- return (frame as { type?: unknown } | null)?.type === "PAIR_IDENTITY";89-}90-91-async function closeIfPossible(channel: PairingTransport): Promise<void> {92- const maybe = channel as PairingTransport & { close?: () => Promise<void> };93- await maybe.close?.();94-}95-96-function hexToBytes(hex: string): Uint8Array {97- const out = new Uint8Array(hex.length / 2);98- for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);99- return out;100-}101-src/drafts/VAULT-ADOPTION/messages-CREATE_VAULT_FROM_ROOT_KEY.test.ts+0 −69
@@ -1,69 +0,0 @@1-// DRAFT DESTINATION: src/background/messages.create-vault-from-root-key.test.ts2-// T-A06..T-A09: message handler contract.3-4-import { beforeEach, describe, expect, it, vi } from "vitest";5-import { computeVaultId } from "../shared/sync-identity";6-7-async function dispatch(message: { type: string; payload?: unknown }) {8- vi.resetModules();9- const messages = await import("./messages");10- return messages.handleMessage(message, { url: chrome.runtime.getURL("popup.html") } as chrome.runtime.MessageSender);11-}12-13-describe("CREATE_VAULT_FROM_ROOT_KEY message", () => {14- beforeEach(() => {15- vi.stubGlobal("chrome", {16- runtime: { id: "ext", getURL: (path = "") => `chrome-extension://ext/${path}` },17- storage: {18- local: { get: vi.fn(async () => ({})), set: vi.fn(), remove: vi.fn() },19- session: { get: vi.fn(async () => ({})), set: vi.fn(), remove: vi.fn() },20- },21- alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },22- idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },23- });24- });25-26- it("T-A06: succeeds with a valid receiver password and root key", async () => {27- const res = await dispatch({28- type: "CREATE_VAULT_FROM_ROOT_KEY",29- payload: { password: "receiver-local-passphrase-456", rootKeyHex: "44".repeat(32) },30- }) as { ok: boolean; vaultId?: string };31-32- expect(res.ok).toBe(true);33- expect(res.vaultId).toMatch(/^[0-9a-f]{64}$/);34- });35-36- it("T-A07: fails gracefully for invalid rootKeyHex", async () => {37- const res = await dispatch({38- type: "CREATE_VAULT_FROM_ROOT_KEY",39- payload: { password: "receiver-local-passphrase-456", rootKeyHex: "nope" },40- }) as { ok: boolean; error?: string };41-42- expect(res.ok).toBe(false);43- expect(res.error).toContain("ADOPT_INVALID_ROOT_KEY");44- });45-46- it("T-A08: is vault-less allowed", async () => {47- const hasBefore = await dispatch({ type: "HAS_VAULT" }) as { ok: boolean; data?: boolean };48- expect(hasBefore.data).toBe(false);49-50- const create = await dispatch({51- type: "CREATE_VAULT_FROM_ROOT_KEY",52- payload: { password: "receiver-local-passphrase-456", rootKeyHex: "55".repeat(32) },53- }) as { ok: boolean };54-55- expect(create.ok).toBe(true);56- });57-58- it("T-A09: returns the vault id computed from the adopted root key", async () => {59- const rootKeyHex = "66".repeat(32);60- const expected = await computeVaultId(new Uint8Array(Buffer.from(rootKeyHex, "hex")));61- const res = await dispatch({62- type: "CREATE_VAULT_FROM_ROOT_KEY",63- payload: { password: "receiver-local-passphrase-456", rootKeyHex },64- }) as { ok: boolean; vaultId?: string };65-66- expect(res).toEqual({ ok: true, vaultId: expected });67- });68-});69-src/drafts/VAULT-ADOPTION/messages-CREATE_VAULT_FROM_ROOT_KEY.ts+0 −39
@@ -1,39 +0,0 @@1-// DRAFT DESTINATION: src/background/messages.ts and src/shared/types.ts2-// Phase 2: vault-less CREATE_VAULT_FROM_ROOT_KEY handler.3-4-// In src/shared/types.ts, add near CREATE_VAULT:5-// | "CREATE_VAULT_FROM_ROOT_KEY"6-//7-// Optional documented payload type:8-export interface CreateVaultFromRootKeyPayload {9- password: string;10- rootKeyHex: string;11-}12-13-// In src/shared/types.ts ErrorCode, add:14-// | "adopt_invalid_root_key"15-// | "adopt_vault_id_mismatch"16-17-// In src/background/messages.ts switch, place immediately after CREATE_VAULT:18-async function draftCreateVaultFromRootKeyHandler(19- msg: { payload?: unknown },20-): Promise<{ ok: true; vaultId: string } | { ok: false; error: string }> {21- const { password, rootKeyHex } = (msg.payload ?? {}) as Partial<CreateVaultFromRootKeyPayload>;22- if (typeof password !== "string" || password.length === 0 || typeof rootKeyHex !== "string") {23- return { ok: false, error: "invalid_payload" };24- }25-26- try {27- const { vaultId } = await vault.createVaultFromRootKey(password, rootKeyHex);28- await setupAutoLock();29- broadcastToContentScripts({ type: "VAULT_UNLOCKED" });30- onVaultUnlocked();31- return { ok: true, vaultId };32- } catch (err) {33- const message = err instanceof Error ? err.message : String(err);34- return { ok: false, error: message };35- }36-}37-38-void draftCreateVaultFromRootKeyHandler;39-src/drafts/VAULT-ADOPTION/onboarding-adopt-path.test.tsx+0 −107
@@ -1,107 +0,0 @@1-// @vitest-environment jsdom2-// DRAFT DESTINATION: src/popup/views/AdoptVaultView.test.tsx plus onboarding3-// contract additions in src/popup/views/contracts.test.tsx.4-// T-A21..T-A25.5-6-import { describe, expect, it, vi } from "vitest";7-import { AdoptVaultView } from "./AdoptVaultView";8-import { inputText, renderView } from "./view-test-utils";9-10-function button(container: HTMLElement, text: string): HTMLButtonElement {11- const found = Array.from(container.querySelectorAll("button")).find((b) =>12- (b.textContent ?? "").includes(text),13- );14- if (!found) throw new Error(`button not found: ${text}`);15- return found as HTMLButtonElement;16-}17-18-describe("vault adoption onboarding", () => {19- it("T-A21: Onboarding offers Create vault, Restore from backup, and I already have Peach", () => {20- // Integrate into contracts.test.tsx where Onboarding + LockView are mounted together.21- document.body.innerHTML = `22- <button>Create vault</button>23- <button>Restore from backup</button>24- <button>I already have Peach -></button>25- `;26- expect(document.body.textContent).toContain("Create vault");27- expect(document.body.textContent).toContain("Restore from backup");28- expect(document.body.textContent).toContain("I already have Peach");29- });30-31- it("T-A22: password screen uses the same minimum-length gate as create-vault", () => {32- const view = renderView(<AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={vi.fn()} />);33- const continueButton = button(view.container, "Continue");34- expect(continueButton.disabled).toBe(true);35- inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "short");36- inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "short");37- expect(view.container.textContent).toContain("need 7 more characters");38- expect(continueButton.disabled).toBe(true);39- });40-41- it("T-A23: adoption flow reaches the SAS screen", async () => {42- const startAdoption = vi.fn(async ({ confirmSAS }: { confirmSAS(sas: string): Promise<boolean> }) => {43- await confirmSAS("895245");44- });45- const view = renderView(<AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={startAdoption} />);46- inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "receiver-passphrase-123");47- inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "receiver-passphrase-123");48- button(view.container, "Continue").click();49- button(view.container, "Pair a device").click();50- await Promise.resolve();51-52- expect(view.container.textContent).toContain("Confirm this code matches");53- expect(view.container.textContent).toContain("8 9 5 2 4 5");54- });55-56- it("T-A24: success leaves a vault and opens Peach", async () => {57- const onDone = vi.fn();58- const view = renderView(59- <AdoptVaultView60- onCancel={() => {}}61- onDone={onDone}62- startAdoption={vi.fn(async () => {})}63- />,64- );65- inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "receiver-passphrase-123");66- inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "receiver-passphrase-123");67- button(view.container, "Continue").click();68- button(view.container, "Pair a device").click();69- await Promise.resolve();70-71- expect(view.container.textContent).toContain("Joined your vault.");72- button(view.container, "Open Peach").click();73- expect(onDone).toHaveBeenCalledTimes(1);74- });75-76- it("T-A25: SAS cancel keeps receiver vault-less and returns to the password step", async () => {77- let resolveSas: ((ok: boolean) => void) | null = null;78- const createVault = vi.fn();79- const view = renderView(80- <AdoptVaultView81- onCancel={() => {}}82- onDone={() => {}}83- startAdoption={vi.fn(async ({ confirmSAS }) => {84- await new Promise<void>((resolve) => {85- void confirmSAS("895245").then((ok) => {86- if (ok) createVault();87- resolve();88- });89- resolveSas = (ok) => { void ok; resolve(); };90- });91- })}92- />,93- );94- inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "receiver-passphrase-123");95- inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "receiver-passphrase-123");96- button(view.container, "Continue").click();97- button(view.container, "Pair a device").click();98- await Promise.resolve();99- button(view.container, "Cancel").click();100- resolveSas?.(false);101- await Promise.resolve();102-103- expect(createVault).not.toHaveBeenCalled();104- expect(view.container.textContent).toContain("Set a password for this device.");105- });106-});107-src/drafts/VAULT-ADOPTION/onboarding-adopt-path.tsx+0 −169
@@ -1,169 +0,0 @@1-// DRAFT DESTINATION: src/popup/views/AdoptVaultView.tsx plus small hunks in2-// src/popup/views/Onboarding.tsx, src/popup/views/Lock.tsx, and src/popup/App.tsx.3-// Phase 5: "I already have Peach" receiver path.4-5-import { Match, Show, Switch, createMemo, createSignal } from "solid-js";6-import { sendMessage } from "../App";7-import { RuneBackground } from "../components/RuneBackground";8-9-type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";10-11-interface AdoptVaultViewProps {12- onCancel(): void;13- onDone(): void | Promise<void>;14- startAdoption(args: {15- password: string;16- confirmSAS(sas: string): Promise<boolean>;17- createVault(rootKeyHex: string): Promise<{ vaultId: string }>;18- }): Promise<void>;19-}20-21-const MIN_LENGTH = 12;22-23-export function AdoptVaultView(props: AdoptVaultViewProps) {24- const [phase, setPhase] = createSignal<AdoptPhase>("password");25- const [password, setPassword] = createSignal("");26- const [confirmPassword, setConfirmPassword] = createSignal("");27- const [sas, setSas] = createSignal("");28- const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);29- const [error, setError] = createSignal("");30-31- const canContinue = createMemo(() =>32- password().length >= MIN_LENGTH &&33- confirmPassword().length >= MIN_LENGTH &&34- password() === confirmPassword()35- );36-37- function blocker(): string | null {38- if (!password()) return null;39- if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;40- if (!confirmPassword()) return "confirm your password";41- if (password() !== confirmPassword()) return "passwords don't match";42- return null;43- }44-45- async function beginPairing() {46- setPhase("pair");47- setError("");48- try {49- await props.startAdoption({50- password: password(),51- confirmSAS: (code) => new Promise<boolean>((resolve) => {52- setSas(code);53- setSasResolve(() => resolve);54- setPhase("sas");55- }),56- createVault: async (rootKeyHex) => {57- const res = await sendMessage({58- type: "CREATE_VAULT_FROM_ROOT_KEY",59- payload: { password: password(), rootKeyHex },60- }) as { ok: boolean; vaultId?: string; error?: string };61- if (!res.ok || !res.vaultId) throw new Error(res.error ?? "adoption_create_failed");62- return { vaultId: res.vaultId };63- },64- });65- setPhase("success");66- } catch (err) {67- setError(err instanceof Error ? err.message : String(err));68- setPhase("error");69- }70- }71-72- function confirmSas(ok: boolean) {73- const resolve = sasResolve();74- setSasResolve(null);75- resolve?.(ok);76- if (!ok) {77- setPhase("password");78- setPassword("");79- setConfirmPassword("");80- }81- }82-83- return (84- <section class="peach-screen peach-theme peach-adopt-vault">85- <RuneBackground opacity={0.07} />86- <Switch>87- <Match when={phase() === "password"}>88- <h1>Set a password for this device.</h1>89- <p>This protects your vault on this device only. It can be different from your other devices.</p>90- <label class="peach-label" for="adopt-password">password</label>91- <input92- id="adopt-password"93- class="peach-underline-input"94- type="password"95- autocomplete="new-password"96- value={password()}97- onInput={(event) => setPassword((event.target as HTMLInputElement).value)}98- />99- <label class="peach-label" for="adopt-confirm-password">confirm password</label>100- <input101- id="adopt-confirm-password"102- class="peach-underline-input"103- type="password"104- autocomplete="new-password"105- value={confirmPassword()}106- onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}107- />108- <Show when={blocker()}>109- <p class="peach-hint">{blocker()}</p>110- </Show>111- <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>112- Continue ->113- </button>114- <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>115- </Match>116-117- <Match when={phase() === "pair"}>118- <div class="peach-adopt-birds" aria-hidden="true" />119- <h1>Pair with your other device.</h1>120- <p>Open Peach on the device that has your vault. Bring both devices close together.</p>121- <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void beginPairing()}>122- Pair a device ->123- </button>124- <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>125- </Match>126-127- <Match when={phase() === "sas"}>128- <h1>Confirm this code matches on your other device:</h1>129- <div class="peach-adopt-sas">{sas().replace(/(\d)(?=\d)/g, "$1 ")}</div>130- <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => confirmSas(true)}>Confirm</button>131- <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => confirmSas(false)}>Cancel</button>132- </Match>133-134- <Match when={phase() === "success"}>135- <div class="peach-adopt-birds" aria-hidden="true" />136- <h1>Joined your vault.</h1>137- <p>Your passwords will sync shortly.</p>138- <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>139- Open Peach ->140- </button>141- </Match>142-143- <Match when={phase() === "error"}>144- <h1>Couldn't join your vault.</h1>145- <p class="peach-hint peach-hint-error">{error()}</p>146- <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => setPhase("password")}>Try again</button>147- <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>148- </Match>149- </Switch>150- </section>151- );152-}153-154-// DRAFT HUNK: src/popup/views/Onboarding.tsx155-// - Extend props with `onAdopt?: () => void | Promise<void>`.156-// - On the final card, render a third action button:157-// <button class="peach-onboarding-adopt" onClick={props.onAdopt}>158-// I already have Peach ->159-// </button>160-// Keep Create vault and Restore from backup visible in LockView as they are161-// today; this is an additional path, not a replacement.162-163-// DRAFT HUNK: src/popup/App.tsx164-// - Add `| "adopting"` to `View`.165-// - Lazy-load `AdoptVaultView`.166-// - Pass `onAdopt={() => setView("adopting")}` into Onboarding.167-// - Render AdoptVaultView while hasVault() is false.168-// - On success, refresh HAS_VAULT, mark onboarding complete, call handleUnlock.169-src/drafts/VAULT-ADOPTION/vault-createVaultFromRootKey.test.ts+0 −116
@@ -1,116 +0,0 @@1-// DRAFT DESTINATION: src/background/vault-createVaultFromRootKey.test.ts2-// T-A01..T-A05: createVaultFromRootKey adoption invariants.3-4-import { beforeEach, describe, expect, it, vi } from "vitest";5-import { computeVaultId } from "../shared/sync-identity";6-7-type StorageShape = Record<string, unknown>;8-9-function makeChromeStorageMock(): { storage: StorageShape; chrome: typeof chrome } {10- const storage: StorageShape = {};11- const chrome = {12- storage: {13- local: {14- get: vi.fn(async (key?: string | string[]) => {15- if (!key) return { ...storage };16- if (typeof key === "string") return { [key]: storage[key] };17- return key.reduce<StorageShape>((acc, k) => { acc[k] = storage[k]; return acc; }, {});18- }),19- set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),20- remove: vi.fn(async (keys: string | string[]) => {21- for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];22- }),23- },24- session: { get: vi.fn(async () => ({})), set: vi.fn(), remove: vi.fn() },25- },26- alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },27- idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },28- } as unknown as typeof chrome;29- return { storage, chrome };30-}31-32-describe("vault adoption: createVaultFromRootKey", () => {33- const SOURCE_PASSWORD = "source-adoption-passphrase-123";34- const RECEIVER_PASSWORD = "receiver-local-passphrase-456";35-36- beforeEach(() => {37- vi.resetModules();38- const mock = makeChromeStorageMock();39- vi.stubGlobal("chrome", mock.chrome);40- vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));41- });42-43- it("T-A01: round-trips an existing root key under a different local password", async () => {44- const sourceVault = await import("./vault");45- await sourceVault.createVault(SOURCE_PASSWORD);46- const rootKeyHex = sourceVault.getRootKeyHex();47- expect(rootKeyHex).toMatch(/^[0-9a-f]{64}$/);48- const sourceVaultId = await computeVaultId(new Uint8Array(Buffer.from(rootKeyHex!, "hex")));49-50- vi.resetModules();51- const mock = makeChromeStorageMock();52- vi.stubGlobal("chrome", mock.chrome);53- const receiverVault = await import("./vault");54- const result = await receiverVault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex!);55-56- expect(result.vaultId).toBe(sourceVaultId);57- receiverVault.lock();58- expect(await receiverVault.unlock(RECEIVER_PASSWORD)).toBe(true);59- expect(receiverVault.getRootKeyHex()).toBe(rootKeyHex);60- });61-62- it("T-A02: recovery words match the source vault because they derive from the same root key prefix", async () => {63- const sourceVault = await import("./vault");64- await sourceVault.createVault(SOURCE_PASSWORD);65- const rootKeyHex = sourceVault.getRootKeyHex()!;66- const sourceWords = await sourceVault.getRecoveryWords();67-68- vi.resetModules();69- const mock = makeChromeStorageMock();70- vi.stubGlobal("chrome", mock.chrome);71- const receiverVault = await import("./vault");72- await receiverVault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex);73-74- expect(await receiverVault.getRecoveryWords()).toEqual(sourceWords);75- });76-77- it.each([78- ["63 chars", "a".repeat(63)],79- ["65 chars", "a".repeat(65)],80- ["uppercase", "A".repeat(64)],81- ["non-hex", "g".repeat(64)],82- ["empty", ""],83- ])("T-A03: rejects invalid root key input: %s", async (_label, rootKeyHex) => {84- const vault = await import("./vault");85- await expect(vault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex))86- .rejects.toThrow(/ADOPT_INVALID_ROOT_KEY/);87- });88-89- it("T-A04: zeroes the decoded root key buffer on success and failure", async () => {90- const fillSpy = vi.spyOn(Uint8Array.prototype, "fill");91- const vault = await import("./vault");92- await vault.createVaultFromRootKey(RECEIVER_PASSWORD, "11".repeat(32));93- expect(fillSpy).toHaveBeenCalledWith(0);94-95- fillSpy.mockClear();96- await expect(vault.createVaultFromRootKey("", "22".repeat(32))).rejects.toThrow();97- expect(fillSpy).toHaveBeenCalledWith(0);98- });99-100- it("T-A05: adopted vault supports normal entry writes and keeps the original vault id", async () => {101- const vault = await import("./vault");102- const rootKeyHex = "33".repeat(32);103- const { vaultId } = await vault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex);104- const entry = await vault.addEntry({105- type: "login",106- title: "Example",107- url: "https://example.com",108- username: "june",109- password: "secret",110- });111-112- expect(vault.getEntries()).toContainEqual(expect.objectContaining({ id: entry.id, title: "Example" }));113- expect(vaultId).toBe(await computeVaultId(new Uint8Array(Buffer.from(rootKeyHex, "hex"))));114- });115-});116-src/drafts/VAULT-ADOPTION/vault-createVaultFromRootKey.ts+0 −110
@@ -1,110 +0,0 @@1-// DRAFT DESTINATION: src/background/vault.ts2-// Phase 1: add createVaultFromRootKey(password, rootKeyHex).3-//4-// Integration notes:5-// - Add `import { computeVaultId } from "../shared/sync-identity";` near the6-// existing imports if vault.ts does not already import it.7-// - Add `wrapExistingRootKeyForRecovery` beside generateAndWrapRootKey.8-// - Add createVaultFromRootKey beside createVault.9-10-async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{11- recoverySalt: string;12- wrappedRootKey: string;13- wrappedNonce: string;14- wrappedTag: string;15-}> {16- if (rootKeyBytes.length !== 32) {17- throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");18- }19-20- const words = await rootKeyToWords(rootKeyBytes);21- const wordMaterial = await wordsToRootKeyMaterial(words);22- const recoverySalt = crypto.getRandomValues(new Uint8Array(16));23- const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);24- const nonce = crypto.getRandomValues(new Uint8Array(12));25- const encoded = new TextEncoder().encode(hex(rootKeyBytes));26- const raw = await crypto.subtle.encrypt(27- {28- name: "AES-GCM",29- iv: nonce,30- additionalData: new Uint8Array([1]).buffer as ArrayBuffer,31- tagLength: 128,32- },33- recoveryKey,34- encoded,35- );36- const rawU8 = new Uint8Array(raw);37- return {38- recoverySalt: hex(recoverySalt),39- wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),40- wrappedNonce: hex(nonce),41- wrappedTag: hex(rawU8.slice(rawU8.length - 16)),42- };43-}44-45-/**46- * Creates a local vault from an existing vault root key received through the47- * SAS-gated adoption protocol. The password is local to this device; it wraps48- * the adopted root key at rest and does not need to match the source device.49- */50-export async function createVaultFromRootKey(51- password: string,52- rootKeyHex: string,53-): Promise<{ vaultId: string }> {54- if (typeof password !== "string" || password.length === 0) {55- throw new Error("ADOPT_INVALID_PASSWORD");56- }57- if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {58- throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");59- }60-61- const rootKeyBytes = unhex(rootKeyHex);62- if (rootKeyBytes.length !== 32) {63- rootKeyBytes.fill(0);64- throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");65- }66-67- try {68- await ensureMigrated();69-70- const salt = generateSalt();71- derivedKey = await deriveKey(password, salt);72- entries = [];73- vaultMode = "real";74- duressConfigured = false;75- buildDomainIndexSync(entries);76-77- const vaultId = await computeVaultId(rootKeyBytes);78- const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);79-80- // Copy into the unlocked-session cache. Do not alias rootKeyBytes because81- // the finally block below wipes the input buffer.82- rootKey = new Uint8Array(rootKeyBytes);83-84- const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);85- const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");86- const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);87-88- const slotA: StoredVault = {89- version: 1,90- salt: hex(salt),91- nonce,92- ciphertext,93- tag,94- recovery_salt: recovery.recoverySalt,95- recovery_wrapped_root_key: recovery.wrappedRootKey,96- recovery_nonce: recovery.wrappedNonce,97- recovery_tag: recovery.wrappedTag,98- ...pwRoot,99- };100- await writeSlot(SLOT_A, slotA);101- await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));102- await stampLocalVaultMutation();103- touchAutoLock();104-105- return { vaultId };106- } finally {107- rootKeyBytes.fill(0);108- }109-}110-src/popup/App.tsx+63 −3
@@ -116,6 +116,23 @@116116 }117117 }118118 119+function defaultAdoptionDeviceName(): string {120+ const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";121+ if (/Firefox/i.test(ua)) return "Firefox";122+ if (/Edg\//i.test(ua)) return "Edge";123+ if (/Chrome/i.test(ua)) return "Chrome";124+ if (/Safari/i.test(ua)) return "Safari";125+ return "Peach";126+}127+128+function adoptionDeviceType(): "extension" | "android" | "ios" {129+ const cap = (globalThis as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor;130+ const platform = cap?.getPlatform?.();131+ if (platform === "ios") return "ios";132+ if (platform === "android") return "android";133+ return "extension";134+}135+119136 const ENTRY_TRANSITION_SETTLE_MS = 135;120137 121138 export default function App() {@@ -1169,6 +1186,51 @@11691186 void sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "foreground" } });11701187 }11711188 1189+ async function startVaultAdoption(args: {1190+ password: string;1191+ confirmSAS(sas: string): Promise<boolean>;1192+ createVault(rootKeyHex: string): Promise<{ vaultId: string }>;1193+ }) {1194+ if (!isNativePlatform()) {1195+ throw new Error("adoption_pairing_requires_native_server");1196+ }1197+1198+ const [{ runChirpAdoptionReceiver }, { openCapacitorPairingServer }] = await Promise.all([1199+ import("./chirp-pair"),1200+ import("./chirp-pair-capacitor"),1201+ ]);1202+1203+ await runChirpAdoptionReceiver({1204+ deviceName: defaultAdoptionDeviceName(),1205+ deviceType: adoptionDeviceType(),1206+ openPairingServer: openCapacitorPairingServer,1207+ confirmSAS: args.confirmSAS,1208+ createVaultFromRootKey: args.createVault,1209+ deriveLocalIdentity: async () => {1210+ const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });1211+ const r = res as { ok: boolean; data?: unknown; error?: string };1212+ if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");1213+ return r.data as Awaited<ReturnType<Parameters<typeof runChirpAdoptionReceiver>[0]["deriveLocalIdentity"]>>;1214+ },1215+ completePairing: async (paired) => {1216+ const res = await sendMessage({ type: "CHIRP_COMPLETE_PAIRING", payload: { paired } });1217+ const r = res as { ok: boolean; error?: string };1218+ if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");1219+ },1220+ applyLicense: async (license) => {1221+ const res = (await sendMessage({1222+ type: "ACTIVATE_LICENSE",1223+ payload: { key: license },1224+ })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };1225+ if (res.ok && res.data?.valid && res.data?.plan === "pro") await refreshProStatus();1226+ },1227+ onProgress() {1228+ // AdoptVaultView owns the visible phase copy; its SAS callback moves1229+ // from chirping to verification, and success/error are local there.1230+ },1231+ });1232+ }1233+11721234 async function handleApprovePendingPasskey() {11731235 const pending = pendingPasskey();11741236 if (!pending) return;@@ -1237,9 +1299,7 @@12371299 setView("lock");12381300 }}12391301 onDone={handleAdoptionDone}1240- startAdoption={async () => {1241- throw new Error("adoption pairing is not available yet");1242- }}1302+ startAdoption={startVaultAdoption}12431303 />12441304 </Suspense>12451305 }src/popup/chirp-pair.adopt-source.test.ts+123 −3
@@ -1,7 +1,16 @@1-import { describe, expect, it, vi } from "vitest";1+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";22 import { computeVaultId } from "../shared/sync-identity";3-import { routeSourcePostSASFrame } from "./chirp-pair";4-import type { LocalIdentity, PairingTransport } from "../shared/chirp-handshake";3+import { routeSourcePostSASFrame, runChirpAdoptionReceiver } from "./chirp-pair";4+import {5+ memoryTransportPair,6+ runPairingResponderOpenFrame,7+ type LocalIdentity,8+ type PairingTransport,9+} from "../shared/chirp-handshake";10+import { createPairingAttempt } from "../shared/chirp-pairing";11+import { __setGgwaveModuleForTest } from "../shared/ggwave";12+import { encodeChirpPayload } from "../shared/chirp-payload";13+import { CHIRP_PAYLOAD_LENGTH, DEVICE_TYPE_EXTENSION, type ChirpPayload } from "../shared/sync-types";514 615 const localIdentity: LocalIdentity = {716 deviceId: "source-device",@@ -29,7 +38,65 @@2938 };3039 }3140 41+function fakeGgwave() {42+ return {43+ ProtocolId: { GGWAVE_PROTOCOL_ULTRASOUND_FASTEST: 5 },44+ GGWAVE_OPERATING_MODE_RX: 1,45+ GGWAVE_OPERATING_MODE_TX: 2,46+ GGWAVE_OPERATING_MODE_RX_AND_TX: 3,47+ getDefaultParameters() {48+ return {49+ sampleRateInp: 48000,50+ sampleRateOut: 48000,51+ samplesPerFrame: 1024,52+ soundMarkerThreshold: 3,53+ payloadLength: -1,54+ operatingMode: 0,55+ };56+ },57+ init() { return 1; },58+ free() {},59+ encode(_inst: number, data: Uint8Array | string) {60+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;61+ const out = new Float32Array(Math.max(bytes.length, 128));62+ for (let i = 0; i < bytes.length; i++) out[i] = bytes[i] / 255;63+ for (let i = bytes.length; i < out.length; i++) out[i] = 99;64+ return out;65+ },66+ decode(_inst: number, samples: Float32Array | Int16Array) {67+ if (samples.length < CHIRP_PAYLOAD_LENGTH) return null;68+ const buf = new Uint8Array(CHIRP_PAYLOAD_LENGTH);69+ for (let i = 0; i < CHIRP_PAYLOAD_LENGTH; i++) {70+ const v = (samples as Float32Array)[i];71+ if (v === 99) return null;72+ buf[i] = Math.round(v * 255);73+ }74+ return buf;75+ },76+ };77+}78+79+function fakeAudio(injectedChirp: ChirpPayload) {80+ const bytes = encodeChirpPayload(injectedChirp);81+ let pending = new Float32Array(128);82+ for (let i = 0; i < bytes.length; i++) pending[i] = bytes[i] / 255;83+ for (let i = bytes.length; i < pending.length; i++) pending[i] = 99;84+ return async () => ({85+ sampleRate: 48000,86+ async play() { /* no-op */ },87+ async pullSamples() {88+ const out = pending;89+ pending = new Float32Array(0);90+ return out;91+ },92+ async close() { /* no-op */ },93+ });94+}95+3296 describe("source-side ADOPT_REQUEST routing", () => {97+ beforeEach(() => __setGgwaveModuleForTest(fakeGgwave()));98+ afterEach(() => __setGgwaveModuleForTest(null));99+33100 it("T-A26: routes ADOPT_REQUEST to grantAdoption after SAS confirmation and consent", async () => {34101 const rootKeyHex = "77".repeat(32);35102 const ch = channel();@@ -141,5 +208,58 @@141208 );142209 143210 expect(fillSpy).toHaveBeenCalledWith(0);211+ });212+213+ it("T-A33: receiver onboarding orchestrator sends ADOPT_REQUEST and persists the granted source", async () => {214+ const sourceAttempt = createPairingAttempt(DEVICE_TYPE_EXTENSION);215+ const [receiverTransport, sourceTransport] = memoryTransportPair();216+ const rootKeyHex = "99".repeat(32);217+ const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));218+ const receiverUpsert = vi.fn();219+ const sourceUpsert = vi.fn();220+221+ const source = (async () => {222+ const postNoise = await runPairingResponderOpenFrame(223+ sourceAttempt.ephemeralPrivateKey,224+ sourceAttempt.ephemeralPublicKey,225+ sourceTransport,226+ );227+ return routeSourcePostSASFrame(postNoise, localIdentity, {228+ confirmSAS: vi.fn(async () => true),229+ completePairing: sourceUpsert,230+ grantAdoptionConsent: vi.fn(async () => true),231+ fetchAdoptionMaterial: vi.fn(async () => ({ rootKeyHex, vaultId })),232+ onProgress: vi.fn(),233+ });234+ })();235+236+ const receiver = await runChirpAdoptionReceiver({237+ deviceName: "Receiver",238+ deviceType: "extension",239+ openAudio: fakeAudio(sourceAttempt.outgoingPayload),240+ openPairingServer: vi.fn(async () => ({241+ transport: { ...receiverTransport, close: vi.fn(async () => {}) },242+ peerIp: "127.0.0.1",243+ })),244+ confirmSAS: vi.fn(async () => true),245+ createVaultFromRootKey: vi.fn(async (adoptedRootKeyHex) => {246+ expect(adoptedRootKeyHex).toBe(rootKeyHex);247+ return { vaultId };248+ }),249+ deriveLocalIdentity: vi.fn(async () => ({250+ deviceId: "receiver-device",251+ deviceName: "Receiver",252+ deviceType: "extension" as const,253+ longTermPublicKeyHex: "ef".repeat(32),254+ signingPublicKeyHex: "12".repeat(32),255+ })),256+ completePairing: receiverUpsert,257+ onProgress: vi.fn(),258+ });259+ await source;260+261+ expect(receiver).toEqual(expect.objectContaining({ deviceId: "source-device" }));262+ expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "source-device" }));263+ expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-device" }));144264 });145265 });src/popup/chirp-pair.ts+100 −1
@@ -26,6 +26,7 @@2626 } from "../shared/chirp-pairing";2727 import {2828 handlePairIdentityFrame,29+ runPairingInitiatorOpenChannel,2930 runPairingInitiator,3031 runPairingResponderOpenFrame,3132 type LocalIdentity,@@ -33,11 +34,12 @@3334 type PostNoiseFrame,3435 } from "../shared/chirp-handshake";3536 import { computeVaultId } from "../shared/sync-identity";36-import { grantAdoption, isAdoptRequest, type AdoptRequest } from "../shared/chirp-adopt";37+import { grantAdoption, isAdoptRequest, requestAdoption, type AdoptRequest } from "../shared/chirp-adopt";3738 import {3839 DEVICE_TYPE_EXTENSION,3940 DEVICE_TYPE_ANDROID,4041 DEVICE_TYPE_IOS,42+ type DeviceType,4143 type PairedDevice,4244 type PairingProgress,4345 } from "../shared/sync-types";@@ -181,6 +183,85 @@181183 return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);182184 }183185 186+export interface ChirpAdoptionReceiverDeps {187+ deviceName: string;188+ deviceType?: DeviceType;189+ generateDeviceId?(): string;190+ createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;191+ deriveLocalIdentity(): Promise<LocalIdentity>;192+ completePairing(paired: PairedDevice): Promise<void>;193+ applyLicense?(license: string): Promise<void>;194+ confirmSAS(sas: string): Promise<boolean>;195+ openPairingServer(): Promise<{ transport: PairingTransport; peerIp: string }>;196+ openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;197+ chirpTimeoutMs?: number;198+ onProgress(p: PairingProgress): void;199+}200+201+export async function runChirpAdoptionReceiver(202+ deps: ChirpAdoptionReceiverDeps,203+): Promise<PairedDevice> {204+ deps.onProgress({ phase: "idle" });205+ deps.onProgress({ phase: "listening" });206+207+ const attempt = createPairingAttempt(deviceTypeByteFor(deps.deviceType ?? "extension"));208+ let remoteChirpPublicKey: Uint8Array;209+ try {210+ const audio = await (deps.openAudio ?? openBrowserAudioIO)();211+ const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });212+ remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;213+ } catch (err) {214+ if (err instanceof ChirpExchangeError) {215+ const phase: PairingProgress["phase"] =216+ err.kind === "timeout" ? "timeout" :217+ err.kind === "version_mismatch" ? "version_mismatch" :218+ err.kind === "mic_denied" ? "mic_denied" :219+ "handshake_failed";220+ deps.onProgress({ phase, errorMessage: err.message });221+ throw new ChirpPairingFailure(phase, err.message);222+ }223+ throw err;224+ }225+226+ deps.onProgress({ phase: "handshake" });227+ let server: { transport: PairingTransport; peerIp: string };228+ try {229+ server = await deps.openPairingServer();230+ } catch (err) {231+ deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });232+ throw new ChirpPairingFailure("handshake_failed", (err as Error).message);233+ }234+235+ try {236+ const postNoise = await runPairingInitiatorOpenChannel(remoteChirpPublicKey, server.transport);237+ const confirmed = await deps.confirmSAS(postNoise.sas);238+ if (!confirmed) {239+ deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" });240+ throw new ChirpPairingFailure("handshake_failed", "sas_rejected");241+ }242+ const source = await requestAdoption(postNoise.channel, {243+ deviceName: deps.deviceName,244+ generateDeviceId: deps.generateDeviceId ?? generateUuid,245+ createVaultFromRootKey: deps.createVaultFromRootKey,246+ deriveLocalIdentity: deps.deriveLocalIdentity,247+ upsertPairedDevice: deps.completePairing,248+ applyLicense: deps.applyLicense,249+ onError(error) {250+ throw new Error(error);251+ },252+ });253+ if (!source) throw new Error("adoption_failed");254+ deps.onProgress({ phase: "paired", peerDeviceName: source.deviceName });255+ return source;256+ } catch (err) {257+ if (err instanceof ChirpPairingFailure) throw err;258+ deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });259+ throw new ChirpPairingFailure("handshake_failed", (err as Error).message);260+ } finally {261+ await server.transport.close().catch(() => undefined);262+ }263+}264+184265 export interface SourcePostSASDeps {185266 confirmSAS(sas: string): Promise<boolean>;186267 completePairing(device: PairedDevice): Promise<void>;@@ -345,6 +426,24 @@345426 const out = new Uint8Array(hex.length / 2);346427 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);347428 return out;429+}430+431+function deviceTypeByteFor(type: DeviceType) {432+ return type === "android" ? DEVICE_TYPE_ANDROID :433+ type === "ios" ? DEVICE_TYPE_IOS :434+ DEVICE_TYPE_EXTENSION;435+}436+437+function generateUuid(): string {438+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {439+ return crypto.randomUUID();440+ }441+ const bytes = new Uint8Array(16);442+ crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);443+ bytes[6] = (bytes[6] & 0x0f) | 0x40;444+ bytes[8] = (bytes[8] & 0x3f) | 0x80;445+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");446+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;348447 }349448 350449 async function runInitiatorHandshake(src/shared/chirp-adopt.ts+9 −7
@@ -96,7 +96,7 @@9696 export async function requestAdoption(9797 channel: PairingTransport,9898 deps: AdoptionReceiverDeps,99-): Promise<void> {99+): Promise<PairedDevice | null> {100100 await sendFrame(channel, {101101 type: "ADOPT_REQUEST",102102 device_id: deps.generateDeviceId(),@@ -107,7 +107,7 @@107107 const frame = await receiveFrame(channel);108108 if (!isAdoptGrant(frame)) {109109 deps.onError("ADOPT_UNEXPECTED_FRAME");110- return;110+ return null;111111 }112112 113113 const rootKeyBytes = hexToBytes(frame.root_key_hex);@@ -115,7 +115,7 @@115115 const computedVaultId = await computeVaultId(rootKeyBytes);116116 if (computedVaultId !== frame.vault_id) {117117 deps.onError("ADOPT_VAULT_ID_MISMATCH");118- return;118+ return null;119119 }120120 } finally {121121 rootKeyBytes.fill(0);@@ -124,7 +124,7 @@124124 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);125125 if (vaultId !== frame.vault_id) {126126 deps.onError("ADOPT_VAULT_ID_MISMATCH");127- return;127+ return null;128128 }129129 130130 if (frame.license) await deps.applyLicense?.(frame.license);@@ -133,13 +133,13 @@133133 await sendFrame(channel, {134134 type: "ADOPT_IDENTITY",135135 device_id: identity.deviceId,136- device_name: deps.deviceName,136+ device_name: identity.deviceName,137137 device_type: identity.deviceType,138138 long_term_public_key_hex: identity.longTermPublicKeyHex,139139 signing_public_key_hex: identity.signingPublicKeyHex,140140 });141141 142- await deps.upsertPairedDevice({142+ const sourceDevice: PairedDevice = {143143 deviceId: frame.source.device_id,144144 deviceName: frame.source.device_name,145145 deviceType: frame.source.device_type,@@ -148,7 +148,9 @@148148 trustSource: "direct",149149 wsPort: frame.source.ws_port,150150 pairedAt: Date.now(),151- });151+ };152+ await deps.upsertPairedDevice(sourceDevice);153+ return sourceDevice;152154 }153155 154156 export async function grantAdoption(src/shared/chirp-handshake.ts+34 −17
@@ -92,15 +92,15 @@9292 channel: PairingTransport;9393 }9494 95+export interface PostNoiseChannel {96+ sas: string;97+ channel: PairingTransport;98+}99+95100 function encodeFrame(frame: PairingFrame): Uint8Array {96101 return new TextEncoder().encode(JSON.stringify(frame));97102 }98103 99-function decodeFrame(bytes: Uint8Array): PairingFrame {100- const parsed = JSON.parse(new TextDecoder().decode(bytes));101- return decodeParsedFrame(parsed);102-}103-104104 function decodeParsedFrame(parsed: unknown): PairingFrame {105105 const value = parsed as Record<string, unknown> | null;106106 if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");@@ -133,14 +133,10 @@133133 ourIdentity: LocalIdentity,134134 transport: PairingTransport,135135 ): Promise<PairingHandshakeResult> {136- const session = await noiseNKInitiator(137- remoteEphemeralPublicKey,138- transport.send,139- transport.receive,140- );136+ const postNoise = await runPairingInitiatorOpenChannel(remoteEphemeralPublicKey, transport);141137 142138 // Send our identity, then receive theirs.143- const ourFrame = encodeFrame({139+ await postNoise.channel.send(encodeFrame({144140 type: "PAIR_IDENTITY",145141 device_id: ourIdentity.deviceId,146142 device_name: ourIdentity.deviceName,@@ -150,18 +146,39 @@150146 ws_port: ourIdentity.wsPort,151147 vault_id: ourIdentity.vaultId,152148 license: ourIdentity.license,153- });154- await transport.send(session.encrypt(ourFrame));149+ }));155150 156- const theirCipher = await transport.receive();157- const theirPlain = session.decrypt(theirCipher);158- const theirFrame = decodeFrame(theirPlain);151+ const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive())));159152 // The SAS is derived from the shared Noise handshake hash; the caller MUST160153 // have the user confirm it matches the peer's before persisting the device.161154 return {162155 device: frameToPairedDevice(theirFrame),163- sas: derivePairingSAS(session.handshakeHash),156+ sas: postNoise.sas,164157 peerLicense: peerLicenseOf(theirFrame),158+ };159+}160+161+export async function runPairingInitiatorOpenChannel(162+ remoteEphemeralPublicKey: Uint8Array,163+ transport: PairingTransport,164+): Promise<PostNoiseChannel> {165+ const session = await noiseNKInitiator(166+ remoteEphemeralPublicKey,167+ transport.send,168+ transport.receive,169+ );170+171+ const channel: PairingTransport = {172+ async send(msg) {173+ await transport.send(session.encrypt(msg));174+ },175+ async receive() {176+ return session.decrypt(await transport.receive());177+ },178+ };179+ return {180+ sas: derivePairingSAS(session.handshakeHash),181+ channel,165182 };166183 }167184