import logging import re import time 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 from app.services.auth_service import ( hash_auth_token, verify_auth_token, generate_tokens, load_user_for_token, 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, ) _log = logging.getLogger(__name__) auth_bp = Blueprint('auth', __name__) EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') # Fixed-length hex validator for the client-supplied recovery verifier. _HEX64_RE = re.compile(r'^[0-9a-f]{64}$') def _recovery_proof_key(user) -> bytes: """ Return the HMAC key the recovery challenge-response is computed over. Preferred: user.recovery_verifier — a 256-bit value derived client-side from the recovery code alone. It is used for nothing else, so learning it grants no decryption ability, and knowing enc_key_salt does not yield it. Legacy fallback: user.enc_key_salt, for recovery codes created before the verifier existed. This is weaker — enc_key_salt is also the vault-key PBKDF2 salt and is disclosed to the client on login, so anyone holding the master password can forge a proof and pull the vault from /recovery/items without a second factor. Accounts on this path are flagged via /recovery/status so the settings UI can prompt the user to regenerate. """ if user.recovery_verifier: return user.recovery_verifier.encode() return user.enc_key_salt.encode() class IncompleteReencryption(Exception): """ Raised when a key-rotation payload does not cover every vault item the user owns. Rotating enc_key_salt while some ciphertext is still under the old key renders those items permanently undecryptable, so the whole transaction is refused unless the caller explicitly opts into partial coverage. """ def __init__(self, expected: int, received: int): self.expected = expected self.received = received super().__init__(f'expected {expected} item(s), received {received}') def _apply_reencrypted_items(user_id: int, items, allow_partial: bool = False) -> tuple[int, int]: """ Apply client-supplied re-encrypted ciphertext to the user's vault items. Both the password-change and account-recovery flows rotate enc_key_salt, which invalidates every ciphertext encrypted under the previous vault key. The client is responsible for re-encrypting each item and sending it back; any item missing from that payload is silently orphaned by the rotation. This helper therefore counts what it actually wrote and compares it against the number of items the user owns. On a shortfall it raises IncompleteReencryption so the caller can roll back rather than commit a rotation that destroys data. allow_partial=True skips the guard. The web client only sets it after showing the user exactly how many items will be lost and getting explicit confirmation — it exists so a single corrupt item cannot lock someone out of recovery entirely. Returns (updated_count, total_count). Does not commit. """ from app.models.vault_item import VaultItem total = VaultItem.query.filter_by(user_id=user_id).count() item_ids = [i.get('id') for i in (items or []) 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 {} updated = 0 for item_data in (items or []): 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 not vault_item: continue 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'] updated += 1 if updated != total and not allow_partial: raise IncompleteReencryption(expected=total, received=updated) return updated, total @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. # # enc_key_salt is deliberately NOT returned here. It is the PBKDF2 salt for # the vault key, and releasing it to a caller that has only cleared the # password factor is a partial authentication result. The client receives it # from /mfa/verify once the second factor is satisfied. if user.totp_enabled: mfa_token = generate_mfa_token(user.id) return jsonify({ 'mfa_required': True, 'mfa_token': mfa_token, }), 200 tokens = generate_tokens(user.id, user.token_epoch) 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 # Same gate as require_jwt: the account must still exist and the token's # epoch must still match. Without this a refresh token captured before a # password change could keep minting fresh access tokens for its full # 7-day lifetime, defeating the revocation entirely. user = load_user_for_token(payload) if user is None: return jsonify({'error': 'Session is no longer valid. Please log in again.'}), 401 # Rotate: blacklist old refresh token and issue fresh pair blacklist_token(refresh_token, 'refresh') tokens = generate_tokens(user.id, user.token_epoch) 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, user.token_epoch) return jsonify({ 'access_token': tokens['access_token'], 'refresh_token': tokens['refresh_token'], # Released here rather than at /login — both factors are now proven. 'enc_key_salt': user.enc_key_salt, }), 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', '') # NOTE: there is deliberately no allow_partial opt-in here. # # Recovery needs one, because refusing outright leaves a locked-out user with # no way into their account. Changing the password has no such pressure — the # current password keeps working — so accepting data loss is never the right # answer, and the server refuses regardless of what the client asks for. 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: # Refuse the rotation outright unless every item was re-encrypted — # see _apply_reencrypted_items. Raises IncompleteReencryption otherwise. updated, total = _apply_reencrypted_items(user.id, items) # 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 user.recovery_verifier = None # Revoke every token issued under the old password. Without this the # "Please log in again" message below is advisory only — outstanding # refresh tokens would stay valid for their full 7-day lifetime. user.token_epoch = (user.token_epoch or 0) + 1 # 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; {updated}/{total} vault item(s) ' 're-encrypted; recovery code cleared' ), ip_address=client_ip(), ) db.session.commit() except IncompleteReencryption as exc: db.session.rollback() AuditLog.log( user_id=g.current_user_id, action='auth.change_password_failed', resource_type='user', resource_id=g.current_user_id, detail=( f'Password change refused — re-encryption payload covered ' f'{exc.received} of {exc.expected} vault item(s)' ), ip_address=client_ip(), ) db.session.commit() return jsonify({ 'error': ( f'Password not changed: the re-encryption payload covered only ' f'{exc.received} of your {exc.expected} vault item(s). Completing ' 'this would permanently lock the missing items. Reload the vault ' 'and try again.' ), 'code': 'incomplete_reencryption', 'expected': exc.expected, 'received': exc.received, }), 409 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() # 64 lowercase hex chars, derived client-side from the recovery code alone. recovery_verifier = (data.get('recovery_verifier') or '').strip().lower() if not recovery_enc_salt or not recovery_iv: return jsonify({'error': 'recovery_enc_salt and recovery_iv are required'}), 400 if not recovery_verifier or not _HEX64_RE.match(recovery_verifier): return jsonify({ 'error': 'recovery_verifier must be 64 hexadecimal characters' }), 400 user = db.session.get(User, g.current_user_id) user.recovery_enc_salt = recovery_enc_salt user.recovery_iv = recovery_iv user.recovery_verifier = recovery_verifier 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), # True when the stored recovery code predates recovery_verifier and so # still relies on the weaker enc_key_salt-keyed proof. The settings UI # surfaces this as a prompt to regenerate. 'recovery_is_legacy': bool(user.recovery_enc_salt and not user.recovery_verifier), }), 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 — neither the recovery code nor the verifier is ever sent in plaintext. The re-encrypted `items` array must cover every vault item the user owns; otherwise the rotation is refused with 409. Pass allow_partial=true to override once the user has confirmed the resulting data loss. 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', []) # Explicit opt-in to recovering with some items left un-re-encrypted. # The client sets this only after telling the user how many items it could # not decrypt and getting confirmation — without the escape hatch a single # corrupt row would block recovery entirely. allow_partial = bool(data.get('allow_partial')) 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: # Refuse to rotate the key while items remain under the old one — # see _apply_reencrypted_items. Raises IncompleteReencryption otherwise. updated, total = _apply_reencrypted_items( user.id, items, allow_partial=allow_partial ) 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 user.recovery_verifier = None # Recovery resets the master password, so revoke prior sessions too — # an attacker holding a stolen token must not survive the victim # recovering their account. user.token_epoch = (user.token_epoch or 0) + 1 AuditLog.log( user_id=user.id, action='auth.recovery_success', resource_type='user', resource_id=user.id, detail=( f'Account recovered; {updated}/{total} vault item(s) re-encrypted' f'{" (PARTIAL — user confirmed data loss)" if updated != total else ""}; ' 'recovery code consumed' ), ip_address=client_ip(), ) db.session.commit() except IncompleteReencryption as exc: # The challenge was already consumed above, so the client must restart # from /recovery/data. That is the correct trade-off: better to repeat # the flow than to commit a rotation that orphans ciphertext. db.session.rollback() AuditLog.log( user_id=user.id, action='auth.recovery_failed', resource_type='user', resource_id=user.id, detail=( f'Recovery refused — re-encryption payload covered ' f'{exc.received} of {exc.expected} vault item(s)' ), ip_address=client_ip(), ) db.session.commit() return jsonify({ 'error': ( f'Recovery stopped: only {exc.received} of your {exc.expected} ' 'vault item(s) could be re-encrypted. Continuing would permanently ' 'lock the rest.' ), 'code': 'incomplete_reencryption', 'expected': exc.expected, 'received': exc.received, }), 409 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, user.token_epoch) 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 derives the recovery verifier from the recovery code: PBKDF2(recovery_code, "passkeeper-recovery-verifier:" + email, 200k) - Client computes: proof = HMAC-SHA256(key=verifier, msg=nonce) - Server stores expected proof in the DB (recovery_challenges table), verifying it on /recover and /recovery/items. The response's `proof_scheme` field tells the client which key to use. Accounts whose recovery code predates recovery_verifier get 'legacy' and key the proof on the enc_key_salt decrypted out of the recovery blob. 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( _recovery_proof_key(user), 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, # Tells the client which value to key the HMAC proof with: # 'verifier' → PBKDF2(recovery_code, "passkeeper-recovery-verifier:" + email) # 'legacy' → the enc_key_salt decrypted out of the recovery blob 'proof_scheme': 'verifier' if user.recovery_verifier else 'legacy', }), 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=recovery_verifier, msg=nonce_from_recovery_data) The verifier is derived client-side from the recovery code alone and is never returned by any endpoint, so only the holder of the recovery code can compute the proof. It is deliberately not enc_key_salt: that value is also the vault-key PBKDF2 salt and is released to the client on login, so keying the proof with it allowed anyone holding the master password to forge a proof and pull the whole vault here — bypassing MFA. 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