diff --git a/app/routes/vault.py b/app/routes/vault.py index 0c84e54..80aa837 100644 --- a/app/routes/vault.py +++ b/app/routes/vault.py @@ -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, diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 752b6d9..46289c2 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -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)