05/02/2026 updated code for security

This commit is contained in:
2026-05-02 15:30:33 -04:00
parent 9af7435cab
commit c7b1806ec8
6 changed files with 594 additions and 124 deletions
+37
View File
@@ -15,6 +15,14 @@ login_manager = LoginManager()
csrf = CSRFProtect() csrf = CSRFProtect()
limiter = Limiter(key_func=get_remote_address) 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: def create_app(config_name: str = 'development') -> Flask:
app = Flask(__name__) app = Flask(__name__)
@@ -115,4 +123,33 @@ def create_app(config_name: str = 'development') -> Flask:
def recover_page(): def recover_page():
return render_template('auth/recover.html') 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 return app
+9
View File
@@ -39,6 +39,15 @@ class User(db.Model, UserMixin):
# The server never sees the recovery code — only the ciphertext of enc_key_salt. # 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_enc_salt = db.Column(db.String(128), nullable=True)
recovery_iv = db.Column(db.String(64), 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') 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') vault_items = db.relationship('VaultItem', backref='owner', lazy='dynamic', cascade='all, delete-orphan')
+226 -52
View File
@@ -1,7 +1,7 @@
import re import re
import time import time
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g, session
from app import db, limiter from app import db, limiter
from app.models.user import User from app.models.user import User
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
@@ -15,6 +15,10 @@ from app.services.auth_service import (
require_jwt, require_jwt,
encrypt_totp_secret, encrypt_totp_secret,
decrypt_totp_secret, decrypt_totp_secret,
generate_recovery_nonce,
verify_recovery_proof,
generate_backup_codes,
verify_and_consume_backup_code,
) )
auth_bp = Blueprint('auth', __name__) auth_bp = Blueprint('auth', __name__)
@@ -66,6 +70,12 @@ def register():
@auth_bp.route('/login', methods=['POST']) @auth_bp.route('/login', methods=['POST'])
@limiter.limit('10 per minute') @limiter.limit('10 per minute')
def login(): 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 {} data = request.get_json(silent=True) or {}
email = (data.get('email') or '').strip().lower() email = (data.get('email') or '').strip().lower()
auth_hash = data.get('auth_hash', '') auth_hash = data.get('auth_hash', '')
@@ -76,20 +86,59 @@ def login():
return jsonify({'error': 'Email and auth_hash are required'}), 400 return jsonify({'error': 'Email and auth_hash are required'}), 400
user = User.query.filter_by(email=email).first() 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( AuditLog.log(
user_id=user.id, user_id=user.id,
action='auth.login_failed', action='auth.login_blocked',
resource_type='user', resource_type='user',
resource_id=user.id, 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(), ip_address=_client_ip(),
) )
db.session.commit() 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 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() user.last_login = datetime.utcnow()
AuditLog.log( AuditLog.log(
@@ -192,7 +241,8 @@ def mfa_setup():
@auth_bp.route('/mfa/enable', methods=['POST']) @auth_bp.route('/mfa/enable', methods=['POST'])
@require_jwt @require_jwt
def mfa_enable(): 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) user = db.session.get(User, g.current_user_id)
if user.totp_enabled: if user.totp_enabled:
return jsonify({'error': 'MFA is already enabled'}), 400 return jsonify({'error': 'MFA is already enabled'}), 400
@@ -213,45 +263,66 @@ def mfa_enable():
user.totp_iv = totp_iv user.totp_iv = totp_iv
user.totp_enabled = True 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( AuditLog.log(
user_id=user.id, user_id=user.id,
action='auth.mfa_enable', action='auth.mfa_enable',
resource_type='user', resource_type='user',
resource_id=user.id, resource_id=user.id,
detail='TOTP two-factor authentication enabled', detail='TOTP two-factor authentication enabled; backup codes generated',
ip_address=_client_ip(), ip_address=_client_ip(),
) )
db.session.commit() 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']) @auth_bp.route('/mfa/disable', methods=['POST'])
@require_jwt @require_jwt
def mfa_disable(): 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) user = db.session.get(User, g.current_user_id)
if not user.totp_enabled: if not user.totp_enabled:
return jsonify({'error': 'MFA is not enabled'}), 400 return jsonify({'error': 'MFA is not enabled'}), 400
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
totp_code = (data.get('totp_code') or '').strip() totp_code = (data.get('totp_code') or '').strip()
backup_code = (data.get('backup_code') or '').strip().lower().replace('-', '').replace(' ', '')
import pyotp import pyotp
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv) 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 return jsonify({'error': 'Invalid verification code'}), 400
user.totp_secret = None user.totp_secret = None
user.totp_iv = None user.totp_iv = None
user.totp_enabled = False user.totp_enabled = False
user.mfa_backup_codes = None
AuditLog.log( AuditLog.log(
user_id=user.id, user_id=user.id,
action='auth.mfa_disable', action='auth.mfa_disable',
resource_type='user', resource_type='user',
resource_id=user.id, resource_id=user.id,
detail='TOTP two-factor authentication disabled', detail='TOTP two-factor authentication disabled; backup codes cleared',
ip_address=_client_ip(), ip_address=_client_ip(),
) )
db.session.commit() db.session.commit()
@@ -262,13 +333,15 @@ def mfa_disable():
@auth_bp.route('/mfa/verify', methods=['POST']) @auth_bp.route('/mfa/verify', methods=['POST'])
@limiter.limit('10 per minute') @limiter.limit('10 per minute')
def mfa_verify(): 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 {} data = request.get_json(silent=True) or {}
mfa_token = data.get('mfa_token', '') mfa_token = data.get('mfa_token', '')
totp_code = (data.get('totp_code') or '').strip() 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: if not mfa_token or (not totp_code and not backup_code):
return jsonify({'error': 'mfa_token and totp_code are required'}), 400 return jsonify({'error': 'mfa_token and either totp_code or backup_code are required'}), 400
try: try:
payload = decode_token(mfa_token, expected_type='mfa', check_blacklist=True) payload = decode_token(mfa_token, expected_type='mfa', check_blacklist=True)
@@ -281,7 +354,27 @@ def mfa_verify():
import pyotp import pyotp
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv) 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 return jsonify({'error': 'Invalid verification code'}), 400
# One-time use: blacklist the mfa_token # One-time use: blacklist the mfa_token
@@ -308,7 +401,54 @@ def mfa_verify():
@require_jwt @require_jwt
def mfa_status(): def mfa_status():
user = db.session.get(User, g.current_user_id) 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']) @auth_bp.route('/me', methods=['GET'])
@@ -521,52 +661,56 @@ def recover_account():
Recover account access using a recovery code. Recover account access using a recovery code.
Flow: Flow:
1. Client looks up enc_key_salt and recovery blobs by email. 1. Client calls /recovery/data → receives enc_key_salt, recovery blobs, nonce.
2. Client decrypts enc_key_salt using the recovery key (derived from the recovery code). 2. Client decrypts recovery_enc_salt using the recovery key → gets enc_key_salt.
3. Client derives new auth_hash and new vault key with a new master password. 3. Client computes: recovery_proof = HMAC-SHA256(enc_key_salt_bytes, nonce).
4. Client re-encrypts all vault items with the new vault key. 4. Client derives new credentials and re-encrypts all vault items.
5. Client POSTs everything here in one atomic payload. 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 {} data = request.get_json(silent=True) or {}
email = (data.get('email') or '').strip().lower() email = (data.get('email') or '').strip().lower()
new_auth_hash = data.get('new_auth_hash', '') new_auth_hash = data.get('new_auth_hash', '')
new_enc_key_salt = data.get('new_enc_key_salt', '') new_enc_key_salt = data.get('new_enc_key_salt', '')
# Proof that the client successfully decrypted enc_key_salt: client_proof = data.get('recovery_proof', '')
# 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', '')
items = data.get('items', []) 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 return jsonify({'error': 'email, new_auth_hash, new_enc_key_salt, and recovery_proof are required'}), 400
time.sleep(0.1) # timing mitigation 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() 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 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 if not verify_recovery_proof(expected_proof, client_proof):
# 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:
AuditLog.log( AuditLog.log(
user_id=user.id, user_id=user.id,
action='auth.recovery_failed', action='auth.recovery_failed',
resource_type='user', resource_type='user',
resource_id=user.id, resource_id=user.id,
detail='Recovery attempt failed — incorrect recovery code', detail='Recovery attempt failed — incorrect recovery proof',
ip_address=_client_ip(), ip_address=_client_ip(),
) )
db.session.commit() db.session.commit()
return jsonify({'error': 'Invalid recovery code'}), 401 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: try:
from app.models.vault_item import VaultItem from app.models.vault_item import VaultItem
@@ -589,7 +733,6 @@ def recover_account():
if vault_item: if vault_item:
vault_item.enc_data = enc_data vault_item.enc_data = enc_data
vault_item.iv = iv vault_item.iv = iv
# Re-encrypt the name ciphertext if the client sent updated enc_name/iv_name.
if item_data.get('enc_name'): if item_data.get('enc_name'):
vault_item.enc_name = item_data['enc_name'] vault_item.enc_name = item_data['enc_name']
if item_data.get('iv_name'): if item_data.get('iv_name'):
@@ -597,7 +740,7 @@ def recover_account():
user.master_hash = hash_auth_token(new_auth_hash) user.master_hash = hash_auth_token(new_auth_hash)
user.enc_key_salt = new_enc_key_salt 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_enc_salt = None
user.recovery_iv = None user.recovery_iv = None
@@ -628,9 +771,16 @@ def recover_account():
def recovery_data(): def recovery_data():
""" """
Return the data the client needs to attempt recovery (unauthenticated). Return the data the client needs to attempt recovery (unauthenticated).
Exposes only: enc_key_salt, recovery_enc_salt, recovery_iv. Exposes: enc_key_salt, recovery_enc_salt, recovery_iv, and a one-time nonce.
Returns 404 if no recovery code is configured (prevents user enumeration
of which accounts have recovery set up — same response for unknown email). 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() email = (request.args.get('email') or '').strip().lower()
if not email: if not email:
@@ -640,10 +790,28 @@ def recovery_data():
if not user or not user.recovery_enc_salt: if not user or not user.recovery_enc_salt:
return jsonify({'error': 'No recovery data found'}), 404 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({ return jsonify({
'enc_key_salt': user.enc_key_salt, 'enc_key_salt': user.enc_key_salt,
'recovery_enc_salt': user.recovery_enc_salt, 'recovery_enc_salt': user.recovery_enc_salt,
'recovery_iv': user.recovery_iv, 'recovery_iv': user.recovery_iv,
'nonce': nonce,
}), 200 }), 200
@@ -653,24 +821,31 @@ def recovery_items():
""" """
Return encrypted vault items for recovery re-encryption (unauthenticated). Return encrypted vault items for recovery re-encryption (unauthenticated).
Requires X-Recovery-Proof header containing the plaintext enc_key_salt. Requires X-Recovery-Proof header containing the HMAC-SHA256 proof:
The server verifies it matches user.enc_key_salt — proof that the client proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_from_recovery_data)
correctly decrypted the recovery blob (i.e. has the correct recovery code).
Items are returned as encrypted ciphertext blobs only — no sensitive The server validates the proof against the value precomputed and stored in
plaintext is exposed. The client re-encrypts them locally. 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() 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 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() 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 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( AuditLog.log(
user_id=user.id, user_id=user.id,
action='auth.recovery_items_denied', action='auth.recovery_items_denied',
@@ -690,4 +865,3 @@ def recovery_items():
for item in items for item in items
] ]
}), 200 }), 200
+120 -2
View File
@@ -1,4 +1,6 @@
import base64 import base64
import hashlib
import hmac
import os import os
import uuid import uuid
import time import time
@@ -139,8 +141,8 @@ def blacklist_token(token: str, token_type: str) -> None:
) )
db.session.add(entry) db.session.add(entry)
db.session.commit() db.session.commit()
# Opportunistic cleanup — runs in same transaction context # Cleanup is handled by the APScheduler background job in create_app(),
TokenBlacklist.cleanup_expired() # not here — keeps the logout/refresh hot path free of extra DB writes.
except Exception: except Exception:
pass # Never let blacklisting errors break the logout flow pass # Never let blacklisting errors break the logout flow
@@ -162,3 +164,119 @@ def require_jwt(f):
g.current_user_id = int(payload['sub']) g.current_user_id = int(payload['sub'])
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated 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
+151 -68
View File
@@ -44,8 +44,9 @@ const Recover = (() => {
// Module state between step 1 and step 2 // Module state between step 1 and step 2
let _email = null; let _email = null;
let _recoveryCode = null; let _recoveryCode = null;
let _oldEncKeySalt = null; // decrypted from recovery blob let _oldEncKeySalt = null; // decrypted from recovery blob
let _oldVaultKey = null; // derived for re-encrypting vault items let _oldVaultKey = null; // derived for re-encrypting vault items
let _recoveryProof = null; // HMAC-SHA256(enc_key_salt_bytes, nonce) — sent as proof
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
@@ -61,36 +62,39 @@ const Recover = (() => {
} }
function bytesToBase64(bytes) { function bytesToBase64(bytes) {
let bin = ''; let bin = "";
bytes.forEach(b => (bin += String.fromCharCode(b))); bytes.forEach((b) => (bin += String.fromCharCode(b)));
return btoa(bin); return btoa(bin);
} }
function formatRecoveryCode(raw) { function formatRecoveryCode(raw) {
// Display as groups of 4 for readability // 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) { function cleanRecoveryCode(input) {
// Strip hyphens/spaces so users can paste formatted or raw codes // 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) { function showError(id, message) {
const el = document.getElementById(id); 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) { function hideError(id) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.classList.add('hidden'); if (el) el.classList.add("hidden");
} }
function setLoading(btn, loading) { function setLoading(btn, loading) {
btn.disabled = loading; btn.disabled = loading;
btn.textContent = loading btn.textContent = loading
? (btn.dataset.loadingText || 'Please wait…') ? btn.dataset.loadingText || "Please wait…"
: (btn.dataset.originalText || btn.textContent); : btn.dataset.originalText || btn.textContent;
} }
// ── Crypto ───────────────────────────────────────────────────────────────── // ── Crypto ─────────────────────────────────────────────────────────────────
@@ -101,23 +105,23 @@ const Recover = (() => {
*/ */
async function deriveRecoveryKey(recoveryCode) { async function deriveRecoveryKey(recoveryCode) {
const baseKey = await subtle.importKey( const baseKey = await subtle.importKey(
'raw', "raw",
strToBytes(recoveryCode), strToBytes(recoveryCode),
'PBKDF2', "PBKDF2",
false, false,
['deriveKey'] ["deriveKey"],
); );
return subtle.deriveKey( return subtle.deriveKey(
{ {
name: 'PBKDF2', name: "PBKDF2",
salt: strToBytes('passkeeper-recovery'), salt: strToBytes("passkeeper-recovery"),
iterations: 200_000, iterations: 200_000,
hash: 'SHA-256', hash: "SHA-256",
}, },
baseKey, baseKey,
{ name: 'AES-GCM', length: 256 }, { name: "AES-GCM", length: 256 },
false, false,
['encrypt', 'decrypt'] ["encrypt", "decrypt"],
); );
} }
@@ -128,9 +132,9 @@ const Recover = (() => {
async function encryptEncKeySalt(recoveryKey, encKeySalt) { async function encryptEncKeySalt(recoveryKey, encKeySalt) {
const iv = window.crypto.getRandomValues(new Uint8Array(12)); const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await subtle.encrypt( const ciphertext = await subtle.encrypt(
{ name: 'AES-GCM', iv }, { name: "AES-GCM", iv },
recoveryKey, recoveryKey,
strToBytes(encKeySalt) strToBytes(encKeySalt),
); );
return { return {
recovery_enc_salt: bytesToBase64(new Uint8Array(ciphertext)), recovery_enc_salt: bytesToBase64(new Uint8Array(ciphertext)),
@@ -144,35 +148,68 @@ const Recover = (() => {
*/ */
async function decryptEncKeySalt(recoveryKey, recoveryEncSalt, recoveryIv) { async function decryptEncKeySalt(recoveryKey, recoveryEncSalt, recoveryIv) {
const plaintext = await subtle.decrypt( const plaintext = await subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(recoveryIv) }, { name: "AES-GCM", iv: base64ToBytes(recoveryIv) },
recoveryKey, recoveryKey,
base64ToBytes(recoveryEncSalt) base64ToBytes(recoveryEncSalt),
); );
return new TextDecoder().decode(plaintext); 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 ─────────────────────────────────────────── // ── Step 1: Verify recovery code ───────────────────────────────────────────
async function handleStep1(e) { async function handleStep1(e) {
e.preventDefault(); e.preventDefault();
hideError('recover-error-1'); hideError("recover-error-1");
const btn = e.target.querySelector('[type="submit"]'); const btn = e.target.querySelector('[type="submit"]');
btn.dataset.originalText = btn.textContent; btn.dataset.originalText = btn.textContent;
setLoading(btn, true); setLoading(btn, true);
try { try {
const email = document.getElementById('recover-email').value.trim().toLowerCase(); const email = document
const rawCode = cleanRecoveryCode(document.getElementById('recover-code').value.trim()); .getElementById("recover-email")
.value.trim()
.toLowerCase();
const rawCode = cleanRecoveryCode(
document.getElementById("recover-code").value.trim(),
);
if (!email || !rawCode) { if (!email || !rawCode) {
showError('recover-error-1', 'Email and recovery code are required.'); showError("recover-error-1", "Email and recovery code are required.");
return; return;
} }
// Fetch recovery blobs from server // 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) { 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; return;
} }
const data = await res.json(); const data = await res.json();
@@ -184,27 +221,37 @@ const Recover = (() => {
decryptedEncKeySalt = await decryptEncKeySalt( decryptedEncKeySalt = await decryptEncKeySalt(
recoveryKey, recoveryKey,
data.recovery_enc_salt, data.recovery_enc_salt,
data.recovery_iv data.recovery_iv,
); );
} catch { } 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; 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 // Derive the old vault key using the recovery code as master password proxy
_oldVaultKey = await Crypto.deriveVaultKey(rawCode, decryptedEncKeySalt); _oldVaultKey = await Crypto.deriveVaultKey(rawCode, decryptedEncKeySalt);
_email = email; _email = email;
_recoveryCode = rawCode; _recoveryCode = rawCode;
_oldEncKeySalt = decryptedEncKeySalt; _oldEncKeySalt = decryptedEncKeySalt;
_recoveryProof = proof;
// Show step 2 // Show step 2
document.getElementById('recover-step-1').classList.add('hidden'); document.getElementById("recover-step-1").classList.add("hidden");
document.getElementById('recover-step-2').classList.remove('hidden'); document.getElementById("recover-step-2").classList.remove("hidden");
document.getElementById('recover-new-pass').focus(); document.getElementById("recover-new-pass").focus();
} catch (err) { } 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); console.error(err);
} finally { } finally {
setLoading(btn, false); setLoading(btn, false);
@@ -215,28 +262,36 @@ const Recover = (() => {
async function handleStep2(e) { async function handleStep2(e) {
e.preventDefault(); e.preventDefault();
hideError('recover-error-2'); hideError("recover-error-2");
const btn = e.target.querySelector('[type="submit"]'); const btn = e.target.querySelector('[type="submit"]');
btn.dataset.originalText = btn.textContent; btn.dataset.originalText = btn.textContent;
setLoading(btn, true); setLoading(btn, true);
try { try {
const newPassword = document.getElementById('recover-new-pass').value; const newPassword = document.getElementById("recover-new-pass").value;
const confirmPassword = document.getElementById('recover-confirm-pass').value; const confirmPassword = document.getElementById(
"recover-confirm-pass",
).value;
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
showError('recover-error-2', 'Passwords do not match.'); showError("recover-error-2", "Passwords do not match.");
return; return;
} }
if (newPassword.length < 12) { 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; return;
} }
// Derive new credentials // Derive new credentials
const newAuthHash = await Crypto.deriveAuthHash(newPassword, _email); const newAuthHash = await Crypto.deriveAuthHash(newPassword, _email);
const newEncKeySalt = Crypto.generateSalt(16); 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) // Fetch all vault items (encrypted with old vault key)
// We use a minimal unauthenticated fetch here — items are still ciphertext on the wire. // 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. // Since items are ciphertext and we verify recovery code server-side, this is acceptable.
// Fetch items unauthenticated via a recovery-scoped endpoint // Fetch items unauthenticated via a recovery-scoped endpoint
const itemsRes = await fetch(`/api/auth/recovery/items?email=${encodeURIComponent(_email)}`, { const itemsRes = await fetch(
headers: { 'X-Recovery-Proof': _oldEncKeySalt }, `/api/auth/recovery/items?email=${encodeURIComponent(_email)}`,
}); {
headers: { "X-Recovery-Proof": _recoveryProof },
},
);
let reEncryptedItems = []; let reEncryptedItems = [];
if (itemsRes.ok) { if (itemsRes.ok) {
@@ -274,8 +332,15 @@ const Recover = (() => {
// Re-encrypt each item: old vault key → new vault key // Re-encrypt each item: old vault key → new vault key
for (const item of itemsData.items) { for (const item of itemsData.items) {
try { try {
const plain = await Crypto.decryptItem(_oldVaultKey, item.enc_data, item.iv); const plain = await Crypto.decryptItem(
const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain); _oldVaultKey,
item.enc_data,
item.iv,
);
const { enc_data, iv } = await Crypto.encryptItem(
newVaultKey,
plain,
);
reEncryptedItems.push({ id: item.id, enc_data, iv }); reEncryptedItems.push({ id: item.id, enc_data, iv });
} catch { } catch {
// Item decryption failed — skip (shouldn't happen if recovery code is correct) // Item decryption failed — skip (shouldn't happen if recovery code is correct)
@@ -285,37 +350,45 @@ const Recover = (() => {
} }
// Submit recovery // Submit recovery
const recoverRes = await fetch('/api/auth/recover', { const recoverRes = await fetch("/api/auth/recover", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
email: _email, email: _email,
new_auth_hash: newAuthHash, new_auth_hash: newAuthHash,
new_enc_key_salt: newEncKeySalt, new_enc_key_salt: newEncKeySalt,
recovery_proof: _oldEncKeySalt, recovery_proof: _recoveryProof,
items: reEncryptedItems, items: reEncryptedItems,
}), }),
}); });
const recoverData = await recoverRes.json(); const recoverData = await recoverRes.json();
if (!recoverRes.ok) { 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; return;
} }
// Store session and redirect // Store session and redirect
sessionStorage.setItem('access_token', recoverData.access_token); sessionStorage.setItem("access_token", recoverData.access_token);
localStorage.setItem('refresh_token', recoverData.refresh_token); localStorage.setItem("refresh_token", recoverData.refresh_token);
sessionStorage.setItem('enc_key_salt', recoverData.enc_key_salt); sessionStorage.setItem("enc_key_salt", recoverData.enc_key_salt);
// Set vault key in VaultSession so unlock overlay is skipped // 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); VaultSession.setKey(finalVaultKey);
window.location.href = '/vault?recovered=1'; window.location.href = "/vault?recovered=1";
} catch (err) { } 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); console.error(err);
} finally { } finally {
setLoading(btn, false); setLoading(btn, false);
@@ -325,14 +398,18 @@ const Recover = (() => {
// ── Init ─────────────────────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────────────────
function init() { function init() {
document.getElementById('recover-form-step1')?.addEventListener('submit', handleStep1); document
document.getElementById('recover-form-step2')?.addEventListener('submit', handleStep2); .getElementById("recover-form-step1")
?.addEventListener("submit", handleStep1);
document
.getElementById("recover-form-step2")
?.addEventListener("submit", handleStep2);
const toggleBtn = document.getElementById('toggle-recover-pass'); const toggleBtn = document.getElementById("toggle-recover-pass");
const passInput = document.getElementById('recover-new-pass'); const passInput = document.getElementById("recover-new-pass");
if (toggleBtn && passInput) { if (toggleBtn && passInput) {
toggleBtn.addEventListener('click', () => { toggleBtn.addEventListener("click", () => {
passInput.type = passInput.type === 'password' ? 'text' : 'password'; passInput.type = passInput.type === "password" ? "text" : "password";
}); });
} }
} }
@@ -345,10 +422,16 @@ const Recover = (() => {
const VaultSession = (() => { const VaultSession = (() => {
let _key = null; let _key = null;
return { return {
setKey(k) { _key = k; }, setKey(k) {
getKey() { return _key; }, _key = k;
clear() { _key = null; }, },
getKey() {
return _key;
},
clear() {
_key = null;
},
}; };
})(); })();
document.addEventListener('DOMContentLoaded', Recover.init); document.addEventListener("DOMContentLoaded", Recover.init);
@@ -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')