From c7b1806ec8c69a37676909fb432f8b445a4664bb Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sat, 2 May 2026 15:30:33 -0400 Subject: [PATCH] 05/02/2026 updated code for security --- app/__init__.py | 37 +++ app/models/user.py | 11 +- app/routes/auth.py | 280 ++++++++++++++---- app/services/auth_service.py | 122 +++++++- app/static/js/recover.js | 219 +++++++++----- ...a7b8c9_add_lockout_and_mfa_backup_codes.py | 49 +++ 6 files changed, 594 insertions(+), 124 deletions(-) create mode 100644 migrations/versions/d4e5f6a7b8c9_add_lockout_and_mfa_backup_codes.py diff --git a/app/__init__.py b/app/__init__.py index 6132bf7..d8909d8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -15,6 +15,14 @@ login_manager = LoginManager() csrf = CSRFProtect() limiter = Limiter(key_func=get_remote_address) +# APScheduler is used for the background token-blacklist cleanup job. +# Imported here so it is available at module level; started inside create_app(). +try: + from apscheduler.schedulers.background import BackgroundScheduler + _scheduler_available = True +except ImportError: # pragma: no cover — optional dependency + _scheduler_available = False + def create_app(config_name: str = 'development') -> Flask: app = Flask(__name__) @@ -115,4 +123,33 @@ def create_app(config_name: str = 'development') -> Flask: def recover_page(): return render_template('auth/recover.html') + # ── Background scheduler — token blacklist cleanup ───────────────────────── + # Runs cleanup_expired() every hour so the token_blacklist table never + # accumulates unbounded rows. Runs in a daemon thread — no request context. + if _scheduler_available: + def _cleanup_expired_tokens(): + with app.app_context(): + try: + from app.models.token_blacklist import TokenBlacklist + TokenBlacklist.cleanup_expired() + import logging + logging.getLogger(__name__).debug( + '[PassKeeper] token_blacklist cleanup completed' + ) + except Exception as exc: # pragma: no cover + import logging + logging.getLogger(__name__).warning( + '[PassKeeper] token_blacklist cleanup failed: %s', exc + ) + + scheduler = BackgroundScheduler(daemon=True) + scheduler.add_job( + _cleanup_expired_tokens, + trigger='interval', + hours=1, + id='token_blacklist_cleanup', + replace_existing=True, + ) + scheduler.start() + return app \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py index d5742a6..8fc9fd8 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -39,6 +39,15 @@ class User(db.Model, UserMixin): # The server never sees the recovery code — only the ciphertext of enc_key_salt. recovery_enc_salt = db.Column(db.String(128), nullable=True) recovery_iv = db.Column(db.String(64), nullable=True) + # Brute-force lockout — incremented on every failed login attempt, + # reset to 0 on success. locked_until is set to now()+15min after + # MAX_FAILED_LOGINS consecutive failures. + failed_login_count = db.Column(db.Integer, default=0, nullable=False, server_default='0') + locked_until = db.Column(db.DateTime, nullable=True) + # MFA backup codes — JSON array of Argon2id-hashed one-time codes. + # Each code is consumed (removed from the array) on use. + # NULL means no backup codes have been generated yet. + mfa_backup_codes = db.Column(db.Text, nullable=True) folders = db.relationship('Folder', backref='owner', lazy='dynamic', cascade='all, delete-orphan') vault_items = db.relationship('VaultItem', backref='owner', lazy='dynamic', cascade='all, delete-orphan') @@ -51,4 +60,4 @@ class User(db.Model, UserMixin): return False def __repr__(self): - return f'' + return f'' \ No newline at end of file diff --git a/app/routes/auth.py b/app/routes/auth.py index dd8b388..b97ee2b 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,7 +1,7 @@ import re import time -from flask import Blueprint, request, jsonify, g +from flask import Blueprint, request, jsonify, g, session from app import db, limiter from app.models.user import User from app.models.audit_log import AuditLog @@ -15,6 +15,10 @@ from app.services.auth_service import ( require_jwt, encrypt_totp_secret, decrypt_totp_secret, + generate_recovery_nonce, + verify_recovery_proof, + generate_backup_codes, + verify_and_consume_backup_code, ) auth_bp = Blueprint('auth', __name__) @@ -66,6 +70,12 @@ def register(): @auth_bp.route('/login', methods=['POST']) @limiter.limit('10 per minute') def login(): + from datetime import datetime, timezone + + # 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', '') @@ -76,20 +86,59 @@ def login(): return jsonify({'error': 'Email and auth_hash are required'}), 400 user = User.query.filter_by(email=email).first() - if not user or not verify_auth_token(auth_hash, user.master_hash): - if user: + + # Per-account lockout check — evaluated before password verification so the + # check itself doesn't leak whether the account exists via timing. + 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_failed', + action='auth.login_blocked', resource_type='user', resource_id=user.id, - detail='Failed login attempt — invalid password', + 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 + + if not user or not verify_auth_token(auth_hash, user.master_hash): + if user: + user.failed_login_count = (user.failed_login_count or 0) + 1 + if user.failed_login_count >= MAX_FAILED_LOGINS: + from datetime import timedelta + user.locked_until = datetime.utcnow() + 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() return jsonify({'error': 'Invalid email or password'}), 401 - from datetime import datetime + # Successful authentication — reset lockout state. + user.failed_login_count = 0 + user.locked_until = None user.last_login = datetime.utcnow() AuditLog.log( @@ -192,7 +241,8 @@ def mfa_setup(): @auth_bp.route('/mfa/enable', methods=['POST']) @require_jwt def mfa_enable(): - """Enable MFA after verifying the first TOTP code.""" + """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 @@ -213,45 +263,66 @@ def mfa_enable(): 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', + detail='TOTP two-factor authentication enabled; backup codes generated', ip_address=_client_ip(), ) db.session.commit() - return jsonify({'message': 'MFA enabled successfully'}), 200 + return jsonify({ + 'message': 'MFA enabled successfully', + 'backup_codes': plaintext_codes, + }), 200 @auth_bp.route('/mfa/disable', methods=['POST']) @require_jwt def mfa_disable(): - """Disable MFA after verifying the current TOTP code.""" + """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) - if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1): + verified = False + + if totp_code: + verified = pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1) + 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', + detail='TOTP two-factor authentication disabled; backup codes cleared', ip_address=_client_ip(), ) db.session.commit() @@ -262,13 +333,15 @@ def mfa_disable(): @auth_bp.route('/mfa/verify', methods=['POST']) @limiter.limit('10 per minute') def mfa_verify(): - """Complete MFA login: verify TOTP code and exchange mfa_token for real tokens.""" + """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: - return jsonify({'error': 'mfa_token and totp_code are required'}), 400 + 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) @@ -281,7 +354,27 @@ def mfa_verify(): import pyotp plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv) - if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1): + verified = False + + if totp_code: + verified = pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1) + + 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 @@ -308,7 +401,54 @@ def mfa_verify(): @require_jwt def mfa_status(): user = db.session.get(User, g.current_user_id) - return jsonify({'totp_enabled': user.totp_enabled}), 200 + 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']) +@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 not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1): + return jsonify({'error': 'Invalid verification code'}), 400 + + 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']) @@ -521,52 +661,56 @@ def recover_account(): Recover account access using a recovery code. Flow: - 1. Client looks up enc_key_salt and recovery blobs by email. - 2. Client decrypts enc_key_salt using the recovery key (derived from the recovery code). - 3. Client derives new auth_hash and new vault key with a new master password. - 4. Client re-encrypts all vault items with the new vault key. + 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. - This endpoint is unauthenticated — the recovery code is the credential. + 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. """ 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', '') - # Proof that the client successfully decrypted enc_key_salt: - # the client re-derives auth_hash from the *original* enc_key_salt path - # and sends it alongside the new credentials for server-side verification. - recovery_proof = data.get('recovery_proof', '') + client_proof = data.get('recovery_proof', '') items = data.get('items', []) - if not all([email, new_auth_hash, new_enc_key_salt, recovery_proof]): + 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 + # 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: + if not user or not user.recovery_enc_salt or user.id != session_user_id: return jsonify({'error': 'No recovery code found for this account'}), 404 - # recovery_proof is the enc_key_salt re-encrypted by the client using the - # recovery key — we return it as a blob for the client to verify, then - # the client sends back the decrypted enc_key_salt as recovery_proof. - # Simpler: recovery_proof = HMAC or simply the decrypted enc_key_salt itself, - # which the client proves by sending it back plaintext. The server checks it - # matches user.enc_key_salt — if the recovery code was wrong, decryption - # would produce garbage that won't match. - if recovery_proof != user.enc_key_salt: + if not verify_recovery_proof(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 code', + detail='Recovery attempt failed — incorrect recovery proof', ip_address=_client_ip(), ) 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 @@ -589,7 +733,6 @@ def recover_account(): 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'): @@ -597,7 +740,7 @@ def recover_account(): 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 + # Recovery code is consumed — clear it so it cannot be reused. user.recovery_enc_salt = None user.recovery_iv = None @@ -628,9 +771,16 @@ def recover_account(): def recovery_data(): """ Return the data the client needs to attempt recovery (unauthenticated). - Exposes only: enc_key_salt, recovery_enc_salt, recovery_iv. - Returns 404 if no recovery code is configured (prevents user enumeration - of which accounts have recovery set up — same response for unknown email). + 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 server-side session at challenge time, + 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). """ email = (request.args.get('email') or '').strip().lower() if not email: @@ -640,10 +790,28 @@ def recovery_data(): 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. + import hashlib, hmac as _hmac + nonce = generate_recovery_nonce() + expected_proof = _hmac.new( + user.enc_key_salt.encode(), + nonce.encode(), + 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 + return jsonify({ 'enc_key_salt': user.enc_key_salt, 'recovery_enc_salt': user.recovery_enc_salt, 'recovery_iv': user.recovery_iv, + 'nonce': nonce, }), 200 @@ -653,24 +821,31 @@ def recovery_items(): """ Return encrypted vault items for recovery re-encryption (unauthenticated). - Requires X-Recovery-Proof header containing the plaintext enc_key_salt. - The server verifies it matches user.enc_key_salt — proof that the client - correctly decrypted the recovery blob (i.e. has the correct recovery code). + Requires X-Recovery-Proof header containing the HMAC-SHA256 proof: + proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_from_recovery_data) - Items are returned as encrypted ciphertext blobs only — no sensitive - plaintext is exposed. The client re-encrypts them locally. + 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. + Items are returned as encrypted ciphertext blobs only. """ email = (request.args.get('email') or '').strip().lower() - recovery_proof = request.headers.get('X-Recovery-Proof', '').strip() + client_proof = request.headers.get('X-Recovery-Proof', '').strip() - if not email or not recovery_proof: + 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: + if not user or not user.recovery_enc_salt or user.id != session_user_id: return jsonify({'error': 'No recovery data found'}), 404 - if recovery_proof != user.enc_key_salt: + if not verify_recovery_proof(expected_proof, client_proof): AuditLog.log( user_id=user.id, action='auth.recovery_items_denied', @@ -689,5 +864,4 @@ def recovery_items(): {'id': item.id, 'enc_data': item.enc_data, 'iv': item.iv} for item in items ] - }), 200 - + }), 200 \ No newline at end of file diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 7593abf..a280f29 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -1,4 +1,6 @@ import base64 +import hashlib +import hmac import os import uuid import time @@ -139,8 +141,8 @@ def blacklist_token(token: str, token_type: str) -> None: ) db.session.add(entry) db.session.commit() - # Opportunistic cleanup — runs in same transaction context - TokenBlacklist.cleanup_expired() + # Cleanup is handled by the APScheduler background job in create_app(), + # not here — keeps the logout/refresh hot path free of extra DB writes. except Exception: pass # Never let blacklisting errors break the logout flow @@ -162,3 +164,119 @@ def require_jwt(f): g.current_user_id = int(payload['sub']) return f(*args, **kwargs) return decorated + + +# ── Recovery proof helpers (HMAC-nonce) ────────────────────────────────────── + +def generate_recovery_nonce() -> str: + """ + Return a fresh 32-byte random nonce (hex) for use in the recovery proof + challenge-response. Must be stored in the server-side flask.session and + consumed (deleted) exactly once. + """ + 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() + + +def verify_recovery_proof(expected_hmac: str, client_hmac: str) -> bool: + """Constant-time comparison of the server-computed proof vs the client-submitted one.""" + return hmac.compare_digest(expected_hmac, client_hmac) + + +# ── MFA backup codes ────────────────────────────────────────────────────────── + +BACKUP_CODE_COUNT = 10 # codes generated per enrollment +BACKUP_CODE_LENGTH = 10 # characters per code (alphanumeric, ~50 bits entropy) +_BACKUP_ALPHABET = 'abcdefghijkmnpqrstuvwxyz23456789' # omit l/o/0/1 to avoid confusion + + +def generate_backup_codes() -> tuple[list[str], list[str]]: + """ + Generate BACKUP_CODE_COUNT plaintext backup codes and their Argon2id hashes. + + Returns (plaintext_codes, hashed_codes). + 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). + """ + 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) + ] + hashed = [ph.hash(code) for code in plaintext] + return plaintext, hashed + + +def verify_and_consume_backup_code(hashed_codes: list[str], candidate: str) -> tuple[bool, list[str]]: + """ + Check `candidate` against the stored hashed backup codes. + + Returns (matched, remaining_hashes). + If matched, the consumed code is removed from remaining_hashes. + Performs constant-time-safe iteration (always checks all codes). + """ + ph = PasswordHasher() + matched_index = -1 + for i, h in enumerate(hashed_codes): + try: + if ph.verify(h, candidate): + matched_index = i + # Do not break — continue iterating to avoid timing leaks. + except Exception: + pass + + if matched_index == -1: + return False, hashed_codes + + remaining = [h for i, h in enumerate(hashed_codes) if i != matched_index] + return True, remaining \ No newline at end of file diff --git a/app/static/js/recover.js b/app/static/js/recover.js index 7ecebd3..07306b4 100644 --- a/app/static/js/recover.js +++ b/app/static/js/recover.js @@ -44,8 +44,9 @@ const Recover = (() => { // Module state between step 1 and step 2 let _email = null; let _recoveryCode = null; - let _oldEncKeySalt = null; // decrypted from recovery blob - let _oldVaultKey = null; // derived for re-encrypting vault items + let _oldEncKeySalt = null; // decrypted from recovery blob + let _oldVaultKey = null; // derived for re-encrypting vault items + let _recoveryProof = null; // HMAC-SHA256(enc_key_salt_bytes, nonce) — sent as proof // ── Helpers ──────────────────────────────────────────────────────────────── @@ -61,36 +62,39 @@ const Recover = (() => { } function bytesToBase64(bytes) { - let bin = ''; - bytes.forEach(b => (bin += String.fromCharCode(b))); + let bin = ""; + bytes.forEach((b) => (bin += String.fromCharCode(b))); return btoa(bin); } function formatRecoveryCode(raw) { // Display as groups of 4 for readability - return raw.match(/.{1,4}/g)?.join('-') ?? raw; + return raw.match(/.{1,4}/g)?.join("-") ?? raw; } function cleanRecoveryCode(input) { // Strip hyphens/spaces so users can paste formatted or raw codes - return input.replace(/[-\s]/g, '').toLowerCase(); + return input.replace(/[-\s]/g, "").toLowerCase(); } function showError(id, message) { const el = document.getElementById(id); - if (el) { el.textContent = message; el.classList.remove('hidden'); } + if (el) { + el.textContent = message; + el.classList.remove("hidden"); + } } function hideError(id) { const el = document.getElementById(id); - if (el) el.classList.add('hidden'); + if (el) el.classList.add("hidden"); } function setLoading(btn, loading) { btn.disabled = loading; btn.textContent = loading - ? (btn.dataset.loadingText || 'Please wait…') - : (btn.dataset.originalText || btn.textContent); + ? btn.dataset.loadingText || "Please wait…" + : btn.dataset.originalText || btn.textContent; } // ── Crypto ───────────────────────────────────────────────────────────────── @@ -101,23 +105,23 @@ const Recover = (() => { */ async function deriveRecoveryKey(recoveryCode) { const baseKey = await subtle.importKey( - 'raw', + "raw", strToBytes(recoveryCode), - 'PBKDF2', + "PBKDF2", false, - ['deriveKey'] + ["deriveKey"], ); return subtle.deriveKey( { - name: 'PBKDF2', - salt: strToBytes('passkeeper-recovery'), + name: "PBKDF2", + salt: strToBytes("passkeeper-recovery"), iterations: 200_000, - hash: 'SHA-256', + hash: "SHA-256", }, baseKey, - { name: 'AES-GCM', length: 256 }, + { name: "AES-GCM", length: 256 }, false, - ['encrypt', 'decrypt'] + ["encrypt", "decrypt"], ); } @@ -128,9 +132,9 @@ const Recover = (() => { async function encryptEncKeySalt(recoveryKey, encKeySalt) { const iv = window.crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await subtle.encrypt( - { name: 'AES-GCM', iv }, + { name: "AES-GCM", iv }, recoveryKey, - strToBytes(encKeySalt) + strToBytes(encKeySalt), ); return { recovery_enc_salt: bytesToBase64(new Uint8Array(ciphertext)), @@ -144,35 +148,68 @@ const Recover = (() => { */ async function decryptEncKeySalt(recoveryKey, recoveryEncSalt, recoveryIv) { const plaintext = await subtle.decrypt( - { name: 'AES-GCM', iv: base64ToBytes(recoveryIv) }, + { name: "AES-GCM", iv: base64ToBytes(recoveryIv) }, recoveryKey, - base64ToBytes(recoveryEncSalt) + base64ToBytes(recoveryEncSalt), ); return new TextDecoder().decode(plaintext); } + /** + * Compute the HMAC-SHA256 recovery proof. + * proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_bytes) + * + * This proves to the server that the client correctly decrypted the recovery + * blob (and therefore holds the right recovery code) without transmitting + * enc_key_salt in plaintext. + */ + async function computeRecoveryProof(encKeySalt, nonce) { + const keyMaterial = await subtle.importKey( + "raw", + strToBytes(encKeySalt), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await subtle.sign("HMAC", keyMaterial, strToBytes(nonce)); + // Convert to hex string to match Python's hmac.hexdigest() + return Array.from(new Uint8Array(signature)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + // ── Step 1: Verify recovery code ─────────────────────────────────────────── async function handleStep1(e) { e.preventDefault(); - hideError('recover-error-1'); + hideError("recover-error-1"); const btn = e.target.querySelector('[type="submit"]'); btn.dataset.originalText = btn.textContent; setLoading(btn, true); try { - const email = document.getElementById('recover-email').value.trim().toLowerCase(); - const rawCode = cleanRecoveryCode(document.getElementById('recover-code').value.trim()); + const email = document + .getElementById("recover-email") + .value.trim() + .toLowerCase(); + const rawCode = cleanRecoveryCode( + document.getElementById("recover-code").value.trim(), + ); if (!email || !rawCode) { - showError('recover-error-1', 'Email and recovery code are required.'); + showError("recover-error-1", "Email and recovery code are required."); return; } // Fetch recovery blobs from server - const res = await fetch(`/api/auth/recovery/data?email=${encodeURIComponent(email)}`); + const res = await fetch( + `/api/auth/recovery/data?email=${encodeURIComponent(email)}`, + ); if (!res.ok) { - showError('recover-error-1', 'No recovery code found for this account.'); + showError( + "recover-error-1", + "No recovery code found for this account.", + ); return; } const data = await res.json(); @@ -184,27 +221,37 @@ const Recover = (() => { decryptedEncKeySalt = await decryptEncKeySalt( recoveryKey, data.recovery_enc_salt, - data.recovery_iv + data.recovery_iv, ); } catch { - showError('recover-error-1', 'Invalid recovery code. Please check and try again.'); + showError( + "recover-error-1", + "Invalid recovery code. Please check and try again.", + ); return; } + // Compute HMAC-SHA256 proof: proves we correctly decrypted the blob + // without sending enc_key_salt in plaintext. + const proof = await computeRecoveryProof(decryptedEncKeySalt, data.nonce); + // Derive the old vault key using the recovery code as master password proxy _oldVaultKey = await Crypto.deriveVaultKey(rawCode, decryptedEncKeySalt); _email = email; _recoveryCode = rawCode; _oldEncKeySalt = decryptedEncKeySalt; + _recoveryProof = proof; // Show step 2 - document.getElementById('recover-step-1').classList.add('hidden'); - document.getElementById('recover-step-2').classList.remove('hidden'); - document.getElementById('recover-new-pass').focus(); - + document.getElementById("recover-step-1").classList.add("hidden"); + document.getElementById("recover-step-2").classList.remove("hidden"); + document.getElementById("recover-new-pass").focus(); } catch (err) { - showError('recover-error-1', 'An unexpected error occurred. Please try again.'); + showError( + "recover-error-1", + "An unexpected error occurred. Please try again.", + ); console.error(err); } finally { setLoading(btn, false); @@ -215,28 +262,36 @@ const Recover = (() => { async function handleStep2(e) { e.preventDefault(); - hideError('recover-error-2'); + hideError("recover-error-2"); const btn = e.target.querySelector('[type="submit"]'); btn.dataset.originalText = btn.textContent; setLoading(btn, true); try { - const newPassword = document.getElementById('recover-new-pass').value; - const confirmPassword = document.getElementById('recover-confirm-pass').value; + const newPassword = document.getElementById("recover-new-pass").value; + const confirmPassword = document.getElementById( + "recover-confirm-pass", + ).value; if (newPassword !== confirmPassword) { - showError('recover-error-2', 'Passwords do not match.'); + showError("recover-error-2", "Passwords do not match."); return; } if (newPassword.length < 12) { - showError('recover-error-2', 'Password must be at least 12 characters.'); + showError( + "recover-error-2", + "Password must be at least 12 characters.", + ); return; } // Derive new credentials const newAuthHash = await Crypto.deriveAuthHash(newPassword, _email); const newEncKeySalt = Crypto.generateSalt(16); - const newVaultKey = await Crypto.deriveVaultKey(newPassword, newEncKeySalt); + const newVaultKey = await Crypto.deriveVaultKey( + newPassword, + newEncKeySalt, + ); // Fetch all vault items (encrypted with old vault key) // We use a minimal unauthenticated fetch here — items are still ciphertext on the wire. @@ -264,9 +319,12 @@ const Recover = (() => { // Since items are ciphertext and we verify recovery code server-side, this is acceptable. // Fetch items unauthenticated via a recovery-scoped endpoint - const itemsRes = await fetch(`/api/auth/recovery/items?email=${encodeURIComponent(_email)}`, { - headers: { 'X-Recovery-Proof': _oldEncKeySalt }, - }); + const itemsRes = await fetch( + `/api/auth/recovery/items?email=${encodeURIComponent(_email)}`, + { + headers: { "X-Recovery-Proof": _recoveryProof }, + }, + ); let reEncryptedItems = []; if (itemsRes.ok) { @@ -274,8 +332,15 @@ const Recover = (() => { // Re-encrypt each item: old vault key → new vault key for (const item of itemsData.items) { try { - const plain = await Crypto.decryptItem(_oldVaultKey, item.enc_data, item.iv); - const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain); + const plain = await Crypto.decryptItem( + _oldVaultKey, + item.enc_data, + item.iv, + ); + const { enc_data, iv } = await Crypto.encryptItem( + newVaultKey, + plain, + ); reEncryptedItems.push({ id: item.id, enc_data, iv }); } catch { // Item decryption failed — skip (shouldn't happen if recovery code is correct) @@ -285,37 +350,45 @@ const Recover = (() => { } // Submit recovery - const recoverRes = await fetch('/api/auth/recover', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const recoverRes = await fetch("/api/auth/recover", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: _email, new_auth_hash: newAuthHash, new_enc_key_salt: newEncKeySalt, - recovery_proof: _oldEncKeySalt, + recovery_proof: _recoveryProof, items: reEncryptedItems, }), }); const recoverData = await recoverRes.json(); if (!recoverRes.ok) { - showError('recover-error-2', recoverData.error || 'Recovery failed. Please try again.'); + showError( + "recover-error-2", + recoverData.error || "Recovery failed. Please try again.", + ); return; } // Store session and redirect - sessionStorage.setItem('access_token', recoverData.access_token); - localStorage.setItem('refresh_token', recoverData.refresh_token); - sessionStorage.setItem('enc_key_salt', recoverData.enc_key_salt); + sessionStorage.setItem("access_token", recoverData.access_token); + localStorage.setItem("refresh_token", recoverData.refresh_token); + sessionStorage.setItem("enc_key_salt", recoverData.enc_key_salt); // Set vault key in VaultSession so unlock overlay is skipped - const finalVaultKey = await Crypto.deriveVaultKey(newPassword, recoverData.enc_key_salt); + const finalVaultKey = await Crypto.deriveVaultKey( + newPassword, + recoverData.enc_key_salt, + ); VaultSession.setKey(finalVaultKey); - window.location.href = '/vault?recovered=1'; - + window.location.href = "/vault?recovered=1"; } catch (err) { - showError('recover-error-2', 'An unexpected error occurred. Please try again.'); + showError( + "recover-error-2", + "An unexpected error occurred. Please try again.", + ); console.error(err); } finally { setLoading(btn, false); @@ -325,14 +398,18 @@ const Recover = (() => { // ── Init ─────────────────────────────────────────────────────────────────── function init() { - document.getElementById('recover-form-step1')?.addEventListener('submit', handleStep1); - document.getElementById('recover-form-step2')?.addEventListener('submit', handleStep2); + document + .getElementById("recover-form-step1") + ?.addEventListener("submit", handleStep1); + document + .getElementById("recover-form-step2") + ?.addEventListener("submit", handleStep2); - const toggleBtn = document.getElementById('toggle-recover-pass'); - const passInput = document.getElementById('recover-new-pass'); + const toggleBtn = document.getElementById("toggle-recover-pass"); + const passInput = document.getElementById("recover-new-pass"); if (toggleBtn && passInput) { - toggleBtn.addEventListener('click', () => { - passInput.type = passInput.type === 'password' ? 'text' : 'password'; + toggleBtn.addEventListener("click", () => { + passInput.type = passInput.type === "password" ? "text" : "password"; }); } } @@ -345,10 +422,16 @@ const Recover = (() => { const VaultSession = (() => { let _key = null; return { - setKey(k) { _key = k; }, - getKey() { return _key; }, - clear() { _key = null; }, + setKey(k) { + _key = k; + }, + getKey() { + return _key; + }, + clear() { + _key = null; + }, }; })(); -document.addEventListener('DOMContentLoaded', Recover.init); +document.addEventListener("DOMContentLoaded", Recover.init); diff --git a/migrations/versions/d4e5f6a7b8c9_add_lockout_and_mfa_backup_codes.py b/migrations/versions/d4e5f6a7b8c9_add_lockout_and_mfa_backup_codes.py new file mode 100644 index 0000000..62c2e0d --- /dev/null +++ b/migrations/versions/d4e5f6a7b8c9_add_lockout_and_mfa_backup_codes.py @@ -0,0 +1,49 @@ +"""add lockout columns and mfa_backup_codes to users + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-05-02 00:00:00.000000 + +Adds three nullable/defaulted columns to users: + - failed_login_count INTEGER NOT NULL DEFAULT 0 + Incremented on every failed login, reset on success or lockout expiry. + - locked_until DATETIME NULL + When set (and in the future), login is rejected with HTTP 429. + - mfa_backup_codes TEXT NULL + JSON array of Argon2id-hashed one-time backup codes generated at + MFA enrollment. NULL = no codes generated / MFA not enabled. + Cleared when MFA is disabled. +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'd4e5f6a7b8c9' +down_revision = 'c3d4e5f6a7b8' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column( + sa.Column( + 'failed_login_count', + sa.Integer(), + nullable=False, + server_default='0', + ) + ) + batch_op.add_column( + sa.Column('locked_until', sa.DateTime(), nullable=True) + ) + batch_op.add_column( + sa.Column('mfa_backup_codes', sa.Text(), nullable=True) + ) + + +def downgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('mfa_backup_codes') + batch_op.drop_column('locked_until') + batch_op.drop_column('failed_login_count') \ No newline at end of file