locked-sync-spool.ts

8.8 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 { isDecoyMode } from "./vault";678const STORAGE_KEY = "peach_locked_sync_spool_v1";910/**11 * Locked sync metadata lives in session storage, but the long-term private key12 * stays only in this worker's heap. A worker restart fails closed until unlock.13 */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;3738export function setLockedSyncPrivateKey(value: Uint8Array): void {39  lockedPrivateKey?.fill(0);40  lockedPrivateKey = new Uint8Array(value);41}4243let spoolMutation: Promise<void> = Promise.resolve();4445export async function replaceLockedSyncSpool(46  next: Omit<LockedSyncSpool, "version" | "inboxEntries" | "inboxTombstones">,47): Promise<void> {48  await mutateSpool(async (current) => {49    await saveSpool({50      ...next,51      version: 1,52      inboxEntries: current?.inboxEntries ?? {},53      inboxTombstones: current?.inboxTombstones ?? {},54    });55  });56}5758export async function getLockedSyncIdentity(): Promise<SyncPeerIdentity | null> {59  const snapshot = await loadSpool();60  if (!snapshot || !lockedPrivateKey) return null;61  return {62    deviceId: snapshot.deviceId,63    deviceName: snapshot.deviceName,64    deviceType: snapshot.deviceType,65    longTermPublicKeyHex: snapshot.longTermPublicKeyHex,66    longTermPrivateKey: new Uint8Array(lockedPrivateKey),67  };68}697071export async function drainLockedSyncInbox(72  live: SyncVaultAdapter,73): Promise<void> {74  const snapshot = await loadSpool();75  if (!snapshot) return;76  const entries = Object.fromEntries(77    Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.ciphertext]),78  );79  const manifest = Object.fromEntries(80    Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.modifiedAt]),81  );82  if (Object.keys(entries).length > 0) await live.applyEntries(entries, manifest);83  if (Object.keys(snapshot.inboxTombstones).length > 0) {84    await live.applyTombstones(snapshot.inboxTombstones);85  }86  await mutateSpool(async (current) => {87    if (!current) return;88    const inboxEntries = { ...current.inboxEntries };89    const inboxTombstones = { ...current.inboxTombstones };90    for (const [id, applied] of Object.entries(snapshot.inboxEntries)) {91      if (inboxEntries[id]?.ciphertext === applied.ciphertext) delete inboxEntries[id];92    }93    for (const [id, appliedAt] of Object.entries(snapshot.inboxTombstones)) {94      if (inboxTombstones[id] === appliedAt) delete inboxTombstones[id];95    }96    await saveSpool({ ...current, inboxEntries, inboxTombstones });97  });98}99100export async function createLockedSyncVaultAdapter(): Promise<SyncVaultAdapter | null> {101  const snapshot = await loadSpool();102  if (!snapshot) return null;103  return {104    async computeVaultId() { return snapshot.vaultId; },105    async getOurDeviceId() { return snapshot.deviceId; },106    async getLocalVersion() { return snapshot.localVersion; },107    async buildEntryManifest() {108      return Object.fromEntries(109        Object.entries(snapshot.entries).map(([id, entry]) => [id, entry.modifiedAt]),110      );111    },112    async buildTombstoneManifest() { return { ...snapshot.tombstones }; },113    async getEncryptedEntries(entryIds) {114      const out: Record<string, string> = {};115      for (const id of entryIds) {116        const entry = snapshot.entries[id];117        if (entry) out[id] = entry.ciphertext;118      }119      return out;120    },121    async applyEntries(entries, remoteManifest = {}) {122      if (isDecoyMode()) throw new Error("vault_locked");123      const list = Object.entries(entries);124      if (list.length > SYNC_LIMITS.MAX_ENTRY_COUNT) throw new Error("sync_entry_count_exceeded");125      await mutateSpool(async (current) => {126        if (isDecoyMode()) throw new Error("vault_locked");127        if (!current || current.vaultId !== snapshot.vaultId) return;128        const inbox = { ...current.inboxEntries };129        const now = Date.now();130        for (const [id, ciphertext] of list) {131          const claimed = remoteManifest[id];132          if (133            typeof claimed !== "number" || !Number.isFinite(claimed) || claimed <= 0 ||134            ciphertext.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64135          ) continue;136          const modifiedAt = clampSyncTimestamp(claimed, now);137          if ((inbox[id]?.modifiedAt ?? 0) < modifiedAt) {138            inbox[id] = { modifiedAt, ciphertext };139          }140        }141        if (Object.keys(inbox).length > SYNC_LIMITS.MAX_ENTRY_COUNT) {142          throw new Error("sync_entry_count_exceeded");143        }144        await saveSpool({ ...current, inboxEntries: inbox });145      });146    },147    async applyTombstones(tombstones) {148      if (isDecoyMode()) throw new Error("vault_locked");149      const list = Object.entries(tombstones);150      if (list.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) throw new Error("sync_tombstone_count_exceeded");151      await mutateSpool(async (current) => {152        if (isDecoyMode()) throw new Error("vault_locked");153        if (!current || current.vaultId !== snapshot.vaultId) return;154        const inbox = { ...current.inboxTombstones };155        const now = Date.now();156        for (const [id, value] of list) {157          if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;158          const deletedAt = clampSyncTimestamp(value, now);159          if ((inbox[id] ?? 0) < deletedAt) inbox[id] = deletedAt;160        }161        if (Object.keys(inbox).length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {162          throw new Error("sync_tombstone_count_exceeded");163        }164        await saveSpool({ ...current, inboxTombstones: inbox });165      });166    },167  };168}169170async function mutateSpool(work: (spool: LockedSyncSpool | null) => Promise<void>): Promise<void> {171  const next = spoolMutation.then(async () => work(await loadSpool()));172  spoolMutation = next.catch(() => undefined);173  return next;174}175176async function loadSpool(): Promise<LockedSyncSpool | null> {177  await removeLegacyDiskSpool();178  if (!chrome.storage.session) return null;179  const result = await chrome.storage.session.get(STORAGE_KEY);180  const value = (result as Record<string, unknown>)[STORAGE_KEY];181  return isLockedSyncSpool(value) ? value : null;182}183184async function saveSpool(spool: LockedSyncSpool): Promise<void> {185  if (!chrome.storage.session) return;186  await chrome.storage.session.set({ [STORAGE_KEY]: spool });187}188189/**190 * Profiles that ran an earlier build still hold the spool — cleartext private191 * key included — in chrome.storage.local. Drop it on first use rather than192 * waiting for an unlock that may never come.193 */194let legacyDiskSpoolRemoval: Promise<void> | null = null;195196function removeLegacyDiskSpool(): Promise<void> {197  if (!legacyDiskSpoolRemoval) {198    legacyDiskSpoolRemoval = Promise.resolve(chrome.storage.local.remove(STORAGE_KEY))199      .catch(() => undefined);200  }201  return legacyDiskSpoolRemoval;202}203204function isLockedSyncSpool(value: unknown): value is LockedSyncSpool {205  if (!value || typeof value !== "object") return false;206  const candidate = value as Partial<LockedSyncSpool>;207  return candidate.version === 1 &&208    typeof candidate.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) &&209    typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 && candidate.deviceId.length <= 128 &&210    typeof candidate.deviceName === "string" && candidate.deviceName.length <= 128 &&211    (candidate.deviceType === "extension" || candidate.deviceType === "android" || candidate.deviceType === "ios") &&212    typeof candidate.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) &&213    typeof candidate.localVersion === "number" && Number.isSafeInteger(candidate.localVersion) &&214    isRecord(candidate.entries) && isRecord(candidate.tombstones) &&215    isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones) &&216    Object.keys(candidate.entries).length <= SYNC_LIMITS.MAX_ENTRY_COUNT &&217    Object.keys(candidate.tombstones).length <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT &&218    Object.keys(candidate.inboxEntries).length <= SYNC_LIMITS.MAX_ENTRY_COUNT &&219    Object.keys(candidate.inboxTombstones).length <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT;220}221222function isRecord(value: unknown): value is Record<string, unknown> {223  return !!value && typeof value === "object" && !Array.isArray(value);224}