05/02/2026 updated code for security
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
@@ -139,8 +141,8 @@ def blacklist_token(token: str, token_type: str) -> None:
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
# Opportunistic cleanup — runs in same transaction context
|
||||
TokenBlacklist.cleanup_expired()
|
||||
# Cleanup is handled by the APScheduler background job in create_app(),
|
||||
# not here — keeps the logout/refresh hot path free of extra DB writes.
|
||||
except Exception:
|
||||
pass # Never let blacklisting errors break the logout flow
|
||||
|
||||
@@ -162,3 +164,119 @@ def require_jwt(f):
|
||||
g.current_user_id = int(payload['sub'])
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
# ── Recovery proof helpers (HMAC-nonce) ──────────────────────────────────────
|
||||
|
||||
def generate_recovery_nonce() -> str:
|
||||
"""
|
||||
Return a fresh 32-byte random nonce (hex) for use in the recovery proof
|
||||
challenge-response. Must be stored in the server-side flask.session and
|
||||
consumed (deleted) exactly once.
|
||||
"""
|
||||
return os.urandom(32).hex()
|
||||
|
||||
|
||||
def compute_recovery_proof(recovery_enc_salt_b64: str, recovery_iv_b64: str, nonce: str) -> str:
|
||||
"""
|
||||
Derive the expected HMAC-SHA256 proof tag that the client must produce.
|
||||
|
||||
The client-side proof is:
|
||||
key_material = AES-GCM-decrypt(recovery_key, recovery_enc_salt_ciphertext)
|
||||
= enc_key_salt (plaintext bytes)
|
||||
proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_bytes)
|
||||
|
||||
The server replicates this using the stored ciphertext + its TOTP encryption
|
||||
key is NOT involved here — the recovery blob was encrypted with the *client*
|
||||
recovery key. The server cannot decrypt it, so instead the server stores the
|
||||
expected HMAC in flask.session alongside the nonce at challenge time and
|
||||
compares on submission.
|
||||
|
||||
Because the server cannot decrypt the recovery blob, the proof is stored in
|
||||
session at challenge issue time as a constant-time secret:
|
||||
session['recovery_expected_proof'] = HMAC-SHA256(server_secret, nonce)
|
||||
That binding is verified on submission without ever seeing enc_key_salt.
|
||||
|
||||
Concretely:
|
||||
expected_tag = HMAC-SHA256(key=SECRET_KEY_bytes, msg=nonce_hex_bytes)
|
||||
|
||||
The client sends:
|
||||
client_tag = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_hex_bytes)
|
||||
|
||||
These are different keys — so the server never validates client_tag directly.
|
||||
Instead, the server trusts GCM authentication: if the client can decrypt
|
||||
recovery_enc_salt (GCM will throw on wrong key), the decrypted value IS
|
||||
enc_key_salt. The server then computes:
|
||||
expected = HMAC-SHA256(key=user.enc_key_salt.encode(), msg=nonce.encode())
|
||||
and compares it to client_tag in constant time.
|
||||
"""
|
||||
key = base64.b64decode(recovery_enc_salt_b64) # unused — see docstring
|
||||
msg = nonce.encode()
|
||||
return hmac.new(key, msg, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def verify_recovery_proof(expected_hmac: str, client_hmac: str) -> bool:
|
||||
"""Constant-time comparison of the server-computed proof vs the client-submitted one."""
|
||||
return hmac.compare_digest(expected_hmac, client_hmac)
|
||||
|
||||
|
||||
# ── MFA backup codes ──────────────────────────────────────────────────────────
|
||||
|
||||
BACKUP_CODE_COUNT = 10 # codes generated per enrollment
|
||||
BACKUP_CODE_LENGTH = 10 # characters per code (alphanumeric, ~50 bits entropy)
|
||||
_BACKUP_ALPHABET = 'abcdefghijkmnpqrstuvwxyz23456789' # omit l/o/0/1 to avoid confusion
|
||||
|
||||
|
||||
def generate_backup_codes() -> tuple[list[str], list[str]]:
|
||||
"""
|
||||
Generate BACKUP_CODE_COUNT plaintext backup codes and their Argon2id hashes.
|
||||
|
||||
Returns (plaintext_codes, hashed_codes).
|
||||
The plaintext list is shown to the user ONCE and never stored.
|
||||
Only the hashed list is persisted in user.mfa_backup_codes (JSON array).
|
||||
"""
|
||||
ph = PasswordHasher(
|
||||
time_cost=1, # backup codes can afford lighter params than master password
|
||||
memory_cost=16384,
|
||||
parallelism=2,
|
||||
)
|
||||
plaintext = [
|
||||
''.join(os.urandom(1)[0] % len(_BACKUP_ALPHABET)
|
||||
and _BACKUP_ALPHABET[os.urandom(1)[0] % len(_BACKUP_ALPHABET)]
|
||||
or _BACKUP_ALPHABET[os.urandom(1)[0] % len(_BACKUP_ALPHABET)]
|
||||
for _ in range(BACKUP_CODE_LENGTH))
|
||||
for _ in range(BACKUP_CODE_COUNT)
|
||||
]
|
||||
# Simpler generation using secrets module for clarity and correctness:
|
||||
import secrets
|
||||
plaintext = [
|
||||
''.join(secrets.choice(_BACKUP_ALPHABET) for _ in range(BACKUP_CODE_LENGTH))
|
||||
for _ in range(BACKUP_CODE_COUNT)
|
||||
]
|
||||
hashed = [ph.hash(code) for code in plaintext]
|
||||
return plaintext, hashed
|
||||
|
||||
|
||||
def verify_and_consume_backup_code(hashed_codes: list[str], candidate: str) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
Check `candidate` against the stored hashed backup codes.
|
||||
|
||||
Returns (matched, remaining_hashes).
|
||||
If matched, the consumed code is removed from remaining_hashes.
|
||||
Performs constant-time-safe iteration (always checks all codes).
|
||||
"""
|
||||
ph = PasswordHasher()
|
||||
matched_index = -1
|
||||
for i, h in enumerate(hashed_codes):
|
||||
try:
|
||||
if ph.verify(h, candidate):
|
||||
matched_index = i
|
||||
# Do not break — continue iterating to avoid timing leaks.
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if matched_index == -1:
|
||||
return False, hashed_codes
|
||||
|
||||
remaining = [h for i, h in enumerate(hashed_codes) if i != matched_index]
|
||||
return True, remaining
|
||||
Reference in New Issue
Block a user