pair-secrets.ts

9.3 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;3435export function setSessionAllowed(allowed: boolean): void {36  sessionAllowed = allowed;37}3839export function isValidPairSecret(value: unknown): value is string {40  return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);41}4243function wrappingKey(rootKey: Uint8Array): Uint8Array {44  if (rootKey.length !== 32) throw new Error("pair_secrets_root_key_invalid");45  return new HKDF(SHA256, rootKey, EMPTY_SALT, WRAP_INFO).expand(32);46}4748function hexToBytes(value: string): Uint8Array {49  const out = new Uint8Array(value.length / 2);50  for (let i = 0; i < value.length; i += 2) {51    out[i / 2] = parseInt(value.slice(i, i + 2), 16);52  }53  return out;54}5556type SecretEnvelope = { v: number; nonce: string; ciphertext: string; tag: string };5758function isEnvelope(value: unknown): value is SecretEnvelope {59  if (typeof value !== "object" || value === null) return false;60  const r = value as Record<string, unknown>;61  return (62    r.v === RECORD_VERSION &&63    typeof r.nonce === "string" &&64    typeof r.ciphertext === "string" &&65    typeof r.tag === "string"66  );67}6869function sanitizeMap(value: unknown): PairSecretMap {70  if (typeof value !== "object" || value === null) return {};71  const out: PairSecretMap = {};72  for (const [deviceId, secret] of Object.entries(value as Record<string, unknown>)) {73    if (typeof deviceId === "string" && deviceId && isValidPairSecret(secret)) {74      out[deviceId] = secret;75    }76  }77  return out;78}7980async function readEncryptedMap(rootKey: Uint8Array): Promise<PairSecretMap> {81  let stored: unknown;82  try {83    const result = await chrome.storage.local.get(RECORD_KEY);84    stored = (result as Record<string, unknown>)[RECORD_KEY];85  } catch {86    return {};87  }88  if (stored === undefined) return {};89  if (!isEnvelope(stored)) return {};90  const wrap = wrappingKey(rootKey);91  try {92    return sanitizeMap(JSON.parse(await decrypt(wrap, stored.nonce, stored.ciphertext, stored.tag, AAD)));93  } catch {94    // Wrong key or corrupt record: fail closed, never partial state.95    return {};96  } finally {97    zeroKey(wrap);98  }99}100101async function writeEncryptedMap(rootKey: Uint8Array, map: PairSecretMap): Promise<void> {102  const wrap = wrappingKey(rootKey);103  try {104    const sealed = await encrypt(wrap, JSON.stringify(map), AAD);105    await chrome.storage.local.set({ [RECORD_KEY]: { v: RECORD_VERSION, ...sealed } });106  } finally {107    zeroKey(wrap);108  }109}110111async function writeSessionCache(map: PairSecretMap): Promise<void> {112  if (!sessionAllowed || !chrome.storage.session) return;113  try {114    await chrome.storage.session.set({ [SESSION_KEY]: map });115  } catch {116    // Session storage is quota-bound; losing locked sync for the session is117    // preferable to failing the caller (same rule as the locked-sync spool).118  }119}120121async function readSessionCache(): Promise<PairSecretMap> {122  if (!sessionAllowed || !chrome.storage.session) return {};123  try {124    const result = await chrome.storage.session.get(SESSION_KEY);125    return sanitizeMap((result as Record<string, unknown>)[SESSION_KEY]);126  } catch {127    return {};128  }129}130131async function clearSessionCache(): Promise<void> {132  if (!chrome.storage.session) return;133  try {134    await chrome.storage.session.remove(SESSION_KEY);135  } catch {136    // Best effort: a stale operational copy is bounded by the browser session.137  }138}139140/**141 * Resolve one device's rendezvous secret: trusted-session copy first, then142 * the encrypted record when the caller holds the root key (unlocked).143 * Returns null when neither path can serve — sync then stays dormant.144 */145export async function getPairSecret(146  deviceId: string,147  rootKeyHex: string | null,148): Promise<string | null> {149  const cached = await readSessionCache();150  const hit = cached[deviceId];151  if (isValidPairSecret(hit)) return hit;152  if (!rootKeyHex || !/^[0-9a-f]{64}$/.test(rootKeyHex)) return null;153  const rootKey = hexToBytes(rootKeyHex);154  try {155    const map = await readEncryptedMap(rootKey);156    const secret = map[deviceId];157    return isValidPairSecret(secret) ? secret : null;158  } finally {159    zeroKey(rootKey);160  }161}162163/**164 * Store (or replace) one device's secret: encrypted record plus session copy.165 * Pair/re-pair paths call this after upserting the public registry row.166 */167export async function setPairSecret(168  deviceId: string,169  secretHex: string,170  rootKey: Uint8Array,171): Promise<void> {172  if (!deviceId || !isValidPairSecret(secretHex)) throw new Error("pair_secret_invalid");173  const map = await readEncryptedMap(rootKey);174  map[deviceId] = secretHex;175  await writeEncryptedMap(rootKey, map);176  const cached = await readSessionCache();177  cached[deviceId] = secretHex;178  await writeSessionCache(cached);179}180181/**182 * Bring the encrypted record, the public registry, and the session cache183 * into agreement: adopt valid legacy plaintext secrets, drop entries for184 * devices no longer in the registry (unpair/revoke/repair), persist only185 * after a verified read-back, and scrub the plaintext source last.186 * Idempotent — safe to run on every real-vault unlock and to retry after187 * an interruption at any step.188 */189export async function reconcilePairSecrets(190  rootKey: Uint8Array,191): Promise<{ migrated: number }> {192  const devices = await listPairedDevices();193  const registryIds = new Set(devices.map((d) => d.deviceId));194  const legacy = new Map<string, string>();195  for (const device of devices) {196    if (isValidPairSecret(device.rendezvousSecretHex)) {197      legacy.set(device.deviceId, device.rendezvousSecretHex);198    }199  }200201  const map = await readEncryptedMap(rootKey);202  let migrated = 0;203  for (const [deviceId, secret] of legacy) {204    if (map[deviceId] !== secret) {205      map[deviceId] = secret;206      migrated += 1;207    }208  }209  let pruned = false;210  for (const deviceId of Object.keys(map)) {211    if (!registryIds.has(deviceId)) {212      delete map[deviceId];213      pruned = true;214    }215  }216217  // Persist only when something actually changed; a fresh vault with no218  // pairs writes nothing.219  if (migrated > 0 || pruned || legacy.size > 0 || Object.keys(map).length > 0) {220    await writeEncryptedMap(rootKey, map);221    // Crash safety: re-read and verify before touching the plaintext source.222    const verified = await readEncryptedMap(rootKey);223    for (const [deviceId, secret] of legacy) {224      if (verified[deviceId] !== secret) throw new Error("pair_secrets_verify_failed");225    }226    if (legacy.size > 0) {227      await writePairedDevices(228        devices.map((device) => {229          if (!isValidPairSecret(device.rendezvousSecretHex)) return device;230          const { rendezvousSecretHex: _dropped, ...pub } = device;231          return pub;232        }),233      );234    }235  }236237  await writeSessionCache(map);238  return { migrated };239}240241/** Rotation drops all peers, so the secret map is cleared with the registry. */242export async function clearPairSecrets(): Promise<void> {243  try {244    await chrome.storage.local.remove(RECORD_KEY);245  } catch {246    // Best effort alongside the registry clear it accompanies.247  }248  await clearSessionCache();249}250251/**252 * Drop the trusted-session operational copy without touching the encrypted253 * record. Decoy unlocks call this: a duress session must never serve the254 * real pair-secret map, and the next real unlock re-populates the copy.255 */256export async function dropSessionCache(): Promise<void> {257  await clearSessionCache();258}259260/**261 * Forget one device immediately (unpair/revoke): the session copy is always262 * removed; the encrypted entry is removed too when the caller holds the root263 * key. Orphans left behind by locked-state removals are pruned by the next264 * reconcile.265 */266export async function dropPairSecret(267  deviceId: string,268  rootKey: Uint8Array | null,269): Promise<void> {270  const cached = await readSessionCache();271  if (cached[deviceId] !== undefined) {272    delete cached[deviceId];273    await writeSessionCache(cached);274  }275  if (!rootKey) return;276  const map = await readEncryptedMap(rootKey);277  if (map[deviceId] === undefined) return;278  delete map[deviceId];279  await writeEncryptedMap(rootKey, map);280}