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
+108 -10
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,10 +309,20 @@ 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.
LRegAlgo := HASH_ALGO_DECOUPLED;
// 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
else
@@ -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,9 +986,18 @@ 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
LNewAlgo := HASH_ALGO_DECOUPLED;
// 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
else
@@ -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.