Files
PassKeeper/app/routes/auth.py
T
nngo b84a6d9245
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
Aug 26 - Enhance security 4
2026-08-26 14:19:25 -04:00

1217 lines
47 KiB
Python

import logging
import re
import time
from flask import Blueprint, request, jsonify, g
from app import db, limiter, client_ip
from app.models.user import User
from app.models.audit_log import AuditLog
from app.services.auth_service import (
hash_auth_token,
verify_auth_token,
generate_tokens,
load_user_for_token,
generate_mfa_token,
decode_token,
blacklist_token,
require_jwt,
encrypt_totp_secret,
decrypt_totp_secret,
generate_recovery_nonce,
verify_recovery_proof,
generate_backup_codes,
verify_and_consume_backup_code,
is_totp_code_used,
mark_totp_code_used,
)
_log = logging.getLogger(__name__)
auth_bp = Blueprint('auth', __name__)
EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
# Fixed-length hex validator for the client-supplied recovery verifier.
_HEX64_RE = re.compile(r'^[0-9a-f]{64}$')
def _recovery_proof_key(user) -> bytes:
"""
Return the HMAC key the recovery challenge-response is computed over.
Preferred: user.recovery_verifier — a 256-bit value derived client-side from
the recovery code alone. It is used for nothing else, so learning it grants
no decryption ability, and knowing enc_key_salt does not yield it.
Legacy fallback: user.enc_key_salt, for recovery codes created before the
verifier existed. This is weaker — enc_key_salt is also the vault-key PBKDF2
salt and is disclosed to the client on login, so anyone holding the master
password can forge a proof and pull the vault from /recovery/items without a
second factor. Accounts on this path are flagged via /recovery/status so the
settings UI can prompt the user to regenerate.
"""
if user.recovery_verifier:
return user.recovery_verifier.encode()
return user.enc_key_salt.encode()
class IncompleteReencryption(Exception):
"""
Raised when a key-rotation payload does not cover every vault item the user
owns. Rotating enc_key_salt while some ciphertext is still under the old key
renders those items permanently undecryptable, so the whole transaction is
refused unless the caller explicitly opts into partial coverage.
"""
def __init__(self, expected: int, received: int):
self.expected = expected
self.received = received
super().__init__(f'expected {expected} item(s), received {received}')
def _apply_reencrypted_items(user_id: int, items, allow_partial: bool = False) -> tuple[int, int]:
"""
Apply client-supplied re-encrypted ciphertext to the user's vault items.
Both the password-change and account-recovery flows rotate enc_key_salt,
which invalidates every ciphertext encrypted under the previous vault key.
The client is responsible for re-encrypting each item and sending it back;
any item missing from that payload is silently orphaned by the rotation.
This helper therefore counts what it actually wrote and compares it against
the number of items the user owns. On a shortfall it raises
IncompleteReencryption so the caller can roll back rather than commit a
rotation that destroys data.
allow_partial=True skips the guard. The web client only sets it after
showing the user exactly how many items will be lost and getting explicit
confirmation — it exists so a single corrupt item cannot lock someone out of
recovery entirely.
Returns (updated_count, total_count). Does not commit.
"""
from app.models.vault_item import VaultItem
total = VaultItem.query.filter_by(user_id=user_id).count()
item_ids = [i.get('id') for i in (items or []) if i.get('id')]
existing = {
v.id: v
for v in VaultItem.query.filter(
VaultItem.user_id == user_id,
VaultItem.id.in_(item_ids),
).all()
} if item_ids else {}
updated = 0
for item_data in (items or []):
item_id = item_data.get('id')
enc_data = item_data.get('enc_data', '')
iv = item_data.get('iv', '')
if not item_id or not enc_data or not iv:
continue
vault_item = existing.get(item_id)
if not vault_item:
continue
vault_item.enc_data = enc_data
vault_item.iv = iv
# Re-encrypt the name ciphertext if the client sent updated enc_name/iv_name.
if item_data.get('enc_name'):
vault_item.enc_name = item_data['enc_name']
if item_data.get('iv_name'):
vault_item.iv_name = item_data['iv_name']
updated += 1
if updated != total and not allow_partial:
raise IncompleteReencryption(expected=total, received=updated)
return updated, total
@auth_bp.route('/register', methods=['POST'])
@limiter.limit('5 per minute')
def register():
data = request.get_json(silent=True) or {}
email = (data.get('email') or '').strip().lower()
auth_hash = data.get('auth_hash', '')
enc_key_salt = data.get('enc_key_salt', '')
if not email or not EMAIL_RE.match(email):
return jsonify({'error': 'Invalid email address'}), 400
if len(email) > 254:
return jsonify({'error': 'Email address is too long'}), 400
if not auth_hash:
return jsonify({'error': 'auth_hash is required'}), 400
if not enc_key_salt:
return jsonify({'error': 'enc_key_salt is required'}), 400
# ── Account-existence must not be observable ────────────────────────────
#
# This used to answer 409 "Email already registered", which let anyone probe
# whether a given address has a PassKeeper account — a useful target list for
# phishing, and exactly the kind of thing a password manager should not leak.
#
# Both branches now return the identical 202 body. The wording sends the user
# to the sign-in page either way, which is the correct next step in both
# cases: registering an address that already exists is harmless because the
# user simply signs in with the password they already have.
#
# Timing has to match too. Creating an account runs Argon2id (deliberately
# slow); returning early without it would make "exists" measurably faster and
# reinstate the oracle through the side door. So the existing-account branch
# performs and discards an equivalent hash.
#
# NOTE: fully closing this needs email verification (roadmap item 4) so the
# address owner is told when someone tries to register it. Until then this
# removes the oracle but cannot notify the legitimate owner.
generic_response = jsonify({
'message': (
'If that email address was available, your account has been created. '
'Please sign in.'
)
}), 202
time.sleep(0.1) # flatten timing across both branches
if User.query.filter_by(email=email).first():
hash_auth_token(auth_hash) # equalise work; result intentionally discarded
AuditLog.log(
user_id=0, # no account to attribute this to
action='auth.register_duplicate',
resource_type='user',
resource_id=None,
detail='Registration attempted for an address that already exists',
ip_address=client_ip(),
)
db.session.commit()
return generic_response
master_hash = hash_auth_token(auth_hash)
user = User(email=email, master_hash=master_hash, enc_key_salt=enc_key_salt)
db.session.add(user)
db.session.flush() # populate user.id before logging
AuditLog.log(
user_id=user.id,
action='auth.register',
resource_type='user',
resource_id=user.id,
detail=f'New account registered: {email}',
ip_address=client_ip(),
)
db.session.commit()
return generic_response
@auth_bp.route('/login', methods=['POST'])
@limiter.limit('10 per minute')
def login():
from datetime import datetime, timezone, timedelta
from sqlalchemy.exc import OperationalError
# Number of consecutive failures before a temporary lockout is applied.
MAX_FAILED_LOGINS = 5
LOCKOUT_MINUTES = 15
data = request.get_json(silent=True) or {}
email = (data.get('email') or '').strip().lower()
auth_hash = data.get('auth_hash', '')
time.sleep(0.1) # mitigate timing-based user enumeration
if not email or not auth_hash:
return jsonify({'error': 'Email and auth_hash are required'}), 400
user = User.query.filter_by(email=email).first()
# Per-account lockout check.
# Guarded with try/except so that a deployment where the migration has not
# yet been run (columns missing) degrades gracefully instead of returning
# an HTML 500 page that breaks JSON parsing in the extension.
try:
if user and user.locked_until:
now = datetime.now(timezone.utc).replace(tzinfo=None)
if user.locked_until > now:
remaining = int((user.locked_until - now).total_seconds() // 60) + 1
AuditLog.log(
user_id=user.id,
action='auth.login_blocked',
resource_type='user',
resource_id=user.id,
detail=f'Login blocked — account locked for {remaining} more minute(s)',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({
'error': f'Account temporarily locked. Try again in {remaining} minute(s).'
}), 429
else:
# Lockout has expired — reset the counter.
user.failed_login_count = 0
user.locked_until = None
except OperationalError:
# Columns do not exist yet — migration pending. Skip lockout check.
db.session.rollback()
if not user or not verify_auth_token(auth_hash, user.master_hash, user=user):
if user:
try:
user.failed_login_count = (user.failed_login_count or 0) + 1
if user.failed_login_count >= MAX_FAILED_LOGINS:
user.locked_until = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(minutes=LOCKOUT_MINUTES)
AuditLog.log(
user_id=user.id,
action='auth.account_locked',
resource_type='user',
resource_id=user.id,
detail=f'Account locked for {LOCKOUT_MINUTES} minutes after {user.failed_login_count} failed attempts',
ip_address=client_ip(),
)
else:
AuditLog.log(
user_id=user.id,
action='auth.login_failed',
resource_type='user',
resource_id=user.id,
detail=f'Failed login attempt — invalid password ({user.failed_login_count}/{MAX_FAILED_LOGINS})',
ip_address=client_ip(),
)
db.session.commit()
except OperationalError:
db.session.rollback()
AuditLog.log(
user_id=user.id,
action='auth.login_failed',
resource_type='user',
resource_id=user.id,
detail='Failed login attempt — invalid password',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'error': 'Invalid email or password'}), 401
# Successful authentication — reset lockout state.
try:
user.failed_login_count = 0
user.locked_until = None
except OperationalError:
db.session.rollback()
user.last_login = datetime.now(timezone.utc).replace(tzinfo=None)
AuditLog.log(
user_id=user.id,
action='auth.login',
resource_type='user',
resource_id=user.id,
detail=f'Successful login{" (MFA pending)" if user.totp_enabled else ""}',
ip_address=client_ip(),
)
db.session.commit()
# MFA gate: if enabled, issue a short-lived mfa_token instead of full tokens.
#
# enc_key_salt is deliberately NOT returned here. It is the PBKDF2 salt for
# the vault key, and releasing it to a caller that has only cleared the
# password factor is a partial authentication result. The client receives it
# from /mfa/verify once the second factor is satisfied.
if user.totp_enabled:
mfa_token = generate_mfa_token(user.id)
return jsonify({
'mfa_required': True,
'mfa_token': mfa_token,
}), 200
tokens = generate_tokens(user.id, user.token_epoch)
return jsonify({
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
'enc_key_salt': user.enc_key_salt,
}), 200
@auth_bp.route('/logout', methods=['POST'])
@limiter.limit('60 per minute')
def logout():
"""Blacklist both the access token (from header) and refresh token (from body)."""
auth_header = request.headers.get('Authorization', '')
if auth_header.startswith('Bearer '):
blacklist_token(auth_header[7:], 'access')
data = request.get_json(silent=True) or {}
refresh_token = data.get('refresh_token', '')
if refresh_token:
blacklist_token(refresh_token, 'refresh')
return jsonify({'message': 'Logged out'}), 200
@auth_bp.route('/refresh', methods=['POST'])
@limiter.limit('30 per minute')
def refresh():
data = request.get_json(silent=True) or {}
refresh_token = data.get('refresh_token', '')
if not refresh_token:
return jsonify({'error': 'refresh_token is required'}), 400
try:
payload = decode_token(refresh_token, expected_type='refresh')
except Exception:
return jsonify({'error': 'Invalid or expired refresh token'}), 401
# Same gate as require_jwt: the account must still exist and the token's
# epoch must still match. Without this a refresh token captured before a
# password change could keep minting fresh access tokens for its full
# 7-day lifetime, defeating the revocation entirely.
user = load_user_for_token(payload)
if user is None:
return jsonify({'error': 'Session is no longer valid. Please log in again.'}), 401
# Rotate: blacklist old refresh token and issue fresh pair
blacklist_token(refresh_token, 'refresh')
tokens = generate_tokens(user.id, user.token_epoch)
return jsonify({
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
}), 200
# ── MFA / TOTP endpoints ─────────────────────────────────────────────────────
@auth_bp.route('/mfa/setup', methods=['GET'])
@limiter.limit('10 per minute')
@require_jwt
def mfa_setup():
"""Generate a new TOTP secret and return QR code (as base64 PNG data URI)."""
user = db.session.get(User, g.current_user_id)
if user.totp_enabled:
return jsonify({'error': 'MFA is already enabled'}), 400
import pyotp
import qrcode
import io
import base64
secret = pyotp.random_base32()
uri = pyotp.TOTP(secret).provisioning_uri(
name=user.email,
issuer_name='PassKeeper',
)
img = qrcode.make(uri)
buf = io.BytesIO()
img.save(buf, format='PNG')
qr_b64 = base64.b64encode(buf.getvalue()).decode()
return jsonify({
'secret': secret,
'qr_code': f'data:image/png;base64,{qr_b64}',
'uri': uri,
}), 200
@auth_bp.route('/mfa/enable', methods=['POST'])
@limiter.limit('10 per minute')
@require_jwt
def mfa_enable():
"""Enable MFA after verifying the first TOTP code. Returns one-time backup codes."""
import json
user = db.session.get(User, g.current_user_id)
if user.totp_enabled:
return jsonify({'error': 'MFA is already enabled'}), 400
data = request.get_json(silent=True) or {}
secret = (data.get('secret') or '').strip()
totp_code = (data.get('totp_code') or '').strip()
if not secret or not totp_code:
return jsonify({'error': 'secret and totp_code are required'}), 400
import pyotp
if not pyotp.TOTP(secret).verify(totp_code, valid_window=1):
return jsonify({'error': 'Invalid verification code'}), 400
# Encrypt the secret before replay check so totp_secret_enc/totp_iv are defined.
totp_secret_enc, totp_iv = encrypt_totp_secret(secret)
# Prevent replay: reject a code that was already consumed within the valid window.
# user.id is not yet persisted (MFA not enabled), so use g.current_user_id directly.
if is_totp_code_used(g.current_user_id, totp_code):
return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400
mark_totp_code_used(g.current_user_id, totp_code)
user.totp_secret = totp_secret_enc
user.totp_iv = totp_iv
user.totp_enabled = True
# Generate one-time backup codes — plaintext shown once, only hashes stored.
plaintext_codes, hashed_codes = generate_backup_codes()
user.mfa_backup_codes = json.dumps(hashed_codes)
AuditLog.log(
user_id=user.id,
action='auth.mfa_enable',
resource_type='user',
resource_id=user.id,
detail='TOTP two-factor authentication enabled; backup codes generated',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({
'message': 'MFA enabled successfully',
'backup_codes': plaintext_codes,
}), 200
@auth_bp.route('/mfa/disable', methods=['POST'])
@limiter.limit('10 per minute')
@require_jwt
def mfa_disable():
"""Disable MFA after verifying the current TOTP code or a backup code."""
import json
user = db.session.get(User, g.current_user_id)
if not user.totp_enabled:
return jsonify({'error': 'MFA is not enabled'}), 400
data = request.get_json(silent=True) or {}
totp_code = (data.get('totp_code') or '').strip()
backup_code = (data.get('backup_code') or '').strip().lower().replace('-', '').replace(' ', '')
import pyotp
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv)
verified = False
if totp_code:
if is_totp_code_used(user.id, totp_code):
return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400
verified = pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1)
if verified:
mark_totp_code_used(user.id, totp_code)
elif backup_code:
stored = json.loads(user.mfa_backup_codes or '[]')
matched, remaining = verify_and_consume_backup_code(stored, backup_code)
if matched:
user.mfa_backup_codes = json.dumps(remaining)
verified = True
if not verified:
return jsonify({'error': 'Invalid verification code'}), 400
user.totp_secret = None
user.totp_iv = None
user.totp_enabled = False
user.mfa_backup_codes = None
AuditLog.log(
user_id=user.id,
action='auth.mfa_disable',
resource_type='user',
resource_id=user.id,
detail='TOTP two-factor authentication disabled; backup codes cleared',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'message': 'MFA disabled'}), 200
@auth_bp.route('/mfa/verify', methods=['POST'])
@limiter.limit('10 per minute')
def mfa_verify():
"""Complete MFA login: verify TOTP code (or backup code) and exchange mfa_token for real tokens."""
import json
data = request.get_json(silent=True) or {}
mfa_token = data.get('mfa_token', '')
totp_code = (data.get('totp_code') or '').strip()
backup_code = (data.get('backup_code') or '').strip().lower().replace('-', '').replace(' ', '')
if not mfa_token or (not totp_code and not backup_code):
return jsonify({'error': 'mfa_token and either totp_code or backup_code are required'}), 400
try:
payload = decode_token(mfa_token, expected_type='mfa', check_blacklist=True)
except Exception:
return jsonify({'error': 'Invalid or expired MFA token'}), 401
user = db.session.get(User, int(payload['sub']))
if not user or not user.totp_enabled:
return jsonify({'error': 'MFA not configured for this account'}), 400
import pyotp
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv)
verified = False
if totp_code:
if is_totp_code_used(user.id, totp_code):
return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400
verified = pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1)
if verified:
mark_totp_code_used(user.id, totp_code)
if not verified and backup_code:
stored = json.loads(user.mfa_backup_codes or '[]')
matched, remaining = verify_and_consume_backup_code(stored, backup_code)
if matched:
user.mfa_backup_codes = json.dumps(remaining)
verified = True
AuditLog.log(
user_id=user.id,
action='auth.mfa_backup_code_used',
resource_type='user',
resource_id=user.id,
detail=f'MFA backup code used; {len(remaining)} code(s) remaining',
ip_address=client_ip(),
)
if not verified:
return jsonify({'error': 'Invalid verification code'}), 400
# One-time use: blacklist the mfa_token
blacklist_token(mfa_token, 'mfa')
AuditLog.log(
user_id=user.id,
action='auth.mfa_verify',
resource_type='user',
resource_id=user.id,
detail='MFA verification successful — session tokens issued',
ip_address=client_ip(),
)
db.session.commit()
tokens = generate_tokens(user.id, user.token_epoch)
return jsonify({
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
# Released here rather than at /login — both factors are now proven.
'enc_key_salt': user.enc_key_salt,
}), 200
@auth_bp.route('/mfa/status', methods=['GET'])
@limiter.limit('60 per minute')
@require_jwt
def mfa_status():
user = db.session.get(User, g.current_user_id)
import json
stored = json.loads(user.mfa_backup_codes or '[]')
return jsonify({
'totp_enabled': user.totp_enabled,
'backup_codes_remaining': len(stored),
}), 200
@auth_bp.route('/mfa/backup-codes/regenerate', methods=['POST'])
@limiter.limit('5 per minute')
@require_jwt
def mfa_backup_codes_regenerate():
"""
Regenerate MFA backup codes. Requires a valid TOTP code to authorise.
All existing backup codes are invalidated and replaced.
Returns the new plaintext codes — shown once, never stored.
"""
import json
user = db.session.get(User, g.current_user_id)
if not user.totp_enabled:
return jsonify({'error': 'MFA is not enabled'}), 400
data = request.get_json(silent=True) or {}
totp_code = (data.get('totp_code') or '').strip()
if not totp_code:
return jsonify({'error': 'totp_code is required'}), 400
import pyotp
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv)
if is_totp_code_used(user.id, totp_code):
return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400
if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1):
return jsonify({'error': 'Invalid verification code'}), 400
mark_totp_code_used(user.id, totp_code)
plaintext_codes, hashed_codes = generate_backup_codes()
user.mfa_backup_codes = json.dumps(hashed_codes)
AuditLog.log(
user_id=user.id,
action='auth.mfa_backup_codes_regenerated',
resource_type='user',
resource_id=user.id,
detail='MFA backup codes regenerated — previous codes invalidated',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({
'message': 'Backup codes regenerated. Save these — they will not be shown again.',
'backup_codes': plaintext_codes,
}), 200
@auth_bp.route('/me', methods=['GET'])
@limiter.limit('60 per minute')
@require_jwt
def me():
"""Return basic profile info for the authenticated user."""
import json
user = db.session.get(User, g.current_user_id)
stored_codes = json.loads(user.mfa_backup_codes or '[]')
return jsonify({
'id': user.id,
'email': user.email,
'created_at': user.created_at.isoformat() if user.created_at else None,
'last_login': user.last_login.isoformat() if user.last_login else None,
'totp_enabled': user.totp_enabled,
'backup_codes_remaining': len(stored_codes),
'recovery_configured': bool(user.recovery_enc_salt),
}), 200
@auth_bp.route('/audit-log', methods=['GET'])
@require_jwt
@limiter.limit('30 per minute')
def audit_log():
"""
Return the authenticated user's recent audit log entries.
Query params:
limit — max entries to return (default 50, max 200)
offset — pagination offset (default 0)
Sensitive field values are never logged — entries contain only action
types, resource IDs, timestamps, and IP addresses.
"""
try:
limit = min(int(request.args.get('limit', 50)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
except (ValueError, TypeError):
return jsonify({'error': 'limit and offset must be integers'}), 400
entries = (
AuditLog.query
.filter_by(user_id=g.current_user_id)
.order_by(AuditLog.created_at.desc())
.limit(limit)
.offset(offset)
.all()
)
total = AuditLog.query.filter_by(user_id=g.current_user_id).count()
return jsonify({
'total': total,
'limit': limit,
'offset': offset,
'entries': [e.to_dict() for e in entries],
}), 200
# ── Account management ────────────────────────────────────────────────────────
@auth_bp.route('/change-password', methods=['POST'])
@limiter.limit('5 per minute')
@require_jwt
def change_password():
"""
Change master password — zero-knowledge atomic re-encryption.
The client must:
1. Derive current auth_hash and verify it locally against what it knows.
2. Re-encrypt every vault item with the new vault key client-side.
3. POST the new credentials + all re-encrypted item blobs in one request.
The server verifies the current password, updates master_hash + enc_key_salt,
and bulk-replaces all vault item ciphertexts atomically. If any step fails,
the entire transaction is rolled back — the vault is never left in a split state.
"""
data = request.get_json(silent=True) or {}
current_auth_hash = data.get('current_auth_hash', '')
new_auth_hash = data.get('new_auth_hash', '')
new_enc_key_salt = data.get('new_enc_key_salt', '')
items = data.get('items', []) # [{id, enc_data, iv, enc_name?, iv_name?}, ...]
sharing_private_key_enc = data.get('sharing_private_key_enc', '')
sharing_private_key_iv = data.get('sharing_private_key_iv', '')
# NOTE: there is deliberately no allow_partial opt-in here.
#
# Recovery needs one, because refusing outright leaves a locked-out user with
# no way into their account. Changing the password has no such pressure — the
# current password keeps working — so accepting data loss is never the right
# answer, and the server refuses regardless of what the client asks for.
if not current_auth_hash or not new_auth_hash or not new_enc_key_salt:
return jsonify({'error': 'current_auth_hash, new_auth_hash, and new_enc_key_salt are required'}), 400
user = db.session.get(User, g.current_user_id)
if not verify_auth_token(current_auth_hash, user.master_hash, user=user):
AuditLog.log(
user_id=user.id,
action='auth.change_password_failed',
resource_type='user',
resource_id=user.id,
detail='Password change rejected — current password incorrect',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'error': 'Current password is incorrect'}), 401
try:
# Refuse the rotation outright unless every item was re-encrypted —
# see _apply_reencrypted_items. Raises IncompleteReencryption otherwise.
updated, total = _apply_reencrypted_items(user.id, items)
# Update credentials
user.master_hash = hash_auth_token(new_auth_hash)
user.enc_key_salt = new_enc_key_salt
# Clear recovery data — it was encrypted with the old vault key and is now invalid
user.recovery_enc_salt = None
user.recovery_iv = None
user.recovery_verifier = None
# Revoke every token issued under the old password. Without this the
# "Please log in again" message below is advisory only — outstanding
# refresh tokens would stay valid for their full 7-day lifetime.
user.token_epoch = (user.token_epoch or 0) + 1
# Re-encrypt sharing private key with new vault key if the client sent it.
# Without this update, the old ciphertext would be undecryptable after key rotation.
if sharing_private_key_enc and sharing_private_key_iv:
user.sharing_private_key_enc = sharing_private_key_enc
user.sharing_private_key_iv = sharing_private_key_iv
AuditLog.log(
user_id=user.id,
action='auth.change_password',
resource_type='user',
resource_id=user.id,
detail=(
f'Master password changed; {updated}/{total} vault item(s) '
're-encrypted; recovery code cleared'
),
ip_address=client_ip(),
)
db.session.commit()
except IncompleteReencryption as exc:
db.session.rollback()
AuditLog.log(
user_id=g.current_user_id,
action='auth.change_password_failed',
resource_type='user',
resource_id=g.current_user_id,
detail=(
f'Password change refused — re-encryption payload covered '
f'{exc.received} of {exc.expected} vault item(s)'
),
ip_address=client_ip(),
)
db.session.commit()
return jsonify({
'error': (
f'Password not changed: the re-encryption payload covered only '
f'{exc.received} of your {exc.expected} vault item(s). Completing '
'this would permanently lock the missing items. Reload the vault '
'and try again.'
),
'code': 'incomplete_reencryption',
'expected': exc.expected,
'received': exc.received,
}), 409
except Exception:
db.session.rollback()
_log.exception('change_password failed for user %s', g.current_user_id)
return jsonify({'error': 'Password change failed. Please try again.'}), 500
return jsonify({'message': 'Password changed successfully. Please log in again.'}), 200
@auth_bp.route('/account', methods=['DELETE'])
@limiter.limit('3 per minute')
@require_jwt
def delete_account():
"""
Permanently delete the authenticated user's account and all associated data.
Requires the current auth_hash for confirmation.
Cascading deletes handle vault_items, folders, shared_items, emergency_access.
"""
data = request.get_json(silent=True) or {}
auth_hash = data.get('auth_hash', '')
if not auth_hash:
return jsonify({'error': 'auth_hash is required for account deletion'}), 400
user = db.session.get(User, g.current_user_id)
if not verify_auth_token(auth_hash, user.master_hash):
AuditLog.log(
user_id=user.id,
action='auth.delete_account_failed',
resource_type='user',
resource_id=user.id,
detail='Account deletion rejected — password incorrect',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'error': 'Incorrect password'}), 401
user_id = user.id
user_email = user.email
try:
# Log before delete (user row will be gone after commit)
AuditLog.log(
user_id=user_id,
action='auth.delete_account',
resource_type='user',
resource_id=user_id,
detail=f'Account permanently deleted: {user_email}',
ip_address=client_ip(),
)
db.session.delete(user)
db.session.commit()
except Exception:
db.session.rollback()
_log.exception('delete_account failed for user %s', user_id)
return jsonify({'error': 'Account deletion failed. Please try again.'}), 500
return jsonify({'message': 'Account deleted'}), 200
# ── Account Recovery ──────────────────────────────────────────────────────────
@auth_bp.route('/recovery/setup', methods=['POST'])
@limiter.limit('10 per minute')
@require_jwt
def recovery_setup():
"""
Store a recovery-key-encrypted copy of enc_key_salt.
The client generates a random 128-bit recovery code, derives a recovery key
from it (PBKDF2), encrypts enc_key_salt with that key (AES-256-GCM), and
sends the ciphertext + iv. The server stores these blobs — it never sees the
recovery code or enc_key_salt plaintext.
The recovery code is displayed to the user once and never stored server-side.
"""
data = request.get_json(silent=True) or {}
recovery_enc_salt = data.get('recovery_enc_salt', '').strip()
recovery_iv = data.get('recovery_iv', '').strip()
# 64 lowercase hex chars, derived client-side from the recovery code alone.
recovery_verifier = (data.get('recovery_verifier') or '').strip().lower()
if not recovery_enc_salt or not recovery_iv:
return jsonify({'error': 'recovery_enc_salt and recovery_iv are required'}), 400
if not recovery_verifier or not _HEX64_RE.match(recovery_verifier):
return jsonify({
'error': 'recovery_verifier must be 64 hexadecimal characters'
}), 400
user = db.session.get(User, g.current_user_id)
user.recovery_enc_salt = recovery_enc_salt
user.recovery_iv = recovery_iv
user.recovery_verifier = recovery_verifier
AuditLog.log(
user_id=user.id,
action='auth.recovery_setup',
resource_type='user',
resource_id=user.id,
detail='Account recovery code configured',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'message': 'Recovery code saved'}), 200
@auth_bp.route('/recovery/status', methods=['GET'])
@limiter.limit('60 per minute')
@require_jwt
def recovery_status():
"""Return whether the user has a recovery code configured."""
user = db.session.get(User, g.current_user_id)
return jsonify({
'recovery_configured': bool(user.recovery_enc_salt),
# True when the stored recovery code predates recovery_verifier and so
# still relies on the weaker enc_key_salt-keyed proof. The settings UI
# surfaces this as a prompt to regenerate.
'recovery_is_legacy': bool(user.recovery_enc_salt and not user.recovery_verifier),
}), 200
@auth_bp.route('/recover', methods=['POST'])
@limiter.limit('5 per minute')
def recover_account():
"""
Recover account access using a recovery code.
Flow:
1. Client calls /recovery/data → receives enc_key_salt, recovery blobs, nonce.
2. Client decrypts recovery_enc_salt using the recovery key → gets enc_key_salt.
3. Client computes: recovery_proof = HMAC-SHA256(enc_key_salt_bytes, nonce).
4. Client derives new credentials and re-encrypts all vault items.
5. Client POSTs everything here in one atomic payload.
The server validates recovery_proof against the value stored in the DB
during /recovery/data — neither the recovery code nor the verifier is ever
sent in plaintext.
The re-encrypted `items` array must cover every vault item the user owns;
otherwise the rotation is refused with 409. Pass allow_partial=true to
override once the user has confirmed the resulting data loss.
The challenge row is consumed (deleted) on first use to prevent replay.
Challenge state is stored in the database, not the Flask session, so the
flow works correctly across all Gunicorn workers.
"""
from app.models.recovery_challenge import RecoveryChallenge
data = request.get_json(silent=True) or {}
email = (data.get('email') or '').strip().lower()
new_auth_hash = data.get('new_auth_hash', '')
new_enc_key_salt = data.get('new_enc_key_salt', '')
client_proof = data.get('recovery_proof', '')
items = data.get('items', [])
# Explicit opt-in to recovering with some items left un-re-encrypted.
# The client sets this only after telling the user how many items it could
# not decrypt and getting confirmation — without the escape hatch a single
# corrupt row would block recovery entirely.
allow_partial = bool(data.get('allow_partial'))
if not all([email, new_auth_hash, new_enc_key_salt, client_proof]):
return jsonify({'error': 'email, new_auth_hash, new_enc_key_salt, and recovery_proof are required'}), 400
time.sleep(0.1) # timing mitigation
user = User.query.filter_by(email=email).first()
if not user or not user.recovery_enc_salt:
return jsonify({'error': 'No recovery code found for this account'}), 404
# Consume the challenge — atomic read-and-delete from the DB.
# consume() returns None if the challenge is missing or expired.
challenge = RecoveryChallenge.consume(user.id)
if not challenge:
return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400
if not verify_recovery_proof(challenge.expected_proof, client_proof):
AuditLog.log(
user_id=user.id,
action='auth.recovery_failed',
resource_type='user',
resource_id=user.id,
detail='Recovery attempt failed — incorrect recovery proof',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'error': 'Invalid recovery code'}), 401
try:
# Refuse to rotate the key while items remain under the old one —
# see _apply_reencrypted_items. Raises IncompleteReencryption otherwise.
updated, total = _apply_reencrypted_items(
user.id, items, allow_partial=allow_partial
)
user.master_hash = hash_auth_token(new_auth_hash)
user.enc_key_salt = new_enc_key_salt
# Recovery code is consumed — clear it so it cannot be reused.
user.recovery_enc_salt = None
user.recovery_iv = None
user.recovery_verifier = None
# Recovery resets the master password, so revoke prior sessions too —
# an attacker holding a stolen token must not survive the victim
# recovering their account.
user.token_epoch = (user.token_epoch or 0) + 1
AuditLog.log(
user_id=user.id,
action='auth.recovery_success',
resource_type='user',
resource_id=user.id,
detail=(
f'Account recovered; {updated}/{total} vault item(s) re-encrypted'
f'{" (PARTIAL — user confirmed data loss)" if updated != total else ""}; '
'recovery code consumed'
),
ip_address=client_ip(),
)
db.session.commit()
except IncompleteReencryption as exc:
# The challenge was already consumed above, so the client must restart
# from /recovery/data. That is the correct trade-off: better to repeat
# the flow than to commit a rotation that orphans ciphertext.
db.session.rollback()
AuditLog.log(
user_id=user.id,
action='auth.recovery_failed',
resource_type='user',
resource_id=user.id,
detail=(
f'Recovery refused — re-encryption payload covered '
f'{exc.received} of {exc.expected} vault item(s)'
),
ip_address=client_ip(),
)
db.session.commit()
return jsonify({
'error': (
f'Recovery stopped: only {exc.received} of your {exc.expected} '
'vault item(s) could be re-encrypted. Continuing would permanently '
'lock the rest.'
),
'code': 'incomplete_reencryption',
'expected': exc.expected,
'received': exc.received,
}), 409
except Exception:
db.session.rollback()
_log.exception('recover_account failed for user %s', user.id)
return jsonify({'error': 'Account recovery failed. Please try again.'}), 500
tokens = generate_tokens(user.id, user.token_epoch)
return jsonify({
'message': 'Account recovered successfully',
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
'enc_key_salt': user.enc_key_salt,
}), 200
@auth_bp.route('/recovery/data', methods=['GET'])
@limiter.limit('10 per minute')
def recovery_data():
"""
Return the data the client needs to attempt recovery (unauthenticated).
Exposes: enc_key_salt, recovery_enc_salt, recovery_iv, and a one-time nonce.
The nonce is used for the HMAC-SHA256 challenge-response proof:
- Client derives the recovery verifier from the recovery code:
PBKDF2(recovery_code, "passkeeper-recovery-verifier:" + email, 200k)
- Client computes: proof = HMAC-SHA256(key=verifier, msg=nonce)
- Server stores expected proof in the DB (recovery_challenges table),
verifying it on /recover and /recovery/items.
The response's `proof_scheme` field tells the client which key to use.
Accounts whose recovery code predates recovery_verifier get 'legacy' and
key the proof on the enc_key_salt decrypted out of the recovery blob.
Returns 404 if no recovery code is configured (prevents user enumeration).
The challenge is stored in the database (not the Flask session cookie) so
it works correctly across all Gunicorn workers.
"""
import hashlib, hmac as _hmac
from app.models.recovery_challenge import RecoveryChallenge
email = (request.args.get('email') or '').strip().lower()
if not email:
return jsonify({'error': 'email is required'}), 400
user = User.query.filter_by(email=email).first()
if not user or not user.recovery_enc_salt:
return jsonify({'error': 'No recovery data found'}), 404
# Generate a fresh nonce and precompute the expected HMAC using the stored
# enc_key_salt. The client must return HMAC-SHA256(enc_key_salt, nonce).
# This proves it decrypted the recovery blob correctly without sending
# enc_key_salt in plaintext.
nonce = generate_recovery_nonce()
expected_proof = _hmac.new(
_recovery_proof_key(user),
nonce.encode(),
hashlib.sha256,
).hexdigest()
# Persist challenge in the DB — safe across all Gunicorn workers.
# RecoveryChallenge.create() deletes any previous challenge for this user
# before inserting, so a re-issued challenge always starts fresh.
RecoveryChallenge.create(
user_id=user.id,
nonce=nonce,
expected_proof=expected_proof,
)
db.session.commit()
return jsonify({
'recovery_enc_salt': user.recovery_enc_salt,
'recovery_iv': user.recovery_iv,
'nonce': nonce,
# Tells the client which value to key the HMAC proof with:
# 'verifier' → PBKDF2(recovery_code, "passkeeper-recovery-verifier:" + email)
# 'legacy' → the enc_key_salt decrypted out of the recovery blob
'proof_scheme': 'verifier' if user.recovery_verifier else 'legacy',
}), 200
@auth_bp.route('/recovery/items', methods=['GET'])
@limiter.limit('10 per minute')
def recovery_items():
"""
Return encrypted vault items for recovery re-encryption (unauthenticated).
Requires X-Recovery-Proof header containing the HMAC-SHA256 proof:
proof = HMAC-SHA256(key=recovery_verifier, msg=nonce_from_recovery_data)
The verifier is derived client-side from the recovery code alone and is
never returned by any endpoint, so only the holder of the recovery code can
compute the proof. It is deliberately not enc_key_salt: that value is also
the vault-key PBKDF2 salt and is released to the client on login, so keying
the proof with it allowed anyone holding the master password to forge a
proof and pull the whole vault here — bypassing MFA.
Replay prevention: the challenge is consumed (deleted) on success, then
immediately re-issued with the same expected_proof but a new nonce and a
fresh TTL. This means each call to /recovery/items rotates the challenge,
so a captured X-Recovery-Proof header cannot be replayed by a third party.
POST /recover will consume the rotated challenge on final commit.
Items are returned as encrypted ciphertext blobs only.
"""
from app.models.recovery_challenge import RecoveryChallenge
email = (request.args.get('email') or '').strip().lower()
client_proof = request.headers.get('X-Recovery-Proof', '').strip()
if not email or not client_proof:
return jsonify({'error': 'email and X-Recovery-Proof header are required'}), 400
user = User.query.filter_by(email=email).first()
if not user or not user.recovery_enc_salt:
return jsonify({'error': 'No recovery data found'}), 404
# Consume the current challenge atomically.
challenge = RecoveryChallenge.consume(user.id)
if not challenge:
return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400
if not verify_recovery_proof(challenge.expected_proof, client_proof):
AuditLog.log(
user_id=user.id,
action='auth.recovery_items_denied',
resource_type='user',
resource_id=user.id,
detail='Recovery items request denied — incorrect recovery proof',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'error': 'Invalid recovery proof'}), 401
# Re-issue a fresh challenge with the same expected_proof but a new nonce
# and TTL. POST /recover will consume this rotated challenge on final commit.
# The client continues to send the same proof value — no client change needed.
new_nonce = generate_recovery_nonce()
RecoveryChallenge.create(
user_id=user.id,
nonce=new_nonce,
expected_proof=challenge.expected_proof, # same proof, new nonce
)
db.session.commit()
from app.models.vault_item import VaultItem
items = VaultItem.query.filter_by(user_id=user.id).all()
return jsonify({
'items': [
{
'id': item.id,
'enc_data': item.enc_data,
'iv': item.iv,
'enc_name': item.enc_name,
'iv_name': item.iv_name,
}
for item in items
]
}), 200