50d1cbac965b
fix: Enforce Pro entitlement during pairing (v4.4.1)
- time
- 2026-07-29 00:22 UTC · 3 months ago
- machine
- koh2
- parent
- 781b92629025
↓ peach-browser-50d1cbac.tar.gzatlas at this save
files changed14 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-source-bg.test.ts+111 −2src/background/relay-adoption-source-bg.ts+24 −5src/popup/App.tsx+4 −0src/popup/components/PairingProRequired.tsx+76 −0src/popup/styles/popup.css+28 −0src/popup/views/AdoptVaultView.test.tsx+13 −0src/popup/views/AdoptVaultView.tsx+18 −8src/popup/views/PairDevice.test.tsx+24 −2src/popup/views/PairDevice.tsx+20 −7src/shared/vault-adopt.test.ts+82 −0src/shared/vault-adopt.ts+54 −4manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.4.0",4+ "version": "4.4.1",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "4.4.0",5+ "version": "4.4.1",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.4.0",3+ "version": "4.4.1",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/relay-adoption-source-bg.test.ts+111 −2
@@ -10,6 +10,8 @@1010 triggerSync: vi.fn(),1111 createVault: vi.fn(),1212 activateLicense: vi.fn(),13+ isProUser: vi.fn(),14+ getLicenseKey: vi.fn(),1315 localIdentity: vi.fn(),1416 rollbackVault: vi.fn(),1517 sasDecisions: [] as Array<{ id: string; ok: boolean }>,@@ -62,8 +64,8 @@6264 }));6365 6466 vi.mock("./license", () => ({65- isProUser: vi.fn(async () => false),66- getLicenseKey: vi.fn(async () => undefined),67+ isProUser: mocks.isProUser,68+ getLicenseKey: mocks.getLicenseKey,6769 activateLicense: mocks.activateLicense,6870 }));6971 @@ -123,6 +125,10 @@123125 mocks.upsert.mockReset();124126 mocks.triggerSync.mockReset();125127 mocks.triggerSync.mockResolvedValue(undefined);128+ mocks.isProUser.mockReset();129+ mocks.isProUser.mockResolvedValue(true);130+ mocks.getLicenseKey.mockReset();131+ mocks.getLicenseKey.mockResolvedValue("PPK-source");126132 mocks.getLocalIdentity.mockResolvedValue({127133 identity: {128134 deviceId: "browser-source",@@ -303,6 +309,46 @@303309 });304310 });305311 312+ it("rejects before reading the root key when neither peer has Pro", async () => {313+ const socket = socketWithFrames([314+ {315+ type: "PEACH_HELLO",316+ token: "AAECAwQFBgcICQoLDA0ODw",317+ },318+ {319+ type: "ADOPT_REQUEST",320+ device_id: "free-receiver",321+ device_name: "Free phone",322+ device_type: "android",323+ },324+ { type: "ADOPT_CONFIRM", confirmed: true },325+ ]);326+ mocks.isProUser.mockResolvedValue(false);327+ mocks.getLicenseKey.mockResolvedValue(undefined);328+ mocks.openSocket.mockResolvedValue(socket);329+330+ await startRelayAdoptionSource();331+ await waitForPush("ADOPTION_SOURCE_RESULT");332+333+ expect(mocks.getRootKeyHex).not.toHaveBeenCalled();334+ expect(mocks.upsert).not.toHaveBeenCalled();335+ expect(mocks.pushes).toContainEqual({336+ type: "ADOPTION_SOURCE_RESULT",337+ payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },338+ });339+ const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {340+ try {341+ return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];342+ } catch {343+ return [];344+ }345+ });346+ expect(sentFrames).toContainEqual({347+ type: "ADOPT_REJECT",348+ reason: "ADOPT_PRO_REQUIRED",349+ });350+ });351+306352 it("fails closed when the user never answers the SAS", async () => {307353 // vi.useFakeTimers is unsafe with the live noise/socket mocks (they308354 // schedule real work), so we hold the SAS gate open by never resolving@@ -360,6 +406,10 @@360406 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });361407 mocks.activateLicense.mockReset();362408 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });409+ mocks.isProUser.mockReset();410+ mocks.isProUser.mockResolvedValue(true);411+ mocks.getLicenseKey.mockReset();412+ mocks.getLicenseKey.mockResolvedValue(undefined);363413 mocks.localIdentity.mockReset();364414 mocks.localIdentity.mockResolvedValue({365415 deviceId: "receiver-id",@@ -407,6 +457,24 @@407457 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");408458 });409459 460+ it("surfaces the Pro-required rejection without creating a vault", async () => {461+ mocks.openSocket.mockResolvedValue(socketWithFrames([462+ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },463+ { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },464+ ]));465+466+ const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");467+ await startRelayAdoptionReceiver("receiver-password");468+ await waitForPush("ADOPTION_SOURCE_RESULT");469+470+ expect(mocks.createVault).not.toHaveBeenCalled();471+ expect(mocks.rollbackVault).not.toHaveBeenCalled();472+ expect(mocks.pushes).toContainEqual({473+ type: "ADOPTION_SOURCE_RESULT",474+ payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },475+ });476+ });477+410478 it("the receiver wires requestAdoption, not grantAdoption", async () => {411479 // The receiver's protocol position is the opposite of the source side:412480 // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.@@ -421,6 +489,7 @@421489 type: "ADOPT_GRANT",422490 root_key_hex: rootKeyHex,423491 vault_id: vaultId,492+ license: "PPK-test",424493 source: {425494 device_id: "phone-source-id",426495 device_name: "June's phone",@@ -537,6 +606,7 @@537606 // License activation, identity derivation, and pairing persistence all538607 // call their modules directly from the service worker.539608 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");609+ expect(mocks.isProUser).toHaveBeenCalled();540610 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));541611 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({542612 deviceId: "phone-source-id",@@ -550,6 +620,44 @@550620 }));551621 });552622 623+ it("rolls back and refuses completion when a carried Pro license is not activated", async () => {624+ const rootKeyHex = "f0".repeat(32);625+ const vaultId = "b".repeat(64);626+ mocks.createVault.mockResolvedValue({ vaultId });627+ mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });628+ mocks.isProUser.mockResolvedValue(false);629+ mocks.openSocket.mockResolvedValue(socketWithFrames([630+ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },631+ {632+ type: "ADOPT_GRANT",633+ root_key_hex: rootKeyHex,634+ vault_id: vaultId,635+ license: "PPK-invalid",636+ source: {637+ device_id: "phone-source-id",638+ device_name: "June's phone",639+ device_type: "android",640+ long_term_public_key_hex: "99".repeat(32),641+ },642+ },643+ ]));644+645+ const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");646+ await startRelayAdoptionReceiver("receiver-password");647+ await waitForPush("ADOPTION_SOURCE_RESULT");648+649+ expect(mocks.rollbackVault).toHaveBeenCalledOnce();650+ expect(mocks.localIdentity).not.toHaveBeenCalled();651+ expect(mocks.upsert).not.toHaveBeenCalled();652+ expect(mocks.pushes).toContainEqual(expect.objectContaining({653+ type: "ADOPTION_SOURCE_RESULT",654+ payload: expect.objectContaining({655+ status: "error",656+ error: "license_transfer_failed",657+ }),658+ }));659+ });660+553661 it("rolls back a newly created vault when a later adoption step fails", async () => {554662 const rootKeyHex = "f0".repeat(32);555663 const vaultId = "b".repeat(64);@@ -561,6 +669,7 @@561669 type: "ADOPT_GRANT",562670 root_key_hex: rootKeyHex,563671 vault_id: vaultId,672+ license: "PPK-test",564673 source: {565674 device_id: "phone-source-id",566675 device_name: "June's phone",src/background/relay-adoption-source-bg.ts+24 −5
@@ -230,6 +230,9 @@230230 async license() {231231 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;232232 },233+ async applyLicense(license) {234+ await applyTransferredLicense(license);235+ },233236 onConsent: (adoptRequest) => requestConsent(234237 adoptRequest,235238 derivePairingSAS(session.handshakeHash),@@ -253,10 +256,10 @@253256 setTimeout(() => {254257 void triggerSyncIfReachable({ reason: "manual", force: true });255258 }, 1_000);256- } catch {259+ } catch (error) {257260 push("ADOPTION_SOURCE_RESULT", {258261 status: "error",259- error: "adoption_failed",262+ error: error instanceof Error ? error.message : "adoption_failed",260263 });261264 } finally {262265 clearTimeout(timeout);@@ -284,6 +287,7 @@284287 const ephemeralPublic = ephemeral.publicKey;285288 let noise: NoiseNKSession | undefined;286289 let createdVault = false;290+ let protocolError: string | undefined;287291 try {288292 const hello = decodeJson<{ type?: unknown; token?: unknown }>(289293 await socket.receive(),@@ -340,8 +344,11 @@340344 async applyLicense(license) {341345 // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self342346 // issue as createVaultFromRootKey. SW can't reach its own listener.343- await activateLicense(license);347+ await applyTransferredLicense(license);344348 },349+ async license() {350+ return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;351+ },345352 async deriveLocalIdentity() {346353 // Direct call for the same reason as applyLicense above.347354 const masterKeyHex = getRootKeyHex();@@ -362,7 +369,7 @@362369 relayUrl,363370 },364371 onError(error) {365- push("ADOPTION_SOURCE_RESULT", { status: "error", error });372+ protocolError = error;366373 },367374 });368375 @@ -373,7 +380,7 @@373380 }374381 push("ADOPTION_SOURCE_RESULT", {375382 status: "error",376- error: "adoption_rejected",383+ error: protocolError ?? "adoption_rejected",377384 });378385 return;379386 }@@ -400,6 +407,18 @@400407 socket.close();401408 ephemeralPrivate.fill(0);402409 ephemeralPublic.fill(0);410+ }411+}412+413+/**414+ * A transferred entitlement is committed only when activation succeeds and415+ * the stored value can immediately be re-read as Pro. activateLicense returns416+ * a normal Free result for invalid input, so callers must not ignore it.417+ */418+async function applyTransferredLicense(license: string): Promise<void> {419+ const activated = await activateLicense(license);420+ if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {421+ throw new Error("license_transfer_failed");403422 }404423 }405424 src/popup/App.tsx+4 −0
@@ -1236,6 +1236,10 @@12361236 await storageSet("peach_onboarded", "true").catch(() => {});12371237 setOnboarded(true);12381238 setHasVault(true);1239+ // The background applies a Pro license carried by the source before it1240+ // acknowledges adoption. This popup was already open when that happened,1241+ // so refresh its cached entitlement before rendering the adopted vault.1242+ await refreshProStatus();12391243 await handleUnlock();12401244 void sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "foreground" } });12411245 }src/popup/components/PairingProRequired.tsx+76 −0
@@ -0,0 +1,76 @@1+import { For, Show } from "solid-js";2+3+export const PEACH_PRO_PURCHASE_URL =4+ "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz";5+6+const SYNC_FEATURES = [7+ ["01", "Peach Sync", "Encrypted sync across every paired device"],8+ ["02", "One-time purchase", "$39 once · yours forever"],9+ ["03", "One shared key", "Pro transfers during authenticated pairing"],10+] as const;11+12+export interface PairingProRequiredProps {13+ onRetry(): void;14+ /** Existing vaults can open the full Peach Pro card in Settings. */15+ onOpenPro?(): void;16+}17+18+/** Dedicated pairing fallback, visually derived from Settings → Peach Pro. */19+export function PairingProRequired(props: PairingProRequiredProps) {20+ return (21+ <section class="peach-pair-pro-required" aria-labelledby="pair-pro-title">22+ <p class="peach-pro-eyebrow">PEACH PRO · SYNC</p>23+ <h2 id="pair-pro-title" class="peach-pro-headline">24+ Pair freely.<br />Sync with <em>Pro.</em>25+ </h2>26+ <p class="peach-pro-subtitle">27+ Pairing can start from any device, but at least one of these two28+ devices needs a valid Peach Pro license before the pairing can finish.29+ No vault or device record was added.30+ </p>31+32+ <div class="peach-pro-grid peach-pair-pro-grid">33+ <For each={SYNC_FEATURES}>34+ {([number, name, description]) => (35+ <div class="peach-pro-grid-item peach-pro-grid-item--wide">36+ <span class="peach-pro-grid-num">{number}</span>37+ <span class="peach-pro-grid-name">{name}</span>38+ <span class="peach-pro-grid-desc">{description}</span>39+ </div>40+ )}41+ </For>42+ </div>43+44+ <div class="peach-pro-upgrade-actions">45+ <Show46+ when={props.onOpenPro}47+ fallback={48+ <a49+ href={PEACH_PRO_PURCHASE_URL}50+ target="_blank"51+ rel="noopener noreferrer"52+ class="peach-pro-upgrade-cta"53+ >54+ Get Peach Pro55+ </a>56+ }57+ >58+ <button59+ type="button"60+ class="peach-pro-upgrade-cta peach-pair-pro-button"61+ onClick={() => props.onOpenPro?.()}62+ >63+ Open Peach Pro64+ </button>65+ </Show>66+ <button67+ type="button"68+ class="peach-btn peach-btn--ghost peach-btn--md"69+ onClick={props.onRetry}70+ >71+ Try another device72+ </button>73+ </div>74+ </section>75+ );76+}src/popup/styles/popup.css+28 −0
@@ -6197,6 +6197,34 @@61976197 background: color-mix(in srgb, var(--peach-accent) 28%, transparent);61986198 }61996199 6200+/* Pairing's terminal entitlement fallback reuses the Peach Pro card's6201+ editorial hierarchy without embedding the settings deck itself. */6202+.peach-pair-pro-required {6203+ width: 100%;6204+ display: flex;6205+ flex-direction: column;6206+ align-items: stretch;6207+ gap: 10px;6208+ text-align: left;6209+}6210+.peach-pair-pro-required .peach-pro-headline {6211+ font-size: 1.75rem;6212+}6213+.peach-pair-pro-required .peach-pro-subtitle {6214+ font-size: 0.6875rem;6215+}6216+.peach-pair-pro-grid {6217+ grid-template-columns: 1fr;6218+}6219+.peach-pair-pro-button {6220+ width: 100%;6221+ cursor: pointer;6222+}6223+.peach-desktop-error--pro {6224+ width: min(100%, 320px);6225+ overflow-y: auto;6226+}6227+62006228 .peach-pro-key-divider {62016229 display: flex;62026230 align-items: center;src/popup/views/AdoptVaultView.test.tsx+13 −0
@@ -139,5 +139,18 @@139139 expect(view.container.textContent).toContain("Pairing continues when you confirm there");140140 expect(view.container.textContent).not.toContain("They match");141141 expect(view.container.textContent).not.toContain("They don't match");142+143+ for (const listener of listeners) {144+ listener({145+ type: "ADOPTION_SOURCE_RESULT",146+ payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },147+ });148+ }149+ await flushView();150+151+ expect(view.container.textContent).toContain("Pair freely");152+ expect(view.container.textContent).toContain("at least one of these two devices");153+ expect(view.container.textContent).toContain("$39 once");154+ expect(view.container.querySelector('a[href*="buy.polar.sh"]')).toBeTruthy();142155 });143156 });src/popup/views/AdoptVaultView.tsx+18 −8
@@ -10,6 +10,7 @@1010 import { sendMessage } from "../App";1111 import { RuneBackground } from "../components/RuneBackground";1212 import { PairingQrCode } from "../components/PairingQrCode";13+import { PairingProRequired } from "../components/PairingProRequired";1314 import { RELAY_DISCLOSURE } from "../components/SyncModeSelector";1415 1516 type AdoptPhase = "password" | "pair" | "success" | "error";@@ -227,14 +228,23 @@227228 </Match>228229 229230 <Match when={phase() === "error"}>230- <h1>Could not join the vault.</h1>231- <p class="peach-hint peach-hint-error">{receiverError()}</p>232- <div class="peach-adopt-actions">233- <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>234- Try again235- </button>236- <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>237- </div>231+ <Show232+ when={receiverError() === "ADOPT_PRO_REQUIRED"}233+ fallback={234+ <>235+ <h1>Could not join the vault.</h1>236+ <p class="peach-hint peach-hint-error">{receiverError()}</p>237+ <div class="peach-adopt-actions">238+ <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>239+ Try again240+ </button>241+ <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>242+ </div>243+ </>244+ }245+ >246+ <PairingProRequired onRetry={tryAgain} />247+ </Show>238248 </Match>239249 </Switch>240250 </div>src/popup/views/PairDevice.test.tsx+24 −2
@@ -63,12 +63,12 @@6363 while (cleanups.length) cleanups.pop()!();6464 });6565 66- function mount() {66+ function mount(onLearnPro: () => void = () => {}) {6767 const rendered = renderView(6868 <PairDeviceView6969 isPro={true}7070 onBack={() => {}}71- onLearnPro={() => {}}71+ onLearnPro={onLearnPro}7272 />,7373 );7474 cleanups.push(rendered.dispose);@@ -175,5 +175,27 @@175175 pushFromBackground("ADOPTION_SOURCE_RESULT", { status: "complete" });176176 expect(container.textContent).toContain("Device added");177177 expect(container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);178+ });179+180+ it("shows the Peach Pro upgrade path when neither pairing peer is Pro", async () => {181+ const onLearnPro = vi.fn();182+ const { container } = mount(onLearnPro);183+ await vi.waitFor(() => {184+ expect(container.textContent).toContain("This extension has the vault");185+ });186+187+ pushFromBackground("ADOPTION_SOURCE_RESULT", {188+ status: "error",189+ error: "ADOPT_PRO_REQUIRED",190+ });191+192+ expect(container.textContent).toContain("Pair freely");193+ expect(container.textContent).toContain("at least one of these two devices");194+ expect(container.textContent).toContain("$39 once");195+ const openPro = [...container.querySelectorAll<HTMLButtonElement>("button")]196+ .find((button) => button.textContent?.trim() === "Open Peach Pro");197+ expect(openPro).toBeTruthy();198+ openPro!.click();199+ expect(onLearnPro).toHaveBeenCalledOnce();178200 });179201 });src/popup/views/PairDevice.tsx+20 −7
@@ -23,6 +23,7 @@2323 import { sendMessage } from "../../shared/bridge";2424 import { DesktopBird, MobileBird } from "../components/PairingBirds";2525 import { PairingQrCode } from "../components/PairingQrCode";26+import { PairingProRequired } from "../components/PairingProRequired";2627 import { SyncModeSelector } from "../components/SyncModeSelector";2728 2829 export interface PairDeviceViewProps {@@ -253,14 +254,26 @@253254 </Show>254255 255256 <Show when={sharePhase() === "error"}>256- <div class="peach-desktop-error">257- <div class="peach-pair-headline">Could not add the device</div>258- <div class="peach-pair-sub">{errorCopy()}</div>259- <div class="peach-pair-action">260- <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>261- <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>257+ <Show258+ when={shareError() === "ADOPT_PRO_REQUIRED"}259+ fallback={260+ <div class="peach-desktop-error">261+ <div class="peach-pair-headline">Could not add the device</div>262+ <div class="peach-pair-sub">{errorCopy()}</div>263+ <div class="peach-pair-action">264+ <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>265+ <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>266+ </div>267+ </div>268+ }269+ >270+ <div class="peach-desktop-error peach-desktop-error--pro">271+ <PairingProRequired272+ onRetry={() => void startShareFlow()}273+ onOpenPro={props.onLearnPro}274+ />262275 </div>263- </div>276+ </Show>264277 </Show>265278 266279 <Show when={sharePhase() === "sas"}>src/shared/vault-adopt.test.ts+82 −0
@@ -99,6 +99,85 @@9999 expect(receiverDeps.applyLicense).toHaveBeenCalledWith("PPK-test");100100 });101101 102+ it("T-A10b: rejects without creating or pairing when neither peer has Pro", async () => {103+ const [receiverChannel, sourceChannel] = memoryPair();104+ const rootKeyHex = "12".repeat(32);105+ const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));106+ const createVault = vi.fn();107+ const receiverUpsert = vi.fn();108+ const sourceUpsert = vi.fn();109+ const onError = vi.fn();110+111+ const receiver = requestAdoption(receiverChannel, {112+ deviceName: "Free receiver",113+ generateDeviceId: () => "free-receiver",114+ confirmSas: vi.fn(async () => true),115+ license: vi.fn(async () => undefined),116+ createVaultFromRootKey: createVault,117+ deriveLocalIdentity: vi.fn(),118+ upsertPairedDevice: receiverUpsert,119+ onError,120+ });121+ const request = JSON.parse(122+ new TextDecoder().decode(await sourceChannel.receive()),123+ ) as AdoptRequest;124+ const source = grantAdoption(sourceChannel, request, {125+ sourceIdentity: identity("free-source"),126+ rootKeyHex: vi.fn(async () => rootKeyHex),127+ vaultId: vi.fn(async () => vaultId),128+ license: vi.fn(async () => undefined),129+ onConsent: vi.fn(async () => true),130+ upsertPairedDevice: sourceUpsert,131+ });132+133+ await expect(receiver).resolves.toBeNull();134+ await expect(source).rejects.toThrow("ADOPT_PRO_REQUIRED");135+ expect(onError).toHaveBeenCalledWith("ADOPT_PRO_REQUIRED");136+ expect(createVault).not.toHaveBeenCalled();137+ expect(receiverUpsert).not.toHaveBeenCalled();138+ expect(sourceUpsert).not.toHaveBeenCalled();139+ });140+141+ it("T-A10c: accepts and transfers a Pro license offered by the receiver", async () => {142+ const [receiverChannel, sourceChannel] = memoryPair();143+ const rootKeyHex = "13".repeat(32);144+ const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));145+ let sourceLicense: string | undefined;146+ const sourceApply = vi.fn(async (license: string) => {147+ sourceLicense = license;148+ });149+150+ const receiver = requestAdoption(receiverChannel, {151+ deviceName: "Pro receiver",152+ generateDeviceId: () => "pro-receiver",153+ confirmSas: vi.fn(async () => true),154+ license: vi.fn(async () => "PPK-receiver"),155+ createVaultFromRootKey: vi.fn(async () => ({ vaultId })),156+ deriveLocalIdentity: vi.fn(async () => identity("receiver")),157+ upsertPairedDevice: vi.fn(),158+ applyLicense: vi.fn(),159+ onError: vi.fn(),160+ });161+ const request = JSON.parse(162+ new TextDecoder().decode(await sourceChannel.receive()),163+ ) as AdoptRequest;164+ const source = grantAdoption(sourceChannel, request, {165+ sourceIdentity: identity("free-source"),166+ rootKeyHex: vi.fn(async () => rootKeyHex),167+ vaultId: vi.fn(async () => vaultId),168+ license: vi.fn(async () => sourceLicense),169+ applyLicense: sourceApply,170+ onConsent: vi.fn(async () => true),171+ upsertPairedDevice: vi.fn(),172+ });173+174+ await expect(Promise.all([receiver, source])).resolves.toEqual([175+ expect.objectContaining({ deviceId: "free-source-id" }),176+ expect.objectContaining({ deviceId: "receiver-id" }),177+ ]);178+ expect(sourceApply).toHaveBeenCalledWith("PPK-receiver");179+ });180+102181 it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {103182 const [receiverChannel, sourceChannel] = memoryPair();104183 const rootKeyHex = vi.fn(async () => "11".repeat(32));@@ -220,6 +299,7 @@220299 sourceIdentity: identity("source"),221300 rootKeyHex: vi.fn(async () => "33".repeat(32)),222301 vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),302+ license: vi.fn(async () => "PPK-test"),223303 onConsent: vi.fn(async () => true),224304 upsertPairedDevice: upsert,225305 });@@ -242,6 +322,7 @@242322 deviceName: "Receiver",243323 generateDeviceId: () => "receiver-ephemeral",244324 confirmSas: vi.fn(async () => true),325+ license: vi.fn(async () => "PPK-local"),245326 createVaultFromRootKey: vi.fn(async () => ({ vaultId })),246327 deriveLocalIdentity: vi.fn(async () => identity("receiver")),247328 upsertPairedDevice: receiverUpsert,@@ -278,6 +359,7 @@278359 deviceName: "Receiver",279360 generateDeviceId: () => "receiver-ephemeral",280361 confirmSas: vi.fn(async () => true),362+ license: vi.fn(async () => "PPK-local"),281363 validateSource: vi.fn(async () => {282364 throw new Error("JOIN_POPULATED_VAULT_BLOCKED");283365 }),src/shared/vault-adopt.ts+54 −4
@@ -7,6 +7,8 @@77 device_id: string;88 device_name: string;99 device_type: DeviceType;10+ /** Signed Pro entitlement offered by the receiver, if it has one. */11+ license?: string;1012 }1113 1214 export interface AdoptGrant {@@ -53,13 +55,20 @@5355 type: "ADOPT_APPLIED";5456 }5557 58+/** Neither peer could prove a Pro entitlement, so no vault state may commit. */59+export interface AdoptReject {60+ type: "ADOPT_REJECT";61+ reason: "ADOPT_PRO_REQUIRED";62+}63+5664 export type AdoptionFrame =5765 | AdoptRequest5866 | AdoptConfirm5967 | AdoptGrant6068 | AdoptIdentity6169 | AdoptComplete62- | AdoptApplied;70+ | AdoptApplied71+ | AdoptReject;6372 6473 export interface AdoptionPairState {6574 rendezvousSecretHex: string;@@ -80,6 +89,8 @@8089 deriveLocalIdentity(): Promise<LocalIdentity>;8190 upsertPairedDevice(device: PairedDevice): Promise<void>;8291 applyLicense?(license: string): Promise<void>;92+ /** Return this receiver's locally validated Pro key, if present. */93+ license?(): Promise<string | undefined>;8394 /** Bound to the same completed Noise session, never taken from frame claims. */8495 pairState?: AdoptionPairState;8596 onError(error: string): void;@@ -90,6 +101,8 @@90101 rootKeyHex(): Promise<string>;91102 vaultId(): Promise<string>;92103 license?(): Promise<string | undefined>;104+ /** Validate and durably store a Pro key offered by the receiver. */105+ applyLicense?(license: string): Promise<void>;93106 onConsent(request: AdoptRequest): Promise<boolean>;94107 upsertPairedDevice(device: PairedDevice): Promise<void>;95108 /** Bound to the same completed Noise session, never taken from frame claims. */@@ -102,7 +115,8 @@102115 typeof value.device_id === "string" &&103116 value.device_id.length > 0 &&104117 typeof value.device_name === "string" &&105- isDeviceType(value.device_type);118+ isDeviceType(value.device_type) &&119+ (value.license === undefined || typeof value.license === "string");106120 }107121 108122 export function isAdoptGrant(frame: unknown): frame is AdoptGrant {@@ -148,6 +162,12 @@148162 return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";149163 }150164 165+export function isAdoptReject(frame: unknown): frame is AdoptReject {166+ const value = frame as Partial<AdoptReject> | null;167+ return value?.type === "ADOPT_REJECT" &&168+ value.reason === "ADOPT_PRO_REQUIRED";169+}170+151171 export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {152172 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);153173 }@@ -156,11 +176,13 @@156176 channel: PairingTransport,157177 deps: AdoptionReceiverDeps,158178 ): Promise<PairedDevice | null> {179+ const receiverLicense = await deps.license?.();159180 await sendFrame(channel, {160181 type: "ADOPT_REQUEST",161182 device_id: deps.generateDeviceId(),162183 device_name: deps.deviceName,163184 device_type: "extension",185+ license: receiverLicense,164186 });165187 166188 const confirmed = await deps.confirmSas();@@ -174,6 +196,10 @@174196 }175197 176198 const frame = await receiveFrame(channel);199+ if (isAdoptReject(frame)) {200+ deps.onError(frame.reason);201+ return null;202+ }177203 if (!isAdoptGrant(frame)) {178204 deps.onError("ADOPT_UNEXPECTED_FRAME");179205 return null;@@ -189,6 +215,10 @@189215 } finally {190216 rootKeyBytes.fill(0);191217 }218+ if (!receiverLicense && !frame.license) {219+ deps.onError("ADOPT_PRO_REQUIRED");220+ return null;221+ }192222 193223 await deps.validateSource?.(frame.source);194224 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);@@ -197,7 +227,9 @@197227 return null;198228 }199229 200- if (frame.license) await deps.applyLicense?.(frame.license);230+ if (!receiverLicense && frame.license) {231+ await deps.applyLicense?.(frame.license);232+ }201233 202234 const identity = await deps.deriveLocalIdentity();203235 await sendFrame(channel, {@@ -243,6 +275,24 @@243275 const confirmation = await receiveFrame(channel);244276 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;245277 278+ let sourceLicense = await deps.license?.();279+ if (!sourceLicense && request.license) {280+ try {281+ if (!deps.applyLicense) throw new Error("license_transfer_unavailable");282+ await deps.applyLicense(request.license);283+ sourceLicense = await deps.license?.();284+ } catch {285+ sourceLicense = undefined;286+ }287+ }288+ if (!sourceLicense) {289+ await sendFrame(channel, {290+ type: "ADOPT_REJECT",291+ reason: "ADOPT_PRO_REQUIRED",292+ });293+ throw new Error("ADOPT_PRO_REQUIRED");294+ }295+246296 const rootKeyHex = await deps.rootKeyHex();247297 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");248298 @@ -250,7 +300,7 @@250300 type: "ADOPT_GRANT",251301 root_key_hex: rootKeyHex,252302 vault_id: await deps.vaultId(),253- license: await deps.license?.(),303+ license: sourceLicense,254304 source: {255305 device_id: deps.sourceIdentity.deviceId,256306 device_name: deps.sourceIdentity.deviceName,