diff --git a/app/__init__.py b/app/__init__.py index e3ec825..ddc923f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -110,7 +110,7 @@ def create_app(config_name: str = 'development') -> Flask: @login_manager.user_loader def load_user(user_id): - return User.query.get(int(user_id)) + return db.session.get(User, int(user_id)) # Blueprints from .routes.auth import auth_bp @@ -226,7 +226,12 @@ def create_app(config_name: str = 'development') -> Flask: id='token_blacklist_cleanup', replace_existing=True, ) - scheduler.start() + # In Flask debug mode the Werkzeug reloader spawns two processes. + # WERKZEUG_RUN_MAIN == 'true' only in the child (the actual server), + # so we skip the scheduler in the parent reloader to avoid duplicate jobs. + import os + if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true': + scheduler.start() # ── Production safety checks ─────────────────────────────────────────────── # Warn loudly at startup when running in production with settings that are diff --git a/app/routes/auth.py b/app/routes/auth.py index 8618e32..927f4ba 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,7 +1,10 @@ +import logging import re import time from flask import Blueprint, request, jsonify, g + +_log = logging.getLogger(__name__) from app import db, limiter, client_ip from app.models.user import User from app.models.audit_log import AuditLog @@ -584,7 +587,7 @@ def change_password(): user = db.session.get(User, g.current_user_id) - if not verify_auth_token(current_auth_hash, user.master_hash): + if not verify_auth_token(current_auth_hash, user.master_hash, user=user): AuditLog.log( user_id=user.id, action='auth.change_password_failed', @@ -646,9 +649,10 @@ def change_password(): ip_address=client_ip(), ) db.session.commit() - except Exception as e: + except Exception: db.session.rollback() - return jsonify({'error': f'Password change failed: {str(e)}'}), 500 + _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 @@ -696,9 +700,10 @@ def delete_account(): ) db.session.delete(user) db.session.commit() - except Exception as e: + except Exception: db.session.rollback() - return jsonify({'error': f'Account deletion failed: {str(e)}'}), 500 + _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 @@ -849,9 +854,10 @@ def recover_account(): ip_address=client_ip(), ) db.session.commit() - except Exception as e: + except Exception: db.session.rollback() - return jsonify({'error': f'Recovery failed: {str(e)}'}), 500 + _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) return jsonify({ diff --git a/app/routes/emergency.py b/app/routes/emergency.py index 90b59c7..d7de4b7 100644 --- a/app/routes/emergency.py +++ b/app/routes/emergency.py @@ -29,14 +29,21 @@ def list_emergency(): ) ).order_by(EmergencyAccess.created_at.desc()).all() + grantor_ids = {ea.grantor_id for ea in access} + grantors = ( + {u.id: u for u in User.query.filter(User.id.in_(grantor_ids)).all()} + if grantor_ids else {} + ) + return jsonify({ 'grants': [ea.to_dict(grantor_email=user.email) for ea in grants], - 'access': [_ea_as_grantee(ea) for ea in access], + 'access': [_ea_as_grantee(ea, grantors.get(ea.grantor_id)) for ea in access], }), 200 -def _ea_as_grantee(ea: EmergencyAccess) -> dict: - grantor = db.session.get(User, ea.grantor_id) +def _ea_as_grantee(ea: EmergencyAccess, grantor: 'User | None' = None) -> dict: + if grantor is None: + grantor = db.session.get(User, ea.grantor_id) d = ea.to_dict(grantor_email=grantor.email if grantor else None) d['grantor_public_key'] = grantor.sharing_public_key if grantor else None return d diff --git a/app/routes/sharing.py b/app/routes/sharing.py index 6314252..593e669 100644 --- a/app/routes/sharing.py +++ b/app/routes/sharing.py @@ -242,10 +242,15 @@ def inbox(): .order_by(SharedItem.created_at.desc()) .all() ) + owner_ids = {s.owner_id for s in shares} + owners = ( + {u.id: u for u in User.query.filter(User.id.in_(owner_ids)).all()} + if owner_ids else {} + ) result = [] for s in shares: d = s.to_dict() - owner = db.session.get(User, s.owner_id) + owner = owners.get(s.owner_id) d['owner_email'] = owner.email if owner else 'Unknown' d['owner_public_key'] = owner.sharing_public_key if owner else None result.append(d) diff --git a/app/routes/vault.py b/app/routes/vault.py index 58b1e17..c2d9c25 100644 --- a/app/routes/vault.py +++ b/app/routes/vault.py @@ -1,5 +1,9 @@ +import logging + from flask import Blueprint, request, jsonify, g from app import db, limiter, client_ip + +_log = logging.getLogger(__name__) from app.models.vault_item import VaultItem, ItemType from app.models.folder import Folder from app.models.audit_log import AuditLog @@ -87,9 +91,10 @@ def create_item(): ip_address=client_ip(), ) db.session.commit() - except Exception as e: + except Exception: db.session.rollback() - return jsonify({'error': f'Database error: {str(e)}'}), 500 + _log.exception('create_item failed for user %s', g.current_user_id) + return jsonify({'error': 'Failed to save item. Please try again.'}), 500 return jsonify(item.to_dict()), 201 @@ -148,9 +153,10 @@ def update_item(item_id): ip_address=client_ip(), ) db.session.commit() - except Exception as e: + except Exception: db.session.rollback() - return jsonify({'error': f'Database error: {str(e)}'}), 500 + _log.exception('update_item failed for user %s item %s', g.current_user_id, item_id) + return jsonify({'error': 'Failed to update item. Please try again.'}), 500 return jsonify(item.to_dict()), 200 diff --git a/app/static/js/recover.js b/app/static/js/recover.js index b643678..4261ccd 100644 --- a/app/static/js/recover.js +++ b/app/static/js/recover.js @@ -103,9 +103,10 @@ const Recover = (() => { /** * Derive an AES-256-GCM key from the recovery code using PBKDF2. - * Salt is fixed to 'passkeeper-recovery' — the recovery code itself is the secret. + * `salt` should be the user's email (per-user uniqueness). + * Falls back to the legacy fixed salt for backward compatibility. */ - async function deriveRecoveryKey(recoveryCode) { + async function deriveRecoveryKey(recoveryCode, salt = "passkeeper-recovery") { const baseKey = await subtle.importKey( "raw", strToBytes(recoveryCode), @@ -116,7 +117,7 @@ const Recover = (() => { return subtle.deriveKey( { name: "PBKDF2", - salt: strToBytes("passkeeper-recovery"), + salt: strToBytes(salt), iterations: 200_000, hash: "SHA-256", }, @@ -216,15 +217,26 @@ const Recover = (() => { } const data = await res.json(); - // Attempt to decrypt enc_key_salt using the recovery code - const recoveryKey = await deriveRecoveryKey(rawCode); + // Attempt to decrypt enc_key_salt using the recovery code. + // Try email-as-salt first (new format), fall back to the legacy fixed salt + // so that recovery codes generated before this fix still work. let decryptedEncKeySalt; try { - decryptedEncKeySalt = await decryptEncKeySalt( - recoveryKey, - data.recovery_enc_salt, - data.recovery_iv, - ); + const keyWithEmail = await deriveRecoveryKey(rawCode, email); + try { + decryptedEncKeySalt = await decryptEncKeySalt( + keyWithEmail, + data.recovery_enc_salt, + data.recovery_iv, + ); + } catch { + const keyLegacy = await deriveRecoveryKey(rawCode); + decryptedEncKeySalt = await decryptEncKeySalt( + keyLegacy, + data.recovery_enc_salt, + data.recovery_iv, + ); + } } catch { showError( "recover-error-1", diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 464fe7b..693e870 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -3442,13 +3442,23 @@ const Vault = (() => { return; } + // Use the user's email as a per-user PBKDF2 salt so precomputed tables + // cannot attack multiple users at once. + const meRes = await apiFetch("/api/auth/me"); + if (!meRes || !meRes.ok) { + showToast("Session error. Please reload.", "error"); + return; + } + const meData = await meRes.json(); + const userEmail = meData.email; + // Generate a random 128-bit (16-byte) recovery code displayed as hex const rawBytes = window.crypto.getRandomValues(new Uint8Array(16)); const recoveryCode = Array.from(rawBytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); - // Derive recovery key from the code + // Derive recovery key from the code using the user's email as PBKDF2 salt const recoveryKeyMaterial = await window.crypto.subtle.importKey( "raw", new TextEncoder().encode(recoveryCode), @@ -3459,7 +3469,7 @@ const Vault = (() => { const recoveryKey = await window.crypto.subtle.deriveKey( { name: "PBKDF2", - salt: new TextEncoder().encode("passkeeper-recovery"), + salt: new TextEncoder().encode(userEmail), iterations: 200_000, hash: "SHA-256", },