import logging import re import time from flask import Blueprint, request, jsonify, g _log = logging.getLogger(__name__) from app import db, limiter, client_ip from app.models.user import User from app.models.audit_log import AuditLog from app.services.auth_service import ( hash_auth_token, verify_auth_token, generate_tokens, generate_mfa_token, decode_token, blacklist_token, require_jwt, encrypt_totp_secret, decrypt_totp_secret, generate_recovery_nonce, verify_recovery_proof, generate_backup_codes, verify_and_consume_backup_code, is_totp_code_used, mark_totp_code_used, ) auth_bp = Blueprint('auth', __name__) EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') @auth_bp.route('/register', methods=['POST']) @limiter.limit('10 per minute') def register(): data = request.get_json(silent=True) or {} email = (data.get('email') or '').strip().lower() auth_hash = data.get('auth_hash', '') enc_key_salt = data.get('enc_key_salt', '') if not email or not EMAIL_RE.match(email): return jsonify({'error': 'Invalid email address'}), 400 if len(email) > 254: return jsonify({'error': 'Email address is too long'}), 400 if not auth_hash: return jsonify({'error': 'auth_hash is required'}), 400 if not enc_key_salt: return jsonify({'error': 'enc_key_salt is required'}), 400 if User.query.filter_by(email=email).first(): return jsonify({'error': 'Email already registered'}), 409 master_hash = hash_auth_token(auth_hash) user = User(email=email, master_hash=master_hash, enc_key_salt=enc_key_salt) db.session.add(user) db.session.flush() # populate user.id before logging AuditLog.log( user_id=user.id, action='auth.register', resource_type='user', resource_id=user.id, detail=f'New account registered: {email}', ip_address=client_ip(), ) db.session.commit() return jsonify({'message': 'Account created successfully'}), 201 @auth_bp.route('/login', methods=['POST']) @limiter.limit('10 per minute') def login(): from datetime import datetime, timezone, timedelta from sqlalchemy.exc import OperationalError # Number of consecutive failures before a temporary lockout is applied. MAX_FAILED_LOGINS = 5 LOCKOUT_MINUTES = 15 data = request.get_json(silent=True) or {} email = (data.get('email') or '').strip().lower() auth_hash = data.get('auth_hash', '') time.sleep(0.1) # mitigate timing-based user enumeration if not email or not auth_hash: return jsonify({'error': 'Email and auth_hash are required'}), 400 user = User.query.filter_by(email=email).first() # Per-account lockout check. # Guarded with try/except so that a deployment where the migration has not # yet been run (columns missing) degrades gracefully instead of returning # an HTML 500 page that breaks JSON parsing in the extension. try: if user and user.locked_until: now = datetime.now(timezone.utc).replace(tzinfo=None) if user.locked_until > now: remaining = int((user.locked_until - now).total_seconds() // 60) + 1 AuditLog.log( user_id=user.id, action='auth.login_blocked', resource_type='user', resource_id=user.id, detail=f'Login blocked — account locked for {remaining} more minute(s)', ip_address=client_ip(), ) db.session.commit() return jsonify({ 'error': f'Account temporarily locked. Try again in {remaining} minute(s).' }), 429 else: # Lockout has expired — reset the counter. user.failed_login_count = 0 user.locked_until = None except OperationalError: # Columns do not exist yet — migration pending. Skip lockout check. db.session.rollback() if not user or not verify_auth_token(auth_hash, user.master_hash, user=user): if user: try: user.failed_login_count = (user.failed_login_count or 0) + 1 if user.failed_login_count >= MAX_FAILED_LOGINS: user.locked_until = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(minutes=LOCKOUT_MINUTES) AuditLog.log( user_id=user.id, action='auth.account_locked', resource_type='user', resource_id=user.id, detail=f'Account locked for {LOCKOUT_MINUTES} minutes after {user.failed_login_count} failed attempts', ip_address=client_ip(), ) else: AuditLog.log( user_id=user.id, action='auth.login_failed', resource_type='user', resource_id=user.id, detail=f'Failed login attempt — invalid password ({user.failed_login_count}/{MAX_FAILED_LOGINS})', ip_address=client_ip(), ) db.session.commit() except OperationalError: db.session.rollback() AuditLog.log( user_id=user.id, action='auth.login_failed', resource_type='user', resource_id=user.id, detail='Failed login attempt — invalid password', ip_address=client_ip(), ) db.session.commit() return jsonify({'error': 'Invalid email or password'}), 401 # Successful authentication — reset lockout state. try: user.failed_login_count = 0 user.locked_until = None except OperationalError: db.session.rollback() user.last_login = datetime.now(timezone.utc).replace(tzinfo=None) AuditLog.log( user_id=user.id, action='auth.login', resource_type='user', resource_id=user.id, detail=f'Successful login{" (MFA pending)" if user.totp_enabled else ""}', ip_address=client_ip(), ) db.session.commit() # MFA gate: if enabled, issue a short-lived mfa_token instead of full tokens if user.totp_enabled: mfa_token = generate_mfa_token(user.id) return jsonify({ 'mfa_required': True, 'mfa_token': mfa_token, 'enc_key_salt': user.enc_key_salt, }), 200 tokens = generate_tokens(user.id) return jsonify({ 'access_token': tokens['access_token'], 'refresh_token': tokens['refresh_token'], 'enc_key_salt': user.enc_key_salt, }), 200 @auth_bp.route('/logout', methods=['POST']) @limiter.limit('60 per minute') def logout(): """Blacklist both the access token (from header) and refresh token (from body).""" auth_header = request.headers.get('Authorization', '') if auth_header.startswith('Bearer '): blacklist_token(auth_header[7:], 'access') data = request.get_json(silent=True) or {} refresh_token = data.get('refresh_token', '') if refresh_token: blacklist_token(refresh_token, 'refresh') return jsonify({'message': 'Logged out'}), 200 @auth_bp.route('/refresh', methods=['POST']) @limiter.limit('30 per minute') def refresh(): data = request.get_json(silent=True) or {} refresh_token = data.get('refresh_token', '') if not refresh_token: return jsonify({'error': 'refresh_token is required'}), 400 try: payload = decode_token(refresh_token, expected_type='refresh') except Exception: return jsonify({'error': 'Invalid or expired refresh token'}), 401 # Rotate: blacklist old refresh token and issue fresh pair blacklist_token(refresh_token, 'refresh') tokens = generate_tokens(int(payload['sub'])) return jsonify({ 'access_token': tokens['access_token'], 'refresh_token': tokens['refresh_token'], }), 200 # ── MFA / TOTP endpoints ───────────────────────────────────────────────────── @auth_bp.route('/mfa/setup', methods=['GET']) @limiter.limit('10 per minute') @require_jwt def mfa_setup(): """Generate a new TOTP secret and return QR code (as base64 PNG data URI).""" user = db.session.get(User, g.current_user_id) if user.totp_enabled: return jsonify({'error': 'MFA is already enabled'}), 400 import pyotp import qrcode import io import base64 secret = pyotp.random_base32() uri = pyotp.TOTP(secret).provisioning_uri( name=user.email, issuer_name='PassKeeper', ) img = qrcode.make(uri) buf = io.BytesIO() img.save(buf, format='PNG') qr_b64 = base64.b64encode(buf.getvalue()).decode() return jsonify({ 'secret': secret, 'qr_code': f'data:image/png;base64,{qr_b64}', 'uri': uri, }), 200 @auth_bp.route('/mfa/enable', methods=['POST']) @limiter.limit('10 per minute') @require_jwt def mfa_enable(): """Enable MFA after verifying the first TOTP code. Returns one-time backup codes.""" import json user = db.session.get(User, g.current_user_id) if user.totp_enabled: return jsonify({'error': 'MFA is already enabled'}), 400 data = request.get_json(silent=True) or {} secret = (data.get('secret') or '').strip() totp_code = (data.get('totp_code') or '').strip() if not secret or not totp_code: return jsonify({'error': 'secret and totp_code are required'}), 400 import pyotp if not pyotp.TOTP(secret).verify(totp_code, valid_window=1): return jsonify({'error': 'Invalid verification code'}), 400 # Encrypt the secret before replay check so totp_secret_enc/totp_iv are defined. totp_secret_enc, totp_iv = encrypt_totp_secret(secret) # Prevent replay: reject a code that was already consumed within the valid window. # user.id is not yet persisted (MFA not enabled), so use g.current_user_id directly. if is_totp_code_used(g.current_user_id, totp_code): return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400 mark_totp_code_used(g.current_user_id, totp_code) user.totp_secret = totp_secret_enc user.totp_iv = totp_iv user.totp_enabled = True # Generate one-time backup codes — plaintext shown once, only hashes stored. plaintext_codes, hashed_codes = generate_backup_codes() user.mfa_backup_codes = json.dumps(hashed_codes) AuditLog.log( user_id=user.id, action='auth.mfa_enable', resource_type='user', resource_id=user.id, detail='TOTP two-factor authentication enabled; backup codes generated', ip_address=client_ip(), ) db.session.commit() return jsonify({ 'message': 'MFA enabled successfully', 'backup_codes': plaintext_codes, }), 200 @auth_bp.route('/mfa/disable', methods=['POST']) @limiter.limit('10 per minute') @require_jwt def mfa_disable(): """Disable MFA after verifying the current TOTP code or a backup code.""" import json user = db.session.get(User, g.current_user_id) if not user.totp_enabled: return jsonify({'error': 'MFA is not enabled'}), 400 data = request.get_json(silent=True) or {} totp_code = (data.get('totp_code') or '').strip() backup_code = (data.get('backup_code') or '').strip().lower().replace('-', '').replace(' ', '') import pyotp plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv) verified = False if totp_code: if is_totp_code_used(user.id, totp_code): return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400 verified = pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1) if verified: mark_totp_code_used(user.id, totp_code) elif backup_code: stored = json.loads(user.mfa_backup_codes or '[]') matched, remaining = verify_and_consume_backup_code(stored, backup_code) if matched: user.mfa_backup_codes = json.dumps(remaining) verified = True if not verified: return jsonify({'error': 'Invalid verification code'}), 400 user.totp_secret = None user.totp_iv = None user.totp_enabled = False user.mfa_backup_codes = None AuditLog.log( user_id=user.id, action='auth.mfa_disable', resource_type='user', resource_id=user.id, detail='TOTP two-factor authentication disabled; backup codes cleared', ip_address=client_ip(), ) db.session.commit() return jsonify({'message': 'MFA disabled'}), 200 @auth_bp.route('/mfa/verify', methods=['POST']) @limiter.limit('10 per minute') def mfa_verify(): """Complete MFA login: verify TOTP code (or backup code) and exchange mfa_token for real tokens.""" import json data = request.get_json(silent=True) or {} mfa_token = data.get('mfa_token', '') totp_code = (data.get('totp_code') or '').strip() backup_code = (data.get('backup_code') or '').strip().lower().replace('-', '').replace(' ', '') if not mfa_token or (not totp_code and not backup_code): return jsonify({'error': 'mfa_token and either totp_code or backup_code are required'}), 400 try: payload = decode_token(mfa_token, expected_type='mfa', check_blacklist=True) except Exception: return jsonify({'error': 'Invalid or expired MFA token'}), 401 user = db.session.get(User, int(payload['sub'])) if not user or not user.totp_enabled: return jsonify({'error': 'MFA not configured for this account'}), 400 import pyotp plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv) verified = False if totp_code: if is_totp_code_used(user.id, totp_code): return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400 verified = pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1) if verified: mark_totp_code_used(user.id, totp_code) if not verified and backup_code: stored = json.loads(user.mfa_backup_codes or '[]') matched, remaining = verify_and_consume_backup_code(stored, backup_code) if matched: user.mfa_backup_codes = json.dumps(remaining) verified = True AuditLog.log( user_id=user.id, action='auth.mfa_backup_code_used', resource_type='user', resource_id=user.id, detail=f'MFA backup code used; {len(remaining)} code(s) remaining', ip_address=client_ip(), ) if not verified: return jsonify({'error': 'Invalid verification code'}), 400 # One-time use: blacklist the mfa_token blacklist_token(mfa_token, 'mfa') AuditLog.log( user_id=user.id, action='auth.mfa_verify', resource_type='user', resource_id=user.id, detail='MFA verification successful — session tokens issued', ip_address=client_ip(), ) db.session.commit() tokens = generate_tokens(user.id) return jsonify({ 'access_token': tokens['access_token'], 'refresh_token': tokens['refresh_token'], }), 200 @auth_bp.route('/mfa/status', methods=['GET']) @limiter.limit('60 per minute') @require_jwt def mfa_status(): user = db.session.get(User, g.current_user_id) import json stored = json.loads(user.mfa_backup_codes or '[]') return jsonify({ 'totp_enabled': user.totp_enabled, 'backup_codes_remaining': len(stored), }), 200 @auth_bp.route('/mfa/backup-codes/regenerate', methods=['POST']) @limiter.limit('5 per minute') @require_jwt def mfa_backup_codes_regenerate(): """ Regenerate MFA backup codes. Requires a valid TOTP code to authorise. All existing backup codes are invalidated and replaced. Returns the new plaintext codes — shown once, never stored. """ import json user = db.session.get(User, g.current_user_id) if not user.totp_enabled: return jsonify({'error': 'MFA is not enabled'}), 400 data = request.get_json(silent=True) or {} totp_code = (data.get('totp_code') or '').strip() if not totp_code: return jsonify({'error': 'totp_code is required'}), 400 import pyotp plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv) if is_totp_code_used(user.id, totp_code): return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400 if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1): return jsonify({'error': 'Invalid verification code'}), 400 mark_totp_code_used(user.id, totp_code) plaintext_codes, hashed_codes = generate_backup_codes() user.mfa_backup_codes = json.dumps(hashed_codes) AuditLog.log( user_id=user.id, action='auth.mfa_backup_codes_regenerated', resource_type='user', resource_id=user.id, detail='MFA backup codes regenerated — previous codes invalidated', ip_address=client_ip(), ) db.session.commit() return jsonify({ 'message': 'Backup codes regenerated. Save these — they will not be shown again.', 'backup_codes': plaintext_codes, }), 200 @auth_bp.route('/me', methods=['GET']) @limiter.limit('60 per minute') @require_jwt def me(): """Return basic profile info for the authenticated user.""" import json user = db.session.get(User, g.current_user_id) stored_codes = json.loads(user.mfa_backup_codes or '[]') return jsonify({ 'id': user.id, 'email': user.email, 'created_at': user.created_at.isoformat() if user.created_at else None, 'last_login': user.last_login.isoformat() if user.last_login else None, 'totp_enabled': user.totp_enabled, 'backup_codes_remaining': len(stored_codes), 'recovery_configured': bool(user.recovery_enc_salt), }), 200 @auth_bp.route('/audit-log', methods=['GET']) @require_jwt @limiter.limit('30 per minute') def audit_log(): """ Return the authenticated user's recent audit log entries. Query params: limit — max entries to return (default 50, max 200) offset — pagination offset (default 0) Sensitive field values are never logged — entries contain only action types, resource IDs, timestamps, and IP addresses. """ try: limit = min(int(request.args.get('limit', 50)), 200) offset = max(int(request.args.get('offset', 0)), 0) except (ValueError, TypeError): return jsonify({'error': 'limit and offset must be integers'}), 400 entries = ( AuditLog.query .filter_by(user_id=g.current_user_id) .order_by(AuditLog.created_at.desc()) .limit(limit) .offset(offset) .all() ) total = AuditLog.query.filter_by(user_id=g.current_user_id).count() return jsonify({ 'total': total, 'limit': limit, 'offset': offset, 'entries': [e.to_dict() for e in entries], }), 200 # ── Account management ──────────────────────────────────────────────────────── @auth_bp.route('/change-password', methods=['POST']) @limiter.limit('5 per minute') @require_jwt def change_password(): """ Change master password — zero-knowledge atomic re-encryption. The client must: 1. Derive current auth_hash and verify it locally against what it knows. 2. Re-encrypt every vault item with the new vault key client-side. 3. POST the new credentials + all re-encrypted item blobs in one request. The server verifies the current password, updates master_hash + enc_key_salt, and bulk-replaces all vault item ciphertexts atomically. If any step fails, the entire transaction is rolled back — the vault is never left in a split state. """ data = request.get_json(silent=True) or {} current_auth_hash = data.get('current_auth_hash', '') new_auth_hash = data.get('new_auth_hash', '') new_enc_key_salt = data.get('new_enc_key_salt', '') items = data.get('items', []) # [{id, enc_data, iv, enc_name?, iv_name?}, ...] sharing_private_key_enc = data.get('sharing_private_key_enc', '') sharing_private_key_iv = data.get('sharing_private_key_iv', '') if not current_auth_hash or not new_auth_hash or not new_enc_key_salt: return jsonify({'error': 'current_auth_hash, new_auth_hash, and new_enc_key_salt are required'}), 400 user = db.session.get(User, g.current_user_id) if not verify_auth_token(current_auth_hash, user.master_hash, user=user): AuditLog.log( user_id=user.id, action='auth.change_password_failed', resource_type='user', resource_id=user.id, detail='Password change rejected — current password incorrect', ip_address=client_ip(), ) db.session.commit() return jsonify({'error': 'Current password is incorrect'}), 401 try: from app.models.vault_item import VaultItem # Bulk-update all vault item ciphertexts with new vault key encryption item_ids = [i.get('id') for i in items if i.get('id')] existing = { v.id: v for v in VaultItem.query.filter( VaultItem.user_id == user.id, VaultItem.id.in_(item_ids), ).all() } if item_ids else {} for item_data in items: item_id = item_data.get('id') enc_data = item_data.get('enc_data', '') iv = item_data.get('iv', '') if not item_id or not enc_data or not iv: continue vault_item = existing.get(item_id) if vault_item: vault_item.enc_data = enc_data vault_item.iv = iv # Re-encrypt the name ciphertext if the client sent updated enc_name/iv_name. if item_data.get('enc_name'): vault_item.enc_name = item_data['enc_name'] if item_data.get('iv_name'): vault_item.iv_name = item_data['iv_name'] # Update credentials user.master_hash = hash_auth_token(new_auth_hash) user.enc_key_salt = new_enc_key_salt # Clear recovery data — it was encrypted with the old vault key and is now invalid user.recovery_enc_salt = None user.recovery_iv = None # Re-encrypt sharing private key with new vault key if the client sent it. # Without this update, the old ciphertext would be undecryptable after key rotation. if sharing_private_key_enc and sharing_private_key_iv: user.sharing_private_key_enc = sharing_private_key_enc user.sharing_private_key_iv = sharing_private_key_iv AuditLog.log( user_id=user.id, action='auth.change_password', resource_type='user', resource_id=user.id, detail=f'Master password changed; {len(existing)} vault item(s) re-encrypted; recovery code cleared', ip_address=client_ip(), ) db.session.commit() except Exception: db.session.rollback() _log.exception('change_password failed for user %s', g.current_user_id) return jsonify({'error': 'Password change failed. Please try again.'}), 500 return jsonify({'message': 'Password changed successfully. Please log in again.'}), 200 @auth_bp.route('/account', methods=['DELETE']) @limiter.limit('3 per minute') @require_jwt def delete_account(): """ Permanently delete the authenticated user's account and all associated data. Requires the current auth_hash for confirmation. Cascading deletes handle vault_items, folders, shared_items, emergency_access. """ data = request.get_json(silent=True) or {} auth_hash = data.get('auth_hash', '') if not auth_hash: return jsonify({'error': 'auth_hash is required for account deletion'}), 400 user = db.session.get(User, g.current_user_id) if not verify_auth_token(auth_hash, user.master_hash): AuditLog.log( user_id=user.id, action='auth.delete_account_failed', resource_type='user', resource_id=user.id, detail='Account deletion rejected — password incorrect', ip_address=client_ip(), ) db.session.commit() return jsonify({'error': 'Incorrect password'}), 401 user_id = user.id user_email = user.email try: # Log before delete (user row will be gone after commit) AuditLog.log( user_id=user_id, action='auth.delete_account', resource_type='user', resource_id=user_id, detail=f'Account permanently deleted: {user_email}', ip_address=client_ip(), ) db.session.delete(user) db.session.commit() except Exception: db.session.rollback() _log.exception('delete_account failed for user %s', user_id) return jsonify({'error': 'Account deletion failed. Please try again.'}), 500 return jsonify({'message': 'Account deleted'}), 200 # ── Account Recovery ────────────────────────────────────────────────────────── @auth_bp.route('/recovery/setup', methods=['POST']) @limiter.limit('10 per minute') @require_jwt def recovery_setup(): """ Store a recovery-key-encrypted copy of enc_key_salt. The client generates a random 128-bit recovery code, derives a recovery key from it (PBKDF2), encrypts enc_key_salt with that key (AES-256-GCM), and sends the ciphertext + iv. The server stores these blobs — it never sees the recovery code or enc_key_salt plaintext. The recovery code is displayed to the user once and never stored server-side. """ data = request.get_json(silent=True) or {} recovery_enc_salt = data.get('recovery_enc_salt', '').strip() recovery_iv = data.get('recovery_iv', '').strip() if not recovery_enc_salt or not recovery_iv: return jsonify({'error': 'recovery_enc_salt and recovery_iv are required'}), 400 user = db.session.get(User, g.current_user_id) user.recovery_enc_salt = recovery_enc_salt user.recovery_iv = recovery_iv AuditLog.log( user_id=user.id, action='auth.recovery_setup', resource_type='user', resource_id=user.id, detail='Account recovery code configured', ip_address=client_ip(), ) db.session.commit() return jsonify({'message': 'Recovery code saved'}), 200 @auth_bp.route('/recovery/status', methods=['GET']) @limiter.limit('60 per minute') @require_jwt def recovery_status(): """Return whether the user has a recovery code configured.""" user = db.session.get(User, g.current_user_id) return jsonify({'recovery_configured': bool(user.recovery_enc_salt)}), 200 @auth_bp.route('/recover', methods=['POST']) @limiter.limit('5 per minute') def recover_account(): """ Recover account access using a recovery code. Flow: 1. Client calls /recovery/data → receives enc_key_salt, recovery blobs, nonce. 2. Client decrypts recovery_enc_salt using the recovery key → gets enc_key_salt. 3. Client computes: recovery_proof = HMAC-SHA256(enc_key_salt_bytes, nonce). 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 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', '') new_enc_key_salt = data.get('new_enc_key_salt', '') client_proof = data.get('recovery_proof', '') items = data.get('items', []) if not all([email, new_auth_hash, new_enc_key_salt, client_proof]): return jsonify({'error': 'email, new_auth_hash, new_enc_key_salt, and recovery_proof are required'}), 400 time.sleep(0.1) # timing mitigation user = User.query.filter_by(email=email).first() if not user or not user.recovery_enc_salt: return jsonify({'error': 'No recovery code found for this account'}), 404 # 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', resource_type='user', resource_id=user.id, detail='Recovery attempt failed — incorrect recovery proof', ip_address=client_ip(), ) db.session.commit() return jsonify({'error': 'Invalid recovery code'}), 401 try: from app.models.vault_item import VaultItem item_ids = [i.get('id') for i in items if i.get('id')] existing = { v.id: v for v in VaultItem.query.filter( VaultItem.user_id == user.id, VaultItem.id.in_(item_ids), ).all() } if item_ids else {} for item_data in items: item_id = item_data.get('id') enc_data = item_data.get('enc_data', '') iv = item_data.get('iv', '') if not item_id or not enc_data or not iv: continue vault_item = existing.get(item_id) if vault_item: vault_item.enc_data = enc_data vault_item.iv = iv if item_data.get('enc_name'): vault_item.enc_name = item_data['enc_name'] if item_data.get('iv_name'): vault_item.iv_name = item_data['iv_name'] user.master_hash = hash_auth_token(new_auth_hash) user.enc_key_salt = new_enc_key_salt # Recovery code is consumed — clear it so it cannot be reused. user.recovery_enc_salt = None user.recovery_iv = None AuditLog.log( user_id=user.id, action='auth.recovery_success', resource_type='user', resource_id=user.id, detail=f'Account recovered; {len(existing)} vault item(s) re-encrypted; recovery code consumed', ip_address=client_ip(), ) db.session.commit() except Exception: db.session.rollback() _log.exception('recover_account failed for user %s', user.id) return jsonify({'error': 'Account recovery failed. Please try again.'}), 500 tokens = generate_tokens(user.id) return jsonify({ 'message': 'Account recovered successfully', 'access_token': tokens['access_token'], 'refresh_token': tokens['refresh_token'], 'enc_key_salt': user.enc_key_salt, }), 200 @auth_bp.route('/recovery/data', methods=['GET']) @limiter.limit('10 per minute') def recovery_data(): """ Return the data the client needs to attempt recovery (unauthenticated). Exposes: enc_key_salt, recovery_enc_salt, recovery_iv, and a one-time nonce. 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 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 user = User.query.filter_by(email=email).first() if not user or not user.recovery_enc_salt: return jsonify({'error': 'No recovery data found'}), 404 # Generate a fresh nonce and precompute the expected HMAC using the stored # 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. nonce = generate_recovery_nonce() expected_proof = _hmac.new( user.enc_key_salt.encode(), nonce.encode(), hashlib.sha256, ).hexdigest() # 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({ 'recovery_enc_salt': user.recovery_enc_salt, 'recovery_iv': user.recovery_iv, 'nonce': nonce, }), 200 @auth_bp.route('/recovery/items', methods=['GET']) @limiter.limit('10 per minute') def recovery_items(): """ Return encrypted vault items for recovery re-encryption (unauthenticated). Requires X-Recovery-Proof header containing the HMAC-SHA256 proof: proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_from_recovery_data) The enc_key_salt used as the HMAC key is NOT returned by /recovery/data; the client must derive it by decrypting the recovery blob with the recovery code. This ensures only the holder of the recovery code can compute the proof. Replay prevention: the challenge is consumed (deleted) on success, then immediately re-issued with the same expected_proof but a new nonce and a fresh TTL. This means each call to /recovery/items rotates the challenge, so a captured X-Recovery-Proof header cannot be replayed by a third party. POST /recover will consume the rotated challenge on final commit. 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 user = User.query.filter_by(email=email).first() if not user or not user.recovery_enc_salt: return jsonify({'error': 'No recovery data found'}), 404 # Consume the current challenge atomically. 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_items_denied', resource_type='user', resource_id=user.id, detail='Recovery items request denied — incorrect recovery proof', ip_address=client_ip(), ) db.session.commit() return jsonify({'error': 'Invalid recovery proof'}), 401 # Re-issue a fresh challenge with the same expected_proof but a new nonce # and TTL. POST /recover will consume this rotated challenge on final commit. # The client continues to send the same proof value — no client change needed. new_nonce = generate_recovery_nonce() RecoveryChallenge.create( user_id=user.id, nonce=new_nonce, expected_proof=challenge.expected_proof, # same proof, new nonce ) db.session.commit() from app.models.vault_item import VaultItem items = VaultItem.query.filter_by(user_id=user.id).all() return jsonify({ 'items': [ { 'id': item.id, 'enc_data': item.enc_data, 'iv': item.iv, 'enc_name': item.enc_name, 'iv_name': item.iv_name, } for item in items ] }), 200