From 80dc22f150e6ba7300c3debcb1f298500ab9f658 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sun, 4 Jan 2026 02:27:06 -0500 Subject: [PATCH] Add Admin Recovery System with multiple backup options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recovery key generation (32-char alphanumeric, dashed format) - Multiple backup methods: text file, QR code, stego image - QR codes obfuscated with XOR (RECOVERY_OBFUSCATION_KEY constant) - Stego backup hides key in image using Stegasoo itself - CLI: `stegasoo admin recover --db path/to/db` - Web routes: /recover, /account/recovery/regenerate - Toast notifications now auto-dismiss after 20s with fade - Updated WEB_UI.md and CLI.md documentation for v4.1.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 32 ++ CLI.md | 180 ++++++- PLAN-4.1.0.md | 55 +++ WEB_UI.md | 248 +++++++++- frontends/web/app.py | 217 ++++++++- frontends/web/auth.py | 137 +++++- frontends/web/templates/account.html | 39 ++ frontends/web/templates/base.html | 2 +- frontends/web/templates/login.html | 6 + frontends/web/templates/recover.html | 129 +++++ .../web/templates/regenerate_recovery.html | 183 +++++++ frontends/web/templates/setup_recovery.html | 176 +++++++ pyproject.toml | 2 +- src/stegasoo/cli.py | 156 ++++++ src/stegasoo/constants.py | 10 +- src/stegasoo/recovery.py | 453 ++++++++++++++++++ 16 files changed, 1989 insertions(+), 36 deletions(-) create mode 100644 frontends/web/templates/recover.html create mode 100644 frontends/web/templates/regenerate_recovery.html create mode 100644 frontends/web/templates/setup_recovery.html create mode 100644 src/stegasoo/recovery.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2703448..f9f5183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ 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.1.0] - 2026-01-04 + +### Added +- **Admin Recovery System**: Password reset for locked-out admins + - Recovery key generated during setup (32-char alphanumeric) + - Multiple backup options: text file, QR code, stego image + - QR codes obfuscated (XOR'd with magic header hash) + - Stego backups hide key in an image using Stegasoo itself + - CLI: `stegasoo admin recover --db path/to/db` +- **EXIF Editor**: Full metadata editing in Tools page + - View all EXIF fields from uploaded image + - Inline editing of individual fields + - Clear all metadata with one click + - Download cleaned image + - CLI: `stegasoo tools exif image.jpg [--clear] [--set Field=Value]` +- **Multi-User Support**: Admin can create up to 16 additional users + - Role-based access control (admin/user) + - Admin user management page + - Temp password generation for new users +- **Saved Channel Keys**: Users can save/manage channel keys in account page + +### Changed +- **Architecture**: Consolidated `resolve_channel_key()` to library layer + - Single source of truth in `src/stegasoo/channel.py` + - CLI, API, WebUI now use thin wrappers +- **DCT Pre-Check**: Fail fast with helpful error before expensive encoding +- **Toast Notifications**: Auto-dismiss after 20 seconds with fade animation +- `RECOVERY_OBFUSCATION_KEY` constant added to `constants.py` + +### Fixed +- DCT payload size error now caught early with clear message + ## [4.0.2] - 2026-01-02 ### Added diff --git a/CLI.md b/CLI.md index 886bbcc..eefb5dc 100644 --- a/CLI.md +++ b/CLI.md @@ -1,11 +1,11 @@ -# Stegasoo CLI Documentation (v4.0.2) +# Stegasoo CLI Documentation (v4.1.0) Complete command-line interface reference for Stegasoo steganography operations. ## Table of Contents - [Installation](#installation) -- [What's New in v4.0.0](#whats-new-in-v400) +- [What's New in v4.1.0](#whats-new-in-v410) - [Quick Start](#quick-start) - [Commands](#commands) - [generate](#generate-command) @@ -13,10 +13,11 @@ Complete command-line interface reference for Stegasoo steganography operations. - [decode](#decode-command) - [verify](#verify-command) - [channel](#channel-command) + - [admin](#admin-command) + - [tools](#tools-command) - [info](#info-command) - [compare](#compare-command) - [modes](#modes-command) - - [strip-metadata](#strip-metadata-command) - [Channel Keys](#channel-keys) - [Embedding Modes](#embedding-modes) - [Security Factors](#security-factors) @@ -65,9 +66,28 @@ stegasoo channel show --- +## What's New in v4.1.0 + +Version 4.1.0 adds **admin recovery** and **tools** commands: + +| Feature | Description | +|---------|-------------| +| Admin recovery | Reset admin password using recovery key | +| EXIF tools | View, edit, and strip image metadata | +| Peek tool | Quick stego detection check | +| Strip tool | Remove hidden data from images | + +**New commands:** +- `stegasoo admin recover` - Reset admin password with recovery key +- `stegasoo tools exif` - View/edit EXIF metadata +- `stegasoo tools peek` - Check for hidden data +- `stegasoo tools strip` - Remove stego data from image + +--- + ## What's New in v4.0.0 -Version 4.0.0 adds **channel key** support for deployment/group isolation: +Version 4.0.0 added **channel key** support for deployment/group isolation: | Feature | Description | |---------|-------------| @@ -76,14 +96,6 @@ Version 4.0.0 adds **channel key** support for deployment/group isolation: | CLI management | New `stegasoo channel` command group | | Flexible override | Use server config, explicit key, or public mode | -**Key benefits:** -- ✅ Isolate messages between teams, deployments, or groups -- ✅ Same credentials can't decode messages from different channels -- ✅ Backward compatible (public mode = no channel key) -- ✅ Easy key distribution via environment variables or config files - -**Breaking change:** v4.0.0 messages (with channel key) cannot be decoded by v3.x installations. - --- ## Quick Start @@ -495,12 +507,150 @@ Now also displays channel key status. --- -### Strip-Metadata Command +### Admin Command -Remove all metadata from an image. +Manage Web UI admin accounts and recovery. + +#### Subcommands + +| Subcommand | Description | +|------------|-------------| +| `recover` | Reset admin password using recovery key | + +#### admin recover + +Reset the admin password for a Web UI database. ```bash -stegasoo strip-metadata IMAGE [OPTIONS] +stegasoo admin recover --db PATH [OPTIONS] +``` + +| Option | Short | Type | Required | Description | +|--------|-------|------|----------|-------------| +| `--db` | `-d` | path | ✓ | Path to stegasoo.db file | +| `--key` | `-k` | string | | Recovery key (prompted if not provided) | +| `--password` | `-p` | string | | New password (prompted if not provided) | + +**Examples:** + +```bash +# Interactive mode (prompts for key and password) +stegasoo admin recover --db frontends/web/instance/stegasoo.db + +# Non-interactive mode +stegasoo admin recover \ + --db /path/to/stegasoo.db \ + --key "XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" \ + --password "NewSecurePassword123" +``` + +**Recovery process:** +1. The recovery key is verified against the database hash +2. If valid, the admin password is reset +3. User can now log in with the new password + +**Note:** Recovery keys are instance-bound. A key from one database won't work on another. + +--- + +### Tools Command + +Image utilities and analysis tools. + +#### Subcommands + +| Subcommand | Description | +|------------|-------------| +| `exif` | View/edit EXIF metadata | +| `peek` | Check for hidden data | +| `strip` | Remove stego data from image | + +#### tools exif + +View and edit EXIF metadata in images. + +```bash +stegasoo tools exif IMAGE [OPTIONS] +``` + +| Option | Type | Description | +|--------|------|-------------| +| `--clear` | flag | Remove all EXIF metadata | +| `--set FIELD=VALUE` | string | Set a specific EXIF field | +| `--output` / `-o` | path | Output filename (default: overwrites input) | +| `--json` | flag | Output as JSON | + +**Examples:** + +```bash +# View all EXIF data +stegasoo tools exif photo.jpg + +# View as JSON +stegasoo tools exif photo.jpg --json + +# Clear all metadata +stegasoo tools exif photo.jpg --clear -o clean.jpg + +# Set specific fields +stegasoo tools exif photo.jpg \ + --set "Artist=John Doe" \ + --set "Copyright=2026" \ + -o tagged.jpg + +# Remove GPS data only +stegasoo tools exif photo.jpg \ + --set "GPSLatitude=" \ + --set "GPSLongitude=" \ + -o no-gps.jpg +``` + +#### tools peek + +Check if an image contains hidden Stegasoo data. + +```bash +stegasoo tools peek IMAGE [OPTIONS] +``` + +| Option | Type | Description | +|--------|------|-------------| +| `--json` | flag | Output as JSON | +| `--quiet` / `-q` | flag | Exit code only (0=found, 1=not found) | + +**Examples:** + +```bash +# Check for hidden data +stegasoo tools peek suspicious.png + +# Script-friendly check +if stegasoo tools peek image.png -q; then + echo "Contains hidden data" +fi +``` + +#### tools strip + +Remove hidden stego data from an image (destructive). + +```bash +stegasoo tools strip IMAGE [OPTIONS] +``` + +| Option | Type | Description | +|--------|------|-------------| +| `--output` / `-o` | path | Output filename | +| `--force` / `-f` | flag | Overwrite without confirmation | + +**Examples:** + +```bash +# Strip and save to new file +stegasoo tools strip stego.png -o clean.png + +# Strip in place (with confirmation) +stegasoo tools strip stego.png ``` --- diff --git a/PLAN-4.1.0.md b/PLAN-4.1.0.md index 5ed57d4..8bde36b 100644 --- a/PLAN-4.1.0.md +++ b/PLAN-4.1.0.md @@ -481,3 +481,58 @@ Reviewed modules for consistency with Library → CLI → API → WebUI pattern: | generate | ✓ | ✓ | - | ✓ | CLI has `stegasoo generate` | Priority order: Developer/CLI → API integrator → WebUI end-user + +--- + +## Admin Recovery System (4.1.0) ✅ DONE + +Password reset capability for locked-out admins with multiple backup options. + +### Library Layer (`src/stegasoo/recovery.py`) + +```python +# Key generation and validation +generate_recovery_key() -> str # XXXX-XXXX-XXXX-... (32 chars) +hash_recovery_key(key) -> str # SHA-256 for storage +verify_recovery_key(key, hash) -> bool + +# QR code (obfuscated - scans as gibberish) +obfuscate_key(key) -> str # XOR with RECOVERY_OBFUSCATION_KEY +deobfuscate_key(data) -> str | None +generate_recovery_qr(key) -> bytes # PNG with obfuscated data +extract_key_from_qr(image) -> str | None + +# Stego backup (hide key in an image) +create_stego_backup(key, carrier_image) -> bytes +extract_stego_backup(stego_image, reference) -> str | None +``` + +### Database (`app_settings` table) + +- `recovery_key_hash` - SHA-256 of recovery key (or null if disabled) + +### Web Routes + +| Route | Method | Description | +|-------|--------|-------------| +| `/setup/recovery` | GET, POST | Step 2 of initial setup | +| `/recover` | GET, POST | Password reset page | +| `/recover/stego` | POST | Extract key from stego backup | +| `/account/recovery/regenerate` | GET, POST | Generate new key | +| `/account/recovery/disable` | POST | Remove recovery option | +| `/account/recovery/stego-backup` | POST | Create stego backup | + +### CLI Commands + +```bash +stegasoo admin recover --db path/to/stegasoo.db # Reset password +stegasoo admin generate-key [--qr] # Generate key (reference) +``` + +### Security Model + +1. Recovery key shown once during setup - only hash stored +2. QR codes XOR'd with `RECOVERY_OBFUSCATION_KEY` (fixed in constants.py) +3. Stego backups use fixed internal passphrase/PIN - security is obscurity +4. Instance-bound: recovery key hash must match in target database +5. Options: text file, QR image, stego image, or no recovery (most secure) diff --git a/WEB_UI.md b/WEB_UI.md index 18e54cd..3f8ba24 100644 --- a/WEB_UI.md +++ b/WEB_UI.md @@ -1,18 +1,22 @@ -# Stegasoo Web UI Documentation (v4.0.2) +# Stegasoo Web UI Documentation (v4.1.0) Complete guide for the Stegasoo web-based steganography interface. ## Table of Contents - [Overview](#overview) -- [What's New in v4.0.2](#whats-new-in-v402) +- [What's New in v4.1.0](#whats-new-in-v410) - [Authentication & HTTPS](#authentication--https) +- [Admin Recovery](#admin-recovery) +- [Multi-User Support](#multi-user-support) - [Installation & Setup](#installation--setup) - [Pages & Features](#pages--features) - [Home Page](#home-page) - [Generate Credentials](#generate-credentials) - [Encode Message](#encode-message) - [Decode Message](#decode-message) + - [Tools Page](#tools-page) + - [Account Page](#account-page) - [About Page](#about-page) - [Embedding Modes](#embedding-modes) - [DCT Mode (Default)](#dct-mode-default) @@ -54,9 +58,29 @@ Built with Flask, Bootstrap 5, and a modern dark theme. --- +## What's New in v4.1.0 + +Version 4.1.0 adds admin recovery, multi-user support, and new tools: + +| Feature | Description | +|---------|-------------| +| **Admin Recovery** | Password reset using secure recovery key | +| **Multi-User Support** | Up to 16 users with role-based access | +| **EXIF Editor** | View, edit, and strip image metadata | +| **Saved Channel Keys** | Users can save/manage channel keys in account | +| **Toast Improvements** | Auto-dismiss after 20 seconds with fade | + +**Key benefits:** +- ✅ Never get locked out - recovery key backup options +- ✅ Share access with team members (admin/user roles) +- ✅ Full EXIF metadata control in Tools page +- ✅ Persistent channel key storage per user + +--- + ## What's New in v4.0.2 -Version 4.0.2 adds authentication and HTTPS support for secure home network deployment: +Version 4.0.2 added authentication and HTTPS support: | Feature | Description | |---------|-------------| @@ -64,14 +88,6 @@ Version 4.0.2 adds authentication and HTTPS support for secure home network depl | **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:** -- ✅ 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 --- @@ -182,6 +198,133 @@ services: --- +## Admin Recovery + +### Overview + +If you forget your admin password, the recovery key is the ONLY way to reset it. Generate and save your recovery key immediately after setup. + +### Recovery Key Format + +``` +XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX +└──────────────────────────────────────┘ + 32 alphanumeric characters (8 groups of 4) +``` + +### Backup Options + +The recovery key can be saved in multiple ways: + +| Method | Description | Security Level | +|--------|-------------|----------------| +| **Text file** | Plain text download | Low - store securely | +| **QR code** | Obfuscated PNG image | Medium - XOR'd with magic hash | +| **Stego image** | Hidden in carrier image | High - requires original image | + +### Generating a Recovery Key + +**During first-run setup:** +1. Complete the admin account wizard +2. You'll be prompted to save your recovery key +3. Choose backup method(s) +4. Confirm you've saved the key + +**From Account page (admin only):** +1. Navigate to `/account` +2. Click "Generate Recovery Key" (or "Regenerate" if one exists) +3. Save using your preferred method +4. Check the confirmation box +5. Click "Save New Key" + +### QR Code Obfuscation + +QR codes are not plain text - they're XOR'd with a fixed obfuscation key derived from Stegasoo's magic headers. This prevents casual scanning from revealing the key. + +### Stego Backup + +Hide your recovery key inside an image using Stegasoo itself: + +1. Upload a carrier image (JPG/PNG, 50KB-2MB) +2. Click the "Stego" button +3. Download the stego image +4. **Important:** Keep the original carrier image - you'll need it for extraction + +### Recovering Your Password + +**URL:** `/recover` + +1. Navigate to the login page +2. Click "Forgot password?" +3. **Option A:** Enter recovery key directly +4. **Option B:** Extract from stego backup: + - Expand "Extract from stego backup" + - Upload your stego backup image + - Upload the original carrier/reference image + - Click "Extract Key" +5. Enter and confirm your new password +6. Click "Reset Password" + +### CLI Recovery + +For locked-out scenarios where you can't access the web UI: + +```bash +stegasoo admin recover --db frontends/web/instance/stegasoo.db +``` + +You'll be prompted for your recovery key and new password. + +### Important Notes + +- Recovery keys are instance-bound (tied to the specific database) +- Regenerating a key invalidates the previous one +- Store backups in a secure, separate location +- Without a recovery key, the only option is to delete the database and reconfigure + +--- + +## Multi-User Support + +### Overview + +Admins can create up to 16 additional users with role-based access control. + +### Roles + +| Role | Permissions | +|------|-------------| +| **Admin** | Full access: encode, decode, generate, tools, user management, recovery | +| **User** | Standard access: encode, decode, generate, account settings | + +### User Management + +**URL:** `/admin/users` (admin only) + +#### Creating Users + +1. Click "Add User" +2. Enter username +3. Select role (admin/user) +4. A temporary password is generated +5. Share the temporary password securely with the new user +6. User must change password on first login + +#### Managing Users + +- View all users and their roles +- Reset user passwords (generates new temp password) +- Change user roles +- Delete users (except yourself) + +### User Limits + +- Maximum 16 users total (including admin) +- At least one admin must exist +- Users can't delete or demote the last admin + +--- + ## Installation & Setup ### From PyPI @@ -549,6 +692,83 @@ If decryption fails: --- +### Tools Page + +**URL:** `/tools` + +The Tools page provides utilities for image analysis and manipulation. + +#### EXIF Editor + +View and edit image metadata (EXIF data). + +**Features:** +- View all EXIF fields from uploaded image +- Inline editing of individual fields +- Clear all metadata with one click +- Download cleaned image + +**Usage:** +1. Upload an image (JPG recommended - richest EXIF data) +2. View all metadata fields in a table +3. Click any field to edit its value +4. Click "Save" to apply changes +5. Use "Clear All" to strip all metadata +6. Download the modified image + +**Common EXIF fields:** +| Field | Description | +|-------|-------------| +| Make/Model | Camera manufacturer and model | +| DateTime | When the photo was taken | +| GPSLatitude/GPSLongitude | Location coordinates | +| Software | Editing software used | +| Artist | Photographer name | + +**Privacy tip:** Always strip EXIF data before sharing images publicly to remove location and device information. + +#### Peek (Stego Detection) + +Quickly check if an image contains hidden data. + +#### Strip Metadata + +Remove all metadata from an image in one click. + +--- + +### Account Page + +**URL:** `/account` + +Manage your account settings and preferences. + +#### Password Change + +1. Enter current password +2. Enter new password (minimum 8 characters) +3. Confirm new password +4. Click "Change Password" + +#### Saved Channel Keys (v4.1.0) + +Users can save frequently-used channel keys for quick access: + +1. Click "Add Channel Key" +2. Enter a name/label for the key +3. Paste the channel key +4. Click "Save" + +Saved keys appear in a dropdown during encode/decode operations. + +#### Recovery Key Management (Admin only) + +- View recovery key status (configured/not configured) +- Generate or regenerate recovery key +- Download backup options (text, QR, stego) + +--- + ### About Page **URL:** `/about` @@ -556,10 +776,10 @@ If decryption fails: Information about the Stegasoo project, security model, and credits. Includes: -- Version information (v3.3.0) -- Recent UI improvements +- Version information (v4.1.0) +- Feature highlights - Security model overview -- Dependency status (Argon2, QR code support) +- Dependency status (Argon2, scipy/DCT, QR code support) --- diff --git a/frontends/web/app.py b/frontends/web/app.py index 6ec0647..5ccec5b 100644 --- a/frontends/web/app.py +++ b/frontends/web/app.py @@ -48,6 +48,9 @@ from auth import ( get_user_by_id, get_user_channel_keys, get_username, + has_recovery_key, + get_recovery_key_hash, + clear_recovery_key, is_admin, is_authenticated, login_required, @@ -55,6 +58,8 @@ from auth import ( logout_user, reset_user_password, save_channel_key, + set_recovery_key_hash, + verify_and_reset_admin_password, update_channel_key_last_used, update_channel_key_name, user_exists, @@ -1586,7 +1591,7 @@ def logout(): @app.route("/setup", methods=["GET", "POST"]) def setup(): - """First-run setup page - create admin account.""" + """First-run setup page - create admin account (Step 1).""" if not app.config.get("AUTH_ENABLED", True): return redirect(url_for("index")) @@ -1608,14 +1613,219 @@ def setup(): if user: login_user(user) session.permanent = True - flash("Admin account created successfully!", "success") - return redirect(url_for("index")) + # Redirect to recovery key setup (Step 2) + return redirect(url_for("setup_recovery")) else: flash(message, "error") return render_template("setup.html") +@app.route("/setup/recovery", methods=["GET", "POST"]) +@login_required +def setup_recovery(): + """Recovery key setup page (Step 2 of initial setup).""" + from stegasoo.recovery import generate_recovery_key, hash_recovery_key, generate_recovery_qr + import base64 + + # Only allow during initial setup (no recovery key yet, first admin) + if has_recovery_key(): + return redirect(url_for("index")) + + current_user = get_current_user() + if current_user.role != "admin": + return redirect(url_for("index")) + + if request.method == "POST": + action = request.form.get("action") + + if action == "skip": + # No recovery key - most secure but no way to recover + flash("Setup complete. No recovery key configured.", "warning") + return redirect(url_for("index")) + + elif action == "save": + # User confirmed they saved the key + recovery_key = request.form.get("recovery_key") + if recovery_key: + key_hash = hash_recovery_key(recovery_key) + set_recovery_key_hash(key_hash) + flash("Setup complete. Recovery key saved.", "success") + return redirect(url_for("index")) + + # Generate a new key to show + recovery_key = generate_recovery_key() + + # Generate QR code as base64 + try: + qr_bytes = generate_recovery_qr(recovery_key) + qr_base64 = base64.b64encode(qr_bytes).decode("utf-8") + except ImportError: + qr_base64 = None + + return render_template( + "setup_recovery.html", + recovery_key=recovery_key, + qr_base64=qr_base64, + ) + + +@app.route("/recover", methods=["GET", "POST"]) +def recover(): + """Password recovery page - reset password using recovery key.""" + # Don't show if no recovery key configured + if not get_recovery_key_hash(): + flash("No recovery key configured for this instance", "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 successfully. Please login.", "success") + return redirect(url_for("login")) + else: + flash(message, "error") + + return render_template("recover.html") + + +@app.route("/account/recovery/regenerate", methods=["GET", "POST"]) +@login_required +@admin_required +def regenerate_recovery(): + """Generate a new recovery key (replaces existing one).""" + from stegasoo.recovery import generate_recovery_key, hash_recovery_key, generate_recovery_qr + import base64 + + if request.method == "POST": + action = request.form.get("action") + + if action == "cancel": + flash("Recovery key generation cancelled", "warning") + return redirect(url_for("account")) + + elif action == "save": + # User confirmed they saved the key + recovery_key = request.form.get("recovery_key") + if recovery_key: + key_hash = hash_recovery_key(recovery_key) + set_recovery_key_hash(key_hash) + flash("New recovery key saved successfully", "success") + return redirect(url_for("account")) + + # Generate a new key to show + recovery_key = generate_recovery_key() + + # Generate QR code as base64 + try: + qr_bytes = generate_recovery_qr(recovery_key) + qr_base64 = base64.b64encode(qr_bytes).decode("utf-8") + except ImportError: + qr_base64 = None + + return render_template( + "regenerate_recovery.html", + recovery_key=recovery_key, + qr_base64=qr_base64, + has_existing=has_recovery_key(), + ) + + +@app.route("/account/recovery/disable", methods=["POST"]) +@login_required +@admin_required +def disable_recovery(): + """Disable recovery key (no password reset possible).""" + if clear_recovery_key(): + flash("Recovery key disabled. Password reset is no longer possible.", "warning") + else: + flash("No recovery key was configured", "error") + return redirect(url_for("account")) + + +@app.route("/account/recovery/stego-backup", methods=["POST"]) +@login_required +@admin_required +def create_stego_backup(): + """Create stego backup - hide recovery key in an image.""" + from stegasoo.recovery import create_stego_backup as make_backup + + recovery_key = request.form.get("recovery_key", "") + if not recovery_key: + flash("No recovery key provided", "error") + return redirect(url_for("regenerate_recovery")) + + if "carrier_image" not in request.files: + flash("No image uploaded", "error") + return redirect(url_for("regenerate_recovery")) + + carrier_file = request.files["carrier_image"] + if not carrier_file.filename: + flash("No image selected", "error") + return redirect(url_for("regenerate_recovery")) + + try: + carrier_data = carrier_file.read() + stego_data = make_backup(recovery_key, carrier_data) + + # Return as downloadable PNG + buffer = io.BytesIO(stego_data) + return send_file( + buffer, + mimetype="image/png", + as_attachment=True, + download_name="stegasoo-recovery-backup.png", + ) + except ValueError as e: + flash(str(e), "error") + return redirect(url_for("regenerate_recovery")) + + +@app.route("/recover/stego", methods=["POST"]) +def recover_from_stego(): + """Extract recovery key from stego backup image.""" + from stegasoo.recovery import extract_stego_backup + + if "stego_image" not in request.files or "reference_image" not in request.files: + flash("Both stego image and reference image are required", "error") + return redirect(url_for("recover")) + + stego_file = request.files["stego_image"] + reference_file = request.files["reference_image"] + + if not stego_file.filename or not reference_file.filename: + flash("Both images must be selected", "error") + return redirect(url_for("recover")) + + try: + stego_data = stego_file.read() + reference_data = reference_file.read() + + extracted_key = extract_stego_backup(stego_data, reference_data) + + if extracted_key: + # Return the key to pre-fill the recovery form + return render_template("recover.html", prefilled_key=extracted_key) + else: + flash("Could not extract recovery key. Check images are correct.", "error") + return redirect(url_for("recover")) + + except Exception as e: + flash(f"Extraction failed: {e}", "error") + return redirect(url_for("recover")) + + @app.route("/account", methods=["GET", "POST"]) @login_required def account(): @@ -1641,6 +1851,7 @@ def account(): username=current_user.username, user=current_user, is_admin=current_user.is_admin, + has_recovery=has_recovery_key(), channel_keys=channel_keys, max_channel_keys=MAX_CHANNEL_KEYS, can_save_key=can_save_channel_key(current_user.id), diff --git a/frontends/web/auth.py b/frontends/web/auth.py index ccb5754..3daf0fe 100644 --- a/frontends/web/auth.py +++ b/frontends/web/auth.py @@ -94,8 +94,9 @@ def init_db(): # Fresh install - create new schema _create_schema(db) else: - # Existing install - check for new tables (channel_keys migration) + # Existing install - check for new tables (migrations) _ensure_channel_keys_table(db) + _ensure_app_settings_table(db) def _create_schema(db: sqlite3.Connection): @@ -125,6 +126,15 @@ def _create_schema(db: sqlite3.Connection): ); CREATE INDEX IF NOT EXISTS idx_channel_keys_user ON user_channel_keys(user_id); + + -- App-level settings (v4.1.0) + -- Stores recovery key hash and other instance-wide settings + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); """) db.commit() @@ -177,6 +187,131 @@ def _ensure_channel_keys_table(db: sqlite3.Connection): db.commit() +def _ensure_app_settings_table(db: sqlite3.Connection): + """Ensure app_settings table exists (v4.1.0 migration).""" + cursor = db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'" + ) + if cursor.fetchone() is None: + db.executescript(""" + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """) + db.commit() + + +# ============================================================================= +# App Settings (v4.1.0) +# ============================================================================= + + +def get_app_setting(key: str) -> str | None: + """Get an app-level setting value.""" + db = get_db() + row = db.execute( + "SELECT value FROM app_settings WHERE key = ?", (key,) + ).fetchone() + return row["value"] if row else None + + +def set_app_setting(key: str, value: str) -> None: + """Set an app-level setting value.""" + db = get_db() + db.execute( + """ + INSERT INTO app_settings (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP + """, + (key, value, value), + ) + db.commit() + + +def delete_app_setting(key: str) -> bool: + """Delete an app-level setting. Returns True if deleted.""" + db = get_db() + cursor = db.execute("DELETE FROM app_settings WHERE key = ?", (key,)) + db.commit() + return cursor.rowcount > 0 + + +# ============================================================================= +# Recovery Key Management (v4.1.0) +# ============================================================================= + + +# Setting key for recovery hash +RECOVERY_KEY_SETTING = "recovery_key_hash" + + +def has_recovery_key() -> bool: + """Check if a recovery key has been configured.""" + return get_app_setting(RECOVERY_KEY_SETTING) is not None + + +def get_recovery_key_hash() -> str | None: + """Get the stored recovery key hash.""" + return get_app_setting(RECOVERY_KEY_SETTING) + + +def set_recovery_key_hash(key_hash: str) -> None: + """Store a recovery key hash.""" + set_app_setting(RECOVERY_KEY_SETTING, key_hash) + + +def clear_recovery_key() -> bool: + """Remove the recovery key. Returns True if removed.""" + return delete_app_setting(RECOVERY_KEY_SETTING) + + +def verify_and_reset_admin_password(recovery_key: str, new_password: str) -> tuple[bool, str]: + """ + Verify recovery key and reset the first admin's password. + + Args: + recovery_key: User-provided recovery key + new_password: New password to set + + Returns: + (success, message) tuple + """ + from stegasoo.recovery import verify_recovery_key + + stored_hash = get_recovery_key_hash() + if not stored_hash: + return False, "No recovery key configured for this instance" + + if not verify_recovery_key(recovery_key, stored_hash): + return False, "Invalid recovery key" + + # Find first admin user + db = get_db() + admin = db.execute( + "SELECT id, username FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ).fetchone() + + if not admin: + return False, "No admin user found" + + # Reset password + new_hash = ph.hash(new_password) + db.execute( + "UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (new_hash, admin["id"]), + ) + db.commit() + + # Invalidate all sessions for this user + invalidate_user_sessions(admin["id"]) + + return True, f"Password reset for '{admin['username']}'" + + # ============================================================================= # User Queries # ============================================================================= diff --git a/frontends/web/templates/account.html b/frontends/web/templates/account.html index 3d4bf2b..f958455 100644 --- a/frontends/web/templates/account.html +++ b/frontends/web/templates/account.html @@ -25,6 +25,45 @@ Manage Users + + +
+
+
+
+ + Recovery Key + {% if has_recovery %} + Configured + {% else %} + Not Set + {% endif %} +
+
+ + + {{ 'Regenerate' if has_recovery else 'Generate' }} + + {% if has_recovery %} +
+ +
+ {% endif %} +
+
+ + {% if has_recovery %} + Allows password reset if you're locked out. + {% else %} + No recovery option - most secure, but no password reset possible. + {% endif %} + +
+
{% endif %}
Change Password
diff --git a/frontends/web/templates/base.html b/frontends/web/templates/base.html index cc4b4cb..c760bb5 100644 --- a/frontends/web/templates/base.html +++ b/frontends/web/templates/base.html @@ -72,7 +72,7 @@
{% with messages = get_flashed_messages(with_categories=true) %} {% for category, message in messages %} -