Merge stegasoo (v4.3.0, steganography) and verisoo (v0.1.0, attestation) as subpackages under soosef.stegasoo and soosef.verisoo. This eliminates cross-repo coordination and enables atomic changes across the full stack. - Copy stegasoo (34 modules) and verisoo (15 modules) into src/soosef/ - Convert all verisoo absolute imports to relative imports - Rewire ~50 import sites across soosef code (cli, web, keystore, tests) - Replace stegasoo/verisoo pip deps with inlined code + pip extras (stego-dct, stego-audio, attest, web, api, cli, fieldkit, all, dev) - Add _availability.py for runtime feature detection - Add unified FastAPI mount point at soosef.api - Copy and adapt tests from both repos (155 pass, 1 skip) - Drop standalone CLI/web frontends; keep FastAPI as optional modules - Both source repos tagged pre-monorepo-consolidation on GitHub Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
754 lines
28 KiB
Python
754 lines
28 KiB
Python
"""
|
|
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 os
|
|
import secrets
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from flask import (
|
|
Flask,
|
|
flash,
|
|
jsonify,
|
|
redirect,
|
|
render_template,
|
|
request,
|
|
send_file,
|
|
session,
|
|
url_for,
|
|
)
|
|
|
|
import soosef
|
|
from soosef.config import SoosefConfig
|
|
from soosef.paths import 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
|
|
|
|
# Point temp_storage at ~/.soosef/temp/ before any routes run, so all
|
|
# uploaded files land where the killswitch's destroy_temp_files step
|
|
# expects them. Must happen after ensure_dirs() so the directory exists.
|
|
import temp_storage as _ts
|
|
|
|
_ts.init(TEMP_DIR)
|
|
|
|
# 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 get_username, is_admin, is_authenticated
|
|
from auth import init_app as init_auth
|
|
|
|
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 soosef.stegasoo import HAS_AUDIO_SUPPORT, get_channel_status, has_dct_support
|
|
from soosef.stegasoo.constants import (
|
|
DEFAULT_PASSPHRASE_WORDS,
|
|
MAX_FILE_PAYLOAD_SIZE,
|
|
MAX_MESSAGE_CHARS,
|
|
MAX_PIN_LENGTH,
|
|
MAX_UPLOAD_SIZE,
|
|
MIN_PASSPHRASE_WORDS,
|
|
MIN_PIN_LENGTH,
|
|
RECOMMENDED_PASSPHRASE_WORDS,
|
|
TEMP_FILE_EXPIRY_MINUTES,
|
|
)
|
|
|
|
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 soosef.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 auth import admin_required, login_required
|
|
from soosef.stegasoo import (
|
|
export_rsa_key_pem,
|
|
generate_credentials,
|
|
get_channel_status,
|
|
load_rsa_key,
|
|
)
|
|
from soosef.stegasoo.constants import (
|
|
DEFAULT_PASSPHRASE_WORDS,
|
|
MAX_PIN_LENGTH,
|
|
MIN_PASSPHRASE_WORDS,
|
|
MIN_PIN_LENGTH,
|
|
TEMP_FILE_EXPIRY,
|
|
VALID_RSA_SIZES,
|
|
)
|
|
from soosef.stegasoo.qr_utils import (
|
|
can_fit_in_qr,
|
|
generate_qr_code,
|
|
)
|
|
from subprocess_stego import (
|
|
SubprocessStego,
|
|
)
|
|
|
|
from soosef.audit import log_action
|
|
|
|
# Initialize subprocess wrapper
|
|
subprocess_stego = SubprocessStego(timeout=180)
|
|
|
|
# ── Auth routes (setup, login, logout, account) ────────────────
|
|
|
|
from auth import (
|
|
MAX_CHANNEL_KEYS,
|
|
MAX_USERS,
|
|
can_create_user,
|
|
can_save_channel_key,
|
|
change_password,
|
|
create_admin_user,
|
|
create_user,
|
|
delete_user,
|
|
generate_temp_password,
|
|
get_all_users,
|
|
get_current_user,
|
|
get_recovery_key_hash,
|
|
get_user_by_id,
|
|
get_user_channel_keys,
|
|
has_recovery_key,
|
|
reset_user_password,
|
|
verify_and_reset_admin_password,
|
|
verify_user_password,
|
|
)
|
|
from auth import (
|
|
is_authenticated as auth_is_authenticated,
|
|
)
|
|
from auth import (
|
|
login_user as auth_login_user,
|
|
)
|
|
from auth import (
|
|
logout_user as auth_logout_user,
|
|
)
|
|
from auth import (
|
|
user_exists as auth_user_exists,
|
|
)
|
|
|
|
@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,
|
|
user_count=len(users),
|
|
max_users=MAX_USERS,
|
|
can_create=can_create_user(),
|
|
current_user=get_current_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)
|
|
log_action(
|
|
actor=get_username(), # noqa: F821
|
|
action="user.create",
|
|
target=f"user:{username}",
|
|
outcome="success" if success else "failure",
|
|
source="web",
|
|
detail=None if success else message,
|
|
)
|
|
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/<int:user_id>/delete", methods=["POST"])
|
|
@admin_required
|
|
def admin_delete_user(user_id):
|
|
target_user = get_user_by_id(user_id)
|
|
target_name = target_user.username if target_user else str(user_id)
|
|
success, message = delete_user(user_id, get_current_user().id)
|
|
log_action(
|
|
actor=get_username(), # noqa: F821
|
|
action="user.delete",
|
|
target=f"user:{target_name}",
|
|
outcome="success" if success else "failure",
|
|
source="web",
|
|
detail=None if success else message,
|
|
)
|
|
flash(message, "success" if success else "error")
|
|
return redirect(url_for("admin_users"))
|
|
|
|
@app.route("/admin/users/<int:user_id>/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)
|
|
target_user = get_user_by_id(user_id)
|
|
target_name = target_user.username if target_user else str(user_id)
|
|
log_action(
|
|
actor=get_username(), # noqa: F821
|
|
action="user.password_reset",
|
|
target=f"user:{target_name}",
|
|
outcome="success" if success else "failure",
|
|
source="web",
|
|
detail=None if success else message,
|
|
)
|
|
if success:
|
|
flash(f"Password for '{target_name}' 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)
|
|
temp_storage.cleanup_expired(TEMP_FILE_EXPIRY)
|
|
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)
|
|
|
|
# ── Generate QR + Key download routes ───────────────────────
|
|
|
|
@app.route("/generate/qr/<token>")
|
|
@login_required
|
|
def generate_qr(token):
|
|
if not _HAS_QRCODE:
|
|
return "QR code support not available", 501
|
|
file_info = temp_storage.get_temp_file(token)
|
|
if not file_info:
|
|
return "Token expired or invalid", 404
|
|
if file_info.get("type") != "rsa_key":
|
|
return "Invalid token type", 400
|
|
try:
|
|
key_pem = file_info["data"].decode("utf-8")
|
|
compress = file_info.get("compress", False)
|
|
qr_png = generate_qr_code(key_pem, compress=compress)
|
|
return send_file(io.BytesIO(qr_png), mimetype="image/png", as_attachment=False)
|
|
except Exception as e:
|
|
return f"Error generating QR code: {e}", 500
|
|
|
|
@app.route("/generate/qr-download/<token>")
|
|
@login_required
|
|
def generate_qr_download(token):
|
|
if not _HAS_QRCODE:
|
|
return "QR code support not available", 501
|
|
file_info = temp_storage.get_temp_file(token)
|
|
if not file_info:
|
|
return "Token expired or invalid", 404
|
|
if file_info.get("type") != "rsa_key":
|
|
return "Invalid token type", 400
|
|
try:
|
|
key_pem = file_info["data"].decode("utf-8")
|
|
compress = file_info.get("compress", False)
|
|
qr_png = generate_qr_code(key_pem, compress=compress)
|
|
return send_file(
|
|
io.BytesIO(qr_png),
|
|
mimetype="image/png",
|
|
as_attachment=True,
|
|
download_name="soosef_rsa_key_qr.png",
|
|
)
|
|
except Exception as e:
|
|
return f"Error generating QR code: {e}", 500
|
|
|
|
@app.route("/generate/download-key", methods=["POST"])
|
|
@login_required
|
|
def download_key():
|
|
key_pem = request.form.get("key_pem", "")
|
|
password = request.form.get("key_password", "")
|
|
if not key_pem:
|
|
flash("No key to download", "error")
|
|
return redirect(url_for("generate"))
|
|
if not password or len(password) < 8:
|
|
flash("Password must be at least 8 characters", "error")
|
|
return redirect(url_for("generate"))
|
|
try:
|
|
private_key = load_rsa_key(key_pem.encode("utf-8"))
|
|
encrypted_pem = export_rsa_key_pem(private_key, password=password)
|
|
key_id = secrets.token_hex(4)
|
|
filename = f"soosef_key_{private_key.key_size}_{key_id}.pem"
|
|
return send_file(
|
|
io.BytesIO(encrypted_pem),
|
|
mimetype="application/x-pem-file",
|
|
as_attachment=True,
|
|
download_name=filename,
|
|
)
|
|
except Exception as e:
|
|
flash(f"Error creating key file: {e}", "error")
|
|
return redirect(url_for("generate"))
|
|
|
|
# ── Encode/Decode/Tools routes (from stego_routes.py) ────────
|
|
|
|
from stego_routes import register_stego_routes
|
|
|
|
register_stego_routes(
|
|
app,
|
|
**{
|
|
"login_required": login_required,
|
|
"subprocess_stego": subprocess_stego,
|
|
"temp_storage": temp_storage,
|
|
"has_qrcode_read": _HAS_QRCODE_READ,
|
|
},
|
|
)
|
|
|
|
# /about route is in stego_routes.py
|
|
|
|
# ── 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/<file_id>")
|
|
@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
|