f5eeec05e167
feat: Add opaque locked background sync spool (v4.9.2)
- time
- 2026-08-11 17:02 UTC · 2 months ago
- machine
- koh2
- parent
- e40a67cca1a3
↓ peach-browser-f5eeec05.tar.gzatlas at this save
files changed12 files
.gitignore+2 −0manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/entry.ts+9 −0src/background/locked-sync-spool.test.ts+99 −0src/background/locked-sync-spool.ts+188 −0src/background/mesh-relay-listener.ts+42 −17src/background/sync-coordinator.ts+6 −3src/background/sync-runner.ts+28 −20src/background/sync-vault-adapter.ts+59 −1src/background/vault.ts+26 −0.gitignore+2 −0
@@ -19,3 +19,5 @@1919 *.crx2020 .agent/2121 memory/22+# Local Pi runtime state23+.atl/manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.9.1",4+ "version": "4.9.2",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": "4.9.1",5+ "version": "4.9.2",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": "4.9.1",3+ "version": "4.9.2",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/entry.ts+9 −0
@@ -15,6 +15,11 @@1515 triggerSyncIfReachable,1616 } from "./sync-trigger";1717 import { productionSyncRunner } from "./sync-runner";18+import {19+ drainAndRefreshLockedSyncSpool,20+ refreshLockedSyncSpool,21+} from "./sync-vault-adapter";22+import { setLockedSyncHooks } from "./vault";1823 import { repairPairedRevocationOverlaps } from "./paired-devices";1924 import {2025 requestMeshRelayReconcile,@@ -55,6 +60,10 @@5560 });5661 5762 export function start(): void {63+ setLockedSyncHooks({64+ refresh: refreshLockedSyncSpool,65+ afterUnlock: drainAndRefreshLockedSyncSpool,66+ });5867 // MV3: the chrome.runtime.onMessage listener MUST be registered during the5968 // worker's initial SYNCHRONOUS evaluation. If registration is deferred behind6069 // an await, a popup/content message that wakes a cold worker is dispatchedsrc/background/locked-sync-spool.test.ts+99 −0
@@ -0,0 +1,99 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+3+type StorageShape = Record<string, unknown>;4+5+function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {6+ const area = (store: StorageShape) => ({7+ get: vi.fn(async (key?: string | string[]) => {8+ if (!key) return store;9+ if (typeof key === "string") return { [key]: store[key] };10+ return key.reduce<Record<string, unknown>>((out, item) => {11+ out[item] = store[item];12+ return out;13+ }, {});14+ }),15+ set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),16+ remove: vi.fn(async (keys: string | string[]) => {17+ for (const key of Array.isArray(keys) ? keys : [keys]) delete store[key];18+ }),19+ });20+ return {21+ storage: { local: area(local), sync: area(sync) },22+ alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },23+ idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },24+ runtime: { sendMessage: vi.fn(async () => undefined) },25+ } as unknown as typeof chrome;26+}27+28+describe("locked sync spool", () => {29+ let local: StorageShape;30+31+ beforeEach(() => {32+ vi.resetModules();33+ local = {};34+ (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, {});35+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));36+ });37+38+ it("retains only opaque envelopes and transport identity after a full lock", async () => {39+ const vault = await import("./vault");40+ await vault.createVault("correct horse battery staple");41+ const entry = await vault.addEntry({ type: "login", title: "Top Secret", password: "swordfish" });42+ const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");43+ await refreshLockedSyncSpool();44+ vault.lock();45+46+ const { createLockedSyncVaultAdapter, getLockedSyncIdentity } = await import("./locked-sync-spool");47+ const adapter = await createLockedSyncVaultAdapter();48+ const identity = await getLockedSyncIdentity();49+ expect(vault.isUnlocked()).toBe(false);50+ expect(identity?.longTermPrivateKey).toHaveLength(32);51+ expect(await adapter?.buildEntryManifest()).toEqual({ [entry.id]: entry.modifiedAt });52+ const envelopes = await adapter?.getEncryptedEntries([entry.id]);53+ expect(envelopes?.[entry.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);54+ expect(JSON.stringify(local)).not.toContain("Top Secret");55+ expect(JSON.stringify(local)).not.toContain("swordfish");56+ });57+58+ it("stages bounded inbound ciphertext without promoting it to the outbound manifest", async () => {59+ const vault = await import("./vault");60+ await vault.createVault("correct horse battery staple");61+ const entry = await vault.addEntry({ type: "login", title: "Cached", password: "secret" });62+ const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");63+ await refreshLockedSyncSpool();64+ vault.lock();65+66+ const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");67+ const adapter = (await createLockedSyncVaultAdapter())!;68+ await adapter.applyEntries({ remote: "AA==" }, { remote: entry.modifiedAt + 1 });69+70+ expect(await adapter.buildEntryManifest()).toEqual({ [entry.id]: entry.modifiedAt });71+ const stored = local.peach_locked_sync_spool_v1 as {72+ inboxEntries: Record<string, { ciphertext: string }>;73+ entries: Record<string, unknown>;74+ };75+ expect(stored.inboxEntries.remote?.ciphertext).toBe("AA==");76+ expect(stored.entries.remote).toBeUndefined();77+ });78+79+ it("drains staged envelopes through the live decrypt boundary after unlock", async () => {80+ const vault = await import("./vault");81+ const syncAdapter = await import("./sync-vault-adapter");82+ vault.setLockedSyncHooks({83+ refresh: syncAdapter.refreshLockedSyncSpool,84+ afterUnlock: syncAdapter.drainAndRefreshLockedSyncSpool,85+ });86+ await vault.createVault("correct horse battery staple");87+ const entry = await vault.addEntry({ type: "login", title: "Local", password: "secret" });88+ vault.lock();89+90+ const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");91+ const adapter = (await createLockedSyncVaultAdapter())!;92+ await adapter.applyEntries({ remote: "AA==" }, { remote: entry.modifiedAt + 1 });93+94+ expect(await vault.unlock("correct horse battery staple")).toBe(true);95+ const stored = local.peach_locked_sync_spool_v1 as { inboxEntries: Record<string, unknown> };96+ expect(stored.inboxEntries).toEqual({});97+ expect(vault.getEntryById(entry.id)?.title).toBe("Local");98+ });99+});src/background/locked-sync-spool.ts+188 −0
@@ -0,0 +1,188 @@1+import type { SyncVaultAdapter } from "./sync-coordinator";2+import type { SyncPeerIdentity } from "../shared/sync-transport";3+import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";4+import type { DeviceType } from "../shared/sync-types";5+6+const STORAGE_KEY = "peach_locked_sync_spool_v1";7+8+interface CachedEntry {9+ modifiedAt: number;10+ ciphertext: string;11+}12+13+interface StagedEntry extends CachedEntry {}14+15+interface LockedSyncSpool {16+ version: 1;17+ vaultId: string;18+ deviceId: string;19+ deviceName: string;20+ deviceType: DeviceType;21+ localVersion: number;22+ longTermPublicKeyHex: string;23+ longTermPrivateKeyHex: string;24+ entries: Record<string, CachedEntry>;25+ tombstones: Record<string, number>;26+ inboxEntries: Record<string, StagedEntry>;27+ inboxTombstones: Record<string, number>;28+}29+30+let spoolMutation: Promise<void> = Promise.resolve();31+32+export async function replaceLockedSyncSpool(33+ next: Omit<LockedSyncSpool, "version" | "inboxEntries" | "inboxTombstones">,34+): Promise<void> {35+ await mutateSpool(async (current) => {36+ await saveSpool({37+ ...next,38+ version: 1,39+ inboxEntries: current?.inboxEntries ?? {},40+ inboxTombstones: current?.inboxTombstones ?? {},41+ });42+ });43+}44+45+export async function clearLockedSyncSpool(): Promise<void> {46+ await mutateSpool(async () => chrome.storage.local.remove(STORAGE_KEY));47+}48+49+export async function getLockedSyncIdentity(): Promise<SyncPeerIdentity | null> {50+ const spool = await loadSpool();51+ if (!spool) return null;52+ return {53+ deviceId: spool.deviceId,54+ deviceName: spool.deviceName,55+ deviceType: spool.deviceType,56+ longTermPublicKeyHex: spool.longTermPublicKeyHex,57+ longTermPrivateKey: hexToBytes(spool.longTermPrivateKeyHex),58+ };59+}60+61+export async function drainLockedSyncInbox(62+ live: SyncVaultAdapter,63+): Promise<void> {64+ const snapshot = await loadSpool();65+ if (!snapshot) return;66+ const entries = Object.fromEntries(67+ Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.ciphertext]),68+ );69+ const manifest = Object.fromEntries(70+ Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.modifiedAt]),71+ );72+ if (Object.keys(entries).length > 0) await live.applyEntries(entries, manifest);73+ if (Object.keys(snapshot.inboxTombstones).length > 0) {74+ await live.applyTombstones(snapshot.inboxTombstones);75+ }76+ await mutateSpool(async (current) => {77+ if (!current) return;78+ const inboxEntries = { ...current.inboxEntries };79+ const inboxTombstones = { ...current.inboxTombstones };80+ for (const [id, applied] of Object.entries(snapshot.inboxEntries)) {81+ if (inboxEntries[id]?.ciphertext === applied.ciphertext) delete inboxEntries[id];82+ }83+ for (const [id, appliedAt] of Object.entries(snapshot.inboxTombstones)) {84+ if (inboxTombstones[id] === appliedAt) delete inboxTombstones[id];85+ }86+ await saveSpool({ ...current, inboxEntries, inboxTombstones });87+ });88+}89+90+export async function createLockedSyncVaultAdapter(): Promise<SyncVaultAdapter | null> {91+ const snapshot = await loadSpool();92+ if (!snapshot) return null;93+ return {94+ async computeVaultId() { return snapshot.vaultId; },95+ async getOurDeviceId() { return snapshot.deviceId; },96+ async getLocalVersion() { return snapshot.localVersion; },97+ async buildEntryManifest() {98+ return Object.fromEntries(99+ Object.entries(snapshot.entries).map(([id, entry]) => [id, entry.modifiedAt]),100+ );101+ },102+ async buildTombstoneManifest() { return { ...snapshot.tombstones }; },103+ async getEncryptedEntries(entryIds) {104+ const out: Record<string, string> = {};105+ for (const id of entryIds) {106+ const entry = snapshot.entries[id];107+ if (entry) out[id] = entry.ciphertext;108+ }109+ return out;110+ },111+ async applyEntries(entries, remoteManifest = {}) {112+ const list = Object.entries(entries);113+ if (list.length > SYNC_LIMITS.MAX_ENTRY_COUNT) throw new Error("sync_entry_count_exceeded");114+ await mutateSpool(async (current) => {115+ if (!current || current.vaultId !== snapshot.vaultId) return;116+ const inbox = { ...current.inboxEntries };117+ const now = Date.now();118+ for (const [id, ciphertext] of list) {119+ const claimed = remoteManifest[id];120+ if (121+ typeof claimed !== "number" || !Number.isFinite(claimed) || claimed <= 0 ||122+ ciphertext.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64123+ ) continue;124+ const modifiedAt = clampSyncTimestamp(claimed, now);125+ if ((inbox[id]?.modifiedAt ?? 0) < modifiedAt) {126+ inbox[id] = { modifiedAt, ciphertext };127+ }128+ }129+ await saveSpool({ ...current, inboxEntries: inbox });130+ });131+ },132+ async applyTombstones(tombstones) {133+ const list = Object.entries(tombstones);134+ if (list.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) throw new Error("sync_tombstone_count_exceeded");135+ await mutateSpool(async (current) => {136+ if (!current || current.vaultId !== snapshot.vaultId) return;137+ const inbox = { ...current.inboxTombstones };138+ const now = Date.now();139+ for (const [id, value] of list) {140+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;141+ const deletedAt = clampSyncTimestamp(value, now);142+ if ((inbox[id] ?? 0) < deletedAt) inbox[id] = deletedAt;143+ }144+ await saveSpool({ ...current, inboxTombstones: inbox });145+ });146+ },147+ };148+}149+150+async function mutateSpool(work: (spool: LockedSyncSpool | null) => Promise<void>): Promise<void> {151+ const next = spoolMutation.then(async () => work(await loadSpool()));152+ spoolMutation = next.catch(() => undefined);153+ return next;154+}155+156+async function loadSpool(): Promise<LockedSyncSpool | null> {157+ const result = await chrome.storage.local.get(STORAGE_KEY);158+ const value = (result as Record<string, unknown>)[STORAGE_KEY];159+ return isLockedSyncSpool(value) ? value : null;160+}161+162+async function saveSpool(spool: LockedSyncSpool): Promise<void> {163+ await chrome.storage.local.set({ [STORAGE_KEY]: spool });164+}165+166+function isLockedSyncSpool(value: unknown): value is LockedSyncSpool {167+ if (!value || typeof value !== "object") return false;168+ const candidate = value as Partial<LockedSyncSpool>;169+ return candidate.version === 1 &&170+ typeof candidate.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) &&171+ typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 &&172+ typeof candidate.deviceName === "string" &&173+ typeof candidate.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) &&174+ typeof candidate.longTermPrivateKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPrivateKeyHex) &&175+ typeof candidate.localVersion === "number" &&176+ isRecord(candidate.entries) && isRecord(candidate.tombstones) &&177+ isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones);178+}179+180+function isRecord(value: unknown): value is Record<string, unknown> {181+ return !!value && typeof value === "object" && !Array.isArray(value);182+}183+184+function hexToBytes(hex: string): Uint8Array {185+ const out = new Uint8Array(hex.length / 2);186+ for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);187+ return out;188+}src/background/mesh-relay-listener.ts+42 −17
@@ -16,6 +16,10 @@1616 } from "../shared/rendezvous";1717 import { runSyncResponder } from "./sync-coordinator";1818 import { createSyncVaultAdapter } from "./sync-vault-adapter";19+import {20+ createLockedSyncVaultAdapter,21+ getLockedSyncIdentity,22+} from "./locked-sync-spool";1923 import { getLocalPairingIdentity } from "./device-identity";2024 import {2125 findPairedDeviceById,@@ -56,11 +60,8 @@5660 export async function selectProductionDialTargets(5761 devices: PairedDevice[],5862 ): Promise<PairedDevice[]> {59- // Preserve the established VAULT_LOCKED outcome. The production runner60- // refuses before opening a socket, so returning the candidates here is safe61- // and avoids misreporting a locked vault as "no eligible host".62- if (!isUnlocked()) return devices;63- const local = await getLocalPairingIdentity();63+ const local = await getMeshIdentity();64+ if (!local) return devices;6465 try {6566 return selectDialTargets(6667 devices,@@ -95,12 +96,8 @@9596 if (listener.timer !== null) clearTimeout(listener.timer);9697 listener.timer = setTimeout(requestMeshRelayReconcile, RECONCILE_INTERVAL_MS);9798 98- if (!isUnlocked()) {99- abortAll(listener);100- return;101- }102-103- const local = await getLocalPairingIdentity();99+ const local = await getMeshIdentity();100+ if (!local) { abortAll(listener); return; }104101 const desired = new Map<string, {105102 peer: PairedDevice;106103 relayUrl: string;@@ -157,7 +154,7 @@157154 signal: AbortSignal,158155 ): Promise<void> {159156 let retryDelayMs = MESH_WAITER_RETRY_DELAY_MS;160- while (!signal.aborted && isUnlocked()) {157+ while (!signal.aborted) {161158 const current = await findPairedDeviceById(target.peer.deviceId);162159 if (!current || !isRouteCapableMeshPeer(current)) return;163160 @@ -165,7 +162,8 @@165162 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;166163 let succeeded = false;167164 try {168- local = await getLocalPairingIdentity();165+ local = await getMeshIdentity();166+ if (!local) return;169167 const identity: SyncPeerIdentity = {170168 ...local.identity,171169 longTermPrivateKey: local.privateKey,@@ -177,10 +175,11 @@177175 target.rendezvousId,178176 { signal },179177 );180- const outcome = await runSyncResponder(181- session,182- createSyncVaultAdapter({ deviceName: current.deviceName }),183- );178+ const adapter = isUnlocked()179+ ? createSyncVaultAdapter({ deviceName: current.deviceName })180+ : await createLockedSyncVaultAdapter();181+ if (!adapter) return;182+ const outcome = await runSyncResponder(session, adapter);184183 if (outcome.status === "completed") {185184 await markLastSyncedAt(current.deviceId, Date.now());186185 succeeded = true;@@ -196,6 +195,32 @@196195 await waitForRetry(signal, retryDelayMs);197196 retryDelayMs = nextMeshWaiterRetryDelay(retryDelayMs, succeeded);198197 }198+}199+200+async function getMeshIdentity(): Promise<{201+ identity: Omit<SyncPeerIdentity, "longTermPrivateKey">;202+ privateKey: Uint8Array;203+ publicKey: Uint8Array;204+} | null> {205+ if (isUnlocked()) return getLocalPairingIdentity();206+ const cached = await getLockedSyncIdentity();207+ if (!cached) return null;208+ return {209+ identity: {210+ deviceId: cached.deviceId,211+ deviceName: cached.deviceName,212+ deviceType: cached.deviceType,213+ longTermPublicKeyHex: cached.longTermPublicKeyHex,214+ },215+ privateKey: cached.longTermPrivateKey,216+ publicKey: hexToBytes(cached.longTermPublicKeyHex),217+ };218+}219+220+function hexToBytes(hex: string): Uint8Array {221+ const out = new Uint8Array(hex.length / 2);222+ for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);223+ return out;199224 }200225 201226 function waitForRetry(signal: AbortSignal, delayMs: number): Promise<void> {src/background/sync-coordinator.ts+6 −3
@@ -61,7 +61,10 @@6161 /** Returns base64 ciphertext for each requested entry id. */6262 getEncryptedEntries(entryIds: string[]): Promise<Record<string, string>>;6363 /** Apply incoming encrypted entries; vault decides per-entry overwrite. */64- applyEntries(entries: Record<string, string>): Promise<void>;64+ applyEntries(65+ entries: Record<string, string>,66+ remoteManifest?: Record<string, number>,67+ ): Promise<void>;6568 /** Apply incoming tombstones; vault retains the latest deletion ts. */6669 applyTombstones(tombstones: Record<string, number>): Promise<void>;6770 /**@@ -226,7 +229,7 @@226229 if (entriesIn.type !== "SYNC_ENTRIES") {227230 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);228231 }229- await vault.applyEntries(entriesIn.entries);232+ await vault.applyEntries(entriesIn.entries, manifestIn.entries);230233 result.entriesReceived = Object.keys(entriesIn.entries).length;231234 }232235 @@ -412,7 +415,7 @@412415 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);413416 }414417 if (wantFromPeer.length > 0) {415- await vault.applyEntries(entriesIn.entries);418+ await vault.applyEntries(entriesIn.entries, manifestIn.entries);416419 result.entriesReceived = Object.keys(entriesIn.entries).length;417420 }418421 if (requestIn.entryIds.length > 0) {src/background/sync-runner.ts+28 −20
@@ -17,6 +17,7 @@1717 1818 import { runSyncInitiator, type SyncVaultAdapter } from "./sync-coordinator";1919 import { createSyncVaultAdapter } from "./sync-vault-adapter";20+import { createLockedSyncVaultAdapter, getLockedSyncIdentity } from "./locked-sync-spool";2021 import {2122 openRelaySyncSession,2223 type SyncSession,@@ -67,7 +68,6 @@6768 device: PairedDevice,6869 deps: SyncRunnerDeps,6970 ): Promise<SyncRunResult> {70- if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };7171 if (!device.longTermPublicKeyHex) {7272 throw new Error("paired_device_missing_pubkey");7373 }@@ -81,29 +81,37 @@8181 );8282 // Sync identity derives from the stable root key, not the password-derived8383 // key. Reconnect uses both halves directly in standard Noise KK.84- const masterKeyHex = getRootKeyHex();85- if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };86- const masterKey = hexToBytesLocal(masterKeyHex);87- const publicIdentity = await buildLocalIdentity(masterKey);88- const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);89- if (!constantTimeEqualHex(90- publicIdentity.longTermPublicKeyHex,91- bytesToHex(localKeys.publicKey),92- )) {93- masterKey.fill(0);94- localKeys.secretKey.fill(0);95- throw new Error("derived reconnect identity mismatch");84+ let masterKey: Uint8Array | null = null;85+ let privateKey: Uint8Array | null = null;86+ let ourIdentity: SyncPeerIdentity;87+ let adapter: SyncVaultAdapter;88+ if (isUnlocked()) {89+ const masterKeyHex = getRootKeyHex();90+ if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };91+ masterKey = hexToBytesLocal(masterKeyHex);92+ const publicIdentity = await buildLocalIdentity(masterKey);93+ const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);94+ privateKey = localKeys.secretKey;95+ if (!constantTimeEqualHex(publicIdentity.longTermPublicKeyHex, bytesToHex(localKeys.publicKey))) {96+ masterKey.fill(0);97+ privateKey.fill(0);98+ throw new Error("derived reconnect identity mismatch");99+ }100+ ourIdentity = { ...publicIdentity, longTermPrivateKey: privateKey };101+ adapter = deps.createAdapter({ deviceName: device.deviceName });102+ } else {103+ const cachedIdentity = await getLockedSyncIdentity();104+ const cachedAdapter = await createLockedSyncVaultAdapter();105+ if (!cachedIdentity || !cachedAdapter) return { ok: false, error: "VAULT_LOCKED" };106+ ourIdentity = cachedIdentity;107+ privateKey = cachedIdentity.longTermPrivateKey;108+ adapter = cachedAdapter;96109 }97- const ourIdentity: SyncPeerIdentity = {98- ...publicIdentity,99- longTermPrivateKey: localKeys.secretKey,100- };101110 102111 // Sanity — the remote pubkey must be 32-byte hex so noise can compare it.103112 void hexToPubkey(expectedRemoteHex);104113 105114 // HV-1b: pass the peer being synced so remote-mutation alerts can name it.106- const adapter = deps.createAdapter({ deviceName: device.deviceName });107115 let session: SyncSession;108116 try {109117 const settings = await deps.getSyncSettings();@@ -116,8 +124,8 @@116124 deps.openRelaySession,117125 );118126 } finally {119- masterKey.fill(0);120- localKeys.secretKey.fill(0);127+ masterKey?.fill(0);128+ privateKey?.fill(0);121129 }122130 123131 try {src/background/sync-vault-adapter.ts+59 −1
@@ -25,7 +25,7 @@2525 import { HKDF } from "@stablelib/hkdf";2626 import { SHA256 } from "@stablelib/sha256";2727 import type { SyncVaultAdapter } from "./sync-coordinator";28-import { getOrCreateDeviceId } from "./device-identity";28+import { getDeviceName, getOrCreateDeviceId } from "./device-identity";2929 import {3030 computeVaultId,3131 deriveSyncIdentityKeypair,@@ -73,6 +73,7 @@7373 PairedDevice,7474 SignedDeviceManifest,7575 } from "../shared/sync-types";76+import { replaceLockedSyncSpool, drainLockedSyncInbox } from "./locked-sync-spool";7677 7778 // Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").7879 // The info binding prevents a ciphertext from one entry slot being replayed into another.@@ -232,6 +233,59 @@232233 };233234 }234235 236+export async function refreshLockedSyncSpool(): Promise<void> {237+ requireUnlocked();238+ const masterKey = requireMasterKey();239+ const deviceId = await getOrCreateDeviceId();240+ const identity = await buildCachedIdentity(masterKey, deviceId);241+ const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};242+ try {243+ for (const entry of getEntries()) {244+ if (entry.deletedAt) continue;245+ const key = await deriveEntryKey(masterKey, entry.id);246+ cachedEntries[entry.id] = {247+ modifiedAt: entry.modifiedAt,248+ ciphertext: await encryptEntry(key, entry),249+ };250+ }251+ const tombstones: Record<string, number> = {};252+ for (const entry of getDeletedEntries()) {253+ if (typeof entry.deletedAt === "number") tombstones[entry.id] = entry.deletedAt;254+ }255+ const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;256+ await replaceLockedSyncSpool({257+ ...storedIdentity,258+ localVersion: await getVaultVersion(),259+ entries: cachedEntries,260+ tombstones,261+ });262+ } finally {263+ masterKey.fill(0);264+ identity.longTermPrivateKeyBytes.fill(0);265+ }266+}267+268+export async function drainAndRefreshLockedSyncSpool(): Promise<void> {269+ const live = createSyncVaultAdapter();270+ await drainLockedSyncInbox(live);271+ await refreshLockedSyncSpool();272+}273+274+async function buildCachedIdentity(masterKey: Uint8Array, deviceId: string) {275+ const vaultId = await computeVaultId(masterKey);276+ const keys = deriveSyncIdentityKeypair(masterKey, deviceId);277+ const deviceName = await getDeviceName();278+ return {279+ vaultId,280+ deviceId,281+ deviceName,282+ deviceType: "extension" as const,283+ longTermPublicKeyHex: pubkeyToHex(keys.publicKey),284+ longTermPrivateKeyHex: bytesToHex(keys.secretKey),285+ longTermPrivateKeyBytes: keys.secretKey,286+ };287+}288+235289 function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {236290 if (237291 !device.deviceId ||@@ -582,6 +636,10 @@582636 let bin = "";583637 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);584638 return btoa(bin);639+}640+641+function bytesToHex(bytes: Uint8Array): string {642+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");585643 }586644 587645 function base64ToBytes(b64: string): Uint8Array {src/background/vault.ts+26 −0
@@ -210,6 +210,24 @@210210 });211211 }212212 213+let lockedSyncRefreshHook: (() => Promise<void>) | null = null;214+let lockedSyncUnlockHook: (() => Promise<void>) | null = null;215+216+export function setLockedSyncHooks(hooks: {217+ refresh: () => Promise<void>;218+ afterUnlock: () => Promise<void>;219+}): void {220+ lockedSyncRefreshHook = hooks.refresh;221+ lockedSyncUnlockHook = hooks.afterUnlock;222+}223+224+async function refreshLockedSyncState(): Promise<void> {225+ if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;226+ await lockedSyncRefreshHook().catch((error) => {227+ console.warn("[Peach sync] locked spool refresh failed:", error);228+ });229+}230+213231 // ---------------------------------------------------------------------------214232 // HIBP breach check (k-anonymity model)215233 // ---------------------------------------------------------------------------@@ -1365,6 +1383,9 @@13651383 if (payloadA.migratedFromLegacy) {13661384 await persistSlotA();13671385 }1386+ await lockedSyncUnlockHook?.().catch((error) => {1387+ console.warn("[Peach sync] locked inbox drain failed:", error);1388+ });13681389 return true;13691390 }13701391 @@ -1459,6 +1480,10 @@14591480 }14601481 }14611482 1483+ await lockedSyncUnlockHook?.().catch((error) => {1484+ console.warn("[Peach sync] locked inbox drain failed:", error);1485+ });1486+14621487 return true;14631488 }14641489 @@ -2436,6 +2461,7 @@24362461 await persistSlotA(reason);24372462 }24382463 buildDomainIndexSync(entries);2464+ await refreshLockedSyncState();24392465 }24402466 24412467 function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {