argus/src/lib/password-vault.js

196 lines
9.5 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);
}
export async function createVault(path, masterPw, purposeRootHex) {
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: [] };
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, 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, 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 };
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 });
}
// -- 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",
}));
}