05/18 Enhanced codes and functionalities

This commit is contained in:
2026-05-18 11:38:28 -04:00
parent 22fff0660e
commit fc4145a78e
8 changed files with 197 additions and 13 deletions
+3
View File
@@ -105,6 +105,7 @@ def create_app(config_name: str = 'development') -> Flask:
from .models.emergency_access import EmergencyAccess from .models.emergency_access import EmergencyAccess
from .models.audit_log import AuditLog from .models.audit_log import AuditLog
from .models.recovery_challenge import RecoveryChallenge from .models.recovery_challenge import RecoveryChallenge
from .models.totp_used_code import TotpUsedCode
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
@@ -190,8 +191,10 @@ def create_app(config_name: str = 'development') -> Flask:
try: try:
from app.models.token_blacklist import TokenBlacklist from app.models.token_blacklist import TokenBlacklist
from app.models.recovery_challenge import RecoveryChallenge from app.models.recovery_challenge import RecoveryChallenge
from app.models.totp_used_code import TotpUsedCode
TokenBlacklist.cleanup_expired() TokenBlacklist.cleanup_expired()
RecoveryChallenge.cleanup_expired() RecoveryChallenge.cleanup_expired()
TotpUsedCode.cleanup_expired()
import logging import logging
logging.getLogger(__name__).debug( logging.getLogger(__name__).debug(
'[PassKeeper] token_blacklist + recovery_challenges cleanup completed' '[PassKeeper] token_blacklist + recovery_challenges cleanup completed'
+78
View File
@@ -0,0 +1,78 @@
from datetime import datetime, timezone, timedelta
from sqlalchemy.dialects.mysql import INTEGER
from app import db
# A TOTP code is valid for at most one 30-second window on each side
# of the current window (valid_window=1), giving a 90-second replay
# window. We keep used-code records for 120 seconds to be safe.
TOTP_CODE_TTL_SECONDS = 120
class TotpUsedCode(db.Model):
"""
One-time consumption record for TOTP codes.
Prevents replay attacks within the valid_window: a code that has
already been accepted for a given user cannot be reused within the
TOTP_CODE_TTL_SECONDS window, even though the code is still
mathematically valid according to pyotp.
Rows older than TOTP_CODE_TTL_SECONDS are cleaned up by the same
APScheduler job that handles token_blacklist and recovery_challenges.
"""
__tablename__ = 'totp_used_codes'
id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True)
user_id = db.Column(
INTEGER(unsigned=True),
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False,
index=True,
)
# The 6-digit code as a zero-padded string, e.g. "042857"
code = db.Column(db.String(6), nullable=False)
expires_at = db.Column(db.DateTime, nullable=False)
__table_args__ = (
db.UniqueConstraint('user_id', 'code', name='uq_totp_used_user_code'),
)
@classmethod
def is_used(cls, user_id: int, code: str) -> bool:
"""Return True if this code has already been consumed for this user."""
entry = cls.query.filter_by(user_id=user_id, code=code).first()
if not entry:
return False
return entry.expires_at > datetime.now(timezone.utc).replace(tzinfo=None)
@classmethod
def mark_used(cls, user_id: int, code: str) -> None:
"""
Record that this code was consumed.
Caller must commit the session (the surrounding request handler does this).
Silently ignores IntegrityError (duplicate insert) — already marked used.
"""
from sqlalchemy.exc import IntegrityError
expires_at = (
datetime.now(timezone.utc).replace(tzinfo=None)
+ timedelta(seconds=TOTP_CODE_TTL_SECONDS)
)
entry = cls(user_id=user_id, code=code, expires_at=expires_at)
db.session.add(entry)
try:
db.session.flush()
except IntegrityError:
db.session.rollback() # already marked — safe to ignore
@classmethod
def cleanup_expired(cls) -> None:
"""Delete expired records — called by the APScheduler cleanup job."""
cls.query.filter(
cls.expires_at <= datetime.now(timezone.utc).replace(tzinfo=None)
).delete()
db.session.commit()
def __repr__(self):
return f'<TotpUsedCode user_id={self.user_id} code={self.code} expires={self.expires_at}>'
+26 -8
View File
@@ -19,6 +19,8 @@ from app.services.auth_service import (
verify_recovery_proof, verify_recovery_proof,
generate_backup_codes, generate_backup_codes,
verify_and_consume_backup_code, verify_and_consume_backup_code,
is_totp_code_used,
mark_totp_code_used,
) )
auth_bp = Blueprint('auth', __name__) auth_bp = Blueprint('auth', __name__)
@@ -280,7 +282,11 @@ def mfa_enable():
if not pyotp.TOTP(secret).verify(totp_code, valid_window=1): if not pyotp.TOTP(secret).verify(totp_code, valid_window=1):
return jsonify({'error': 'Invalid verification code'}), 400 return jsonify({'error': 'Invalid verification code'}), 400
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_secret = totp_secret_enc
user.totp_iv = totp_iv user.totp_iv = totp_iv
user.totp_enabled = True user.totp_enabled = True
@@ -324,7 +330,11 @@ def mfa_disable():
verified = False verified = False
if totp_code: 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) verified = pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1)
if verified:
mark_totp_code_used(user.id, totp_code)
elif backup_code: elif backup_code:
stored = json.loads(user.mfa_backup_codes or '[]') stored = json.loads(user.mfa_backup_codes or '[]')
matched, remaining = verify_and_consume_backup_code(stored, backup_code) matched, remaining = verify_and_consume_backup_code(stored, backup_code)
@@ -380,7 +390,11 @@ def mfa_verify():
verified = False verified = False
if totp_code: 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) 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: if not verified and backup_code:
stored = json.loads(user.mfa_backup_codes or '[]') stored = json.loads(user.mfa_backup_codes or '[]')
@@ -454,8 +468,11 @@ def mfa_backup_codes_regenerate():
import pyotp import pyotp
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv) 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): if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1):
return jsonify({'error': 'Invalid verification code'}), 400 return jsonify({'error': 'Invalid verification code'}), 400
mark_totp_code_used(user.id, totp_code)
plaintext_codes, hashed_codes = generate_backup_codes() plaintext_codes, hashed_codes = generate_backup_codes()
user.mfa_backup_codes = json.dumps(hashed_codes) user.mfa_backup_codes = json.dumps(hashed_codes)
@@ -886,7 +903,6 @@ def recovery_data():
db.session.commit() db.session.commit()
return jsonify({ return jsonify({
'enc_key_salt': user.enc_key_salt,
'recovery_enc_salt': user.recovery_enc_salt, 'recovery_enc_salt': user.recovery_enc_salt,
'recovery_iv': user.recovery_iv, 'recovery_iv': user.recovery_iv,
'nonce': nonce, 'nonce': nonce,
@@ -902,11 +918,12 @@ def recovery_items():
Requires X-Recovery-Proof header containing the HMAC-SHA256 proof: Requires X-Recovery-Proof header containing the HMAC-SHA256 proof:
proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_from_recovery_data) proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_from_recovery_data)
The server validates the proof against the value stored in the DB The enc_key_salt used as the HMAC key is NOT returned by /recovery/data;
during /recovery/data — enc_key_salt is never sent in plaintext. the client must derive it by decrypting the recovery blob with the recovery
The challenge row is NOT consumed here; it is consumed by the final code. This ensures only the holder of the recovery code can compute the proof.
POST /recover call so the client can call this endpoint to preview
items before committing the full re-encryption. The challenge row is NOT consumed here — it is consumed by the final
POST /recover call so that endpoint can also validate the proof.
Items are returned as encrypted ciphertext blobs only. Items are returned as encrypted ciphertext blobs only.
""" """
from app.models.recovery_challenge import RecoveryChallenge from app.models.recovery_challenge import RecoveryChallenge
@@ -921,7 +938,8 @@ def recovery_items():
if not user or not user.recovery_enc_salt: if not user or not user.recovery_enc_salt:
return jsonify({'error': 'No recovery data found'}), 404 return jsonify({'error': 'No recovery data found'}), 404
# Validate against the DB-stored challenge — safe across all workers. # Validate against the DB-stored challenge without consuming it —
# POST /recover will consume it atomically on commit.
challenge = RecoveryChallenge.query.filter_by(user_id=user.id).first() challenge = RecoveryChallenge.query.filter_by(user_id=user.id).first()
from datetime import datetime, timezone from datetime import datetime, timezone
if not challenge or challenge.expires_at < datetime.now(timezone.utc).replace(tzinfo=None): if not challenge or challenge.expires_at < datetime.now(timezone.utc).replace(tzinfo=None):
+33 -1
View File
@@ -1,6 +1,7 @@
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
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.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.services.auth_service import require_jwt from app.services.auth_service import require_jwt
@@ -9,6 +10,25 @@ vault_bp = Blueprint('vault', __name__)
VALID_TYPES = {t.value for t in ItemType} VALID_TYPES = {t.value for t in ItemType}
def _validate_folder_id(folder_id, user_id: int):
"""
Verify folder_id belongs to user_id.
Returns the sanitised folder_id (int or None).
Raises ValueError with a safe message if the folder doesn't exist or
belongs to another user.
"""
if folder_id is None:
return None
try:
folder_id = int(folder_id)
except (TypeError, ValueError):
raise ValueError('folder_id must be an integer')
folder = Folder.query.filter_by(id=folder_id, user_id=user_id).first()
if not folder:
raise ValueError('Folder not found')
return folder_id
@vault_bp.route('', methods=['GET']) @vault_bp.route('', methods=['GET'])
@limiter.limit('120 per minute') @limiter.limit('120 per minute')
@require_jwt @require_jwt
@@ -40,6 +60,11 @@ def create_item():
if not enc_data or not iv: if not enc_data or not iv:
return jsonify({'error': 'enc_data and iv are required'}), 400 return jsonify({'error': 'enc_data and iv are required'}), 400
try:
folder_id = _validate_folder_id(folder_id, g.current_user_id)
except ValueError as e:
return jsonify({'error': str(e)}), 400
item = VaultItem( item = VaultItem(
user_id=g.current_user_id, user_id=g.current_user_id,
folder_id=folder_id, folder_id=folder_id,
@@ -93,7 +118,10 @@ def update_item(item_id):
return jsonify({'error': 'name cannot be empty'}), 400 return jsonify({'error': 'name cannot be empty'}), 400
item.name = name item.name = name
if 'folder_id' in data: if 'folder_id' in data:
item.folder_id = data['folder_id'] try:
item.folder_id = _validate_folder_id(data['folder_id'], g.current_user_id)
except ValueError as e:
return jsonify({'error': str(e)}), 400
if 'enc_data' in data: if 'enc_data' in data:
item.enc_data = data['enc_data'] item.enc_data = data['enc_data']
if 'iv' in data: if 'iv' in data:
@@ -204,6 +232,10 @@ def import_items():
skipped += 1 skipped += 1
continue continue
folder_id = row.get('folder_id') folder_id = row.get('folder_id')
try:
folder_id = _validate_folder_id(folder_id, g.current_user_id)
except ValueError:
folder_id = None # invalid/foreign folder — import to root instead of skipping
enc_name = row.get('enc_name') or None enc_name = row.get('enc_name') or None
iv_name = row.get('iv_name') or None iv_name = row.get('iv_name') or None
item = VaultItem( item = VaultItem(
+17 -1
View File
@@ -177,7 +177,23 @@ def require_jwt(f):
return decorated return decorated
# ── Recovery proof helpers (HMAC-nonce) ────────────────────────────────────── # ── TOTP replay prevention ────────────────────────────────────────────────────
def is_totp_code_used(user_id: int, code: str) -> bool:
"""Return True if this TOTP code was already consumed for this user."""
from app.models.totp_used_code import TotpUsedCode
return TotpUsedCode.is_used(user_id, code)
def mark_totp_code_used(user_id: int, code: str) -> None:
"""
Record that this TOTP code was consumed so it cannot be replayed.
Caller must commit the session — the surrounding request handler does this.
"""
from app.models.totp_used_code import TotpUsedCode
TotpUsedCode.mark_used(user_id, code)
def generate_recovery_nonce() -> str: def generate_recovery_nonce() -> str:
""" """
+4 -2
View File
@@ -2,9 +2,11 @@
* recover.js — Account recovery flow * recover.js — Account recovery flow
* *
* Step 1: User provides email + recovery code. * Step 1: User provides email + recovery code.
* - Fetch recovery data (enc_key_salt, recovery_enc_salt, recovery_iv) from server. * - Fetch recovery data (recovery_enc_salt, recovery_iv, nonce) from server.
* NOTE: enc_key_salt is NOT returned here — the client must derive it by
* decrypting the recovery blob with the recovery code.
* - Derive recovery key from the recovery code (PBKDF2). * - Derive recovery key from the recovery code (PBKDF2).
* - Decrypt enc_key_salt using the recovery key. * - Decrypt recovery_enc_salt using the recovery key → decrypted enc_key_salt.
* - If decryption succeeds, store decrypted enc_key_salt in module state → show step 2. * - If decryption succeeds, store decrypted enc_key_salt in module state → show step 2.
* *
* Step 2: User provides new master password. * Step 2: User provides new master password.
@@ -0,0 +1,35 @@
"""add totp_used_codes table for TOTP replay prevention
Revision ID: f6a7b8c9d0e1
Revises: e5f6a7b8c9d0
Create Date: 2026-05-17 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'f6a7b8c9d0e1'
down_revision = 'e5f6a7b8c9d0'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'totp_used_codes',
sa.Column('id', mysql.INTEGER(unsigned=True), autoincrement=True, nullable=False),
sa.Column('user_id', mysql.INTEGER(unsigned=True), nullable=False),
sa.Column('code', sa.String(length=6), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'code', name='uq_totp_used_user_code'),
)
op.create_index('ix_totp_used_codes_user_id', 'totp_used_codes', ['user_id'])
def downgrade():
op.drop_index('ix_totp_used_codes_user_id', table_name='totp_used_codes')
op.drop_table('totp_used_codes')
+1 -1
View File
@@ -52,7 +52,7 @@ server {
# Content-Security-Policy (HTTP header takes precedence over meta tag) # Content-Security-Policy (HTTP header takes precedence over meta tag)
add_header Content-Security-Policy add_header Content-Security-Policy
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';" "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.pwnedpasswords.com; frame-ancestors 'none';"
always; always;
# ── Request hardening ───────────────────────────────────────────────────── # ── Request hardening ─────────────────────────────────────────────────────