3cf91c205c62

persist derived key in chrome.storage.session; restore vault state on SW restart without user re-unlock, fix onConnect dead code (v3.6.27)

time
2026-05-19 15:54 UTC · 5 months ago
machine
koh2
parent
02cdb92bcff6
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/entry.ts+22 −0src/background/vault.ts+58 −0src/popup/components/settings/StackDeck.tsx+1 −1
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach",5-  "version": "3.6.26",5+  "version": "3.6.27",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "3.6.26",4+  "version": "3.6.27",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": "3.6.26",3+  "version": "3.6.27",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/entry.ts+22 −0
@@ -2,6 +2,10 @@22 import { setupAutoLock } from "./alarms";33 import { setupPasskeyProxy } from "./passkey";44 import { setupCommands } from "./commands";5+import { restoreFromSession, isUnlocked, lock } from "./vault";6+import { ensureSettingsLoaded } from "./vault";7+import { isRecoveryConfirmPending } from "./recovery-pending";8+import { denyAllPendingPasskeyRequests } from "./passkey";59 610 // Keep alive for MV3 service worker711 chrome.runtime.onInstalled.addListener(() => {});@@ -9,7 +13,25 @@913 // The chrome.idle.onStateChanged listener lives in ./alarms (the architectural1014 // home for auto-lock timing). Keeping it in only one place avoids drift.1115 16+// Lock-on-close via port disconnect. The popup opens a "popup" named port;17+// when that port disconnects (popup closed), lock if lockOnClose is enabled.18+// Previously declared in index.ts which was never bundled — moved here.19+chrome.runtime.onConnect.addListener((port) => {20+  if (port.name !== "popup") return;21+  port.onDisconnect.addListener(async () => {22+    await denyAllPendingPasskeyRequests();23+    const s = await ensureSettingsLoaded();24+    if (!isUnlocked() || s.lockOnClose === false) return;25+    if (await isRecoveryConfirmPending()) return;26+    lock();27+  });28+});29+1230 export function start() {31+  // Restore vault unlock state from chrome.storage.session if the MV3 service32+  // worker was restarted mid-session. Runs before message handlers are active33+  // so the first incoming message from the popup sees the correct unlock state.34+  void restoreFromSession();1335   setupMessageHandler();1436   void setupAutoLock();1537   setupCommands();

view file

src/background/vault.ts+58 −0
@@ -1045,6 +1045,7 @@10451045 10461046   if (payloadA && keyA) {10471047     derivedKey = keyA;1048+    void saveSessionKey(keyA, "real");10481049     vaultMode = "real";10491050     entries = payloadA.entries;10501051     duressConfigured = payloadA.duressConfigured;@@ -1090,6 +1091,7 @@10901091 10911092   if (payloadB && keyB) {10921093     derivedKey = keyB;1094+    void saveSessionKey(keyB, "decoy");10931095     vaultMode = "decoy";10941096     entries = payloadB.entries;10951097     duressConfigured = false;@@ -1112,7 +1114,63 @@11121114   return false;11131115 }11141116 1117+// ---------------------------------------------------------------------------1118+// MV3 service worker session key — survives SW restarts within a browser session1119+// ---------------------------------------------------------------------------1120+1121+// chrome.storage.session is cleared on browser close; survives SW restart.1122+// Firefox does not implement it (persistent background script, no restart risk).1123+const SESSION_KEY_STORAGE = "peach_session_dk";1124+1125+async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1126+  if (!chrome.storage.session) return;1127+  await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1128+}1129+1130+async function clearSessionKey(): Promise<void> {1131+  if (!chrome.storage.session) return;1132+  await chrome.storage.session.remove(SESSION_KEY_STORAGE);1133+}1134+1135+/**1136+ * On SW restart, re-derive all in-memory vault state from the session key1137+ * stored in chrome.storage.session (set at unlock time, cleared on lock).1138+ * Returns true if the vault was restored, false if no session key was found.1139+ * No-ops if the vault is already unlocked.1140+ */1141+export async function restoreFromSession(): Promise<boolean> {1142+  if (derivedKey !== null) return true;1143+  if (!chrome.storage.session) return false;1144+  const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1145+  const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1146+  if (!entry?.keyHex) return false;1147+1148+  const key = unhex(entry.keyHex);1149+  const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1150+  const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1151+  const slot = await loadSlot(slotKey);1152+  if (!slot) { await clearSessionKey(); return false; }1153+1154+  const result = await tryDecryptSlotResult(slot, key, aadTag);1155+  if (result.status !== "ok") { await clearSessionKey(); return false; }1156+1157+  derivedKey = key;1158+  vaultMode = entry.mode;1159+  entries = result.payload.entries;1160+  duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1161+  buildDomainIndexSync(entries);1162+  await loadSettings();1163+  touchAutoLock();1164+1165+  if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1166+    try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { /* non-fatal */ }1167+  }1168+1169+  return true;1170+}1171+11151172 export function lock(): void {1173+  void clearSessionKey();11161174   if (derivedKey) {11171175     zeroKey(derivedKey);11181176     derivedKey = null;

view file

src/popup/components/settings/StackDeck.tsx+1 −1
@@ -1,4 +1,4 @@1-import { createMemo, createSignal, For, Show, onMount, onCleanup } from "solid-js";1+import { createMemo, createSignal, For, onMount, onCleanup } from "solid-js";22 import { StackCard } from "./StackCard";33 import type { CardId, StackBodies, StackCardSpec } from "./types";44 

view file