Aug 26 - Enhance security 2
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

This commit is contained in:
2026-08-26 12:54:17 -04:00
parent 82dd7c5aef
commit 6c1bef73c8
20 changed files with 1193 additions and 79 deletions
+11 -2
View File
@@ -42,7 +42,14 @@ def create_app(config_name: str = 'development') -> Flask:
# IP keys spoof-resistant — a client cannot bypass per-IP limits by injecting
# an arbitrary IP into the XFF header.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
app.config.from_object(config[config_name])
selected_config = config[config_name]
# Reject insecure production settings before the app is handed back, so no
# request is ever served under one. Deliberately not done at class-definition
# time — that made importing app.config impossible without production
# secrets, breaking tests and local tooling.
if hasattr(selected_config, 'validate'):
selected_config.validate()
app.config.from_object(selected_config)
# Extensions
db.init_app(app)
@@ -230,7 +237,9 @@ def create_app(config_name: str = 'development') -> Flask:
# 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':
if app.config.get('SCHEDULER_ENABLED', True) and (
not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
):
scheduler.start()
# ── Production safety checks ───────────────────────────────────────────────
+70 -28
View File
@@ -78,6 +78,11 @@ class BaseConfig:
# Set via .env: STATIC_VERSION=20260418
STATIC_VERSION = os.environ.get('STATIC_VERSION', '1')
# Background cleanup scheduler (token_blacklist / recovery_challenges /
# totp_used_codes / expired shares). Disabled under test so the suite does
# not spawn a daemon thread per app fixture.
SCHEDULER_ENABLED = True
# Session cookie defaults — applied in all environments.
# SECURE is intentionally left out of BaseConfig so dev HTTP still works.
# See ProductionConfig below for the full hardened set.
@@ -90,6 +95,35 @@ class DevelopmentConfig(BaseConfig):
RATELIMIT_ENABLED = False
class TestingConfig(BaseConfig):
"""
In-memory SQLite, no rate limiting, no background threads.
SQLite is viable here because the only MySQL-specific construct in the
models is mysql.INTEGER(unsigned=True), which SQLAlchemy renders as a plain
INTEGER on other dialects. That means these tests cover application logic
and flow, NOT MySQL-specific behaviour (collation, ON UPDATE NOW(), unsigned
range) — schema changes still need a real migration run against MySQL.
"""
TESTING = True
DEBUG = False
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
SQLALCHEMY_ENGINE_OPTIONS = {}
RATELIMIT_ENABLED = False
SCHEDULER_ENABLED = False
WTF_CSRF_ENABLED = False
SECRET_KEY = 'test-secret-not-used-in-production'
JWT_SECRET_KEY = 'test-jwt-secret-not-used-in-production'
TOTP_ENCRYPTION_KEY = '00' * 32
CORS_ORIGINS = 'http://localhost'
WEBAUTHN_RP_ID = 'localhost'
WEBAUTHN_ORIGINS = ['http://localhost']
# Keep Argon2 cheap so the suite is not dominated by password hashing.
ARGON2_TIME_COST = 1
ARGON2_MEMORY_COST = 8
ARGON2_PARALLELISM = 1
class ProductionConfig(BaseConfig):
DEBUG = False
RATELIMIT_ENABLED = True
@@ -98,40 +132,48 @@ class ProductionConfig(BaseConfig):
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
# ── Critical security checks — fail loudly at startup, not silently at runtime ──
# These checks run at class definition time (i.e. at import / app startup).
# Any misconfiguration raises RuntimeError immediately so the process never
# serves a single request with an insecure configuration.
@classmethod
def validate(cls):
"""
Fail loudly on insecure production configuration.
_secret_key = os.environ.get('SECRET_KEY', '')
if not _secret_key or _secret_key in _INSECURE_SECRET_DEFAULTS:
raise RuntimeError(
'[PassKeeper] SECRET_KEY is not set or uses an insecure placeholder. '
'Generate a strong key with: python -c "import secrets; print(secrets.token_hex(32))" '
'and add SECRET_KEY=<value> to your production .env file.'
)
SECRET_KEY = _secret_key
Called from create_app() when this config is selected — NOT at class
definition time. Running it in the class body meant merely *importing*
app.config raised unless production secrets were present in the
environment, which broke the test suite and any local tooling that
imports the app (including `flask db upgrade` on a dev box).
_jwt_secret = os.environ.get('JWT_SECRET_KEY', '')
if not _jwt_secret or _jwt_secret in _INSECURE_SECRET_DEFAULTS:
raise RuntimeError(
'[PassKeeper] JWT_SECRET_KEY is not set or uses an insecure placeholder. '
'Generate a strong key with: python -c "import secrets; print(secrets.token_hex(32))" '
'and add JWT_SECRET_KEY=<value> to your production .env file.'
)
JWT_SECRET_KEY = _jwt_secret
The fail-loud property is preserved: create_app('production') raises
before the app is returned, so the process still never serves a request
under an insecure configuration.
"""
secret_key = os.environ.get('SECRET_KEY', '')
if not secret_key or secret_key in _INSECURE_SECRET_DEFAULTS:
raise RuntimeError(
'[PassKeeper] SECRET_KEY is not set or uses an insecure placeholder. '
'Generate a strong key with: python -c "import secrets; print(secrets.token_hex(32))" '
'and add SECRET_KEY=<value> to your production .env file.'
)
_cors = os.environ.get('CORS_ORIGINS', '')
if not _cors or _cors.strip() == '*':
raise RuntimeError(
'[PassKeeper] CORS_ORIGINS must be set to a specific origin in production '
'(e.g. CORS_ORIGINS=https://pwkeeper.ngodanguyen.tech). '
'A wildcard "*" is not permitted in production.'
)
CORS_ORIGINS = _cors
jwt_secret = os.environ.get('JWT_SECRET_KEY', '')
if not jwt_secret or jwt_secret in _INSECURE_SECRET_DEFAULTS:
raise RuntimeError(
'[PassKeeper] JWT_SECRET_KEY is not set or uses an insecure placeholder. '
'Generate a strong key with: python -c "import secrets; print(secrets.token_hex(32))" '
'and add JWT_SECRET_KEY=<value> to your production .env file.'
)
cors = os.environ.get('CORS_ORIGINS', '')
if not cors or cors.strip() == '*':
raise RuntimeError(
'[PassKeeper] CORS_ORIGINS must be set to a specific origin in production '
'(e.g. CORS_ORIGINS=https://pwkeeper.ngodanguyen.tech). '
'A wildcard "*" is not permitted in production.'
)
config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig,
}
+8
View File
@@ -58,6 +58,14 @@ class User(db.Model, UserMixin):
# 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)
# Session generation counter. Every issued JWT carries the value current at
# the time it was minted; require_jwt rejects tokens whose claim no longer
# matches. Incrementing this revokes every outstanding access and refresh
# token at once, which is what a master-password change must do — otherwise
# a stolen refresh token outlives the password it was obtained under.
# Tokens issued before this column existed decode with epoch 0 and stay
# valid until the next credential change.
token_epoch = db.Column(db.Integer, default=0, nullable=False, server_default='0')
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')
+32 -14
View File
@@ -4,7 +4,6 @@ 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
@@ -12,6 +11,7 @@ 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,
@@ -26,6 +26,8 @@ from app.services.auth_service import (
mark_totp_code_used,
)
_log = logging.getLogger(__name__)
auth_bp = Blueprint('auth', __name__)
EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
@@ -285,7 +287,7 @@ def login():
'mfa_token': mfa_token,
}), 200
tokens = generate_tokens(user.id)
tokens = generate_tokens(user.id, user.token_epoch)
return jsonify({
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
@@ -322,9 +324,17 @@ def 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(int(payload['sub']))
tokens = generate_tokens(user.id, user.token_epoch)
return jsonify({
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
@@ -534,7 +544,7 @@ def mfa_verify():
)
db.session.commit()
tokens = generate_tokens(user.id)
tokens = generate_tokens(user.id, user.token_epoch)
return jsonify({
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
@@ -684,9 +694,12 @@ def change_password():
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', '')
# Explicit opt-in to rotating the key while some items go un-re-encrypted.
# The client must have confirmed the resulting data loss with the user.
allow_partial = bool(data.get('allow_partial'))
# 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
@@ -708,9 +721,7 @@ def change_password():
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, allow_partial=allow_partial
)
updated, total = _apply_reencrypted_items(user.id, items)
# Update credentials
user.master_hash = hash_auth_token(new_auth_hash)
@@ -719,6 +730,10 @@ def change_password():
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:
@@ -731,9 +746,8 @@ def change_password():
resource_type='user',
resource_id=user.id,
detail=(
f'Master password changed; {updated}/{total} vault item(s) re-encrypted'
f'{" (PARTIAL — user confirmed data loss)" if updated != total else ""}; '
'recovery code cleared'
f'Master password changed; {updated}/{total} vault item(s) '
're-encrypted; recovery code cleared'
),
ip_address=client_ip(),
)
@@ -962,6 +976,10 @@ def recover_account():
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,
@@ -1008,7 +1026,7 @@ def recover_account():
_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, user.token_epoch)
return jsonify({
'message': 'Account recovered successfully',
'access_token': tokens['access_token'],
+2 -2
View File
@@ -2,13 +2,13 @@ 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
from app.services.auth_service import require_jwt
_log = logging.getLogger(__name__)
vault_bp = Blueprint('vault', __name__)
VALID_TYPES = {t.value for t in ItemType}
+32 -9
View File
@@ -27,6 +27,7 @@ Challenge storage:
is stored client-side (signed, not encrypted the challenge is not secret).
"""
import json
import logging
from datetime import datetime, timezone
import webauthn
@@ -50,6 +51,8 @@ from app.services.auth_service import require_jwt, generate_tokens
webauthn_bp = Blueprint('webauthn', __name__)
_log = logging.getLogger(__name__)
# Session key for the pending challenge bytes.
_REG_CHALLENGE_KEY = 'webauthn_reg_challenge'
_AUTH_CHALLENGE_KEY = 'webauthn_auth_challenge'
@@ -113,7 +116,12 @@ def register_begin():
user_display_name=user.email,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
# REQUIRED, not PREFERRED. A passkey here replaces BOTH the password
# and the TOTP second factor, so the authenticator must actually
# verify the human (biometric or PIN) rather than merely prove it is
# present. Under PREFERRED an authenticator is free to skip that,
# which reduced a full login to possession of an unlocked device.
user_verification=UserVerificationRequirement.REQUIRED,
authenticator_attachment=authenticator_attachment,
),
exclude_credentials=exclude_credentials,
@@ -156,10 +164,18 @@ def register_complete():
expected_challenge=expected_challenge,
expected_rp_id=_rp_id(),
expected_origin=_origins(),
require_user_verification=False,
# Reject a credential created without user verification — otherwise
# the REQUIRED hint above is only a request, not a guarantee.
require_user_verification=True,
)
except (InvalidCBORData, InvalidRegistrationResponse, Exception) as e:
return jsonify({'error': f'Registration verification failed: {str(e)}'}), 400
except (InvalidCBORData, InvalidRegistrationResponse) as e:
# Never echo str(e) to the client: py-webauthn messages quote raw
# attestation internals. Log the detail, return a generic message.
_log.warning('[PassKeeper] passkey registration rejected: %s', e)
return jsonify({'error': 'Could not verify this passkey. Please try again.'}), 400
except Exception:
_log.exception('[PassKeeper] passkey registration failed unexpectedly')
return jsonify({'error': 'Could not verify this passkey. Please try again.'}), 400
# Persist the new credential.
import base64
@@ -231,7 +247,8 @@ def authenticate_begin():
options = webauthn.generate_authentication_options(
rp_id=_rp_id(),
allow_credentials=allow_credentials,
user_verification=UserVerificationRequirement.PREFERRED,
# See register_begin — this assertion stands in for password + MFA.
user_verification=UserVerificationRequirement.REQUIRED,
)
session[_AUTH_CHALLENGE_KEY] = webauthn.options_to_json(options)
@@ -296,15 +313,21 @@ def authenticate_complete():
expected_origin=_origins(),
credential_public_key=webauthn.base64url_to_bytes(credential.public_key),
credential_current_sign_count=credential.sign_count,
require_user_verification=False,
# Enforced, not merely requested: the assertion must carry the UV
# flag or it is not sufficient to stand in for two factors.
require_user_verification=True,
)
except Exception:
_log.warning(
'[PassKeeper] passkey assertion rejected for credential id=%s',
credential.id, exc_info=True,
)
except Exception as e:
AuditLog.log(
user_id=user.id,
action='webauthn.auth_failed',
resource_type='webauthn_credential',
resource_id=credential.id,
detail=f'Passkey authentication failed: {str(e)[:200]}',
detail='Passkey authentication failed (assertion rejected)',
ip_address=client_ip(),
)
db.session.commit()
@@ -315,7 +338,7 @@ def authenticate_complete():
credential.last_used_at = datetime.now(timezone.utc).replace(tzinfo=None)
# Issue tokens.
tokens = generate_tokens(user.id)
tokens = generate_tokens(user.id, user.token_epoch)
AuditLog.log(
user_id=user.id,
+58 -4
View File
@@ -84,14 +84,23 @@ def decrypt_totp_secret(ciphertext_b64: str, iv_b64: str) -> str:
return aesgcm.decrypt(iv, ciphertext, None).decode()
def generate_tokens(user_id: int) -> dict:
"""Return access_token and refresh_token JWTs, each with a unique jti."""
def generate_tokens(user_id: int, token_epoch: int = 0) -> dict:
"""
Return access_token and refresh_token JWTs, each with a unique jti.
token_epoch stamps the user's current session generation into both tokens.
require_jwt and /refresh compare it against users.token_epoch and reject on
mismatch, so incrementing that column revokes every outstanding token.
Always pass user.token_epoch the 0 default exists only so old call sites
fail visibly in tests rather than silently minting unrevokable tokens.
"""
now = datetime.now(timezone.utc).replace(tzinfo=None)
secret = current_app.config['JWT_SECRET_KEY']
access_payload = {
'sub': str(user_id),
'type': 'access',
'jti': str(uuid.uuid4()),
'epoch': int(token_epoch or 0),
'iat': now,
'exp': now + current_app.config['JWT_ACCESS_TOKEN_EXPIRES'],
}
@@ -99,6 +108,7 @@ def generate_tokens(user_id: int) -> dict:
'sub': str(user_id),
'type': 'refresh',
'jti': str(uuid.uuid4()),
'epoch': int(token_epoch or 0),
'iat': now,
'exp': now + current_app.config['JWT_REFRESH_TOKEN_EXPIRES'],
}
@@ -172,8 +182,45 @@ def blacklist_token(token: str, token_type: str) -> None:
pass # Never let blacklisting errors break the logout flow
def load_user_for_token(payload) -> 'object | None':
"""
Resolve the User a validated token refers to, or None if the token must be
rejected.
Two checks beyond signature validity:
1. The user still exists. Routes immediately dereference the result of
db.session.get(User, ...); without this a valid token for a deleted
account produced an AttributeError on None and a 500.
2. The token's epoch claim still matches users.token_epoch. A master-password
change increments that column, which revokes every token minted before it.
Tokens issued before the claim existed decode as 0 and match the column
default, so an upgrade does not sign existing sessions out.
"""
from app.models.user import User
from app import db
try:
user_id = int(payload['sub'])
except (KeyError, TypeError, ValueError):
return None
user = db.session.get(User, user_id)
if user is None:
return None
if int(payload.get('epoch', 0) or 0) != int(user.token_epoch or 0):
return None
return user
def require_jwt(f):
"""Decorator: validates Bearer token and sets g.current_user_id."""
"""
Decorator: validates the Bearer token and sets g.current_user_id.
Also sets g.current_user to the resolved User so handlers can reuse it
instead of issuing a second lookup (SQLAlchemy's identity map makes the
repeat cheap, but reusing it is clearer).
"""
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get('Authorization', '')
@@ -186,7 +233,14 @@ def require_jwt(f):
return jsonify({'error': 'Token expired'}), 401
except jwt.PyJWTError:
return jsonify({'error': 'Invalid token'}), 401
g.current_user_id = int(payload['sub'])
user = load_user_for_token(payload)
if user is None:
# Deleted account, or a token predating a credential change.
return jsonify({'error': 'Session is no longer valid. Please log in again.'}), 401
g.current_user = user
g.current_user_id = user.id
return f(*args, **kwargs)
return decorated