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
12 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/

view file

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": {

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

view file

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 dispatched

view file

src/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+});

view file

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

view file

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> {

view file

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

view file

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 {

view file

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 {

view file

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 {

view file