399fb468341b

feat: Make pairing single-confirm and atomic (v4.2.18)

time
2026-07-27 19:42 UTC · 3 months ago
machine
koh2
parent
425e41d0ec00
14 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+0 −21src/background/relay-adoption-source-bg.test.ts+25 −20src/background/relay-adoption-source-bg.ts+8 −30src/popup/components/AdoptionConsentModal.test.tsx+0 −85src/popup/components/AdoptionConsentModal.tsx+0 −99src/popup/styles/popup.css+9 −74src/popup/views/PairDevice.test.tsx+10 −22src/popup/views/PairDevice.tsx+16 −39src/shared/types.ts+0 −1src/shared/vault-adopt.test.ts+74 −1src/shared/vault-adopt.ts+40 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.17",4+  "version": "4.2.18",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

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

view file

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

view file

src/background/messages.ts+0 −21
@@ -88,7 +88,6 @@8888 // the build if a runtime import() reappears anywhere in the worker's graph.8989 import {9090   cancelRelayAdoptionSource,91-  confirmRelayAdoptionSource,9291   isRelayAdoptionSourceInFlight,9392   startRelayAdoptionSource,9493 } from "./relay-adoption-source-bg";@@ -1459,26 +1458,6 @@14591458             error: error instanceof Error ? error.message : "adoption_source_failed",14601459           };14611460         }1462-      }1463-1464-      case "ADOPTION_SOURCE_CONFIRM": {1465-        const { password, cancel } = (msg.payload ?? {}) as {1466-          password?: unknown;1467-          cancel?: unknown;1468-        };1469-        if (cancel === true) {1470-          return {1471-            ok: true,1472-            data: await confirmRelayAdoptionSource(null),1473-          };1474-        }1475-        if (typeof password !== "string" || password.length === 0) {1476-          return { ok: false, error: "invalid_payload" };1477-        }1478-        return {1479-          ok: true,1480-          data: await confirmRelayAdoptionSource(password),1481-        };14821461       }14831462 14841463       case "CANCEL_ADOPTION_SOURCE": {

view file

src/background/relay-adoption-source-bg.test.ts+25 −20
@@ -3,7 +3,6 @@33 const mocks = vi.hoisted(() => ({44   openSocket: vi.fn(),55   getLocalIdentity: vi.fn(),6-  verifyPassword: vi.fn(),76   getRootKeyHex: vi.fn(),87   upsert: vi.fn(),98   pushes: [] as Array<{ type: string; payload: unknown }>,@@ -61,7 +60,6 @@6160 6261 vi.mock("./vault", () => ({6362   isUnlocked: vi.fn(() => true),64-  verifyMainPassword: mocks.verifyPassword,6563   getRootKeyHex: mocks.getRootKeyHex,6664 }));6765 @@ -79,7 +77,6 @@7977 8078 import {8179   cancelRelayAdoptionSource,82-  confirmRelayAdoptionSource,8380   startRelayAdoptionSource,8481 } from "./relay-adoption-source-bg";8582 @@ -110,7 +107,6 @@110107   mocks.pushes.length = 0;111108   mocks.openSocket.mockReset();112109   mocks.getLocalIdentity.mockReset();113-  mocks.verifyPassword.mockReset();114110   mocks.getRootKeyHex.mockReset();115111   mocks.upsert.mockReset();116112   mocks.triggerSync.mockReset();@@ -142,7 +138,7 @@142138 });143139 144140 describe("relay adoption source background boundary", () => {145-  it("does not access the root for a wrong password and only grants after both confirmations", async () => {141+  it("uses the receiver SAS confirmation as the only root-key commit gate", async () => {146142     const socket = socketWithFrames([147143       {148144         type: "PEACH_HELLO",@@ -162,25 +158,13 @@162158         device_type: "android",163159         long_term_public_key_hex: "d".repeat(64),164160       },161+      { type: "ADOPT_APPLIED" },165162     ]);166163     mocks.openSocket.mockResolvedValue(socket);167-    mocks.verifyPassword168-      .mockResolvedValueOnce(false)169-      .mockResolvedValueOnce(true);170164 171165     const started = await startRelayAdoptionSource();172166     expect(started.invitation).toMatch(/^peach-pair-v1:/);173167     await waitForPush("ADOPTION_SOURCE_SAS");174-    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();175-176-    await expect(confirmRelayAdoptionSource("wrong")).resolves.toEqual({177-      valid: false,178-    });179-    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();180-181-    await expect(confirmRelayAdoptionSource("correct")).resolves.toEqual({182-      valid: true,183-    });184168     await waitForPush("ADOPTION_SOURCE_RESULT");185169 186170     expect(mocks.getRootKeyHex).toHaveBeenCalled();@@ -196,9 +180,18 @@196180       reason: "manual",197181       force: true,198182     });183+    const sentTypes = socket.send.mock.calls184+      .map(([bytes]) => {185+        try {186+          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;187+        } catch {188+          return undefined;189+        }190+      });191+    expect(sentTypes).toContain("ADOPT_COMPLETE");199192   });200193 201-  it("fails closed on cancellation without reading or sending the root", async () => {194+  it("fails closed when the scanning device rejects the SAS", async () => {202195     const socket = socketWithFrames([203196       {204197         type: "PEACH_HELLO",@@ -210,12 +203,12 @@210203         device_name: "New phone",211204         device_type: "android",212205       },206+      { type: "ADOPT_CONFIRM", confirmed: false },213207     ]);214208     mocks.openSocket.mockResolvedValue(socket);215209 216210     await startRelayAdoptionSource();217211     await waitForPush("ADOPTION_SOURCE_SAS");218-    cancelRelayAdoptionSource();219212     await waitForPush("ADOPTION_SOURCE_RESULT");220213 221214     expect(mocks.getRootKeyHex).not.toHaveBeenCalled();@@ -224,5 +217,17 @@224217       type: "ADOPTION_SOURCE_RESULT",225218       payload: { status: "error", error: "adoption_rejected" },226219     });220+  });221+222+  it("cancelling the source screen closes the room without reading the root", async () => {223+    const socket = socketWithFrames([]);224+    mocks.openSocket.mockResolvedValue(socket);225+226+    await startRelayAdoptionSource();227+    cancelRelayAdoptionSource();228+229+    expect(socket.close).toHaveBeenCalled();230+    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();231+    expect(mocks.upsert).not.toHaveBeenCalled();227232   });228233 });

view file

src/background/relay-adoption-source-bg.ts+8 −30
@@ -29,7 +29,7 @@2929 import { getLocalPairingIdentity } from "./device-identity";3030 import { getSyncSettings, relayInForce } from "./sync-settings";3131 import { getLicenseKey, isProUser } from "./license";32-import { getRootKeyHex, isUnlocked, verifyMainPassword } from "./vault";32+import { getRootKeyHex, isUnlocked } from "./vault";3333 import { computeVaultId } from "../shared/sync-identity";3434 import { upsertPairedDevice } from "./paired-devices";3535 import { triggerSyncIfReachable } from "./sync-trigger";@@ -49,7 +49,6 @@4949 5050 let inFlight = false;5151 let activeSocket: PairingSocket | null = null;52-let pendingConsent: ((approved: boolean) => void) | null = null;5352 5453 export function isRelayAdoptionSourceInFlight(): boolean {5554   return inFlight;@@ -98,30 +97,7 @@9897   }9998 }10099 101-/**102- * Wrong passwords do not consume the pending decision; cancel does.103- */104-export async function confirmRelayAdoptionSource(105-  password: string | null,106-): Promise<{ valid: boolean }> {107-  const pending = pendingConsent;108-  if (!pending) return { valid: false };109-  if (password === null) {110-    pendingConsent = null;111-    pending(false);112-    return { valid: true };113-  }114-  const valid = await verifyMainPassword(password);115-  if (valid) {116-    pendingConsent = null;117-    pending(true);118-  }119-  return { valid };120-}121-122100 export function cancelRelayAdoptionSource(): void {123-  pendingConsent?.(false);124-  pendingConsent = null;125101   activeSocket?.close();126102   clearState();127103 }@@ -223,19 +199,21 @@223199   }224200 }225201 202+/**203+ * Opening Add Device in an unlocked extension is the source-side authorization.204+ * Announce the SAS for comparison, then let the scanning device's explicit205+ * confirmation be the single commit gate. grantAdoption still refuses to send206+ * the root key until the receiver returns ADOPT_CONFIRM(true).207+ */226208 function requestConsent(request: AdoptRequest, sas: string): Promise<boolean> {227209   push("ADOPTION_SOURCE_SAS", {228210     sas,229211     receiverDeviceName: request.device_name,230212   });231-  return new Promise<boolean>((resolve) => {232-    pendingConsent = resolve;233-  });213+  return Promise.resolve(true);234214 }235215 236216 function clearState(): void {237-  pendingConsent?.(false);238-  pendingConsent = null;239217   activeSocket = null;240218   inFlight = false;241219 }

