argus/src/lib/password-vault.js
Local Dev 03b52727bf Vault: PIN setup steps, 6-8 digit PINs, save and offer logins, keep sign-ins
The PIN could only be six digits and was set from three bare inputs; the
unlock prompt sat at the top of the page; and the password manager only
filled when you found the key chip, never offered to save, and "Clear
cookies on quit" signed you out of every site, including the ones whose
login the vault already holds.

- PINs are 6 to 8 digits. The PIN record stores its length so pads draw the
  right number of dots and submit on the last digit; a PIN of the wrong
  length is refused without a strike, so an older Aegis pad cannot burn the
  count against an 8-digit PIN.
- Settings sets a PIN in steps: master password, choose the PIN on a pad
  (6/7/8), repeat it, done. The locked vault opens Theseus's own prompt,
  which is now centred, with the PIN pad or the master password field.
- After a sign-in or sign-up form is sent and the page moves on, Theseus
  offers to save (or update) the login, with an optional "ask for my PIN or
  password before filling it". Focusing a login form offers the saved
  logins under it; on a locked vault it offers to unlock first. A failed
  login (the password field still showing) gets no offer.
- "Keep sign-ins for sites in your vault" (on): the quit clear spares the
  cookies and site storage of sites with a saved login. Their hostnames are
  kept sealed with the OS keystore so the list is readable at quit while
  the vault is locked. Verified end to end on a scratch profile: signed in,
  restarted, still signed in; another site's cookie was cleared.
2026-10-04 20:23:43 +02:00

322 lines
15 KiB
JavaScript

// 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",
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}`);
}