f87c03ab9e01

license key included in CJK and encrypted JSON vault exports, restored on import (v2.4.1)

time
2026-04-27 14:06 UTC · 6 months ago
machine
koh2
parent
34d5c471b02c
18 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/license.ts+10 −0src/background/messages.ts+5 −0src/crypto/cjkExport.ts+3 −2src/crypto/cjkImport.ts+5 −0src/crypto/cjkSerialise.ts+15 −3src/popup/views/CJKExport.tsx+18 −2src/popup/views/CJKImport.tsx+6 −0src/popup/views/Export.tsx+17 −2src/popup/views/Import.tsx+5 −0src/popup/views/contracts.test.tsx+2 −1src/shared/backup.test.ts+3 −4src/shared/backup.ts+33 −6src/shared/import.test.ts+1 −1src/shared/import.ts+7 −3src/shared/types.ts+13 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.4.0",4+  "version": "2.4.1",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

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

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.4.0",3+  "version": "2.4.1",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/license.ts+10 −0
@@ -163,6 +163,16 @@163163   return info.valid && info.plan === "pro";164164 }165165 166+/** Returns the raw stored license key string, or null if none. */167+export async function getLicenseKey(): Promise<string | null> {168+  try {169+    const result = await chrome.storage.local.get("peach_license") as Record<string, string | undefined>;170+    return result["peach_license"] ?? null;171+  } catch {172+    return null;173+  }174+}175+166176 export async function deactivateLicense(): Promise<void> {167177   await chrome.storage.local.remove("peach_license");168178 }

view file

src/background/messages.ts+5 −0
@@ -16,6 +16,7 @@1616   activateLicense,1717   deactivateLicense,1818   getLicenseInfo,19+  getLicenseKey,1920   isProUser,2021   markCelebrationPlayed,2122   shouldPlayCelebration,@@ -402,6 +403,10 @@402403       case "GET_LICENSE": {403404         const licenseInfo = await getLicenseInfo();404405         return { ok: true, data: licenseInfo };406+      }407+      case "GET_LICENSE_KEY": {408+        const key = await getLicenseKey();409+        return { ok: true, data: key };405410       }406411       case "DEACTIVATE_LICENSE": {407412         await deactivateLicense();

view file

src/crypto/cjkExport.ts+3 −2
@@ -21,7 +21,7 @@2121 } from "./cjkPayload";2222 import { serialiseVault } from "./cjkSerialise";2323 import { compressZstd } from "./zstd";24-import type { VaultEntry } from "../shared/types";24+import type { PeachLicenseExport, VaultEntry } from "../shared/types";2525 import type { VaultSettings } from "../background/vault";2626 2727 // ---------------------------------------------------------------------------@@ -139,6 +139,7 @@139139 export interface ExportInput {140140   entries: VaultEntry[];141141   settings?: VaultSettings;142+  license?: PeachLicenseExport;142143   passphrase: string;143144   argon2?: Argon2Params;144145 }@@ -174,7 +175,7 @@174175   const { logins, notes, passkeys } = countsByType(activeEntries);175176   const trashedCount = allEntries.length - activeEntries.length;176177 177-  const serialised = serialiseVault(allEntries, input.settings ?? {});178+  const serialised = serialiseVault(allEntries, input.settings ?? {}, input.license);178179 179180   // Step 3: compress180181   const compressed = await compressZstd(serialised, 19);

view file

