056807d6a42e

move the locked-sync spool to session storage so locked sync keeps working without a disk-resident key (PB-26-104, v5.0.46)

time
2026-09-02 04:34 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
assessment remediation corrections
parent
11473a0cb44f
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/locked-sync-spool.test.ts+42 −9src/background/locked-sync-spool.ts+56 −8src/background/sync-runner.test.ts+37 −3src/background/sync-vault-adapter.ts+5 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.45",4+  "version": "5.0.46",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.0.45",5+  "version": "5.0.46",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.0.45",3+  "version": "5.0.46",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/locked-sync-spool.test.ts+42 −9
@@ -2,7 +2,7 @@22 33 type StorageShape = Record<string, unknown>;44 5-function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {5+function makeChromeMock(local: StorageShape, sync: StorageShape, session: StorageShape): typeof chrome {66   const area = (store: StorageShape) => ({77     get: vi.fn(async (key?: string | string[]) => {88       if (!key) return store;@@ -18,7 +18,7 @@1818     }),1919   });2020   return {21-    storage: { local: area(local), sync: area(sync) },21+    storage: { local: area(local), sync: area(sync), session: area(session) },2222     alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },2323     idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },2424     runtime: { sendMessage: vi.fn(async () => undefined) },@@ -27,11 +27,13 @@2727 2828 describe("locked sync spool", () => {2929   let local: StorageShape;30+  let session: StorageShape;3031 3132   beforeEach(() => {3233     vi.resetModules();3334     local = {};34-    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, {});35+    session = {};36+    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, {}, session);3537     vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));3638   });3739 @@ -47,9 +49,15 @@4749     const adapter = await createLockedSyncVaultAdapter();4850     const identity = await getLockedSyncIdentity();4951     expect(vault.isUnlocked()).toBe(false);50-    expect(identity).toBeNull();52+    // PB-26-104: locked sync is not allowed to die as a side effect of53+    // protecting the key — the identity has to still be usable.54+    expect(identity).not.toBeNull();55+    expect(identity?.longTermPrivateKey).toHaveLength(32);56+    // And no part of the spool, private key included, is on disk.57+    expect(local.peach_locked_sync_spool_v1).toBeUndefined();5158     expect(JSON.stringify(local)).not.toContain("longTermPrivateKeyHex");5259     expect(JSON.stringify(local)).not.toContain("longTermPrivateKey");60+    expect(session.peach_locked_sync_spool_v1).toBeDefined();5361     expect(await adapter?.buildEntryManifest()).toEqual({ [entry.id]: entry.modifiedAt });5462     const envelopes = await adapter?.getEncryptedEntries([entry.id]);5563     expect(envelopes?.[entry.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);@@ -70,7 +78,7 @@7078     await adapter.applyEntries({ remote: "AA==" }, { remote: entry.modifiedAt + 1 });7179 7280     expect(await adapter.buildEntryManifest()).toEqual({ [entry.id]: entry.modifiedAt });73-    const stored = local.peach_locked_sync_spool_v1 as {81+    const stored = session.peach_locked_sync_spool_v1 as {7482       inboxEntries: Record<string, { ciphertext: string }>;7583       entries: Record<string, unknown>;7684     };@@ -87,7 +95,7 @@8795 8896     const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");8997     const adapter = (await createLockedSyncVaultAdapter())!;90-    const stored = local.peach_locked_sync_spool_v1 as {98+    const stored = session.peach_locked_sync_spool_v1 as {9199       inboxEntries: Record<string, { modifiedAt: number; ciphertext: string }>;92100       inboxTombstones: Record<string, number>;93101     };@@ -102,7 +110,10 @@102110       .rejects.toThrow("sync_tombstone_count_exceeded");103111   });104112 105-  it("fails closed on a legacy private-key-bearing record until unlock refreshes it", async () => {113+  // PB-26-104: profiles upgraded from the disk-backed spool still hold a114+  // cleartext private key in chrome.storage.local. It has to go on first use,115+  // not on the next unlock, and the stale record must never be adopted.116+  it("scrubs a legacy disk-backed spool and refuses to adopt it", async () => {106117     local.peach_locked_sync_spool_v1 = {107118       version: 1,108119       vaultId: "a".repeat(64),@@ -120,9 +131,31 @@120131     const { createLockedSyncVaultAdapter, getLockedSyncIdentity } = await import("./locked-sync-spool");121132     expect(await getLockedSyncIdentity()).toBeNull();122133     expect(await createLockedSyncVaultAdapter()).toBeNull();123-    expect((local.peach_locked_sync_spool_v1 as { inboxEntries: object }).inboxEntries).toEqual({ queued: { modifiedAt: 1, ciphertext: "AA==" } });134+    // The cleartext key is gone from disk rather than left until unlock.135+    expect(local.peach_locked_sync_spool_v1).toBeUndefined();124136   });125137 138+  // chrome.storage.session is memory-backed but outlives the MV3 service139+  // worker, which is what makes it the right home for the spool: locked sync140+  // keeps working across worker death without the key ever reaching disk.141+  it("the spool survives a service-worker restart but not a new browser session", async () => {142+    const vault = await import("./vault");143+    await vault.createVault("correct horse battery staple");144+    const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");145+    await refreshLockedSyncSpool();146+    vault.lock();147+148+    vi.resetModules();149+    const { getLockedSyncIdentity } = await import("./locked-sync-spool");150+    expect(await getLockedSyncIdentity()).not.toBeNull();151+152+    // Browser restart: the session area is cleared for us.153+    for (const key of Object.keys(session)) delete session[key];154+    vi.resetModules();155+    const afterRestart = await import("./locked-sync-spool");156+    expect(await afterRestart.getLockedSyncIdentity()).toBeNull();157+  });158+126159   it("drains staged envelopes through the live decrypt boundary after unlock", async () => {127160     const vault = await import("./vault");128161     const syncAdapter = await import("./sync-vault-adapter");@@ -139,7 +172,7 @@139172     await adapter.applyEntries({ remote: "AA==" }, { remote: entry.modifiedAt + 1 });140173 141174     expect(await vault.unlock("correct horse battery staple")).toBe(true);142-    const stored = local.peach_locked_sync_spool_v1 as { inboxEntries: Record<string, unknown> };175+    const stored = session.peach_locked_sync_spool_v1 as { inboxEntries: Record<string, unknown> };143176     expect(stored.inboxEntries).toEqual({});144177     expect(vault.getEntryById(entry.id)?.title).toBe("Local");145178   });

