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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user