pair-secrets.ts

9.9 KB · raw

1/**2 * pair-secrets.ts — PB-26-213: the reusable rendezvous secret must never sit3 * in plaintext in chrome.storage.local.4 *5 * Public routing metadata stays in the `peach_paired_devices_v3` registry;6 * the secrets live in a separate AES-256-GCM record under a wrapping key7 * derived from the vault root key (HKDF-SHA256, domain-separated label).8 * Only a trusted-session operational copy exists outside ciphertext, so9 * locked sync keeps working for the browser session without any disk copy.10 *11 * This module never imports vault.ts (vault.ts imports this module): every12 * function that needs root-key material takes it as a parameter.13 */14import { HKDF } from "@stablelib/hkdf";15import { SHA256 } from "@stablelib/sha256";1617import { decrypt, encrypt, zeroKey } from "./aes";18import { listPairedDevices, writePairedDevices } from "./paired-devices";1920const RECORD_KEY = "peach_pair_secrets_v1";21const SESSION_KEY = "peach_pair_secrets_session";22const RECORD_VERSION = 1;23const AAD = "peach-pair-secrets-v1";24const WRAP_INFO = new TextEncoder().encode("peach-pair-secrets-v1");25const EMPTY_SALT = new Uint8Array(0);2627export type PairSecretMap = Record<string, string>;2829// PB-26-203: the session operational copy is only written/read where startup30// positively established trusted-only session storage. Defaults to allowed31// so direct imports keep working; vault.applyStorageHardening sets the real32// value before the first restore.33let sessionAllowed = true;34let mutationTail: Promise<void> = Promise.resolve();3536function serializeMutation<T>(operation: () => Promise<T>): Promise<T> {37  const result = mutationTail.then(operation, operation);38  mutationTail = result.then(39    () => undefined,40    () => undefined,41  );42  return result;43}4445export function setSessionAllowed(allowed: boolean): void {46  sessionAllowed = allowed;47}4849export function isValidPairSecret(value: unknown): value is string {50  return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);51}5253function wrappingKey(rootKey: Uint8Array): Uint8Array {54  if (rootKey.length !== 32) throw new Error("pair_secrets_root_key_invalid");55  return new HKDF(SHA256, rootKey, EMPTY_SALT, WRAP_INFO).expand(32);56}5758function hexToBytes(value: string): Uint8Array {59  const out = new Uint8Array(value.length / 2);60  for (let i = 0; i < value.length; i += 2) {61    out[i / 2] = parseInt(value.slice(i, i + 2), 16);62  }63  return out;64}6566type SecretEnvelope = { v: number; nonce: string; ciphertext: string; tag: string };6768function isEnvelope(value: unknown): value is SecretEnvelope {69  if (typeof value !== "object" || value === null) return false;70  const r = value as Record<string, unknown>;71  return (72    r.v === RECORD_VERSION &&73    typeof r.nonce === "string" &&74    typeof r.ciphertext === "string" &&75    typeof r.tag === "string"76  );77}7879function sanitizeMap(value: unknown): PairSecretMap {80  if (typeof value !== "object" || value === null) return {};81  const out: PairSecretMap = {};82  for (const [deviceId, secret] of Object.entries(value as Record<string, unknown>)) {83    if (typeof deviceId === "string" && deviceId && isValidPairSecret(secret)) {84      out[deviceId] = secret;85    }86  }87  return out;88}8990async function readEncryptedMap(rootKey: Uint8Array): Promise<PairSecretMap> {91  let stored: unknown;92  try {93    const result = await chrome.storage.local.get(RECORD_KEY);94    stored = (result as Record<string, unknown>)[RECORD_KEY];95  } catch {96    return {};97  }98  if (stored === undefined) return {};99  if (!isEnvelope(stored)) return {};100  const wrap = wrappingKey(rootKey);101  try {102    return sanitizeMap(JSON.parse(await decrypt(wrap, stored.nonce, stored.ciphertext, stored.tag, AAD)));103  } catch {104    // Wrong key or corrupt record: fail closed, never partial state.105    return {};106  } finally {107    zeroKey(wrap);108  }109}110111async function writeEncryptedMap(rootKey: Uint8Array, map: PairSecretMap): Promise<void> {112  const wrap = wrappingKey(rootKey);113  try {114    const sealed = await encrypt(wrap, JSON.stringify(map), AAD);115    await chrome.storage.local.set({ [RECORD_KEY]: { v: RECORD_VERSION, ...sealed } });116  } finally {117    zeroKey(wrap);118  }119}120121async function writeSessionCache(map: PairSecretMap): Promise<void> {122  if (!sessionAllowed || !chrome.storage.session) return;123  try {124    await chrome.storage.session.set({ [SESSION_KEY]: map });125  } catch {126    // Session storage is quota-bound; losing locked sync for the session is127    // preferable to failing the caller (same rule as the locked-sync spool).128  }129}130131async function readSessionCache(): Promise<PairSecretMap> {132  if (!sessionAllowed || !chrome.storage.session) return {};133  try {134    const result = await chrome.storage.session.get(SESSION_KEY);135    return sanitizeMap((result as Record<string, unknown>)[SESSION_KEY]);136  } catch {137    return {};138  }139}140141async function clearSessionCache(): Promise<void> {142  if (!chrome.storage.session) return;143  try {144    await chrome.storage.session.remove(SESSION_KEY);145  } catch {146    // Best effort: a stale operational copy is bounded by the browser session.147  }148}149150/**151 * Resolve one device's rendezvous secret: trusted-session copy first, then152 * the encrypted record when the caller holds the root key (unlocked).153 * Returns null when neither path can serve — sync then stays dormant.154 */155export async function getPairSecret(156  deviceId: string,157  rootKeyHex: string | null,158): Promise<string | null> {159  const cached = await readSessionCache();160  const hit = cached[deviceId];161  if (isValidPairSecret(hit)) return hit;162  if (!rootKeyHex || !/^[0-9a-f]{64}$/.test(rootKeyHex)) return null;163  const rootKey = hexToBytes(rootKeyHex);164  try {165    const map = await readEncryptedMap(rootKey);166    const secret = map[deviceId];167    return isValidPairSecret(secret) ? secret : null;168  } finally {169    zeroKey(rootKey);170  }171}172173/**174 * Store (or replace) one device's secret: encrypted record plus session copy.175 * Pair/re-pair paths call this after upserting the public registry row.176 */177export async function setPairSecret(178  deviceId: string,179  secretHex: string,180  rootKey: Uint8Array,181): Promise<void> {182  if (!deviceId || !isValidPairSecret(secretHex)) throw new Error("pair_secret_invalid");183  await serializeMutation(async () => {184    const map = await readEncryptedMap(rootKey);185    map[deviceId] = secretHex;186    await writeEncryptedMap(rootKey, map);187    const cached = await readSessionCache();188    cached[deviceId] = secretHex;189    await writeSessionCache(cached);190  });191}192193/**194 * Bring the encrypted record, the public registry, and the session cache195 * into agreement: adopt valid legacy plaintext secrets, drop entries for196 * devices no longer in the registry (unpair/revoke/repair), persist only197 * after a verified read-back, and scrub the plaintext source last.198 * Idempotent — safe to run on every real-vault unlock and to retry after199 * an interruption at any step.200 */201export async function reconcilePairSecrets(202  rootKey: Uint8Array,203): Promise<{ migrated: number }> {204  return serializeMutation(async () => {205    const devices = await listPairedDevices();206    const registryIds = new Set(devices.map((d) => d.deviceId));207    const legacy = new Map<string, string>();208    for (const device of devices) {209      if (isValidPairSecret(device.rendezvousSecretHex)) {210        legacy.set(device.deviceId, device.rendezvousSecretHex);211      }212    }213214    const map = await readEncryptedMap(rootKey);215    let migrated = 0;216    for (const [deviceId, secret] of legacy) {217      if (map[deviceId] !== secret) {218        map[deviceId] = secret;219        migrated += 1;220      }221    }222    let pruned = false;223    for (const deviceId of Object.keys(map)) {224      if (!registryIds.has(deviceId)) {225        delete map[deviceId];226        pruned = true;227      }228    }229230    // Persist only when something actually changed; a fresh vault with no231    // pairs writes nothing.232    if (migrated > 0 || pruned || legacy.size > 0 || Object.keys(map).length > 0) {233      await writeEncryptedMap(rootKey, map);234      // Crash safety: re-read and verify before touching the plaintext source.235      const verified = await readEncryptedMap(rootKey);236      for (const [deviceId, secret] of legacy) {237        if (verified[deviceId] !== secret) throw new Error("pair_secrets_verify_failed");238      }239      if (legacy.size > 0) {240        await writePairedDevices(241          devices.map((device) => {242            if (!isValidPairSecret(device.rendezvousSecretHex)) return device;243            const { rendezvousSecretHex: _dropped, ...pub } = device;244            return pub;245          }),246        );247      }248    }249250    await writeSessionCache(map);251    return { migrated };252  });253}254255/** Rotation drops all peers, so the secret map is cleared with the registry. */256export async function clearPairSecrets(): Promise<void> {257  await serializeMutation(async () => {258    try {259      await chrome.storage.local.remove(RECORD_KEY);260    } catch {261      // Best effort alongside the registry clear it accompanies.262    }263    await clearSessionCache();264  });265}266267/**268 * Drop the trusted-session operational copy without touching the encrypted269 * record. Decoy unlocks call this: a duress session must never serve the270 * real pair-secret map, and the next real unlock re-populates the copy.271 */272export async function dropSessionCache(): Promise<void> {273  await serializeMutation(clearSessionCache);274}275276/**277 * Forget one device immediately (unpair/revoke): the session copy is always278 * removed; the encrypted entry is removed too when the caller holds the root279 * key. Orphans left behind by locked-state removals are pruned by the next280 * reconcile.281 */282export async function dropPairSecret(283  deviceId: string,284  rootKey: Uint8Array | null,285): Promise<void> {286  await serializeMutation(async () => {287    const cached = await readSessionCache();288    if (cached[deviceId] !== undefined) {289      delete cached[deviceId];290      await writeSessionCache(cached);291    }292    if (!rootKey) return;293    const map = await readEncryptedMap(rootKey);294    if (map[deviceId] === undefined) return;295    delete map[deviceId];296    await writeEncryptedMap(rootKey, map);297  });298}