196 lines
9.5 KiB
JavaScript
196 lines
9.5 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);
|
||
|
|
}
|
||
|
|
|
||
|
|
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",
|
||
|
|
}));
|
||
|
|
}
|