locked-sync-spool.ts

10.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";5import { ensureSettingsLoaded, isDecoyMode, lockedSyncRequiresUnlock } from "./vault";6const STORAGE_KEY = "peach_locked_sync_spool_v1";78/**9 * Locked sync metadata lives in session storage. The private key is retained10 * there by default so a cold worker can resume locked sync; hardened custody11 * omits it and therefore requires unlock after a restart.12 */13const PRIVATE_KEY_STORAGE_KEY = "peach_locked_sync_private_key_v1";1415interface CachedEntry {16  modifiedAt: number;17  ciphertext: string;18}1920interface StagedEntry extends CachedEntry {}2122interface LockedSyncSpool {23  version: 1;24  vaultId: string;25  deviceId: string;26  deviceName: string;27  deviceType: DeviceType;28  localVersion: number;29  longTermPublicKeyHex: string;30  entries: Record<string, CachedEntry>;31  tombstones: Record<string, number>;32  inboxEntries: Record<string, StagedEntry>;33  inboxTombstones: Record<string, number>;34}3536let lockedPrivateKey: Uint8Array | null = null;3738function bytesToHex(value: Uint8Array): string {39  return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("");40}4142function hexToBytes(value: string): Uint8Array | null {43  if (!/^[0-9a-f]{64}$/.test(value)) return null;44  const bytes = new Uint8Array(32);45  for (let index = 0; index < bytes.length; index += 1) {46    bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);47  }48  return bytes;49}5051export async function setLockedSyncPrivateKey(value: Uint8Array, persist: boolean): Promise<void> {52  lockedPrivateKey?.fill(0);53  lockedPrivateKey = new Uint8Array(value);54  if (!chrome.storage.session) return;55  if (persist) {56    await chrome.storage.session.set({ [PRIVATE_KEY_STORAGE_KEY]: bytesToHex(value) });57  } else {58    await chrome.storage.session.remove(PRIVATE_KEY_STORAGE_KEY);59  }60}6162export async function clearLockedSyncPrivateKey(): Promise<void> {63  lockedPrivateKey?.fill(0);64  lockedPrivateKey = null;65  if (chrome.storage.session) await chrome.storage.session.remove(PRIVATE_KEY_STORAGE_KEY);66}6768let spoolMutation: Promise<void> = Promise.resolve();6970export async function replaceLockedSyncSpool(71  next: Omit<LockedSyncSpool, "version" | "inboxEntries" | "inboxTombstones">,72): Promise<void> {73  await mutateSpool(async (current) => {74    await saveSpool({75      ...next,76      version: 1,77      inboxEntries: current?.inboxEntries ?? {},78      inboxTombstones: current?.inboxTombstones ?? {},79    });80  });81}8283export async function getLockedSyncIdentity(): Promise<SyncPeerIdentity | null> {84  const snapshot = await loadSpool();85  if (!snapshot) return null;86  if (!lockedPrivateKey) {87    await ensureSettingsLoaded();88    if (lockedSyncRequiresUnlock() || !chrome.storage.session) return null;89    const result = await chrome.storage.session.get(PRIVATE_KEY_STORAGE_KEY);90    const recovered = hexToBytes((result as Record<string, unknown>)[PRIVATE_KEY_STORAGE_KEY] as string);91    if (!recovered) return null;92    lockedPrivateKey = recovered;93  }94  return {95    deviceId: snapshot.deviceId,96    deviceName: snapshot.deviceName,97    deviceType: snapshot.deviceType,98    longTermPublicKeyHex: snapshot.longTermPublicKeyHex,99    longTermPrivateKey: new Uint8Array(lockedPrivateKey),100  };101}102103104export async function drainLockedSyncInbox(105  live: SyncVaultAdapter,106): Promise<void> {107  const snapshot = await loadSpool();108  if (!snapshot) return;109  const entries = Object.fromEntries(110    Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.ciphertext]),111  );112  const manifest = Object.fromEntries(113    Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.modifiedAt]),114  );115  if (Object.keys(entries).length > 0) await live.applyEntries(entries, manifest);116  if (Object.keys(snapshot.inboxTombstones).length > 0) {117    await live.applyTombstones(snapshot.inboxTombstones);118  }119  await mutateSpool(async (current) => {120    if (!current) return;121    const inboxEntries = { ...current.inboxEntries };122    const inboxTombstones = { ...current.inboxTombstones };123    for (const [id, applied] of Object.entries(snapshot.inboxEntries)) {124      if (inboxEntries[id]?.ciphertext === applied.ciphertext) delete inboxEntries[id];125    }126    for (const [id, appliedAt] of Object.entries(snapshot.inboxTombstones)) {127      if (inboxTombstones[id] === appliedAt) delete inboxTombstones[id];128    }129    await saveSpool({ ...current, inboxEntries, inboxTombstones });130  });131}132133export async function createLockedSyncVaultAdapter(): Promise<SyncVaultAdapter | null> {134  const snapshot = await loadSpool();135  if (!snapshot) return null;136  return {137    async computeVaultId() { return snapshot.vaultId; },138    async getOurDeviceId() { return snapshot.deviceId; },139    async getLocalVersion() { return snapshot.localVersion; },140    async buildEntryManifest() {141      return Object.fromEntries(142        Object.entries(snapshot.entries).map(([id, entry]) => [id, entry.modifiedAt]),143      );144    },145    async buildTombstoneManifest() { return { ...snapshot.tombstones }; },146    async getEncryptedEntries(entryIds) {147      const out: Record<string, string> = {};148      for (const id of entryIds) {149        const entry = snapshot.entries[id];150        if (entry) out[id] = entry.ciphertext;151      }152      return out;153    },154    async applyEntries(entries, remoteManifest = {}) {155      if (isDecoyMode()) throw new Error("vault_locked");156      const list = Object.entries(entries);157      if (list.length > SYNC_LIMITS.MAX_ENTRY_COUNT) throw new Error("sync_entry_count_exceeded");158      await mutateSpool(async (current) => {159        if (isDecoyMode()) throw new Error("vault_locked");160        if (!current || current.vaultId !== snapshot.vaultId) return;161        const inbox = { ...current.inboxEntries };162        const now = Date.now();163        for (const [id, ciphertext] of list) {164          const claimed = remoteManifest[id];165          if (166            typeof claimed !== "number" || !Number.isFinite(claimed) || claimed <= 0 ||167            ciphertext.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64168          ) continue;169          const modifiedAt = clampSyncTimestamp(claimed, now);170          if ((inbox[id]?.modifiedAt ?? 0) < modifiedAt) {171            inbox[id] = { modifiedAt, ciphertext };172          }173        }174        if (Object.keys(inbox).length > SYNC_LIMITS.MAX_ENTRY_COUNT) {175          throw new Error("sync_entry_count_exceeded");176        }177        await saveSpool({ ...current, inboxEntries: inbox });178      });179    },180    async applyTombstones(tombstones) {181      if (isDecoyMode()) throw new Error("vault_locked");182      const list = Object.entries(tombstones);183      if (list.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) throw new Error("sync_tombstone_count_exceeded");184      await mutateSpool(async (current) => {185        if (isDecoyMode()) throw new Error("vault_locked");186        if (!current || current.vaultId !== snapshot.vaultId) return;187        const inbox = { ...current.inboxTombstones };188        const now = Date.now();189        for (const [id, value] of list) {190          if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;191          const deletedAt = clampSyncTimestamp(value, now);192          if ((inbox[id] ?? 0) < deletedAt) inbox[id] = deletedAt;193        }194        if (Object.keys(inbox).length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {195          throw new Error("sync_tombstone_count_exceeded");196        }197        await saveSpool({ ...current, inboxTombstones: inbox });198      });199    },200  };201}202203async function mutateSpool(work: (spool: LockedSyncSpool | null) => Promise<void>): Promise<void> {204  const next = spoolMutation.then(async () => work(await loadSpool()));205  spoolMutation = next.catch(() => undefined);206  return next;207}208209async function loadSpool(): Promise<LockedSyncSpool | null> {210  await removeLegacyDiskSpool();211  if (!chrome.storage.session) return null;212  const result = await chrome.storage.session.get(STORAGE_KEY);213  const value = (result as Record<string, unknown>)[STORAGE_KEY];214  return isLockedSyncSpool(value) ? value : null;215}216217async function saveSpool(spool: LockedSyncSpool): Promise<void> {218  if (!chrome.storage.session) return;219  await chrome.storage.session.set({ [STORAGE_KEY]: spool });220}221222/**223 * Profiles that ran an earlier build still hold the spool — cleartext private224 * key included — in chrome.storage.local. Drop it on first use rather than225 * waiting for an unlock that may never come.226 */227let legacyDiskSpoolRemoval: Promise<void> | null = null;228229function removeLegacyDiskSpool(): Promise<void> {230  if (!legacyDiskSpoolRemoval) {231    legacyDiskSpoolRemoval = Promise.resolve(chrome.storage.local.remove(STORAGE_KEY))232      .catch(() => undefined);233  }234  return legacyDiskSpoolRemoval;235}236237function isLockedSyncSpool(value: unknown): value is LockedSyncSpool {238  if (!value || typeof value !== "object") return false;239  const candidate = value as Partial<LockedSyncSpool>;240  return candidate.version === 1 &&241    typeof candidate.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) &&242    typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 && candidate.deviceId.length <= 128 &&243    typeof candidate.deviceName === "string" && candidate.deviceName.length <= 128 &&244    (candidate.deviceType === "extension" || candidate.deviceType === "android" || candidate.deviceType === "ios") &&245    typeof candidate.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) &&246    typeof candidate.localVersion === "number" && Number.isSafeInteger(candidate.localVersion) &&247    isRecord(candidate.entries) && isRecord(candidate.tombstones) &&248    isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones) &&249    Object.keys(candidate.entries).length <= SYNC_LIMITS.MAX_ENTRY_COUNT &&250    Object.keys(candidate.tombstones).length <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT &&251    Object.keys(candidate.inboxEntries).length <= SYNC_LIMITS.MAX_ENTRY_COUNT &&252    Object.keys(candidate.inboxTombstones).length <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT;253}254255function isRecord(value: unknown): value is Record<string, unknown> {256  return !!value && typeof value === "object" && !Array.isArray(value);257}