From e7dca84f401c87582ef391b579eee2519f9318a4 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:19:16 +0100 Subject: [PATCH] fix(recovery): echo Argon2 params in the redeem response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The redeem client already reads `state.argon2Params = r.argon2 || null`, but the server never sent `argon2`, so it always fell back to null → the default params. Correct today (every account uses ARGON2_DEFAULT_PARAMS), but a latent wrong-key derivation the day per-account params diverge: same class as the setup verifier bug — an Argon2 account derived under the wrong params. Echo m/t/p from the users row like /login/challenge does; omitted for non-Argon2 accounts (m=0). No client change — it already consumes the field. Co-Authored-By: Claude Opus 4.8 --- .../Handlers/PM.Handler.Recovery.pas | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/delphi-backend/Handlers/PM.Handler.Recovery.pas b/delphi-backend/Handlers/PM.Handler.Recovery.pas index 5625708..347f0be 100644 --- a/delphi-backend/Handlers/PM.Handler.Recovery.pas +++ b/delphi-backend/Handlers/PM.Handler.Recovery.pas @@ -279,6 +279,8 @@ var LUser, LCode, LCodeHash, LIP, LStoredHash, LKdfSalt, LWrappedKey, LWrappedIv, LSalt, LToken, LCSRF, LAlgo: string; LUserId, LKdfIters, LCurrentUses, LNewUses: Integer; + LArgM, LArgT, LArgP: Integer; + LArg: TJSONObject; LQ: TFDQuery; begin LIP := GetClientIP(ARequest); @@ -314,6 +316,7 @@ begin // 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, u.hash_algo, ' + + ' u.argon2_m, u.argon2_t, u.argon2_p, ' + ' 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 ' + @@ -331,6 +334,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; LStoredHash := LQ.FieldByName('code_hash').AsString; LKdfSalt := LQ.FieldByName('kdf_salt').AsString; LWrappedKey := LQ.FieldByName('wrapped_key').AsString; @@ -407,6 +413,18 @@ begin LObj.AddPair('salt', LSalt); LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters)); LObj.AddPair('hashAlgo', LAlgo); + // Echo the Argon2 params (as /login/challenge does) so the client derives + // under the account's real params, not the default fallback. Harmless today + // (all accounts use the default) but avoids a silent wrong-key derivation if + // per-account params are ever tuned. Omitted for non-Argon2 accounts (m=0). + if LArgM > 0 then + begin + LArg := TJSONObject.Create; + LArg.AddPair('m', TJSONNumber.Create(LArgM)); + LArg.AddPair('t', TJSONNumber.Create(LArgT)); + LArg.AddPair('p', TJSONNumber.Create(LArgP)); + LObj.AddPair('argon2', LArg); + end; LObj.AddPair('wrappedKey', LWrappedKey); LObj.AddPair('wrappedIv', LWrappedIv); LObj.AddPair('kdfSalt', LKdfSalt);