05/16 Enhanced codes

This commit is contained in:
2026-05-16 21:06:27 -04:00
parent fc5dd49d69
commit eee5f62689
2 changed files with 8 additions and 47 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ VALID_TYPES = {t.value for t in ItemType}
@require_jwt
def list_items():
items = VaultItem.query.filter_by(user_id=g.current_user_id).order_by(
VaultItem.name.asc()
VaultItem.created_at.desc()
).all()
return jsonify([item.to_dict() for item in items]), 200
@@ -164,7 +164,7 @@ def export_items():
created_at, updated_at }
"""
items = VaultItem.query.filter_by(user_id=g.current_user_id).order_by(
VaultItem.name.asc()
VaultItem.created_at.desc()
).all()
AuditLog.log(
user_id=g.current_user_id,
+6 -45
View File
@@ -177,42 +177,11 @@ def generate_recovery_nonce() -> str:
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()
# NOTE: compute_recovery_proof() is intentionally absent.
# The server cannot decrypt the recovery blob (it was encrypted client-side with
# the user's recovery key). Instead, the expected HMAC is computed inline in
# the /recovery/data route using user.enc_key_salt as the HMAC key, stored in
# flask.session, and compared on submission via verify_recovery_proof() below.
def verify_recovery_proof(expected_hmac: str, client_hmac: str) -> bool:
@@ -235,20 +204,12 @@ def generate_backup_codes() -> tuple[list[str], list[str]]:
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).
"""
import secrets
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)