50d1cbac965b

fix: Enforce Pro entitlement during pairing (v4.4.1)

time
2026-07-29 00:22 UTC · 3 months ago
machine
koh2
parent
781b92629025
14 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 −4
manifest.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": {

view file

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",

view file

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",

view file

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",

view file

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 

view file

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   }

view file

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+}

view file

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;

view file

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 });

view file

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>

view file

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 });

view file

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"}>

view file

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       }),

view file

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,

view file