locked-sync-spool.ts

8.7 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";78/**9 * The spool lives in `chrome.storage.session`, never `chrome.storage.local`.10 *11 * PB-26-104: locked sync needs the device's long-term Noise static while the12 * vault is locked, which is exactly when no material exists to wrap it with —13 * anything available while locked is by definition at rest. Session storage is14 * memory-backed: the key never reaches disk, surviving the MV3 service-worker15 * restarts that a plain in-memory cache would not, and dying with the browser16 * session. That is the same accepted profile as the master-password-derived17 * session key (M-1).18 *19 * Session storage is quota-bound, so a vault too large to spool simply loses20 * locked sync for that browser session: the write failure is caught by21 * vault.refreshLockedSyncState and lock() still completes.22 */2324interface 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}194195/**196 * Profiles that ran an earlier build still hold the spool — cleartext private197 * key included — in chrome.storage.local. Drop it on first use rather than198 * waiting for an unlock that may never come.199 */200let 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}