view file

src/background/locked-sync-spool.ts+56 −8
@@ -5,6 +5,22 @@55 66 const STORAGE_KEY = "peach_locked_sync_spool_v1";77 8+/**9+ * The spool lives in `chrome.storage.session`, never `chrome.storage.local`.10+ *11+ * PB-26-104: locked sync needs the device's long-term Noise static while the12+ * vault is locked, which is exactly when no material exists to wrap it with —13+ * anything available while locked is by definition at rest. Session storage is14+ * memory-backed: the key never reaches disk, surviving the MV3 service-worker15+ * restarts that a plain in-memory cache would not, and dying with the browser16+ * session. That is the same accepted profile as the master-password-derived17+ * session key (M-1).18+ *19+ * Session storage is quota-bound, so a vault too large to spool simply loses20+ * locked sync for that browser session: the write failure is caught by21+ * vault.refreshLockedSyncState and lock() still completes.22+ */23+824 interface CachedEntry {925   modifiedAt: number;1026   ciphertext: string;@@ -20,6 +36,7 @@2036   deviceType: DeviceType;2137   localVersion: number;2238   longTermPublicKeyHex: string;39+  longTermPrivateKeyHex: string;2340   entries: Record<string, CachedEntry>;2441   tombstones: Record<string, number>;2542   inboxEntries: Record<string, StagedEntry>;@@ -42,10 +59,25 @@4259 }4360 4461 export async function getLockedSyncIdentity(): Promise<SyncPeerIdentity | null> {45-  // A private key must never outlive the unlocked vault; defer all Noise auth.46-  return null;62+  const snapshot = await loadSpool();63+  if (!snapshot) return null;64+  return {65+    deviceId: snapshot.deviceId,66+    deviceName: snapshot.deviceName,67+    deviceType: snapshot.deviceType,68+    longTermPublicKeyHex: snapshot.longTermPublicKeyHex,69+    longTermPrivateKey: hexToBytes(snapshot.longTermPrivateKeyHex),70+  };4771 }4872 73+function hexToBytes(value: string): Uint8Array {74+  const out = new Uint8Array(value.length / 2);75+  for (let i = 0; i < out.length; i += 1) {76+    out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);77+  }78+  return out;79+}80+4981 export async function drainLockedSyncInbox(5082   live: SyncVaultAdapter,5183 ): Promise<void> {@@ -148,23 +180,39 @@148180 }149181 150182 async function loadSpool(): Promise<LockedSyncSpool | null> {151-  const result = await chrome.storage.local.get(STORAGE_KEY);183+  await removeLegacyDiskSpool();184+  if (!chrome.storage.session) return null;185+  const result = await chrome.storage.session.get(STORAGE_KEY);152186   const value = (result as Record<string, unknown>)[STORAGE_KEY];153187   return isLockedSyncSpool(value) ? value : null;154188 }155189 156190 async function saveSpool(spool: LockedSyncSpool): Promise<void> {157-  await chrome.storage.local.set({ [STORAGE_KEY]: spool });191+  if (!chrome.storage.session) return;192+  await chrome.storage.session.set({ [STORAGE_KEY]: spool });158193 }159194 195+/**196+ * Profiles that ran an earlier build still hold the spool — cleartext private197+ * key included — in chrome.storage.local. Drop it on first use rather than198+ * waiting for an unlock that may never come.199+ */200+let legacyDiskSpoolRemoval: Promise<void> | null = null;201+202+function removeLegacyDiskSpool(): Promise<void> {203+  if (!legacyDiskSpoolRemoval) {204+    legacyDiskSpoolRemoval = Promise.resolve(chrome.storage.local.remove(STORAGE_KEY))205+      .catch(() => undefined);206+  }207+  return legacyDiskSpoolRemoval;208+}209+160210 function isLockedSyncSpool(value: unknown): value is LockedSyncSpool {161211   if (!value || typeof value !== "object") return false;162212   const candidate = value as Partial<LockedSyncSpool>;163-  if (164-    Object.hasOwn(candidate, "longTermPrivateKeyHex") ||165-    Object.hasOwn(candidate, "longTermPrivateKey")166-  ) return false;167213   return candidate.version === 1 &&214+    typeof candidate.longTermPrivateKeyHex === "string" &&215+    /^[0-9a-f]{64}$/.test(candidate.longTermPrivateKeyHex) &&168216     typeof candidate.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) &&169217     typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 &&170218     typeof candidate.deviceName === "string" &&

view file

src/background/sync-runner.test.ts+37 −3
@@ -8,7 +8,11 @@88 99 type StorageShape = Record<string, unknown>;1010 11-function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {11+function makeChromeMock(12+  local: StorageShape,13+  sync: StorageShape,14+  session: StorageShape = {},15+): typeof chrome {1216   const area = (store: StorageShape) => ({1317     get: vi.fn(async (key?: string | string[]) => {1418       if (!key) return store;@@ -22,7 +26,7 @@2226     }),2327   });2428   return {25-    storage: { local: area(local), sync: area(sync) },29+    storage: { local: area(local), sync: area(sync), session: area(session) },2630     alarms: {2731       clear: vi.fn(),2832       create: vi.fn(),@@ -179,12 +183,14 @@179183 describe("productionSyncRunner", () => {180184   let local: StorageShape;181185   let sync: StorageShape;186+  let sessionStore: StorageShape;182187 183188   beforeEach(() => {184189     vi.resetModules();185190     local = {};186191     sync = {};187-    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);192+    sessionStore = {};193+    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync, sessionStore);188194     vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));189195   });190196 @@ -193,6 +199,34 @@193199     await vault.createVault("correct horse battery staple");194200     return vault;195201   }202+203+  // PB-26-104: locked sync has to keep working with the spool moved to204+  // chrome.storage.session. The previous fix removed the private key from the205+  // spool outright, which silently turned every locked round into VAULT_LOCKED.206+  it("completes a sync round while locked, using the session-scoped spool", async () => {207+    const vault = await bootVault();208+    await vault.addEntry({ type: "login", title: "Locked Round", password: "swordfish" });209+    const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");210+    await refreshLockedSyncSpool();211+    vault.lock();212+    expect(vault.isUnlocked()).toBe(false);213+214+    const { session, peer } = inMemoryPair();215+    const deps: SyncRunnerDeps = {216+      openRelaySession: vi.fn(async () => session),217+      getSyncSettings: relaySettings,218+      createAdapter: emptyAdapter,219+    };220+    const { runWithDeps } = await import("./sync-runner");221+    const runPromise = runWithDeps(makeDevice(), deps);222+    await runPeer(peer, {});223+    const result = await runPromise;224+225+    expect(result.ok).toBe(true);226+    // Nothing about the spool reached disk-backed storage.227+    expect(JSON.stringify(local)).not.toContain("longTermPrivateKey");228+    expect(local.peach_locked_sync_spool_v1).toBeUndefined();229+  });196230 197231   it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {198232     const vault = await bootVault();

view file

src/background/sync-vault-adapter.ts+5 −1
@@ -233,7 +233,11 @@233233       };234234     }235235     const tombstones = getTombstoneManifest();236-    const { longTermPrivateKeyBytes: _privateKeyBytes, longTermPrivateKeyHex: _privateKeyHex, ...storedIdentity } = identity;236+    // PB-26-104: the private key rides along because locked sync needs it, but237+    // the spool is written to chrome.storage.session — never to disk. See238+    // locked-sync-spool.ts for why session storage is the only home that239+    // satisfies both constraints.240+    const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;237241     await replaceLockedSyncSpool({238242       ...storedIdentity,239243       localVersion: await getVaultVersion(),

view file