import type { SyncVaultAdapter } from "./sync-coordinator"; import type { SyncPeerIdentity } from "../shared/sync-transport"; import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits"; import type { DeviceType } from "../shared/sync-types"; const STORAGE_KEY = "peach_locked_sync_spool_v1"; /** * The spool lives in `chrome.storage.session`, never `chrome.storage.local`. * * PB-26-104: locked sync needs the device's long-term Noise static while the * vault is locked, which is exactly when no material exists to wrap it with — * anything available while locked is by definition at rest. Session storage is * memory-backed: the key never reaches disk, surviving the MV3 service-worker * restarts that a plain in-memory cache would not, and dying with the browser * session. That is the same accepted profile as the master-password-derived * session key (M-1). * * Session storage is quota-bound, so a vault too large to spool simply loses * locked sync for that browser session: the write failure is caught by * vault.refreshLockedSyncState and lock() still completes. */ interface CachedEntry { modifiedAt: number; ciphertext: string; } interface StagedEntry extends CachedEntry {} interface LockedSyncSpool { version: 1; vaultId: string; deviceId: string; deviceName: string; deviceType: DeviceType; localVersion: number; longTermPublicKeyHex: string; longTermPrivateKeyHex: string; entries: Record; tombstones: Record; inboxEntries: Record; inboxTombstones: Record; } let spoolMutation: Promise = Promise.resolve(); export async function replaceLockedSyncSpool( next: Omit, ): Promise { await mutateSpool(async (current) => { await saveSpool({ ...next, version: 1, inboxEntries: current?.inboxEntries ?? {}, inboxTombstones: current?.inboxTombstones ?? {}, }); }); } export async function getLockedSyncIdentity(): Promise { const snapshot = await loadSpool(); if (!snapshot) return null; return { deviceId: snapshot.deviceId, deviceName: snapshot.deviceName, deviceType: snapshot.deviceType, longTermPublicKeyHex: snapshot.longTermPublicKeyHex, longTermPrivateKey: hexToBytes(snapshot.longTermPrivateKeyHex), }; } function hexToBytes(value: string): Uint8Array { const out = new Uint8Array(value.length / 2); for (let i = 0; i < out.length; i += 1) { out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16); } return out; } export async function drainLockedSyncInbox( live: SyncVaultAdapter, ): Promise { const snapshot = await loadSpool(); if (!snapshot) return; const entries = Object.fromEntries( Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.ciphertext]), ); const manifest = Object.fromEntries( Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.modifiedAt]), ); if (Object.keys(entries).length > 0) await live.applyEntries(entries, manifest); if (Object.keys(snapshot.inboxTombstones).length > 0) { await live.applyTombstones(snapshot.inboxTombstones); } await mutateSpool(async (current) => { if (!current) return; const inboxEntries = { ...current.inboxEntries }; const inboxTombstones = { ...current.inboxTombstones }; for (const [id, applied] of Object.entries(snapshot.inboxEntries)) { if (inboxEntries[id]?.ciphertext === applied.ciphertext) delete inboxEntries[id]; } for (const [id, appliedAt] of Object.entries(snapshot.inboxTombstones)) { if (inboxTombstones[id] === appliedAt) delete inboxTombstones[id]; } await saveSpool({ ...current, inboxEntries, inboxTombstones }); }); } export async function createLockedSyncVaultAdapter(): Promise { const snapshot = await loadSpool(); if (!snapshot) return null; return { async computeVaultId() { return snapshot.vaultId; }, async getOurDeviceId() { return snapshot.deviceId; }, async getLocalVersion() { return snapshot.localVersion; }, async buildEntryManifest() { return Object.fromEntries( Object.entries(snapshot.entries).map(([id, entry]) => [id, entry.modifiedAt]), ); }, async buildTombstoneManifest() { return { ...snapshot.tombstones }; }, async getEncryptedEntries(entryIds) { const out: Record = {}; for (const id of entryIds) { const entry = snapshot.entries[id]; if (entry) out[id] = entry.ciphertext; } return out; }, async applyEntries(entries, remoteManifest = {}) { const list = Object.entries(entries); if (list.length > SYNC_LIMITS.MAX_ENTRY_COUNT) throw new Error("sync_entry_count_exceeded"); await mutateSpool(async (current) => { if (!current || current.vaultId !== snapshot.vaultId) return; const inbox = { ...current.inboxEntries }; const now = Date.now(); for (const [id, ciphertext] of list) { const claimed = remoteManifest[id]; if ( typeof claimed !== "number" || !Number.isFinite(claimed) || claimed <= 0 || ciphertext.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64 ) continue; const modifiedAt = clampSyncTimestamp(claimed, now); if ((inbox[id]?.modifiedAt ?? 0) < modifiedAt) { inbox[id] = { modifiedAt, ciphertext }; } } if (Object.keys(inbox).length > SYNC_LIMITS.MAX_ENTRY_COUNT) { throw new Error("sync_entry_count_exceeded"); } await saveSpool({ ...current, inboxEntries: inbox }); }); }, async applyTombstones(tombstones) { const list = Object.entries(tombstones); if (list.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) throw new Error("sync_tombstone_count_exceeded"); await mutateSpool(async (current) => { if (!current || current.vaultId !== snapshot.vaultId) return; const inbox = { ...current.inboxTombstones }; const now = Date.now(); for (const [id, value] of list) { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue; const deletedAt = clampSyncTimestamp(value, now); if ((inbox[id] ?? 0) < deletedAt) inbox[id] = deletedAt; } if (Object.keys(inbox).length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) { throw new Error("sync_tombstone_count_exceeded"); } await saveSpool({ ...current, inboxTombstones: inbox }); }); }, }; } async function mutateSpool(work: (spool: LockedSyncSpool | null) => Promise): Promise { const next = spoolMutation.then(async () => work(await loadSpool())); spoolMutation = next.catch(() => undefined); return next; } async function loadSpool(): Promise { await removeLegacyDiskSpool(); if (!chrome.storage.session) return null; const result = await chrome.storage.session.get(STORAGE_KEY); const value = (result as Record)[STORAGE_KEY]; return isLockedSyncSpool(value) ? value : null; } async function saveSpool(spool: LockedSyncSpool): Promise { if (!chrome.storage.session) return; await chrome.storage.session.set({ [STORAGE_KEY]: spool }); } /** * Profiles that ran an earlier build still hold the spool — cleartext private * key included — in chrome.storage.local. Drop it on first use rather than * waiting for an unlock that may never come. */ let legacyDiskSpoolRemoval: Promise | null = null; function removeLegacyDiskSpool(): Promise { if (!legacyDiskSpoolRemoval) { legacyDiskSpoolRemoval = Promise.resolve(chrome.storage.local.remove(STORAGE_KEY)) .catch(() => undefined); } return legacyDiskSpoolRemoval; } function isLockedSyncSpool(value: unknown): value is LockedSyncSpool { if (!value || typeof value !== "object") return false; const candidate = value as Partial; return candidate.version === 1 && typeof candidate.longTermPrivateKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPrivateKeyHex) && typeof candidate.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) && typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 && typeof candidate.deviceName === "string" && typeof candidate.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) && typeof candidate.localVersion === "number" && isRecord(candidate.entries) && isRecord(candidate.tombstones) && isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones); } function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); }