v4.0.2: Add Web UI authentication and optional HTTPS
Some checks failed
Release / test (push) Failing after 43s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped

- Add single-admin login with SQLite3 user storage
- First-run setup wizard for admin account creation
- Account management page for password changes
- Optional HTTPS with auto-generated self-signed certificates
- Configurable via STEGASOO_AUTH_ENABLED, STEGASOO_HTTPS_ENABLED env vars
- UI improvements: larger QR previews, consistent panel styling
- Update docker-compose.yml with auth config and persistent volumes
- Update all documentation for v4.0.2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-02 20:00:47 -05:00
parent 28d77957eb
commit cf247d207f
18 changed files with 961 additions and 54 deletions

4
.gitignore vendored
View File

@@ -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/

6
API.md
View File

@@ -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
<binary image data>
```

View File

@@ -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

2
CLI.md
View File

@@ -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.

View File

@@ -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)
---

138
WEB_UI.md
View File

@@ -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:
```
---

View File

@@ -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

View File

@@ -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/<token>")
@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/<token>")
@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/<file_id>")
@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/<thumb_id>")
@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/<file_id>")
@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/<file_id>")
@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/<file_id>", 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/<file_id>")
@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,
)

162
frontends/web/auth.py Normal file
View File

@@ -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()

111
frontends/web/ssl_utils.py Normal file
View File

@@ -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)

View File

@@ -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;
}
/* ----------------------------------------------------------------------------

View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Account - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-person-gear me-2"></i>Account Settings</h5>
</div>
<div class="card-body">
<p class="text-muted mb-4">
Logged in as <strong>{{ username }}</strong>
</p>
<h6 class="text-muted mb-3">Change Password</h6>
<form method="POST" action="{{ url_for('account') }}" id="accountForm">
<div class="mb-3">
<label class="form-label">
<i class="bi bi-key me-1"></i> Current Password
</label>
<div class="input-group">
<input type="password" name="current_password" class="form-control"
id="currentPasswordInput" required>
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('currentPasswordInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">
<i class="bi bi-key-fill me-1"></i> New Password
</label>
<div class="input-group">
<input type="password" name="new_password" class="form-control"
id="newPasswordInput" required minlength="8">
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('newPasswordInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-text">Minimum 8 characters</div>
</div>
<div class="mb-4">
<label class="form-label">
<i class="bi bi-key-fill me-1"></i> Confirm New Password
</label>
<div class="input-group">
<input type="password" name="new_password_confirm" class="form-control"
id="newPasswordConfirmInput" required minlength="8">
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('newPasswordConfirmInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-check-lg me-2"></i>Update Password
</button>
</form>
<hr class="my-4">
<a href="{{ url_for('logout') }}" class="btn btn-outline-danger w-100">
<i class="bi bi-box-arrow-left me-2"></i>Logout
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const icon = btn.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('bi-eye', 'bi-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('bi-eye-slash', 'bi-eye');
}
}
document.getElementById('accountForm')?.addEventListener('submit', function(e) {
const newPass = document.getElementById('newPasswordInput').value;
const confirm = document.getElementById('newPasswordConfirmInput').value;
if (newPass !== confirm) {
e.preventDefault();
alert('New passwords do not match');
}
});
</script>
{% endblock %}

View File

@@ -24,20 +24,38 @@
<li class="nav-item">
<a class="nav-link" href="/"><i class="bi bi-house me-1"></i> Home</a>
</li>
{% if not auth_enabled or is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="/encode"><i class="bi bi-lock me-1"></i> Encode</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/decode"><i class="bi bi-unlock me-1"></i> Decode</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/generate"><i class="bi bi-key me-1"></i> Generate</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="/about"><i class="bi bi-info-circle me-1"></i> About</a>
</li>
{% if auth_enabled %}
{% if is_authenticated %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle me-1"></i> {{ username }}
</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
<li><a class="dropdown-item" href="/account"><i class="bi bi-gear me-2"></i>Account</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li>
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="/login"><i class="bi bi-box-arrow-in-right me-1"></i> Login</a>
</li>
{% endif %}
{% endif %}
</ul>
</div>
</div>

View File

@@ -327,11 +327,11 @@
<!-- PIN + Channel Row -->
<div class="row">
<div class="col-md-4 mb-3">
<div class="col-md-6 mb-3">
<div class="security-box h-100">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group pin-input-container">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
<i class="bi bi-eye"></i>
</button>
@@ -340,7 +340,7 @@
</div>
</div>
<div class="col-md-8 mb-3">
<div class="col-md-6 mb-3">
<div class="security-box h-100">
<label class="form-label">
<i class="bi bi-broadcast me-1"></i> Channel

View File

@@ -394,11 +394,11 @@
<!-- PIN + Channel Row -->
<div class="row">
<div class="col-md-4 mb-3">
<div class="col-md-6 mb-3">
<div class="security-box h-100">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group pin-input-container">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
<i class="bi bi-eye"></i>
</button>
@@ -407,7 +407,7 @@
</div>
</div>
<div class="col-md-8 mb-3">
<div class="col-md-6 mb-3">
<div class="security-box h-100">
<label class="form-label">
<i class="bi bi-broadcast me-1"></i> Channel

View File

@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Login - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5 col-lg-4">
<div class="card">
<div class="card-header text-center">
<i class="bi bi-shield-lock fs-1 d-block mb-2"></i>
<h5 class="mb-0">Login</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('login') }}">
<div class="mb-3">
<label class="form-label">
<i class="bi bi-person me-1"></i> Username
</label>
<input type="text" name="username" class="form-control"
value="{{ username }}" readonly>
</div>
<div class="mb-4">
<label class="form-label">
<i class="bi bi-key me-1"></i> Password
</label>
<div class="input-group">
<input type="password" name="password" class="form-control"
id="passwordInput" required autofocus>
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('passwordInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-box-arrow-in-right me-2"></i>Login
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const icon = btn.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('bi-eye', 'bi-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('bi-eye-slash', 'bi-eye');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}Setup - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card">
<div class="card-header text-center">
<i class="bi bi-gear-fill fs-1 d-block mb-2"></i>
<h5 class="mb-0">Initial Setup</h5>
</div>
<div class="card-body">
<p class="text-muted text-center mb-4">
Welcome to Stegasoo! Create your admin account to get started.
</p>
<form method="POST" action="{{ url_for('setup') }}" id="setupForm">
<div class="mb-3">
<label class="form-label">
<i class="bi bi-person me-1"></i> Username
</label>
<input type="text" name="username" class="form-control"
value="admin" required minlength="3">
</div>
<div class="mb-3">
<label class="form-label">
<i class="bi bi-key me-1"></i> Password
</label>
<div class="input-group">
<input type="password" name="password" class="form-control"
id="passwordInput" required minlength="8">
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('passwordInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-text">Minimum 8 characters</div>
</div>
<div class="mb-4">
<label class="form-label">
<i class="bi bi-key-fill me-1"></i> Confirm Password
</label>
<div class="input-group">
<input type="password" name="password_confirm" class="form-control"
id="passwordConfirmInput" required minlength="8">
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('passwordConfirmInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-check-lg me-2"></i>Create Admin Account
</button>
</form>
</div>
</div>
<div class="alert alert-info mt-4 small">
<i class="bi bi-info-circle me-2"></i>
This is a single-user setup. The admin account has full access to all features.
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const icon = btn.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('bi-eye', 'bi-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('bi-eye-slash', 'bi-eye');
}
}
document.getElementById('setupForm')?.addEventListener('submit', function(e) {
const pass = document.getElementById('passwordInput').value;
const confirm = document.getElementById('passwordConfirmInput').value;
if (pass !== confirm) {
e.preventDefault();
alert('Passwords do not match');
}
});
</script>
{% endblock %}

View File

@@ -1,9 +1,14 @@
"""
Stegasoo Constants and Configuration (v4.0.1 - Channel Key Support)
Stegasoo Constants and Configuration (v4.0.2 - Web UI Authentication)
Central location for all magic numbers, limits, and crypto parameters.
All version numbers, limits, and configuration values should be defined here.
CHANGES in v4.0.2:
- Added Web UI authentication with SQLite3 user storage
- Added optional HTTPS with auto-generated self-signed certificates
- UI improvements for QR preview panels and PIN/channel columns
BREAKING CHANGES in v4.0.0:
- Added channel key support for deployment/group isolation
- FORMAT_VERSION bumped to 5 (adds flags byte to header)
@@ -20,7 +25,7 @@ from pathlib import Path
# VERSION
# ============================================================================
__version__ = "4.0.1"
__version__ = "4.0.2"
# ============================================================================
# FILE FORMAT