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