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
↓ peach-browser-056807d6.tar.gzatlas at this save
files changed7 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 −1manifest.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": {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",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",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 });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" &&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();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(),