locked-sync-spool.ts

7.2 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  longTermPrivateKeyHex: string;24  entries: Record<string, CachedEntry>;25  tombstones: Record<string, number>;26  inboxEntries: Record<string, StagedEntry>;27  inboxTombstones: Record<string, number>;28}2930let spoolMutation: Promise<void> = Promise.resolve();3132export async function replaceLockedSyncSpool(33  next: Omit<LockedSyncSpool, "version" | "inboxEntries" | "inboxTombstones">,34): Promise<void> {35  await mutateSpool(async (current) => {36    await saveSpool({37      ...next,38      version: 1,39      inboxEntries: current?.inboxEntries ?? {},40      inboxTombstones: current?.inboxTombstones ?? {},41    });42  });43}4445export async function clearLockedSyncSpool(): Promise<void> {46  await mutateSpool(async () => chrome.storage.local.remove(STORAGE_KEY));47}4849export async function getLockedSyncIdentity(): Promise<SyncPeerIdentity | null> {50  const spool = await loadSpool();51  if (!spool) return null;52  return {53    deviceId: spool.deviceId,54    deviceName: spool.deviceName,55    deviceType: spool.deviceType,56    longTermPublicKeyHex: spool.longTermPublicKeyHex,57    longTermPrivateKey: hexToBytes(spool.longTermPrivateKeyHex),58  };59}6061export async function drainLockedSyncInbox(62  live: SyncVaultAdapter,63): Promise<void> {64  const snapshot = await loadSpool();65  if (!snapshot) return;66  const entries = Object.fromEntries(67    Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.ciphertext]),68  );69  const manifest = Object.fromEntries(70    Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.modifiedAt]),71  );72  if (Object.keys(entries).length > 0) await live.applyEntries(entries, manifest);73  if (Object.keys(snapshot.inboxTombstones).length > 0) {74    await live.applyTombstones(snapshot.inboxTombstones);75  }76  await mutateSpool(async (current) => {77    if (!current) return;78    const inboxEntries = { ...current.inboxEntries };79    const inboxTombstones = { ...current.inboxTombstones };80    for (const [id, applied] of Object.entries(snapshot.inboxEntries)) {81      if (inboxEntries[id]?.ciphertext === applied.ciphertext) delete inboxEntries[id];82    }83    for (const [id, appliedAt] of Object.entries(snapshot.inboxTombstones)) {84      if (inboxTombstones[id] === appliedAt) delete inboxTombstones[id];85    }86    await saveSpool({ ...current, inboxEntries, inboxTombstones });87  });88}8990export async function createLockedSyncVaultAdapter(): Promise<SyncVaultAdapter | null> {91  const snapshot = await loadSpool();92  if (!snapshot) return null;93  return {94    async computeVaultId() { return snapshot.vaultId; },95    async getOurDeviceId() { return snapshot.deviceId; },96    async getLocalVersion() { return snapshot.localVersion; },97    async buildEntryManifest() {98      return Object.fromEntries(99        Object.entries(snapshot.entries).map(([id, entry]) => [id, entry.modifiedAt]),100      );101    },102    async buildTombstoneManifest() { return { ...snapshot.tombstones }; },103    async getEncryptedEntries(entryIds) {104      const out: Record<string, string> = {};105      for (const id of entryIds) {106        const entry = snapshot.entries[id];107        if (entry) out[id] = entry.ciphertext;108      }109      return out;110    },111    async applyEntries(entries, remoteManifest = {}) {112      const list = Object.entries(entries);113      if (list.length > SYNC_LIMITS.MAX_ENTRY_COUNT) throw new Error("sync_entry_count_exceeded");114      await mutateSpool(async (current) => {115        if (!current || current.vaultId !== snapshot.vaultId) return;116        const inbox = { ...current.inboxEntries };117        const now = Date.now();118        for (const [id, ciphertext] of list) {119          const claimed = remoteManifest[id];120          if (121            typeof claimed !== "number" || !Number.isFinite(claimed) || claimed <= 0 ||122            ciphertext.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64123          ) continue;124          const modifiedAt = clampSyncTimestamp(claimed, now);125          if ((inbox[id]?.modifiedAt ?? 0) < modifiedAt) {126            inbox[id] = { modifiedAt, ciphertext };127          }128        }129        await saveSpool({ ...current, inboxEntries: inbox });130      });131    },132    async applyTombstones(tombstones) {133      const list = Object.entries(tombstones);134      if (list.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) throw new Error("sync_tombstone_count_exceeded");135      await mutateSpool(async (current) => {136        if (!current || current.vaultId !== snapshot.vaultId) return;137        const inbox = { ...current.inboxTombstones };138        const now = Date.now();139        for (const [id, value] of list) {140          if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;141          const deletedAt = clampSyncTimestamp(value, now);142          if ((inbox[id] ?? 0) < deletedAt) inbox[id] = deletedAt;143        }144        await saveSpool({ ...current, inboxTombstones: inbox });145      });146    },147  };148}149150async function mutateSpool(work: (spool: LockedSyncSpool | null) => Promise<void>): Promise<void> {151  const next = spoolMutation.then(async () => work(await loadSpool()));152  spoolMutation = next.catch(() => undefined);153  return next;154}155156async function loadSpool(): Promise<LockedSyncSpool | null> {157  const result = await chrome.storage.local.get(STORAGE_KEY);158  const value = (result as Record<string, unknown>)[STORAGE_KEY];159  return isLockedSyncSpool(value) ? value : null;160}161162async function saveSpool(spool: LockedSyncSpool): Promise<void> {163  await chrome.storage.local.set({ [STORAGE_KEY]: spool });164}165166function isLockedSyncSpool(value: unknown): value is LockedSyncSpool {167  if (!value || typeof value !== "object") return false;168  const candidate = value as Partial<LockedSyncSpool>;169  return candidate.version === 1 &&170    typeof candidate.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) &&171    typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 &&172    typeof candidate.deviceName === "string" &&173    typeof candidate.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) &&174    typeof candidate.longTermPrivateKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPrivateKeyHex) &&175    typeof candidate.localVersion === "number" &&176    isRecord(candidate.entries) && isRecord(candidate.tombstones) &&177    isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones);178}179180function isRecord(value: unknown): value is Record<string, unknown> {181  return !!value && typeof value === "object" && !Array.isArray(value);182}183184function hexToBytes(hex: string): Uint8Array {185  const out = new Uint8Array(hex.length / 2);186  for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);187  return out;188}