diff --git a/.gitignore b/.gitignore index 244194c..e6da8e1 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,7 @@ build.sh rbld_containers.sh quick_web.sh project_stats.sh + +# Web UI auth database and SSL certs +frontends/web/instance/ +frontends/web/certs/ diff --git a/API.md b/API.md index bd0299c..6cf9e94 100644 --- a/API.md +++ b/API.md @@ -1,4 +1,4 @@ -# Stegasoo REST API Documentation (v4.0.1) +# Stegasoo REST API Documentation (v4.0.2) Complete REST API reference for Stegasoo steganography operations. @@ -113,7 +113,7 @@ Check API status and configuration. ```json { - "version": "4.0.1", + "version": "4.0.2", "has_argon2": true, "has_qrcode_read": true, "has_dct": true, @@ -462,7 +462,7 @@ X-Stegasoo-Capacity-Percent: 12.4 X-Stegasoo-Embed-Mode: lsb X-Stegasoo-Channel-Mode: private X-Stegasoo-Channel-Fingerprint: ABCD-••••-...-3456 -X-Stegasoo-Version: 4.0.1 +X-Stegasoo-Version: 4.0.2 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index bb5738b..2703448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to Stegasoo will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org). +## [4.0.2] - 2026-01-02 + +### Added +- **Web UI Authentication**: Single-admin login with SQLite3 user storage + - First-run setup wizard for admin account creation + - Account management page for password changes + - `@login_required` decorator protects encode/decode/generate routes + - Argon2id password hashing (lighter 64MB for fast login) +- **Optional HTTPS**: Auto-generated self-signed certificates for home network deployment + - Configurable via `STEGASOO_HTTPS_ENABLED` environment variable + - Certificates stored in `frontends/web/certs/` +- New environment variables: `STEGASOO_AUTH_ENABLED`, `STEGASOO_HTTPS_ENABLED`, `STEGASOO_HOSTNAME` + +### Changed +- PIN entry column widened in encode/decode forms (col-md-4 → col-md-6) +- Channel options column narrowed (col-md-8 → col-md-6) +- QR preview panels enlarged for better text readability +- Consistent font sizing across all preview panel banners (0.7rem filename, 0.6rem data, 0.65rem badges) + +### Fixed +- QR preview text too small to read in encode/decode templates +- Inconsistent label sizes between reference/carrier/stego panels + ## [4.0.1] - 2025-01-02 ### Fixed @@ -87,6 +110,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). - CLI interface - Basic PIN authentication +[4.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.1...v4.0.2 [4.0.1]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.0...v4.0.1 [4.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v3.2.0...v4.0.0 [3.2.0]: https://github.com/adlee-was-taken/stegasoo/compare/v3.0.2...v3.2.0 diff --git a/CLI.md b/CLI.md index 7b3ed5d..886bbcc 100644 --- a/CLI.md +++ b/CLI.md @@ -1,4 +1,4 @@ -# Stegasoo CLI Documentation (v4.0.1) +# Stegasoo CLI Documentation (v4.0.2) Complete command-line interface reference for Stegasoo steganography operations. diff --git a/INSTALL.md b/INSTALL.md index 4e49757..d8644f6 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -227,6 +227,23 @@ docker-compose logs -f docker-compose down ``` +#### Authentication Configuration (v4.0.2) + +The Web UI supports optional authentication. Configure via environment variables: + +```bash +# .env file (create in project root) +STEGASOO_AUTH_ENABLED=true # Enable login (default: true) +STEGASOO_HTTPS_ENABLED=false # Enable HTTPS (default: false) +STEGASOO_HOSTNAME=localhost # Hostname for SSL cert +STEGASOO_CHANNEL_KEY= # Optional channel key + +# Then run +docker-compose up -d web +``` + +On first access, you'll be prompted to create an admin account. The database and SSL certs are persisted in Docker volumes. + #### Services | Service | URL | Description | @@ -424,6 +441,10 @@ Stegasoo works on Raspberry Pi 4 (2GB+ RAM recommended): # System dependencies sudo apt-get install python3-dev libzbar0 libjpeg-dev +# Create venv with Python 3.12 (if available, or 3.11) +python3 -m venv venv +source venv/bin/activate + # Install (may take a while to compile) pip install stegasoo[cli] @@ -431,7 +452,25 @@ pip install stegasoo[cli] pip install stegasoo[web] # Needs ~768MB free ``` -**Note:** Argon2 operations will be slower on Pi due to memory-hardness. +**Running the Web UI on Pi:** +```bash +cd frontends/web + +# Optional: Enable authentication +export STEGASOO_AUTH_ENABLED=true + +# Optional: Enable HTTPS for local network security +export STEGASOO_HTTPS_ENABLED=true +export STEGASOO_HOSTNAME=raspberrypi.local + +# Start server +python app.py +``` + +**Notes:** +- Argon2 operations will be slower on Pi due to memory-hardness +- First run will prompt you to create an admin account +- HTTPS generates a self-signed certificate (browsers will warn) --- diff --git a/WEB_UI.md b/WEB_UI.md index c57cc6e..5bb8077 100644 --- a/WEB_UI.md +++ b/WEB_UI.md @@ -1,11 +1,12 @@ -# Stegasoo Web UI Documentation (v4.0.1) +# Stegasoo Web UI Documentation (v4.0.2) Complete guide for the Stegasoo web-based steganography interface. ## Table of Contents - [Overview](#overview) -- [What's New in v4.0.1](#whats-new-in-v401) +- [What's New in v4.0.2](#whats-new-in-v402) +- [Authentication & HTTPS](#authentication--https) - [Installation & Setup](#installation--setup) - [Pages & Features](#pages--features) - [Home Page](#home-page) @@ -53,24 +54,118 @@ Built with Flask, Bootstrap 5, and a modern dark theme. --- -## What's New in v4.0.1 +## What's New in v4.0.2 -Version 4.0.1 adds channel key support and UI improvements: +Version 4.0.2 adds authentication and HTTPS support for secure home network deployment: | Feature | Description | |---------|-------------| -| Channel keys | 256-bit keys for deployment/group isolation | -| Channel dropdown | Select channel mode (Auto/Public/Custom) | -| LED indicators | Visual status indicators for form fields | -| Key capsule styling | Improved RSA key display | -| Streamlined layout | PIN + Channel key in same row | +| **Authentication** | Single-admin login with SQLite3 user storage | +| **First-run setup** | Wizard to create admin account on first access | +| **Account management** | Change password page | +| **Optional HTTPS** | Auto-generated self-signed certificates | +| **UI improvements** | Larger QR previews, consistent panel styling | **Key benefits:** -- ✅ Channel key isolation - Different teams/deployments can't read each other's messages -- ✅ Dropdown selection for channel mode instead of radio buttons -- ✅ Visual LED indicators show field status -- ✅ Cleaner form layout with improved spacing -- ✅ Backward compatible - public mode works without channel key +- ✅ Secure your Web UI with username/password +- ✅ No manual database setup - automatic on first run +- ✅ HTTPS with auto-generated certs for home networks +- ✅ Configurable via environment variables +- ✅ Improved readability of QR preview panels + +--- + +## Authentication & HTTPS + +### Overview + +v4.0.2 adds optional authentication and HTTPS for secure home network deployment. + +### First-Run Setup + +On first access, you'll be prompted to create an admin account: + +1. Navigate to `http://localhost:5000` +2. You'll be redirected to `/setup` +3. Enter a username (e.g., "admin") +4. Enter a password (minimum 8 characters) +5. Confirm the password +6. Click "Create Admin Account" + +The admin account is stored in `frontends/web/instance/stegasoo.db` (SQLite). + +### Login + +After setup, protected pages require login: + +- **Protected routes:** `/encode`, `/decode`, `/generate`, `/account`, `/api/*` +- **Public routes:** `/`, `/about`, `/login`, `/setup` + +### Account Management + +Access `/account` to: +- View current username +- Change your password +- Logout + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `STEGASOO_AUTH_ENABLED` | `true` | Enable/disable authentication | +| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS with self-signed certs | +| `STEGASOO_HOSTNAME` | `localhost` | Hostname for certificate generation | + +### Enabling HTTPS + +```bash +# Enable HTTPS +export STEGASOO_HTTPS_ENABLED=true +export STEGASOO_HOSTNAME=stegasoo.local # Optional: your hostname + +cd frontends/web +python app.py +``` + +On first run with HTTPS enabled: +- Generates RSA 2048-bit private key +- Creates self-signed X.509 certificate (365 days validity) +- Stores in `frontends/web/certs/` +- Server starts on https://localhost:5000 + +**Note:** Browsers will show a security warning for self-signed certificates. This is expected for home network use. + +### Disabling Authentication + +For development or trusted networks: + +```bash +export STEGASOO_AUTH_ENABLED=false +cd frontends/web +python app.py +``` + +### Docker Configuration + +```yaml +# docker-compose.yml +services: + web: + environment: + STEGASOO_AUTH_ENABLED: "true" + STEGASOO_HTTPS_ENABLED: "true" + STEGASOO_HOSTNAME: "stegasoo.local" + volumes: + - ./instance:/app/frontends/web/instance # Persist user database + - ./certs:/app/frontends/web/certs # Persist SSL certs +``` + +### Security Notes + +- Passwords are hashed with Argon2id (time_cost=3, memory_cost=64MB) +- Single admin user only (no registration) +- Session-based authentication using Flask sessions +- Database stored in `instance/stegasoo.db` (add to `.gitignore`) --- @@ -752,6 +847,10 @@ Both modes use the same strong encryption (AES-256-GCM with Argon2id key derivat |----------|---------|-------------| | `FLASK_ENV` | production | Flask environment | | `PYTHONPATH` | - | Include `src/` for development | +| `STEGASOO_AUTH_ENABLED` | `true` | Enable/disable authentication (v4.0.2) | +| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS with self-signed certs (v4.0.2) | +| `STEGASOO_HOSTNAME` | `localhost` | Hostname for certificate CN (v4.0.2) | +| `STEGASOO_CHANNEL_KEY` | - | Channel key for deployment isolation | ### Application Limits @@ -808,12 +907,23 @@ services: target: web ports: - "5000:5000" + environment: + STEGASOO_AUTH_ENABLED: "true" + STEGASOO_HTTPS_ENABLED: "false" + STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-} + volumes: + - stegasoo-web-data:/app/frontends/web/instance + - stegasoo-web-certs:/app/frontends/web/certs deploy: resources: limits: memory: 768M reservations: memory: 384M + +volumes: + stegasoo-web-data: + stegasoo-web-certs: ``` --- diff --git a/docker-compose.yml b/docker-compose.yml index cb4e500..ab69087 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,14 @@ services: environment: <<: *common-env FLASK_ENV: production + # Authentication (v4.0.2) + STEGASOO_AUTH_ENABLED: ${STEGASOO_AUTH_ENABLED:-true} + STEGASOO_HTTPS_ENABLED: ${STEGASOO_HTTPS_ENABLED:-false} + STEGASOO_HOSTNAME: ${STEGASOO_HOSTNAME:-localhost} + volumes: + # Persist auth database and SSL certs (v4.0.2) + - stegasoo-web-data:/app/frontends/web/instance + - stegasoo-web-certs:/app/frontends/web/certs restart: unless-stopped deploy: resources: @@ -45,3 +53,10 @@ services: memory: 768M reservations: memory: 384M + +# Named volumes for persistent data +volumes: + stegasoo-web-data: + driver: local + stegasoo-web-certs: + driver: local diff --git a/frontends/web/app.py b/frontends/web/app.py index 3728102..b2bb6a3 100644 --- a/frontends/web/app.py +++ b/frontends/web/app.py @@ -29,8 +29,31 @@ import sys import time from pathlib import Path -from flask import Flask, flash, jsonify, redirect, render_template, request, send_file, url_for +from auth import ( + change_password, + create_user, + get_username, + is_authenticated, + login_required, + user_exists, + verify_password, +) +from auth import ( + init_app as init_auth, +) +from flask import ( + Flask, + flash, + jsonify, + redirect, + render_template, + request, + send_file, + session, + url_for, +) from PIL import Image +from ssl_utils import ensure_certs os.environ["NUMPY_MADVISE_HUGEPAGE"] = "0" os.environ["OMP_NUM_THREADS"] = "1" @@ -124,6 +147,13 @@ app = Flask(__name__) app.secret_key = secrets.token_hex(32) app.config["MAX_CONTENT_LENGTH"] = MAX_FILE_SIZE +# Auth configuration from environment +app.config["AUTH_ENABLED"] = os.environ.get("STEGASOO_AUTH_ENABLED", "true").lower() == "true" +app.config["HTTPS_ENABLED"] = os.environ.get("STEGASOO_HTTPS_ENABLED", "false").lower() == "true" + +# Initialize auth module +init_auth(app) + # Temporary file storage for sharing (file_id -> {data, timestamp, filename}) TEMP_FILES: dict[str, dict] = {} THUMBNAIL_FILES: dict[str, bytes] = {} @@ -159,6 +189,10 @@ def inject_globals(): "channel_configured": channel_status["configured"], "channel_fingerprint": channel_status.get("fingerprint"), "channel_source": channel_status.get("source"), + # NEW in v4.0.2 - Auth state + "auth_enabled": app.config.get("AUTH_ENABLED", True), + "is_authenticated": is_authenticated(), + "username": get_username() if is_authenticated() else None, } @@ -296,6 +330,7 @@ def index(): @app.route("/api/channel/status") +@login_required def api_channel_status(): """ Get current channel key status (v4.0.0). @@ -330,6 +365,7 @@ def api_channel_status(): @app.route("/api/channel/validate", methods=["POST"]) +@login_required def api_channel_validate(): """ Validate a channel key format (v4.0.0). @@ -366,6 +402,7 @@ def api_channel_validate(): @app.route("/generate", methods=["GET", "POST"]) +@login_required def generate(): if request.method == "POST": # v3.2.0: Changed from words_per_phrase to words_per_passphrase, default increased to 4 @@ -450,6 +487,7 @@ def generate(): @app.route("/generate/qr/") +@login_required def generate_qr(token): """Generate QR code for RSA key.""" if not HAS_QRCODE: @@ -473,6 +511,7 @@ def generate_qr(token): @app.route("/generate/qr-download/") +@login_required def generate_qr_download(token): """Download QR code as PNG file.""" if not HAS_QRCODE: @@ -501,6 +540,7 @@ def generate_qr_download(token): @app.route("/qr/crop", methods=["POST"]) +@login_required def qr_crop(): """ Detect and crop QR code from an image. @@ -538,6 +578,7 @@ def qr_crop(): @app.route("/generate/download-key", methods=["POST"]) +@login_required def download_key(): """Download RSA key as password-protected PEM file.""" key_pem = request.form.get("key_pem", "") @@ -570,6 +611,7 @@ def download_key(): @app.route("/extract-key-from-qr", methods=["POST"]) +@login_required def extract_key_from_qr_route(): """ Extract RSA key from uploaded QR code image. @@ -609,6 +651,7 @@ def extract_key_from_qr_route(): @app.route("/api/compare-capacity", methods=["POST"]) +@login_required def api_compare_capacity(): """ Compare LSB and DCT capacity for an uploaded carrier image. @@ -652,6 +695,7 @@ def api_compare_capacity(): @app.route("/api/check-fit", methods=["POST"]) +@login_required def api_check_fit(): """ Check if a payload will fit in the carrier with selected mode. @@ -705,6 +749,7 @@ def api_check_fit(): @app.route("/encode", methods=["GET", "POST"]) +@login_required def encode_page(): if request.method == "POST": try: @@ -926,6 +971,7 @@ def encode_page(): @app.route("/encode/result/") +@login_required def encode_result(file_id): if file_id not in TEMP_FILES: flash("File expired or not found. Please encode again.", "error") @@ -956,6 +1002,7 @@ def encode_result(file_id): @app.route("/encode/thumbnail/") +@login_required def encode_thumbnail(thumb_id): """Serve thumbnail image.""" if thumb_id not in THUMBNAIL_FILES: @@ -967,6 +1014,7 @@ def encode_thumbnail(thumb_id): @app.route("/encode/download/") +@login_required def encode_download(file_id): if file_id not in TEMP_FILES: flash("File expired or not found.", "error") @@ -984,6 +1032,7 @@ def encode_download(file_id): @app.route("/encode/file/") +@login_required def encode_file_route(file_id): """Serve file for Web Share API.""" if file_id not in TEMP_FILES: @@ -1001,6 +1050,7 @@ def encode_file_route(file_id): @app.route("/encode/cleanup/", methods=["POST"]) +@login_required def encode_cleanup(file_id): """Manually cleanup a file after sharing.""" TEMP_FILES.pop(file_id, None) @@ -1018,6 +1068,7 @@ def encode_cleanup(file_id): @app.route("/decode", methods=["GET", "POST"]) +@login_required def decode_page(): if request.method == "POST": try: @@ -1170,6 +1221,7 @@ def decode_page(): @app.route("/decode/download/") +@login_required def decode_download(file_id): """Download decoded file.""" if file_id not in TEMP_FILES: @@ -1245,9 +1297,117 @@ def test_capacity_nopil(): ) +# ============================================================================ +# AUTHENTICATION ROUTES (v4.0.2) +# ============================================================================ + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + """Login page.""" + if not app.config.get("AUTH_ENABLED", True): + return redirect(url_for("index")) + + if not user_exists(): + return redirect(url_for("setup")) + + if is_authenticated(): + return redirect(url_for("index")) + + if request.method == "POST": + password = request.form.get("password", "") + if verify_password(password): + session["authenticated"] = True + session.permanent = True + flash("Login successful", "success") + return redirect(url_for("index")) + else: + flash("Invalid password", "error") + + return render_template("login.html", username=get_username()) + + +@app.route("/logout") +def logout(): + """Logout and clear session.""" + session.clear() + flash("Logged out successfully", "success") + return redirect(url_for("index")) + + +@app.route("/setup", methods=["GET", "POST"]) +def setup(): + """First-run setup page.""" + if not app.config.get("AUTH_ENABLED", True): + return redirect(url_for("index")) + + if 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 len(password) < 8: + flash("Password must be at least 8 characters", "error") + elif password != password_confirm: + flash("Passwords do not match", "error") + else: + try: + create_user(username, password) + session["authenticated"] = True + session.permanent = True + flash("Admin account created successfully!", "success") + return redirect(url_for("index")) + except Exception as e: + flash(f"Error creating account: {e}", "error") + + return render_template("setup.html") + + +@app.route("/account", methods=["GET", "POST"]) +@login_required +def account(): + """Account management page.""" + if request.method == "POST": + current = request.form.get("current_password", "") + new = request.form.get("new_password", "") + new_confirm = request.form.get("new_password_confirm", "") + + if new != new_confirm: + flash("New passwords do not match", "error") + else: + success, message = change_password(current, new) + flash(message, "success" if success else "error") + + return render_template("account.html", username=get_username()) + + # ============================================================================ # MAIN # ============================================================================ if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000, debug=False) + base_dir = Path(__file__).parent + + # HTTPS configuration + ssl_context = None + if app.config.get("HTTPS_ENABLED", False): + hostname = os.environ.get("STEGASOO_HOSTNAME", "localhost") + cert_path, key_path = ensure_certs(base_dir, hostname) + ssl_context = (str(cert_path), str(key_path)) + print(f"HTTPS enabled with self-signed certificate for {hostname}") + + # Auth status + if app.config.get("AUTH_ENABLED", True): + print("Authentication enabled") + else: + print("Authentication disabled") + + app.run( + host="0.0.0.0", + port=5000, + debug=False, + ssl_context=ssl_context, + ) diff --git a/frontends/web/auth.py b/frontends/web/auth.py new file mode 100644 index 0000000..75f0797 --- /dev/null +++ b/frontends/web/auth.py @@ -0,0 +1,162 @@ +""" +Stegasoo Authentication Module + +Single-admin authentication with Argon2 password hashing. +Uses Flask sessions for authentication state and SQLite3 for storage. +""" + +import functools +import sqlite3 +from pathlib import Path + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +from flask import current_app, g, redirect, session, url_for + +# Argon2 password hasher (lighter than stegasoo's 256MB for faster login) +ph = PasswordHasher( + time_cost=3, + memory_cost=65536, # 64MB + parallelism=4, + hash_len=32, + salt_len=16, +) + + +def get_db_path() -> Path: + """Get database path in Flask instance folder.""" + instance_path = Path(current_app.instance_path) + instance_path.mkdir(parents=True, exist_ok=True) + return instance_path / "stegasoo.db" + + +def get_db() -> sqlite3.Connection: + """Get database connection, cached on Flask g object.""" + if "db" not in g: + g.db = sqlite3.connect(get_db_path()) + g.db.row_factory = sqlite3.Row + return g.db + + +def close_db(e=None): + """Close database connection at end of request.""" + db = g.pop("db", None) + if db is not None: + db.close() + + +def init_db(): + """Initialize database schema.""" + db = get_db() + db.executescript(""" + CREATE TABLE IF NOT EXISTS admin_user ( + id INTEGER PRIMARY KEY CHECK (id = 1), + username TEXT NOT NULL DEFAULT 'admin', + password_hash TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """) + db.commit() + + +def user_exists() -> bool: + """Check if admin user has been created.""" + db = get_db() + result = db.execute("SELECT 1 FROM admin_user WHERE id = 1").fetchone() + return result is not None + + +def create_user(username: str, password: str): + """Create admin user (first-run setup).""" + if user_exists(): + raise ValueError("Admin user already exists") + + password_hash = ph.hash(password) + db = get_db() + db.execute( + "INSERT INTO admin_user (id, username, password_hash) VALUES (1, ?, ?)", + (username, password_hash), + ) + db.commit() + + +def get_username() -> str: + """Get the admin username.""" + db = get_db() + row = db.execute("SELECT username FROM admin_user WHERE id = 1").fetchone() + return row["username"] if row else "admin" + + +def verify_password(password: str) -> bool: + """Verify password against stored hash.""" + db = get_db() + row = db.execute("SELECT password_hash FROM admin_user WHERE id = 1").fetchone() + if not row: + return False + try: + ph.verify(row["password_hash"], password) + # Rehash if parameters changed + if ph.check_needs_rehash(row["password_hash"]): + new_hash = ph.hash(password) + db.execute( + "UPDATE admin_user SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1", + (new_hash,), + ) + db.commit() + return True + except VerifyMismatchError: + return False + + +def change_password(current_password: str, new_password: str) -> tuple[bool, str]: + """Change admin password. Returns (success, message).""" + if not verify_password(current_password): + return False, "Current password is incorrect" + + if len(new_password) < 8: + return False, "New password must be at least 8 characters" + + new_hash = ph.hash(new_password) + db = get_db() + db.execute( + "UPDATE admin_user SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1", + (new_hash,), + ) + db.commit() + return True, "Password changed successfully" + + +def is_authenticated() -> bool: + """Check if current session is authenticated.""" + return session.get("authenticated", False) + + +def login_required(f): + """Decorator to require login for a route.""" + + @functools.wraps(f) + def decorated_function(*args, **kwargs): + # Check if auth is enabled + if not current_app.config.get("AUTH_ENABLED", True): + return f(*args, **kwargs) + + # Check for first-run setup + if not user_exists(): + return redirect(url_for("setup")) + + # Check authentication + if not is_authenticated(): + return redirect(url_for("login")) + + return f(*args, **kwargs) + + return decorated_function + + +def init_app(app): + """Initialize auth module with Flask app.""" + app.teardown_appcontext(close_db) + + with app.app_context(): + init_db() diff --git a/frontends/web/ssl_utils.py b/frontends/web/ssl_utils.py new file mode 100644 index 0000000..d631eb7 --- /dev/null +++ b/frontends/web/ssl_utils.py @@ -0,0 +1,111 @@ +""" +SSL Certificate Utilities + +Auto-generates self-signed certificates for HTTPS. +Uses cryptography library (already a dependency). +""" + +import datetime +import ipaddress +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + + +def get_cert_paths(base_dir: Path) -> tuple[Path, Path]: + """Get paths for cert and key files.""" + cert_dir = base_dir / "certs" + cert_dir.mkdir(parents=True, exist_ok=True) + return cert_dir / "server.crt", cert_dir / "server.key" + + +def certs_exist(base_dir: Path) -> bool: + """Check if both cert files exist.""" + cert_path, key_path = get_cert_paths(base_dir) + return cert_path.exists() and key_path.exists() + + +def generate_self_signed_cert( + base_dir: Path, + hostname: str = "localhost", + days_valid: int = 365, +) -> tuple[Path, Path]: + """ + Generate self-signed SSL certificate. + + Args: + base_dir: Base directory for certs folder + hostname: Server hostname for certificate + days_valid: Certificate validity in days + + Returns: + Tuple of (cert_path, key_path) + """ + cert_path, key_path = get_cert_paths(base_dir) + + # Generate RSA key + key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + # Create certificate + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"), + x509.NameAttribute(NameOID.COMMON_NAME, hostname), + ]) + + # Subject Alternative Names + san_list = [ + x509.DNSName(hostname), + x509.DNSName("localhost"), + x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")), + ] + # Add the hostname as IP if it looks like one + try: + san_list.append(x509.IPAddress(ipaddress.IPv4Address(hostname))) + except ipaddress.AddressValueError: + pass + + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=days_valid)) + .add_extension( + x509.SubjectAlternativeName(san_list), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + + # Write key file (chmod 600) + key_path.write_bytes( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + key_path.chmod(0o600) + + # Write cert file + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + + return cert_path, key_path + + +def ensure_certs(base_dir: Path, hostname: str = "localhost") -> tuple[Path, Path]: + """Ensure certificates exist, generating if needed.""" + if certs_exist(base_dir): + return get_cert_paths(base_dir) + + print(f"Generating self-signed SSL certificate for {hostname}...") + return generate_self_signed_cert(base_dir, hostname) diff --git a/frontends/web/static/style.css b/frontends/web/static/style.css index abd012e..5d39bac 100644 --- a/frontends/web/static/style.css +++ b/frontends/web/static/style.css @@ -724,6 +724,7 @@ footer { .scan-data-value { color: rgba(0, 255, 170, 1); font-weight: 600; + font-size: 0.65rem; } .scan-hash-preview { @@ -744,7 +745,7 @@ footer { border: 1px solid rgba(0, 255, 170, 0.4); border-radius: 3px; padding: 2px 6px; - font-size: 0.5rem; + font-size: 0.65rem; color: rgba(0, 255, 170, 1); text-transform: uppercase; letter-spacing: 0.3px; @@ -1001,6 +1002,7 @@ footer { .pixel-data-value { color: #d4e157; font-weight: 600; + font-size: 0.65rem; } .pixel-status-badge { @@ -1010,7 +1012,7 @@ footer { border: 1px solid rgba(212, 225, 87, 0.4); border-radius: 3px; padding: 2px 6px; - font-size: 0.55rem; + font-size: 0.65rem; color: #d4e157; text-transform: uppercase; letter-spacing: 0.5px; @@ -1047,10 +1049,10 @@ footer { /* Expand drop zone when showing scanned QR result */ #rsaQrSection .drop-zone:has(.qr-scan-container:not(.d-none)) { width: auto; - min-width: 200px; - max-width: 280px; + min-width: 280px; + max-width: 400px; height: auto; - min-height: 200px; + min-height: 280px; aspect-ratio: auto; } @@ -1070,9 +1072,9 @@ footer { overflow: visible; border-radius: 8px; background: rgba(0, 0, 0, 0.3); - min-height: 160px; - min-width: 160px; - padding: 10px; + min-height: 220px; + min-width: 220px; + padding: 12px; display: flex; justify-content: center; align-items: center; @@ -1090,10 +1092,10 @@ footer { /* Cropped image - hidden until loaded, scales UP to fill container */ .qr-scan-container .qr-cropped { - max-height: 180px; - max-width: 180px; - min-width: 140px; - min-height: 140px; + max-height: 240px; + max-width: 240px; + min-width: 180px; + min-height: 180px; width: auto; height: auto; object-fit: contain; @@ -1255,11 +1257,11 @@ footer { left: 0; right: 0; z-index: 10; - background: linear-gradient(to top, - rgba(10, 15, 30, 0.95) 0%, - rgba(10, 15, 30, 0.6) 80%, + background: linear-gradient(to top, + rgba(10, 15, 30, 0.95) 0%, + rgba(10, 15, 30, 0.6) 80%, transparent 100%); - padding: 4px 6px 3px 6px; + padding: 8px 10px 6px 10px; opacity: 0; transition: opacity 0.3s ease; border-radius: 0 0 6px 6px; @@ -1282,10 +1284,10 @@ footer { /* QR Data Panel text styles */ .qr-data-filename { font-family: 'Courier New', monospace; - font-size: 0.6rem; + font-size: 0.7rem; color: #fff; text-align: center; - margin-bottom: 2px; + margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1301,7 +1303,7 @@ footer { justify-content: space-between; align-items: center; font-family: 'Courier New', monospace; - font-size: 0.5rem; + font-size: 0.6rem; white-space: nowrap; } @@ -1310,9 +1312,9 @@ footer { align-items: center; background: rgba(0, 255, 170, 0.15); border: 1px solid rgba(0, 255, 170, 0.4); - border-radius: 2px; - padding: 1px 4px; - font-size: 0.45rem; + border-radius: 3px; + padding: 2px 6px; + font-size: 0.65rem; color: rgba(0, 255, 170, 1); text-transform: uppercase; letter-spacing: 0.3px; @@ -1321,7 +1323,7 @@ footer { .qr-data-value { color: rgba(0, 255, 170, 1); font-weight: 600; - font-size: 0.5rem; + font-size: 0.65rem; } /* ---------------------------------------------------------------------------- diff --git a/frontends/web/templates/account.html b/frontends/web/templates/account.html new file mode 100644 index 0000000..f64bd6d --- /dev/null +++ b/frontends/web/templates/account.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} + +{% block title %}Account - Stegasoo{% endblock %} + +{% block content %} +
+
+
+
+
Account Settings
+
+
+

+ Logged in as {{ username }} +

+ +
Change Password
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
Minimum 8 characters
+
+ +
+ +
+ + +
+
+ + +
+ +
+ + + Logout + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/frontends/web/templates/base.html b/frontends/web/templates/base.html index 6f40f92..3449aa0 100644 --- a/frontends/web/templates/base.html +++ b/frontends/web/templates/base.html @@ -24,20 +24,38 @@ + {% if not auth_enabled or is_authenticated %} - - + {% endif %} + {% if auth_enabled %} + {% if is_authenticated %} + + {% else %} + + {% endif %} + {% endif %} diff --git a/frontends/web/templates/decode.html b/frontends/web/templates/decode.html index 4e9da76..44c5c87 100644 --- a/frontends/web/templates/decode.html +++ b/frontends/web/templates/decode.html @@ -327,11 +327,11 @@
-
+
- + @@ -340,7 +340,7 @@
-
+