locked-sync-spool.ts

9.0 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 * The spool lives in `chrome.storage.session`, never `chrome.storage.local`.12 *13 * PB-26-104: locked sync needs the device's long-term Noise static while the14 * vault is locked, which is exactly when no material exists to wrap it with —15 * anything available while locked is by definition at rest. Session storage is16 * memory-backed: the key never reaches disk, surviving the MV3 service-worker17 * restarts that a plain in-memory cache would not, and dying with the browser18 * session. That is the same accepted profile as the master-password-derived19 * session key (M-1).20 *21 * Session storage is quota-bound, so a vault too large to spool simply loses22 * locked sync for that browser session: the write failure is caught by23 * vault.refreshLockedSyncState and lock() still completes.24 */2526interface CachedEntry {27  modifiedAt: number;28  ciphertext: string;29}3031interface StagedEntry extends CachedEntry {}3233interface LockedSyncSpool {34  version: 1;35  vaultId: string;36  deviceId: string;37  deviceName: string;38  deviceType: DeviceType;39  localVersion: number;40  longTermPublicKeyHex: string;41  longTermPrivateKeyHex: string;42  entries: Record<string, CachedEntry>;43  tombstones: Record<string, number>;44  inboxEntries: Record<string, StagedEntry>;45  inboxTombstones: Record<string, number>;46}4748let spoolMutation: Promise<void> = Promise.resolve();4950export async function replaceLockedSyncSpool(51  next: Omit<LockedSyncSpool, "version" | "inboxEntries" | "inboxTombstones">,52): Promise<void> {53  await mutateSpool(async (current) => {54    await saveSpool({55      ...next,56      version: 1,57      inboxEntries: current?.inboxEntries ?? {},58      inboxTombstones: current?.inboxTombstones ?? {},59    });60  });61}6263export async function getLockedSyncIdentity(): Promise<SyncPeerIdentity | null> {64  const snapshot = await loadSpool();65  if (!snapshot) return null;66  return {67    deviceId: snapshot.deviceId,68    deviceName: snapshot.deviceName,69    deviceType: snapshot.deviceType,70    longTermPublicKeyHex: snapshot.longTermPublicKeyHex,71    longTermPrivateKey: hexToBytes(snapshot.longTermPrivateKeyHex),72  };73}7475function hexToBytes(value: string): Uint8Array {76  const out = new Uint8Array(value.length / 2);77  for (let i = 0; i < out.length; i += 1) {78    out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);79  }80  return out;81}8283export async function drainLockedSyncInbox(84  live: SyncVaultAdapter,85): Promise<void> {86  const snapshot = await loadSpool();87  if (!snapshot) return;88  const entries = Object.fromEntries(89    Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.ciphertext]),90  );91  const manifest = Object.fromEntries(92    Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.modifiedAt]),93  );94  if (Object.keys(entries).length > 0) await live.applyEntries(entries, manifest);95  if (Object.keys(snapshot.inboxTombstones).length > 0) {96    await live.applyTombstones(snapshot.inboxTombstones);97  }98  await mutateSpool(async (current) => {99    if (!current) return;100    const inboxEntries = { ...current.inboxEntries };101    const inboxTombstones = { ...current.inboxTombstones };102    for (const [id, applied] of Object.entries(snapshot.inboxEntries)) {103      if (inboxEntries[id]?.ciphertext === applied.ciphertext) delete inboxEntries[id];104    }105    for (const [id, appliedAt] of Object.entries(snapshot.inboxTombstones)) {106      if (inboxTombstones[id] === appliedAt) delete inboxTombstones[id];107    }108    await saveSpool({ ...current, inboxEntries, inboxTombstones });109  });110}111112export async function createLockedSyncVaultAdapter(): Promise<SyncVaultAdapter | null> {113  const snapshot = await loadSpool();114  if (!snapshot) return null;115  return {116    async computeVaultId() { return snapshot.vaultId; },117    async getOurDeviceId() { return snapshot.deviceId; },118    async getLocalVersion() { return snapshot.localVersion; },119    async buildEntryManifest() {120      return Object.fromEntries(121        Object.entries(snapshot.entries).map(([id, entry]) => [id, entry.modifiedAt]),122      );123    },124    async buildTombstoneManifest() { return { ...snapshot.tombstones }; },125    async getEncryptedEntries(entryIds) {126      const out: Record<string, string> = {};127      for (const id of entryIds) {128        const entry = snapshot.entries[id];129        if (entry) out[id] = entry.ciphertext;130      }131      return out;132    },133    async applyEntries(entries, remoteManifest = {}) {134      if (isDecoyMode()) throw new Error("vault_locked");135      const list = Object.entries(entries);136      if (list.length > SYNC_LIMITS.MAX_ENTRY_COUNT) throw new Error("sync_entry_count_exceeded");137      await mutateSpool(async (current) => {138        if (isDecoyMode()) throw new Error("vault_locked");139        if (!current || current.vaultId !== snapshot.vaultId) return;140        const inbox = { ...current.inboxEntries };141        const now = Date.now();142        for (const [id, ciphertext] of list) {143          const claimed = remoteManifest[id];144          if (145            typeof claimed !== "number" || !Number.isFinite(claimed) || claimed <= 0 ||146            ciphertext.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64147          ) continue;148          const modifiedAt = clampSyncTimestamp(claimed, now);149          if ((inbox[id]?.modifiedAt ?? 0) < modifiedAt) {150            inbox[id] = { modifiedAt, ciphertext };151          }152        }153        if (Object.keys(inbox).length > SYNC_LIMITS.MAX_ENTRY_COUNT) {154          throw new Error("sync_entry_count_exceeded");155        }156        await saveSpool({ ...current, inboxEntries: inbox });157      });158    },159    async applyTombstones(tombstones) {160      if (isDecoyMode()) throw new Error("vault_locked");161      const list = Object.entries(tombstones);162      if (list.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) throw new Error("sync_tombstone_count_exceeded");163      await mutateSpool(async (current) => {164        if (isDecoyMode()) throw new Error("vault_locked");165        if (!current || current.vaultId !== snapshot.vaultId) return;166        const inbox = { ...current.inboxTombstones };167        const now = Date.now();168        for (const [id, value] of list) {169          if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;170          const deletedAt = clampSyncTimestamp(value, now);171          if ((inbox[id] ?? 0) < deletedAt) inbox[id] = deletedAt;172        }173        if (Object.keys(inbox).length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {174          throw new Error("sync_tombstone_count_exceeded");175        }176        await saveSpool({ ...current, inboxTombstones: inbox });177      });178    },179  };180}181182async function mutateSpool(work: (spool: LockedSyncSpool | null) => Promise<void>): Promise<void> {183  const next = spoolMutation.then(async () => work(await loadSpool()));184  spoolMutation = next.catch(() => undefined);185  return next;186}187188async function loadSpool(): Promise<LockedSyncSpool | null> {189  await removeLegacyDiskSpool();190  if (!chrome.storage.session) return null;191  const result = await chrome.storage.session.get(STORAGE_KEY);192  const value = (result as Record<string, unknown>)[STORAGE_KEY];193  return isLockedSyncSpool(value) ? value : null;194}195196async function saveSpool(spool: LockedSyncSpool): Promise<void> {197  if (!chrome.storage.session) return;198  await chrome.storage.session.set({ [STORAGE_KEY]: spool });199}200201/**202 * Profiles that ran an earlier build still hold the spool — cleartext private203 * key included — in chrome.storage.local. Drop it on first use rather than204 * waiting for an unlock that may never come.205 */206let legacyDiskSpoolRemoval: Promise<void> | null = null;207208function removeLegacyDiskSpool(): Promise<void> {209  if (!legacyDiskSpoolRemoval) {210    legacyDiskSpoolRemoval = Promise.resolve(chrome.storage.local.remove(STORAGE_KEY))211      .catch(() => undefined);212  }213  return legacyDiskSpoolRemoval;214}215216function isLockedSyncSpool(value: unknown): value is LockedSyncSpool {217  if (!value || typeof value !== "object") return false;218  const candidate = value as Partial<LockedSyncSpool>;219  return candidate.version === 1 &&220    typeof candidate.longTermPrivateKeyHex === "string" &&221    /^[0-9a-f]{64}$/.test(candidate.longTermPrivateKeyHex) &&222    typeof candidate.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) &&223    typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 &&224    typeof candidate.deviceName === "string" &&225    typeof candidate.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) &&226    typeof candidate.localVersion === "number" &&227    isRecord(candidate.entries) && isRecord(candidate.tombstones) &&228    isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones);229}230231function isRecord(value: unknown): value is Record<string, unknown> {232  return !!value && typeof value === "object" && !Array.isArray(value);233}