feat(recovery): single-use recovery code for forgotten master password
In a zero-knowledge vault, forgetting the master password normally
means losing the data — the AES key is derived from the master pw
and the server can't help. This commit adds the standard escape
hatch: a one-time recovery code that key-wraps the AES key so the
user can get back in.
Threat model
============
The plaintext recovery code is shown to the user exactly once, at
generation time. Server only ever stores SHA-256(code) + an AES-GCM
wrap of the vault key under a KEK = PBKDF2(code, kdf_salt, 600k).
Without the plaintext code the server cannot unwrap. The code is
high-entropy (96 bits from a 32-char ambiguity-free alphabet, in 4
groups of 4) — printed form is misreading-resistant.
Single use: redeeming deletes the row inside the same DB.Lock the
lookup happened in, so concurrent redeem attempts are race-free.
Failed redemptions feed both the per-IP rate limit AND the per-
username lockout, so brute-forcing the code is infeasible.
Schema
======
recovery_keys (
user_id INTEGER PRIMARY KEY (1:1 with users, FK cascade),
code_hash TEXT NOT NULL (SHA-256 hex of plaintext code),
kdf_salt TEXT NOT NULL (PBKDF2 salt for KEK derivation),
wrapped_key TEXT NOT NULL (base64 AES-GCM ciphertext of vault key),
wrapped_iv TEXT NOT NULL (base64 12B IV for the wrap),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
Backend: new unit PM.Handler.Recovery
=====================================
GET /recovery-key/status (auth) -> { configured, created_at? }
POST /recovery-key/setup (auth + CSRF) body {masterPassword, codeHash,
kdfSalt, wrappedKey, wrappedIv}
DELETE /recovery-key (auth + CSRF) -> remove config
POST /recovery-key/redeem (NO auth) body {username, code}
-> session + wrappedKey + wrappedIv + kdfSalt
+ user's current salt + kdfIterations
VerifyMasterPassword() helper handles both legacy 'pbkdf2' and
current 'pbkdf2-sha256' schemes consistently with PM.Handler.Auth.
Setup flow
==========
1. Settings → "Generate recovery code" button (asks master pw via reauth).
2. Client generates: 16-char code + fresh kdf_salt + exports the current
AES key via crypto.subtle.exportKey('raw').
3. Client wraps the raw key under KEK=PBKDF2(code, kdf_salt, 600k)
with a random 12B IV → base64.
4. POSTs to /recovery-key/setup. Server verifies master pw, INSERT-or-
replaces the row (DELETE+INSERT, no UPSERT — same pattern as the
lockout table since FireDAC's UPSERT support is patchy).
5. Confirm modal shows the plaintext code in a monospace, user-select-all
panel. The modal is forcing: "I saved it" button is the only way out.
Modal is the only place the code ever appears — server never sees it.
Redeem flow (forgot master pw)
==============================
1. Auth screen → "Forgot master password? Use a recovery code" link.
2. promptDialog: username, then code (masked input).
3. POST /recovery-key/redeem. Server hashes the typed code, joins with
users by username, ConstantTimeEquals against stored hash. On match:
- deletes the recovery_keys row (single-use)
- issues a fresh session token + CSRF
- returns: { token, csrfToken, salt, kdfIterations, kdfSalt,
wrappedKey, wrappedIv, userId }
4. Client unwraps the AES key with PBKDF2(code, kdfSalt, 600k) → raw bytes
→ importKey('raw') back into a CryptoKey.
5. State is reconstituted from the new session, persistCryptoKey, enterApp.
6. Client immediately opens the Change-master-password modal — the
recovery code is consumed and the account needs a fresh master pw
AND a fresh recovery code (the user generates a new one from Settings).
Backward compat
===============
Recovery is opt-in. Existing users see "No recovery key set" in Settings
until they generate one. No migration needed — the table is created via
CREATE TABLE IF NOT EXISTS at server startup, FK cascade on user delete.
Minor UI additions
==================
- .btn-link CSS class for the auth-screen "Forgot master password?" link.
- Recovery-status label in Settings refreshed on every openSettings()
via GET /recovery-key/status.
This commit is contained in:
@@ -116,6 +116,14 @@ svg { width: 16px; height: 16px; flex-shrink: 0; }
|
|||||||
.btn-block { width: 100%; }
|
.btn-block { width: 100%; }
|
||||||
.btn-sm { padding: 6px 10px; font-size: 12px; }
|
.btn-sm { padding: 6px 10px; font-size: 12px; }
|
||||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-link {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-color: transparent;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.btn-link:hover { color: var(--accent); background: transparent; }
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
unit PM.Handler.Recovery;
|
||||||
|
|
||||||
|
(*
|
||||||
|
Recovery key endpoints — one-time-use code that wraps the user's AES vault
|
||||||
|
key for emergency access when the master password is lost.
|
||||||
|
|
||||||
|
Threat model:
|
||||||
|
The server stores only SHA-256(code), never the plaintext. The wrapped_key
|
||||||
|
is AES-GCM ciphertext of the user's vault key under a KEK derived from
|
||||||
|
PBKDF2(code, kdf_salt, 600k). Without the plaintext code, the server
|
||||||
|
cannot unwrap the key on its own. The user is the only party that ever
|
||||||
|
has access to the plaintext, and only once (right after generation).
|
||||||
|
|
||||||
|
Single use:
|
||||||
|
Redeeming the recovery key deletes the row. The user is expected to set
|
||||||
|
a fresh master password and generate a new recovery key immediately
|
||||||
|
after, which the client does automatically via change-master-password
|
||||||
|
+ setup.
|
||||||
|
|
||||||
|
GET /recovery-key/status -> { configured: bool, created_at? }
|
||||||
|
POST /recovery-key/setup body { masterPassword, codeHash,
|
||||||
|
kdfSalt, wrappedKey, wrappedIv } -> { message }
|
||||||
|
DELETE /recovery-key -> { message }
|
||||||
|
POST /recovery-key/redeem body { username, code } -> session +
|
||||||
|
{ wrappedKey, wrappedIv, kdfSalt, salt,
|
||||||
|
kdfIterations, token, csrfToken, userId }
|
||||||
|
(NO session auth — this IS the auth)
|
||||||
|
*)
|
||||||
|
|
||||||
|
interface
|
||||||
|
|
||||||
|
implementation
|
||||||
|
|
||||||
|
uses
|
||||||
|
System.SysUtils, System.JSON,
|
||||||
|
FireDAC.Comp.Client, FireDAC.Stan.Param,
|
||||||
|
IdCustomHTTPServer,
|
||||||
|
PM.Router, PM.JSON, PM.Database, PM.Crypto, PM.Session, PM.Audit, PM.RateLimit;
|
||||||
|
|
||||||
|
// Verifies the user's master password against their current stored hash.
|
||||||
|
// Used by /recovery-key/setup so a stolen session token alone can't set up
|
||||||
|
// a recovery backdoor.
|
||||||
|
function VerifyMasterPassword(AUserId: Integer; const APwd: string;
|
||||||
|
out AUsername: string): Boolean;
|
||||||
|
const
|
||||||
|
HASH_ALGO_LEGACY = 'pbkdf2';
|
||||||
|
HASH_ALGO_CURRENT = 'pbkdf2-sha256';
|
||||||
|
PBKDF2_ITERATIONS = 100000;
|
||||||
|
var
|
||||||
|
LQ: TFDQuery;
|
||||||
|
LStoredHash, LSalt, LAlgo, LComputed: string;
|
||||||
|
LIters: Integer;
|
||||||
|
begin
|
||||||
|
Result := False;
|
||||||
|
AUsername := '';
|
||||||
|
DB.Lock;
|
||||||
|
try
|
||||||
|
LQ := TFDQuery.Create(nil);
|
||||||
|
try
|
||||||
|
LQ.Connection := DB.Connection;
|
||||||
|
LQ.SQL.Text :=
|
||||||
|
'SELECT username, password_hash, salt, hash_algo, kdf_iterations ' +
|
||||||
|
'FROM users WHERE id = :uid';
|
||||||
|
LQ.ParamByName('uid').AsInteger := AUserId;
|
||||||
|
LQ.Open;
|
||||||
|
if LQ.IsEmpty then Exit;
|
||||||
|
AUsername := LQ.FieldByName('username').AsString;
|
||||||
|
LStoredHash := LQ.FieldByName('password_hash').AsString;
|
||||||
|
LSalt := LQ.FieldByName('salt').AsString;
|
||||||
|
LAlgo := LQ.FieldByName('hash_algo').AsString;
|
||||||
|
LIters := LQ.FieldByName('kdf_iterations').AsInteger;
|
||||||
|
if LAlgo = '' then LAlgo := HASH_ALGO_LEGACY;
|
||||||
|
if LIters <= 0 then LIters := PBKDF2_ITERATIONS;
|
||||||
|
finally
|
||||||
|
LQ.Free;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
DB.Unlock;
|
||||||
|
end;
|
||||||
|
|
||||||
|
if SameText(LAlgo, HASH_ALGO_LEGACY) then
|
||||||
|
begin
|
||||||
|
LComputed := PBKDF2_SHA256_Hex(APwd, LSalt, LIters);
|
||||||
|
Result := ConstantTimeEquals(LComputed, LStoredHash);
|
||||||
|
end
|
||||||
|
else if SameText(LAlgo, HASH_ALGO_CURRENT) then
|
||||||
|
begin
|
||||||
|
LComputed := SHA256Hex(PBKDF2_SHA256_Hex(APwd, LSalt, LIters));
|
||||||
|
Result := ConstantTimeEquals(LComputed, LStoredHash);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// ===== GET /recovery-key/status ==============================================
|
||||||
|
procedure HandleStatus(ARequest: TIdHTTPRequestInfo;
|
||||||
|
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||||
|
var
|
||||||
|
LUserId: Integer;
|
||||||
|
LQ: TFDQuery;
|
||||||
|
LObj: TJSONObject;
|
||||||
|
LConfigured: Boolean;
|
||||||
|
LCreatedAt: string;
|
||||||
|
begin
|
||||||
|
try
|
||||||
|
LUserId := Authenticate(ARequest, AResponse);
|
||||||
|
except
|
||||||
|
on ESessionRejected do Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
LConfigured := False;
|
||||||
|
LCreatedAt := '';
|
||||||
|
DB.Lock;
|
||||||
|
try
|
||||||
|
LQ := TFDQuery.Create(nil);
|
||||||
|
try
|
||||||
|
LQ.Connection := DB.Connection;
|
||||||
|
LQ.SQL.Text :=
|
||||||
|
'SELECT created_at FROM recovery_keys WHERE user_id = :uid';
|
||||||
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
|
LQ.Open;
|
||||||
|
if not LQ.IsEmpty then
|
||||||
|
begin
|
||||||
|
LConfigured := True;
|
||||||
|
LCreatedAt := LQ.FieldByName('created_at').AsString;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
LQ.Free;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
DB.Unlock;
|
||||||
|
end;
|
||||||
|
|
||||||
|
LObj := TJSONObject.Create;
|
||||||
|
LObj.AddPair('configured', TJSONBool.Create(LConfigured));
|
||||||
|
if LConfigured then LObj.AddPair('created_at', LCreatedAt);
|
||||||
|
TJSONHelper.SendJSON(AResponse, LObj);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// ===== POST /recovery-key/setup ==============================================
|
||||||
|
procedure HandleSetup(ARequest: TIdHTTPRequestInfo;
|
||||||
|
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||||
|
var
|
||||||
|
LUserId: Integer;
|
||||||
|
LBody: TJSONObject;
|
||||||
|
LPwd, LCodeHash, LKdfSalt, LWrappedKey, LWrappedIv, LIP, LUser: string;
|
||||||
|
LQ: TFDQuery;
|
||||||
|
begin
|
||||||
|
try
|
||||||
|
LUserId := Authenticate(ARequest, AResponse);
|
||||||
|
RequireCSRF(ARequest, AResponse, LUserId);
|
||||||
|
except
|
||||||
|
on ESessionRejected do Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
LIP := GetClientIP(ARequest);
|
||||||
|
LBody := TJSONHelper.ReadBody(ARequest);
|
||||||
|
try
|
||||||
|
LPwd := LBody.GetValue<string>('masterPassword', '');
|
||||||
|
LCodeHash := LBody.GetValue<string>('codeHash', '');
|
||||||
|
LKdfSalt := LBody.GetValue<string>('kdfSalt', '');
|
||||||
|
LWrappedKey := LBody.GetValue<string>('wrappedKey', '');
|
||||||
|
LWrappedIv := LBody.GetValue<string>('wrappedIv', '');
|
||||||
|
finally
|
||||||
|
LBody.Free;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Length sanity: SHA-256 hex = 64; kdf salt hex = 64; wrapped pieces are
|
||||||
|
// base64 — minimal length check to weed out obvious garbage.
|
||||||
|
if (Length(LCodeHash) <> 64) or (Length(LKdfSalt) <> 64) or
|
||||||
|
(LWrappedKey = '') or (LWrappedIv = '') then
|
||||||
|
begin
|
||||||
|
TJSONHelper.SendError(AResponse, 400, 'Invalid recovery payload');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
if not VerifyMasterPassword(LUserId, LPwd, LUser) then
|
||||||
|
begin
|
||||||
|
RecordFailedAccountAttempt(LUser, LIP);
|
||||||
|
LogAudit(LUserId, 'failed_recovery_setup', LIP);
|
||||||
|
TJSONHelper.SendError(AResponse, 401, 'Invalid master password');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
DB.Lock;
|
||||||
|
try
|
||||||
|
LQ := TFDQuery.Create(nil);
|
||||||
|
try
|
||||||
|
LQ.Connection := DB.Connection;
|
||||||
|
// INSERT-or-replace via DELETE+INSERT (portable, avoids the UPSERT
|
||||||
|
// syntax we saw FireDAC choke on for the lockout table earlier).
|
||||||
|
LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid';
|
||||||
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
|
LQ.ExecSQL;
|
||||||
|
|
||||||
|
LQ.SQL.Text :=
|
||||||
|
'INSERT INTO recovery_keys ' +
|
||||||
|
' (user_id, code_hash, kdf_salt, wrapped_key, wrapped_iv) ' +
|
||||||
|
'VALUES (:uid, :ch, :ks, :wk, :wi)';
|
||||||
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
|
LQ.ParamByName('ch').AsString := LCodeHash;
|
||||||
|
LQ.ParamByName('ks').AsString := LKdfSalt;
|
||||||
|
LQ.ParamByName('wk').AsString := LWrappedKey;
|
||||||
|
LQ.ParamByName('wi').AsString := LWrappedIv;
|
||||||
|
LQ.ExecSQL;
|
||||||
|
finally
|
||||||
|
LQ.Free;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
DB.Unlock;
|
||||||
|
end;
|
||||||
|
|
||||||
|
LogAudit(LUserId, 'recovery_setup', LIP);
|
||||||
|
TJSONHelper.SendOK(AResponse, 'Recovery key configured');
|
||||||
|
end;
|
||||||
|
|
||||||
|
// ===== DELETE /recovery-key ==================================================
|
||||||
|
procedure HandleDelete(ARequest: TIdHTTPRequestInfo;
|
||||||
|
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||||
|
var
|
||||||
|
LUserId: Integer;
|
||||||
|
LQ: TFDQuery;
|
||||||
|
begin
|
||||||
|
try
|
||||||
|
LUserId := Authenticate(ARequest, AResponse);
|
||||||
|
RequireCSRF(ARequest, AResponse, LUserId);
|
||||||
|
except
|
||||||
|
on ESessionRejected do Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
DB.Lock;
|
||||||
|
try
|
||||||
|
LQ := TFDQuery.Create(nil);
|
||||||
|
try
|
||||||
|
LQ.Connection := DB.Connection;
|
||||||
|
LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid';
|
||||||
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
|
LQ.ExecSQL;
|
||||||
|
finally
|
||||||
|
LQ.Free;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
DB.Unlock;
|
||||||
|
end;
|
||||||
|
|
||||||
|
LogAudit(LUserId, 'recovery_delete', GetClientIP(ARequest));
|
||||||
|
TJSONHelper.SendOK(AResponse, 'Recovery key removed');
|
||||||
|
end;
|
||||||
|
|
||||||
|
// ===== POST /recovery-key/redeem =============================================
|
||||||
|
// No session auth required — this is the entry point when the user CAN'T log
|
||||||
|
// in. Per-IP rate limit + per-account lockout still apply: an attacker can't
|
||||||
|
// brute-force the (high-entropy) recovery code by trying every possible
|
||||||
|
// value without hitting the lockout.
|
||||||
|
procedure HandleRedeem(ARequest: TIdHTTPRequestInfo;
|
||||||
|
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||||
|
var
|
||||||
|
LBody, LObj: TJSONObject;
|
||||||
|
LUser, LCode, LCodeHash, LIP, LStoredHash, LKdfSalt, LWrappedKey, LWrappedIv,
|
||||||
|
LSalt, LToken, LCSRF: string;
|
||||||
|
LUserId, LKdfIters: Integer;
|
||||||
|
LQ: TFDQuery;
|
||||||
|
begin
|
||||||
|
LIP := GetClientIP(ARequest);
|
||||||
|
if CheckRateLimit(LIP) >= 10 then
|
||||||
|
begin
|
||||||
|
TJSONHelper.SendError(AResponse, 429, 'Too many attempts. Try again later.');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
LBody := TJSONHelper.ReadBody(ARequest);
|
||||||
|
try
|
||||||
|
LUser := Trim(LBody.GetValue<string>('username', ''));
|
||||||
|
LCode := Trim(LBody.GetValue<string>('code', ''));
|
||||||
|
finally
|
||||||
|
LBody.Free;
|
||||||
|
end;
|
||||||
|
|
||||||
|
if (LUser = '') or (LCode = '') then
|
||||||
|
begin
|
||||||
|
TJSONHelper.SendError(AResponse, 400, 'Username and code required');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
if RejectIfAccountLocked(AResponse, LUser) then Exit;
|
||||||
|
|
||||||
|
LCodeHash := SHA256Hex(LCode);
|
||||||
|
|
||||||
|
DB.Lock;
|
||||||
|
try
|
||||||
|
LQ := TFDQuery.Create(nil);
|
||||||
|
try
|
||||||
|
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, ' +
|
||||||
|
' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv ' +
|
||||||
|
'FROM users u ' +
|
||||||
|
'LEFT JOIN recovery_keys rk ON rk.user_id = u.id ' +
|
||||||
|
'WHERE u.username = :u';
|
||||||
|
LQ.ParamByName('u').AsString := LUser;
|
||||||
|
LQ.Open;
|
||||||
|
if LQ.IsEmpty then
|
||||||
|
begin
|
||||||
|
// User doesn't exist OR has no recovery key configured. Same error
|
||||||
|
// either way to avoid leaking which.
|
||||||
|
RecordAttempt(LIP);
|
||||||
|
RecordFailedAccountAttempt(LUser, LIP);
|
||||||
|
TJSONHelper.SendError(AResponse, 401, 'Invalid username or recovery code');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
LUserId := LQ.FieldByName('id').AsInteger;
|
||||||
|
LSalt := LQ.FieldByName('salt').AsString;
|
||||||
|
LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger;
|
||||||
|
LStoredHash := LQ.FieldByName('code_hash').AsString;
|
||||||
|
LKdfSalt := LQ.FieldByName('kdf_salt').AsString;
|
||||||
|
LWrappedKey := LQ.FieldByName('wrapped_key').AsString;
|
||||||
|
LWrappedIv := LQ.FieldByName('wrapped_iv').AsString;
|
||||||
|
finally
|
||||||
|
LQ.Free;
|
||||||
|
end;
|
||||||
|
|
||||||
|
if (LStoredHash = '') or (LKdfSalt = '') or (LWrappedKey = '') then
|
||||||
|
begin
|
||||||
|
// User exists but no recovery row.
|
||||||
|
DB.Unlock;
|
||||||
|
try
|
||||||
|
RecordAttempt(LIP);
|
||||||
|
RecordFailedAccountAttempt(LUser, LIP);
|
||||||
|
finally
|
||||||
|
DB.Lock;
|
||||||
|
end;
|
||||||
|
TJSONHelper.SendError(AResponse, 401, 'Invalid username or recovery code');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
if not ConstantTimeEquals(LCodeHash, LStoredHash) then
|
||||||
|
begin
|
||||||
|
DB.Unlock;
|
||||||
|
try
|
||||||
|
RecordAttempt(LIP);
|
||||||
|
RecordFailedAccountAttempt(LUser, LIP);
|
||||||
|
finally
|
||||||
|
DB.Lock;
|
||||||
|
end;
|
||||||
|
LogAudit(LUserId, 'failed_recovery_redeem', LIP);
|
||||||
|
TJSONHelper.SendError(AResponse, 401, 'Invalid username or recovery code');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Code matches. Consume (delete the row) inside the same lock so the
|
||||||
|
// single-use guarantee holds even under concurrent requests.
|
||||||
|
LQ := TFDQuery.Create(nil);
|
||||||
|
try
|
||||||
|
LQ.Connection := DB.Connection;
|
||||||
|
LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid';
|
||||||
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
|
LQ.ExecSQL;
|
||||||
|
finally
|
||||||
|
LQ.Free;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
DB.Unlock;
|
||||||
|
end;
|
||||||
|
|
||||||
|
ClearAttempts(LIP);
|
||||||
|
ClearAccountLockout(LUser);
|
||||||
|
CreateSession(LUserId, LToken, LCSRF);
|
||||||
|
LogAudit(LUserId, 'recovery_redeem', LIP);
|
||||||
|
|
||||||
|
LObj := TJSONObject.Create;
|
||||||
|
LObj.AddPair('message', 'OK');
|
||||||
|
LObj.AddPair('userId', TJSONNumber.Create(LUserId));
|
||||||
|
LObj.AddPair('token', LToken);
|
||||||
|
LObj.AddPair('csrfToken', LCSRF);
|
||||||
|
LObj.AddPair('salt', LSalt);
|
||||||
|
LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters));
|
||||||
|
LObj.AddPair('wrappedKey', LWrappedKey);
|
||||||
|
LObj.AddPair('wrappedIv', LWrappedIv);
|
||||||
|
LObj.AddPair('kdfSalt', LKdfSalt);
|
||||||
|
TJSONHelper.SendJSON(AResponse, LObj);
|
||||||
|
end;
|
||||||
|
|
||||||
|
initialization
|
||||||
|
Router.Register('GET', '/recovery-key/status', HandleStatus);
|
||||||
|
Router.Register('POST', '/recovery-key/setup', HandleSetup);
|
||||||
|
Router.Register('DELETE', '/recovery-key', HandleDelete);
|
||||||
|
Router.Register('POST', '/recovery-key/redeem', HandleRedeem);
|
||||||
|
|
||||||
|
end.
|
||||||
@@ -19,7 +19,8 @@ uses
|
|||||||
PM.Handler.Auth in 'Handlers\PM.Handler.Auth.pas',
|
PM.Handler.Auth in 'Handlers\PM.Handler.Auth.pas',
|
||||||
PM.Handler.Folders in 'Handlers\PM.Handler.Folders.pas',
|
PM.Handler.Folders in 'Handlers\PM.Handler.Folders.pas',
|
||||||
PM.Handler.Entries in 'Handlers\PM.Handler.Entries.pas',
|
PM.Handler.Entries in 'Handlers\PM.Handler.Entries.pas',
|
||||||
PM.Handler.Passkey in 'Handlers\PM.Handler.Passkey.pas';
|
PM.Handler.Passkey in 'Handlers\PM.Handler.Passkey.pas',
|
||||||
|
PM.Handler.Recovery in 'Handlers\PM.Handler.Recovery.pas';
|
||||||
|
|
||||||
{$R *.res}
|
{$R *.res}
|
||||||
{$R assets\assets.res}
|
{$R assets\assets.res}
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
|
|||||||
<DCCReference Include="Handlers\PM.Handler.Folders.pas"/>
|
<DCCReference Include="Handlers\PM.Handler.Folders.pas"/>
|
||||||
<DCCReference Include="Handlers\PM.Handler.Entries.pas"/>
|
<DCCReference Include="Handlers\PM.Handler.Entries.pas"/>
|
||||||
<DCCReference Include="Handlers\PM.Handler.Passkey.pas"/>
|
<DCCReference Include="Handlers\PM.Handler.Passkey.pas"/>
|
||||||
|
<DCCReference Include="Handlers\PM.Handler.Recovery.pas"/>
|
||||||
<BuildConfiguration Include="Base">
|
<BuildConfiguration Include="Base">
|
||||||
<Key>Base</Key>
|
<Key>Base</Key>
|
||||||
</BuildConfiguration>
|
</BuildConfiguration>
|
||||||
|
|||||||
@@ -147,6 +147,22 @@ begin
|
|||||||
' last_attempt_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
|
' last_attempt_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
|
||||||
' last_attempt_ip TEXT' +
|
' last_attempt_ip TEXT' +
|
||||||
')');
|
')');
|
||||||
|
// Recovery key — per-user single-use code that wraps the current AES
|
||||||
|
// vault key, used to recover access if the master password is forgotten.
|
||||||
|
// The plaintext code is shown to the user exactly once at setup; the
|
||||||
|
// server only ever sees SHA-256(code) for lookup. wrapped_key is the
|
||||||
|
// user''s AES key encrypted (AES-GCM) under a KEK derived from
|
||||||
|
// PBKDF2(code, kdf_salt, 600k). Single-use: redeem deletes the row.
|
||||||
|
FConn.ExecSQL(
|
||||||
|
'CREATE TABLE IF NOT EXISTS recovery_keys (' +
|
||||||
|
' user_id INTEGER PRIMARY KEY,' +
|
||||||
|
' code_hash TEXT NOT NULL,' +
|
||||||
|
' kdf_salt TEXT NOT NULL,' +
|
||||||
|
' wrapped_key TEXT NOT NULL,' +
|
||||||
|
' wrapped_iv TEXT NOT NULL,' +
|
||||||
|
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
|
||||||
|
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' +
|
||||||
|
')');
|
||||||
FConn.ExecSQL(
|
FConn.ExecSQL(
|
||||||
'CREATE TABLE IF NOT EXISTS audit_log (' +
|
'CREATE TABLE IF NOT EXISTS audit_log (' +
|
||||||
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
|
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
|
||||||
|
|||||||
+23
@@ -109,6 +109,9 @@
|
|||||||
<svg><use href="#i-lock"/></svg>
|
<svg><use href="#i-lock"/></svg>
|
||||||
Use a passkey
|
Use a passkey
|
||||||
</button>
|
</button>
|
||||||
|
<button id="recoveryBtn" type="button" class="btn btn-link btn-block" style="font-size:12px;margin-top:4px">
|
||||||
|
Forgot master password? Use a recovery code
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form id="registerForm" class="auth-form is-hidden" autocomplete="off">
|
<form id="registerForm" class="auth-form is-hidden" autocomplete="off">
|
||||||
@@ -374,6 +377,26 @@
|
|||||||
<svg><use href="#i-lock"/></svg> Change master password
|
<svg><use href="#i-lock"/></svg> Change master password
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="slideover-field">
|
||||||
|
<div class="slideover-field-label">Recovery key</div>
|
||||||
|
<p id="recoveryStatus" style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
|
||||||
|
No recovery key set.
|
||||||
|
</p>
|
||||||
|
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.4">
|
||||||
|
A single-use code that lets you recover access if you
|
||||||
|
forget your master password. Generate it once, save the
|
||||||
|
code somewhere offline — the server never sees it again.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="recoverySetupBtn">
|
||||||
|
Generate recovery code
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm is-danger" id="recoveryRemoveBtn" style="display:none">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -2409,6 +2409,256 @@ function closeReauth(ok) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// RECOVERY KEY — one-shot emergency access
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Generated at the user's request from Settings. The plaintext code is
|
||||||
|
// shown exactly once; the server stores only SHA-256(code) for lookup
|
||||||
|
// + an AES-GCM wrap of the current vault key under a KEK derived from
|
||||||
|
// PBKDF2(code, kdfSalt, 600k).
|
||||||
|
//
|
||||||
|
// Recovery flow (master pw forgotten):
|
||||||
|
// 1. Auth screen → "Use recovery code" → enter username + code
|
||||||
|
// 2. Server hashes code, looks up user, verifies match, DELETES the
|
||||||
|
// recovery row (single-use), returns wrapped key + KEK salt +
|
||||||
|
// a fresh session.
|
||||||
|
// 3. Client derives the KEK, unwraps the AES key.
|
||||||
|
// 4. Client immediately forces a master-password change so the
|
||||||
|
// account isn't left with the recovery code's KEK as the only
|
||||||
|
// escape hatch.
|
||||||
|
|
||||||
|
// Random recovery code: 16 chars in 4 groups of 4. ~96 bits entropy
|
||||||
|
// from a 36-char alphabet (no ambiguous chars: no 0/O/I/l/1) so the
|
||||||
|
// printed form is misreading-resistant.
|
||||||
|
function generateRecoveryCode() {
|
||||||
|
const A = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 32 chars
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
if (i > 0 && i % 4 === 0) s += '-';
|
||||||
|
s += A[bytes[i] % A.length];
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256HexLocal(input) {
|
||||||
|
const buf = new TextEncoder().encode(input);
|
||||||
|
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
|
||||||
|
const bytes = new Uint8Array(hashBuf);
|
||||||
|
let hex = '';
|
||||||
|
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive a KEK from the recovery code + per-row salt, then wrap the
|
||||||
|
// supplied AES key bytes under it. Returns base64 ciphertext + IV.
|
||||||
|
async function wrapAesKeyForRecovery(aesKeyBytes, recoveryCode, kdfSaltHex) {
|
||||||
|
const saltBytes = new TextEncoder().encode(kdfSaltHex); // match deriveKey's quirk
|
||||||
|
const km = await crypto.subtle.importKey(
|
||||||
|
'raw', new TextEncoder().encode(recoveryCode),
|
||||||
|
'PBKDF2', false, ['deriveKey']);
|
||||||
|
const kek = await crypto.subtle.deriveKey(
|
||||||
|
{ name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' },
|
||||||
|
km,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false, ['encrypt', 'decrypt']);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, kek, aesKeyBytes);
|
||||||
|
return { wrappedKey: bytesToBase64(ct), wrappedIv: bytesToBase64(iv) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unwrapAesKeyFromRecovery(wrappedKeyB64, wrappedIvB64, recoveryCode, kdfSaltHex) {
|
||||||
|
const saltBytes = new TextEncoder().encode(kdfSaltHex);
|
||||||
|
const km = await crypto.subtle.importKey(
|
||||||
|
'raw', new TextEncoder().encode(recoveryCode),
|
||||||
|
'PBKDF2', false, ['deriveKey']);
|
||||||
|
const kek = await crypto.subtle.deriveKey(
|
||||||
|
{ name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' },
|
||||||
|
km,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false, ['encrypt', 'decrypt']);
|
||||||
|
const iv = base64ToBytes(wrappedIvB64);
|
||||||
|
const ct = base64ToBytes(wrappedKeyB64);
|
||||||
|
return await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, ct); // raw bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new recovery key for the logged-in user. Shows the plaintext
|
||||||
|
// code in a modal that the user must explicitly acknowledge before closing.
|
||||||
|
async function doGenerateRecoveryKey() {
|
||||||
|
if (!state.cryptoKey) { return toast('Vault locked', 'warning'); }
|
||||||
|
|
||||||
|
const masterPwd = await askReauth(
|
||||||
|
'Confirm your master password to generate a recovery key.');
|
||||||
|
if (!masterPwd) return;
|
||||||
|
|
||||||
|
// Generate the code + a fresh per-row salt for the KEK PBKDF2. Salt is
|
||||||
|
// per-recovery so regenerating doesn't reuse the same KDF parameters.
|
||||||
|
const code = generateRecoveryCode();
|
||||||
|
const codeHash = await sha256HexLocal(code);
|
||||||
|
const kdfSalt = randomHexSalt();
|
||||||
|
|
||||||
|
// Export the current AES key as raw bytes so we can wrap it under
|
||||||
|
// the recovery KEK. The export only works because deriveKey was
|
||||||
|
// called with `extractable=true` — already the case in our code.
|
||||||
|
const rawKey = await crypto.subtle.exportKey('raw', state.cryptoKey);
|
||||||
|
const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery(
|
||||||
|
rawKey, code, kdfSalt);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('/recovery-key/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({
|
||||||
|
masterPassword: masterPwd,
|
||||||
|
codeHash: codeHash,
|
||||||
|
kdfSalt: kdfSalt,
|
||||||
|
wrappedKey: wrappedKey,
|
||||||
|
wrappedIv: wrappedIv,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return toast('Setup failed: ' + (err.message || ''), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the Settings button label
|
||||||
|
state.recoveryConfigured = true;
|
||||||
|
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
|
||||||
|
|
||||||
|
// Show the code ONCE. Use the confirm modal so the user has to
|
||||||
|
// explicitly click "I saved it" before the value vanishes.
|
||||||
|
await confirmDialog({
|
||||||
|
title: 'Your recovery code',
|
||||||
|
message: '<p>Save this code somewhere safe (password manager, ' +
|
||||||
|
'safe deposit box, printed copy). It will <b>not</b> be ' +
|
||||||
|
'shown again.</p>' +
|
||||||
|
'<p style="font-family:JetBrains Mono,monospace;font-size:20px;' +
|
||||||
|
'letter-spacing:2px;text-align:center;padding:14px;' +
|
||||||
|
'background:var(--bg-elev);border-radius:6px;user-select:all">' +
|
||||||
|
code + '</p>' +
|
||||||
|
'<p style="color:var(--text-dim);font-size:12px">' +
|
||||||
|
'Using it later will let you recover access if you forget ' +
|
||||||
|
'your master password. The code is single-use.</p>',
|
||||||
|
okText: 'I saved it',
|
||||||
|
});
|
||||||
|
toast('Recovery code generated');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRemoveRecoveryKey() {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: 'Remove recovery key',
|
||||||
|
message: 'You will lose your ability to recover this account if you ' +
|
||||||
|
'forget the master password. Continue?',
|
||||||
|
okText: 'Remove',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api('/recovery-key', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
state.recoveryConfigured = false;
|
||||||
|
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
|
||||||
|
toast('Recovery key removed');
|
||||||
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRecoveryStatusLabel() {
|
||||||
|
const lbl = $('#recoveryStatus');
|
||||||
|
const setupBtn = $('#recoverySetupBtn');
|
||||||
|
const removeBtn = $('#recoveryRemoveBtn');
|
||||||
|
if (!lbl) return;
|
||||||
|
if (state.recoveryConfigured) {
|
||||||
|
lbl.textContent = 'Recovery key is configured.';
|
||||||
|
if (setupBtn) setupBtn.textContent = 'Regenerate code';
|
||||||
|
if (removeBtn) removeBtn.style.display = '';
|
||||||
|
} else {
|
||||||
|
lbl.textContent = 'No recovery key set.';
|
||||||
|
if (setupBtn) setupBtn.textContent = 'Generate recovery code';
|
||||||
|
if (removeBtn) removeBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRecoveryStatus() {
|
||||||
|
try {
|
||||||
|
const r = await api('/recovery-key/status', { headers: authHeaders() });
|
||||||
|
state.recoveryConfigured = !!r.configured;
|
||||||
|
updateRecoveryStatusLabel();
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recovery redeem flow — called from the auth screen when the user clicks
|
||||||
|
// "Use a recovery code". Prompts for username + code, redeems, unwraps the
|
||||||
|
// vault key, immediately forces a master password change.
|
||||||
|
async function doRecoveryRedeem() {
|
||||||
|
const u = await promptDialog({
|
||||||
|
title: 'Recover access',
|
||||||
|
message: 'Enter your username — we\'ll ask for the recovery code next.',
|
||||||
|
placeholder: 'Username',
|
||||||
|
okText: 'Continue',
|
||||||
|
});
|
||||||
|
if (!u) return;
|
||||||
|
const code = await promptDialog({
|
||||||
|
title: 'Enter recovery code',
|
||||||
|
message: 'Recovery codes look like XXXX-XXXX-XXXX-XXXX. ' +
|
||||||
|
'They\'re single-use — using one will remove it from your account.',
|
||||||
|
placeholder: 'XXXX-XXXX-XXXX-XXXX',
|
||||||
|
okText: 'Recover',
|
||||||
|
password: true,
|
||||||
|
});
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await api('/recovery-key/redeem', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: u.trim(), code: code.trim() }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 429 && err.body && err.body.retry_after) {
|
||||||
|
return showLockoutCountdown(err.body.retry_after);
|
||||||
|
}
|
||||||
|
return toast('Recovery failed: ' + (err.message || 'invalid code'), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap the vault key with the code the user just typed.
|
||||||
|
let rawKey;
|
||||||
|
try {
|
||||||
|
rawKey = await unwrapAesKeyFromRecovery(
|
||||||
|
r.wrappedKey, r.wrappedIv, code.trim(), r.kdfSalt);
|
||||||
|
} catch (e) {
|
||||||
|
return toast('Could not decrypt vault — wrong code?', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstitute state from the new session.
|
||||||
|
state.token = r.token;
|
||||||
|
state.csrf = r.csrfToken;
|
||||||
|
state.salt = r.salt;
|
||||||
|
state.username = u.trim();
|
||||||
|
sessionStorage.setItem('authToken', state.token);
|
||||||
|
sessionStorage.setItem('csrfToken', state.csrf);
|
||||||
|
sessionStorage.setItem('salt', state.salt);
|
||||||
|
sessionStorage.setItem('username', state.username);
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
state.cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
|
||||||
|
await persistCryptoKey();
|
||||||
|
|
||||||
|
toast('Access recovered — please set a new master password');
|
||||||
|
await enterApp();
|
||||||
|
|
||||||
|
// Force a master pw change immediately. The recovery code is consumed
|
||||||
|
// (server deleted the row); the account is currently orphaned from
|
||||||
|
// a "we know who you are" perspective. Setting a new master pw both
|
||||||
|
// restores normal login AND lets the user generate a fresh recovery
|
||||||
|
// code afterwards.
|
||||||
|
setTimeout(openChangeMasterModal, 300);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// CHANGE MASTER PASSWORD
|
// CHANGE MASTER PASSWORD
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -3044,6 +3294,8 @@ function openSettings() {
|
|||||||
$('#settingMaskUser').checked = state.maskUsernames;
|
$('#settingMaskUser').checked = state.maskUsernames;
|
||||||
$('#settingHIBP').checked = state.hibpEnabled;
|
$('#settingHIBP').checked = state.hibpEnabled;
|
||||||
$('#settingUser').textContent = state.username;
|
$('#settingUser').textContent = state.username;
|
||||||
|
// Async: query server for recovery key state and update the label
|
||||||
|
refreshRecoveryStatus();
|
||||||
$('#settingsPanel').classList.add('is-open');
|
$('#settingsPanel').classList.add('is-open');
|
||||||
}
|
}
|
||||||
function closeSettings() {
|
function closeSettings() {
|
||||||
@@ -3337,6 +3589,11 @@ async function init() {
|
|||||||
$$('#changeMasterModal [data-close]').forEach(b =>
|
$$('#changeMasterModal [data-close]').forEach(b =>
|
||||||
b.addEventListener('click', closeChangeMasterModal));
|
b.addEventListener('click', closeChangeMasterModal));
|
||||||
|
|
||||||
|
// Recovery key
|
||||||
|
$('#recoverySetupBtn').addEventListener('click', doGenerateRecoveryKey);
|
||||||
|
$('#recoveryRemoveBtn').addEventListener('click', doRemoveRecoveryKey);
|
||||||
|
$('#recoveryBtn').addEventListener('click', doRecoveryRedeem);
|
||||||
|
|
||||||
// Re-auth modal
|
// Re-auth modal
|
||||||
$('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); });
|
$('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); });
|
||||||
$$('#reauthModal [data-close]').forEach(b => b.addEventListener('click', () => closeReauth(false)));
|
$$('#reauthModal [data-close]').forEach(b => b.addEventListener('click', () => closeReauth(false)));
|
||||||
|
|||||||
Reference in New Issue
Block a user