view file

src/popup/components/AdoptionConsentModal.test.tsx+0 −85
@@ -1,85 +0,0 @@1-// @vitest-environment jsdom2-3-import { afterEach, describe, expect, it, vi } from "vitest";4-import { AdoptionConsentModal } from "./AdoptionConsentModal";5-import { flushView, inputText, renderView } from "../views/view-test-utils";6-7-function button(container: HTMLElement, text: string): HTMLButtonElement {8-  const found = Array.from(container.querySelectorAll("button")).find((b) =>9-    (b.textContent ?? "").includes(text),10-  );11-  if (!found) throw new Error(`button not found: ${text}`);12-  return found as HTMLButtonElement;13-}14-15-function mount(overrides: Partial<Parameters<typeof AdoptionConsentModal>[0]> = {}) {16-  const props = {17-    receiverDeviceName: "June's iPhone",18-    sas: "89524517",19-    verifyMasterPassword: vi.fn(async () => true),20-    onCancel: vi.fn(),21-    onApprove: vi.fn(async () => {}),22-    ...overrides,23-  };24-  const rendered = renderView(<AdoptionConsentModal {...props} />);25-  return { ...rendered, props };26-}27-28-describe("AdoptionConsentModal", () => {29-  const cleanups: Array<() => void> = [];30-31-  afterEach(() => {32-    while (cleanups.length) cleanups.pop()!();33-  });34-35-  function mounted(overrides: Partial<Parameters<typeof AdoptionConsentModal>[0]> = {}) {36-    const rendered = mount(overrides);37-    cleanups.push(rendered.dispose);38-    return rendered;39-  }40-41-  it("T-A16: renders receiver device name, consequence copy, and SAS code", () => {42-    const { container } = mounted();43-    expect(container.textContent).toContain("June's iPhone");44-    expect(container.textContent).toContain("copies your entire vault including all passwords and Pro");45-    expect(container.textContent).toContain("89524517");46-  });47-48-  it("T-A17: Share vault button is disabled until a password is entered", () => {49-    const { container } = mounted();50-    const share = button(container, "Share vault");51-    expect(share.disabled).toBe(true);52-    inputText(container.querySelector("#adoption-consent-password") as HTMLInputElement, "pw");53-    expect(share.disabled).toBe(false);54-  });55-56-  it("T-A18: wrong password shows inline error, clears the field, and does not approve", async () => {57-    const { container, props } = mounted({ verifyMasterPassword: vi.fn(async () => false) });58-    const input = container.querySelector("#adoption-consent-password") as HTMLInputElement;59-    inputText(input, "wrong");60-    button(container, "Share vault").click();61-    await flushView();62-63-    expect(container.textContent).toContain("Incorrect password");64-    expect(input.value).toBe("");65-    expect(document.activeElement).toBe(input);66-    expect(props.onApprove).not.toHaveBeenCalled();67-  });68-69-  it("T-A19: correct password calls onApprove", async () => {70-    const { container, props } = mounted({ verifyMasterPassword: vi.fn(async () => true) });71-    inputText(container.querySelector("#adoption-consent-password") as HTMLInputElement, "correct");72-    button(container, "Share vault").click();73-    await flushView();74-75-    expect(props.verifyMasterPassword).toHaveBeenCalledWith("correct");76-    expect(props.onApprove).toHaveBeenCalledTimes(1);77-  });78-79-  it("T-A20: cancel closes without approval", () => {80-    const { container, props } = mounted();81-    button(container, "Cancel").click();82-    expect(props.onCancel).toHaveBeenCalledTimes(1);83-    expect(props.onApprove).not.toHaveBeenCalled();84-  });85-});

