aa22799926ea
add configurable locked Sync key custody at Browser 5.2.53
- time
- 2026-09-12 11:34 UTC · 1 month ago
- machine
- koh2
- agent
- camel-stream/auto
- session
- configurable locked sync
- parent
- bc40f634fb77
↓ peach-browser-aa227999.tar.gzatlas at this save
files changed12 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/entry.ts+2 −0src/background/locked-sync-spool.test.ts+8 −8src/background/locked-sync-spool.ts+40 −7src/background/messages.ts+4 −0src/background/sync-vault-adapter.ts+4 −1src/background/vault.ts+5 −0src/popup/App.tsx+8 −1src/popup/views/Settings.tsx+20 −1src/popup/views/contracts.test.tsx+10 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.2.52",4+ "version": "5.2.53",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": "5.2.52",5+ "version": "5.2.53",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": "5.2.52",3+ "version": "5.2.53",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/entry.ts+2 −0
@@ -19,6 +19,7 @@1919 drainAndRefreshLockedSyncSpool,2020 refreshLockedSyncSpool,2121 } from "./sync-vault-adapter";22+import { clearLockedSyncPrivateKey } from "./locked-sync-spool";2223 import { setLockedSyncHooks } from "./vault";2324 import { repairPairedRevocationOverlaps } from "./paired-devices";2425 import {@@ -71,6 +72,7 @@7172 refresh: refreshLockedSyncSpool,7273 afterUnlock: drainAndRefreshLockedSyncSpool,7374 onDecoy: () => {75+ void clearLockedSyncPrivateKey();7476 abortMeshRelaySessions();7577 abortSyncRunnerSessions();7678 },src/background/locked-sync-spool.test.ts+8 −8
@@ -135,7 +135,7 @@135135 expect(local.peach_locked_sync_spool_v1).toBeUndefined();136136 });137137 138- it("fails closed after a worker restart because the private key is not persisted", async () => {138+ it("recovers the default session-custodied key after a worker restart", async () => {139139 const vault = await import("./vault");140140 await vault.createVault("correct horse battery staple");141141 const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");@@ -144,14 +144,14 @@144144 145145 vi.resetModules();146146 const { getLockedSyncIdentity } = await import("./locked-sync-spool");147- expect(await getLockedSyncIdentity()).toBeNull();148- expect(JSON.stringify(session.peach_locked_sync_spool_v1)).not.toContain("PrivateKey");149- expect(JSON.stringify(session.peach_locked_sync_spool_v1)).not.toContain("longTermPrivateKey");147+ expect(await getLockedSyncIdentity()).not.toBeNull();148+ expect((session.peach_locked_sync_private_key_v1 as string)).toMatch(/^[0-9a-f]{64}$/);150149 151- for (const key of Object.keys(session)) delete session[key];152- vi.resetModules();153- const afterRestart = await import("./locked-sync-spool");154- expect(await afterRestart.getLockedSyncIdentity()).toBeNull();150+ const hardenedVault = await import("./vault");151+ await hardenedVault.updateSettings({ lockedSyncRequiresUnlock: true });152+ const { clearLockedSyncPrivateKey } = await import("./locked-sync-spool");153+ await clearLockedSyncPrivateKey();154+ expect(await getLockedSyncIdentity()).toBeNull();155155 });156156 157157 it("drains staged envelopes through the live decrypt boundary after unlock", async () => {src/background/locked-sync-spool.ts+40 −7
@@ -2,15 +2,15 @@22 import type { SyncPeerIdentity } from "../shared/sync-transport";33 import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";44 import type { DeviceType } from "../shared/sync-types";5-import { isDecoyMode } from "./vault";6-7-5+import { ensureSettingsLoaded, isDecoyMode, lockedSyncRequiresUnlock } from "./vault";86 const STORAGE_KEY = "peach_locked_sync_spool_v1";97 108 /**11- * Locked sync metadata lives in session storage, but the long-term private key12- * stays only in this worker's heap. A worker restart fails closed until unlock.9+ * Locked sync metadata lives in session storage. The private key is retained10+ * there by default so a cold worker can resume locked sync; hardened custody11+ * omits it and therefore requires unlock after a restart.1312 */13+const PRIVATE_KEY_STORAGE_KEY = "peach_locked_sync_private_key_v1";1414 1515 interface CachedEntry {1616 modifiedAt: number;@@ -35,11 +35,36 @@3535 3636 let lockedPrivateKey: Uint8Array | null = null;3737 38-export function setLockedSyncPrivateKey(value: Uint8Array): void {38+function bytesToHex(value: Uint8Array): string {39+ return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("");40+}41+42+function hexToBytes(value: string): Uint8Array | null {43+ if (!/^[0-9a-f]{64}$/.test(value)) return null;44+ const bytes = new Uint8Array(32);45+ for (let index = 0; index < bytes.length; index += 1) {46+ bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);47+ }48+ return bytes;49+}50+51+export async function setLockedSyncPrivateKey(value: Uint8Array, persist: boolean): Promise<void> {3952 lockedPrivateKey?.fill(0);4053 lockedPrivateKey = new Uint8Array(value);54+ if (!chrome.storage.session) return;55+ if (persist) {56+ await chrome.storage.session.set({ [PRIVATE_KEY_STORAGE_KEY]: bytesToHex(value) });57+ } else {58+ await chrome.storage.session.remove(PRIVATE_KEY_STORAGE_KEY);59+ }4160 }4261 62+export async function clearLockedSyncPrivateKey(): Promise<void> {63+ lockedPrivateKey?.fill(0);64+ lockedPrivateKey = null;65+ if (chrome.storage.session) await chrome.storage.session.remove(PRIVATE_KEY_STORAGE_KEY);66+}67+4368 let spoolMutation: Promise<void> = Promise.resolve();4469 4570 export async function replaceLockedSyncSpool(@@ -57,7 +82,15 @@5782 5883 export async function getLockedSyncIdentity(): Promise<SyncPeerIdentity | null> {5984 const snapshot = await loadSpool();60- if (!snapshot || !lockedPrivateKey) return null;85+ if (!snapshot) return null;86+ if (!lockedPrivateKey) {87+ await ensureSettingsLoaded();88+ if (lockedSyncRequiresUnlock() || !chrome.storage.session) return null;89+ const result = await chrome.storage.session.get(PRIVATE_KEY_STORAGE_KEY);90+ const recovered = hexToBytes((result as Record<string, unknown>)[PRIVATE_KEY_STORAGE_KEY] as string);91+ if (!recovered) return null;92+ lockedPrivateKey = recovered;93+ }6194 return {6295 deviceId: snapshot.deviceId,6396 deviceName: snapshot.deviceName,src/background/messages.ts+4 −0
@@ -74,6 +74,7 @@7474 setDeviceName as setDeviceNameV3,7575 type LocalDeviceIdentity,7676 } from "./device-identity";77+import { clearLockedSyncPrivateKey } from "./locked-sync-spool";7778 import {7879 isDeviceRevoked,7980 listPairedDevices as listPairedDevicesV3,@@ -1080,6 +1081,9 @@10801081 return { ok: false, error: "invalid_payload" };10811082 }10821083 await vault.updateSettings(patch as vault.VaultSettings);1084+ if ((patch as Record<string, unknown>).lockedSyncRequiresUnlock === true) {1085+ await clearLockedSyncPrivateKey();1086+ }10831087 if (typeof (patch as Record<string, unknown>).autoLockMinutes === "number") {10841088 await setupAutoLock();10851089 if (vault.isUnlocked()) touchAutoLock();src/background/sync-vault-adapter.ts+4 −1
@@ -48,6 +48,8 @@4848 getVaultVersion,4949 isUnlocked,5050 isDecoyMode,51+ ensureSettingsLoaded,52+ lockedSyncRequiresUnlock,5153 } from "./vault";5254 import {5355 findPairedDeviceById,@@ -249,7 +251,8 @@249251 };250252 }251253 const tombstones = getTombstoneManifest();252- setLockedSyncPrivateKey(identity.longTermPrivateKeyBytes);254+ await ensureSettingsLoaded();255+ await setLockedSyncPrivateKey(identity.longTermPrivateKeyBytes, !lockedSyncRequiresUnlock());253256 const { longTermPrivateKeyHex: _privateKeyHex, longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;254257 await replaceLockedSyncSpool({255258 ...storedIdentity,src/background/vault.ts+5 −0
@@ -146,6 +146,8 @@146146 * `prefers-reduced-motion`.147147 */148148 reduceMotion?: boolean;149+ /** Require an unlock after a worker restart before locked Sync resumes. */150+ lockedSyncRequiresUnlock?: boolean;149151 /** Whether this vault has a local PIN unlock wrapper configured. */150152 pinEnabled?: boolean;151153 /** Whether the lock screen should open on PIN instead of the password. */@@ -401,6 +403,9 @@401403 .catch(() => {402404 /* no sync runner registered, or nothing reachable — non-fatal */403405 });406+}407+export function lockedSyncRequiresUnlock(): boolean {408+ return settings.lockedSyncRequiresUnlock === true;404409 }405410 406411 let lockedSyncRefreshHook: (() => Promise<void>) | null = null;src/popup/App.tsx+8 −1
@@ -201,6 +201,7 @@201201 const [reduceMotion, setReduceMotion] = createSignal<boolean>(false);202202 const [lockOnClose, setLockOnClose] = createSignal(false);203203 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);204+ const [lockedSyncRequiresUnlock, setLockedSyncRequiresUnlock] = createSignal(false);204205 const [breachMonitoring, setBreachMonitoring] = createSignal(true);205206 const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);206207 const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);@@ -498,6 +499,10 @@498499 payload: { autoLockMinutes: minutes },499500 });500501 }502+ async function applyLockedSyncRequiresUnlock(enabled: boolean) {503+ setLockedSyncRequiresUnlock(enabled);504+ await sendMessage({ type: "UPDATE_SETTINGS", payload: { lockedSyncRequiresUnlock: enabled } });505+ }501506 502507 async function applyBreachMonitoring(enabled: boolean) {503508 setBreachMonitoring(enabled);@@ -980,8 +985,8 @@980985 setReduceMotion(s.reduceMotion === true);981986 setLockOnClose(s.lockOnClose === true);982987 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);988+ setLockedSyncRequiresUnlock(s.lockedSyncRequiresUnlock === true);983989 setBreachMonitoring(s.breachMonitoring !== false);984- setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);985990 setShortcutBindings(getActiveBindings(s.shortcuts));986991 }987992 @@ -1725,6 +1730,7 @@17251730 lockOnClose={lockOnClose()}17261731 autoLockMinutes={autoLockMinutes()}17271732 trashRetentionDays={trashRetentionDays()}1733+ lockedSyncRequiresUnlock={lockedSyncRequiresUnlock()}17281734 theme={theme()}17291735 accentColor={accentColor()}17301736 textSize={textSize()}@@ -1735,6 +1741,7 @@17351741 isExiting={settingsExiting()}17361742 onBreachMonitoringChange={applyBreachMonitoring}17371743 onLockOnCloseChange={applyLockOnClose}1744+ onLockedSyncRequiresUnlockChange={applyLockedSyncRequiresUnlock}17381745 onAutoLockMinutesChange={applyAutoLockMinutes}17391746 onTrashRetentionChange={applyTrashRetention}17401747 onThemeChange={applyTheme}src/popup/views/Settings.tsx+20 −1
@@ -45,6 +45,7 @@4545 breachMonitoring: boolean;4646 lockOnClose: boolean;4747 autoLockMinutes: number;48+ lockedSyncRequiresUnlock: boolean;4849 trashRetentionDays: number;4950 theme: "dark" | "light";5051 accentColor: AccentColorName;@@ -56,6 +57,7 @@5657 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;5758 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;5859 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;60+ onLockedSyncRequiresUnlockChange: (enabled: boolean) => void | Promise<void>;5961 onTrashRetentionChange: (days: number) => void | Promise<void>;6062 onThemeChange: (theme: "dark" | "light") => void;6163 onAccentChange: (name: AccentColorName) => void | Promise<void>;@@ -1033,7 +1035,24 @@10331035 <option value="0">Never</option>10341036 </select>10351037 </div>1036-1038+ <p class="peach-settings-sub-label">SYNC WHILE LOCKED</p>1039+ <div class="peach-settings-row">1040+ <label for="locked-sync-custody">Private key custody</label>1041+ <select1042+ id="locked-sync-custody"1043+ class="peach-settings-select"1044+ value={props.lockedSyncRequiresUnlock ? "unlock_required" : "session"}1045+ onChange={(e) => void props.onLockedSyncRequiresUnlockChange(e.currentTarget.value === "unlock_required")}1046+ >1047+ <option value="session">Keep Sync available</option>1048+ <option value="unlock_required">Require unlock after restart</option>1049+ </select>1050+ </div>1051+ <p class="peach-settings-help">1052+ {props.lockedSyncRequiresUnlock1053+ ? "The Sync identity is not persisted while locked. Unlock Peach after a background restart to resume Sync."1054+ : "Keeps locked Sync available during this browser session."}1055+ </p>10371056 <p class="peach-settings-sub-label">QUICK UNLOCK</p>10381057 <div class="peach-settings-row peach-settings-row--meta">10391058 <span>PIN unlock</span>src/popup/views/contracts.test.tsx+10 −0
@@ -169,6 +169,7 @@169169 breachMonitoring: true,170170 lockOnClose: true,171171 autoLockMinutes: 5,172+ lockedSyncRequiresUnlock: false,172173 trashRetentionDays: 30,173174 theme: "dark" as const,174175 accentColor: "peach" as const,@@ -178,6 +179,7 @@178179 onLockOnCloseChange: vi.fn(),179180 onAutoLockMinutesChange: vi.fn(),180181 onTrashRetentionChange: vi.fn(),182+ onLockedSyncRequiresUnlockChange: vi.fn(),181183 onThemeChange: vi.fn(),182184 onAccentChange: vi.fn(),183185 onTextSizeChange: vi.fn(),@@ -948,6 +950,7 @@948950 entriesCount={1}949951 breachMonitoring={true}950952 lockOnClose={true}953+ lockedSyncRequiresUnlock={false}951954 autoLockMinutes={5}952955 trashRetentionDays={30}953956 theme="dark"@@ -955,6 +958,7 @@955958 textSize="medium"956959 reduceMotion={false}957960 onBreachMonitoringChange={vi.fn()}961+ onLockedSyncRequiresUnlockChange={vi.fn()}958962 onLockOnCloseChange={vi.fn()}959963 onAutoLockMinutesChange={vi.fn()}960964 onTrashRetentionChange={vi.fn()}@@ -1012,6 +1016,7 @@10121016 entriesCount={1}10131017 breachMonitoring={true}10141018 lockOnClose={true}1019+ lockedSyncRequiresUnlock={false}10151020 autoLockMinutes={5}10161021 trashRetentionDays={30}10171022 theme="dark"@@ -1019,6 +1024,7 @@10191024 textSize="medium"10201025 reduceMotion={false}10211026 initialSection="feedback"1027+ onLockedSyncRequiresUnlockChange={vi.fn()}10221028 onBreachMonitoringChange={vi.fn()}10231029 onLockOnCloseChange={vi.fn()}10241030 onAutoLockMinutesChange={vi.fn()}@@ -1150,6 +1156,7 @@11501156 entriesCount={1}11511157 breachMonitoring={true}11521158 lockOnClose={true}1159+ lockedSyncRequiresUnlock={false}11531160 autoLockMinutes={5}11541161 trashRetentionDays={30}11551162 theme="dark"@@ -1157,6 +1164,7 @@11571164 textSize="medium"11581165 reduceMotion={false}11591166 initialSection="feedback"1167+ onLockedSyncRequiresUnlockChange={vi.fn()}11601168 onBreachMonitoringChange={vi.fn()}11611169 onLockOnCloseChange={vi.fn()}11621170 onAutoLockMinutesChange={vi.fn()}@@ -1214,6 +1222,7 @@12141222 entriesCount={1}12151223 breachMonitoring={true}12161224 lockOnClose={true}1225+ lockedSyncRequiresUnlock={false}12171226 autoLockMinutes={5}12181227 trashRetentionDays={30}12191228 theme="dark"@@ -1221,6 +1230,7 @@12211230 textSize="medium"12221231 reduceMotion={false}12231232 onBreachMonitoringChange={vi.fn()}1233+ onLockedSyncRequiresUnlockChange={vi.fn()}12241234 onLockOnCloseChange={vi.fn()}12251235 onAutoLockMinutesChange={onAutoLockMinutesChange}12261236 onTrashRetentionChange={vi.fn()}