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:
r-zakarya
2026-07-05 14:52:11 +01:00
parent 2bd0fcfbf8
commit 5e88ad33d1
9 changed files with 258 additions and 56 deletions
+42 -15
View File
@@ -541,34 +541,61 @@ needs no purge.
## Auth-hash schemes (`users.hash_algo`)
Three markers, all zero-knowledge (server never sees the master pw) :
Four 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)`
- `pbkdf2-sha256` (**older 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
- `pbkdf2-sha256-v2` (**DECOUPLED**) : 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).
- `argon2id-v2` (**current default**) : same decoupled verifier as above,
but the client derives the key with **Argon2id** (memory-hard) instead
of PBKDF2. KDF params (`m`/`t`/`p`) live in `users.argon2_m/t/p` and are
echoed by `/login/challenge` so the client knows how to derive. OWASP
baseline `m=19456 KiB, t=2, p=1` (`ARGON2_DEFAULT_PARAMS`, ~0.65 s/unlock).
**The server NEVER runs Argon2** — it only stores/echoes the params and
SHA256-wraps the 64-hex verifier exactly like any `-v2` scheme, so no new
verify branch. Argon2 is a **pure-JS** vendored bundle (`js/argon2.js`,
`@noble/hashes`) — WASM would need CSP `wasm-unsafe-eval`, which we don't
grant. Verified against the RFC 9106 test vector in the unit suite.
**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.
**The AES key (`cryptoKey`) is ALWAYS the raw KDF output** (Argon2id *or*
`hex(PBKDF2)`) regardless of the verifier scheme — the verifier string is a
separate layer, so entries stay decryptable. Switching the *verifier* scheme
(pbkdf2→v2) never re-encrypts, but switching the *KDF* (PBKDF2→Argon2id)
changes the derived key → the master-pw-change flow **re-encrypts the whole
vault** (its natural migration point).
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)`.
Client verifier formula = `verifierFromKeyHex(keyHex, algo)`;
`isDecoupledVerifierAlgo(algo)` = `algo.endsWith('-v2')` (both `-v2` markers
decouple). The KDF branch is `deriveKeyBytes(pwd, salt, algo, iters,
argonParams)` — Argon2id for `argon2id-*`, else PBKDF2.
Adoption is **new-registration + master-pw-change only** — existing accounts
stay on their algo until they rotate (no forced login-path migration; the
PBKDF2 100k→600k `/migrate-kdf` upgrade is unrelated and stays PBKDF2).
`state.hashAlgo` + `state.argon2Params` are cached from `/login/challenge`
and also carried in the quick-unlock / PIN cold-start blobs (so a
cold-started session can still derive-from-password for reauth/rotation).
Cold-start verifier paths use the raw stored key via `verifierFromKeyHex`
**no** KDF params needed there. Unknown/empty `hashAlgo` → key hex → correct
for every pre-decoupling account. Since all Argon2 accounts currently use
`ARGON2_DEFAULT_PARAMS`, a `null → default` params fallback is also correct
today (the blob persistence is future-proofing for tunable params).
### Argon2 server plumbing touch-points (keep in sync)
`POST /register` + `POST /change-master-password` accept `hashAlgo:
'argon2id-v2'` + `argon2:{m,t,p}` (bounds-checked via `ReadArgon2Params`,
persisted to `argon2_m/t/p`). `/login/challenge` returns them via
`AppendArgon2Params`. DB columns default 0 (= PBKDF2). Verify path
(`VerifierToStoredHash`/`CheckVerifier`) is KDF-agnostic — untouched.
## PIN unlock
+24 -9
View File
@@ -51,15 +51,30 @@ PBKDF2 avec un `info`/salt distinct. Ainsi le verifier transmis n'est
pas la clé. Migration possible sans re-chiffrer les entries (seul le
verifier stocké côté serveur change).
### 1.2 🟠 PBKDF2-SHA256 vs Argon2id
### 1.2 🟠 PBKDF2-SHA256 vs Argon2id — **corrigé (2026-07-05)**
600k itérations SHA-256 pour les nouveaux comptes (correct, conforme
OWASP 2023 ≥ 600k). Mais PBKDF2-SHA256 reste **GPU/ASIC-friendly**.
Un master pw faible tombe vite sur du matériel dédié si la DB fuit.
PBKDF2-SHA256 (600k) reste **GPU/ASIC-friendly** → un master pw faible
tombe vite sur matériel dédié si la DB fuit.
**Recommandation** : migrer vers **Argon2id** (memory-hard). Coût :
WebAssembly côté JS (argon2-browser) + implémentation Delphi, avec
migration progressive (comme le passage 100k→600k déjà en place).
**Fait** : nouveau scheme `argon2id-v2` (Argon2id memory-hard + verifier
décplé). Détails dans le CLAUDE.md « Auth-hash schemes ».
- **JS pur** (`js/argon2.js`, bundle `@noble/hashes@2.2.0`), **pas WASM** :
la CSP `script-src 'self'` n'accorde pas `wasm-unsafe-eval`. Vérifié
contre le vecteur RFC 9106 §5.3 (test unitaire).
- **Zéro Argon2 côté Delphi** (l'audit se trompait sur ce point) : le
serveur ne dérive jamais la clé, il stocke/échoie juste les params
(`argon2_m/t/p`) et SHA256-wrappe le verifier comme tout `-v2`.
- Params OWASP `m=19 MiB, t=2, p=1` (~0.65 s/unlock), stockés par compte.
- **Adoption** : register + change-master-pw (nouveau défaut). Comptes
existants restent PBKDF2 jusqu'à rotation. Passer à Argon2id re-chiffre
tout le vault (la clé dérivée change) — c'est le flow rotation existant.
- Tests : 8 tests crypto Argon2 (vecteur RFC, branche KDF, contrat de
params register↔login, sensibilité aux params). 42/42.
**Reste** : dériver via `argon2idAsync` pour ne pas geler l'UI ~0.65 s
(actuellement sync) ; option « migrer vers Argon2id sans changer de pw »
(aujourd'hui il faut changer le master pw). **Non compilé/testé runtime
Delphi dans cette session** — nécessite un rebuild `PMServer.dproj`.
### 1.3 🟡 Métadonnées en clair
@@ -268,7 +283,7 @@ cf. la checklist "Entry payload" de CLAUDE.md).
| Feature | Valeur | Effort | Note |
|---|---|---|---|
| **Argon2id** (KDF memory-hard) | 🔴 Haute | Moyen | argon2-browser + Delphi, migration progressive |
| ~~**Argon2id** (KDF memory-hard)~~ | 🔴 Haute | ~~Moyen~~ | Fait : `argon2id-v2`, JS pur (noble), zéro Delphi. Cf. §1.2 |
| **ETag/If-Match sur sync** | 🟠 Haute | Faible | Évite les lost updates multi-device |
| **Extension navigateur** | 🟠 Haute | Élevé | Autofill in-page, feature #1 demandée |
| **Windows Hello (biométrie)** | 🟠 Moyenne | Moyen | `Windows.Security.Credentials` — déverrouillage biométrique |
@@ -289,7 +304,7 @@ cf. la checklist "Entry payload" de CLAUDE.md).
3. **ETag/If-Match sur sync** (§2.1) — évite la perte de données multi-device.
4. ~~**Timestamps UTC partout** (§2.2)~~ — ✅ fait (`NowUTCStr`, going-forward ; rows existantes self-heal).
5. ~~**Tests unitaires crypto + merge** (§3.2)~~ — ✅ fait (35 tests, `js/tests/`, gate de build).
6. **Argon2id** (§1.2) — durcissement KDF, migration progressive.
6. ~~**Argon2id** (§1.2)~~ — ✅ fait (`argon2id-v2`, JS pur, adoption register+rotation).
7. Découpage `app.js` en modules (§3.1) — maintenabilité long terme.
---
+106 -8
View File
@@ -56,9 +56,64 @@ const
// 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';
// 'argon2id-v2' : DECOUPLED verifier (same SHA256 wrap as -sha256-v2), but
// the CLIENT derives the key with Argon2id (memory-hard) instead of
// PBKDF2. The server NEVER runs Argon2 — it only stores/echoes the params
// (argon2_m/t/p) so the client knows how to derive, and SHA256-wraps the
// 64-hex verifier exactly as for any other -v2 scheme. New registrations
// and master-pw changes land here; existing accounts stay on their algo
// until they rotate. Verify path is unchanged (VerifierToStoredHash).
HASH_ALGO_ARGON2 = 'argon2id-v2';
// Argon2 parameter sanity bounds — reject client-supplied params outside
// these so a hostile/buggy client can't set a 1-iteration or multi-GiB KDF.
ARGON2_M_MIN = 8; // KiB
ARGON2_M_MAX = 1048576; // 1 GiB
ARGON2_T_MIN = 1;
ARGON2_T_MAX = 16;
ARGON2_P_MIN = 1;
ARGON2_P_MAX = 16;
DEFAULT_FOLDERS: array[0..4] of string = ('All', 'Social', 'Banking', 'Work', 'Personal');
type
TArgon2Params = record
M, T, P: Integer;
Valid: Boolean; // True only when all three are within bounds
end;
// Read + bounds-check the optional {argon2:{m,t,p}} object from a request
// body. Valid=False when the object is absent or any field is out of range.
function ReadArgon2Params(ABody: TJSONObject): TArgon2Params;
var
LArg: TJSONObject;
begin
Result.M := 0; Result.T := 0; Result.P := 0; Result.Valid := False;
LArg := ABody.GetValue<TJSONObject>('argon2'); // nil when absent
if LArg = nil then Exit;
Result.M := LArg.GetValue<Integer>('m', 0);
Result.T := LArg.GetValue<Integer>('t', 0);
Result.P := LArg.GetValue<Integer>('p', 0);
Result.Valid :=
(Result.M >= ARGON2_M_MIN) and (Result.M <= ARGON2_M_MAX) and
(Result.T >= ARGON2_T_MIN) and (Result.T <= ARGON2_T_MAX) and
(Result.P >= ARGON2_P_MIN) and (Result.P <= ARGON2_P_MAX);
end;
// Attach an {argon2:{m,t,p}} object to a response when the params are set
// (m>0). No-op for PBKDF2 accounts so their responses are byte-identical.
procedure AppendArgon2Params(AObj: TJSONObject; AM, AT, AP: Integer);
var
LArg: TJSONObject;
begin
if AM <= 0 then Exit;
LArg := TJSONObject.Create;
LArg.AddPair('m', TJSONNumber.Create(AM));
LArg.AddPair('t', TJSONNumber.Create(AT));
LArg.AddPair('p', TJSONNumber.Create(AP));
AObj.AddPair('argon2', LArg);
end;
// Auth-hash computation for the current scheme. Wraps PBKDF2 output in
// SHA-256 so the stored value is no longer usable as the AES decryption
// key. Use this everywhere we write or verify a hash under
@@ -169,8 +224,9 @@ procedure HandleRegister(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LBody: TJSONObject;
LUser, LPwd, LVerifier, LSalt, LHash, LToken, LCSRF, LIP: string;
LUser, LPwd, LVerifier, LSalt, LHash, LToken, LCSRF, LIP, LReqAlgo: string;
LKdfIters: Integer;
LArgon: TArgon2Params;
LQ: TFDQuery;
LUserId: Integer;
begin
@@ -191,6 +247,10 @@ begin
LVerifier := LBody.GetValue<string>('verifier', '');
LSalt := LBody.GetValue<string>('salt', '');
LKdfIters := LBody.GetValue<Integer>('kdfIterations', PBKDF2_ITERATIONS_TARGET);
// Optional: client declares an Argon2id KDF. hashAlgo='argon2id-v2' +
// argon2:{m,t,p}. Absent → defaults to the PBKDF2 decoupled scheme.
LReqAlgo := LBody.GetValue<string>('hashAlgo', '');
LArgon := ReadArgon2Params(LBody);
finally
LBody.Free;
end;
@@ -249,9 +309,19 @@ begin
// 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;
// argon2_m/t/p persisted only for Argon2id accounts; 0 = PBKDF2.
var LArgM := 0; var LArgT := 0; var LArgP := 0;
if LVerifier <> '' then
begin
// ZK path: use the client-supplied salt + iters + verifier as-is.
// If the client declared Argon2id (with valid params), land on that
// scheme and record the params; otherwise the PBKDF2 decoupled scheme.
if SameText(LReqAlgo, HASH_ALGO_ARGON2) and LArgon.Valid then
begin
LRegAlgo := HASH_ALGO_ARGON2;
LArgM := LArgon.M; LArgT := LArgon.T; LArgP := LArgon.P;
end
else
LRegAlgo := HASH_ALGO_DECOUPLED;
LHash := VerifierToStoredHash(LVerifier, LRegAlgo);
end
@@ -267,13 +337,17 @@ begin
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'INSERT INTO users (username, password_hash, salt, hash_algo, kdf_iterations) ' +
'VALUES (:u, :h, :s, :algo, :it)';
'INSERT INTO users (username, password_hash, salt, hash_algo, kdf_iterations, ' +
' argon2_m, argon2_t, argon2_p) ' +
'VALUES (:u, :h, :s, :algo, :it, :am, :at, :ap)';
LQ.ParamByName('algo').AsString := LRegAlgo;
LQ.ParamByName('u').AsString := LUser;
LQ.ParamByName('h').AsString := LHash;
LQ.ParamByName('s').AsString := LSalt;
LQ.ParamByName('it').AsInteger := LKdfIters;
LQ.ParamByName('am').AsInteger := LArgM;
LQ.ParamByName('at').AsInteger := LArgT;
LQ.ParamByName('ap').AsInteger := LArgP;
LQ.ExecSQL;
LUserId := DB.Connection.GetLastAutoGenValue('users');
finally
@@ -788,8 +862,9 @@ var
LBody, LEntry, LObj: TJSONObject;
LEntries: TJSONArray;
LUser, LCurPwd, LNewPwd, LCurVerifier, LNewVerifier, LNewSalt,
LStoredHash, LOldSalt, LAlgo, LIP, LComputed, LNewHash: string;
LStoredHash, LOldSalt, LAlgo, LIP, LComputed, LNewHash, LReqAlgo: string;
LOldIters: Integer;
LArgon: TArgon2Params;
LQ: TFDQuery;
LValid: Boolean;
LEntryId: Integer;
@@ -814,6 +889,9 @@ begin
// for the NEW pw (PBKDF2 over the new salt at target iters).
LCurVerifier := LBody.GetValue<string>('currentVerifier', '');
LNewVerifier := LBody.GetValue<string>('newVerifier', '');
// Optional: rotate onto Argon2id. hashAlgo='argon2id-v2' + argon2:{m,t,p}.
LReqAlgo := LBody.GetValue<string>('hashAlgo', '');
LArgon := ReadArgon2Params(LBody);
LEntries := LBody.GetValue<TJSONArray>('entries');
// Input validation. Either plaintext OR verifier must be present; we
@@ -908,8 +986,17 @@ begin
// client-supplied newVerifier (rotating onto the DECOUPLED scheme).
// Plaintext: derive server-side (stays CURRENT).
var LNewAlgo := HASH_ALGO_CURRENT;
var LNewArgM := 0; var LNewArgT := 0; var LNewArgP := 0;
if LNewVerifier <> '' then
begin
// ZK rotation: Argon2id if the client declared it (valid params),
// else the PBKDF2 decoupled scheme. Both SHA256-wrap the verifier.
if SameText(LReqAlgo, HASH_ALGO_ARGON2) and LArgon.Valid then
begin
LNewAlgo := HASH_ALGO_ARGON2;
LNewArgM := LArgon.M; LNewArgT := LArgon.T; LNewArgP := LArgon.P;
end
else
LNewAlgo := HASH_ALGO_DECOUPLED;
LNewHash := VerifierToStoredHash(LNewVerifier, LNewAlgo);
end
@@ -927,12 +1014,18 @@ begin
' password_hash = :h, ' +
' salt = :s, ' +
' kdf_iterations = :it, ' +
' hash_algo = :algo ' +
' hash_algo = :algo, ' +
' argon2_m = :am, ' +
' argon2_t = :at, ' +
' argon2_p = :ap ' +
'WHERE id = :uid';
LQ.ParamByName('h').AsString := LNewHash;
LQ.ParamByName('s').AsString := LNewSalt;
LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET;
LQ.ParamByName('algo').AsString := LNewAlgo;
LQ.ParamByName('am').AsInteger := LNewArgM;
LQ.ParamByName('at').AsInteger := LNewArgT;
LQ.ParamByName('ap').AsInteger := LNewArgP;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
finally
@@ -1062,7 +1155,7 @@ procedure HandleLoginChallenge(ARequest: TIdHTTPRequestInfo;
var
LBody, LObj: TJSONObject;
LUser, LSalt, LIP, LAlgo: string;
LKdfIters: Integer;
LKdfIters, LArgM, LArgT, LArgP: Integer;
LQ: TFDQuery;
begin
LIP := GetClientIP(ARequest);
@@ -1090,7 +1183,7 @@ begin
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT salt, kdf_iterations, hash_algo ' +
'SELECT salt, kdf_iterations, hash_algo, argon2_m, argon2_t, argon2_p ' +
'FROM users WHERE username = :u';
LQ.ParamByName('u').AsString := LUser;
LQ.Open;
@@ -1102,6 +1195,9 @@ begin
LSalt := LQ.FieldByName('salt').AsString;
LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger;
LAlgo := LQ.FieldByName('hash_algo').AsString;
LArgM := LQ.FieldByName('argon2_m').AsInteger;
LArgT := LQ.FieldByName('argon2_t').AsInteger;
LArgP := LQ.FieldByName('argon2_p').AsInteger;
if LAlgo = '' then LAlgo := HASH_ALGO_LEGACY;
if LKdfIters <= 0 then LKdfIters := PBKDF2_ITERATIONS;
finally
@@ -1115,8 +1211,10 @@ begin
LObj.AddPair('salt', LSalt);
LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters));
// Echo back the hash_algo so the client can choose the right wrap path
// when needed (legacy vs current). Most clients ignore it.
// (legacy vs -v2) and KDF. For Argon2id accounts, also echo the params
// the client must feed to the KDF.
LObj.AddPair('hashAlgo', LAlgo);
AppendArgon2Params(LObj, LArgM, LArgT, LArgP);
TJSONHelper.SendJSON(AResponse, LObj);
end;
+8
View File
@@ -424,6 +424,14 @@ begin
// (600 000 as of 2026). Login flow transparently re-hashes legacy users
// and re-encrypts their entries on the client side.
AddColumnIfMissing('users', 'kdf_iterations', 'INTEGER DEFAULT 100000');
// Argon2id KDF parameters (memory KiB / time cost / parallelism). 0 = the
// account uses PBKDF2 (kdf_iterations above); non-zero = hash_algo is an
// 'argon2id-*' scheme and these drive the client-side key derivation.
// Stored per-user (like kdf_iterations) so the cost can be tuned later
// without breaking existing accounts.
AddColumnIfMissing('users', 'argon2_m', 'INTEGER DEFAULT 0');
AddColumnIfMissing('users', 'argon2_t', 'INTEGER DEFAULT 0');
AddColumnIfMissing('users', 'argon2_p', 'INTEGER DEFAULT 0');
AddColumnIfMissing('recovery_keys', 'remaining_uses', 'INTEGER DEFAULT 5');
// Server-side preferences blob (JSON). Synced across devices on login,
// saved on every change from the JS settings panel. Device-specific
+2 -1
View File
@@ -1,8 +1,9 @@
// Auto-generated by BuildAssets.ps1 - do not edit by hand.
const
EMBEDDED_ASSET_COUNT = 3;
EMBEDDED_ASSET_COUNT = 4;
EMBEDDED_ASSETS: array[0..EMBEDDED_ASSET_COUNT-1] of TEmbeddedAsset = (
(UrlPath: '/index.html'; ResName: 'INDEX_HTML'),
(UrlPath: '/js/argon2.js'; ResName: 'JS_ARGON2_JS'),
(UrlPath: '/js/app.js'; ResName: 'JS_APP_JS'),
(UrlPath: '/css/style.css'; ResName: 'CSS_STYLE_CSS')
);
+1
View File
@@ -2,5 +2,6 @@
#pragma code_page(65001)
INDEX_HTML RCDATA "Z:\\password-manager\\index.html"
JS_ARGON2_JS RCDATA "Z:\\password-manager\\js\\argon2.js"
JS_APP_JS RCDATA "Z:\\password-manager\\js\\app.js"
CSS_STYLE_CSS RCDATA "Z:\\password-manager\\css\\style.css"
Binary file not shown.
+51 -21
View File
@@ -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' }),
+22
View File
@@ -122,6 +122,28 @@ test('deriveKeyAndVerifier: argon2id uses ARGON2_DEFAULT_PARAMS when none passed
assert.deepEqual({ ...T.ARGON2_DEFAULT_PARAMS }, { m: 19456, t: 2, p: 1 });
});
test('argon2 param contract: identical params → identical key+verifier (register↔login)', async () => {
// Register derives with params P; login re-derives with the params the
// challenge echoes back. If those match, the key + verifier must match
// exactly — otherwise the user could register but never log in.
const pwd = 'pw', salt = 'saltsaltsalt', params = { m: 512, t: 2, p: 1 };
const a = await T.deriveKeyAndVerifier(pwd, salt, 0, T.HASH_ALGO_ARGON2, params);
const b = await T.deriveKeyAndVerifier(pwd, salt, 0, T.HASH_ALGO_ARGON2, { ...params });
const ra = new Uint8Array(await ctx.crypto.subtle.exportKey('raw', a.cryptoKey));
const rb = new Uint8Array(await ctx.crypto.subtle.exportKey('raw', b.cryptoKey));
assert.equal(T.bytesToHex(ra), T.bytesToHex(rb));
assert.equal(a.verifier, b.verifier);
});
test('argon2 param sensitivity: differing params → different key (transmission matters)', async () => {
// If the server drops/garbles the echoed params, the client derives a
// different key → login fails closed rather than silently mis-deriving.
const pwd = 'pw', salt = 'saltsaltsalt';
const a = await T.deriveKeyAndVerifier(pwd, salt, 0, T.HASH_ALGO_ARGON2, { m: 512, t: 2, p: 1 });
const b = await T.deriveKeyAndVerifier(pwd, salt, 0, T.HASH_ALGO_ARGON2, { m: 512, t: 3, p: 1 });
assert.notEqual(a.verifier, b.verifier);
});
test('encrypt/decrypt round-trips under an Argon2id-derived key', async () => {
const { cryptoKey } = await T.deriveKeyAndVerifier(
'master', 'saltsaltsalt', 0, T.HASH_ALGO_ARGON2, { m: 512, t: 1, p: 1 });