src/crypto/cjkImport.ts+5 −0
@@ -76,6 +76,10 @@7676 export interface ImportResult {7777   entries: VaultEntry[];7878   settings: VaultSettings;79+  /** License key from the backup, if the backup was a Pro export. */80+  licenseKey?: string;81+  /** Activation timestamp from the backup license, if present. */82+  licenseActivatedAt?: number;7983   /** The entry count advertised by the ASCII header line. */8084   announcedEntryCount: number;8185   /** The entry count recorded in the binary header. */@@ -365,6 +369,7 @@365369   return {366370     entries: payload.entries,367371     settings: payload.settings,372+    ...(payload.license?.key ? { licenseKey: payload.license.key, licenseActivatedAt: payload.license.activatedAt } : {}),368373     announcedEntryCount: parsed.entryCount,369374     binaryEntryCount: fields.entryCount,370375   };

view file

src/crypto/cjkSerialise.ts+15 −3
@@ -15,7 +15,7 @@1515  * and completeness are worth the extra kilobytes.1616  */1717 18-import type { VaultEntry } from "../shared/types";18+import type { PeachLicenseExport, VaultEntry } from "../shared/types";1919 import type { VaultSettings } from "../background/vault";2020 2121 /**@@ -27,8 +27,11 @@2727   v: 3;2828   entries: VaultEntry[];2929   settings: VaultSettings;30+  license?: PeachLicenseExport;3031 }3132 33+export type { PeachLicenseExport };34+3235 /**3336  * Serialise the live vault (excluding tombstones — the caller filters) and3437  * the current user settings into the bytes that will be zstd-compressed@@ -36,12 +39,14 @@3639  */3740 export function serialiseVault(3841   entries: VaultEntry[],39-  settings: VaultSettings42+  settings: VaultSettings,43+  license?: PeachLicenseExport4044 ): Uint8Array {4145   const payload: VaultBackupPayload = {4246     v: 3,4347     entries: entries.map(stripEphemeral),4448     settings: { ...settings },49+    ...(license ? { license } : {}),4550   };4651   return new TextEncoder().encode(JSON.stringify(payload));4752 }@@ -77,7 +82,14 @@7782       ? (candidate.settings as VaultSettings)7883       : {};7984 80-  return { v: 3, entries, settings };85+  const license =86+    candidate.license &&87+    typeof candidate.license === "object" &&88+    typeof (candidate.license as PeachLicenseExport).key === "string"89+      ? (candidate.license as PeachLicenseExport)90+      : undefined;91+92+  return { v: 3, entries, settings, ...(license ? { license } : {}) };8193 }8294 8395 /**

view file

src/popup/views/CJKExport.tsx+18 −2
@@ -28,7 +28,7 @@2828   type HibpResult,2929   type StrengthTier,3030 } from "../../crypto/cjkPassphrase";31-import type { VaultEntry } from "../../shared/types";31+import type { PeachLicenseExport, VaultEntry } from "../../shared/types";3232 3333 import { sendMessage } from "../App";3434 @@ -221,9 +221,10 @@221221 222222     try {223223       // 1. Fetch entries + settings from the background vault224-      const [entriesRes, settingsRes] = await Promise.all([224+      const [entriesRes, settingsRes, licenseRes] = await Promise.all([225225         sendMessage({ type: "GET_ENTRIES" }),226226         sendMessage({ type: "GET_SETTINGS" }),227+        sendMessage({ type: "GET_LICENSE" }),227228       ]);228229       const entriesResponse = entriesRes as {229230         ok: boolean;@@ -249,12 +250,27 @@249250 250251       const settings = settingsResponse.data ?? {};251252 253+      // Build license export if the vault is Pro.254+      const licenseInfo = (licenseRes as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;255+      let license: PeachLicenseExport | undefined;256+      if (licenseInfo?.valid && licenseInfo.plan === "pro") {257+        const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });258+        const keyStr = (keyRes as { ok: boolean; data?: string }).data;259+        if (keyStr) {260+          license = {261+            key: keyStr,262+            ...(licenseInfo.issued ? { activatedAt: new Date(licenseInfo.issued).getTime() } : {}),263+          };264+        }265+      }266+252267       setProgress("deriving-key");253268       await new Promise((r) => setTimeout(r, 0));254269 255270       const result = await exportToCJK({256271         entries,257272         settings: settings as any,273+        license,258274         passphrase: passphrase(),259275         argon2: ARGON2_DEFAULT,260276       });

view file

src/popup/views/CJKImport.tsx+6 −0
@@ -388,6 +388,12 @@388388     const summary = (res as { ok: boolean; data?: { added: number; updated: number; skipped: number } }).data;389389     const total = (summary?.added ?? 0) + (summary?.updated ?? 0);390390     setImportedCount(total || payload.entries.length);391+392+    // Apply license last — Pro gate must be active before any Pro features initialize.393+    // Silent on failure: a bad key must never block a successful vault restore.394+    if (payload.license?.key) {395+      await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});396+    }391397   }392398 393399   async function finishSuccess(count: number) {

view file

src/popup/views/Export.tsx+17 −2
@@ -5,7 +5,7 @@55 // because of this. See Projects.md §4.1.66 import { createMemo, createSignal, Show } from "solid-js";77 import { sendMessage } from "../App";8-import type { VaultEntry } from "../../shared/types";8+import type { PeachLicenseExport, VaultEntry } from "../../shared/types";99 import { exportEntriesToPeachJson } from "../../shared/backup";1010 import { CJKExportView } from "./CJKExport";1111 @@ -68,7 +68,22 @@6868       : `${entries.length} entr${entries.length === 1 ? "y" : "ies"}`;6969 7070     try {71-      const blob = await exportEntriesToPeachJson(entries, exportPassword());71+      // Include license in the export if Pro, so a restore reactivates automatically.72+      let license: PeachLicenseExport | undefined;73+      const licenseRes = await sendMessage({ type: "GET_LICENSE" });74+      const licenseInfo = (licenseRes as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;75+      if (licenseInfo?.valid && licenseInfo.plan === "pro") {76+        const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });77+        const keyStr = (keyRes as { ok: boolean; data?: string }).data;78+        if (keyStr) {79+          license = {80+            key: keyStr,81+            ...(licenseInfo.issued ? { activatedAt: new Date(licenseInfo.issued).getTime() } : {}),82+          };83+        }84+      }85+86+      const blob = await exportEntriesToPeachJson(entries, exportPassword(), license);7287       const filename = `peach-backup-${dateStamp()}.json`;7388       downloadBlob(7489         JSON.stringify(blob, null, 2),

view file

src/popup/views/Import.tsx+5 −0
@@ -224,6 +224,11 @@224224         throw new Error("NO_ENTRIES_IMPORTED");225225       }226226 227+      // Apply license last — silent on failure, never blocks a successful import.228+      if (currentPreview.licenseKey) {229+        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});230+      }231+227232       setDuplicateWarning(dupes);228233       await props.onImport();229234     } catch (err) {

view file

src/popup/views/contracts.test.tsx+2 −1
@@ -299,7 +299,8 @@299299         expect.objectContaining({ id: "entry-1" }),300300         expect.objectContaining({ id: "deleted", deletedAt: 2 }),301301       ],302-      "correct horse battery staple"302+      "correct horse battery staple",303+      undefined303304     );304305   });305306 

view file

src/shared/backup.test.ts+3 −4
@@ -24,9 +24,8 @@2424   it("importEntriesFromPeachJson decrypts entries with the correct password", async () => {2525     const envelope = await exportEntriesToPeachJson(sampleEntries, "export-pass");2626 27-    await expect(28-      importEntriesFromPeachJson(JSON.stringify(envelope), "export-pass")29-    ).resolves.toMatchObject([27+    const result = await importEntriesFromPeachJson(JSON.stringify(envelope), "export-pass");28+    expect(result.entries).toMatchObject([3029       {3130         type: "login",3231         title: "GitHub",@@ -69,7 +68,7 @@6968     ];7069 7170     const envelope = await exportEntriesToPeachJson(entries, "export-pass");72-    const imported = await importEntriesFromPeachJson(JSON.stringify(envelope), "export-pass");71+    const { entries: imported } = await importEntriesFromPeachJson(JSON.stringify(envelope), "export-pass");7372 7473     expect(imported).toHaveLength(2);7574 

view file

src/shared/backup.ts+33 −6
@@ -1,5 +1,5 @@11 import { decrypt, encrypt } from "../background/aes";2-import type { VaultEntry } from "./types";2+import type { PeachLicenseExport, VaultEntry } from "./types";33 44 export interface PeachExportEnvelope {55   version: 2;@@ -15,11 +15,16 @@1515 1616 export async function exportEntriesToPeachJson(1717   entries: VaultEntry[],18-  password: string18+  password: string,19+  license?: PeachLicenseExport1920 ): Promise<PeachExportEnvelope> {2021   const salt = crypto.getRandomValues(new Uint8Array(16));2122   const key = await derivePeachExportKey(password, salt);22-  const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(entries));23+  // Inner payload: { entries, license? } — allows license restore on import.24+  // Old exports contained only the entries array; importers handle both shapes.25+  const inner: { entries: VaultEntry[]; license?: PeachLicenseExport } = { entries };26+  if (license) inner.license = license;27+  const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(inner));2328 2429   return {2530     version: 2,@@ -32,10 +37,16 @@3237   };3338 }3439 40+export interface PeachJsonImportResult {41+  entries: ImportableEntry[];42+  licenseKey?: string;43+  licenseActivatedAt?: number;44+}45+3546 export async function importEntriesFromPeachJson(3647   raw: string,3748   password: string38-): Promise<ImportableEntry[]> {49+): Promise<PeachJsonImportResult> {3950   let envelope: Partial<PeachExportEnvelope>;4051   try {4152     envelope = JSON.parse(raw) as Partial<PeachExportEnvelope>;@@ -75,13 +86,29 @@7586     throw new Error("INVALID_PEACH_EXPORT");7687   }7788 78-  if (!Array.isArray(parsed)) {89+  // Handle both old format (plain entries array) and new format ({ entries, license? })90+  let rawEntries: unknown[];91+  let licenseKey: string | undefined;92+  let licenseActivatedAt: number | undefined;93+94+  if (Array.isArray(parsed)) {95+    rawEntries = parsed;96+  } else if (parsed && typeof parsed === "object" && Array.isArray((parsed as { entries?: unknown }).entries)) {97+    const obj = parsed as { entries: unknown[]; license?: PeachLicenseExport };98+    rawEntries = obj.entries;99+    if (obj.license && typeof obj.license.key === "string") {100+      licenseKey = obj.license.key;101+      licenseActivatedAt = obj.license.activatedAt;102+    }103+  } else {79104     throw new Error("INVALID_PEACH_EXPORT");80105   }81106 82-  return parsed107+  const entries = rawEntries83108     .map(normalizeImportedEntry)84109     .filter((entry): entry is ImportableEntry => entry !== null);110+111+  return { entries, ...(licenseKey ? { licenseKey, licenseActivatedAt } : {}) };85112 }86113 87114 export async function derivePeachExportKey(

view file

src/shared/import.test.ts+1 −1
@@ -185,7 +185,7 @@185185       },186186     ], "export-pass");187187 188-    const restored = await importEntriesFromPeachJson(188+    const { entries: restored } = await importEntriesFromPeachJson(189189       JSON.stringify(envelope),190190       "export-pass"191191     );

view file

src/shared/import.ts+7 −3
@@ -26,6 +26,9 @@2626   totalItems: number;2727   skippedItems: number;2828   requiresPassword: boolean;29+  /** License key from the backup, present only on Pro encrypted JSON exports. */30+  licenseKey?: string;31+  licenseActivatedAt?: number;2932 }3033 3134 export async function parseImportSource(@@ -117,14 +120,15 @@117120         };118121       }119122 120-      const entries = await importEntriesFromPeachJson(raw, password);123+      const result = await importEntriesFromPeachJson(raw, password);121124       return {122125         format,123126         label: "Peach encrypted backup",124-        entries,125-        totalItems: entries.length,127+        entries: result.entries,128+        totalItems: result.entries.length,126129         skippedItems: 0,127130         requiresPassword: false,131+        ...(result.licenseKey ? { licenseKey: result.licenseKey, licenseActivatedAt: result.licenseActivatedAt } : {}),128132       };129133     }130134     default:

view file

src/shared/types.ts+13 −1
@@ -171,6 +171,7 @@171171   | "FIRE_CANARY_PING"172172   | "ACTIVATE_LICENSE"173173   | "GET_LICENSE"174+  | "GET_LICENSE_KEY"174175   | "IS_DURESS_ENABLED"175176   | "SETUP_DURESS"176177   | "DISABLE_DURESS"@@ -217,7 +218,9 @@217218   | "SUMMON_AUTOFILL" // background → content: open the in-page palette218219   | "VAULT_LOCKED"    // background → content/popup: vault just locked219220   | "VAULT_UNLOCKED"  // background → content/popup: vault just unlocked220-  | "PRO_GATE_TRIGGERED"; // background → popup: a Pro feature was attempted on free221+  | "PRO_GATE_TRIGGERED" // background → popup: a Pro feature was attempted on free222+  | "SHOULD_PLAY_CELEBRATION"223+  | "MARK_CELEBRATION_PLAYED";221224 222225 export type ProFeature =223226   | "sync"@@ -225,6 +228,15 @@225228   | "advanced_health"226229   | "smart_password_change"227230   | "ios_app";231+232+/**233+ * License export shape included in CJK phrase and encrypted JSON backups.234+ * `celebrationPlayed` is intentionally absent — a new device deserves the moment.235+ */236+export interface PeachLicenseExport {237+  key: string;238+  activatedAt?: number;239+}228240 229241 export interface AdvancedHealthEntryReport {230242   id: string;

view file