/** * vault.ts — sole owner of vault state and chrome.storage vault keys. * * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings` * goes through this module. No other file accesses those keys directly. * External callers reach vault operations via `messages.ts` — never by * importing from here cross-boundary (popup/content must not). Invariants * for the two-slot schema, unlock timing, and key/disk atomicity are in * Projects.md §3.2–§3.6. */ import { deriveKey, generateSalt } from "../crypto/argon2"; import { extractDomain } from "../shared/domain"; import { clampSyncTimestamp } from "../shared/sync-limits"; import { computeVaultId } from "../shared/sync-identity"; // Static, not `await import()`: runtime import() throws in an MV3 service // worker. Neither module imports vault.ts back, so there is no cycle to break. import { triggerSyncIfReachable } from "./sync-trigger"; import { clearPairedDevices } from "./paired-devices"; import { rootKeyToWords, wordsToRootKeyMaterial, deriveRecoveryKey, } from "../shared/recovery-words"; import type { AccentColorName, Canary, ManualSummonRecord, PasskeyData, SavedIdentity, StoredVault, VaultEntry, VaultHistory, VaultSnapshot, } from "../shared/types"; import { decrypt, encrypt, zeroKey } from "./aes"; import { clearAutoLock, touchAutoLock } from "./alarms"; import { clearRecoveryConfirmPending, setRecoveryConfirmPending, } from "./recovery-pending"; import { clearPairSecrets, dropSessionCache, reconcilePairSecrets, setSessionAllowed, } from "./pair-secrets"; type EntryDraft = Omit< VaultEntry, "id" | "createdAt" | "modifiedAt" | "deletedAt" >; // Slot-neutral storage keys (v1.4.24+). Both slots always exist; slot B // either holds the decoy vault (when duress is configured) or random noise // that looks identical at rest. No external indicator is stored — duress // configuration is recorded only inside the encrypted payload of slot A. const SLOT_A = "peach_vault_a"; const SLOT_B = "peach_vault_b"; // Plaintext is padded to the next multiple of this size before encryption. // This coarsens ciphertext-length leakage into 1 KiB buckets. const PAD_BLOCK = 1024; // How many past passwords to retain per entry. Generous enough to be useful as // a recovery aid while bounding the secret material we hold (and the ciphertext // growth) for an entry whose password is rotated frequently. const PASSWORD_HISTORY_MAX = 50; // When duress is configured, BOTH slots are padded to this fixed ceiling so // their ciphertext lengths are identical and cannot reveal that a hidden decoy // vault exists (audit MEDIUM — plausible-deniability leak). 256 KiB comfortably // holds a very large vault (thousands of entries incl. passkeys). The decoy // slot can't be re-encrypted on every real-vault save (the real session has no // decoy key), so a fixed ceiling — rather than size-matching on each write — is // what keeps the two slots equal. A real vault whose plaintext exceeds this // would grow past the ceiling and re-expose the bit; that is an extreme, // documented edge well beyond normal use. const DURESS_SLOT_SIZE = 262_144; let derivedKey: Uint8Array | null = null; // 32-byte random root key, separate from the derivedKey used for vault encryption. // The root key is wrapped (encrypted) under the recovery key derived from the // 12-word phrase. When the user changes their password, only the wrapping changes; // the root key and recovery words remain stable. let rootKey: Uint8Array | null = null; let entries: VaultEntry[] = []; // Tombstones outlive their full entry records so a peer that was offline // during a purge cannot reintroduce an old ciphertext envelope. This map is // serialized only inside the encrypted vault payload. let tombstones = new Map(); let domainIndex: Map = new Map(); let vaultMode: "real" | "decoy" = "real"; // Cached post-unlock view of whether the real vault records that duress is // configured. Only meaningful after a real-mode unlock; for decoy-mode unlock // this stays false because we never see slot A's plaintext. let duressConfigured = false; // Set to a corruption reason during the last unlock() attempt when both slots // decrypted but failed schema validation. Consumed and cleared by // consumeLastUnlockFailureReason() so messages.ts can surface vault_corrupted // distinctly from wrong-password (M7/COR-007). // // NOTE: module-level state — if vault.ts is ever moved to a worker context, // this must be replaced with a structured return value from unlock() or a // postMessage channel. The signal is invisible across module-instance // boundaries. let lastUnlockFailureReason: string | null = null; export function consumeLastUnlockFailureReason(): string | null { const r = lastUnlockFailureReason; lastUnlockFailureReason = null; return r; } // --------------------------------------------------------------------------- // Settings (persisted inside the encrypted vault store as a sidecar key) // --------------------------------------------------------------------------- export type TextSize = "small" | "medium" | "large"; export interface VaultSettings { accentColor?: AccentColorName; theme?: "dark" | "light"; autoLockMinutes?: number; clipboardClearSeconds?: number; lockOnClose?: boolean; breachMonitoring?: boolean; passkeyProviderEnabled?: boolean; trashRetentionDays?: number; manualSummonDomains?: Record; manualSummonSalt?: string; declinedCardFingerprints?: string[]; /** Metadata is bounded because it remains outside the encrypted vault blob. */ metadataVersion?: 1; /** * Text size scale for body copy and entry titles. Stops at "large" by * design — XL clips dense screens (Vault, Health) inside the fixed * 330×480 popup surface. */ textSize?: TextSize; /** * When true, all entrance/exit transitions and ambient animations are * collapsed to instant. Honoured alongside the user's OS-level * `prefers-reduced-motion`. */ reduceMotion?: boolean; /** Whether this vault has a local PIN unlock wrapper configured. */ pinEnabled?: boolean; /** Whether the lock screen should open on PIN instead of the password. */ pinPreferred?: boolean; /** * User overrides for keyboard shortcuts. Keys are ShortcutAction names * (see src/shared/shortcuts.ts). Missing actions fall through to * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the * canonical lookup. Storing only the deltas keeps the vault payload * small and lets default updates land for users who never rebind. */ shortcuts?: Record; } interface StoredPinWrap { version: 1; salt: string; nonce: string; ciphertext: string; tag: string; } const PIN_WRAP_STORAGE = "peach_pin_wrap"; const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts"; const PIN_MIN_LENGTH = 4; const PIN_MAX_LENGTH = 12; const PIN_MAX_ATTEMPTS = 3; let settings: VaultSettings = {}; const CARD_DECLINE_MAX = 256; /** Hex length of a keyed card-decline digest. Legacy records are 16 hex. */ const CARD_DECLINE_DIGEST_HEX = 32; async function metadataDigest(value: string, key: string): Promise { const bytes = new TextEncoder().encode(`${key}:${value}`); const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join(""); } let inMemoryPinWrap: StoredPinWrap | null = null; let inMemoryPinFailedAttempts = 0; let sessionPinStorageFailed = false; export function getSettings(): VaultSettings { return settings; } export async function ensureSettingsLoaded(): Promise { await loadSettings(); return settings; } export async function updateSettings(patch: Partial): Promise { await loadSettings(); // LV-7: cheap insurance against prototype pollution if a future caller ever // feeds parsed external JSON here (native spread keeps __proto__ an own- // property today, but Object.assign-style consumers and JSON revivers have // been burned before). const safe = Object.fromEntries( Object.entries(patch).filter( ([k]) => !["__proto__", "constructor", "prototype"].includes(k), ), ) as Partial; settings = { ...settings, ...safe }; await persistSettings(); } function isValidPin(pin: string): boolean { return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin); } /** Set up a local PIN wrapper after proving the current master password. */ export async function setupPinUnlock(currentPassword: string, pin: string): Promise { if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false; if (!(await verifyMainPassword(currentPassword))) return false; const salt = generateSalt(); const pinKey = await deriveKey(pin, salt); try { const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1"); await savePinWrap({ version: 1, salt: hex(salt), ...wrapped }); await updateSettings({ pinEnabled: true }); return true; } finally { zeroKey(pinKey); } } async function clearPinUnlockStorage(): Promise { inMemoryPinWrap = null; inMemoryPinFailedAttempts = 0; sessionPinStorageFailed = false; if (chrome.storage.session) { await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]); } await updateSettings({ pinEnabled: false }); } /** Remove the PIN wrapper after proving the current master password. */ export async function disablePinUnlock(currentPassword: string): Promise { if (!derivedKey || vaultMode !== "real") return false; if (!(await verifyMainPassword(currentPassword))) return false; await clearPinUnlockStorage(); return true; } /** Unlock the real vault from its locally stored PIN-wrapped derived key. */ export async function unlockWithPin(pin: string): Promise { if (!isValidPin(pin)) return false; await loadSettings(); if (!settings.pinEnabled) return false; const { stored, failedAttempts } = await loadPinState(); if (failedAttempts >= PIN_MAX_ATTEMPTS) return false; if ( !stored || stored.version !== 1 || !stored.salt || !stored.nonce || !stored.ciphertext || !stored.tag ) return false; const pinKey = await deriveKey(pin, unhex(stored.salt)); let unwrappedKey: Uint8Array | null = null; try { const masterKeyHex = await decrypt( pinKey, stored.nonce, stored.ciphertext, stored.tag, "pin_wrap_v1", ); if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false; unwrappedKey = unhex(masterKeyHex); const result = await unlockWithKey(masterKeyHex); if (!result.ok) return false; await resetPinFailedAttempts(); return true; } catch { await recordPinFailure(failedAttempts + 1); return false; } finally { zeroKey(pinKey); if (unwrappedKey) zeroKey(unwrappedKey); } } async function savePinWrap(stored: StoredPinWrap): Promise { inMemoryPinWrap = stored; inMemoryPinFailedAttempts = 0; sessionPinStorageFailed = !chrome.storage.session; if (!chrome.storage.session) return; try { await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 }); sessionPinStorageFailed = false; } catch { // Firefox fallback: keep the wrapper only in this background context. sessionPinStorageFailed = true; } } async function loadPinState(): Promise<{ stored: StoredPinWrap | undefined; failedAttempts: number; }> { if (!chrome.storage.session) { return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts }; } try { const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]); return { stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ?? (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined), failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number" ? result[PIN_ATTEMPTS_STORAGE] : 0, }; } catch { return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts }; } } async function recordPinFailure(failedAttempts: number): Promise { inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS); if (!chrome.storage.session) return; try { await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts }); } catch { /* in-memory counter remains the fallback */ } } async function resetPinFailedAttempts(): Promise { inMemoryPinFailedAttempts = 0; if (!chrome.storage.session) return; try { await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 }); } catch { /* in-memory counter remains the fallback */ } } export async function getPinUnlockStatus(): Promise<{ configured: boolean; available: boolean; attemptsRemaining: number; }> { await loadSettings(); if (!settings.pinEnabled) { return { configured: false, available: false, attemptsRemaining: 0 }; } const { stored, failedAttempts } = await loadPinState(); const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS; return { configured: true, available, attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0, }; } async function persistSettings(): Promise { try { await chrome.storage.local.set({ peach_settings: { ...settings, manualSummonDomains: undefined, manualSummonSalt: undefined, cardFingerprintSalt: undefined, declinedCardFingerprints: settings.declinedCardFingerprints ?? [], metadataVersion: 1, }, }); } catch (err) { throw mapStorageError(err); } } function mapStorageError(err: unknown): Error { const message = err instanceof Error ? err.message : String(err); if (/QUOTA_BYTES|quota/i.test(message)) { return new Error("STORAGE_QUOTA_EXCEEDED"); } return err instanceof Error ? err : new Error(message); } async function loadSettings(): Promise { const result = await chrome.storage.local.get("peach_settings"); settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {}; } // Fire-and-forget sync trigger after a committed vault write. A failure here // must never fail the write that caused it. function fireSyncTrigger(reason: "vault_change"): void { void Promise.resolve() .then(() => triggerSyncIfReachable({ reason })) .catch(() => { /* no sync runner registered, or nothing reachable — non-fatal */ }); } let lockedSyncRefreshHook: (() => Promise) | null = null; let lockedSyncUnlockHook: (() => Promise) | null = null; export function setLockedSyncHooks(hooks: { refresh: () => Promise; afterUnlock: () => Promise; }): void { lockedSyncRefreshHook = hooks.refresh; lockedSyncUnlockHook = hooks.afterUnlock; } async function refreshLockedSyncState(): Promise { if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return; await lockedSyncRefreshHook().catch((error) => { console.warn("[Peach sync] locked spool refresh failed:", error); }); } // --------------------------------------------------------------------------- // HIBP breach check (k-anonymity model) // --------------------------------------------------------------------------- export async function checkBreach(password: string): Promise { // HIBP k-anonymity API is keyed on SHA-1 prefixes — this is dictated by // the public API, not a security choice. Do not migrate to SHA-256 here. const encoder = new TextEncoder(); const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password)); const hashHex = Array.from(new Uint8Array(hashBuffer)) .map((b) => b.toString(16).padStart(2, "0")) .join("") .toUpperCase(); const prefix = hashHex.slice(0, 5); const suffix = hashHex.slice(5); try { // PCH-02-007: request HIBP response padding so response length does not // reveal how many suffixes a queried prefix returned. const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, { headers: { "Add-Padding": "true" }, }); if (!res.ok) return 0; const text = await res.text(); const match = text.split("\r\n").find((l) => l.startsWith(suffix)); return match ? parseInt(match.split(":")[1], 10) : 0; } catch { return 0; // Network unavailable — don't block } } export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise { // Unreachable from the message layer, which gates on isUnlocked first. const key = await cardDeclineKey(); if (!key) return; const fingerprint = await cardFingerprint(last4, expMonth, expYear); await loadSettings(); settings = { ...settings, declinedCardFingerprints: [ // Legacy records predate the keyed digest and are silently dropped here: // they are 16 hex characters rather than 32, and are no longer accepted. ...(settings.declinedCardFingerprints ?? []).filter( (stored) => stored.length === CARD_DECLINE_DIGEST_HEX, ), await metadataDigest(fingerprint, key), ].slice(-CARD_DECLINE_MAX), }; await persistSettings(); } export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise { await loadSettings(); const key = await cardDeclineKey(); if (!key) return false; const fingerprint = await cardFingerprint(last4, expMonth, expYear); const candidate = await metadataDigest(fingerprint, key); return (settings.declinedCardFingerprints ?? []).some( (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate, ); } /** * Keying material for the declined-card digest, derived from the vault root * key so it is available only while unlocked and never written to storage. * * PB-26-108: the previous scheme stored a random salt beside the records. A * salt defeats precomputed tables, not an attacker holding the file — the * candidate space here is ~10^4 last-four values x 12 months x a few years, so * a stored salt left the card's last four and expiry recoverable offline in * seconds. `peach_settings` is unencrypted and directly readable by a content * script on Firefox, so the key has to live outside storage entirely. */ async function cardDeclineKey(): Promise { const root = getRootKeyHex(); if (!root) return null; const digest = new Uint8Array( await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(`peach-card-decline-v1:${root}`), ), ); return hex(digest); } async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise { const encoder = new TextEncoder(); const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`)); return Array.from(new Uint8Array(buf).slice(0, 8)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } function hex(buf: Uint8Array): string { return Array.from(buf) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } function unhex(s: string): Uint8Array { const out = new Uint8Array(s.length / 2); for (let i = 0; i < s.length; i += 2) { out[i / 2] = parseInt(s.slice(i, i + 2), 16); } return out; } // --------------------------------------------------------------------------- // Root key generation and recovery word wrapping // --------------------------------------------------------------------------- /** * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key * derived from the recovery word material via HKDF-SHA256. The wrapped blob * fields are stored in slot A alongside the password-wrapped ciphertext. The * root key itself is kept in memory for the session so getRecoveryWords() can * encode it without re-deriving. * * SECURITY MODEL (PCH-01-005): the recovery KEK is derived from the 12-word * material, which encodes only rootKey[0..16] (recovery-words.ts). The KEK is thus * derived from a PREFIX of the very plaintext it wraps, and effective recovery * security is 128-bit (standard for a 12-word phrase), NOT 256-bit. This is safe — * unwrapping requires the words, and the GCM ciphertext leaks nothing without them — * but it is intentional, not an oversight. A future escrow version wanting 256-bit * recovery must derive the words from INDEPENDENT entropy, not from the root key. */ async function generateAndWrapRootKey(): Promise<{ rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey recoverySalt: string; wrappedRootKey: string; wrappedNonce: string; wrappedTag: string; }> { const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32)); // Derive the recovery key from the BIP39 words encoding of the root key. const words = await rootKeyToWords(rootKeyBytes); const wordMaterial = await wordsToRootKeyMaterial(words); const recoverySalt = crypto.getRandomValues(new Uint8Array(16)); const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt); // Wrap the root key with the recovery key using AES-256-GCM directly. // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8. const nonce = crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(hex(rootKeyBytes)); const raw = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128, }, recoveryKey, encoded ); // WebCrypto returns ciphertext || auth tag concatenated; split them. const rawU8 = new Uint8Array(raw); const ciphertext = hex(rawU8.slice(0, rawU8.length - 16)); const tag = hex(rawU8.slice(rawU8.length - 16)); return { rawRootKey: hex(rootKeyBytes), recoverySalt: hex(recoverySalt), wrappedRootKey: ciphertext, wrappedNonce: hex(nonce), wrappedTag: tag, }; } async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{ recoverySalt: string; wrappedRootKey: string; wrappedNonce: string; wrappedTag: string; }> { if (rootKeyBytes.length !== 32) { throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes"); } const words = await rootKeyToWords(rootKeyBytes); const wordMaterial = await wordsToRootKeyMaterial(words); const recoverySalt = crypto.getRandomValues(new Uint8Array(16)); const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt); const nonce = crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(hex(rootKeyBytes)); const raw = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128, }, recoveryKey, encoded, ); const rawU8 = new Uint8Array(raw); return { recoverySalt: hex(recoverySalt), wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)), wrappedNonce: hex(nonce), wrappedTag: hex(rawU8.slice(rawU8.length - 16)), }; } /** * Unwrap the root key using recovery words. Called during recovery flow when * the user provides their 12-word phrase and a new password. * Exported for use by the RECOVER_WITH_WORDS message handler. */ export async function unwrapRootKeyForRecovery( wrappedRootKey: string, wrappedNonce: string, wrappedTag: string, recoverySaltHex: string, words: string[] ): Promise { const wordMaterial = await wordsToRootKeyMaterial(words); const recoverySalt = unhex(recoverySaltHex); const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt); // Decrypt and cache the root key. The root key bytes were hex-encoded before // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers. // Each component is stored separately in the vault slot; decode them individually // so the nonce is always exactly the 12 bytes from wrappedNonce. const nonceRaw = unhex(wrappedNonce); const ciphertextRaw = unhex(wrappedRootKey); const tagRaw = unhex(wrappedTag); // Combine ciphertext and tag for AES-GCM (expects ciphertext||tag). // Use explicit ArrayBuffer allocations to satisfy TS BufferSource constraints. const nonceU8 = nonceRaw as unknown as Uint8Array; const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length); combined.set(ciphertextRaw, 0); combined.set(tagRaw, ciphertextRaw.length); const raw = await crypto.subtle.decrypt( { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 }, recoveryKey, combined ); const decryptedHex = new TextDecoder().decode(raw); rootKey = unhex(decryptedHex); } /** * Re-wrap the cached root key under a new derived key (from the user's new * password). Called after password change and after recovery re-key so the * same recovery words continue to work with the updated password — and so * subsequent unlocks can rehydrate rootKey via deriveRootKeyFromDerivedKey. * * recovery_* fields are intentionally not touched: those wrap rootKey under * the recovery words, which do not change when the password changes. */ export async function rewrapRootKeyForPassword( newDerivedKey: Uint8Array, _newSalt: Uint8Array ): Promise { if (!rootKey) throw new Error("root key not set"); const slotA = await loadSlot(SLOT_A); if (!slotA) throw new Error("missing slot A"); const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey); await writeSlot(SLOT_A, { ...slotA, ...pwRoot }); } /** * Change the vault encryption to a new derived key (already derived from * the user's new password). Used by RECOVER_WITH_WORDS and changePassword. * Caller is responsible for key swap and snapshot. */ async function reencryptVaultForNewKey( newKey: Uint8Array, slotA: StoredVault ): Promise { const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0); const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1"); await writeSlot(SLOT_A, { ...slotA, nonce, ciphertext, tag, }); } /** * Full password change with an already-derived key (used after recovery). * Unlike changePassword(), this skips re-verification of current password * since recovery flow has already validated the user's identity via words. */ export async function changePasswordWithNewKey( newSalt: Uint8Array, newKey: Uint8Array ): Promise { const epoch = vaultEpoch; const throwIfStale = () => { if (epoch === vaultEpoch) return; zeroKey(newKey); throw new Error("vault_locked"); }; const slotA = await loadSlot(SLOT_A); throwIfStale(); if (!slotA) throw new Error("missing slot A"); // reencryptVaultForNewKey must receive the updated salt so that subsequent // UNLOCK calls with the new password can re-derive the correct key. await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) }); throwIfStale(); if (derivedKey) zeroKey(derivedKey); derivedKey = newKey; // PCH-01-008: re-escrow the new key (see changePassword). The recovery flow // reaches this path, so a post-recovery SW restart would otherwise lock out // a user who just reset their password. await saveSessionKey(newKey, "real", epoch); throwIfStale(); // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow // should have populated it — fall back silently rather than blowing up the // recovery path; the escrow is already present on every supported vault. if (rootKey) { const slotAfter = await loadSlot(SLOT_A); throwIfStale(); if (slotAfter) { const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey); throwIfStale(); await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot }); throwIfStale(); } } if (!duressConfigured) { await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2)); throwIfStale(); } await clearPinUnlockStorage(); throwIfStale(); await stampLocalVaultMutation(); throwIfStale(); } // --------------------------------------------------------------------------- // Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive // a new key after password reset without re-importing the crypto module. // --------------------------------------------------------------------------- export { deriveKey, generateSalt }; export async function loadSlotA(): Promise { return loadSlot(SLOT_A); } /** * Recover the session root key by AES-GCM-decrypting the pw_root_* fields * (a second wrapping of the root key under the password-derived key). This * is what makes GET_RECOVERY_WORDS work across lock/unlock cycles without * the user re-entering their recovery phrase: the same root key bytes — * and therefore the same 12 words — survive every unlock. * * Returns null when the escrow is absent or decryption fails. */ async function deriveRootKeyFromDerivedKey( derivedKeyBytes: Uint8Array, slotA: StoredVault, ): Promise { if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) { return null; } try { const rootHex = await decrypt( derivedKeyBytes, slotA.pw_root_nonce, slotA.pw_root_ciphertext, slotA.pw_root_tag, "pw_root_v1", ); return unhex(rootHex); } catch { return null; } } // AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the // hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault. async function wrapRootKeyForDerivedKey( derivedKeyBytes: Uint8Array, rootKeyBytes: Uint8Array, ): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> { const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1"); return { pw_root_ciphertext: ciphertext, pw_root_nonce: nonce, pw_root_tag: tag, }; } /** Returns the 12 recovery words for the current session's root key. */ export async function getRecoveryWords(): Promise { if (!rootKey) { throw new Error("recovery not set up"); } return rootKeyToWords(rootKey); } async function loadSlot(key: string): Promise { const result = await chrome.storage.local.get(key); return (result as Record)[key] ?? null; } async function writeSlot(key: string, vault: StoredVault): Promise { try { await chrome.storage.local.set({ [key]: vault }); } catch (err) { throw mapStorageError(err); } } /** * Roll back a vault that was created by [createVaultFromRootKey] but * couldn't be paired (e.g. the subsequent license activation, identity * derivation, or paired-device upsert failed). Without this, a failed * receiver session leaves behind an empty vault — the user has to * re-pair to get a working setup, but the empty vault sits in the way. * * The call is idempotent: removing keys that aren't there is a no-op. */ export async function rollbackAdoptedVault(): Promise { await lock(); try { await chrome.storage.local.remove([ SLOT_A, SLOT_B, "peach_last_modified_at", ]); } catch (err) { throw mapStorageError(err); } } async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise { const raw = await chrome.storage.local.get("peach_last_modified_at"); const previous = Number( (raw as Record).peach_last_modified_at, ); const next = Math.max( Date.now(), Number.isFinite(previous) ? previous + 1 : 0, ); try { await chrome.storage.local.set({ [SLOT_A]: slotA, [SLOT_B]: slotB, peach_last_modified_at: String(next), }); } catch (err) { throw mapStorageError(err); } } // Monotonic clock for per-entry modifiedAt / deletedAt timestamps. Guards the // sync layer's last-write-wins merge against wall-clock regressions (NTP step, // manual clock change, DST glitch): a backwards Date.now() must never produce // an entry timestamp that's <= the entry's existing modifiedAt, or a genuine // local edit would lose to a stale remote copy. The returned value is always // strictly greater than both the entry's current modifiedAt and the previous // call's result (SEV-2-E). let lastEntryTimestamp = 0; function monotonicEntryTimestamp(currentModifiedAt?: number): number { const base = Math.max( Date.now(), (currentModifiedAt ?? 0) + 1, lastEntryTimestamp + 1, ); lastEntryTimestamp = base; return base; } async function stampLocalVaultMutation(): Promise { const raw = await chrome.storage.local.get("peach_last_modified_at"); const previous = Number( (raw as Record).peach_last_modified_at, ); const next = Math.max( Date.now(), Number.isFinite(previous) ? previous + 1 : 0, ); await chrome.storage.local.set({ peach_last_modified_at: String(next), }); } // --------------------------------------------------------------------------- // Padding + noise helpers // --------------------------------------------------------------------------- /** * Pads the plaintext string with trailing spaces so that the encoded byte * length is a multiple of PAD_BLOCK. JSON.parse tolerates trailing * whitespace after a complete document, so the padding is transparent on * decode. Ciphertext length equals plaintext length plus a 16-byte tag. */ function padPlaintext(json: string, minBytes = 0): string { const encoded = new TextEncoder().encode(json); const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK); // minBytes lets the duress path force a fixed ceiling so both slots match. const target = Math.max(bucket, minBytes); const padLen = target - encoded.length; return json + " ".repeat(padLen); } /** * Produces a StoredVault-shaped record filled with CSPRNG bytes. The * ciphertext length matches the given byte count. Because GCM ciphertext is * pseudo-random, this blob is indistinguishable at rest from a real * encrypted vault without the key. * * crypto.getRandomValues() caps each call at 65536 bytes (the Web Crypto * spec hard limit). Chunk the fill so vaults larger than that — common once * the user has dozens of entries or any passkey — don't throw. */ const CSPRNG_CHUNK = 65_536; function fillRandom(buf: Uint8Array): Uint8Array { for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) { const end = Math.min(offset + CSPRNG_CHUNK, buf.length); const chunk = new Uint8Array(end - offset); crypto.getRandomValues(chunk); buf.set(chunk, offset); } return buf; } function generateNoiseBlob(ciphertextBytes: number): StoredVault { const salt = crypto.getRandomValues(new Uint8Array(16)); const nonce = crypto.getRandomValues(new Uint8Array(12)); const cipher = fillRandom(new Uint8Array(ciphertextBytes)); const tag = crypto.getRandomValues(new Uint8Array(16)); return { version: 1, salt: hex(salt), nonce: hex(nonce), ciphertext: hex(cipher), tag: hex(tag), }; } // --------------------------------------------------------------------------- // Encrypted plaintext envelope // --------------------------------------------------------------------------- type VaultPayload = { entries: VaultEntry[]; tombstones?: Record; duressConfigured?: boolean; }; type DecryptedPayload = { entries: VaultEntry[]; tombstones: Map; duressConfigured: boolean; }; function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string { const envelope: VaultPayload = { entries, tombstones: Object.fromEntries(tombstones), duressConfigured: duress, }; return padPlaintext(JSON.stringify(envelope), minBytes); } function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map { const result = new Map(); if (raw && typeof raw === "object" && !Array.isArray(raw)) { for (const [id, timestamp] of Object.entries(raw as Record)) { if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) { result.set(id, timestamp); } } } // Older payloads had no ledger. Rebuild the durable portion from any // soft-deleted entries before they are eligible for a later purge. for (const entry of allEntries) { if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) { continue; } if ((result.get(entry.id) ?? 0) < entry.deletedAt) { result.set(entry.id, entry.deletedAt); } } return result; } function reconcileTombstones(): void { const byId = new Map(entries.map((entry) => [entry.id, entry])); for (const [id] of tombstones) { if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id); } for (const entry of entries) { if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) { if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) { tombstones.set(entry.id, entry.deletedAt); } } } } function decodePayload(plain: string): DecryptedPayload { const parsed = JSON.parse(plain) as VaultPayload; if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) { throw new Error("invalid vault payload"); } const normalized = parsed.entries.map(normalizeEntry); return { entries: normalized, tombstones: decodeTombstones(parsed.tombstones, normalized), duressConfigured: parsed.duressConfigured === true, }; } /** * Consolidate duplicate passkey records after registration. * * Multiple registrations for one RP can leave duplicate passkey records. This * pass collapses the duplicates: * * 1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`. * 2. Within each group, the most recently-active credential wins (prefer * lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt). * 3. If a NON-passkey login entry exists for that RP (matched by url or * additionalUrls hostname), attach the winning passkey to it and * soft-delete every passkey-bearing entry in the group. * 4. Otherwise, soft-delete every loser in the group, keeping the winner * as a passkey-only entry. * * Soft delete (deletedAt) rather than splice — the trash flow keeps the * record around for retention and the user can restore if the heuristic * picked wrong. The `merged` flag goes back to decodePayload so the * unlock path persists the consolidated shape immediately. */ function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } { let merged = false; const now = Date.now(); const candidates = entries.filter((e) => !e.deletedAt && e.passkey); const byRpId = new Map(); for (const e of candidates) { const rpId = e.passkey!.rpId; const list = byRpId.get(rpId); if (list) list.push(e); else byRpId.set(rpId, [e]); } for (const [rpId, group] of byRpId) { if (group.length === 0) continue; group.sort((a, b) => { const aT = a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt; const bT = b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt; return bT - aT; }); const winner = group[0]!; // Look for a NON-passkey login entry matching this RP. Use the URL // hostname rather than a substring search so a "login.foo.com" entry // doesn't accidentally absorb a passkey for "foo.com". const loginTarget = entries.find((e) => { if (e.deletedAt || e.passkey || e.type !== "login") return false; if (e.url && extractDomain(e.url) === rpId) return true; for (const extra of e.additionalUrls ?? []) { if (extractDomain(extra) === rpId) return true; } return false; }); if (loginTarget) { loginTarget.passkey = winner.passkey; // A passkey entry may also carry the site's TOTP seed. Preserve it when // the passkey is consolidated onto an existing login instead of // soft-deleting the only record that held the 2FA configuration. if (!loginTarget.totp && winner.totp) { loginTarget.totp = winner.totp; loginTarget.totpAlgorithm = winner.totpAlgorithm; loginTarget.totpDigits = winner.totpDigits; loginTarget.totpPeriod = winner.totpPeriod; } loginTarget.modifiedAt = now; for (const dup of group) { if (!dup.deletedAt) { dup.deletedAt = now; dup.modifiedAt = now; } } merged = true; } else if (group.length > 1) { for (let i = 1; i < group.length; i += 1) { const dup = group[i]!; if (!dup.deletedAt) { dup.deletedAt = now; dup.modifiedAt = now; } } merged = true; } } return { merged }; } // Validates that a `StoredVault` record on disk has the field shape we expect // before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only // authenticates the static VERSION_BYTE AAD, not the `version` field on the // outer record — a corrupted `version: 42` slot still decrypts cleanly. This // check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a // history-restore path instead of falsely reporting "wrong password". function hasValidVaultSchema(v: unknown): v is StoredVault { if (typeof v !== "object" || v === null) return false; const r = v as Record; return ( r.version === 1 && typeof r.salt === "string" && typeof r.nonce === "string" && typeof r.ciphertext === "string" && typeof r.tag === "string" ); } export type TryDecryptResult = | { status: "ok"; payload: DecryptedPayload } | { status: "wrong_key" } | { status: "corrupted"; reason: string }; // Exported solely for `unlockFromCodex` below, which needs to verify a // supplied passphrase against a slotA blob *without* committing anything to // storage. Keep this internal otherwise — callers crossing the messages.ts // boundary should always go through `unlock` / `verifyMainPassword` so the // constant-time invariants in Projects.md §3.4 are not bypassed. export async function tryDecryptSlot( vault: StoredVault, key: Uint8Array, aadTag?: string, ): Promise { const res = await tryDecryptSlotResult(vault, key, aadTag); return res.status === "ok" ? res.payload : null; } export async function tryDecryptSlotResult( vault: StoredVault, key: Uint8Array, aadTag?: string, ): Promise { if (!hasValidVaultSchema(vault)) { return { status: "corrupted", reason: "invalid_slot_schema" }; } let plain: string; try { plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag); } catch { return { status: "wrong_key" }; } try { const parsed = JSON.parse(plain) as unknown; if (typeof parsed !== "object" || parsed === null) { return { status: "corrupted", reason: "invalid_payload_shape" }; } if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) { return { status: "corrupted", reason: "invalid_payload_shape" }; } return { status: "ok", payload: decodePayload(plain) }; } catch { return { status: "corrupted", reason: "invalid_payload_json" }; } } // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- function buildDomainIndexSync(all: VaultEntry[]): void { domainIndex = new Map(); for (const entry of all) { if (entry.deletedAt) continue; if (entry.type === "note" || entry.type === "card") continue; // An entry can match multiple sites via `additionalUrls`. Index each // hostname so `queryDomain` returns the entry regardless of which alias // the user is currently visiting. Dedupe per-entry so a single entry // never appears twice in one bucket. const domains = new Set(); const primary = extractDomain(entry.url ?? ""); if (primary) domains.add(primary); for (const extra of entry.additionalUrls ?? []) { const d = extractDomain(extra); if (d) domains.add(d); } if (domains.size === 0) continue; for (const domain of domains) { const bucket = domainIndex.get(domain) ?? []; bucket.push(entry); domainIndex.set(domain, bucket); } } } export async function createVault(password: string): Promise { const salt = generateSalt(); derivedKey = await deriveKey(password, salt); entries = []; tombstones = new Map(); vaultMode = "real"; duressConfigured = false; buildDomainIndexSync(entries); // Generate and wrap the root key for recovery word escrow. const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } = await generateAndWrapRootKey(); rootKey = unhex(rawRootKey); const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0); const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1"); // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate // it without recovery words (H2/COR-006). const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey); const slotA: StoredVault = { version: 1, salt: hex(salt), nonce, ciphertext, tag, recovery_salt: recoverySalt, recovery_wrapped_root_key: wrappedRootKey, recovery_nonce: wrappedNonce, recovery_tag: wrappedTag, ...pwRoot, }; await writeSlot(SLOT_A, slotA); // Matching-size noise blob for slot B, so a fresh install shows two // similarly-shaped records regardless of whether duress is ever set up. await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2)); await stampLocalVaultMutation(); touchAutoLock(); } /** * Creates a local vault from an existing vault root key received through the * SAS-gated adoption protocol. The password is local to this device; it wraps * the adopted root key at rest and does not need to match the source device. */ export async function createVaultFromRootKey( password: string, rootKeyHex: string, ): Promise<{ vaultId: string }> { if (typeof password !== "string" || password.length === 0) { throw new Error("ADOPT_INVALID_PASSWORD"); } if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) { throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars"); } const rootKeyBytes = unhex(rootKeyHex); if (rootKeyBytes.length !== 32) { rootKeyBytes.fill(0); throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes"); } let nextDerivedKey: Uint8Array | null = null; let sessionRootKey: Uint8Array | null = null; try { if ((await loadSlot(SLOT_A)) !== null) { throw new Error("ADOPT_VAULT_ALREADY_EXISTS"); } const nextEntries: VaultEntry[] = []; const nextDuressConfigured = false; const vaultId = await computeVaultId(rootKeyBytes); const salt = generateSalt(); nextDerivedKey = await deriveKey(password, salt); const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes); sessionRootKey = new Uint8Array(rootKeyBytes); const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0); const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1"); const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey); const slotA: StoredVault = { version: 1, salt: hex(salt), nonce, ciphertext, tag, recovery_salt: recovery.recoverySalt, recovery_wrapped_root_key: recovery.wrappedRootKey, recovery_nonce: recovery.wrappedNonce, recovery_tag: recovery.wrappedTag, ...pwRoot, }; const slotB = generateNoiseBlob(ciphertext.length / 2); await commitCreatedVaultSlots(slotA, slotB); const previousDerivedKey = derivedKey; const previousRootKey = rootKey; derivedKey = nextDerivedKey; rootKey = sessionRootKey; nextDerivedKey = null; sessionRootKey = null; entries = nextEntries; tombstones = new Map(); vaultMode = "real"; duressConfigured = nextDuressConfigured; buildDomainIndexSync(entries); if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey); if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0); touchAutoLock(); return { vaultId }; } finally { rootKeyBytes.fill(0); if (nextDerivedKey) zeroKey(nextDerivedKey); if (sessionRootKey) sessionRootKey.fill(0); } } export async function hasVault(): Promise { return (await loadSlot(SLOT_A)) !== null; } /** * Returns whether the real vault has recorded that a duress passphrase is * configured. Reliable only after a real-mode unlock — before unlock or in * decoy mode, returns false because we cannot read slot A's plaintext. */ export async function isDuressEnabled(): Promise { return vaultMode === "real" && duressConfigured; } export async function setupDuress(duressPassword: string): Promise { if (!derivedKey || vaultMode !== "real") { throw new Error("vault must be unlocked in real mode"); } // Encrypt the decoy payload (empty entries) under the duress key. Pad to // at least the current slot A ciphertext size so the two slots look the // same at rest at setup time. const slotA = await loadSlot(SLOT_A); if (!slotA) throw new Error("missing slot A"); const decoySalt = generateSalt(); const decoyKey = await deriveKey(duressPassword, decoySalt); try { // Pad the decoy to the fixed duress ceiling. Slot A is re-encrypted to the // same ceiling by the persistSlotA() call below (duressConfigured is now // true), so both slots are exactly DURESS_SLOT_SIZE+tag at rest and their // lengths cannot reveal that duress is configured (audit MEDIUM). const plaintext = encodePayload([], false, DURESS_SLOT_SIZE); const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1"); await writeSlot(SLOT_B, { version: 1, salt: hex(decoySalt), nonce, ciphertext, tag, }); } finally { zeroKey(decoyKey); } // Re-encrypt slot A with the duressConfigured flag set to true. No external // flag is written — the bit lives inside the encrypted envelope. duressConfigured = true; await persistSlotA(); } export async function disableDuress(): Promise { if (!derivedKey || vaultMode !== "real") { throw new Error("vault must be unlocked in real mode"); } // Overwrite slot B with fresh noise that matches slot A's size. The slot // remains occupied so its mere existence does not signal that duress was // ever configured. const slotA = await loadSlot(SLOT_A); if (slotA) { await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2)); } duressConfigured = false; await persistSlotA(); } // L14/COR-015: this function intentionally checks slot A (real vault) only. // A duress-mode user attempting changePassword is rejected earlier by the // vaultMode === 'decoy' check at changePassword. The real-vs-decoy // distinction here is by design — duress users cannot rotate the real // password by guessing it, and the asymmetry in error timing is acceptable // because duress mode is opt-in and the user knows their own decoy key. export async function verifyMainPassword(password: string): Promise { const slotA = await loadSlot(SLOT_A); if (!slotA) return false; const key = await deriveKey(password, unhex(slotA.salt)); try { return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null; } finally { zeroKey(key); } } export async function unlock(password: string): Promise { return attemptUnlockWithDerive(password, deriveKey); } async function attemptUnlockWithDerive( password: string, derive: typeof deriveKey, ): Promise { const epoch = vaultEpoch; const stale = () => epoch !== vaultEpoch; // Load both current-format slots in parallel. Both exist on a supported // install; the // parallel load and the parallel derivation below keep wall-clock time // constant regardless of which slot's passphrase (real or decoy) the // caller supplied, so a timing side channel cannot distinguish the // duress path from the real-unlock path (DUR-002). const [slotA, slotB] = await Promise.all([ loadSlot(SLOT_A), loadSlot(SLOT_B), ]); // Derive both keys in parallel, unconditionally. const [keyA, keyB] = await Promise.all([ slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null), slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null), ]); // Attempt both decrypts in parallel. The Result variant lets us distinguish // 'wrong_key' from 'corrupted' so callers can offer a history-restore path // when a slot's schema is invalid even though AES-GCM authenticated (M7). const [resultA, resultB] = await Promise.all([ keyA && slotA ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1") : Promise.resolve({ status: "wrong_key" } as TryDecryptResult), keyB && slotB ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1") : Promise.resolve({ status: "wrong_key" } as TryDecryptResult), ]); const payloadA = resultA.status === "ok" ? resultA.payload : null; const payloadB = resultB.status === "ok" ? resultB.payload : null; if (stale()) { if (keyA) zeroKey(keyA); if (keyB) zeroKey(keyB); return false; } if (payloadA && keyA) { if (stale()) { zeroKey(keyA); if (keyB) zeroKey(keyB); return false; } derivedKey = keyA; vaultMode = "real"; entries = payloadA.entries; tombstones = payloadA.tombstones; duressConfigured = payloadA.duressConfigured; buildDomainIndexSync(entries); await loadSettings(); if (stale()) { await lock(); return false; } touchAutoLock(); if (keyB) zeroKey(keyB); // Current vaults always carry a password-wrapped root key. Older vaults // without this escrow are outside the supported storage format. rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null; if (!rootKey) { await lock(); return false; } // PB-26-213: adopt legacy plaintext pair secrets into the encrypted // record and refresh the trusted-session operational copy. await reconcilePairSecrets(rootKey); if (stale()) { await lock(); return false; } await lockedSyncUnlockHook?.().catch((error) => { console.warn("[Peach sync] locked inbox drain failed:", error); }); if (stale()) { await lock(); return false; } await resetPinFailedAttempts(); if (stale()) { await lock(); return false; } await saveSessionKey(keyA, "real", epoch); if (stale()) return false; return true; } if (payloadB && keyB) { if (stale()) { zeroKey(keyB); if (keyA) zeroKey(keyA); return false; } derivedKey = keyB; vaultMode = "decoy"; vaultMode = "decoy"; entries = payloadB.entries; tombstones = payloadB.tombstones; duressConfigured = false; buildDomainIndexSync(entries); await loadSettings(); touchAutoLock(); if (keyA) zeroKey(keyA); // PB-26-213: a duress session must never serve the real pair-secret map. await dropSessionCache(); if (stale()) { await lock(); return false; } await saveSessionKey(keyB, "decoy", epoch); if (stale()) return false; return true; } if (keyA) zeroKey(keyA); if (keyB) zeroKey(keyB); // Surface corruption (good AES tag, broken schema) distinctly from // wrong-password so the popup can offer history-restore (M7/COR-007). if (resultA.status === "corrupted") { lastUnlockFailureReason = resultA.reason; } else if (resultB.status === "corrupted") { lastUnlockFailureReason = resultB.reason; } return false; } // --------------------------------------------------------------------------- // MV3 service worker session key — survives SW restarts within a browser session // --------------------------------------------------------------------------- // chrome.storage.session is cleared on browser close; survives SW restart. // Firefox does not implement it (persistent background script, no restart risk). const SESSION_KEY_STORAGE = "peach_session_dk"; // Order writes and removals so lock's awaited removal is a barrier after // every escrow write that started before it. let sessionKeyMutation = Promise.resolve(); function mutateSessionKey(mutation: () => Promise): Promise { const task = sessionKeyMutation.then(mutation, mutation); sessionKeyMutation = task.catch(() => undefined); return task; } // PB-26-203: the session-key escrow may only operate where startup positively // established trusted-only session storage. Defaults to available so direct // vault imports (tests, non-entry bootstraps) keep working; entry.ts applies // the real hardening result before the first restore. let sessionEscrowAvailable = true; /** * Apply the storage-hardening result at startup. When the trusted-only * session state cannot be established, any stored session key is removed and * the escrow stays off: the user unlocks again after a worker restart * instead of trusting an unproven boundary. */ export async function applyStorageHardening(sessionEscrow: boolean): Promise { setSessionAllowed(sessionEscrow); if (sessionEscrow) return; sessionEscrowAvailable = false; await clearSessionKey(); } async function saveSessionKey( key: Uint8Array, mode: "real" | "decoy", expectedEpoch = vaultEpoch, ): Promise { const session = chrome.storage.session; if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return; const stored = { keyHex: hex(key), mode }; await mutateSessionKey(async () => { if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return; await session.set({ [SESSION_KEY_STORAGE]: stored }); }); } async function clearSessionKey(): Promise { const session = chrome.storage.session; if (!session) return; await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE)); } // A lock that only holds while the service worker stays alive is no lock at // all: a cold worker's restoreFromSession() would find the surviving session // key and silently re-unlock ~a minute after the user locked. So lock() // bumps this epoch synchronously at entry and zeroes keys/indexes before any // await; a restore that started earlier sees the mismatch after its next // await and aborts instead of publishing. let vaultEpoch = 0; // One in-flight restore shared by concurrent callers: storage is read once // and the locked-sync unlock hook runs once. let restoreInFlight: Promise | null = null; /** * On SW restart, re-derive all in-memory vault state from the session key * stored in chrome.storage.session (set at unlock time, cleared by lock(); * also cleared on browser close or when restoration fails). * Returns true if the vault was restored, false if no session key was found. * No-ops if the vault is already unlocked. */ export async function restoreFromSession(): Promise { if (derivedKey !== null) return true; if (restoreInFlight) return restoreInFlight; const task = restoreFromSessionInner(); restoreInFlight = task; try { return await task; } finally { restoreInFlight = null; } } async function restoreFromSessionInner(): Promise { const epoch = vaultEpoch; const stale = () => epoch !== vaultEpoch; if (derivedKey !== null) return true; if (!sessionEscrowAvailable) return false; if (!chrome.storage.session) return false; const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE); if (stale()) return false; const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined; if (!entry?.keyHex) return false; const key = unhex(entry.keyHex); const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B; const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1"; const slot = await loadSlot(slotKey); if (stale()) { zeroKey(key); return false; } if (!slot) { await clearSessionKey(); return false; } const result = await tryDecryptSlotResult(slot, key, aadTag); if (stale()) { zeroKey(key); return false; } if (result.status !== "ok") { await clearSessionKey(); return false; } // Decrypted payload stays in locals until the final epoch-checked commit // below; nothing observable is published before then. const payload = result.payload; await loadSettings(); if (stale()) { zeroKey(key); return false; } // Rehydrate the root key used by sync identity derivation from the password // escrow stored on the current vault format. let freshRoot: Uint8Array | null = null; if (entry.mode === "real") { freshRoot = await deriveRootKeyFromDerivedKey(key, slot); if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; } if (!freshRoot) { await lock(); return false; } } if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; } if (freshRoot) { // PB-26-213: encrypted pair-secret record + session copy, before publish. await reconcilePairSecrets(freshRoot); if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; } } else { // PB-26-213: a duress restore must never serve the real pair-secret map. await dropSessionCache(); if (stale()) { zeroKey(key); return false; } } derivedKey = key; vaultMode = entry.mode; entries = payload.entries; tombstones = payload.tombstones; duressConfigured = entry.mode === "real" ? payload.duressConfigured : false; if (freshRoot) rootKey = freshRoot; buildDomainIndexSync(entries); touchAutoLock(); await lockedSyncUnlockHook?.().catch((error) => { console.warn("[Peach sync] locked inbox drain failed:", error); }); if (stale()) return false; return true; } export async function lock(): Promise { // A user-initiated lock must be durable across MV3 service-worker restarts. // The SW is idle-killed after ~30s of inactivity, and the popup's next open // wakes a cold SW whose restoreFromSession() reads chrome.storage.session. // If the session key survived lock(), that restore would silently re-unlock // the vault ~a minute after the user locked it — a lock that only held // while the SW happened to stay alive. The background-sync convenience the // session key once bought (sync runner re-waking to a restored vault) is // not worth an unlocked vault the user never asked to unlock; when locked, // sync-runner correctly reports VAULT_LOCKED instead. vaultEpoch++; if (derivedKey) { zeroKey(derivedKey); derivedKey = null; } if (rootKey) { rootKey.fill(0); rootKey = null; } entries = []; tombstones = new Map(); domainIndex = new Map(); vaultMode = "real"; duressConfigured = false; clearAutoLock(); await clearSessionKey(); await clearRecoveryConfirmPending(); } export function isUnlocked(): boolean { return derivedKey !== null; } // Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The // PIN-unlock path unwraps this key from its PIN wrap; the passphrase path // always derives from scratch via Argon2id. export async function unlockWithKey( masterKeyHex: string, ): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> { const epoch = vaultEpoch; const stale = () => epoch !== vaultEpoch; let masterKeyBytes: Uint8Array | null = null; try { masterKeyBytes = unhex(masterKeyHex); const stored = await loadSlot(SLOT_A); if (!stored) return { ok: false, error: "invalid_key" }; const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1"); if (!payload || stale()) { zeroKey(masterKeyBytes); return { ok: false, error: "invalid_key" }; } derivedKey = masterKeyBytes; vaultMode = "real"; entries = payload.entries; tombstones = payload.tombstones; duressConfigured = payload.duressConfigured; buildDomainIndexSync(entries); await loadSettings(); touchAutoLock(); rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored); if (!rootKey) { await lock(); return { ok: false, error: "invalid_key" }; } await reconcilePairSecrets(rootKey); if (stale()) { await lock(); return { ok: false, error: "invalid_key" }; } await saveSessionKey(masterKeyBytes, "real", epoch); if (stale()) return { ok: false, error: "invalid_key" }; return { ok: true, entries }; } catch { if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes); return { ok: false, error: "invalid_key" }; } } // --- Adoption duress-slot helpers (passthrough, no decrypt) --- /** * Adoption SOURCE helper: returns the sealed slot B as a JSON string when * duress is configured and slot B exists, so the inline snapshot can ship it * to the receiver. Returns null otherwise. Never decrypts. */ export async function exportDuressSlotForAdoption(): Promise { if (!(await isDuressEnabled())) return null; const slotB = await loadSlot(SLOT_B); if (!slotB) return null; return JSON.stringify(slotB); } /** * Adoption RECEIVER helper: installs a duress slot received from a source * device. Validates the StoredVault shape (same guard as unlockFromCodex) * and writes it to slot B. Throws on invalid shape so the caller can treat * it as a non-fatal snapshot failure. Never decrypts. */ export async function installAdoptedDuressSlot(json: string): Promise { let parsed: StoredVault; try { const raw = JSON.parse(json) as unknown; if (!hasValidVaultSchema(raw)) { throw new Error("invalid_duress_slot_shape"); } parsed = raw; } catch (err) { if (err instanceof SyntaxError) { throw new Error("invalid_duress_slot_shape"); } throw err; } await writeSlot(SLOT_B, parsed); } /** * Restore the vault from a parsed Peach Codex payload. * * The supplied passphrase is verified against the Codex's slotA *before* any * storage mutation — wrong passphrases return without touching disk. On * success, the current vault state is snapshotted (reason: "restore") so the * user can revert via Settings -> Vault History, then slotA/slotB are written * and `peach_last_modified_at` is set to the Codex's recorded timestamp. A * regular `unlock()` follows so the rest of the SW sees the standard * post-unlock state. * * Intentionally non-Pro-gated — see peach_codex_v2.md "Pro gating". A Codex * printed by a Pro user must always be restorable, even on a free device. * * Timing is not held constant here (in contrast to `unlock`): the Codex flow * is an explicit user action with a known passphrase, not a passive lock * screen, so the duress-vs-real timing side channel does not apply. */ export async function unlockFromCodex(params: { slotA: string; // JSON string of StoredVault for slot A slotB: string | null; // JSON string of StoredVault for slot B, or null timestamp: number; masterPassphrase: string; }): Promise< | { ok: true; entryCount: number } | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" } > { let parsedSlotA: StoredVault; let parsedSlotB: StoredVault | null = null; try { const rawA = JSON.parse(params.slotA) as unknown; if (!hasValidVaultSchema(rawA)) { return { ok: false, reason: "invalid_slot_format" }; } parsedSlotA = rawA; if (params.slotB !== null) { const rawB = JSON.parse(params.slotB) as unknown; if (!hasValidVaultSchema(rawB)) { return { ok: false, reason: "invalid_slot_format" }; } parsedSlotB = rawB; } } catch { return { ok: false, reason: "invalid_slot_format" }; } // Verify the passphrase against the supplied slotA WITHOUT writing anything. // If this fails the on-disk vault is left completely untouched. const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt)); const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1"); if (!trialPayload) { zeroKey(trialKey); return { ok: false, reason: "wrong_passphrase" }; } // Drop the trial key; `unlock()` below derives its own copy. Holding two // copies of the master key in memory at once would be a needless surface. zeroKey(trialKey); // Snapshot the about-to-be-overwritten on-disk state so the user can revert // a mistaken Codex restore via the Vault History UI. Non-fatal if it fails. await takeVaultSnapshot("restore"); await writeSlot(SLOT_A, parsedSlotA); if (parsedSlotB) { await writeSlot(SLOT_B, parsedSlotB); } else { // Slot B must always exist with ciphertext length matching slot A // (Projects.md §3.2). Codex prints may omit slot B (duress wasn't // configured); regenerate noise to satisfy the invariant. await writeSlot( SLOT_B, generateNoiseBlob(parsedSlotA.ciphertext.length / 2), ); } await chrome.storage.local.set({ peach_last_modified_at: String(params.timestamp), }); // Standard unlock path so domain index, settings, auto-lock, and // recovery-key state all populate exactly as a normal unlock would. const ok = await unlock(params.masterPassphrase); if (!ok) { // Should be unreachable — we just verified the passphrase against the // same slotA we wrote. Treat as wrong_passphrase to keep the contract // simple if some race or storage anomaly intrudes. return { ok: false, reason: "wrong_passphrase" }; } return { ok: true, entryCount: getEntries().length }; } // Returns the current in-memory derived key as a hex string. Only the PIN // unlock path needs this value, and it reads module state directly; this // accessor exists so unlockWithKey's root-key rehydration is testable. export /* @testable */ function getDerivedKeyHex(): string { if (!derivedKey) throw new Error("vault_not_unlocked"); return hex(derivedKey); } /** * Returns the vault's stable root key as hex, or null when locked / not yet * populated. The root key is random per-vault, generated at creation, escrowed * under the recovery words, and re-wrapped (not regenerated) on every password * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex. * * This is the correct input for sync identity derivation (vault_id, the Noise * NK static key, the Ed25519 manifest signing key, and per-entry envelope * keys): paired devices keep the same identity and can still decrypt each * other's entries after either side rotates its master password (SEV-2-B). * * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED * instead of crashing; rootKey is populated on every supported real-mode * unlock via deriveRootKeyFromDerivedKey. */ export function getRootKeyHex(): string | null { return rootKey ? hex(rootKey) : null; } export function getEntries(): VaultEntry[] { if (!derivedKey) throw new Error("vault_locked"); return entries.filter((entry) => !entry.deletedAt); } const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function getIdentities(): SavedIdentity[] { if (!derivedKey) throw new Error("vault_locked"); const active = entries.filter((e) => !e.deletedAt && e.type === "login"); const emailMap = new Map(); const usernameMap = new Map(); for (const entry of active) { const domain = extractDomain(entry.url ?? "") ?? ""; const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0; function tally(map: Map, v: string) { const existing = map.get(v); if (!existing || modifiedAt > existing.lastUsed) { map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain }); } else { map.set(v, { ...existing, count: existing.count + 1 }); } } const uVal = entry.username?.trim(); if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal); // Also count the dedicated email field — entries created via the inline // popup new-entry form store email here rather than in username. const eVal = entry.email?.trim(); if (eVal) tally(emailMap, eVal); } const toIdentity = (type: "email" | "username") => ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({ value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined, }); return [ ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount), ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount), ]; } export async function batchAddEntries(drafts: EntryDraft[]): Promise { if (!derivedKey) throw new Error("vault_locked"); const now = Date.now(); const added: VaultEntry[] = []; for (const draft of drafts) { try { const sanitized = sanitizeEntryDraft(draft); const nextEntry: VaultEntry = { ...sanitized, id: crypto.randomUUID(), createdAt: now, modifiedAt: monotonicEntryTimestamp(), }; entries.push(nextEntry); added.push(nextEntry); } catch { // Skip invalid drafts silently — caller can see diff between input and output length } } if (added.length > 0) { await persistEntries(); } return added; } /** * Restore a set of entries in full fidelity — preserves id, createdAt, * modifiedAt, and every other field unlike `batchAddEntries` which * assigns fresh identity. Used by restore paths where the backup is the * authoritative source of truth. `mode` controls how entries already in * the vault are treated. */ export async function restoreEntries( incoming: VaultEntry[], mode: "replace" | "merge" ): Promise<{ added: number; updated: number; skipped: number }> { if (!derivedKey) throw new Error("vault_locked"); // A replace with zero incoming entries is almost certainly a caller bug // (e.g. an import that silently dropped everything). Refuse rather than // wipe the vault. if (mode === "replace" && incoming.length === 0) { throw new Error("refuse to replace with empty entry set"); } let added = 0; let updated = 0; let skipped = 0; if (mode === "replace") { entries = []; for (const entry of incoming) { const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry)); if (!normalized) { skipped += 1; continue; } entries.push(normalized); if (normalized.deletedAt) { tombstones.set( normalized.id, Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt), ); } else { tombstones.delete(normalized.id); } added += 1; } } else { const byId = new Map(); for (const e of entries) byId.set(e.id, e); for (const entry of incoming) { const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry)); if (!normalized) { skipped += 1; continue; } const existing = byId.get(normalized.id); if (!existing) { entries.push(normalized); if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt); else tombstones.delete(normalized.id); added += 1; } else if (normalized.modifiedAt > existing.modifiedAt) { const idx = entries.findIndex((e) => e.id === existing.id); if (idx !== -1) { entries[idx] = normalized; if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt); else tombstones.delete(normalized.id); updated += 1; } } else { skipped += 1; } } } await persistEntries("import"); return { added, updated, skipped }; } /** * A Peach entry id is a UUID. A restored or imported file carries whatever id * its author used, and no reader bounds it, so one oversized id would ride * into every future sync frame and be rejected there — a permanent, opaque * failure on both platforms, caused by data that was already local. * * The action is mint, not reject: this is the last point where an id is still * ours to choose, so the worst outcome is one duplicate if the same vault is * later restored beside a device still holding the old id. Rejecting would * lose the user's import. * * Deliberately NOT inside sanitizeRestoredEntry, which applyMergedRemoteEntries * also calls. Sync has to adopt a peer's id verbatim — that is the only thing * keeping two devices agreed on which entry they are discussing — so reminting * there would fork identity on every device, on every round. */ const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/; function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null { if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry; return { ...entry, id: crypto.randomUUID() }; } /** * Validate + normalise a VaultEntry coming from a backup, import, or sync * peer. MV-6: this is a strict field WHITELIST — `normalizeEntry` copies * only known VaultEntry fields (unknown/attacker keys are dropped), and * nested `passkey`/`canary` shapes are rebuilt field-by-field, never * spread. Returns null for values the live vault shouldn't accept. */ function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null { if (!entry || typeof entry !== "object") return null; const title = typeof entry.title === "string" ? entry.title.trim() : ""; if (!title) return null; const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID(); const now = Date.now(); const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now; const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt; // normalizeEntry whitelists every known VaultEntry field before the entry // reaches the live vault. const normalized = normalizeEntry({ ...entry, id, title, type: entry.type ?? "login", createdAt, modifiedAt, }); if (normalized.passkey) { normalized.passkey = sanitizePasskey(normalized.passkey); } if (normalized.canary) { normalized.canary = sanitizeCanary(normalized.canary); } // PCH-01-016: bound tags on the restore path too, so both import funnels agree. normalized.tags = sanitizeTags(normalized.tags); // Login entries from backup must carry a password (or be passkey-only, // in which case the passkey sub-object replaces the password requirement). if ( normalized.type === "login" && !normalized.passkey && typeof normalized.password !== "string" ) { return null; } if (entry.passkey && !normalized.passkey) { // Entry claimed a passkey but the shape didn't validate — refuse the // whole entry rather than silently importing a credential-less husk. return null; } return normalized; } /** * Rebuild a PasskeyData object from untrusted input, field by field. * Returns undefined when required fields are missing/mistyped — a rogue * passkey credential must not activate via import (MV-6 attack scenario). */ function sanitizePasskey(pk: unknown): PasskeyData | undefined { if (!pk || typeof pk !== "object") return undefined; const r = pk as Record; if ( typeof r.rpId !== "string" || !r.rpId || typeof r.credentialId !== "string" || !r.credentialId || typeof r.publicKeyCose !== "string" || typeof r.userHandle !== "string" || typeof r.userName !== "string" ) { return undefined; } return { rpId: r.rpId, rpName: typeof r.rpName === "string" ? r.rpName : r.rpId, credentialId: r.credentialId, publicKeyCose: r.publicKeyCose, privateKeyJwk: r.privateKeyJwk && typeof r.privateKeyJwk === "object" ? (r.privateKeyJwk as JsonWebKey) : undefined, userHandle: r.userHandle, userName: r.userName, userDisplayName: typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName, algorithm: r.algorithm === -8 ? -8 : -7, signCount: typeof r.signCount === "number" ? r.signCount : 0, aaguid: typeof r.aaguid === "string" ? r.aaguid : "", backupEligible: r.backupEligible === true, backupState: r.backupState === true, lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined, browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined, transports: Array.isArray(r.transports) ? r.transports.filter((t): t is string => typeof t === "string") : undefined, createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(), }; } /** * Rebuild a Canary object from untrusted input. The webhook must be a * well-formed HTTPS URL — a poisoned canary.webhookUrl is a standing * exfiltration endpoint that the canary subsystem will POST entry titles * to (MV-6 attack scenario). Invalid → the canary is dropped, not the * entry. FIRE_CANARY_PING re-validates with the full SSRF guard at fire * time (isAllowedCanaryWebhookUrl), so this import-time check is the * outer of two gates. */ function sanitizeCanary(c: unknown): Canary | undefined { if (!c || typeof c !== "object") return undefined; const r = c as Record; if (typeof r.webhookUrl !== "string") return undefined; let url: URL; try { url = new URL(r.webhookUrl); } catch { return undefined; } if (url.protocol !== "https:") return undefined; return { webhookUrl: url.toString(), label: typeof r.label === "string" ? r.label : undefined, lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined, pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined, }; } /** * MV-6 / PCH-01-016: tags from imported / batch-added entries are untrusted. * Keep only non-empty strings, cap each at 256 chars, and cap the count at 255 * (the same per-entry ceiling the export codec enforces, peachPayload.ts). * Non-array or all-invalid input collapses to undefined. */ function sanitizeTags(tags: unknown): string[] | undefined { if (!Array.isArray(tags)) return undefined; const out: string[] = []; for (const t of tags) { if (typeof t !== "string") continue; const trimmed = t.trim(); if (!trimmed) continue; out.push(trimmed.slice(0, 256)); if (out.length >= 255) break; } return out.length > 0 ? out : undefined; } // Soft-deletes every non-deleted entry in one call. The literal string // argument is a tripwire against accidental calls — any caller that would // wipe the vault has to consciously type "REPLACE_ALL". Do not relax this // to a boolean. export async function clearVaultEntries( confirmation: "REPLACE_ALL" ): Promise { if (confirmation !== "REPLACE_ALL") { throw new Error("clearVaultEntries requires explicit confirmation"); } if (!derivedKey) throw new Error("vault_locked"); for (const entry of entries) { if (!entry.deletedAt) { const stamp = monotonicEntryTimestamp(Math.max( entry.modifiedAt, tombstones.get(entry.id) ?? 0, )); entry.deletedAt = stamp; entry.modifiedAt = stamp; tombstones.set(entry.id, stamp); } } await persistEntries(); } export async function addEntry(entry: EntryDraft): Promise { if (!derivedKey) throw new Error("vault_locked"); const sanitized = sanitizeEntryDraft(entry); const now = Date.now(); const stamp = monotonicEntryTimestamp(); const nextEntry: VaultEntry = { ...sanitized, id: crypto.randomUUID(), createdAt: now, modifiedAt: stamp, // Stamp passwordChangedAt at creation so age tracking is accurate from // day one. Non-login entries carry the same stamp harmlessly. passwordChangedAt: sanitized.password ? stamp : undefined, }; // Clone-and-replace so a failed persist rolls back the in-memory state // (COR-008). Without this, a quota-full or storage error leaves the // module-level `entries` ahead of what's actually on disk. const rollback = entries; entries = [...entries, nextEntry]; try { await persistEntries(); } catch (err) { entries = rollback; throw err; } // Fire-and-forget breach check (only for login entries if breach monitoring is enabled or not set) if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) { checkBreach(nextEntry.password).then(async (count) => { if (count > 0) { const idx = entries.findIndex((e) => e.id === nextEntry.id); if (idx !== -1) { entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) }; await persistEntries(); } } }); } fireSyncTrigger("vault_change"); return nextEntry; } /** * Run the passkey-merge pass on the live in-memory entries and persist if * anything changed. Called immediately after any passkey registration so that * a same-domain TOTP-only entry already in the vault gets consolidated right * away instead of waiting until the next lock/unlock cycle. */ export async function consolidatePasskeyEntries(): Promise { if (!derivedKey) return; const { merged } = mergePasskeyOnlyIntoLogins(entries); if (merged) { await persistEntries(); } } export async function updateEntry( id: string, patch: Partial ): Promise { if (!derivedKey) throw new Error("vault_locked"); const idx = entries.findIndex((entry) => entry.id === id); if (idx === -1 || entries[idx].deletedAt) { throw new Error("entry_not_found"); } const current = entries[idx]; const sanitizedPatch = sanitizeEntryPatch(patch); // Refresh passwordChangedAt only when the password value actually changes — // a metadata-only update (title, tags, etc.) must not reset the age clock // that drives the Pro health rotation recommendation. const passwordChanged = "password" in patch && typeof sanitizedPatch.password === "string" && sanitizedPatch.password !== current.password; const stamp = monotonicEntryTimestamp(current.modifiedAt); // When the password actually changes, push the outgoing one onto the history // (newest first) so the user can reveal/copy/restore it later. We record the // age it carried (passwordChangedAt, falling back to createdAt) rather than // `now`, so the list reflects when each password was last in effect. Only // capture a non-empty prior value — clearing a password leaves no secret to // keep, and we skip an immediate exact duplicate (e.g. restore-then-restore) // to avoid noise. const priorPassword = current.password; const shouldCapture = passwordChanged && typeof priorPassword === "string" && priorPassword.length > 0 && current.passwordHistory?.[0]?.password !== priorPassword; const nextHistory = shouldCapture ? [ { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt }, ...(current.passwordHistory ?? []), ].slice(0, PASSWORD_HISTORY_MAX) : current.passwordHistory; entries[idx] = { ...current, ...sanitizedPatch, modifiedAt: stamp, ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}), }; // Card entries must retain their card number if (entries[idx].type === "card" && !entries[idx].cardNumber) { entries[idx] = current; throw new Error("card number is required for card entries"); } // Roll the in-memory entry back to its pre-edit state if the disk write // fails (e.g. storage quota). Without this the module-level `entries` array // would hold an edit that never landed on disk, diverging from the vault // until the next unlock silently reverts it (SEV-3-A). Mirrors addEntry. try { await persistEntries(); } catch (err) { entries[idx] = current; throw err; } // Breach check when password changes: clear immediately so old breach doesn't linger, // then async-check the new password and only re-set if it's also found. if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") { entries[idx] = { ...entries[idx], breach: undefined }; await persistEntries(); const password = entries[idx].password; if (password) { checkBreach(password).then(async (count) => { const i = entries.findIndex((e) => e.id === id); if (i !== -1 && count > 0) { entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) }; await persistEntries(); } }); } } fireSyncTrigger("vault_change"); } export async function deleteEntry(id: string): Promise { if (!derivedKey) throw new Error("vault_locked"); const entry = entries.find((item) => item.id === id); if (!entry || entry.deletedAt) { throw new Error("entry_not_found"); } // Tombstone timestamp must be strictly greater than the entry's last // modification so the deletion wins LWW against any in-flight edit, even // under clock skew (SEV-2-E). const previousModifiedAt = entry.modifiedAt; const previousTombstone = tombstones.get(id); const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0)); entry.deletedAt = stamp; entry.modifiedAt = stamp; tombstones.set(id, stamp); // Roll the tombstone back if the disk write fails, so a quota error doesn't // leave the entry deleted in memory but alive on disk (SEV-3-A). try { await persistEntries(); } catch (err) { delete entry.deletedAt; entry.modifiedAt = previousModifiedAt; if (previousTombstone === undefined) tombstones.delete(id); else tombstones.set(id, previousTombstone); throw err; } fireSyncTrigger("vault_change"); } export function getDeletedEntries(): VaultEntry[] { if (!derivedKey) return []; return entries.filter((e) => !!e.deletedAt); } /** Return all durable deletion timestamps, including purged full entries. */ export function getTombstoneManifest(): Record { if (!derivedKey) return {}; return Object.fromEntries(tombstones); } /** * Returns the vault's monotonic version — the `peach_last_modified_at` value * stamped on every user-initiated write. Remote (sync) writes do NOT advance * this version so a sync round never cascades into a second outbound sync. */ export async function getVaultVersion(): Promise { try { const raw = await chrome.storage.local.get("peach_last_modified_at"); const v = (raw as Record).peach_last_modified_at; if (typeof v === "number") return v; if (typeof v === "string" && v !== "") { const n = Number.parseInt(v, 10); return Number.isFinite(n) ? n : 0; } return 0; } catch { return 0; } } /** * Apply a batch of remote entries + tombstones from a sync peer. * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and * persists with reason `sync_received` — does NOT fire the sync dispatcher, so * applying a sync round cannot cascade into another outbound sync. * * `applied` counts entries that actually changed local state (new add, * overwrite-by-newer, or new tombstone). `received` is the raw payload size * the caller can surface in the sync result UI. */ export async function applyMergedRemoteEntries( remoteEntries: VaultEntry[], remoteTombstones: Record, _context: { deviceName?: string } = {}, ): Promise<{ received: number; applied: number; conflicts: number }> { // DESIGN: last-write-wins is applied PER-ENTRY, not per-field. If two devices // concurrently edit different fields of the same entry, the one with the // newer modifiedAt wins the whole entry and the other device's field edits // are dropped silently. This is the deliberate trade-off for a simple, // conflict-free merge — per-field CRDT merge is out of scope. Guidance for // users: edit a given entry on one device at a time. if (!derivedKey) throw new Error("vault_locked"); const received = remoteEntries.length + Object.keys(remoteTombstones).length; let applied = 0; let conflicts = 0; const byId = new Map(); for (const e of entries) byId.set(e.id, e); const mergeNow = Date.now(); const remoteTombstoneTimes = new Map(); for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) { if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) { remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow)); } } for (const remote of remoteEntries) { const sanitized = sanitizeRestoredEntry(remote); if (!sanitized) continue; // HV-1a: a far-future remote modifiedAt must not permanently pin a // malicious value against honest local edits. Clamp BEFORE any // comparison against local timestamps. sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow); if (typeof sanitized.deletedAt === "number") { sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow); } const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id); const localTombstoneTs = tombstones.get(sanitized.id); const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0); if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) { continue; } const local = byId.get(sanitized.id); if (!local) { entries.push(sanitized); byId.set(sanitized.id, sanitized); tombstones.delete(sanitized.id); applied += 1; } else if (sanitized.modifiedAt > local.modifiedAt) { const idx = entries.findIndex((e) => e.id === local.id); if (idx !== -1) { entries[idx] = sanitized; byId.set(sanitized.id, sanitized); tombstones.delete(sanitized.id); applied += 1; if (!local.deletedAt) conflicts += 1; } } } for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) { if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue; // HV-1a/1c: clamp the tombstone timestamp before every comparison. const deletedAt = remoteTombstoneTimes.get(id)!; const knownTombstone = tombstones.get(id); if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue; const local = byId.get(id); if (!local) { tombstones.set(id, deletedAt); applied += 1; continue; } if (local.deletedAt && local.deletedAt >= deletedAt) continue; if (deletedAt < local.modifiedAt) continue; const idx = entries.findIndex((e) => e.id === id); if (idx !== -1) { entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt }; tombstones.set(id, deletedAt); applied += 1; } } if (applied > 0) { await persistEntries("sync_received"); buildDomainIndexSync(entries); } return { received, applied, conflicts }; } export async function restoreEntry(id: string): Promise { if (!derivedKey) throw new Error("vault_locked"); const entry = entries.find((e) => e.id === id && e.deletedAt); if (!entry) throw new Error("deleted entry not found"); const previousDeletedAt = entry.deletedAt; const previousModifiedAt = entry.modifiedAt; const previousTombstone = tombstones.get(id); delete entry.deletedAt; entry.modifiedAt = monotonicEntryTimestamp(Math.max( entry.modifiedAt, previousTombstone ?? 0, )); tombstones.delete(id); try { await persistEntries(); } catch (err) { entry.deletedAt = previousDeletedAt; entry.modifiedAt = previousModifiedAt; if (previousTombstone !== undefined) tombstones.set(id, previousTombstone); throw err; } buildDomainIndexSync(entries); } export async function purgeEntry(id: string): Promise { if (!derivedKey) throw new Error("vault_locked"); const idx = entries.findIndex((e) => e.id === id && e.deletedAt); if (idx === -1) throw new Error("deleted entry not found"); const entry = entries[idx]!; tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!)); entries.splice(idx, 1); await persistEntries(); } /** Permanently delete entries whose deletedAt is older than retentionMs. 0 = keep forever. */ export async function purgeExpiredTrash(retentionMs: number): Promise { if (!derivedKey || retentionMs === 0) return 0; const cutoff = Date.now() - retentionMs; const before = entries.length; for (const entry of entries) { if (entry.deletedAt && entry.deletedAt < cutoff) { tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt)); } } entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff)); const purged = before - entries.length; if (purged > 0) await persistEntries(); return purged; } export function queryDomain(domain: string): VaultEntry[] { return domainIndex.get(domain) ?? []; } export function getEntryById(id: string): VaultEntry | undefined { return entries.find((entry) => entry.id === id && !entry.deletedAt); } async function persistSlotA( reason: VaultSnapshot["reason"] = "local_save", ): Promise { if (!derivedKey) throw new Error("vault_locked"); const slotA = await loadSlot(SLOT_A); if (!slotA) throw new Error("missing slot A"); // Snapshot the about-to-be-overwritten state BEFORE we mutate disk. Failures // are swallowed by takeVaultSnapshot — history must never block a save. await takeVaultSnapshot(reason); const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0); const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1"); // Preserve existing recovery escrow fields. const nextSlotA: StoredVault = { ...slotA, nonce, ciphertext, tag, // recovery_salt and recovery_wrapped_root_key are already set from loadSlot }; await writeSlot(SLOT_A, nextSlotA); // Slot A (the real vault) is now committed. Wrap every later write so a // mid-persist failure (e.g. a storage error after slot A landed) restores the // prior slot A before rethrowing. Without this, a "failed" save that already // wrote slot A leaves disk ahead of the in-memory rollback in addEntry/ // updateEntry/deleteEntry — the entry stays silently on disk and resurfaces // as a duplicate on the next unlock (observed under storage-quota pressure). try { // When duress is not configured, keep slot B size-matched with slot A by // regenerating its noise. When duress IS configured, leave slot B alone — // we don't have the decoy key to re-encrypt it. This is a known trade-off. if (!duressConfigured) { await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2)); } // Only advance the vault version for locally-originated writes. Entries // applied from a sync peer ("sync_received") must NOT bump the counter — // otherwise applying a remote round makes us look newer than the peer and // triggers a redundant outbound sync, defeating the no-op optimisation // and risking a ping-pong between two devices (SEV-2-C). if (reason !== "sync_received") { await stampLocalVaultMutation(); } } catch (err) { // Best-effort restore — the prior slot A is smaller-or-equal, so this write // fits even when the failure was a quota overflow. await writeSlot(SLOT_A, slotA).catch(() => {}); throw err; } return nextSlotA; } async function persistSlotB(): Promise { if (!derivedKey) throw new Error("vault_locked"); const slotB = await loadSlot(SLOT_B); if (!slotB) throw new Error("missing slot B"); // Decoy plaintext always reports duressConfigured=false — the decoy user // sees a clean slate. persistSlotB only runs in decoy mode, which only // exists once duress is configured, so pad to the fixed duress ceiling to // keep slot B's length equal to slot A's (audit MEDIUM — deniability). const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE); const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1"); await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag }); // Symmetric with persistSlotA: if the version stamp fails after the decoy // slot committed, restore the prior slot B so a failed decoy-mode save can't // leave disk ahead of the in-memory rollback. try { await stampLocalVaultMutation(); } catch (err) { await writeSlot(SLOT_B, slotB).catch(() => {}); throw err; } } async function persistEntries( reason: VaultSnapshot["reason"] = "local_save", ): Promise { if (!derivedKey) throw new Error("vault_locked"); // Reconcile direct soft-delete callers while restoring the previous map if // storage rejects the save. const previousTombstones = tombstones; tombstones = new Map(tombstones); reconcileTombstones(); try { if (vaultMode === "decoy") { await persistSlotB(); } else { await persistSlotA(reason); } } catch (err) { tombstones = previousTombstones; throw err; } buildDomainIndexSync(entries); await refreshLockedSyncState(); } // PB-26-011: drop URLs whose parsed scheme is not http(s) at the storage // boundary. Schemeless values (e.g. "github.com") are inert and kept — they // pre-date this guard in existing data. Unparseable values are kept: the // reviewed render paths never execute them (favicons via _favicon/, domains // via tldts); this is defense-in-depth against future sinks. function allowHttpUrl(url: string | undefined): string { const trimmed = url?.trim() ?? ""; if (!trimmed) return ""; try { const parsed = new URL(trimmed); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return ""; } catch { // No scheme to reject. } return trimmed; } function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined { if (!Array.isArray(values)) return undefined; const seen = new Set(); const cleaned: string[] = []; for (const value of values) { const trimmed = typeof value === "string" ? value.trim() : ""; if (!trimmed) continue; if (!allowHttpUrl(trimmed)) continue; const key = trimmed.toLowerCase(); if (seen.has(key)) continue; seen.add(key); cleaned.push(trimmed); } return cleaned.length > 0 ? cleaned : undefined; } function sanitizeEntryDraft(entry: EntryDraft): EntryDraft { const title = entry.title.trim(); const type: VaultEntry["type"] = entry.type ?? "login"; if (!title) throw new Error("title is required"); if (entry.passkey) { const p = entry.passkey; if ( !p.rpId || !p.credentialId || !p.publicKeyCose || !p.userHandle || !p.userName || (p.algorithm !== -7 && p.algorithm !== -8) ) { throw new Error("invalid passkey credential"); } } if (type === "card") { if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) { throw new Error("cardholder name and card number are required"); } } return { type, title, username: type === "login" ? entry.username?.trim() ?? "" : undefined, email: type === "login" ? entry.email?.trim() || undefined : undefined, password: type === "login" ? entry.password : undefined, url: type === "login" ? allowHttpUrl(entry.url) : undefined, additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined, totp: type === "login" ? entry.totp?.trim() || undefined : undefined, // PCH-01-019: preserve non-default TOTP params through batch-add/import so // they are not silently stripped (they fall back to SHA-1/6/30 when absent). totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined, totpDigits: type === "login" ? entry.totpDigits : undefined, totpPeriod: type === "login" ? entry.totpPeriod : undefined, notes: entry.notes?.trim() || undefined, breach: type === "login" ? entry.breach : undefined, // PCH-01-016: the batch-add path was bypassing the MV-6 canary/tags // whitelist applied on the RESTORE_ENTRIES path. Run both sanitizers here. canary: type === "login" ? sanitizeCanary(entry.canary) : undefined, tags: sanitizeTags(entry.tags), passkey: entry.passkey, cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined, cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined, expMonth: type === "card" ? entry.expMonth?.trim() : undefined, expYear: type === "card" ? entry.expYear?.trim() : undefined, cvc: type === "card" ? entry.cvc?.trim() : undefined, cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined, network: type === "card" ? entry.network : undefined, last4: type === "card" ? entry.last4?.trim() : undefined, }; } function sanitizeEntryPatch(patch: Partial): Partial { const nextPatch: Partial = {}; if ("type" in patch) { nextPatch.type = patch.type; } if ("title" in patch) { const title = patch.title?.trim() ?? ""; if (!title) throw new Error("title is required"); nextPatch.title = title; } if ("url" in patch) { nextPatch.url = allowHttpUrl(patch.url); } if ("additionalUrls" in patch) { const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : []; const seen = new Set(); const cleaned: string[] = []; for (const value of incoming) { const trimmed = typeof value === "string" ? value.trim() : ""; if (!trimmed || !allowHttpUrl(trimmed)) continue; const key = trimmed.toLowerCase(); if (seen.has(key)) continue; seen.add(key); cleaned.push(trimmed); } nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined; } if ("username" in patch) { nextPatch.username = patch.username?.trim() ?? ""; } if ("email" in patch) { nextPatch.email = patch.email?.trim() || undefined; } if ("password" in patch) { const password = patch.password ?? ""; // Allow clearing a password (e.g. on passkey-only entries) nextPatch.password = password || undefined; } if ("totp" in patch) { nextPatch.totp = patch.totp?.trim() || undefined; } if ("notes" in patch) { nextPatch.notes = patch.notes?.trim() || undefined; } if ("breach" in patch) { nextPatch.breach = patch.breach; } if ("canary" in patch) { // PCH-02-001: route update patches through the same canary whitelist used // by add/restore/sync. Invalid shapes drop the canary; `undefined` clears it. nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined; } if ("passkey" in patch) { // A patch with `passkey: undefined` removes the credential from the entry // (used by "remove passkey" without deleting the whole entry). if (patch.passkey === undefined || patch.passkey === null) { nextPatch.passkey = undefined; } else { const sanitized = sanitizePasskey(patch.passkey); if (!sanitized) throw new Error("invalid passkey credential"); nextPatch.passkey = sanitized; } } if ("tags" in patch) { // PCH-02-001: bound tags exactly like both import funnels. nextPatch.tags = sanitizeTags(patch.tags); } if ("cardholderName" in patch) { nextPatch.cardholderName = patch.cardholderName?.trim() || undefined; } if ("cardNumber" in patch) { const n = patch.cardNumber ?? ""; nextPatch.cardNumber = n || undefined; } if ("expMonth" in patch) { nextPatch.expMonth = patch.expMonth?.trim() || undefined; } if ("expYear" in patch) { nextPatch.expYear = patch.expYear?.trim() || undefined; } if ("cvc" in patch) { nextPatch.cvc = patch.cvc?.trim() || undefined; } if ("cardNickname" in patch) { nextPatch.cardNickname = patch.cardNickname?.trim() || undefined; } if ("network" in patch) { nextPatch.network = patch.network; } if ("last4" in patch) { nextPatch.last4 = patch.last4?.trim() || undefined; } return nextPatch; } function normalizeEntry(entry: VaultEntry): VaultEntry { const base: VaultEntry = { id: entry.id, type: entry.type, title: entry.title, username: entry.username, email: entry.email, password: entry.password, url: entry.url, additionalUrls: "additionalUrls" in entry && Array.isArray(entry.additionalUrls) ? entry.additionalUrls : undefined, totp: entry.totp, // PCH-01-019: carry TOTP params through the normalize/restore path too. totpAlgorithm: entry.totpAlgorithm, totpDigits: entry.totpDigits, totpPeriod: entry.totpPeriod, notes: entry.notes, breach: entry.breach, // Preserve the password-age clock across lock/unlock. Without this the // field is dropped on every decode and the advanced-health "aged password" // signal silently resets to createdAt on each unlock (SEV-2-A). passwordChangedAt: "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined, // Preserve past-password history across lock/unlock. normalizeEntry is a // field whitelist, so an unlisted field is silently dropped on every decode // (same class as the passwordChangedAt SEV-2-A above). passwordHistory: "passwordHistory" in entry && Array.isArray(entry.passwordHistory) ? entry.passwordHistory : undefined, canary: entry.canary, passkey: entry.passkey, createdAt: entry.createdAt, modifiedAt: entry.modifiedAt, deletedAt: entry.deletedAt, tags: entry.tags, cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined, cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined, expMonth: "expMonth" in entry ? entry.expMonth : undefined, expYear: "expYear" in entry ? entry.expYear : undefined, cvc: "cvc" in entry ? entry.cvc : undefined, cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined, network: "network" in entry ? entry.network : undefined, last4: "last4" in entry ? entry.last4 : undefined, }; return base; } // --------------------------------------------------------------------------- // Deduplication // --------------------------------------------------------------------------- function entryDupeKey(e: VaultEntry): string { if (e.type === "note") return `note|${e.title.toLowerCase()}`; // Passkey-bearing entries dedupe by the credential's RP + user. Two logins // on different sites that happen to share a username are still distinct; // two passkey credentials for the same RP+user are duplicates regardless // of any login fields they may also carry. if (e.passkey) { return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`; } return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`; } /** Returns the number of duplicate entries (those that would be removed). */ export function countDuplicates(): number { const best = new Map(); let dupeCount = 0; for (const e of entries.filter((e) => !e.deletedAt)) { const key = entryDupeKey(e); const existing = best.get(key); if (!existing) { best.set(key, e); } else { dupeCount++; // Keep the more recently modified one in the map if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) { best.set(key, e); } } } return dupeCount; } /** * Soft-deletes duplicate entries, keeping the most recently modified copy of * each group. Returns the number of entries moved to trash. */ export async function deduplicateEntries(): Promise { if (!derivedKey) throw new Error("vault_locked"); // Dedup reshapes slot B permanently under the decoy key, which is // forensically distinguishable from a freshly-seeded decoy vault. Refuse. if (vaultMode === "decoy") { throw new Error("deduplicate disabled in decoy mode"); } const best = new Map(); for (const e of entries.filter((en) => !en.deletedAt)) { const key = entryDupeKey(e); const existing = best.get(key); if (!existing) { best.set(key, e); } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) { best.set(key, e); } } const keepIds = new Set(Array.from(best.values()).map((e) => e.id)); let removed = 0; entries = entries.map((e) => { if (!e.deletedAt && !keepIds.has(e.id)) { removed++; const deletedAt = monotonicEntryTimestamp(Math.max( e.modifiedAt, tombstones.get(e.id) ?? 0, )); tombstones.set(e.id, deletedAt); return { ...e, deletedAt }; } return e; }); if (removed > 0) await persistEntries(); return removed; } // --------------------------------------------------------------------------- // Vault history — snapshot BEFORE every vault write so the saved blob is // what is about to be lost, not what was just written. Capped at 5. The // stored blobs are AES-256-GCM ciphertext under the master password, so // history adds zero security surface. // --------------------------------------------------------------------------- const HISTORY_KEY = "peach_vault_history"; const HISTORY_MAX = 5; // PCH-01-023: coarsen a live-entry count into a low-resolution bucket so the // plaintext snapshot field cannot reveal exact vault size or single-entry change // deltas. Buckets widen as the vault grows so absolute leakage stays roughly // proportional rather than exact. function bucketEntryCount(n: number): number { if (n <= 0) return 0; if (n <= 10) return 10; if (n <= 25) return 25; if (n <= 50) return 50; if (n <= 100) return 100; if (n <= 250) return 250; if (n <= 500) return 500; return Math.ceil(n / 500) * 500; } /** * Snapshot the CURRENT on-disk vault state into history. Call this BEFORE * overwriting peach_vault_a so the saved blob captures the about-to-be-lost * state. Identical-to-most-recent blobs are skipped to avoid filling history * with no-op writes. */ export async function takeVaultSnapshot( reason: VaultSnapshot["reason"], ): Promise { try { const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]); const slotA = (raw as Record)[SLOT_A]; const slotB = (raw as Record)[SLOT_B]; // Nothing to snapshot if the real slot doesn't exist yet (fresh install, // pre-createVault). createVault skips snapshots intentionally — there is // no prior state to preserve. if (!slotA) return; const vault_a = JSON.stringify(slotA); const vault_b = JSON.stringify(slotB ?? slotA); // Best-effort entry count: only meaningful when the in-memory state is // unlocked AND in real mode. Decoy mode would leak the decoy entry // count; locked mode has no entries to count. Both fall through to null. // PCH-01-023: store a coarse bucket, not the exact count — chrome.storage // .local sits next to the AES-GCM blobs in plaintext, so an exact count would // leak vault size and per-write deltas (the metadata padding hides this). let entryCount: number | null = null; if (derivedKey && vaultMode === "real") { try { entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length); } catch { entryCount = null; } } const existingRaw = (raw as Record)[HISTORY_KEY]; const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots) ? existingRaw : { snapshots: [] }; // Deduplication: if the most recent snapshot has byte-identical blobs, // skip — saves the same vault N times in a row would otherwise burn the // entire history window with no recovery value. const latest = history.snapshots[0]; if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) { return; } const snapshot: VaultSnapshot = { id: crypto.randomUUID(), savedAt: Date.now(), reason, vault_a, vault_b, entryCount, }; history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX); await chrome.storage.local.set({ [HISTORY_KEY]: history }); } catch (err) { // Non-fatal — history must never block a save. A failed snapshot just // means the user loses one rung of the safety ladder for this write. // M4/QUA-006: log so the failure is visible; if it looks like a quota // problem, notify the popup so the user can prune history themselves. console.warn('[Peach] takeVaultSnapshot failed:', err); if (String(err).includes('QUOTA') || String(err).includes('quota')) { try { await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' }); } catch { // popup may be closed; surfaced via console.warn above } } } } /** Wipe all vault history. Called by Panic / DESTROY_VAULT. */ export async function clearVaultHistory(): Promise { try { await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory }); } catch { // Non-fatal — DESTROY_VAULT also runs storage.local.clear(). } } /** Read all snapshots, newest first, for the Settings vault history view. */ export async function getVaultHistory(): Promise { const raw = await chrome.storage.local.get(HISTORY_KEY); const history = (raw as Record)[HISTORY_KEY]; if (!history || !Array.isArray(history.snapshots)) return []; return history.snapshots; } /** * Replace the on-disk vault with the chosen snapshot's blobs and lock the * vault. The current state is itself snapshotted with reason 'restore' first * so the restore is reversible. After this returns, the user must unlock * with the password that matches the restored blob. */ export async function restoreVaultSnapshot( snapshotId: string, ): Promise<{ ok: true } | { ok: false; error: string }> { const history = await getVaultHistory(); const snapshot = history.find((s) => s.id === snapshotId); if (!snapshot) return { ok: false, error: "snapshot_not_found" }; // Snapshot the about-to-be-replaced current state so the restore is // reversible. takeVaultSnapshot is no-op when the most recent entry is // byte-identical, so a restore-of-a-restore stays clean. await takeVaultSnapshot("restore"); let restoredA: StoredVault; let restoredB: StoredVault; try { restoredA = JSON.parse(snapshot.vault_a) as StoredVault; restoredB = JSON.parse(snapshot.vault_b) as StoredVault; } catch { return { ok: false, error: "snapshot_corrupt" }; } await writeSlot(SLOT_A, restoredA); await writeSlot(SLOT_B, restoredB); await stampLocalVaultMutation(); // Lock so the user must prove they can decrypt the restored blob. If they // restored a pre-password-change snapshot they discover it immediately at // the lock screen rather than silently holding an inaccessible vault. await lock(); return { ok: true }; } /** * Rotate the vault's sync identity by generating a FRESH ROOT KEY and * re-wrapping everything under it (MV-8). Because every sync credential — * vault_id, the Noise static keypair, the Ed25519 manifest signing key, * and the per-entry envelope keys — derives from the root key, rotation * makes a lost/stolen device's retained key material useless. All paired * devices must re-pair afterwards. * * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from * the root key, so rotation INVALIDATES the old recovery phrase. The flow * sets peach_recovery_pending so the popup re-presents the new words for * confirmation, same as the initial recovery-word confirmation path. */ export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> { if (!derivedKey || vaultMode !== "real") { return { ok: false, error: "vault_locked" }; } const slotA = await loadSlot(SLOT_A); if (!slotA) return { ok: false, error: "missing_slot_a" }; // 1. Fresh root key + recovery escrow (new words). const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } = await generateAndWrapRootKey(); // 2. Re-wrap the new root key under the current password-derived key. const newRootKey = unhex(rawRootKey); const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey); // 3. Snapshot, then single atomic slot write — both escrow wrappings of // the new root key land together (same transactional rule as // changePassword). Entries are untouched: they are sealed under the // password-derived key, not the root key. await takeVaultSnapshot("local_save"); const nextSlotA: StoredVault = { ...slotA, recovery_salt: recoverySalt, recovery_wrapped_root_key: wrappedRootKey, recovery_nonce: wrappedNonce, recovery_tag: wrappedTag, ...pwRoot, }; await writeSlot(SLOT_A, nextSlotA); // 4. Swap in memory only after disk committed. if (rootKey) zeroKey(rootKey); rootKey = newRootKey; // 5. Old peers' identities derive from the dead root key — drop them all, // with their rendezvous secrets (PB-26-213: rotation clears the map, so // there is nothing to rewrap under the new key). await clearPairedDevices(); await clearPairSecrets(); // 6. Force re-presentation of the NEW recovery words on next popup view. await setRecoveryConfirmPending(); // same flag the create-vault flow uses await stampLocalVaultMutation(); return { ok: true }; } /** * Change the master password. Re-encrypts slot A with a fresh salt + new * derived key. Slot B (duress or noise) is left untouched when duress is * configured — it uses an independent key derived from the duress password — * and regenerated as noise when duress is not configured. */ export async function changePassword( currentPassword: string, newPassword: string ): Promise { const epoch = vaultEpoch; const stale = () => epoch !== vaultEpoch; if (!derivedKey || vaultMode !== "real") return false; // Verify current password before allowing any change const valid = await verifyMainPassword(currentPassword); if (!valid) return false; const slotA = await loadSlot(SLOT_A); if (!slotA) return false; if (!rootKey) return false; // Derive fresh key from new password + new random salt const newSalt = generateSalt(); const newKey = await deriveKey(newPassword, newSalt); if (stale()) { zeroKey(newKey); return false; } // Re-encrypt the in-memory entries with the new key const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0); const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1"); // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey // under recovery words and remain untouched — recovery phrase keeps working // across password changes (H4/COR-002). const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey); const finalSlotA: StoredVault = { ...slotA, salt: hex(newSalt), nonce, ciphertext, tag, ...pwRoot, }; // Snapshot the OLD-key blob before overwriting it. If the user later // restores from this snapshot they will need the OLD password — the // post-restore lock surfaces that immediately. await takeVaultSnapshot("local_save"); // Single atomic write: ciphertext, salt, and pw_root_* fields all land in // one storage transaction. An SW kill before this point leaves the old // slot intact; after this point everything tied to the new password is // already on disk together (M6/COR-004). await writeSlot(SLOT_A, finalSlotA); if (stale()) { zeroKey(newKey); return false; } // Swap the in-memory key synchronously before any further await. If we // threw between the Slot A write and this swap, the next persistEntries() // would overwrite Slot A with old-key ciphertext, locking the user out. zeroKey(derivedKey); derivedKey = newKey; // PCH-01-008: the MV3 session escrow still holds the OLD key. Without this, // the next service-worker restore decrypts the new-key Slot A with the stale // key, fails tryDecryptSlotResult, and silently drops the user to a lock // screen. Re-escrow the new key so restore survives a password change. if (stale()) { await lock(); return false; } await saveSessionKey(newKey, "real", epoch); if (stale()) return false; // Regenerate noise for slot B when duress is not in use. Runs after the // key swap so a failure here is a disposable noise regeneration, not a // lockout. if (!duressConfigured) { await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2)); } await clearPinUnlockStorage(); await stampLocalVaultMutation(); return true; }