""" SooSeF Web Frontend Flask application factory that unifies Stegasoo (steganography) and Verisoo (provenance attestation) into a single web UI with fieldkit security features. ARCHITECTURE ============ The stegasoo web UI (3,600+ lines, 60 routes) is mounted wholesale via _register_stegasoo_routes() rather than being rewritten into a blueprint. This preserves the battle-tested subprocess isolation, async job management, and all existing route logic without modification. SooSeF-native features (attest, fieldkit, keys) are clean blueprints. Stegasoo routes (mounted at root): /encode, /decode, /generate, /tools, /api/* SooSeF blueprints: /attest, /verify → attest blueprint /fieldkit/* → fieldkit blueprint /keys/* → keys blueprint /admin/* → admin blueprint (extends stegasoo's) """ import io import mimetypes import os import secrets import sys import threading import time from concurrent.futures import ThreadPoolExecutor from pathlib import Path from flask import ( Flask, flash, jsonify, redirect, render_template, request, send_file, session, url_for, ) from PIL import Image import soosef from soosef.config import SoosefConfig from soosef.paths import AUTH_DB, INSTANCE_DIR, SECRET_KEY_FILE, TEMP_DIR, ensure_dirs # Suppress numpy/scipy warnings in subprocesses os.environ["NUMPY_MADVISE_HUGEPAGE"] = "0" os.environ["OMP_NUM_THREADS"] = "1" def create_app(config: SoosefConfig | None = None) -> Flask: """Application factory.""" config = config or SoosefConfig.load() ensure_dirs() web_dir = Path(__file__).parent app = Flask( __name__, instance_path=str(INSTANCE_DIR), template_folder=str(web_dir / "templates"), static_folder=str(web_dir / "static"), ) app.config["MAX_CONTENT_LENGTH"] = config.max_upload_mb * 1024 * 1024 app.config["AUTH_ENABLED"] = config.auth_enabled app.config["HTTPS_ENABLED"] = config.https_enabled app.config["SOOSEF_CONFIG"] = config # Persist secret key so sessions survive restarts _load_secret_key(app) # ── Initialize auth ─────────────────────────────────────────── # Add web dir to path so auth.py and support modules are importable sys.path.insert(0, str(web_dir)) from auth import init_app as init_auth, is_authenticated, is_admin, get_username init_auth(app) # ── Register stegasoo routes ────────────────────────────────── _register_stegasoo_routes(app) # ── Register SooSeF-native blueprints ───────────────────────── from frontends.web.blueprints.attest import bp as attest_bp from frontends.web.blueprints.fieldkit import bp as fieldkit_bp from frontends.web.blueprints.keys import bp as keys_bp app.register_blueprint(attest_bp) app.register_blueprint(fieldkit_bp) app.register_blueprint(keys_bp) # ── Context processor (injected into ALL templates) ─────────── @app.context_processor def inject_globals(): from soosef.keystore import KeystoreManager ks = KeystoreManager() ks_status = ks.status() # Fieldkit alert level fieldkit_status = "ok" if config.deadman_enabled: from soosef.fieldkit.deadman import DeadmanSwitch dm = DeadmanSwitch() if dm.should_fire(): fieldkit_status = "alarm" elif dm.is_overdue(): fieldkit_status = "warn" # Stegasoo capabilities try: from stegasoo import has_dct_support, HAS_AUDIO_SUPPORT from stegasoo import get_channel_status from stegasoo.constants import ( MAX_MESSAGE_CHARS, MAX_FILE_PAYLOAD_SIZE, MAX_UPLOAD_SIZE, TEMP_FILE_EXPIRY_MINUTES, MIN_PIN_LENGTH, MAX_PIN_LENGTH, MIN_PASSPHRASE_WORDS, RECOMMENDED_PASSPHRASE_WORDS, DEFAULT_PASSPHRASE_WORDS, __version__ as stegasoo_version, ) has_dct = has_dct_support() has_audio = HAS_AUDIO_SUPPORT channel_status = get_channel_status() # Stegasoo-specific template vars (needed by stego templates) stego_vars = { "has_dct": has_dct, "has_audio": has_audio, "max_message_chars": MAX_MESSAGE_CHARS, "max_payload_kb": MAX_FILE_PAYLOAD_SIZE // 1024, "max_upload_mb": MAX_UPLOAD_SIZE // (1024 * 1024), "temp_file_expiry_minutes": TEMP_FILE_EXPIRY_MINUTES, "min_pin_length": MIN_PIN_LENGTH, "max_pin_length": MAX_PIN_LENGTH, "min_passphrase_words": MIN_PASSPHRASE_WORDS, "recommended_passphrase_words": RECOMMENDED_PASSPHRASE_WORDS, "default_passphrase_words": DEFAULT_PASSPHRASE_WORDS, "channel_mode": channel_status["mode"], "channel_source": channel_status.get("source"), "supported_audio_formats": "WAV, FLAC, MP3, OGG, AAC, M4A" if has_audio else "", } except ImportError: has_dct = False has_audio = False stego_vars = {} # Verisoo availability try: import verisoo # noqa: F401 has_verisoo = True except ImportError: has_verisoo = False # Saved channel keys for authenticated users saved_channel_keys = [] if is_authenticated(): try: from auth import get_current_user, get_user_channel_keys current_user = get_current_user() if current_user: saved_channel_keys = get_user_channel_keys(current_user.id) except Exception: pass base_vars = { "version": soosef.__version__, "has_verisoo": has_verisoo, "has_fieldkit": config.killswitch_enabled or config.deadman_enabled, "fieldkit_status": fieldkit_status, "channel_configured": ks_status.has_channel_key, "channel_fingerprint": ks_status.channel_fingerprint or "", "identity_configured": ks_status.has_identity, "identity_fingerprint": ks_status.identity_fingerprint or "", "auth_enabled": app.config["AUTH_ENABLED"], "is_authenticated": is_authenticated(), "is_admin": is_admin(), "username": get_username() if is_authenticated() else None, "saved_channel_keys": saved_channel_keys, # QR support flags "has_qrcode": _HAS_QRCODE, "has_qrcode_read": _HAS_QRCODE_READ, } return {**base_vars, **stego_vars} # ── Root routes ─────────────────────────────────────────────── @app.route("/") def index(): return render_template("index.html") return app # ── QR support detection ───────────────────────────────────────────── try: import qrcode # noqa: F401 _HAS_QRCODE = True except ImportError: _HAS_QRCODE = False try: from pyzbar.pyzbar import decode as pyzbar_decode # noqa: F401 _HAS_QRCODE_READ = True except ImportError: _HAS_QRCODE_READ = False # ── Stegasoo route mounting ────────────────────────────────────────── def _register_stegasoo_routes(app: Flask) -> None: """ Mount all stegasoo web routes into the Flask app. Rather than rewriting 3,600 lines of battle-tested route logic, we import stegasoo's app.py and re-register its routes. The stegasoo templates are in templates/stego/ and extend our base.html. """ import temp_storage from subprocess_stego import ( SubprocessStego, cleanup_progress_file, generate_job_id, get_progress_file_path, read_progress, ) from auth import login_required, admin_required import stegasoo from stegasoo import ( HAS_AUDIO_SUPPORT, CapacityError, DecryptionError, FilePayload, InvalidHeaderError, InvalidMagicBytesError, ReedSolomonError, StegasooError, export_rsa_key_pem, generate_credentials, generate_filename, get_channel_status, has_argon2, has_dct_support, load_rsa_key, validate_channel_key, validate_file_payload, validate_image, validate_message, validate_passphrase, validate_pin, validate_rsa_key, validate_security_factors, ) from stegasoo.constants import ( DEFAULT_PASSPHRASE_WORDS, MAX_FILE_PAYLOAD_SIZE, MAX_FILE_SIZE, MAX_MESSAGE_CHARS, MAX_PIN_LENGTH, MAX_UPLOAD_SIZE, MIN_PASSPHRASE_WORDS, MIN_PIN_LENGTH, RECOMMENDED_PASSPHRASE_WORDS, TEMP_FILE_EXPIRY, TEMP_FILE_EXPIRY_MINUTES, THUMBNAIL_QUALITY, THUMBNAIL_SIZE, VALID_RSA_SIZES, __version__, ) from stegasoo.qr_utils import ( can_fit_in_qr, decompress_data, detect_and_crop_qr, extract_key_from_qr, generate_qr_code, is_compressed, ) from stegasoo.channel import resolve_channel_key # Initialize subprocess wrapper subprocess_stego = SubprocessStego(timeout=180) # Async job management _executor = ThreadPoolExecutor(max_workers=2) _jobs = {} _jobs_lock = threading.Lock() def _store_job(job_id, data): with _jobs_lock: _jobs[job_id] = data def _get_job(job_id): with _jobs_lock: return _jobs.get(job_id) def _cleanup_old_jobs(max_age_seconds=3600): now = time.time() with _jobs_lock: to_remove = [ jid for jid, data in _jobs.items() if now - data.get("created", 0) > max_age_seconds ] for jid in to_remove: cleanup_progress_file(jid) del _jobs[jid] # Helper functions def resolve_channel_key_form(channel_key_value): try: result = resolve_channel_key(channel_key_value) if result is None: return "auto" elif result == "": return "none" else: return result except (ValueError, FileNotFoundError): return "auto" def generate_thumbnail(image_data, size=THUMBNAIL_SIZE): try: with Image.open(io.BytesIO(image_data)) as img: if img.mode in ("RGBA", "LA", "P"): background = Image.new("RGB", img.size, (255, 255, 255)) if img.mode == "P": img = img.convert("RGBA") background.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None) img = background elif img.mode == "L": img = img.convert("RGB") elif img.mode != "RGB": img = img.convert("RGB") img.thumbnail(size, Image.Resampling.LANCZOS) buffer = io.BytesIO() img.save(buffer, format="JPEG", quality=THUMBNAIL_QUALITY, optimize=True) return buffer.getvalue() except Exception: return None def cleanup_temp_files(): temp_storage.cleanup_expired(TEMP_FILE_EXPIRY) def allowed_image(filename): if not filename or "." not in filename: return False return filename.rsplit(".", 1)[1].lower() in {"png", "jpg", "jpeg", "bmp", "gif"} def allowed_audio(filename): if not filename or "." not in filename: return False return filename.rsplit(".", 1)[1].lower() in {"wav", "flac", "mp3", "ogg", "aac", "m4a", "aiff", "aif"} def format_size(size_bytes): if size_bytes < 1024: return f"{size_bytes} B" elif size_bytes < 1024 * 1024: return f"{size_bytes / 1024:.1f} KB" else: return f"{size_bytes / (1024 * 1024):.1f} MB" # ── Auth routes (setup, login, logout, account) ──────────────── from auth import ( create_admin_user, verify_user_password, login_user as auth_login_user, logout_user as auth_logout_user, is_authenticated as auth_is_authenticated, user_exists as auth_user_exists, get_current_user, get_recovery_key_hash, has_recovery_key, set_recovery_key_hash, verify_and_reset_admin_password, change_password, get_all_users, create_user, delete_user, get_user_by_id, reset_user_password, generate_temp_password, can_create_user, get_non_admin_count, get_user_channel_keys, save_channel_key, delete_channel_key, can_save_channel_key, update_channel_key_name, update_channel_key_last_used, get_channel_key_by_id, clear_recovery_key, MAX_USERS, MAX_CHANNEL_KEYS, ) @app.route("/login", methods=["GET", "POST"]) def login(): if not app.config.get("AUTH_ENABLED", True): return redirect(url_for("index")) if not auth_user_exists(): return redirect(url_for("setup")) if auth_is_authenticated(): return redirect(url_for("index")) if request.method == "POST": username = request.form.get("username", "") password = request.form.get("password", "") user = verify_user_password(username, password) if user: auth_login_user(user) session.permanent = True flash("Login successful", "success") return redirect(url_for("index")) else: flash("Invalid username or password", "error") return render_template("login.html") @app.route("/logout") def logout(): auth_logout_user() flash("Logged out successfully", "success") return redirect(url_for("index")) @app.route("/setup", methods=["GET", "POST"]) def setup(): if not app.config.get("AUTH_ENABLED", True): return redirect(url_for("index")) if auth_user_exists(): return redirect(url_for("login")) if request.method == "POST": username = request.form.get("username", "admin") password = request.form.get("password", "") password_confirm = request.form.get("password_confirm", "") if password != password_confirm: flash("Passwords do not match", "error") else: success, message = create_admin_user(username, password) if success: user = verify_user_password(username, password) if user: auth_login_user(user) session.permanent = True flash("Setup complete!", "success") return redirect(url_for("index")) else: flash(message, "error") return render_template("setup.html") @app.route("/recover", methods=["GET", "POST"]) def recover(): if not get_recovery_key_hash(): flash("No recovery key configured", "error") return redirect(url_for("login")) if request.method == "POST": recovery_key = request.form.get("recovery_key", "").strip() new_password = request.form.get("new_password", "") new_password_confirm = request.form.get("new_password_confirm", "") if not recovery_key: flash("Please enter your recovery key", "error") elif new_password != new_password_confirm: flash("Passwords do not match", "error") elif len(new_password) < 8: flash("Password must be at least 8 characters", "error") else: success, message = verify_and_reset_admin_password(recovery_key, new_password) if success: flash("Password reset. Please login.", "success") return redirect(url_for("login")) else: flash(message, "error") return render_template("recover.html") @app.route("/account", methods=["GET", "POST"]) @login_required def account(): current_user = get_current_user() if request.method == "POST": current_password = request.form.get("current_password", "") new_password = request.form.get("new_password", "") confirm = request.form.get("confirm_password", "") if new_password != confirm: flash("Passwords do not match", "error") else: success, message = change_password(current_user.id, current_password, new_password) flash(message, "success" if success else "error") saved_keys = get_user_channel_keys(current_user.id) if current_user else [] return render_template( "account.html", current_user=current_user, saved_channel_keys=saved_keys, max_channel_keys=MAX_CHANNEL_KEYS, can_save_keys=can_save_channel_key(current_user.id) if current_user else False, has_recovery=has_recovery_key(), ) # ── Admin routes ───────────────────────────────────────────── @app.route("/admin/users") @admin_required def admin_users(): users = get_all_users() return render_template( "admin/users.html", users=users, max_users=MAX_USERS, can_create=can_create_user(), ) @app.route("/admin/users/new", methods=["GET", "POST"]) @admin_required def admin_new_user(): if request.method == "POST": username = request.form.get("username", "") temp_password = generate_temp_password() success, message = create_user(username, temp_password) if success: flash(f"User '{username}' created with temporary password: {temp_password}", "success") else: flash(message, "error") return redirect(url_for("admin_users")) return render_template("admin/user_new.html") @app.route("/admin/users//delete", methods=["POST"]) @admin_required def admin_delete_user(user_id): success, message = delete_user(user_id) flash(message, "success" if success else "error") return redirect(url_for("admin_users")) @app.route("/admin/users//reset", methods=["POST"]) @admin_required def admin_reset_password(user_id): temp_password = generate_temp_password() success, message = reset_user_password(user_id, temp_password) if success: user = get_user_by_id(user_id) flash(f"Password for '{user.username}' reset to: {temp_password}", "success") else: flash(message, "error") return redirect(url_for("admin_users")) # ── Generate routes ─────────────────────────────────────────── @app.route("/generate", methods=["GET", "POST"]) @login_required def generate(): if request.method == "POST": words_per_passphrase = int( request.form.get("words_per_passphrase", DEFAULT_PASSPHRASE_WORDS) ) use_pin = request.form.get("use_pin") == "on" use_rsa = request.form.get("use_rsa") == "on" if not use_pin and not use_rsa: flash("You must select at least one security factor (PIN or RSA Key)", "error") return render_template("stego/generate.html", generated=False, has_qrcode=_HAS_QRCODE) pin_length = int(request.form.get("pin_length", 6)) rsa_bits = int(request.form.get("rsa_bits", 2048)) words_per_passphrase = max(MIN_PASSPHRASE_WORDS, min(12, words_per_passphrase)) pin_length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, pin_length)) if rsa_bits not in VALID_RSA_SIZES: rsa_bits = 2048 try: creds = generate_credentials( use_pin=use_pin, use_rsa=use_rsa, pin_length=pin_length, rsa_bits=rsa_bits, passphrase_words=words_per_passphrase, ) qr_token = None qr_needs_compression = False qr_too_large = False if creds.rsa_key_pem and _HAS_QRCODE: if can_fit_in_qr(creds.rsa_key_pem, compress=True): qr_needs_compression = True else: qr_too_large = True if not qr_too_large: qr_token = secrets.token_urlsafe(16) cleanup_temp_files() temp_storage.save_temp_file( qr_token, creds.rsa_key_pem.encode(), {"filename": "rsa_key.pem", "type": "rsa_key", "compress": qr_needs_compression}, ) return render_template( "stego/generate.html", passphrase=creds.passphrase, pin=creds.pin, generated=True, words_per_passphrase=words_per_passphrase, pin_length=pin_length if use_pin else None, use_pin=use_pin, use_rsa=use_rsa, rsa_bits=rsa_bits, rsa_key_pem=creds.rsa_key_pem, passphrase_entropy=creds.passphrase_entropy, pin_entropy=creds.pin_entropy, rsa_entropy=creds.rsa_entropy, total_entropy=creds.total_entropy, has_qrcode=_HAS_QRCODE, qr_token=qr_token, qr_needs_compression=qr_needs_compression, qr_too_large=qr_too_large, ) except Exception as e: flash(f"Error generating credentials: {e}", "error") return render_template("stego/generate.html", generated=False, has_qrcode=_HAS_QRCODE) return render_template("stego/generate.html", generated=False, has_qrcode=_HAS_QRCODE) # ── Encode (placeholder — full route migration is Phase 1b) ─── @app.route("/encode", methods=["GET", "POST"]) @login_required def encode(): return render_template("stego/encode.html") @app.route("/decode", methods=["GET", "POST"]) @login_required def decode(): return render_template("stego/decode.html") @app.route("/tools") @login_required def tools(): return render_template("stego/tools.html") @app.route("/about") def about(): return render_template("stego/about.html") # ── API routes (capacity, channel, download) ────────────────── @app.route("/api/channel/status") @login_required def api_channel_status(): result = subprocess_stego.get_channel_status(reveal=False) if result.success: return jsonify({ "success": True, "mode": result.mode, "configured": result.configured, "fingerprint": result.fingerprint, "source": result.source, }) else: status = get_channel_status() return jsonify({ "success": True, "mode": status["mode"], "configured": status["configured"], "fingerprint": status.get("fingerprint"), "source": status.get("source"), }) @app.route("/api/compare-capacity", methods=["POST"]) @login_required def api_compare_capacity(): carrier = request.files.get("carrier") if not carrier: return jsonify({"error": "No carrier image provided"}), 400 try: carrier_data = carrier.read() result = subprocess_stego.compare_modes(carrier_data) if not result.success: return jsonify({"error": result.error or "Comparison failed"}), 500 return jsonify({ "success": True, "width": result.width, "height": result.height, "lsb": { "capacity_bytes": result.lsb["capacity_bytes"], "capacity_kb": round(result.lsb["capacity_kb"], 1), "output": result.lsb.get("output", "PNG"), }, "dct": { "capacity_bytes": result.dct["capacity_bytes"], "capacity_kb": round(result.dct["capacity_kb"], 1), "output": result.dct.get("output", "JPEG"), "available": result.dct.get("available", True), "ratio": round(result.dct.get("ratio_vs_lsb", 0), 1), }, }) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/download/") @login_required def api_download(file_id): file_info = temp_storage.get_temp_file(file_id) if not file_info: return jsonify({"error": "File not found or expired"}), 404 filename = file_info.get("filename", "download") mime_type = file_info.get("mime_type", "application/octet-stream") return send_file( io.BytesIO(file_info["data"]), mimetype=mime_type, as_attachment=True, download_name=filename, ) @app.route("/api/generate/credentials", methods=["POST"]) @login_required def api_generate_credentials(): try: creds = generate_credentials(use_pin=True, use_rsa=False) return jsonify({ "success": True, "passphrase": creds.passphrase, "pin": creds.pin, }) except Exception as e: return jsonify({"error": str(e)}), 500 def _load_secret_key(app: Flask) -> None: """Load or generate persistent secret key for Flask sessions.""" SECRET_KEY_FILE.parent.mkdir(parents=True, exist_ok=True) if SECRET_KEY_FILE.exists(): app.secret_key = SECRET_KEY_FILE.read_bytes() else: key = secrets.token_bytes(32) SECRET_KEY_FILE.write_bytes(key) SECRET_KEY_FILE.chmod(0o600) app.secret_key = key