/** * pair-secrets.ts — PB-26-213: the reusable rendezvous secret must never sit * in plaintext in chrome.storage.local. * * Public routing metadata stays in the `peach_paired_devices_v3` registry; * the secrets live in a separate AES-256-GCM record under a wrapping key * derived from the vault root key (HKDF-SHA256, domain-separated label). * Only a trusted-session operational copy exists outside ciphertext, so * locked sync keeps working for the browser session without any disk copy. * * This module never imports vault.ts (vault.ts imports this module): every * function that needs root-key material takes it as a parameter. */ import { HKDF } from "@stablelib/hkdf"; import { SHA256 } from "@stablelib/sha256"; import { decrypt, encrypt, zeroKey } from "./aes"; import { listPairedDevices, writePairedDevices } from "./paired-devices"; const RECORD_KEY = "peach_pair_secrets_v1"; const SESSION_KEY = "peach_pair_secrets_session"; const RECORD_VERSION = 1; const AAD = "peach-pair-secrets-v1"; const WRAP_INFO = new TextEncoder().encode("peach-pair-secrets-v1"); const EMPTY_SALT = new Uint8Array(0); export type PairSecretMap = Record; // PB-26-203: the session operational copy is only written/read where startup // positively established trusted-only session storage. Defaults to allowed // so direct imports keep working; vault.applyStorageHardening sets the real // value before the first restore. let sessionAllowed = true; let mutationTail: Promise = Promise.resolve(); function serializeMutation(operation: () => Promise): Promise { const result = mutationTail.then(operation, operation); mutationTail = result.then( () => undefined, () => undefined, ); return result; } export function setSessionAllowed(allowed: boolean): void { sessionAllowed = allowed; } export function isValidPairSecret(value: unknown): value is string { return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); } function wrappingKey(rootKey: Uint8Array): Uint8Array { if (rootKey.length !== 32) throw new Error("pair_secrets_root_key_invalid"); return new HKDF(SHA256, rootKey, EMPTY_SALT, WRAP_INFO).expand(32); } function hexToBytes(value: string): Uint8Array { const out = new Uint8Array(value.length / 2); for (let i = 0; i < value.length; i += 2) { out[i / 2] = parseInt(value.slice(i, i + 2), 16); } return out; } type SecretEnvelope = { v: number; nonce: string; ciphertext: string; tag: string }; function isEnvelope(value: unknown): value is SecretEnvelope { if (typeof value !== "object" || value === null) return false; const r = value as Record; return ( r.v === RECORD_VERSION && typeof r.nonce === "string" && typeof r.ciphertext === "string" && typeof r.tag === "string" ); } function sanitizeMap(value: unknown): PairSecretMap { if (typeof value !== "object" || value === null) return {}; const out: PairSecretMap = {}; for (const [deviceId, secret] of Object.entries(value as Record)) { if (typeof deviceId === "string" && deviceId && isValidPairSecret(secret)) { out[deviceId] = secret; } } return out; } async function readEncryptedMap(rootKey: Uint8Array): Promise { let stored: unknown; try { const result = await chrome.storage.local.get(RECORD_KEY); stored = (result as Record)[RECORD_KEY]; } catch { return {}; } if (stored === undefined) return {}; if (!isEnvelope(stored)) return {}; const wrap = wrappingKey(rootKey); try { return sanitizeMap(JSON.parse(await decrypt(wrap, stored.nonce, stored.ciphertext, stored.tag, AAD))); } catch { // Wrong key or corrupt record: fail closed, never partial state. return {}; } finally { zeroKey(wrap); } } async function writeEncryptedMap(rootKey: Uint8Array, map: PairSecretMap): Promise { const wrap = wrappingKey(rootKey); try { const sealed = await encrypt(wrap, JSON.stringify(map), AAD); await chrome.storage.local.set({ [RECORD_KEY]: { v: RECORD_VERSION, ...sealed } }); } finally { zeroKey(wrap); } } async function writeSessionCache(map: PairSecretMap): Promise { if (!sessionAllowed || !chrome.storage.session) return; try { await chrome.storage.session.set({ [SESSION_KEY]: map }); } catch { // Session storage is quota-bound; losing locked sync for the session is // preferable to failing the caller (same rule as the locked-sync spool). } } async function readSessionCache(): Promise { if (!sessionAllowed || !chrome.storage.session) return {}; try { const result = await chrome.storage.session.get(SESSION_KEY); return sanitizeMap((result as Record)[SESSION_KEY]); } catch { return {}; } } async function clearSessionCache(): Promise { if (!chrome.storage.session) return; try { await chrome.storage.session.remove(SESSION_KEY); } catch { // Best effort: a stale operational copy is bounded by the browser session. } } /** * Resolve one device's rendezvous secret: trusted-session copy first, then * the encrypted record when the caller holds the root key (unlocked). * Returns null when neither path can serve — sync then stays dormant. */ export async function getPairSecret( deviceId: string, rootKeyHex: string | null, ): Promise { const cached = await readSessionCache(); const hit = cached[deviceId]; if (isValidPairSecret(hit)) return hit; if (!rootKeyHex || !/^[0-9a-f]{64}$/.test(rootKeyHex)) return null; const rootKey = hexToBytes(rootKeyHex); try { const map = await readEncryptedMap(rootKey); const secret = map[deviceId]; return isValidPairSecret(secret) ? secret : null; } finally { zeroKey(rootKey); } } /** * Store (or replace) one device's secret: encrypted record plus session copy. * Pair/re-pair paths call this after upserting the public registry row. */ export async function setPairSecret( deviceId: string, secretHex: string, rootKey: Uint8Array, ): Promise { if (!deviceId || !isValidPairSecret(secretHex)) throw new Error("pair_secret_invalid"); await serializeMutation(async () => { const map = await readEncryptedMap(rootKey); map[deviceId] = secretHex; await writeEncryptedMap(rootKey, map); const cached = await readSessionCache(); cached[deviceId] = secretHex; await writeSessionCache(cached); }); } /** * Bring the encrypted record, the public registry, and the session cache * into agreement: adopt valid legacy plaintext secrets, drop entries for * devices no longer in the registry (unpair/revoke/repair), persist only * after a verified read-back, and scrub the plaintext source last. * Idempotent — safe to run on every real-vault unlock and to retry after * an interruption at any step. */ export async function reconcilePairSecrets( rootKey: Uint8Array, ): Promise<{ migrated: number }> { return serializeMutation(async () => { const devices = await listPairedDevices(); const registryIds = new Set(devices.map((d) => d.deviceId)); const legacy = new Map(); for (const device of devices) { if (isValidPairSecret(device.rendezvousSecretHex)) { legacy.set(device.deviceId, device.rendezvousSecretHex); } } const map = await readEncryptedMap(rootKey); let migrated = 0; for (const [deviceId, secret] of legacy) { if (map[deviceId] !== secret) { map[deviceId] = secret; migrated += 1; } } let pruned = false; for (const deviceId of Object.keys(map)) { if (!registryIds.has(deviceId)) { delete map[deviceId]; pruned = true; } } // Persist only when something actually changed; a fresh vault with no // pairs writes nothing. if (migrated > 0 || pruned || legacy.size > 0 || Object.keys(map).length > 0) { await writeEncryptedMap(rootKey, map); // Crash safety: re-read and verify before touching the plaintext source. const verified = await readEncryptedMap(rootKey); for (const [deviceId, secret] of legacy) { if (verified[deviceId] !== secret) throw new Error("pair_secrets_verify_failed"); } if (legacy.size > 0) { await writePairedDevices( devices.map((device) => { if (!isValidPairSecret(device.rendezvousSecretHex)) return device; const { rendezvousSecretHex: _dropped, ...pub } = device; return pub; }), ); } } await writeSessionCache(map); return { migrated }; }); } /** Rotation drops all peers, so the secret map is cleared with the registry. */ export async function clearPairSecrets(): Promise { await serializeMutation(async () => { try { await chrome.storage.local.remove(RECORD_KEY); } catch { // Best effort alongside the registry clear it accompanies. } await clearSessionCache(); }); } /** * Drop the trusted-session operational copy without touching the encrypted * record. Decoy unlocks call this: a duress session must never serve the * real pair-secret map, and the next real unlock re-populates the copy. */ export async function dropSessionCache(): Promise { await serializeMutation(clearSessionCache); } /** * Forget one device immediately (unpair/revoke): the session copy is always * removed; the encrypted entry is removed too when the caller holds the root * key. Orphans left behind by locked-state removals are pruned by the next * reconcile. */ export async function dropPairSecret( deviceId: string, rootKey: Uint8Array | null, ): Promise { await serializeMutation(async () => { const cached = await readSessionCache(); if (cached[deviceId] !== undefined) { delete cached[deviceId]; await writeSessionCache(cached); } if (!rootKey) return; const map = await readEncryptedMap(rootKey); if (map[deviceId] === undefined) return; delete map[deviceId]; await writeEncryptedMap(rootKey, map); }); }