view file at the parent

src/popup/components/AdoptionConsentModal.tsx+0 −99
@@ -1,99 +0,0 @@1-import { Show, createSignal } from "solid-js";2-3-export interface AdoptionConsentModalProps {4-  receiverDeviceName: string;5-  sas: string;6-  verifyMasterPassword(password: string): Promise<boolean>;7-  onCancel(): void;8-  onApprove(): Promise<void>;9-}10-11-export function AdoptionConsentModal(props: AdoptionConsentModalProps) {12-  const [password, setPassword] = createSignal("");13-  const [error, setError] = createSignal("");14-  const [busy, setBusy] = createSignal(false);15-16-  async function submit() {17-    if (!password() || busy()) return;18-    setBusy(true);19-    setError("");20-    try {21-      const ok = await props.verifyMasterPassword(password());22-      if (!ok) {23-        setPassword("");24-        setError("Incorrect password");25-        queueMicrotask(() => document.getElementById("adoption-consent-password")?.focus());26-        return;27-      }28-      await props.onApprove();29-    } finally {30-      setBusy(false);31-    }32-  }33-34-  return (35-    <div class="peach-adoption-consent-backdrop" role="presentation">36-      <section37-        class="peach-adoption-consent"38-        role="dialog"39-        aria-modal="true"40-        aria-labelledby="adoption-consent-title"41-      >42-        <div class="peach-adoption-consent-icon" aria-hidden="true">!</div>43-        <h2 id="adoption-consent-title">Share vault with new device?</h2>44-        <p class="peach-adoption-consent-device">45-          <strong>{props.receiverDeviceName}</strong> wants to join this vault.46-        </p>47-        <p class="peach-adoption-consent-warning">48-          This copies your entire vault including all passwords and Pro to that device.49-        </p>50-        <div class="peach-adoption-consent-sas">51-          <span>Confirm the code matches</span>52-          <strong>{props.sas}</strong>53-        </div>54-        <label class="peach-label" for="adoption-consent-password">55-          Enter your master password to approve56-        </label>57-        <input58-          id="adoption-consent-password"59-          class="peach-underline-input"60-          type="password"61-          autocomplete="current-password"62-          value={password()}63-          disabled={busy()}64-          aria-invalid={error() ? "true" : "false"}65-          aria-describedby={error() ? "adoption-consent-error" : undefined}66-          onInput={(event) => {67-            setPassword((event.target as HTMLInputElement).value);68-            if (error()) setError("");69-          }}70-          onKeyDown={(event) => {71-            if (event.key === "Enter") void submit();72-            if (event.key === "Escape") props.onCancel();73-          }}74-        />75-        <Show when={error()}>76-          <p id="adoption-consent-error" class="peach-hint peach-hint-error">{error()}</p>77-        </Show>78-        <div class="peach-adoption-consent-actions">79-          <button80-            type="button"81-            class="peach-btn peach-btn--ghost peach-btn--md"82-            disabled={busy()}83-            onClick={props.onCancel}84-          >85-            Cancel86-          </button>87-          <button88-            type="button"89-            class="peach-btn peach-btn--accent peach-btn--md peach-adoption-consent-share"90-            disabled={!password() || busy()}91-            onClick={() => void submit()}92-          >93-            {busy() ? "Sharing..." : "Share vault ->"}94-          </button>95-        </div>96-      </section>97-    </div>98-  );99-}

view file at the parent

