05/17 enhance codes

This commit is contained in:
Nguyen Ngo
2026-05-17 18:29:13 -04:00
parent eee5f62689
commit afc3250c48
6 changed files with 246 additions and 52 deletions
+45 -34
View File
@@ -1,7 +1,7 @@
import re
import time
from flask import Blueprint, request, jsonify, g, session
from flask import Blueprint, request, jsonify, g
from app import db, limiter, client_ip
from app.models.user import User
from app.models.audit_log import AuditLog
@@ -738,10 +738,13 @@ def recover_account():
4. Client derives new credentials and re-encrypts all vault items.
5. Client POSTs everything here in one atomic payload.
The server validates recovery_proof against the value precomputed during
/recovery/data — enc_key_salt is never sent in plaintext.
The nonce is consumed on first use to prevent replay.
The server validates recovery_proof against the value stored in the DB
during /recovery/data — enc_key_salt is never sent in plaintext.
The challenge row is consumed (deleted) on first use to prevent replay.
Challenge state is stored in the database, not the Flask session, so the
flow works correctly across all Gunicorn workers.
"""
from app.models.recovery_challenge import RecoveryChallenge
data = request.get_json(silent=True) or {}
email = (data.get('email') or '').strip().lower()
new_auth_hash = data.get('new_auth_hash', '')
@@ -754,18 +757,17 @@ def recover_account():
time.sleep(0.1) # timing mitigation
# Validate session binding.
expected_proof = session.get('recovery_expected_proof', '')
session_user_id = session.get('recovery_user_id')
if not expected_proof or not session_user_id:
return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400
user = User.query.filter_by(email=email).first()
if not user or not user.recovery_enc_salt or user.id != session_user_id:
if not user or not user.recovery_enc_salt:
return jsonify({'error': 'No recovery code found for this account'}), 404
if not verify_recovery_proof(expected_proof, client_proof):
# Consume the challenge — atomic read-and-delete from the DB.
# consume() returns None if the challenge is missing or expired.
challenge = RecoveryChallenge.consume(user.id)
if not challenge:
return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400
if not verify_recovery_proof(challenge.expected_proof, client_proof):
AuditLog.log(
user_id=user.id,
action='auth.recovery_failed',
@@ -777,10 +779,6 @@ def recover_account():
db.session.commit()
return jsonify({'error': 'Invalid recovery code'}), 401
# Consume the nonce — one-time use only.
session.pop('recovery_nonce', None)
session.pop('recovery_expected_proof', None)
session.pop('recovery_user_id', None)
try:
from app.models.vault_item import VaultItem
@@ -847,12 +845,17 @@ def recovery_data():
The nonce is used for the HMAC-SHA256 challenge-response proof:
- Client decrypts recovery_enc_salt → gets enc_key_salt bytes.
- Client computes: proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce)
- Server stores expected proof in server-side session at challenge time,
- Server stores expected proof in the DB (recovery_challenges table),
verifying it on /recover and /recovery/items without ever receiving
enc_key_salt in plaintext.
Returns 404 if no recovery code is configured (prevents user enumeration).
The challenge is stored in the database (not the Flask session cookie) so
it works correctly across all Gunicorn workers.
"""
import hashlib, hmac as _hmac
from app.models.recovery_challenge import RecoveryChallenge
email = (request.args.get('email') or '').strip().lower()
if not email:
return jsonify({'error': 'email is required'}), 400
@@ -865,7 +868,6 @@ def recovery_data():
# enc_key_salt. The client must return HMAC-SHA256(enc_key_salt, nonce).
# This proves it decrypted the recovery blob correctly without sending
# enc_key_salt in plaintext.
import hashlib, hmac as _hmac
nonce = generate_recovery_nonce()
expected_proof = _hmac.new(
user.enc_key_salt.encode(),
@@ -873,10 +875,15 @@ def recovery_data():
hashlib.sha256,
).hexdigest()
# Store expected proof and bind it to the user — consumed on first use.
session['recovery_nonce'] = nonce
session['recovery_expected_proof'] = expected_proof
session['recovery_user_id'] = user.id
# Persist challenge in the DB — safe across all Gunicorn workers.
# RecoveryChallenge.create() deletes any previous challenge for this user
# before inserting, so a re-issued challenge always starts fresh.
RecoveryChallenge.create(
user_id=user.id,
nonce=nonce,
expected_proof=expected_proof,
)
db.session.commit()
return jsonify({
'enc_key_salt': user.enc_key_salt,
@@ -895,28 +902,32 @@ def recovery_items():
Requires X-Recovery-Proof header containing the HMAC-SHA256 proof:
proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_from_recovery_data)
The server validates the proof against the value precomputed and stored in
flask.session during /recovery/data — enc_key_salt is never sent in plaintext.
The server validates the proof against the value stored in the DB
during /recovery/data — enc_key_salt is never sent in plaintext.
The challenge row is NOT consumed here; it is consumed by the final
POST /recover call so the client can call this endpoint to preview
items before committing the full re-encryption.
Items are returned as encrypted ciphertext blobs only.
"""
from app.models.recovery_challenge import RecoveryChallenge
email = (request.args.get('email') or '').strip().lower()
client_proof = request.headers.get('X-Recovery-Proof', '').strip()
if not email or not client_proof:
return jsonify({'error': 'email and X-Recovery-Proof header are required'}), 400
# Validate session binding: proof must match what was issued to this session.
expected_proof = session.get('recovery_expected_proof', '')
session_user_id = session.get('recovery_user_id')
if not expected_proof or not session_user_id:
return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400
user = User.query.filter_by(email=email).first()
if not user or not user.recovery_enc_salt or user.id != session_user_id:
if not user or not user.recovery_enc_salt:
return jsonify({'error': 'No recovery data found'}), 404
if not verify_recovery_proof(expected_proof, client_proof):
# Validate against the DB-stored challenge — safe across all workers.
challenge = RecoveryChallenge.query.filter_by(user_id=user.id).first()
from datetime import datetime, timezone
if not challenge or challenge.expires_at < datetime.now(timezone.utc).replace(tzinfo=None):
return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400
if not verify_recovery_proof(challenge.expected_proof, client_proof):
AuditLog.log(
user_id=user.id,
action='auth.recovery_items_denied',