feat(crypto): adopt Argon2id (argon2id-v2) on register + master-pw change
Phase 2 of CODE_AUDIT §1.2 — live adoption of the Argon2id foundation.
Verified at runtime: a rotated account shows hash_algo=argon2id-v2 with
argon2_m=19456,t=2,p=1 in vault.db.
Server (never runs Argon2 — zero-knowledge, only stores/echoes params):
- DB: users.argon2_m/t/p columns (default 0 = PBKDF2).
- PM.Handler.Auth: HASH_ALGO_ARGON2 + param bounds, ReadArgon2Params /
AppendArgon2Params helpers. /register and /change-master-password accept
hashAlgo='argon2id-v2' + argon2:{m,t,p} and persist them; /login/challenge
echoes them. Verify path (VerifierToStoredHash/CheckVerifier) is
KDF-agnostic — the 64-hex verifier is SHA256-wrapped as for any -v2 scheme.
Client (app.js):
- state.argon2Params, cached from the challenge and persisted to
sessionStorage + the quick-unlock / PIN cold-start blobs (so a cold-started
session can still derive-from-password for reauth/rotation).
- Register + master-pw rotation derive with argon2id-v2 + ARGON2_DEFAULT_PARAMS
(OWASP m=19MiB,t=2,p=1) and send the params. Rotation re-encrypts the whole
vault under the new Argon2 key (natural migration point). Existing accounts
stay PBKDF2 until they rotate.
- Params threaded through every derive-from-password site (login, reauth,
recovery setup, change-pw current verifier). Cold-start verifier-from-raw-key
paths need no params (isDecoupledVerifierAlgo handles the -v2 wrap).
Tests: +2 param-contract tests (register<->login determinism, param
sensitivity). 42/42. Assets rebuilt to embed js/argon2.js.
Docs: CLAUDE.md auth-hash section rewritten (4 markers); CODE_AUDIT §1.2 +
table + plan marked done.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -588,6 +588,15 @@ const state = {
|
||||
// the /login/challenge response, from the cold-start blob, or hardcoded
|
||||
// to v2 on register / master-pw change.
|
||||
hashAlgo: sessionStorage.getItem('hashAlgo') || '',
|
||||
// Argon2id KDF params { m, t, p } for the current account, or null for
|
||||
// PBKDF2 accounts. Set from /login/challenge, the cold-start blobs, or
|
||||
// ARGON2_DEFAULT_PARAMS on register / master-pw change. Needed wherever a
|
||||
// key/verifier is DERIVED FROM THE PASSWORD (login, reauth, rotation) —
|
||||
// NOT for cold-start verifier-from-raw-key paths.
|
||||
argon2Params: (() => {
|
||||
try { return JSON.parse(sessionStorage.getItem('argon2Params') || 'null'); }
|
||||
catch { return null; }
|
||||
})(),
|
||||
cryptoKey: null,
|
||||
entries: [],
|
||||
trashed: [],
|
||||
@@ -1742,8 +1751,8 @@ async function runKdfMigration(masterPwd, fromIters, toIters) {
|
||||
// 600k + decoupled → never signalled). Under a non-v2 algo the
|
||||
// verifier is the key hex, so both derivations round-trip exactly as
|
||||
// before; passing state.hashAlgo keeps it explicit.
|
||||
const oldVerifier = await computeVerifier(masterPwd, state.salt, fromIters, state.hashAlgo);
|
||||
const newVerifier = await computeVerifier(masterPwd, state.salt, toIters, state.hashAlgo);
|
||||
const oldVerifier = await computeVerifier(masterPwd, state.salt, fromIters, state.hashAlgo, state.argon2Params);
|
||||
const newVerifier = await computeVerifier(masterPwd, state.salt, toIters, state.hashAlgo, state.argon2Params);
|
||||
|
||||
await api('/migrate-kdf', {
|
||||
method: 'POST',
|
||||
@@ -1918,10 +1927,13 @@ async function doLogin(e) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: u }),
|
||||
});
|
||||
// The challenge tells us the account's auth scheme; compute the
|
||||
// verifier accordingly (v2 → decoupled, else → key hex).
|
||||
// The challenge tells us the account's auth scheme + KDF params;
|
||||
// derive the key/verifier accordingly (argon2id-v2 → Argon2 with the
|
||||
// echoed params; -v2 → decoupled; else → key hex).
|
||||
state.hashAlgo = ch.hashAlgo || '';
|
||||
const derived = await deriveKeyAndVerifier(p, ch.salt, ch.kdfIterations, state.hashAlgo);
|
||||
state.argon2Params = ch.argon2 || null;
|
||||
const derived = await deriveKeyAndVerifier(
|
||||
p, ch.salt, ch.kdfIterations, state.hashAlgo, state.argon2Params);
|
||||
|
||||
const r = await api('/login', {
|
||||
method: 'POST',
|
||||
@@ -1939,6 +1951,7 @@ async function doLogin(e) {
|
||||
sessionStorage.setItem('username', state.username);
|
||||
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||||
sessionStorage.setItem('argon2Params', JSON.stringify(state.argon2Params));
|
||||
// Persist via DPAPI when running inside the Delphi host (localStorage
|
||||
// is wiped on each restart because the HTTP port — and therefore the
|
||||
// origin — changes every launch). Fall back to localStorage for the
|
||||
@@ -1995,9 +2008,11 @@ async function doRegister(e) {
|
||||
// leaves the browser.
|
||||
const newSalt = randomHexSalt();
|
||||
const newIters = 600000;
|
||||
// New accounts use the decoupled-verifier scheme (v2).
|
||||
state.hashAlgo = HASH_ALGO_V2;
|
||||
const derived = await deriveKeyAndVerifier(p, newSalt, newIters, HASH_ALGO_V2);
|
||||
// New accounts use Argon2id (memory-hard) with the decoupled verifier.
|
||||
state.hashAlgo = HASH_ALGO_ARGON2;
|
||||
state.argon2Params = { ...ARGON2_DEFAULT_PARAMS };
|
||||
const derived = await deriveKeyAndVerifier(
|
||||
p, newSalt, newIters, HASH_ALGO_ARGON2, state.argon2Params);
|
||||
|
||||
const r = await api('/register', {
|
||||
method: 'POST',
|
||||
@@ -2007,7 +2022,8 @@ async function doRegister(e) {
|
||||
salt: newSalt,
|
||||
kdfIterations: newIters,
|
||||
verifier: derived.verifier,
|
||||
hashAlgo: HASH_ALGO_V2,
|
||||
hashAlgo: HASH_ALGO_ARGON2,
|
||||
argon2: state.argon2Params,
|
||||
}),
|
||||
});
|
||||
state.token = r.token;
|
||||
@@ -2021,6 +2037,7 @@ async function doRegister(e) {
|
||||
sessionStorage.setItem('username', state.username);
|
||||
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||||
sessionStorage.setItem('argon2Params', JSON.stringify(state.argon2Params));
|
||||
state.cryptoKey = derived.cryptoKey;
|
||||
await persistCryptoKey();
|
||||
toast('Vault created');
|
||||
@@ -2133,7 +2150,8 @@ async function doUnlock(p) {
|
||||
// Compute the verifier locally with the salt+iters cached at login.
|
||||
// Server compares verifier → never sees the plaintext master pw.
|
||||
const iters = state.kdfIterations || 100000;
|
||||
const derived = await deriveKeyAndVerifier(p, state.salt, iters, state.hashAlgo);
|
||||
const derived = await deriveKeyAndVerifier(
|
||||
p, state.salt, iters, state.hashAlgo, state.argon2Params);
|
||||
const r = await api('/reauth', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
@@ -7193,6 +7211,7 @@ async function pinBuildBlob(pin) {
|
||||
// pre-decoupling blobs → cold-start defaults to the key hex, which
|
||||
// is correct for those (legacy) accounts.
|
||||
hashAlgo: state.hashAlgo || '',
|
||||
argon2Params: state.argon2Params || null,
|
||||
salt: bytesToBase64(salt),
|
||||
iters: PIN_KDF_ITERS,
|
||||
iv: bytesToBase64(iv),
|
||||
@@ -7300,7 +7319,7 @@ async function pinSetupFlow() {
|
||||
if (!masterPwd) return;
|
||||
try {
|
||||
const verifier = await computeVerifier(
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||||
await api('/reauth', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
@@ -7388,6 +7407,7 @@ async function loginViaPin(pin) {
|
||||
state.salt = blob.loginSalt || state.salt;
|
||||
state.kdfIterations = blob.loginIters || state.kdfIterations || 600000;
|
||||
state.hashAlgo = blob.hashAlgo || '';
|
||||
state.argon2Params = blob.argon2Params || null;
|
||||
|
||||
try {
|
||||
state.cryptoKey = await crypto.subtle.importKey(
|
||||
@@ -7442,7 +7462,7 @@ async function enableQuickUnlock() {
|
||||
if (!masterPwd) return;
|
||||
try {
|
||||
const verifier = await computeVerifier(
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||||
await api('/reauth', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
@@ -7464,6 +7484,7 @@ async function enableQuickUnlock() {
|
||||
kdfIterations: state.kdfIterations,
|
||||
// Auth scheme for cold-start verifier selection (see pinBuildBlob).
|
||||
hashAlgo: state.hashAlgo || '',
|
||||
argon2Params: state.argon2Params || null,
|
||||
key: bytesToBase64(raw),
|
||||
});
|
||||
const b64 = bytesToBase64(new TextEncoder().encode(blob));
|
||||
@@ -7523,6 +7544,7 @@ async function tryQuickUnlock() {
|
||||
state.salt = parsed.salt;
|
||||
state.kdfIterations = parsed.kdfIterations || 600000;
|
||||
state.hashAlgo = parsed.hashAlgo || '';
|
||||
state.argon2Params = parsed.argon2Params || null;
|
||||
const rawKey = base64ToBytes(parsed.key);
|
||||
|
||||
try {
|
||||
@@ -7712,7 +7734,7 @@ async function doGenerateRecoveryKey() {
|
||||
// Send a verifier instead of the master pw — server proves the
|
||||
// user still knows the master pw without ever seeing the plaintext.
|
||||
const verifier = await computeVerifier(
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||||
await api('/recovery-key/setup', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
@@ -7894,6 +7916,7 @@ async function doRecoveryRedeem() {
|
||||
// Account's auth scheme — needed so the recovery-mode master-pw change
|
||||
// proves the current key under the right verifier transform.
|
||||
state.hashAlgo = r.hashAlgo || '';
|
||||
state.argon2Params = r.argon2 || null;
|
||||
sessionStorage.setItem('authToken', state.token);
|
||||
sessionStorage.setItem('csrfToken', state.csrf);
|
||||
sessionStorage.setItem('salt', state.salt);
|
||||
@@ -8005,10 +8028,12 @@ async function doChangeMasterPassword() {
|
||||
// Also compute the verifier for the CURRENT pw so the server can
|
||||
// authenticate the change without ever seeing the plaintext.
|
||||
const newSalt = randomHexSalt();
|
||||
// Rotate onto the decoupled-verifier scheme (v2) — a master-pw
|
||||
// change re-derives + re-encrypts everything anyway, so it's the
|
||||
// natural migration point for existing accounts.
|
||||
const newDerived = await deriveKeyAndVerifier(newPwd, newSalt, 600000, HASH_ALGO_V2);
|
||||
// Rotate onto Argon2id (memory-hard KDF) + decoupled verifier — a
|
||||
// master-pw change re-derives + re-encrypts everything anyway, so it's
|
||||
// the natural migration point for existing PBKDF2 accounts.
|
||||
const newArgon = { ...ARGON2_DEFAULT_PARAMS };
|
||||
const newDerived = await deriveKeyAndVerifier(
|
||||
newPwd, newSalt, 600000, HASH_ALGO_ARGON2, newArgon);
|
||||
const newKey = newDerived.cryptoKey;
|
||||
let currentVerifier;
|
||||
if (recoveryMode) {
|
||||
@@ -8019,7 +8044,7 @@ async function doChangeMasterPassword() {
|
||||
currentVerifier = await verifierFromKeyHex(bytesToHex(rawCurrentKey), state.hashAlgo);
|
||||
} else {
|
||||
currentVerifier = await computeVerifier(
|
||||
curPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
|
||||
curPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||||
}
|
||||
|
||||
// Step 2: re-encrypt every entry's password AND every entry's TOTP
|
||||
@@ -8092,6 +8117,8 @@ async function doChangeMasterPassword() {
|
||||
currentVerifier: currentVerifier,
|
||||
newVerifier: newDerived.verifier,
|
||||
newSalt: newSalt,
|
||||
hashAlgo: HASH_ALGO_ARGON2,
|
||||
argon2: newArgon,
|
||||
entries: encrypted,
|
||||
}),
|
||||
});
|
||||
@@ -8100,13 +8127,15 @@ async function doChangeMasterPassword() {
|
||||
// the cached ciphertexts, persist for F5 survival.
|
||||
state.salt = r.salt || newSalt;
|
||||
state.kdfIterations = r.kdfIterations || 600000;
|
||||
// The account is now on the decoupled-verifier scheme.
|
||||
state.hashAlgo = HASH_ALGO_V2;
|
||||
// The account is now on the Argon2id + decoupled-verifier scheme.
|
||||
state.hashAlgo = HASH_ALGO_ARGON2;
|
||||
state.argon2Params = newArgon;
|
||||
state.cryptoKey = newKey;
|
||||
await persistCryptoKey();
|
||||
sessionStorage.setItem('salt', state.salt);
|
||||
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
|
||||
sessionStorage.setItem('hashAlgo', state.hashAlgo);
|
||||
sessionStorage.setItem('argon2Params', JSON.stringify(state.argon2Params));
|
||||
// Server invalidated every session for this user (including ours)
|
||||
// and minted a fresh pair — adopt them so subsequent API calls
|
||||
// don't bounce with "invalid session".
|
||||
@@ -8188,6 +8217,7 @@ async function doChangeMasterPassword() {
|
||||
salt: state.salt,
|
||||
kdfIterations: state.kdfIterations,
|
||||
hashAlgo: state.hashAlgo || '',
|
||||
argon2Params: state.argon2Params || null,
|
||||
key: bytesToBase64(new Uint8Array(raw)),
|
||||
});
|
||||
const b64 = bytesToBase64(new TextEncoder().encode(blob));
|
||||
@@ -9009,7 +9039,7 @@ async function doExport() {
|
||||
if (!masterPwd) return; // user cancelled
|
||||
try {
|
||||
const verifier = await computeVerifier(
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo);
|
||||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||||
await api('/reauth', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
|
||||
Reference in New Issue
Block a user