1import type { SyncVaultAdapter } from "./sync-coordinator";2import type { SyncPeerIdentity } from "../shared/sync-transport";3import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";4import type { DeviceType } from "../shared/sync-types";5import { isDecoyMode } from "./vault";678const STORAGE_KEY = "peach_locked_sync_spool_v1";91011121314151617181920212223242526interface CachedEntry {27 modifiedAt: number;28 ciphertext: string;29}3031interface StagedEntry extends CachedEntry {}3233interface LockedSyncSpool {34 version: 1;35 vaultId: string;36 deviceId: string;37 deviceName: string;38 deviceType: DeviceType;39 localVersion: number;40 longTermPublicKeyHex: string;41 longTermPrivateKeyHex: string;42 entries: Record<string, CachedEntry>;43 tombstones: Record<string, number>;44 inboxEntries: Record<string, StagedEntry>;45 inboxTombstones: Record<string, number>;46}4748let spoolMutation: Promise<void> = Promise.resolve();4950export async function replaceLockedSyncSpool(51 next: Omit<LockedSyncSpool, "version" | "inboxEntries" | "inboxTombstones">,52): Promise<void> {53 await mutateSpool(async (current) => {54 await saveSpool({55 ...next,56 version: 1,57 inboxEntries: current?.inboxEntries ?? {},58 inboxTombstones: current?.inboxTombstones ?? {},59 });60 });61}6263export async function getLockedSyncIdentity(): Promise<SyncPeerIdentity | null> {64 const snapshot = await loadSpool();65 if (!snapshot) return null;66 return {67 deviceId: snapshot.deviceId,68 deviceName: snapshot.deviceName,69 deviceType: snapshot.deviceType,70 longTermPublicKeyHex: snapshot.longTermPublicKeyHex,71 longTermPrivateKey: hexToBytes(snapshot.longTermPrivateKeyHex),72 };73}7475function hexToBytes(value: string): Uint8Array {76 const out = new Uint8Array(value.length / 2);77 for (let i = 0; i < out.length; i += 1) {78 out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);79 }80 return out;81}8283export async function drainLockedSyncInbox(84 live: SyncVaultAdapter,85): Promise<void> {86 const snapshot = await loadSpool();87 if (!snapshot) return;88 const entries = Object.fromEntries(89 Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.ciphertext]),90 );91 const manifest = Object.fromEntries(92 Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.modifiedAt]),93 );94 if (Object.keys(entries).length > 0) await live.applyEntries(entries, manifest);95 if (Object.keys(snapshot.inboxTombstones).length > 0) {96 await live.applyTombstones(snapshot.inboxTombstones);97 }98 await mutateSpool(async (current) => {99 if (!current) return;100 const inboxEntries = { ...current.inboxEntries };101 const inboxTombstones = { ...current.inboxTombstones };102 for (const [id, applied] of Object.entries(snapshot.inboxEntries)) {103 if (inboxEntries[id]?.ciphertext === applied.ciphertext) delete inboxEntries[id];104 }105 for (const [id, appliedAt] of Object.entries(snapshot.inboxTombstones)) {106 if (inboxTombstones[id] === appliedAt) delete inboxTombstones[id];107 }108 await saveSpool({ ...current, inboxEntries, inboxTombstones });109 });110}111112export async function createLockedSyncVaultAdapter(): Promise<SyncVaultAdapter | null> {113 const snapshot = await loadSpool();114 if (!snapshot) return null;115 return {116 async computeVaultId() { return snapshot.vaultId; },117 async getOurDeviceId() { return snapshot.deviceId; },118 async getLocalVersion() { return snapshot.localVersion; },119 async buildEntryManifest() {120 return Object.fromEntries(121 Object.entries(snapshot.entries).map(([id, entry]) => [id, entry.modifiedAt]),122 );123 },124 async buildTombstoneManifest() { return { ...snapshot.tombstones }; },125 async getEncryptedEntries(entryIds) {126 const out: Record<string, string> = {};127 for (const id of entryIds) {128 const entry = snapshot.entries[id];129 if (entry) out[id] = entry.ciphertext;130 }131 return out;132 },133 async applyEntries(entries, remoteManifest = {}) {134 if (isDecoyMode()) throw new Error("vault_locked");135 const list = Object.entries(entries);136 if (list.length > SYNC_LIMITS.MAX_ENTRY_COUNT) throw new Error("sync_entry_count_exceeded");137 await mutateSpool(async (current) => {138 if (isDecoyMode()) throw new Error("vault_locked");139 if (!current || current.vaultId !== snapshot.vaultId) return;140 const inbox = { ...current.inboxEntries };141 const now = Date.now();142 for (const [id, ciphertext] of list) {143 const claimed = remoteManifest[id];144 if (145 typeof claimed !== "number" || !Number.isFinite(claimed) || claimed <= 0 ||146 ciphertext.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64147 ) continue;148 const modifiedAt = clampSyncTimestamp(claimed, now);149 if ((inbox[id]?.modifiedAt ?? 0) < modifiedAt) {150 inbox[id] = { modifiedAt, ciphertext };151 }152 }153 if (Object.keys(inbox).length > SYNC_LIMITS.MAX_ENTRY_COUNT) {154 throw new Error("sync_entry_count_exceeded");155 }156 await saveSpool({ ...current, inboxEntries: inbox });157 });158 },159 async applyTombstones(tombstones) {160 if (isDecoyMode()) throw new Error("vault_locked");161 const list = Object.entries(tombstones);162 if (list.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) throw new Error("sync_tombstone_count_exceeded");163 await mutateSpool(async (current) => {164 if (isDecoyMode()) throw new Error("vault_locked");165 if (!current || current.vaultId !== snapshot.vaultId) return;166 const inbox = { ...current.inboxTombstones };167 const now = Date.now();168 for (const [id, value] of list) {169 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;170 const deletedAt = clampSyncTimestamp(value, now);171 if ((inbox[id] ?? 0) < deletedAt) inbox[id] = deletedAt;172 }173 if (Object.keys(inbox).length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {174 throw new Error("sync_tombstone_count_exceeded");175 }176 await saveSpool({ ...current, inboxTombstones: inbox });177 });178 },179 };180}181182async function mutateSpool(work: (spool: LockedSyncSpool | null) => Promise<void>): Promise<void> {183 const next = spoolMutation.then(async () => work(await loadSpool()));184 spoolMutation = next.catch(() => undefined);185 return next;186}187188async function loadSpool(): Promise<LockedSyncSpool | null> {189 await removeLegacyDiskSpool();190 if (!chrome.storage.session) return null;191 const result = await chrome.storage.session.get(STORAGE_KEY);192 const value = (result as Record<string, unknown>)[STORAGE_KEY];193 return isLockedSyncSpool(value) ? value : null;194}195196async function saveSpool(spool: LockedSyncSpool): Promise<void> {197 if (!chrome.storage.session) return;198 await chrome.storage.session.set({ [STORAGE_KEY]: spool });199}200201202203204205206let legacyDiskSpoolRemoval: Promise<void> | null = null;207208function removeLegacyDiskSpool(): Promise<void> {209 if (!legacyDiskSpoolRemoval) {210 legacyDiskSpoolRemoval = Promise.resolve(chrome.storage.local.remove(STORAGE_KEY))211 .catch(() => undefined);212 }213 return legacyDiskSpoolRemoval;214}215216function isLockedSyncSpool(value: unknown): value is LockedSyncSpool {217 if (!value || typeof value !== "object") return false;218 const candidate = value as Partial<LockedSyncSpool>;219 return candidate.version === 1 &&220 typeof candidate.longTermPrivateKeyHex === "string" &&221 /^[0-9a-f]{64}$/.test(candidate.longTermPrivateKeyHex) &&222 typeof candidate.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) &&223 typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 &&224 typeof candidate.deviceName === "string" &&225 typeof candidate.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) &&226 typeof candidate.localVersion === "number" &&227 isRecord(candidate.entries) && isRecord(candidate.tombstones) &&228 isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones);229}230231function isRecord(value: unknown): value is Record<string, unknown> {232 return !!value && typeof value === "object" && !Array.isArray(value);233}