05/17 enhance codes
This commit is contained in:
+45
-34
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user