locked-sync-spool.ts

6.9 KB · raw

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";78interface CachedEntry {9  modifiedAt: number;10  ciphertext: string;11}1213interface StagedEntry extends CachedEntry {}1415interface LockedSyncSpool {16  version: 1;17  vaultId: string;18  deviceId: string;19  deviceName: string;20  deviceType: DeviceType;21  localVersion: number;22  longTermPublicKeyHex: string;23  entries: Record<string, CachedEntry>;24  tombstones: Record<string, number>;25  inboxEntries: Record<string, StagedEntry>;26  inboxTombstones: Record<string, number>;27}2829let spoolMutation: Promise<void> = Promise.resolve();3031export async function replaceLockedSyncSpool(32  next: Omit<LockedSyncSpool, "version" | "inboxEntries" | "inboxTombstones">,33): Promise<void> {34  await mutateSpool(async (current) => {35    await saveSpool({36      ...next,37      version: 1,38      inboxEntries: current?.inboxEntries ?? {},39      inboxTombstones: current?.inboxTombstones ?? {},40    });41  });42}4344export async function getLockedSyncIdentity(): Promise<SyncPeerIdentity | null> {45  // A private key must never outlive the unlocked vault; defer all Noise auth.46  return null;47}4849export async function drainLockedSyncInbox(50  live: SyncVaultAdapter,51): Promise<void> {52  const snapshot = await loadSpool();53  if (!snapshot) return;54  const entries = Object.fromEntries(55    Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.ciphertext]),56  );57  const manifest = Object.fromEntries(58    Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.modifiedAt]),59  );60  if (Object.keys(entries).length > 0) await live.applyEntries(entries, manifest);61  if (Object.keys(snapshot.inboxTombstones).length > 0) {62    await live.applyTombstones(snapshot.inboxTombstones);63  }64  await mutateSpool(async (current) => {65    if (!current) return;66    const inboxEntries = { ...current.inboxEntries };67    const inboxTombstones = { ...current.inboxTombstones };68    for (const [id, applied] of Object.entries(snapshot.inboxEntries)) {69      if (inboxEntries[id]?.ciphertext === applied.ciphertext) delete inboxEntries[id];70    }71    for (const [id, appliedAt] of Object.entries(snapshot.inboxTombstones)) {72      if (inboxTombstones[id] === appliedAt) delete inboxTombstones[id];73    }74    await saveSpool({ ...current, inboxEntries, inboxTombstones });75  });76}7778export async function createLockedSyncVaultAdapter(): Promise<SyncVaultAdapter | null> {79  const snapshot = await loadSpool();80  if (!snapshot) return null;81  return {82    async computeVaultId() { return snapshot.vaultId; },83    async getOurDeviceId() { return snapshot.deviceId; },84    async getLocalVersion() { return snapshot.localVersion; },85    async buildEntryManifest() {86      return Object.fromEntries(87        Object.entries(snapshot.entries).map(([id, entry]) => [id, entry.modifiedAt]),88      );89    },90    async buildTombstoneManifest() { return { ...snapshot.tombstones }; },91    async getEncryptedEntries(entryIds) {92      const out: Record<string, string> = {};93      for (const id of entryIds) {94        const entry = snapshot.entries[id];95        if (entry) out[id] = entry.ciphertext;96      }97      return out;98    },99    async applyEntries(entries, remoteManifest = {}) {100      const list = Object.entries(entries);101      if (list.length > SYNC_LIMITS.MAX_ENTRY_COUNT) throw new Error("sync_entry_count_exceeded");102      await mutateSpool(async (current) => {103        if (!current || current.vaultId !== snapshot.vaultId) return;104        const inbox = { ...current.inboxEntries };105        const now = Date.now();106        for (const [id, ciphertext] of list) {107          const claimed = remoteManifest[id];108          if (109            typeof claimed !== "number" || !Number.isFinite(claimed) || claimed <= 0 ||110            ciphertext.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64111          ) continue;112          const modifiedAt = clampSyncTimestamp(claimed, now);113          if ((inbox[id]?.modifiedAt ?? 0) < modifiedAt) {114            inbox[id] = { modifiedAt, ciphertext };115          }116        }117        if (Object.keys(inbox).length > SYNC_LIMITS.MAX_ENTRY_COUNT) {118          throw new Error("sync_entry_count_exceeded");119        }120        await saveSpool({ ...current, inboxEntries: inbox });121      });122    },123    async applyTombstones(tombstones) {124      const list = Object.entries(tombstones);125      if (list.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) throw new Error("sync_tombstone_count_exceeded");126      await mutateSpool(async (current) => {127        if (!current || current.vaultId !== snapshot.vaultId) return;128        const inbox = { ...current.inboxTombstones };129        const now = Date.now();130        for (const [id, value] of list) {131          if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;132          const deletedAt = clampSyncTimestamp(value, now);133          if ((inbox[id] ?? 0) < deletedAt) inbox[id] = deletedAt;134        }135        if (Object.keys(inbox).length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {136          throw new Error("sync_tombstone_count_exceeded");137        }138        await saveSpool({ ...current, inboxTombstones: inbox });139      });140    },141  };142}143144async function mutateSpool(work: (spool: LockedSyncSpool | null) => Promise<void>): Promise<void> {145  const next = spoolMutation.then(async () => work(await loadSpool()));146  spoolMutation = next.catch(() => undefined);147  return next;148}149150async function loadSpool(): Promise<LockedSyncSpool | null> {151  const result = await chrome.storage.local.get(STORAGE_KEY);152  const value = (result as Record<string, unknown>)[STORAGE_KEY];153  return isLockedSyncSpool(value) ? value : null;154}155156async function saveSpool(spool: LockedSyncSpool): Promise<void> {157  await chrome.storage.local.set({ [STORAGE_KEY]: spool });158}159160function isLockedSyncSpool(value: unknown): value is LockedSyncSpool {161  if (!value || typeof value !== "object") return false;162  const candidate = value as Partial<LockedSyncSpool>;163  if (164    Object.hasOwn(candidate, "longTermPrivateKeyHex") ||165    Object.hasOwn(candidate, "longTermPrivateKey")166  ) return false;167  return candidate.version === 1 &&168    typeof candidate.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) &&169    typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 &&170    typeof candidate.deviceName === "string" &&171    typeof candidate.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) &&172    typeof candidate.localVersion === "number" &&173    isRecord(candidate.entries) && isRecord(candidate.tombstones) &&174    isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones);175}176177function isRecord(value: unknown): value is Record<string, unknown> {178  return !!value && typeof value === "object" && !Array.isArray(value);179}