05/02/2026 updated code for security
This commit is contained in:
@@ -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
|
||||||
@@ -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')
|
||||||
|
|||||||
+223
-49
@@ -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()
|
||||||
|
|
||||||
|
# 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_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
|
||||||
|
|
||||||
if not user or not verify_auth_token(auth_hash, user.master_hash):
|
if not user or not verify_auth_token(auth_hash, user.master_hash):
|
||||||
if user:
|
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(
|
AuditLog.log(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
action='auth.login_failed',
|
action='auth.login_failed',
|
||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail='Failed login attempt — invalid password',
|
detail=f'Failed login attempt — invalid password ({user.failed_login_count}/{MAX_FAILED_LOGINS})',
|
||||||
ip_address=_client_ip(),
|
ip_address=_client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+149
-66
@@ -46,6 +46,7 @@ const Recover = (() => {
|
|||||||
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')
|
||||||
Reference in New Issue
Block a user