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";56const STORAGE_KEY = "peach_locked_sync_spool_v1";789101112131415161718192021222324interface CachedEntry {25 modifiedAt: number;26 ciphertext: string;27}2829interface StagedEntry extends CachedEntry {}3031interface LockedSyncSpool {32 version: 1;33 vaultId: string;34 deviceId: string;35 deviceName: string;36 deviceType: DeviceType;37 localVersion: number;38 longTermPublicKeyHex: string;39 longTermPrivateKeyHex: string;40 entries: Record<string, CachedEntry>;41 tombstones: Record<string, number>;42 inboxEntries: Record<string, StagedEntry>;43 inboxTombstones: Record<string, number>;44}4546let spoolMutation: Promise<void> = Promise.resolve();4748export async function replaceLockedSyncSpool(49 next: Omit<LockedSyncSpool, "version" | "inboxEntries" | "inboxTombstones">,50): Promise<void> {51 await mutateSpool(async (current) => {52 await saveSpool({53 ...next,54 version: 1,55 inboxEntries: current?.inboxEntries ?? {},56 inboxTombstones: current?.inboxTombstones ?? {},57 });58 });59}6061export async function getLockedSyncIdentity(): Promise<SyncPeerIdentity | null> {62 const snapshot = await loadSpool();63 if (!snapshot) return null;64 return {65 deviceId: snapshot.deviceId,66 deviceName: snapshot.deviceName,67 deviceType: snapshot.deviceType,68 longTermPublicKeyHex: snapshot.longTermPublicKeyHex,69 longTermPrivateKey: hexToBytes(snapshot.longTermPrivateKeyHex),70 };71}7273function hexToBytes(value: string): Uint8Array {74 const out = new Uint8Array(value.length / 2);75 for (let i = 0; i < out.length; i += 1) {76 out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);77 }78 return out;79}8081export async function drainLockedSyncInbox(82 live: SyncVaultAdapter,83): Promise<void> {84 const snapshot = await loadSpool();85 if (!snapshot) return;86 const entries = Object.fromEntries(87 Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.ciphertext]),88 );89 const manifest = Object.fromEntries(90 Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.modifiedAt]),91 );92 if (Object.keys(entries).length > 0) await live.applyEntries(entries, manifest);93 if (Object.keys(snapshot.inboxTombstones).length > 0) {94 await live.applyTombstones(snapshot.inboxTombstones);95 }96 await mutateSpool(async (current) => {97 if (!current) return;98 const inboxEntries = { ...current.inboxEntries };99 const inboxTombstones = { ...current.inboxTombstones };100 for (const [id, applied] of Object.entries(snapshot.inboxEntries)) {101 if (inboxEntries[id]?.ciphertext === applied.ciphertext) delete inboxEntries[id];102 }103 for (const [id, appliedAt] of Object.entries(snapshot.inboxTombstones)) {104 if (inboxTombstones[id] === appliedAt) delete inboxTombstones[id];105 }106 await saveSpool({ ...current, inboxEntries, inboxTombstones });107 });108}109110export async function createLockedSyncVaultAdapter(): Promise<SyncVaultAdapter | null> {111 const snapshot = await loadSpool();112 if (!snapshot) return null;113 return {114 async computeVaultId() { return snapshot.vaultId; },115 async getOurDeviceId() { return snapshot.deviceId; },116 async getLocalVersion() { return snapshot.localVersion; },117 async buildEntryManifest() {118 return Object.fromEntries(119 Object.entries(snapshot.entries).map(([id, entry]) => [id, entry.modifiedAt]),120 );121 },122 async buildTombstoneManifest() { return { ...snapshot.tombstones }; },123 async getEncryptedEntries(entryIds) {124 const out: Record<string, string> = {};125 for (const id of entryIds) {126 const entry = snapshot.entries[id];127 if (entry) out[id] = entry.ciphertext;128 }129 return out;130 },131 async applyEntries(entries, remoteManifest = {}) {132 const list = Object.entries(entries);133 if (list.length > SYNC_LIMITS.MAX_ENTRY_COUNT) throw new Error("sync_entry_count_exceeded");134 await mutateSpool(async (current) => {135 if (!current || current.vaultId !== snapshot.vaultId) return;136 const inbox = { ...current.inboxEntries };137 const now = Date.now();138 for (const [id, ciphertext] of list) {139 const claimed = remoteManifest[id];140 if (141 typeof claimed !== "number" || !Number.isFinite(claimed) || claimed <= 0 ||142 ciphertext.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64143 ) continue;144 const modifiedAt = clampSyncTimestamp(claimed, now);145 if ((inbox[id]?.modifiedAt ?? 0) < modifiedAt) {146 inbox[id] = { modifiedAt, ciphertext };147 }148 }149 if (Object.keys(inbox).length > SYNC_LIMITS.MAX_ENTRY_COUNT) {150 throw new Error("sync_entry_count_exceeded");151 }152 await saveSpool({ ...current, inboxEntries: inbox });153 });154 },155 async applyTombstones(tombstones) {156 const list = Object.entries(tombstones);157 if (list.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) throw new Error("sync_tombstone_count_exceeded");158 await mutateSpool(async (current) => {159 if (!current || current.vaultId !== snapshot.vaultId) return;160 const inbox = { ...current.inboxTombstones };161 const now = Date.now();162 for (const [id, value] of list) {163 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;164 const deletedAt = clampSyncTimestamp(value, now);165 if ((inbox[id] ?? 0) < deletedAt) inbox[id] = deletedAt;166 }167 if (Object.keys(inbox).length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {168 throw new Error("sync_tombstone_count_exceeded");169 }170 await saveSpool({ ...current, inboxTombstones: inbox });171 });172 },173 };174}175176async function mutateSpool(work: (spool: LockedSyncSpool | null) => Promise<void>): Promise<void> {177 const next = spoolMutation.then(async () => work(await loadSpool()));178 spoolMutation = next.catch(() => undefined);179 return next;180}181182async function loadSpool(): Promise<LockedSyncSpool | null> {183 await removeLegacyDiskSpool();184 if (!chrome.storage.session) return null;185 const result = await chrome.storage.session.get(STORAGE_KEY);186 const value = (result as Record<string, unknown>)[STORAGE_KEY];187 return isLockedSyncSpool(value) ? value : null;188}189190async function saveSpool(spool: LockedSyncSpool): Promise<void> {191 if (!chrome.storage.session) return;192 await chrome.storage.session.set({ [STORAGE_KEY]: spool });193}194195196197198199200let legacyDiskSpoolRemoval: Promise<void> | null = null;201202function removeLegacyDiskSpool(): Promise<void> {203 if (!legacyDiskSpoolRemoval) {204 legacyDiskSpoolRemoval = Promise.resolve(chrome.storage.local.remove(STORAGE_KEY))205 .catch(() => undefined);206 }207 return legacyDiskSpoolRemoval;208}209210function isLockedSyncSpool(value: unknown): value is LockedSyncSpool {211 if (!value || typeof value !== "object") return false;212 const candidate = value as Partial<LockedSyncSpool>;213 return candidate.version === 1 &&214 typeof candidate.longTermPrivateKeyHex === "string" &&215 /^[0-9a-f]{64}$/.test(candidate.longTermPrivateKeyHex) &&216 typeof candidate.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) &&217 typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 &&218 typeof candidate.deviceName === "string" &&219 typeof candidate.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) &&220 typeof candidate.localVersion === "number" &&221 isRecord(candidate.entries) && isRecord(candidate.tombstones) &&222 isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones);223}224225function isRecord(value: unknown): value is Record<string, unknown> {226 return !!value && typeof value === "object" && !Array.isArray(value);227}