src/popup/styles/popup.css+9 −74
@@ -7400,86 +7400,21 @@74007400   color: rgb(var(--danger-tint));74017401 }74027402 7403-.peach-adoption-consent-backdrop {7404-  position: fixed;7405-  inset: 0;7406-  z-index: 1000;7407-  display: grid;7408-  place-items: center;7409-  padding: 18px;7410-  background: rgba(15, 10, 5, 0.72);7411-}7412-7413-.peach-adoption-consent {7414-  width: min(100%, 340px);7415-  border: 1px solid color-mix(in srgb, #f59e0b 54%, var(--peach-border));7416-  border-radius: 8px;7417-  padding: 18px;7418-  background: color-mix(in srgb, #2c1b08 84%, var(--peach-surface));7403+.peach-adoption-sas {7404+  margin: 16px auto 0;7405+  padding: 12px 16px;7406+  border: 1px solid color-mix(in srgb, var(--peach-t0) 18%, transparent);7407+  border-radius: 10px;7408+  background: color-mix(in srgb, var(--peach-t0) 7%, transparent);74197409   color: var(--peach-t0);7420-  box-shadow: 0 18px 60px rgba(0, 0, 0, 0.42);7410+  text-align: center;74217411 }74227412 7423-.peach-adoption-consent-icon {7424-  width: 32px;7425-  height: 32px;7426-  display: grid;7427-  place-items: center;7428-  border-radius: 50%;7429-  background: #f59e0b;7430-  color: #1f1304;7431-  font-weight: 800;7432-}7433-7434-.peach-adoption-consent h2 {7435-  margin: 12px 0 8px;7436-  font-size: 1.22rem;7437-  line-height: 1.15;7438-}7439-7440-.peach-adoption-consent-device,7441-.peach-adoption-consent-warning {7442-  margin: 0 0 12px;7443-  color: var(--peach-t1);7444-  line-height: 1.35;7445-}7446-7447-.peach-adoption-consent-device strong {7448-  color: var(--peach-t0);7449-}7450-7451-.peach-adoption-consent-warning {7452-  color: #ffd79a;7453-}7454-7455-.peach-adoption-consent-sas {7456-  display: flex;7457-  justify-content: space-between;7458-  align-items: center;7459-  gap: 12px;7460-  margin: 14px 0;7461-  padding: 10px;7462-  border-radius: 6px;7463-  background: rgba(245, 158, 11, 0.12);7464-}7465-7466-/* Tracking, not interleaved spaces: eight digits with per-digit spaces no7467-   longer fit beside the label at 320px. */7468-.peach-adoption-consent-sas strong {7413+.peach-adoption-sas strong {7414+  font-size: 1.65rem;74697415   font-variant-numeric: tabular-nums;74707416   letter-spacing: 0.18em;74717417   white-space: nowrap;7472-}7473-7474-.peach-adoption-consent-actions {7475-  display: flex;7476-  justify-content: space-between;7477-  gap: 10px;7478-  margin-top: 16px;7479-}7480-7481-.peach-adoption-consent-actions > button {7482-  flex: 1;74837418 }74847419 74857420 /* ── Panic flow (three-step destruction) ───────────────────────────────── */

view file

src/popup/views/PairDevice.test.tsx+10 −22
@@ -19,7 +19,7 @@1919   "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";2020 2121 import { PairDeviceView } from "./PairDevice";22-import { renderView, inputText } from "./view-test-utils";22+import { renderView } from "./view-test-utils";2323 2424 // --- fake chrome.runtime push channel (bg → popup) ------------------------2525 type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;@@ -39,9 +39,6 @@3939         data: { invitation: SOURCE_INVITATION },4040       });4141     }42-    if (msg?.type === "ADOPTION_SOURCE_CONFIRM") {43-      return Promise.resolve({ ok: true, data: { valid: true } });44-    }4542     if (msg?.type === "GET_SYNC_SETTINGS" || msg?.type === "SET_SYNC_SETTINGS") {4643       return Promise.resolve({4744         ok: true,@@ -162,7 +159,7 @@162159     });163160   });164161 165-  it("requires the matching SAS and master password before sharing", async () => {162+  it("shows the SAS and waits for the phone's single confirmation", async () => {166163     const { container } = mount();167164     await vi.waitFor(() => {168165       expect(container.textContent).toContain("Scan this in Peach");@@ -174,23 +171,14 @@174171     });175172     expect(container.textContent).toContain("June's phone");176173     expect(container.textContent).toContain("48219371");177-178-    inputText(179-      container.querySelector("#adoption-consent-password") as HTMLInputElement,180-      "correct horse battery staple",174+    expect(container.textContent).toContain("tap “They match” there");175+    expect(container.querySelector("#adoption-consent-password")).toBeNull();176+    expect(mockSendMessage).not.toHaveBeenCalledWith(177+      expect.objectContaining({ type: "ADOPTION_SOURCE_CONFIRM" }),181178     );182-    const approve = Array.from(container.querySelectorAll("button")).find(183-      (button) => button.textContent?.includes("Share vault"),184-    ) as HTMLButtonElement;185-    approve.click();186-    await vi.waitFor(() => {187-      expect(mockSendMessage).toHaveBeenCalledWith({188-        type: "ADOPTION_SOURCE_CONFIRM",189-        payload: { password: "correct horse battery staple" },190-      });191-    });192-    await vi.waitFor(() => {193-      expect(container.textContent).toContain("Waiting for the new device");194-    });179+180+    pushFromBackground("ADOPTION_SOURCE_RESULT", { status: "complete" });181+    expect(container.textContent).toContain("Device added");182+    expect(container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);195183   });196184 });

view file

src/popup/views/PairDevice.tsx+16 −39
@@ -24,7 +24,6 @@2424 } from "solid-js";2525 import { sendMessage } from "../../shared/bridge";2626 import { DesktopBird, MobileBird } from "../components/PairingBirds";27-import { AdoptionConsentModal } from "../components/AdoptionConsentModal";2827 import { PairingQrCode } from "../components/PairingQrCode";2928 import { SyncModeSelector } from "../components/SyncModeSelector";3029 @@ -36,7 +35,7 @@3635   onProChanged?: () => void;3736 }3837 39-type SharePhase = "opening" | "invitation" | "sas" | "connecting" | "paired" | "error";38+type SharePhase = "opening" | "invitation" | "sas" | "paired" | "error";4039 4140 export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {4241   const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");@@ -83,14 +82,6 @@8382     await startShareFlow();8483   }8584 86-  async function verifySourcePassword(password: string): Promise<boolean> {87-    const response = (await sendMessage({88-      type: "ADOPTION_SOURCE_CONFIRM",89-      payload: { password },90-    })) as { ok: boolean; data?: { valid?: boolean } };91-    return response.ok && response.data?.valid === true;92-  }93-9485   onMount(() => {9586     void startShareFlow();9687 @@ -145,13 +136,13 @@145136   );146137 147138   // The bird stage — always animating — shared by every pairing screen.148-  const birdStage = (active: boolean) => (149-    <div class={`peach-pair-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">139+  const birdStage = (phase: SharePhase) => (140+    <div class={`peach-pair-stage ${phase === "sas" || phase === "paired" ? "act-handshake" : "act-listening"}`} aria-hidden="true">150141       <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">151-        <DesktopBird pose="chirping" />142+        <DesktopBird pose={phase === "paired" ? "happy" : "chirping"} />152143       </div>153144       <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">154-        <MobileBird pose="attentive" />145+        <MobileBird pose={phase === "paired" ? "happy" : "attentive"} />155146       </div>156147     </div>157148   );@@ -174,7 +165,6 @@174165       case "opening": return "Opening a secure pairing room";175166       case "invitation": return "Scan this code on your other device";176167       case "sas": return "Confirm the code matches";177-      case "connecting": return "Waiting for the new device";178168       case "paired": return "Device added";179169       case "error": return "Could not add the device";180170     }@@ -199,7 +189,7 @@199189         <div class="peach-pair-head-spacer" />200190       </header>201191 202-      {birdStage(sharePhase() === "sas" || sharePhase() === "connecting")}192+      {birdStage(sharePhase())}203193 204194       <Show when={sharePhase() === "opening"}>205195         <div class="peach-desktop-searching">@@ -275,13 +265,6 @@275265         </div>276266       </Show>277267 278-      <Show when={sharePhase() === "connecting"}>279-        <div class="peach-desktop-searching">280-          <div class="peach-pair-headline">Waiting for the new device…</div>281-          <div class="peach-pair-sub">The vault key stays put until that device confirms the same code too.</div>282-        </div>283-      </Show>284-285268       <Show when={sharePhase() === "paired"}>286269         <div class="peach-desktop-paired">287270           <div class="peach-pair-headline">Device added</div>@@ -304,22 +287,16 @@304287       </Show>305288 306289       <Show when={sharePhase() === "sas"}>307-        <AdoptionConsentModal308-          receiverDeviceName={receiverName()}309-          sas={sourceSas()}310-          verifyMasterPassword={verifySourcePassword}311-          onCancel={() => {312-            void sendMessage({313-              type: "ADOPTION_SOURCE_CONFIRM",314-              payload: { cancel: true },315-            });316-            setShareError("adoption_rejected");317-            setSharePhase("error");318-          }}319-          onApprove={async () => {320-            setSharePhase("connecting");321-          }}322-        />290+        <div class="peach-desktop-searching">291+          <div class="peach-pair-headline">Confirm this code on your phone</div>292+          <div class="peach-pair-sub">293+            If {receiverName()} shows the same code, tap “They match” there. Peach294+            will finish only after both devices save the pairing.295+          </div>296+          <div class="peach-adoption-sas" aria-label="Pairing confirmation code">297+            <strong>{sourceSas()}</strong>298+          </div>299+        </div>323300       </Show>324301 325302       <div class="peach-sr-only" aria-live="polite">{liveText()}</div>

