05/02/2026 updated code for security

This commit is contained in:
2026-05-02 15:30:33 -04:00
parent 9af7435cab
commit c7b1806ec8
6 changed files with 594 additions and 124 deletions
+120 -2
View File
@@ -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