// Local password vault + deterministic password derivation. // // The vault is a single encrypted file at /passwords.vault. Its // header (KDF params + salt + IV) is plaintext; contents are opaque // AES-256-GCM ciphertext. Metadata about which sites the user has passwords // for is INSIDE the ciphertext — a stolen laptop reveals only "this user // enabled the password manager", nothing else. // // Two entry shapes coexist in one vault: // { id, domain, username, generated: { version, rules } } — password is // re-derived from the seed on demand via derivePassword(); nothing // else is stored, so the same seed on any device produces the same // password. // { id, domain, username, literal } — a pasted / // legacy password, encrypted with the vault key (which is already // protecting the wrapping ciphertext, so `literal` is stored plain // inside the plaintext JSON, not double-encrypted). // // See ../../TheseusNavigator/DESIGN-password-manager.md for the threat // model and the "why" behind every choice. import { webcrypto as wc } from "node:crypto"; import { readFileSync, writeFileSync, existsSync, renameSync } from "node:fs"; const enc = new TextEncoder(); const dec = new TextDecoder(); const PBKDF2_ITERS = 200_000; const VAULT_VERSION = 1; // Purpose byte in the HKDF `info` string. Keep in lockstep with the design // doc — changing this value invalidates every derived password. const PW_INFO_PREFIX = "silentmode-passwords-v1"; // -- byte helpers ------------------------------------------------------------- export function bytesToHex(b) { return Array.from(b).map((x) => x.toString(16).padStart(2, "0")).join(""); } export function hexToBytes(h) { const b = new Uint8Array(h.length / 2); for (let i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); return b; } // -- key derivation ----------------------------------------------------------- async function importPbkdf2Key(masterPw) { return wc.subtle.importKey("raw", enc.encode(masterPw), "PBKDF2", false, ["deriveKey"]); } async function deriveVaultKey(masterPw, salt, iters) { const km = await importPbkdf2Key(masterPw); return wc.subtle.deriveKey( { name: "PBKDF2", salt, iterations: iters, hash: "SHA-256" }, km, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); } // BIP39 mnemonic → 64-byte seed. Standard: PBKDF2-SHA512(mnemonic, "mnemonic" // + passphrase, 2048). No wordlist validation — a user pasting garbage gets // a valid-shaped but unrecoverable seed, which is their problem. export async function bip39ToSeed(mnemonic, passphrase = "") { const km = await wc.subtle.importKey("raw", enc.encode(String(mnemonic).normalize("NFKD").trim()), "PBKDF2", false, ["deriveBits"]); const salt = enc.encode(("mnemonic" + String(passphrase || "")).normalize("NFKD")); return new Uint8Array(await wc.subtle.deriveBits( { name: "PBKDF2", salt, iterations: 2048, hash: "SHA-512" }, km, 512)); } // Derive the password-purpose root key (32 bytes) from a 64-byte BIP39 seed. // Hardened-derivation-style separation: HKDF with a purpose-specific info // keeps this subtree opaque to any other purpose derived from the same seed. export async function seedToPurposeRoot(seedBytes, purpose = "passwords/0") { const key = await wc.subtle.importKey("raw", seedBytes, "HKDF", false, ["deriveBits"]); const info = enc.encode(`silentmode/${purpose}`); return new Uint8Array(await wc.subtle.deriveBits( { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256)); } // -- deterministic password derivation --------------------------------------- const CHARSET_LOW = "abcdefghijkmnopqrstuvwxyz"; // no `l` const CHARSET_UPP = "ABCDEFGHIJKLMNPQRSTUVWXYZ"; // no `O` — visually clashes with 0 const CHARSET_DIG = "23456789"; // no 0/1 — visually clash const CHARSET_SYM = "!@#$%^&*()-_=+[]{},.?"; const DEFAULT_RULES = Object.freeze({ length: 20, upper: true, lower: true, digits: true, symbols: true }); export async function derivePassword(purposeRootHex, { domain, username = "", version = 1, rules = {} } = {}) { const r = { ...DEFAULT_RULES, ...rules }; if (!domain) throw new Error("derivePassword: domain required"); const key = await wc.subtle.importKey("raw", hexToBytes(purposeRootHex), "HKDF", false, ["deriveBits"]); const info = enc.encode(`${PW_INFO_PREFIX}|${String(domain).toLowerCase()}|${String(username).toLowerCase()}|v${version}`); // 64 bytes gives enough headroom for a length-32 password + class-anchor bytes. const bits = new Uint8Array(await wc.subtle.deriveBits( { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 512)); return mapBytesToPassword(bits, r); } function mapBytesToPassword(bytes, rules) { const classes = []; if (rules.lower) classes.push(CHARSET_LOW); if (rules.upper) classes.push(CHARSET_UPP); if (rules.digits) classes.push(CHARSET_DIG); if (rules.symbols) classes.push(CHARSET_SYM); if (!classes.length) classes.push(CHARSET_LOW); const len = Math.max(4, Math.min(64, rules.length || 20)); // Anchor: the first `classes.length` chars each come from one class in // order, guaranteeing at least one of each enabled class. Fill the rest // by picking from ALL classes' concatenation. const out = []; let i = 0; for (const cls of classes) { if (i >= len) break; out.push(cls[bytes[i] % cls.length]); i++; } const all = classes.join(""); while (i < len) { out.push(all[bytes[i] % all.length]); i++; } return out.join(""); } // -- vault lifecycle ---------------------------------------------------------- export function vaultExists(path) { return existsSync(path); } function writeVaultFile(path, { salt, iv, ct, iters }) { const raw = { version: VAULT_VERSION, kdf: { name: "PBKDF2", iters, salt: bytesToHex(salt) }, iv: bytesToHex(iv), ciphertext: bytesToHex(ct), }; writeFileSync(path + ".tmp", JSON.stringify(raw), "utf8"); renameSync(path + ".tmp", path); } // Extra plaintext fields are additive: legacy vaults omit `messengerRoot` and // keep working; new vaults created from a mnemonic pre-compute it so Hermes // can pick it up without re-entering the mnemonic. Never persist the seed // itself — only the per-purpose HKDF root, so a leak of one purpose (e.g. a // stolen password derivation) never yields another (e.g. the messaging key). export async function createVault(path, masterPw, purposeRootHex, opts = {}) { const salt = wc.getRandomValues(new Uint8Array(16)); const iv = wc.getRandomValues(new Uint8Array(12)); const key = await deriveVaultKey(masterPw, salt, PBKDF2_ITERS); const plaintext = { purposeRoot: purposeRootHex, entries: [] }; if (opts.messengerRootHex) plaintext.messengerRoot = opts.messengerRootHex; const ct = new Uint8Array(await wc.subtle.encrypt( { name: "AES-GCM", iv }, key, enc.encode(JSON.stringify(plaintext)))); writeVaultFile(path, { salt, iv, ct, iters: PBKDF2_ITERS }); return { key, purposeRoot: purposeRootHex, messengerRoot: opts.messengerRootHex || null, entries: [], _salt: salt, _iters: PBKDF2_ITERS, }; } export async function unlockVault(path, masterPw) { const raw = JSON.parse(readFileSync(path, "utf8")); if (raw.version !== VAULT_VERSION) throw new Error(`vault version ${raw.version} not supported`); const salt = hexToBytes(raw.kdf.salt); const iv = hexToBytes(raw.iv); const ct = hexToBytes(raw.ciphertext); const key = await deriveVaultKey(masterPw, salt, raw.kdf.iters); let pt; try { pt = new Uint8Array(await wc.subtle.decrypt({ name: "AES-GCM", iv }, key, ct)); } catch { throw new Error("bad master password"); } const plaintext = JSON.parse(dec.decode(pt)); return { key, purposeRoot: plaintext.purposeRoot, messengerRoot: plaintext.messengerRoot || null, entries: plaintext.entries || [], _salt: salt, _iters: raw.kdf.iters, }; } export async function saveVault(path, state) { const iv = wc.getRandomValues(new Uint8Array(12)); const plaintext = { purposeRoot: state.purposeRoot, entries: state.entries }; if (state.messengerRoot) plaintext.messengerRoot = state.messengerRoot; const ct = new Uint8Array(await wc.subtle.encrypt( { name: "AES-GCM", iv }, state.key, enc.encode(JSON.stringify(plaintext)))); writeVaultFile(path, { salt: state._salt, iv, ct, iters: state._iters }); } // Retroactively bind an existing (unlocked) vault to a mnemonic by adding the // messenger root. Useful for users who set up the vault before Hermes existed. // Callers must have vetted the mnemonic (typically by re-deriving the vault's // existing purposeRoot from it and comparing) so we don't silently accept a // mismatched seed. Returns the new state; caller saves. export async function bindMessengerRoot(state, messengerRootHex) { return { ...state, messengerRoot: String(messengerRootHex) }; } // -- entry mutations (operate on an unlocked state object) -------------------- export function newEntry({ domain, username = "", literal, generated } = {}) { if (!domain) throw new Error("entry: domain required"); const id = wc.getRandomValues(new Uint8Array(16)); const hex = bytesToHex(id); // UUIDv4-like formatting (not RFC-strict — we don't need cross-tool interop). const idStr = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; const base = { id: idStr, domain: String(domain).toLowerCase(), username: String(username), addedAt: dateStamp() }; if (literal) return { ...base, literal: String(literal) }; return { ...base, generated: generated || { version: 1, rules: { ...DEFAULT_RULES } } }; } // Date without Date.now() — this file may be imported into contexts where // Date is unavailable. Callers supply the timestamp OR we ISO-8601 it via // the runtime env's default toISOString. function dateStamp() { try { return new Date().toISOString(); } catch { return ""; } } // Retrieve the password plaintext for an entry (looks up in the vault OR // re-derives it from purposeRoot for `generated` entries). export async function resolvePassword(state, id) { const entry = (state.entries || []).find((e) => e.id === id); if (!entry) throw new Error("no such entry"); if (entry.literal) return entry.literal; const g = entry.generated || {}; return derivePassword(state.purposeRoot, { domain: entry.domain, username: entry.username, version: g.version || 1, rules: g.rules || {}, }); } // Convenience metadata list — never leaks passwords, safe for renderers. export function listMetadata(state) { return (state.entries || []).map((e) => ({ id: e.id, domain: e.domain, username: e.username, addedAt: e.addedAt, kind: e.literal ? "literal" : "generated", confirm: !!e.confirm, // ask for the PIN / master password before filling or showing it })); } // -- wallet imports ---------------------------------------------------------- // Externally-generated wallet material (imported seeds + BIP44 paths, or raw // WIFs) lives in its OWN encrypted file, `wallet-imports.enc`, distinct from // the primary passwords vault. See DESIGN-wallet-multi-account-amendment.md. // // Same PBKDF2/AES-GCM envelope as the passwords vault, same master password, // deliberately DIFFERENT KDF salt so the two AES keys are cryptographically // disjoint — a leak of one key never yields the other. Blast radius: a bug // that corrupts wallet-imports.enc cannot destroy the primary seed and vice // versa. // // Derivation rule (design §3): imports are used DIRECTLY — // `HDKey.fromMasterSeed(seed).derive(path)` OR the raw WIF's private key. // No HKDF steps. The `path` field is the disambiguator: when present, honour // it verbatim so the same seed + path produces the same address across // Electron Cash, mainnet-js, Deviant's keystore, and Theseus. const IMPORTS_VERSION = "silentmode/wallet-imports/1"; function writeImportsFile(path, { salt, iv, ct, iters }) { const raw = { version: VAULT_VERSION, kdf: { name: "PBKDF2", iters, salt: bytesToHex(salt) }, iv: bytesToHex(iv), ciphertext: bytesToHex(ct), }; writeFileSync(path + ".tmp", JSON.stringify(raw), "utf8"); renameSync(path + ".tmp", path); } export function importsExist(path) { return existsSync(path); } // Create an empty imports file. Called the first time the user adds an // external wallet. Uses a fresh salt so its AES key is unrelated to the // passwords vault's. export async function createImports(path, masterPw) { const salt = wc.getRandomValues(new Uint8Array(16)); const iv = wc.getRandomValues(new Uint8Array(12)); const key = await deriveVaultKey(masterPw, salt, PBKDF2_ITERS); const plaintext = { version: IMPORTS_VERSION, generated: dateStamp(), accounts: {} }; const ct = new Uint8Array(await wc.subtle.encrypt( { name: "AES-GCM", iv }, key, enc.encode(JSON.stringify(plaintext)))); writeImportsFile(path, { salt, iv, ct, iters: PBKDF2_ITERS }); return { key, accounts: {}, _salt: salt, _iters: PBKDF2_ITERS }; } // Unlock the imports file with the same master password used for the primary // vault. Salt is the imports file's own — the key differs from vaultState.key. export async function unlockImports(path, masterPw) { const raw = JSON.parse(readFileSync(path, "utf8")); if (raw.version !== VAULT_VERSION) throw new Error(`imports version ${raw.version} not supported`); const salt = hexToBytes(raw.kdf.salt); const iv = hexToBytes(raw.iv); const ct = hexToBytes(raw.ciphertext); const key = await deriveVaultKey(masterPw, salt, raw.kdf.iters); let pt; try { pt = new Uint8Array(await wc.subtle.decrypt({ name: "AES-GCM", iv }, key, ct)); } catch { throw new Error("bad master password"); } const plaintext = JSON.parse(dec.decode(pt)); return { key, accounts: plaintext.accounts || {}, _salt: salt, _iters: raw.kdf.iters, }; } export async function saveImports(path, state) { const iv = wc.getRandomValues(new Uint8Array(12)); const plaintext = { version: IMPORTS_VERSION, generated: dateStamp(), accounts: state.accounts || {} }; const ct = new Uint8Array(await wc.subtle.encrypt( { name: "AES-GCM", iv }, state.key, enc.encode(JSON.stringify(plaintext)))); writeImportsFile(path, { salt: state._salt, iv, ct, iters: state._iters }); } // Public metadata for the imports UI. Never returns seeds/WIFs. export function listImportsMetadata(state) { return Object.entries(state.accounts || {}).map(([id, a]) => ({ id, kind: a.kind, cashaddr: a.cashaddr, label: a.label, category: a.category || "", source: a.source || "", path: a.path || "", createdAt: a.createdAt || 0, })); } // Signer material for one imported wallet. Callers hold this in RAM for the // duration of a single signing operation and drop it. Only ever exposed to // add-ons that already have vault-derive-equivalent trust (i.e. the wallet // add-on that consumes imports). export function getImportSigner(state, id) { const a = (state.accounts || {})[id]; if (!a) throw new Error("no such import"); if (a.kind === "seed") return { kind: "seed", seed: a.seed, path: a.path }; if (a.kind === "wif") return { kind: "wif", wif: a.wif }; throw new Error(`unknown import kind: ${a.kind}`); }