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"; import { ensureSettingsLoaded, isDecoyMode, lockedSyncRequiresUnlock } from "./vault"; const STORAGE_KEY = "peach_locked_sync_spool_v1"; /** * Locked sync metadata lives in session storage. The private key is retained * there by default so a cold worker can resume locked sync; hardened custody * omits it and therefore requires unlock after a restart. */ const PRIVATE_KEY_STORAGE_KEY = "peach_locked_sync_private_key_v1"; 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; entries: Record; tombstones: Record; inboxEntries: Record; inboxTombstones: Record; } let lockedPrivateKey: Uint8Array | null = null; function bytesToHex(value: Uint8Array): string { return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); } function hexToBytes(value: string): Uint8Array | null { if (!/^[0-9a-f]{64}$/.test(value)) return null; const bytes = new Uint8Array(32); for (let index = 0; index < bytes.length; index += 1) { bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16); } return bytes; } export async function setLockedSyncPrivateKey(value: Uint8Array, persist: boolean): Promise { lockedPrivateKey?.fill(0); lockedPrivateKey = new Uint8Array(value); if (!chrome.storage.session) return; if (persist) { await chrome.storage.session.set({ [PRIVATE_KEY_STORAGE_KEY]: bytesToHex(value) }); } else { await chrome.storage.session.remove(PRIVATE_KEY_STORAGE_KEY); } } export async function clearLockedSyncPrivateKey(): Promise { lockedPrivateKey?.fill(0); lockedPrivateKey = null; if (chrome.storage.session) await chrome.storage.session.remove(PRIVATE_KEY_STORAGE_KEY); } 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; if (!lockedPrivateKey) { await ensureSettingsLoaded(); if (lockedSyncRequiresUnlock() || !chrome.storage.session) return null; const result = await chrome.storage.session.get(PRIVATE_KEY_STORAGE_KEY); const recovered = hexToBytes((result as Record)[PRIVATE_KEY_STORAGE_KEY] as string); if (!recovered) return null; lockedPrivateKey = recovered; } return { deviceId: snapshot.deviceId, deviceName: snapshot.deviceName, deviceType: snapshot.deviceType, longTermPublicKeyHex: snapshot.longTermPublicKeyHex, longTermPrivateKey: new Uint8Array(lockedPrivateKey), }; } 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 = {}) { if (isDecoyMode()) throw new Error("vault_locked"); 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 (isDecoyMode()) throw new Error("vault_locked"); 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) { if (isDecoyMode()) throw new Error("vault_locked"); 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 (isDecoyMode()) throw new Error("vault_locked"); 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.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) && typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 && candidate.deviceId.length <= 128 && typeof candidate.deviceName === "string" && candidate.deviceName.length <= 128 && (candidate.deviceType === "extension" || candidate.deviceType === "android" || candidate.deviceType === "ios") && typeof candidate.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) && typeof candidate.localVersion === "number" && Number.isSafeInteger(candidate.localVersion) && isRecord(candidate.entries) && isRecord(candidate.tombstones) && isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones) && Object.keys(candidate.entries).length <= SYNC_LIMITS.MAX_ENTRY_COUNT && Object.keys(candidate.tombstones).length <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT && Object.keys(candidate.inboxEntries).length <= SYNC_LIMITS.MAX_ENTRY_COUNT && Object.keys(candidate.inboxTombstones).length <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; } function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); }