view file

src/shared/types.ts+0 −1
@@ -306,7 +306,6 @@306306   // Relay pairing. START/CONFIRM/CANCEL are popup→bg requests handled in the307307   // switch; ADOPTION_SOURCE_SAS / ADOPTION_SOURCE_RESULT are bg→popup pushes.308308   | "START_ADOPTION_SOURCE"309-  | "ADOPTION_SOURCE_CONFIRM"310309   | "CANCEL_ADOPTION_SOURCE"311310   | "ADOPTION_SOURCE_SAS"312311   | "ADOPTION_SOURCE_RESULT"

view file

src/shared/vault-adopt.test.ts+74 −1
@@ -48,7 +48,7 @@4848 }4949 5050 describe("vault adoption sub-protocol", () => {51-  it("T-A10: completes ADOPT_REQUEST/ADOPT_GRANT/ADOPT_IDENTITY KAT", async () => {51+  it("T-A10: completes ADOPT_REQUEST through mutual completion acknowledgements", async () => {5252     const [receiverChannel, sourceChannel] = memoryPair();5353     const rootKeyHex = "11".repeat(32);5454     const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));@@ -230,6 +230,79 @@230230 231231     await Promise.race([promise, Promise.resolve()]);232232     expect(upsert).not.toHaveBeenCalled();233+  });234+235+  it("T-A16: receiver does not persist source without ADOPT_COMPLETE", async () => {236+    const [receiverChannel, sourceChannel] = memoryPair();237+    const rootKeyHex = "66".repeat(32);238+    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));239+    const receiverUpsert = vi.fn();240+    const onError = vi.fn();241+    const receiver = requestAdoption(receiverChannel, {242+      deviceName: "Receiver",243+      generateDeviceId: () => "receiver-ephemeral",244+      confirmSas: vi.fn(async () => true),245+      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),246+      deriveLocalIdentity: vi.fn(async () => identity("receiver")),247+      upsertPairedDevice: receiverUpsert,248+      onError,249+    });250+251+    await sourceChannel.receive(); // ADOPT_REQUEST252+    await sourceChannel.receive(); // ADOPT_CONFIRM253+    await sendFrame(sourceChannel, {254+      type: "ADOPT_GRANT",255+      root_key_hex: rootKeyHex,256+      vault_id: vaultId,257+      source: {258+        device_id: "source-id",259+        device_name: "Source",260+        device_type: "extension",261+        long_term_public_key_hex: "ab".repeat(32),262+      },263+    });264+    await sourceChannel.receive(); // ADOPT_IDENTITY265+    await sendFrame(sourceChannel, { type: "ADOPT_CONFIRM", confirmed: true });266+267+    await expect(receiver).resolves.toBeNull();268+    expect(onError).toHaveBeenCalledWith("ADOPT_COMPLETION_MISSING");269+    expect(receiverUpsert).not.toHaveBeenCalled();270+  });271+272+  it("T-A17: receiver safety policy runs before vault creation", async () => {273+    const [receiverChannel, sourceChannel] = memoryPair();274+    const rootKeyHex = "77".repeat(32);275+    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));276+    const createVaultFromRootKey = vi.fn();277+    const receiver = requestAdoption(receiverChannel, {278+      deviceName: "Receiver",279+      generateDeviceId: () => "receiver-ephemeral",280+      confirmSas: vi.fn(async () => true),281+      validateSource: vi.fn(async () => {282+        throw new Error("JOIN_POPULATED_VAULT_BLOCKED");283+      }),284+      createVaultFromRootKey,285+      deriveLocalIdentity: vi.fn(),286+      upsertPairedDevice: vi.fn(),287+      onError: vi.fn(),288+    });289+290+    await sourceChannel.receive(); // ADOPT_REQUEST291+    await sourceChannel.receive(); // ADOPT_CONFIRM292+    await sendFrame(sourceChannel, {293+      type: "ADOPT_GRANT",294+      root_key_hex: rootKeyHex,295+      vault_id: vaultId,296+      source: {297+        device_id: "new-source",298+        device_name: "Source",299+        device_type: "extension",300+        long_term_public_key_hex: "ab".repeat(32),301+      },302+    });303+304+    await expect(receiver).rejects.toThrow("JOIN_POPULATED_VAULT_BLOCKED");305+    expect(createVaultFromRootKey).not.toHaveBeenCalled();233306   });234307 235308   it("T-A31: does not apply a license if vault creation fails", async () => {

view file

src/shared/vault-adopt.ts+40 −1
@@ -39,11 +39,27 @@3939   signing_public_key_hex?: string;4040 }4141 42+/**43+ * Encrypted source-to-receiver acknowledgement sent only after the source has44+ * durably recorded the receiver. Without it the receiver could report success45+ * after sending ADOPT_IDENTITY even though the source never received it.46+ */47+export interface AdoptComplete {48+  type: "ADOPT_COMPLETE";49+}50+51+/** Receiver-to-source acknowledgement sent after the source record is durable. */52+export interface AdoptApplied {53+  type: "ADOPT_APPLIED";54+}55+4256 export type AdoptionFrame =4357   | AdoptRequest4458   | AdoptConfirm4559   | AdoptGrant46-  | AdoptIdentity;60+  | AdoptIdentity61+  | AdoptComplete62+  | AdoptApplied;4763 4864 export interface AdoptionPairState {4965   rendezvousSecretHex: string;@@ -55,6 +71,11 @@5571   generateDeviceId(): string;5672   /** Root-key transfer cannot begin until this device confirms the SAS. */5773   confirmSas(): Promise<boolean>;74+  /**75+   * Optional local safety policy evaluated after the authenticated grant is76+   * validated but before any vault state changes.77+   */78+  validateSource?(source: AdoptGrant["source"]): Promise<void>;5879   createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;5980   deriveLocalIdentity(): Promise<LocalIdentity>;6081   upsertPairedDevice(device: PairedDevice): Promise<void>;@@ -119,6 +140,14 @@119140     /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);120141 }121142 143+export function isAdoptComplete(frame: unknown): frame is AdoptComplete {144+  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";145+}146+147+export function isAdoptApplied(frame: unknown): frame is AdoptApplied {148+  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";149+}150+122151 export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {123152   return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);124153 }@@ -161,6 +190,7 @@161190     rootKeyBytes.fill(0);162191   }163192 193+  await deps.validateSource?.(frame.source);164194   const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);165195   if (vaultId !== frame.vault_id) {166196     deps.onError("ADOPT_VAULT_ID_MISMATCH");@@ -179,6 +209,12 @@179209     signing_public_key_hex: identity.signingPublicKeyHex,180210   });181211 212+  const completion = await receiveFrame(channel);213+  if (!isAdoptComplete(completion)) {214+    deps.onError("ADOPT_COMPLETION_MISSING");215+    return null;216+  }217+182218   const sourceDevice: PairedDevice = {183219     deviceId: frame.source.device_id,184220     deviceName: frame.source.device_name,@@ -192,6 +228,7 @@192228     pairedAt: Date.now(),193229   };194230   await deps.upsertPairedDevice(sourceDevice);231+  await sendFrame(channel, { type: "ADOPT_APPLIED" });195232   return sourceDevice;196233 }197234 @@ -240,6 +277,8 @@240277     pairedAt: Date.now(),241278   };242279   await deps.upsertPairedDevice(device);280+  await sendFrame(channel, { type: "ADOPT_COMPLETE" });281+  if (!isAdoptApplied(await receiveFrame(channel))) return null;243282   return device;244283 }245284 

view file