05/22 Enhance codes and fix bugs 2
This commit is contained in:
+7
-2
@@ -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,7 +226,12 @@ def create_app(config_name: str = 'development') -> Flask:
|
|||||||
id='token_blacklist_cleanup',
|
id='token_blacklist_cleanup',
|
||||||
replace_existing=True,
|
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 ───────────────────────────────────────────────
|
# ── Production safety checks ───────────────────────────────────────────────
|
||||||
# Warn loudly at startup when running in production with settings that are
|
# Warn loudly at startup when running in production with settings that are
|
||||||
|
|||||||
+13
-7
@@ -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({
|
||||||
|
|||||||
+10
-3
@@ -29,14 +29,21 @@ 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:
|
||||||
grantor = db.session.get(User, ea.grantor_id)
|
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 = 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
|
||||||
return d
|
return d
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
+22
-10
@@ -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 {
|
try {
|
||||||
decryptedEncKeySalt = await decryptEncKeySalt(
|
const keyWithEmail = await deriveRecoveryKey(rawCode, email);
|
||||||
recoveryKey,
|
try {
|
||||||
data.recovery_enc_salt,
|
decryptedEncKeySalt = await decryptEncKeySalt(
|
||||||
data.recovery_iv,
|
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 {
|
} catch {
|
||||||
showError(
|
showError(
|
||||||
"recover-error-1",
|
"recover-error-1",
|
||||||
|
|||||||
+12
-2
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user