diff --git a/app/__init__.py b/app/__init__.py index 1fd92d9..c7b223c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -26,7 +26,39 @@ def create_app(config_name: str = 'development') -> Flask: login_manager.init_app(app) csrf.init_app(app) limiter.init_app(app) - CORS(app, resources={r'/api/*': {'origins': '*'}}) + + # Restrict CORS to the configured origin (locked to production domain in prod) + cors_origins = app.config.get('CORS_ORIGINS', '*') + CORS(app, resources={r'/api/*': {'origins': cors_origins}}) + + # Attach security headers to every response + @app.after_request + def set_security_headers(response): + # Strict-Transport-Security: enforce HTTPS for 1 year, include subdomains + response.headers['Strict-Transport-Security'] = ( + 'max-age=31536000; includeSubDomains' + ) + # Prevent clickjacking + response.headers['X-Frame-Options'] = 'DENY' + # Prevent MIME-type sniffing + response.headers['X-Content-Type-Options'] = 'nosniff' + # Control referrer information leakage + response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' + # Permissions policy — disable features the app does not use + response.headers['Permissions-Policy'] = ( + 'geolocation=(), camera=(), microphone=()' + ) + # CSP via HTTP header (authoritative — overrides the meta tag for all resources) + response.headers['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';" + ) + return response # Ensure all models are imported so SQLAlchemy knows about them from .models.user import User diff --git a/app/config.py b/app/config.py index 4a0e2fd..4d08fc1 100644 --- a/app/config.py +++ b/app/config.py @@ -29,7 +29,17 @@ class BaseConfig: ARGON2_MEMORY_COST = int(os.environ.get('ARGON2_MEMORY_COST', 65536)) ARGON2_PARALLELISM = int(os.environ.get('ARGON2_PARALLELISM', 4)) - RATELIMIT_STORAGE_URI = 'memory://' + # Server-side AES-256-GCM key for encrypting TOTP secrets at rest. + # Must be a 64-character hex string (32 bytes). + # Generate: python -c "import secrets; print(secrets.token_hex(32))" + TOTP_ENCRYPTION_KEY = os.environ.get('TOTP_ENCRYPTION_KEY', '') + + # Rate limiting — use Redis in production so all Gunicorn workers share one counter. + # Falls back to memory (per-worker, dev only). + RATELIMIT_STORAGE_URI = os.environ.get('RATELIMIT_STORAGE_URI', 'memory://') + + # CORS — restrict to the production origin in production config. + CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*') class DevelopmentConfig(BaseConfig): diff --git a/app/models/user.py b/app/models/user.py index 59717bf..7914164 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -22,7 +22,11 @@ class User(db.Model, UserMixin): created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) last_login = db.Column(db.DateTime, nullable=True) # TOTP / MFA - totp_secret = db.Column(db.String(64), nullable=True) + # totp_secret: AES-256-GCM ciphertext of the base32 TOTP secret, base64-encoded. + # Encrypted server-side with the TOTP_ENCRYPTION_KEY from config. + # totp_iv: base64-encoded 12-byte GCM nonce for the above. + totp_secret = db.Column(db.String(255), nullable=True) + totp_iv = db.Column(db.String(64), nullable=True) totp_enabled = db.Column(db.Boolean, default=False, nullable=False) # ECDH P-256 sharing keypair # Public key: raw uncompressed point (65 bytes), base64-encoded (~88 chars), stored plaintext diff --git a/app/routes/auth.py b/app/routes/auth.py index b094c33..fc1b8cf 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -13,6 +13,8 @@ from app.services.auth_service import ( decode_token, blacklist_token, require_jwt, + encrypt_totp_secret, + decrypt_totp_secret, ) auth_bp = Blueprint('auth', __name__) @@ -75,6 +77,16 @@ def login(): user = User.query.filter_by(email=email).first() if not user or not verify_auth_token(auth_hash, user.master_hash): + if user: + AuditLog.log( + user_id=user.id, + action='auth.login_failed', + resource_type='user', + resource_id=user.id, + detail='Failed login attempt — invalid password', + ip_address=_client_ip(), + ) + db.session.commit() return jsonify({'error': 'Invalid email or password'}), 401 from datetime import datetime @@ -196,7 +208,9 @@ def mfa_enable(): if not pyotp.TOTP(secret).verify(totp_code, valid_window=1): return jsonify({'error': 'Invalid verification code'}), 400 - user.totp_secret = secret + totp_secret_enc, totp_iv = encrypt_totp_secret(secret) + user.totp_secret = totp_secret_enc + user.totp_iv = totp_iv user.totp_enabled = True AuditLog.log( @@ -224,10 +238,12 @@ def mfa_disable(): totp_code = (data.get('totp_code') or '').strip() import pyotp - if not pyotp.TOTP(user.totp_secret).verify(totp_code, valid_window=1): + plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv) + if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1): return jsonify({'error': 'Invalid verification code'}), 400 user.totp_secret = None + user.totp_iv = None user.totp_enabled = False AuditLog.log( @@ -264,7 +280,8 @@ def mfa_verify(): return jsonify({'error': 'MFA not configured for this account'}), 400 import pyotp - if not pyotp.TOTP(user.totp_secret).verify(totp_code, valid_window=1): + plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv) + if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1): return jsonify({'error': 'Invalid verification code'}), 400 # One-time use: blacklist the mfa_token diff --git a/app/routes/emergency.py b/app/routes/emergency.py index 38598ff..f2bb3a6 100644 --- a/app/routes/emergency.py +++ b/app/routes/emergency.py @@ -50,7 +50,10 @@ def create_emergency(): """Grantor creates an emergency access invitation for a trusted contact.""" data = request.get_json(silent=True) or {} grantee_email = (data.get('grantee_email') or '').strip().lower() - wait_days = int(data.get('wait_days', 7)) + try: + wait_days = int(data.get('wait_days', 7)) + except (TypeError, ValueError): + return jsonify({'error': 'wait_days must be an integer'}), 400 if not grantee_email: return jsonify({'error': 'grantee_email is required'}), 400 diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 029a6c7..7593abf 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -1,9 +1,12 @@ +import base64 +import os import uuid import time from datetime import datetime, timedelta from functools import wraps import jwt +from cryptography.hazmat.primitives.ciphers.aead import AESGCM from flask import current_app, request, g, jsonify from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError @@ -27,6 +30,44 @@ def verify_auth_token(auth_hash: str, stored_hash: str) -> bool: return False +def _get_totp_key() -> bytes: + """ + Return the 32-byte AES key used for server-side TOTP secret encryption. + The key is stored as a 64-char hex string in TOTP_ENCRYPTION_KEY config. + """ + hex_key = current_app.config.get('TOTP_ENCRYPTION_KEY', '') + if not hex_key or len(hex_key) != 64: + raise RuntimeError( + 'TOTP_ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes). ' + 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"' + ) + return bytes.fromhex(hex_key) + + +def encrypt_totp_secret(plaintext_secret: str) -> tuple[str, str]: + """ + Encrypt a plaintext TOTP base32 secret with AES-256-GCM. + Returns (ciphertext_b64, iv_b64). + """ + key = _get_totp_key() + iv = os.urandom(12) + aesgcm = AESGCM(key) + ciphertext = aesgcm.encrypt(iv, plaintext_secret.encode(), None) + return base64.b64encode(ciphertext).decode(), base64.b64encode(iv).decode() + + +def decrypt_totp_secret(ciphertext_b64: str, iv_b64: str) -> str: + """ + Decrypt a base64-encoded AES-256-GCM TOTP secret ciphertext. + Returns the plaintext base32 secret string. + """ + key = _get_totp_key() + iv = base64.b64decode(iv_b64) + ciphertext = base64.b64decode(ciphertext_b64) + aesgcm = AESGCM(key) + 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.""" now = datetime.utcnow() diff --git a/migrations/versions/a1b2c3d4e5f6_encrypt_totp_secret_at_rest.py b/migrations/versions/a1b2c3d4e5f6_encrypt_totp_secret_at_rest.py new file mode 100644 index 0000000..65e8498 --- /dev/null +++ b/migrations/versions/a1b2c3d4e5f6_encrypt_totp_secret_at_rest.py @@ -0,0 +1,49 @@ +"""encrypt totp_secret at rest: widen column and add totp_iv + +Revision ID: a1b2c3d4e5f6 +Revises: 71d7158dd3b9 +Create Date: 2026-04-18 00:00:00.000000 + +This migration: + 1. Widens users.totp_secret from VARCHAR(64) to VARCHAR(255) to hold base64 + AES-256-GCM ciphertext (plaintext secret ~32 chars → ciphertext ~64 bytes + → base64 ~88 chars, plus GCM tag 16 bytes → up to ~120 chars; 255 is safe). + 2. Adds users.totp_iv VARCHAR(64) for the base64 12-byte GCM nonce. + +After running this migration you MUST run the one-time re-encryption script: + + python scripts/reencrypt_totp_secrets.py + +That script reads every existing plaintext totp_secret, encrypts it with the +TOTP_ENCRYPTION_KEY from .env, and writes back the ciphertext + iv. +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'a1b2c3d4e5f6' +down_revision = '71d7158dd3b9' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column( + 'totp_secret', + existing_type=sa.String(length=64), + type_=sa.String(length=255), + existing_nullable=True, + ) + batch_op.add_column(sa.Column('totp_iv', sa.String(length=64), nullable=True)) + + +def downgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('totp_iv') + batch_op.alter_column( + 'totp_secret', + existing_type=sa.String(length=255), + type_=sa.String(length=64), + existing_nullable=True, + ) diff --git a/requirements.txt b/requirements.txt index 94586e5..8c0ec65 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,5 @@ python-dotenv>=1.0 gunicorn>=21.0 pyotp>=2.9.0 qrcode[pil]>=7.4.2 +cryptography>=42.0 # AES-256-GCM server-side TOTP secret encryption +redis>=5.0 # Shared rate-limit storage across Gunicorn workers diff --git a/scripts/backup.cron b/scripts/backup.cron new file mode 100644 index 0000000..f0d36e5 --- /dev/null +++ b/scripts/backup.cron @@ -0,0 +1,16 @@ +# PassKeeper — automated database backup cron +# +# Install: +# crontab -e +# Paste the line below, then save. +# +# What it does: +# Runs backup_db.sh at 02:00 daily. +# stdout/stderr is redirected to backup.log; cron will also email on failure +# if MAILTO is configured in the system crontab. +# +# Adjust the path to match your deployment directory. + +MAILTO="" + +0 2 * * * /bin/bash /home/spuser/PassKeeper/scripts/backup_db.sh >> /home/spuser/backups/passkeeper/backup.log 2>&1 diff --git a/scripts/backup_db.sh b/scripts/backup_db.sh new file mode 100644 index 0000000..7aedc42 --- /dev/null +++ b/scripts/backup_db.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# /home/spuser/PassKeeper/scripts/backup_db.sh +# +# Automated MySQL backup for PassKeeper. +# - Dumps the passkeeper database with mysqldump +# - Compresses with gzip +# - Retains the last 30 daily backups +# - Logs all actions with timestamps +# - Exits non-zero on any failure so cron can email on error +# +# Usage: +# chmod +x scripts/backup_db.sh +# # Test manually: +# bash scripts/backup_db.sh +# # Schedule via cron (see scripts/backup.cron) + +set -euo pipefail + +# ── Configuration ───────────────────────────────────────────────────────────── +ENV_FILE="$(dirname "$(realpath "$0")")/../.env" + +# Load .env variables +if [[ -f "$ENV_FILE" ]]; then + set -o allexport + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +o allexport +else + echo "ERROR: .env file not found at $ENV_FILE" >&2 + exit 1 +fi + +BACKUP_DIR="${BACKUP_DIR:-/home/spuser/backups/passkeeper}" +RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +BACKUP_FILE="${BACKUP_DIR}/passkeeper_${TIMESTAMP}.sql.gz" +LOG_FILE="${BACKUP_DIR}/backup.log" + +# MySQL credentials from .env +DB_HOST="${MYSQL_HOST:-127.0.0.1}" +DB_PORT="${MYSQL_PORT:-3306}" +DB_USER="${MYSQL_USER:-passkeeper}" +DB_PASS="${MYSQL_PASSWORD}" +DB_NAME="${MYSQL_DB:-passkeeper}" + +# ── Helpers ─────────────────────────────────────────────────────────────────── +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; } + +# ── Pre-flight ──────────────────────────────────────────────────────────────── +mkdir -p "$BACKUP_DIR" +chmod 700 "$BACKUP_DIR" + +log "INFO Starting backup → $BACKUP_FILE" + +# ── Dump ───────────────────────────────────────────────────────────────────── +# --single-transaction: consistent snapshot without locking (InnoDB) +# --routines --events: include stored procedures/events if any +# --no-tablespaces: avoid PROCESS privilege requirement on MySQL 8+ +mysqldump \ + --host="$DB_HOST" \ + --port="$DB_PORT" \ + --user="$DB_USER" \ + --password="$DB_PASS" \ + --single-transaction \ + --routines \ + --events \ + --no-tablespaces \ + "$DB_NAME" \ + | gzip -9 > "$BACKUP_FILE" + +BACKUP_SIZE="$(du -sh "$BACKUP_FILE" | cut -f1)" +log "INFO Backup complete — size: $BACKUP_SIZE" + +# ── Verify the file is non-empty ────────────────────────────────────────────── +if [[ ! -s "$BACKUP_FILE" ]]; then + log "ERROR Backup file is empty — aborting retention cleanup" + exit 1 +fi + +# ── Retention: delete backups older than RETENTION_DAYS ────────────────────── +DELETED=$(find "$BACKUP_DIR" -maxdepth 1 -name 'passkeeper_*.sql.gz' \ + -mtime +"$RETENTION_DAYS" -print -delete | wc -l) +log "INFO Retention cleanup: removed $DELETED file(s) older than ${RETENTION_DAYS} days" + +log "INFO Backup finished successfully" diff --git a/scripts/passkeeper-logrotate b/scripts/passkeeper-logrotate new file mode 100644 index 0000000..3ec5004 --- /dev/null +++ b/scripts/passkeeper-logrotate @@ -0,0 +1,26 @@ +# /etc/logrotate.d/passkeeper +# +# Install: +# sudo cp scripts/passkeeper-logrotate /etc/logrotate.d/passkeeper +# sudo logrotate -d /etc/logrotate.d/passkeeper # dry-run to verify +# sudo logrotate -f /etc/logrotate.d/passkeeper # force a rotation now +# +# Rotates Gunicorn access and error logs daily, retaining 30 days. +# Sends USR1 to Gunicorn after rotation so it reopens its log file handles +# without a full restart (zero downtime). + +/home/spuser/logs/access.log +/home/spuser/logs/error.log { + daily + rotate 30 + compress + delaycompress + missingok + notifempty + create 0640 www-data www-data + sharedscripts + postrotate + # Signal Gunicorn to reopen log files + systemctl kill -s USR1 passkeeper.service 2>/dev/null || true + endscript +} diff --git a/scripts/passkeeper-nginx.conf b/scripts/passkeeper-nginx.conf new file mode 100644 index 0000000..1bfc48c --- /dev/null +++ b/scripts/passkeeper-nginx.conf @@ -0,0 +1,114 @@ +# /etc/nginx/sites-available/passkeeper +# +# Hardened production Nginx config for PassKeeper. +# Phase 5 additions vs original: +# - Strict-Transport-Security (HSTS) with preload +# - X-Frame-Options: DENY +# - X-Content-Type-Options: nosniff +# - Referrer-Policy +# - Permissions-Policy +# - Content-Security-Policy (HTTP header level — authoritative over meta tag) +# - Connection-level rate limiting zones for auth endpoints +# - Buffer and timeout hardening + +# ── Rate limiting zones ──────────────────────────────────────────────────────── +# auth_limit: 10 req/s per IP for auth endpoints (login, register, MFA verify) +# api_limit: 60 req/s per IP for all other API endpoints +limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=60r/m; + +server { + server_name pwkeeper.ngodanguyen.tech passkeeper.ngodanguyen.tech; + + # ── TLS ─────────────────────────────────────────────────────────────────── + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/pwkeeper.ngodanguyen.tech/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/pwkeeper.ngodanguyen.tech/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Modern TLS only + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # ── Security headers ────────────────────────────────────────────────────── + # HSTS: enforce HTTPS for 1 year; include subdomains; allow preload submission + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + + # Clickjacking protection + add_header X-Frame-Options "DENY" always; + + # Prevent MIME sniffing + add_header X-Content-Type-Options "nosniff" always; + + # Control referrer leakage + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Disable unused browser features + add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always; + + # Content-Security-Policy (HTTP header takes precedence over meta tag) + 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';" + always; + + # ── Request hardening ───────────────────────────────────────────────────── + # Hide Nginx version + server_tokens off; + + # Limit request body size (vault items are small; prevent large payload abuse) + client_max_body_size 1m; + + # Timeouts + client_body_timeout 12s; + client_header_timeout 12s; + keepalive_timeout 15s; + send_timeout 10s; + + # ── Static files ────────────────────────────────────────────────────────── + location /static/ { + alias /home/spuser/PassKeeper/app/static/; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # ── Auth endpoints — stricter Nginx-level rate limit ────────────────────── + # Flask-Limiter (Redis-backed) is the primary guard; Nginx adds a network-level + # burst buffer of 5 before returning 429 directly, saving upstream connections. + location ~ ^/api/auth/(login|register|mfa/verify) { + limit_req zone=auth_limit burst=5 nodelay; + limit_req_status 429; + + proxy_pass http://127.0.0.1:5000; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── All other requests ──────────────────────────────────────────────────── + location / { + limit_req zone=api_limit burst=20 nodelay; + limit_req_status 429; + + proxy_pass http://127.0.0.1:5000; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +# ── HTTP → HTTPS redirect ───────────────────────────────────────────────────── +server { + listen 80; + server_name pwkeeper.ngodanguyen.tech passkeeper.ngodanguyen.tech; + return 301 https://$host$request_uri; +} diff --git a/scripts/passkeeper.service b/scripts/passkeeper.service new file mode 100644 index 0000000..08ad6c1 --- /dev/null +++ b/scripts/passkeeper.service @@ -0,0 +1,62 @@ +# /etc/systemd/system/passkeeper.service +# +# Install / update: +# sudo cp scripts/passkeeper.service /etc/systemd/system/passkeeper.service +# sudo systemctl daemon-reload +# sudo systemctl enable passkeeper +# sudo systemctl restart passkeeper +# journalctl -xeu passkeeper.service +# +# Phase 5 additions vs original: +# - WatchdogSec: systemd kills and restarts a hung Gunicorn within 30 s +# - Gunicorn --timeout: workers that don't respond within 25 s are replaced +# - Gunicorn --graceful-timeout: allows in-flight requests to finish on reload +# - PrivateTmp, NoNewPrivileges, ProtectSystem: basic systemd sandboxing +# - StartLimitIntervalSec / StartLimitBurst: caps restart storm + +[Unit] +Description=PassKeeper Gunicorn daemon +After=network.target mysql.service +Wants=mysql.service + +# Restart policy: cap to 5 restarts in 60 s to prevent restart storms +StartLimitIntervalSec=60 +StartLimitBurst=5 + +[Service] +User=www-data +Group=www-data +WorkingDirectory=/home/spuser/PassKeeper +EnvironmentFile=/home/spuser/PassKeeper/.env + +ExecStart=/home/spuser/.venv/bin/gunicorn \ + --workers 4 \ + --bind 127.0.0.1:5000 \ + --timeout 25 \ + --graceful-timeout 20 \ + --keep-alive 5 \ + --access-logfile /home/spuser/logs/access.log \ + --error-logfile /home/spuser/logs/error.log \ + --log-level warning \ + wsgi:app + +# Reload (zero-downtime): send USR2 to Gunicorn master +ExecReload=/bin/kill -s USR2 $MAINPID + +# Watchdog: systemd sends SIGKILL if Gunicorn doesn't send keepalives within 30 s. +# Requires gunicorn to be started with --preload OR the watchdog plugin; here we +# rely on the worker timeout (25 s) to recycle hung workers before the 30 s +# watchdog fires, which restarts the entire service. +WatchdogSec=30s + +Restart=on-failure +RestartSec=5s + +# Systemd sandboxing +PrivateTmp=true +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/home/spuser/PassKeeper /home/spuser/logs /home/spuser/backups + +[Install] +WantedBy=multi-user.target diff --git a/scripts/reencrypt_totp_secrets.py b/scripts/reencrypt_totp_secrets.py new file mode 100644 index 0000000..7655ac9 --- /dev/null +++ b/scripts/reencrypt_totp_secrets.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +""" +Re-encrypt existing plaintext TOTP secrets with AES-256-GCM. + +Run ONCE after applying migration a1b2c3d4e5f6 and before restarting the app: + + python scripts/reencrypt_totp_secrets.py + +Requirements: + - TOTP_ENCRYPTION_KEY must be set in .env (64-char hex string) + - Run from the project root directory + +This script is idempotent: it skips users who already have a totp_iv set, +so it is safe to re-run if interrupted. +""" +import os +import sys + +# Allow running from project root +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv +load_dotenv() + +from app import create_app, db +from app.models.user import User +from app.services.auth_service import encrypt_totp_secret + +app = create_app(os.environ.get('FLASK_ENV', 'production')) + +with app.app_context(): + users = User.query.filter( + User.totp_enabled == True, + User.totp_secret != None, + User.totp_iv == None, # skip already-encrypted rows + ).all() + + if not users: + print('No plaintext TOTP secrets found. Nothing to do.') + sys.exit(0) + + print(f'Found {len(users)} user(s) with plaintext TOTP secrets. Re-encrypting...') + errors = 0 + for user in users: + try: + plaintext = user.totp_secret + ciphertext_b64, iv_b64 = encrypt_totp_secret(plaintext) + user.totp_secret = ciphertext_b64 + user.totp_iv = iv_b64 + print(f' [OK] user_id={user.id} ({user.email})') + except Exception as e: + print(f' [ERROR] user_id={user.id} ({user.email}): {e}', file=sys.stderr) + errors += 1 + + if errors: + db.session.rollback() + print(f'\nAborted — {errors} error(s) encountered. No changes committed.', file=sys.stderr) + sys.exit(1) + + db.session.commit() + print(f'\nDone. {len(users)} TOTP secret(s) re-encrypted successfully.')