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
↓ peach-browser-399fb468.tar.gzatlas at this save
files changed14 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 −1manifest.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": {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",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",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": {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 });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 }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-});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-}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) ───────────────────────────────── */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 });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>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"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 () => {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