From 3f8ecde57176f5ba9168deaa21b7b3e446ef9cd0 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:38:20 +0100 Subject: [PATCH] feat(security): decouple the login verifier from the AES vault key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zero-knowledge verifier sent to /login used to be the raw PBKDF2 output in hex — i.e. the exact bytes of the AES key that encrypts every entry. Intercepting a /login body (loopback, but still) handed over the vault key. This introduces a decoupled scheme where the transmitted verifier is a one-way function of the key. New auth-hash scheme - users.hash_algo 'pbkdf2-sha256-v2': the client sends verifier = SHA256(keyHex + "pmserver/auth-verifier/v2") instead of keyHex. Stored form is still SHA256(verifier) (identical server wrap to 'pbkdf2-sha256'), so only the algo LABEL differs — it tells the client which verifier formula to use. Verification needs no new server branch (VerifierToStoredHash already SHA256-wraps any non-legacy verifier). - The AES key (cryptoKey) stays hex(PBKDF2) for EVERY algo, so entries remain decryptable and switching schemes never re-encrypts data. Adoption: new-registration + master-pw-change only - Register and change-master-password write v2. Existing accounts keep their algo until they rotate — the login/reauth migration signal now fires only for LEGACY 'pbkdf2' (was: anything != CURRENT), so sha256/v2 accounts are never force-migrated (which would have downgraded v2 → sha256 via migrate-kdf). Client (js/app.js): algo-aware everywhere - verifierFromKeyHex(keyHex, algo) central helper; deriveKeyAndVerifier / computeVerifier take an algo arg. state.hashAlgo caches the account scheme, set from /login/challenge, register, change-master, the quick-unlock / PIN cold-start blobs, and the /recovery-key/redeem response. All ~12 verifier sites updated (login, register, reauth ×4, change-master current+new, migrate-kdf, quick-unlock + PIN cold-start, recovery-mode current verifier). Safety invariant: unknown/empty hashAlgo → key hex → byte-identical to the old behaviour, so every pre-decoupling account (and every existing quick-unlock / PIN blob without the new field) keeps working unchanged. Verified: existing account + pre-change quick-unlock still unlocks; a master-pw change now writes 'pbkdf2-sha256-v2' in vault.db. Server: recovery redeem returns hashAlgo; register + change-master store the decoupled algo; login + reauth migration signal narrowed to legacy. Also: BuildAssets.ps1 pipes $null into node --check so the JS syntax gate can't block on stdin in the Delphi pre-build environment. Addresses CODE_AUDIT.md section 1.1. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 31 ++++++ delphi-backend/Handlers/PM.Handler.Auth.pas | 47 ++++++-- .../Handlers/PM.Handler.Recovery.pas | 6 +- delphi-backend/assets/BuildAssets.ps1 | 5 +- delphi-backend/assets/assets.res | Bin 653580 -> 657952 bytes js/app.js | 104 +++++++++++++++--- 6 files changed, 162 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index be59ce2..39a6e72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -516,6 +516,37 @@ restore-then-sync actually stick. A live `vault_entries` row can never coexist with its tombstone (hard-delete removes the row), so `PUT` needs no purge. +## Auth-hash schemes (`users.hash_algo`) + +Three markers, all zero-knowledge (server never sees the master pw) : + +- `pbkdf2` (**LEGACY**) : stored hash = raw `PBKDF2(pw,salt,iters)` hex. + Those bytes ARE the AES vault key → a stolen `vault.db` = the key. + Auto-upgraded to `pbkdf2-sha256` at next login via `/migrate-kdf`. +- `pbkdf2-sha256` (**previous default**) : stored = `SHA256(verifier)` + where the client's transmitted `verifier` is still the key hex. Safe + at rest, but the `/login` body carries the key. +- `pbkdf2-sha256-v2` (**DECOUPLED, current default**) : the client sends + `verifier = SHA256(keyHex + "pmserver/auth-verifier/v2")` instead of + `keyHex`. The transmitted verifier is now a one-way function of the + key → intercepting `/login` no longer hands over the AES key. Stored = + `SHA256(verifier)` (same server wrap as `pbkdf2-sha256`; only the algo + LABEL differs, telling the client which verifier formula to use). + +**The AES key (`cryptoKey`) is ALWAYS `hex(PBKDF2)` regardless of algo** +— only the verifier string changes, so entries stay decryptable and +switching schemes never re-encrypts data. + +Adoption is **new-registration + master-pw-change only** — existing +accounts stay on their algo until they rotate (no forced login-path +migration; `not SameText(algo, LEGACY)` no longer signals migration, so +sha256/v2 accounts are left alone). Client picks the verifier formula +from the algo returned by `/login/challenge`, cached in `state.hashAlgo` +(also carried in the quick-unlock / PIN cold-start blobs and the +`/recovery-key/redeem` response). Unknown/empty `hashAlgo` → key hex → +correct for every pre-decoupling account, which is what makes the +rollout safe. Central client helper: `verifierFromKeyHex(keyHex, algo)`. + ## PIN unlock Optional shortcut unlock with a 4–12 digit PIN, complementary to Quick diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas index ccb3d15..228cc80 100644 --- a/delphi-backend/Handlers/PM.Handler.Auth.pas +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -47,6 +47,15 @@ const // during /login to compute the comparison. HASH_ALGO_LEGACY = 'pbkdf2'; HASH_ALGO_CURRENT = 'pbkdf2-sha256'; + // 'pbkdf2-sha256-v2' : DECOUPLED. Same stored form as CURRENT + // (SHA256 of the client verifier), but the client's transmitted + // verifier is now SHA256(keyHex + domain) instead of keyHex — so the + // /login body no longer carries the raw AES vault key. Used by new + // registrations and by every master-pw change. Existing accounts stay + // on their current algo until they rotate (no forced migration). + // Verification is identical to CURRENT (VerifierToStoredHash wraps any + // non-legacy verifier in SHA256), so no new verify branch is needed. + HASH_ALGO_DECOUPLED = 'pbkdf2-sha256-v2'; DEFAULT_FOLDERS: array[0..4] of string = ('All', 'Social', 'Banking', 'Work', 'Personal'); @@ -235,10 +244,16 @@ begin LQ.Free; end; + // New ZK registrations land on the DECOUPLED scheme; the plaintext + // fallback (legacy clients) stays on CURRENT. VerifierToStoredHash + // wraps both the same way (SHA256), so only the stored algo LABEL + // differs — it's what tells the client which verifier formula to use. + var LRegAlgo := HASH_ALGO_CURRENT; if LVerifier <> '' then begin // ZK path: use the client-supplied salt + iters + verifier as-is. - LHash := VerifierToStoredHash(LVerifier, HASH_ALGO_CURRENT); + LRegAlgo := HASH_ALGO_DECOUPLED; + LHash := VerifierToStoredHash(LVerifier, LRegAlgo); end else begin @@ -253,7 +268,8 @@ begin LQ.Connection := DB.Connection; LQ.SQL.Text := 'INSERT INTO users (username, password_hash, salt, hash_algo, kdf_iterations) ' + - 'VALUES (:u, :h, :s, ''' + HASH_ALGO_CURRENT + ''', :it)'; + 'VALUES (:u, :h, :s, :algo, :it)'; + LQ.ParamByName('algo').AsString := LRegAlgo; LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('h').AsString := LHash; LQ.ParamByName('s').AsString := LSalt; @@ -396,12 +412,15 @@ begin LogAudit(LUserId, 'login', LIP); // Signal migration whenever EITHER: // - the user's iteration count is below the target (KDF bump needed), OR - // - the user's hash_algo is not the current scheme (format upgrade needed - // to remove the AES-key-in-vault.db architectural flaw). - // The client then calls /migrate-kdf which fixes both in one atomic step. + // - the user is on the LEGACY 'pbkdf2' scheme (stored hash = raw key hex; + // upgrade to SHA256-wrapped to remove the AES-key-in-vault.db flaw). + // NOTE: we deliberately do NOT signal for 'pbkdf2-sha256' or the newer + // 'pbkdf2-sha256-v2' (decoupled) — those are already SHA256-wrapped at + // rest, and forcing sha256 → v2 is out of scope (v2 is adopted only on + // register / master-pw change, never force-migrated at login). SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF, LKdfIters, (LKdfIters < PBKDF2_ITERATIONS_TARGET) or - not SameText(LAlgo, HASH_ALGO_CURRENT)); + SameText(LAlgo, HASH_ALGO_LEGACY)); end; // ===== /logout =============================================================== @@ -535,8 +554,11 @@ begin var LObj := TJSONObject.Create; LObj.AddPair('message', 'OK'); LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters)); + // Same rule as HandleLogin: only KDF-bump or LEGACY format triggers + // migration. sha256 / v2 accounts are left as-is (v2 must not be + // force-downgraded to sha256 by migrate-kdf). if (LKdfIters < PBKDF2_ITERATIONS_TARGET) or - not SameText(LAlgo, HASH_ALGO_CURRENT) then + SameText(LAlgo, HASH_ALGO_LEGACY) then begin var LMig := TJSONObject.Create; LMig.AddPair('target', TJSONNumber.Create(PBKDF2_ITERATIONS_TARGET)); @@ -883,9 +905,14 @@ begin end; // Step 3: compute the new auth hash. ZK path: just wrap the - // client-supplied newVerifier. Plaintext: derive server-side. + // client-supplied newVerifier (rotating onto the DECOUPLED scheme). + // Plaintext: derive server-side (stays CURRENT). + var LNewAlgo := HASH_ALGO_CURRENT; if LNewVerifier <> '' then - LNewHash := VerifierToStoredHash(LNewVerifier, HASH_ALGO_CURRENT) + begin + LNewAlgo := HASH_ALGO_DECOUPLED; + LNewHash := VerifierToStoredHash(LNewVerifier, LNewAlgo); + end else LNewHash := ComputeAuthHashCurrent(LNewPwd, LNewSalt, PBKDF2_ITERATIONS_TARGET); @@ -905,7 +932,7 @@ begin LQ.ParamByName('h').AsString := LNewHash; LQ.ParamByName('s').AsString := LNewSalt; LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET; - LQ.ParamByName('algo').AsString := HASH_ALGO_CURRENT; + LQ.ParamByName('algo').AsString := LNewAlgo; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ExecSQL; finally diff --git a/delphi-backend/Handlers/PM.Handler.Recovery.pas b/delphi-backend/Handlers/PM.Handler.Recovery.pas index 193e77d..928bb54 100644 --- a/delphi-backend/Handlers/PM.Handler.Recovery.pas +++ b/delphi-backend/Handlers/PM.Handler.Recovery.pas @@ -273,7 +273,7 @@ procedure HandleRedeem(ARequest: TIdHTTPRequestInfo; var LBody, LObj: TJSONObject; LUser, LCode, LCodeHash, LIP, LStoredHash, LKdfSalt, LWrappedKey, LWrappedIv, - LSalt, LToken, LCSRF: string; + LSalt, LToken, LCSRF, LAlgo: string; LUserId, LKdfIters, LCurrentUses, LNewUses: Integer; LQ: TFDQuery; begin @@ -309,7 +309,7 @@ begin LQ.Connection := DB.Connection; // Join to users to look up by username + verify the code in one shot. LQ.SQL.Text := - 'SELECT u.id, u.salt, u.kdf_iterations, ' + + 'SELECT u.id, u.salt, u.kdf_iterations, u.hash_algo, ' + ' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv, rk.remaining_uses ' + 'FROM users u ' + 'LEFT JOIN recovery_keys rk ON rk.user_id = u.id ' + @@ -326,6 +326,7 @@ begin LUserId := LQ.FieldByName('id').AsInteger; LSalt := LQ.FieldByName('salt').AsString; LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger; + LAlgo := LQ.FieldByName('hash_algo').AsString; LStoredHash := LQ.FieldByName('code_hash').AsString; LKdfSalt := LQ.FieldByName('kdf_salt').AsString; LWrappedKey := LQ.FieldByName('wrapped_key').AsString; @@ -401,6 +402,7 @@ begin LObj.AddPair('csrfToken', LCSRF); LObj.AddPair('salt', LSalt); LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters)); + LObj.AddPair('hashAlgo', LAlgo); LObj.AddPair('wrappedKey', LWrappedKey); LObj.AddPair('wrappedIv', LWrappedIv); LObj.AddPair('kdfSalt', LKdfSalt); diff --git a/delphi-backend/assets/BuildAssets.ps1 b/delphi-backend/assets/BuildAssets.ps1 index 883e6c4..056b302 100644 --- a/delphi-backend/assets/BuildAssets.ps1 +++ b/delphi-backend/assets/BuildAssets.ps1 @@ -91,7 +91,10 @@ if ($jsFiles) { foreach ($jf in $jsFiles) { Log "Syntax check: $($jf.Relative)" # --check prints errors to stderr and returns non-zero on failure. - $out = & $node.Source --check $jf.FullPath 2>&1 + # Pipe $null into node so it can NEVER block waiting on stdin + # (some Windows node shims read stdin when launched from a + # non-interactive pre-build event, which would hang the build). + $out = $null | & $node.Source --check $jf.FullPath 2>&1 if ($LASTEXITCODE -ne 0) { Log "JS SYNTAX ERROR in $($jf.Relative):" Log ($out | Out-String) diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 268d6f7fb5e8997aac0411fb22b5c4dc362600ad..cbe7dd20649dc8666e480faaa37c4f16d845915c 100644 GIT binary patch delta 4570 zcmd5=+ix6K8P`rcn`YCC(~u^1OmiX{c~{v>ut-F0Ie6nZI7+%sY^SA)kb8FK?CvPD zGn1LKc2+5DA|MGB2!tvQ4^`R-ltKhGK}f5DNb(O3(9wMm36BRFf-DP(u4sHL5W2J$EOvL(!#Rz<$k_@t>+~h20nUH{GP@8a zBR&f}*9llw9u4tuZ8H~e>T^=I-Ex+=KB-aPG~B}`Gh=HmS)CxR0~V@QzyaCQntUius$g8iC@$vR|t;E?er1mHS>O7m7xtOkq<2v=v zfd_>;aOF{-a@Wt3muk$BVMmyZ2PrUxPF5&5B>?_>acO>~c=F2&D=$uD<=KvXWFA1{ zEdVEAKKuc1P)LmB)@T!L0rfeMf)W~VL~0;`bf|z_6bmym*_dW^#^M39E9B!#uY*p| zmgN9I2J<+w*dc_JTdvt8j!SG8a6`O7jKsxo@DxgBl{RdS!pWnd<>VmN49`Un+2eqFeE_0w^%D6KzWmZ%Fq}s+YIz+ zl1PF|aD@7dG#px~FatE6PZ=N{-h(ii9#BJ5(ZS-G<@uEt=N6A2J3hC#a&+N^;_;Ha zFj^1MFHo#+#ZLjsZ%VAcr;qMP2i(t)i&9ac({iIC67TCf#lx*1~g{ zDS6Ikyx}{tMuKG*>Nf4LHF5?>_+rubX)_Hb@a)%82jzEoh#dfVP;p7gM5Uf3 z9~iRF|As6)ATN)P-&;THS*0!D}KRX7kvnR2U6REH?D8eO&A zhMy&^h7%T_iM@`n3s9WhEhCdp(V*!Vqyh)I1qG~Fk%^u9l=jfkr=VKa4qpUar;{0aAaJ3axF2Z9DU*23H=RhKMRhHCFnrUS9Au1 zfp5r`?TMT zBC5-PT4&sFX4?U2gn7grn_$sL^L&aJZXG%mdmsvU%{L)*NDUc~Rip0G!oZmw9^}t-F82*UA*$P{$~3|QF~bvKcCfhJ*yxa&knNQ{$y6$ zm-;F|P=cltH_!%*dL%IxM%?VwLIXKHEadKKhSBa4oN0T-Y)RYpWVWX;g^K#A_G(F+ zPKZ+d7$Z-W%Wl+t9P(tr_hxap0%=i&<^w9(8SSvKg_yD>*9a zRqaddnMJL#eItbDZ*1Nzyb_rH`aAj?S6M}a?@jPM)MgdkBx<=+Fo3v?&L$$aGYOn; zRckzM06jGr^9-On3|6onpb5v0jSFBFstv|Iuz&*+x&;pO7Ixk;_~9`8yDde_m)gB> zCkmwKy23MA1;;b!T2`;JnGM&^Zfj7(AYUKGr|i$X=7J!wk59s?mx*=K(X0YK;;T=> z;R<#4ik=#NdSfw$wfNBw^>03v8=!xwfN8nq^_Rh=clB4q{ZIAh#eZ(>O7w{3b<+0k42(9pxM zcrwv`ZC!s(>lYl3%PvY)u&@@|z?SUy0b|6ooFfg(HjjV~y6Uu#rv6qVdCJB@0AMED zzq+lzxp{vdp%Evr-v+Q>DXY<{eBxbwul9dJ#`c{(eUvdz4tWeyX>I zG5JLorz>=xsDV6uJV;b%C-+!aU@6@G03~=ahEL%A_<=ccu@5(z=-mkqP~-*}>}))q3(NE1#WF80Z%LN=N6_!LBv&!N+30x=Z$s#Sk{sAPEh@XP z<>czD?)pu|Eszal<#Bn4*spQDU>34fhBjTM@@@bLU zn*7Sfzu)~{a-@B6Yx3|DWk?e8R+9kXa@;k1Y2(0ppe{NCW3Y02)&>~XAO24}%;k~f z>`w-j?)g6sjR+VlJH@?&i6QZa4>qO5%#GwT!u#=1NgViPa;F$x*Y{k#eka+!erNLs Q_pkC>kG1)&Ek7##7jt3*^#A|> delta 596 zcma)(U2Mx?6vn;pzvnGATA6lTJJK>)OV>$6;$s&bmgy>+#B3H3N_sP)QQNc`OT$GI zYF)+hkRZM;HAu<{i-pXD#8Mx3E~Jr&5G1&f5b=%+*`15$=A4t?InQ%0p7v$G)}@Og zV3+n04%$Vmg9rDDtu=6FMWf;O4500(NFtOa8^6tpX96N8YUCOZ%0ps4LV4l9ik$Ev zniI`@Ehp$4hC8LgtAN~qV7=VJ`Fi=nH8N-@)P`jfhP&hsn>Erul>r4f>OtLpIIHY zft{)umTJ`xPs*&&<5GRj>Au$YB@(gyy;JtQZgm`A)+z@-cdMp32sh~Lhx}@sPc2oe tu3{o2tSC3=d~STuU;K25s&Id$FlB5d#kP_;`BBWsWJYIlW^~rsw%@Ew)sO%H diff --git a/js/app.js b/js/app.js index 6c02074..a465ff5 100644 --- a/js/app.js +++ b/js/app.js @@ -493,6 +493,13 @@ const state = { // and on-the-fly verifier computations don't need a /login/challenge // round trip every time. Refreshed from every auth response. kdfIterations: parseInt(sessionStorage.getItem('kdfIterations') || '0') || 0, + // Auth-hash scheme of the current account. Drives which verifier formula + // the client sends: 'pbkdf2-sha256-v2' → SHA256(keyHex + domain) so the + // transmitted verifier is NOT the raw AES key; anything else → keyHex + // (legacy / pre-decoupling accounts, byte-identical to before). Set from + // the /login/challenge response, from the cold-start blob, or hardcoded + // to v2 on register / master-pw change. + hashAlgo: sessionStorage.getItem('hashAlgo') || '', cryptoKey: null, entries: [], trashed: [], @@ -619,7 +626,29 @@ function bytesToHex(arr) { return hex; } -async function deriveKeyAndVerifier(pwd, saltHex, iterations) { +// Decoupled-verifier scheme marker + domain separator. When the account's +// hash_algo is HASH_ALGO_V2, the verifier sent to the server is a one-way +// SHA-256 of the key hex (domain-separated), NOT the key hex itself — so +// intercepting the /login body no longer hands over the AES vault key. +// The AES key (cryptoKey) is ALWAYS the raw PBKDF2 output regardless, so +// entries stay decryptable and legacy accounts are unaffected. +const HASH_ALGO_V2 = 'pbkdf2-sha256-v2'; +const AUTH_VERIFIER_DOMAIN = 'pmserver/auth-verifier/v2'; + +async function sha256Hex(str) { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); + return bytesToHex(new Uint8Array(buf)); +} + +// Map the raw PBKDF2 key hex → the verifier to transmit, per account algo. +// v2 → domain-separated SHA-256 (decoupled from the key). Anything else → +// the key hex verbatim (legacy behaviour, unchanged for existing accounts). +async function verifierFromKeyHex(keyHex, algo) { + if (algo === HASH_ALGO_V2) return await sha256Hex(keyHex + AUTH_VERIFIER_DOMAIN); + return keyHex; +} + +async function deriveKeyAndVerifier(pwd, saltHex, iterations, algo) { iterations = iterations || 100000; const enc = new TextEncoder(); const km = await crypto.subtle.importKey( @@ -631,11 +660,12 @@ async function deriveKeyAndVerifier(pwd, saltHex, iterations) { const keyBytes = new Uint8Array(bits); const cryptoKey = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); - return { cryptoKey, verifier: bytesToHex(keyBytes) }; + const verifier = await verifierFromKeyHex(bytesToHex(keyBytes), algo); + return { cryptoKey, verifier }; } -async function computeVerifier(pwd, saltHex, iterations) { - const r = await deriveKeyAndVerifier(pwd, saltHex, iterations); +async function computeVerifier(pwd, saltHex, iterations, algo) { + const r = await deriveKeyAndVerifier(pwd, saltHex, iterations, algo); return r.verifier; } @@ -1591,8 +1621,12 @@ async function runKdfMigration(masterPwd, fromIters, toIters) { // the user knows the master pw under the current (legacy) iters; // newVerifier is what the server will SHA-256-wrap to be the new // stored hash after migration. Master pw never leaves the browser. - const oldVerifier = await computeVerifier(masterPwd, state.salt, fromIters); - const newVerifier = await computeVerifier(masterPwd, state.salt, toIters); + // Only non-v2 accounts ever reach the KDF migration (v2 accounts are + // 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); await api('/migrate-kdf', { method: 'POST', @@ -1767,7 +1801,10 @@ async function doLogin(e) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u }), }); - const derived = await deriveKeyAndVerifier(p, ch.salt, ch.kdfIterations); + // The challenge tells us the account's auth scheme; compute the + // verifier accordingly (v2 → decoupled, else → key hex). + state.hashAlgo = ch.hashAlgo || ''; + const derived = await deriveKeyAndVerifier(p, ch.salt, ch.kdfIterations, state.hashAlgo); const r = await api('/login', { method: 'POST', @@ -1784,6 +1821,7 @@ async function doLogin(e) { sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('username', state.username); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); + sessionStorage.setItem('hashAlgo', state.hashAlgo); // 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 @@ -1840,7 +1878,9 @@ async function doRegister(e) { // leaves the browser. const newSalt = randomHexSalt(); const newIters = 600000; - const derived = await deriveKeyAndVerifier(p, newSalt, newIters); + // New accounts use the decoupled-verifier scheme (v2). + state.hashAlgo = HASH_ALGO_V2; + const derived = await deriveKeyAndVerifier(p, newSalt, newIters, HASH_ALGO_V2); const r = await api('/register', { method: 'POST', @@ -1850,6 +1890,7 @@ async function doRegister(e) { salt: newSalt, kdfIterations: newIters, verifier: derived.verifier, + hashAlgo: HASH_ALGO_V2, }), }); state.token = r.token; @@ -1862,6 +1903,7 @@ async function doRegister(e) { sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('username', state.username); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); + sessionStorage.setItem('hashAlgo', state.hashAlgo); state.cryptoKey = derived.cryptoKey; await persistCryptoKey(); toast('Vault created'); @@ -1974,7 +2016,7 @@ 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); + const derived = await deriveKeyAndVerifier(p, state.salt, iters, state.hashAlgo); const r = await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), @@ -6996,6 +7038,11 @@ async function pinBuildBlob(pin) { username: state.username, loginSalt: state.salt, loginIters: state.kdfIterations || 600000, + // Auth scheme so cold-start sends the right verifier (v2 accounts + // need the decoupled transform, not the raw key hex). Absent on + // pre-decoupling blobs → cold-start defaults to the key hex, which + // is correct for those (legacy) accounts. + hashAlgo: state.hashAlgo || '', salt: bytesToBase64(salt), iters: PIN_KDF_ITERS, iv: bytesToBase64(iv), @@ -7103,7 +7150,7 @@ async function pinSetupFlow() { if (!masterPwd) return; try { const verifier = await computeVerifier( - masterPwd, state.salt, state.kdfIterations || 100000); + masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo); await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), @@ -7190,6 +7237,7 @@ async function loginViaPin(pin) { state.username = blob.username || state.username; state.salt = blob.loginSalt || state.salt; state.kdfIterations = blob.loginIters || state.kdfIterations || 600000; + state.hashAlgo = blob.hashAlgo || ''; try { state.cryptoKey = await crypto.subtle.importKey( @@ -7197,7 +7245,8 @@ async function loginViaPin(pin) { } catch (_) { return false; } try { - const verifier = bytesToHex(rawKey); + // v2 accounts need the decoupled verifier; legacy → key hex. + const verifier = await verifierFromKeyHex(bytesToHex(rawKey), state.hashAlgo); const r = await api('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -7216,6 +7265,7 @@ async function loginViaPin(pin) { sessionStorage.setItem('username', state.username); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); + sessionStorage.setItem('hashAlgo', state.hashAlgo); sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); await persistCryptoKey(); @@ -7242,7 +7292,7 @@ async function enableQuickUnlock() { if (!masterPwd) return; try { const verifier = await computeVerifier( - masterPwd, state.salt, state.kdfIterations || 100000); + masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo); await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), @@ -7262,6 +7312,8 @@ async function enableQuickUnlock() { username: state.username, salt: state.salt, kdfIterations: state.kdfIterations, + // Auth scheme for cold-start verifier selection (see pinBuildBlob). + hashAlgo: state.hashAlgo || '', key: bytesToBase64(raw), }); const b64 = bytesToBase64(new TextEncoder().encode(blob)); @@ -7320,6 +7372,7 @@ async function tryQuickUnlock() { state.username = parsed.username; state.salt = parsed.salt; state.kdfIterations = parsed.kdfIterations || 600000; + state.hashAlgo = parsed.hashAlgo || ''; const rawKey = base64ToBytes(parsed.key); try { @@ -7335,7 +7388,8 @@ async function tryQuickUnlock() { // up by the server's session GC, which used to drop the user back to // the login screen on cold start. try { - const verifier = bytesToHex(rawKey); + // v2 accounts need the decoupled verifier; legacy → key hex. + const verifier = await verifierFromKeyHex(bytesToHex(rawKey), state.hashAlgo); const r = await api('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -7355,6 +7409,7 @@ async function tryQuickUnlock() { sessionStorage.setItem('username', state.username); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); + sessionStorage.setItem('hashAlgo', state.hashAlgo); sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); await persistCryptoKey(); @@ -7507,7 +7562,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); + masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo); await api('/recovery-key/setup', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), @@ -7686,11 +7741,15 @@ async function doRecoveryRedeem() { state.salt = r.salt; state.username = u.trim(); state.kdfIterations = r.kdfIterations || 600000; + // 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 || ''; sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('username', state.username); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); + sessionStorage.setItem('hashAlgo', state.hashAlgo); // Import the raw key bytes as a fresh AES-GCM CryptoKey (extractable // so master-pw change can later re-export and re-wrap as needed). @@ -7791,15 +7850,21 @@ 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(); - const newDerived = await deriveKeyAndVerifier(newPwd, newSalt, 600000); + // 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); const newKey = newDerived.cryptoKey; let currentVerifier; if (recoveryMode) { + // Current pw is proven via the in-memory recovered key. The + // server compares under the account's CURRENT algo, so apply the + // same verifier transform (v2 → decoupled, else → key hex). const rawCurrentKey = new Uint8Array(await crypto.subtle.exportKey('raw', state.cryptoKey)); - currentVerifier = bytesToHex(rawCurrentKey); + currentVerifier = await verifierFromKeyHex(bytesToHex(rawCurrentKey), state.hashAlgo); } else { currentVerifier = await computeVerifier( - curPwd, state.salt, state.kdfIterations || 100000); + curPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo); } // Step 2: re-encrypt every entry's password AND every entry's TOTP @@ -7875,10 +7940,13 @@ 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; state.cryptoKey = newKey; await persistCryptoKey(); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); + sessionStorage.setItem('hashAlgo', state.hashAlgo); // 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". @@ -8751,7 +8819,7 @@ async function doExport() { if (!masterPwd) return; // user cancelled try { const verifier = await computeVerifier( - masterPwd, state.salt, state.kdfIterations || 100000); + masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo); await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }),