05/22 Enhance codes and fix bugs 2

This commit is contained in:
2026-05-22 10:46:43 -04:00
parent 3231c8eb56
commit f5dc6660c5
7 changed files with 80 additions and 29 deletions
+6 -1
View File
@@ -110,7 +110,7 @@ def create_app(config_name: str = 'development') -> Flask:
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
return User.query.get(int(user_id)) return db.session.get(User, int(user_id))
# Blueprints # Blueprints
from .routes.auth import auth_bp from .routes.auth import auth_bp
@@ -226,6 +226,11 @@ def create_app(config_name: str = 'development') -> Flask:
id='token_blacklist_cleanup', id='token_blacklist_cleanup',
replace_existing=True, replace_existing=True,
) )
# 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() scheduler.start()
# ── Production safety checks ─────────────────────────────────────────────── # ── Production safety checks ───────────────────────────────────────────────
+13 -7
View File
@@ -1,7 +1,10 @@
import logging
import re import re
import time import time
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
_log = logging.getLogger(__name__)
from app import db, limiter, client_ip from app import db, limiter, client_ip
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
@@ -584,7 +587,7 @@ def change_password():
user = db.session.get(User, g.current_user_id) 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( AuditLog.log(
user_id=user.id, user_id=user.id,
action='auth.change_password_failed', action='auth.change_password_failed',
@@ -646,9 +649,10 @@ def change_password():
ip_address=client_ip(), ip_address=client_ip(),
) )
db.session.commit() db.session.commit()
except Exception as e: except Exception:
db.session.rollback() 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 return jsonify({'message': 'Password changed successfully. Please log in again.'}), 200
@@ -696,9 +700,10 @@ def delete_account():
) )
db.session.delete(user) db.session.delete(user)
db.session.commit() db.session.commit()
except Exception as e: except Exception:
db.session.rollback() 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 return jsonify({'message': 'Account deleted'}), 200
@@ -849,9 +854,10 @@ def recover_account():
ip_address=client_ip(), ip_address=client_ip(),
) )
db.session.commit() db.session.commit()
except Exception as e: except Exception:
db.session.rollback() 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) tokens = generate_tokens(user.id)
return jsonify({ return jsonify({
+9 -2
View File
@@ -29,13 +29,20 @@ def list_emergency():
) )
).order_by(EmergencyAccess.created_at.desc()).all() ).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({ return jsonify({
'grants': [ea.to_dict(grantor_email=user.email) for ea in grants], '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 }), 200
def _ea_as_grantee(ea: EmergencyAccess) -> dict: def _ea_as_grantee(ea: EmergencyAccess, grantor: 'User | None' = None) -> dict:
if grantor is None:
grantor = db.session.get(User, ea.grantor_id) grantor = db.session.get(User, ea.grantor_id)
d = ea.to_dict(grantor_email=grantor.email if grantor else None) d = ea.to_dict(grantor_email=grantor.email if grantor else None)
d['grantor_public_key'] = grantor.sharing_public_key if grantor else None d['grantor_public_key'] = grantor.sharing_public_key if grantor else None
+6 -1
View File
@@ -242,10 +242,15 @@ def inbox():
.order_by(SharedItem.created_at.desc()) .order_by(SharedItem.created_at.desc())
.all() .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 = [] result = []
for s in shares: for s in shares:
d = s.to_dict() 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_email'] = owner.email if owner else 'Unknown'
d['owner_public_key'] = owner.sharing_public_key if owner else None d['owner_public_key'] = owner.sharing_public_key if owner else None
result.append(d) result.append(d)
+10 -4
View File
@@ -1,5 +1,9 @@
import logging
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from app import db, limiter, client_ip from app import db, limiter, client_ip
_log = logging.getLogger(__name__)
from app.models.vault_item import VaultItem, ItemType from app.models.vault_item import VaultItem, ItemType
from app.models.folder import Folder from app.models.folder import Folder
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
@@ -87,9 +91,10 @@ def create_item():
ip_address=client_ip(), ip_address=client_ip(),
) )
db.session.commit() db.session.commit()
except Exception as e: except Exception:
db.session.rollback() 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 return jsonify(item.to_dict()), 201
@@ -148,9 +153,10 @@ def update_item(item_id):
ip_address=client_ip(), ip_address=client_ip(),
) )
db.session.commit() db.session.commit()
except Exception as e: except Exception:
db.session.rollback() 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 return jsonify(item.to_dict()), 200
+18 -6
View File
@@ -103,9 +103,10 @@ const Recover = (() => {
/** /**
* Derive an AES-256-GCM key from the recovery code using PBKDF2. * 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( const baseKey = await subtle.importKey(
"raw", "raw",
strToBytes(recoveryCode), strToBytes(recoveryCode),
@@ -116,7 +117,7 @@ const Recover = (() => {
return subtle.deriveKey( return subtle.deriveKey(
{ {
name: "PBKDF2", name: "PBKDF2",
salt: strToBytes("passkeeper-recovery"), salt: strToBytes(salt),
iterations: 200_000, iterations: 200_000,
hash: "SHA-256", hash: "SHA-256",
}, },
@@ -216,15 +217,26 @@ const Recover = (() => {
} }
const data = await res.json(); const data = await res.json();
// Attempt to decrypt enc_key_salt using the recovery code // Attempt to decrypt enc_key_salt using the recovery code.
const recoveryKey = await deriveRecoveryKey(rawCode); // 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; let decryptedEncKeySalt;
try {
const keyWithEmail = await deriveRecoveryKey(rawCode, email);
try { try {
decryptedEncKeySalt = await decryptEncKeySalt( decryptedEncKeySalt = await decryptEncKeySalt(
recoveryKey, keyWithEmail,
data.recovery_enc_salt, data.recovery_enc_salt,
data.recovery_iv, data.recovery_iv,
); );
} catch {
const keyLegacy = await deriveRecoveryKey(rawCode);
decryptedEncKeySalt = await decryptEncKeySalt(
keyLegacy,
data.recovery_enc_salt,
data.recovery_iv,
);
}
} catch { } catch {
showError( showError(
"recover-error-1", "recover-error-1",
+12 -2
View File
@@ -3442,13 +3442,23 @@ const Vault = (() => {
return; 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 // Generate a random 128-bit (16-byte) recovery code displayed as hex
const rawBytes = window.crypto.getRandomValues(new Uint8Array(16)); const rawBytes = window.crypto.getRandomValues(new Uint8Array(16));
const recoveryCode = Array.from(rawBytes) const recoveryCode = Array.from(rawBytes)
.map((b) => b.toString(16).padStart(2, "0")) .map((b) => b.toString(16).padStart(2, "0"))
.join(""); .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( const recoveryKeyMaterial = await window.crypto.subtle.importKey(
"raw", "raw",
new TextEncoder().encode(recoveryCode), new TextEncoder().encode(recoveryCode),
@@ -3459,7 +3469,7 @@ const Vault = (() => {
const recoveryKey = await window.crypto.subtle.deriveKey( const recoveryKey = await window.crypto.subtle.deriveKey(
{ {
name: "PBKDF2", name: "PBKDF2",
salt: new TextEncoder().encode("passkeeper-recovery"), salt: new TextEncoder().encode(userEmail),
iterations: 200_000, iterations: 200_000,
hash: "SHA-256", hash: "SHA-256",
}, },