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
12 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 −0
manifest.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": {

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": "5.2.52",5+  "version": "5.2.53",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": "5.2.52",3+  "version": "5.2.53",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

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

view file

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 () => {

view file

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,

view file

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

view file

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,

view file

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;

view file

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}

view file

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>

view file

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

view file