argus/src/lib/password-vault.js

322 lines
15 KiB
JavaScript
Raw Normal View History

// Local password vault + deterministic password derivation.
//
// The vault is a single encrypted file at <userData>/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",
}));
}
feat(theseus/aegis): 0.6.1 — in-panel vault setup/unlock, BCH wallet imports, opt-in fiat prices, WizardConnect Aegis Wallet 0.4.4 → 0.6.1: - Vault lifecycle from the wallet gate. The locked / not-yet-created states now show a master-password form (with optional BIP39 mnemonic on setup) instead of redirecting users to Settings › Passwords. New api.vault.lifecycle {status, setup, unlock, lock} in addons-host, gated by the existing "vault-derive" capability. api.openSettings(section) also added; settings.html honours a #section hash on open. - Imported BCH wallets (design M.1a, read-only). Paste a mnemonic + BIP44 path or a WIF; the cashaddr is derived in the add-on, the signer material goes to a separate wallet-imports.enc via api.vault.imports {list, add, remove, signer}. Argus password-vault gains createImports / unlockImports / saveImports with its own KDF salt so the imports key is disjoint from the passwords key. lib/chain-bch-imported.js is a single-address Electrum adapter; spend support is deferred to M.1b. - Opt-in USD prices via CoinGecko (lib/prices.js), off by default, persisted in add-on storage. Fiat lines under balances, in the wallet picker, and a portfolio total when 2+ wallets are open. Settings tab is now reachable while the vault is locked so the toggle is always available. - WizardConnect wallet-side pairing for BCH wallets (lib/wc.js, lib/wc-sign.js). @wizardconnect/{core,wallet} are loaded dynamically via api.import to stay on the right side of LGPL §4d. Sign requests go through approvalModal and are restricted to P2PKH inputs with SIGHASH_ALL|FORKID|UTXOS. - DGB adapter load is now soft-fail: when Aegis runs from userData/addons the bundled ESM can't resolve peer deps, so DGB becomes unavailable instead of taking the whole add-on down.
2026-09-09 10:33:21 +02:00
// -- 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}`);
}