Add Admin Recovery System with multiple backup options
- 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 <noreply@anthropic.com>
This commit is contained in:
32
CHANGELOG.md
32
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
|
||||
|
||||
180
CLI.md
180
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
|
||||
248
WEB_UI.md
248
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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -25,6 +25,45 @@
|
||||
<i class="bi bi-people me-2"></i>Manage Users
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Recovery Key Management (Admin only) -->
|
||||
<div class="card bg-dark mb-4">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-shield-lock me-2"></i>
|
||||
<strong>Recovery Key</strong>
|
||||
{% if has_recovery %}
|
||||
<span class="badge bg-success ms-2">Configured</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-2">Not Set</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('regenerate_recovery') }}" class="btn btn-outline-warning"
|
||||
onclick="return confirm('Generate a new recovery key? This will invalidate any existing key.')">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>
|
||||
{{ 'Regenerate' if has_recovery else 'Generate' }}
|
||||
</a>
|
||||
{% if has_recovery %}
|
||||
<form method="POST" action="{{ url_for('disable_recovery') }}" style="display:inline;">
|
||||
<button type="submit" class="btn btn-outline-danger"
|
||||
onclick="return confirm('Disable recovery? If you forget your password, you will NOT be able to recover your account.')">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-2">
|
||||
{% if has_recovery %}
|
||||
Allows password reset if you're locked out.
|
||||
{% else %}
|
||||
No recovery option - most secure, but no password reset possible.
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h6 class="text-muted mb-3">Change Password</h6>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
<div class="toast-container position-fixed end-0 p-3" style="z-index: 1100; top: 70px;">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="toast show align-items-center text-bg-{{ 'danger' if category == 'error' else ('warning' if category == 'warning' else 'success') }} border-0" role="alert" data-bs-autohide="true" data-bs-delay="4000">
|
||||
<div class="toast show align-items-center text-bg-{{ 'danger' if category == 'error' else ('warning' if category == 'warning' else 'success') }} border-0 fade" role="alert" data-bs-autohide="true" data-bs-delay="20000">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{ 'exclamation-triangle' if category == 'error' else ('exclamation-circle' if category == 'warning' else 'check-circle') }} me-2"></i>
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Login
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('recover') }}" class="text-muted small">
|
||||
<i class="bi bi-key me-1"></i> Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
129
frontends/web/templates/recover.html
Normal file
129
frontends/web/templates/recover.html
Normal file
@@ -0,0 +1,129 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Password Recovery - 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-shield-lock fs-1 d-block mb-2"></i>
|
||||
<h5 class="mb-0">Password Recovery</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted text-center mb-4">
|
||||
Enter your recovery key to reset your admin password.
|
||||
</p>
|
||||
|
||||
<!-- Extract from Stego Backup -->
|
||||
<div class="accordion mb-3" id="stegoAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed py-2" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#stegoExtract">
|
||||
<i class="bi bi-incognito me-2"></i>
|
||||
<small>Extract from stego backup</small>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="stegoExtract" class="accordion-collapse collapse"
|
||||
data-bs-parent="#stegoAccordion">
|
||||
<div class="accordion-body py-2">
|
||||
<form method="POST" action="{{ url_for('recover_from_stego') }}"
|
||||
enctype="multipart/form-data">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Stego Image</label>
|
||||
<input type="file" name="stego_image"
|
||||
class="form-control form-control-sm"
|
||||
accept="image/*" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Original Reference</label>
|
||||
<input type="file" name="reference_image"
|
||||
class="form-control form-control-sm"
|
||||
accept="image/*" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary w-100">
|
||||
<i class="bi bi-unlock me-1"></i> Extract Key
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('recover') }}" id="recoverForm">
|
||||
<!-- Recovery Key Input -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key-fill me-1"></i> Recovery Key
|
||||
</label>
|
||||
<textarea name="recovery_key" class="form-control font-monospace"
|
||||
rows="2" required
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
|
||||
style="font-size: 0.9em;">{{ prefilled_key or '' }}</textarea>
|
||||
<div class="form-text">
|
||||
Paste your full recovery key (with or without dashes)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- New Password -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-lock me-1"></i> New Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="new_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>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-lock-fill me-1"></i> Confirm Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="new_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>Reset Password
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('login') }}" class="text-muted small">
|
||||
<i class="bi bi-arrow-left me-1"></i> Back to Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mt-4 small">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Note:</strong> This will reset the admin password. If you don't have a valid recovery key,
|
||||
you'll need to delete the database and reconfigure Stegasoo.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
<script>
|
||||
StegasooAuth.initPasswordConfirmation('recoverForm', 'passwordInput', 'passwordConfirmInput');
|
||||
</script>
|
||||
{% endblock %}
|
||||
183
frontends/web/templates/regenerate_recovery.html
Normal file
183
frontends/web/templates/regenerate_recovery.html
Normal file
@@ -0,0 +1,183 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Regenerate Recovery Key - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<i class="bi bi-arrow-repeat fs-1 d-block mb-2"></i>
|
||||
<h5 class="mb-0">{{ 'Regenerate' if has_existing else 'Generate' }} Recovery Key</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if has_existing %}
|
||||
<!-- Warning for existing key -->
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Warning:</strong> Your existing recovery key will be invalidated.
|
||||
Make sure to save this new key before continuing.
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Info for first-time setup -->
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>What is a recovery key?</strong><br>
|
||||
If you forget your admin password, this key is the ONLY way to reset it.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Recovery Key Display -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key-fill me-1"></i> Your New Recovery Key
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace text-center"
|
||||
id="recoveryKey" value="{{ recovery_key }}" readonly
|
||||
style="font-size: 1.1em; letter-spacing: 0.5px;">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="copyToClipboard()" title="Copy to clipboard">
|
||||
<i class="bi bi-clipboard" id="copyIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code (if available) -->
|
||||
{% if qr_base64 %}
|
||||
<div class="mb-4 text-center">
|
||||
<label class="form-label d-block">
|
||||
<i class="bi bi-qr-code me-1"></i> QR Code
|
||||
</label>
|
||||
<img src="data:image/png;base64,{{ qr_base64 }}"
|
||||
alt="Recovery Key QR Code" class="img-fluid border rounded"
|
||||
style="max-width: 200px;" id="qrImage">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Download Options -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-download me-1"></i> Download Options
|
||||
</label>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="downloadTextFile()">
|
||||
<i class="bi bi-file-text me-1"></i> Text File
|
||||
</button>
|
||||
{% if qr_base64 %}
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="downloadQRImage()">
|
||||
<i class="bi bi-image me-1"></i> QR Image
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stego Backup Option -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-incognito me-1"></i> Hide in Image
|
||||
</label>
|
||||
<form method="POST" action="{{ url_for('create_stego_backup') }}"
|
||||
enctype="multipart/form-data" class="d-flex gap-2 align-items-end">
|
||||
<input type="hidden" name="recovery_key" value="{{ recovery_key }}">
|
||||
<div class="flex-grow-1">
|
||||
<input type="file" name="carrier_image" class="form-control form-control-sm"
|
||||
accept="image/jpeg,image/png" required>
|
||||
<div class="form-text">JPG/PNG, 50KB-2MB</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-download me-1"></i> Stego
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Confirmation Form -->
|
||||
<form method="POST" id="recoveryForm">
|
||||
<input type="hidden" name="recovery_key" value="{{ recovery_key }}">
|
||||
|
||||
<!-- Confirm checkbox -->
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="confirmSaved"
|
||||
onchange="updateButtons()">
|
||||
<label class="form-check-label" for="confirmSaved">
|
||||
I have saved my recovery key in a secure location
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 justify-content-between">
|
||||
<!-- Cancel button -->
|
||||
<button type="submit" name="action" value="cancel"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-lg me-1"></i> Cancel
|
||||
</button>
|
||||
|
||||
<!-- Save button -->
|
||||
<button type="submit" name="action" value="save"
|
||||
class="btn btn-primary" id="saveBtn" disabled>
|
||||
<i class="bi bi-check-lg me-1"></i> Save New Key
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Copy recovery key to clipboard
|
||||
function copyToClipboard() {
|
||||
const keyInput = document.getElementById('recoveryKey');
|
||||
navigator.clipboard.writeText(keyInput.value).then(() => {
|
||||
const icon = document.getElementById('copyIcon');
|
||||
icon.className = 'bi bi-clipboard-check';
|
||||
setTimeout(() => { icon.className = 'bi bi-clipboard'; }, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Download as text file
|
||||
function downloadTextFile() {
|
||||
const key = document.getElementById('recoveryKey').value;
|
||||
const content = `Stegasoo Recovery Key
|
||||
=====================
|
||||
|
||||
${key}
|
||||
|
||||
IMPORTANT:
|
||||
- Keep this file in a secure location
|
||||
- Anyone with this key can reset admin passwords
|
||||
- Do not store with your password
|
||||
|
||||
Generated: ${new Date().toISOString()}
|
||||
`;
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'stegasoo-recovery-key.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Download QR as image
|
||||
function downloadQRImage() {
|
||||
const img = document.getElementById('qrImage');
|
||||
if (!img) return;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = img.src;
|
||||
a.download = 'stegasoo-recovery-qr.png';
|
||||
a.click();
|
||||
}
|
||||
|
||||
// Enable save button when checkbox is checked
|
||||
function updateButtons() {
|
||||
const checkbox = document.getElementById('confirmSaved');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
saveBtn.disabled = !checkbox.checked;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
176
frontends/web/templates/setup_recovery.html
Normal file
176
frontends/web/templates/setup_recovery.html
Normal file
@@ -0,0 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Recovery Key Setup - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
<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">Recovery Key Setup</h5>
|
||||
<small class="text-muted">Step 2 of 2</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Explanation -->
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>What is a recovery key?</strong><br>
|
||||
If you forget your admin password, this key is the ONLY way to reset it.
|
||||
Save it somewhere safe - it will not be shown again.
|
||||
</div>
|
||||
|
||||
<!-- Recovery Key Display -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key-fill me-1"></i> Your Recovery Key
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace text-center"
|
||||
id="recoveryKey" value="{{ recovery_key }}" readonly
|
||||
style="font-size: 1.1em; letter-spacing: 0.5px;">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="copyToClipboard()" title="Copy to clipboard">
|
||||
<i class="bi bi-clipboard" id="copyIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code (if available) -->
|
||||
{% if qr_base64 %}
|
||||
<div class="mb-4 text-center">
|
||||
<label class="form-label d-block">
|
||||
<i class="bi bi-qr-code me-1"></i> QR Code
|
||||
</label>
|
||||
<img src="data:image/png;base64,{{ qr_base64 }}"
|
||||
alt="Recovery Key QR Code" class="img-fluid border rounded"
|
||||
style="max-width: 200px;" id="qrImage">
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">Scan with your phone's camera app</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Download Options -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-download me-1"></i> Download Options
|
||||
</label>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="downloadTextFile()">
|
||||
<i class="bi bi-file-text me-1"></i> Text File
|
||||
</button>
|
||||
{% if qr_base64 %}
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="downloadQRImage()">
|
||||
<i class="bi bi-image me-1"></i> QR Image
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Confirmation Form -->
|
||||
<form method="POST" id="recoveryForm">
|
||||
<input type="hidden" name="recovery_key" value="{{ recovery_key }}">
|
||||
|
||||
<!-- Confirm checkbox -->
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="confirmSaved"
|
||||
onchange="updateButtons()">
|
||||
<label class="form-check-label" for="confirmSaved">
|
||||
I have saved my recovery key in a secure location
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 justify-content-between">
|
||||
<!-- Skip button (no recovery) -->
|
||||
<button type="submit" name="action" value="skip"
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="return confirm('Are you sure? Without a recovery key, there is NO way to reset your password if you forget it.')">
|
||||
<i class="bi bi-skip-forward me-1"></i> Skip (No Recovery)
|
||||
</button>
|
||||
|
||||
<!-- Save button (with key) -->
|
||||
<button type="submit" name="action" value="save"
|
||||
class="btn btn-primary" id="saveBtn" disabled>
|
||||
<i class="bi bi-check-lg me-1"></i> Continue
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Notes -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-shield-check me-2"></i>Security Notes
|
||||
</div>
|
||||
<div class="card-body small">
|
||||
<ul class="mb-0">
|
||||
<li>The recovery key is <strong>not stored</strong> - only a hash is saved</li>
|
||||
<li>Keep it separate from your password (different location)</li>
|
||||
<li>Anyone with this key can reset admin passwords</li>
|
||||
<li>If you lose it and forget your password, you must recreate the database</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Copy recovery key to clipboard
|
||||
function copyToClipboard() {
|
||||
const keyInput = document.getElementById('recoveryKey');
|
||||
navigator.clipboard.writeText(keyInput.value).then(() => {
|
||||
const icon = document.getElementById('copyIcon');
|
||||
icon.className = 'bi bi-clipboard-check';
|
||||
setTimeout(() => { icon.className = 'bi bi-clipboard'; }, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Download as text file
|
||||
function downloadTextFile() {
|
||||
const key = document.getElementById('recoveryKey').value;
|
||||
const content = `Stegasoo Recovery Key
|
||||
=====================
|
||||
|
||||
${key}
|
||||
|
||||
IMPORTANT:
|
||||
- Keep this file in a secure location
|
||||
- Anyone with this key can reset admin passwords
|
||||
- Do not store with your password
|
||||
|
||||
Generated: ${new Date().toISOString()}
|
||||
`;
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'stegasoo-recovery-key.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Download QR as image
|
||||
function downloadQRImage() {
|
||||
const img = document.getElementById('qrImage');
|
||||
if (!img) return;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = img.src;
|
||||
a.download = 'stegasoo-recovery-qr.png';
|
||||
a.click();
|
||||
}
|
||||
|
||||
// Enable save button when checkbox is checked
|
||||
function updateButtons() {
|
||||
const checkbox = document.getElementById('confirmSaved');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
saveBtn.disabled = !checkbox.checked;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "stegasoo"
|
||||
version = "4.0.1"
|
||||
version = "4.1.0"
|
||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -964,6 +964,162 @@ def tools_exif(image, clear, set_fields, output, as_json):
|
||||
raise click.UsageError(str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ADMIN COMMANDS (Web UI administration)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@cli.group()
|
||||
@click.pass_context
|
||||
def admin(ctx):
|
||||
"""Web UI administration commands."""
|
||||
pass
|
||||
|
||||
|
||||
@admin.command("recover")
|
||||
@click.option(
|
||||
"--db", "db_path",
|
||||
type=click.Path(exists=True),
|
||||
help="Path to stegasoo.db (default: frontends/web/instance/stegasoo.db)"
|
||||
)
|
||||
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True,
|
||||
help="New admin password")
|
||||
def admin_recover(db_path, password):
|
||||
"""Reset admin password using recovery key.
|
||||
|
||||
Allows password reset for Web UI admin account when locked out.
|
||||
Requires the recovery key that was saved during setup.
|
||||
|
||||
Example:
|
||||
|
||||
stegasoo admin recover --db /path/to/stegasoo.db
|
||||
"""
|
||||
import sqlite3
|
||||
from argon2 import PasswordHasher
|
||||
from .recovery import verify_recovery_key
|
||||
|
||||
# Try default paths if not specified
|
||||
if not db_path:
|
||||
candidates = [
|
||||
Path("frontends/web/instance/stegasoo.db"),
|
||||
Path("instance/stegasoo.db"),
|
||||
Path("/app/instance/stegasoo.db"),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
db_path = str(candidate)
|
||||
break
|
||||
|
||||
if not db_path or not Path(db_path).exists():
|
||||
raise click.UsageError(
|
||||
"Database not found. Use --db to specify path to stegasoo.db"
|
||||
)
|
||||
|
||||
click.echo(f"Database: {db_path}")
|
||||
|
||||
# Connect and check for recovery key
|
||||
db = sqlite3.connect(db_path)
|
||||
db.row_factory = sqlite3.Row
|
||||
|
||||
# Get recovery key hash from app_settings
|
||||
cursor = db.execute(
|
||||
"SELECT value FROM app_settings WHERE key = 'recovery_key_hash'"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
db.close()
|
||||
raise click.ClickException(
|
||||
"No recovery key configured for this instance. "
|
||||
"Password reset is not possible."
|
||||
)
|
||||
|
||||
stored_hash = row["value"]
|
||||
|
||||
# Prompt for recovery key
|
||||
recovery_key = click.prompt(
|
||||
"Enter your recovery key",
|
||||
hide_input=False, # Recovery keys are meant to be visible
|
||||
)
|
||||
|
||||
# Verify recovery key
|
||||
if not verify_recovery_key(recovery_key, stored_hash):
|
||||
db.close()
|
||||
raise click.ClickException("Invalid recovery key")
|
||||
|
||||
# Validate password
|
||||
if len(password) < 8:
|
||||
db.close()
|
||||
raise click.UsageError("Password must be at least 8 characters")
|
||||
|
||||
# Hash new password with same settings as web UI
|
||||
ph = PasswordHasher(
|
||||
time_cost=3,
|
||||
memory_cost=65536, # 64MB
|
||||
parallelism=4,
|
||||
hash_len=32,
|
||||
salt_len=16,
|
||||
)
|
||||
new_hash = ph.hash(password)
|
||||
|
||||
# Find and update admin user
|
||||
admin = db.execute(
|
||||
"SELECT id, username FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
|
||||
if not admin:
|
||||
db.close()
|
||||
raise click.ClickException("No admin user found in database")
|
||||
|
||||
db.execute(
|
||||
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(new_hash, admin["id"]),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
click.echo(f"\nPassword reset successfully for admin '{admin['username']}'")
|
||||
click.echo("You can now login to the Web UI with your new password.")
|
||||
|
||||
|
||||
@admin.command("generate-key")
|
||||
@click.option("--qr", "show_qr", is_flag=True, help="Show QR code in terminal (if supported)")
|
||||
def admin_generate_key(show_qr):
|
||||
"""Generate a new recovery key (for reference only).
|
||||
|
||||
This generates a new random recovery key and displays it.
|
||||
To actually set the recovery key, use the Web UI.
|
||||
|
||||
Example:
|
||||
|
||||
stegasoo admin generate-key
|
||||
stegasoo admin generate-key --qr
|
||||
"""
|
||||
from .recovery import generate_recovery_key, get_recovery_fingerprint
|
||||
|
||||
key = generate_recovery_key()
|
||||
|
||||
click.echo("\nNew Recovery Key:")
|
||||
click.echo("─" * 50)
|
||||
click.echo(f" {key}")
|
||||
click.echo("─" * 50)
|
||||
click.echo(f"Fingerprint: {get_recovery_fingerprint(key)}")
|
||||
|
||||
if show_qr:
|
||||
try:
|
||||
import qrcode
|
||||
qr = qrcode.QRCode(box_size=1, border=1)
|
||||
qr.add_data(key)
|
||||
qr.make()
|
||||
click.echo("\nQR Code:")
|
||||
qr.print_ascii(invert=True)
|
||||
except ImportError:
|
||||
click.echo("\n(qrcode library not installed for terminal QR)")
|
||||
|
||||
click.echo("\nNote: Save this key securely. To set it in the Web UI,")
|
||||
click.echo("go to Account > Recovery Key > Regenerate")
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for CLI."""
|
||||
cli(obj={})
|
||||
|
||||
@@ -25,7 +25,7 @@ from pathlib import Path
|
||||
# VERSION
|
||||
# ============================================================================
|
||||
|
||||
__version__ = "4.0.2"
|
||||
__version__ = "4.1.0"
|
||||
|
||||
# ============================================================================
|
||||
# FILE FORMAT
|
||||
@@ -234,6 +234,14 @@ DCT_MAGIC_HEADER = b"\x89DCT" # Magic header for DCT mode
|
||||
DCT_FORMAT_VERSION = 1
|
||||
DCT_STEP_SIZE = 8 # QIM quantization step
|
||||
|
||||
# Recovery key obfuscation - FIXED value for admin recovery QR codes
|
||||
# SHA256("\x89ST3\x89DCT") - hardcoded so it never changes even if headers are added
|
||||
# Used to XOR recovery keys in QR codes so they scan as gibberish
|
||||
RECOVERY_OBFUSCATION_KEY = bytes.fromhex(
|
||||
"d6c70bce27780db942562550e9fe1459"
|
||||
"9dfdb8421f5acc79696b05db4e7afbd2"
|
||||
) # 32 bytes
|
||||
|
||||
# Valid embedding modes
|
||||
VALID_EMBED_MODES = {EMBED_MODE_LSB, EMBED_MODE_DCT}
|
||||
|
||||
|
||||
453
src/stegasoo/recovery.py
Normal file
453
src/stegasoo/recovery.py
Normal file
@@ -0,0 +1,453 @@
|
||||
"""
|
||||
Stegasoo Admin Recovery Module (v4.1.0)
|
||||
|
||||
Generates and manages recovery keys for admin password reset.
|
||||
|
||||
Recovery keys use the same format as channel keys (32 alphanumeric chars
|
||||
with dashes) but serve a different purpose - they allow resetting the
|
||||
admin password when locked out.
|
||||
|
||||
Security model:
|
||||
- Recovery key is generated once during setup
|
||||
- Only the hash is stored in the database
|
||||
- The actual key is shown once and must be saved by the user
|
||||
- Key can reset any admin account's password
|
||||
- No recovery key = no password reset possible (most secure)
|
||||
|
||||
Usage:
|
||||
# During setup - generate and show to user
|
||||
key = generate_recovery_key()
|
||||
key_hash = hash_recovery_key(key)
|
||||
# Store key_hash in database, show key to user
|
||||
|
||||
# During recovery - verify user's key
|
||||
if verify_recovery_key(user_input, stored_hash):
|
||||
# Allow password reset
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
from io import BytesIO
|
||||
|
||||
from .constants import RECOVERY_OBFUSCATION_KEY
|
||||
from .debug import debug
|
||||
|
||||
|
||||
def _xor_bytes(data: bytes, key: bytes) -> bytes:
|
||||
"""XOR data with repeating key."""
|
||||
return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
|
||||
|
||||
|
||||
def obfuscate_key(key: str) -> str:
|
||||
"""
|
||||
Obfuscate a recovery key for QR encoding.
|
||||
|
||||
XORs the key with magic header hash and base64 encodes.
|
||||
Result looks like random gibberish when scanned.
|
||||
|
||||
Args:
|
||||
key: Plain recovery key (formatted or normalized)
|
||||
|
||||
Returns:
|
||||
Obfuscated string prefixed with "STEGO:" marker
|
||||
"""
|
||||
normalized = normalize_recovery_key(key)
|
||||
key_bytes = normalized.encode("utf-8")
|
||||
xored = _xor_bytes(key_bytes, RECOVERY_OBFUSCATION_KEY)
|
||||
encoded = base64.b64encode(xored).decode("ascii")
|
||||
return f"STEGO:{encoded}"
|
||||
|
||||
|
||||
def deobfuscate_key(obfuscated: str) -> str | None:
|
||||
"""
|
||||
Deobfuscate a recovery key from QR data.
|
||||
|
||||
Reverses the obfuscation process.
|
||||
|
||||
Args:
|
||||
obfuscated: Obfuscated string from QR scan
|
||||
|
||||
Returns:
|
||||
Formatted recovery key, or None if invalid
|
||||
"""
|
||||
if not obfuscated.startswith("STEGO:"):
|
||||
# Not obfuscated - try as plain key
|
||||
try:
|
||||
return format_recovery_key(obfuscated)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
try:
|
||||
encoded = obfuscated[6:] # Strip "STEGO:" prefix
|
||||
xored = base64.b64decode(encoded)
|
||||
key_bytes = _xor_bytes(xored, RECOVERY_OBFUSCATION_KEY)
|
||||
normalized = key_bytes.decode("utf-8")
|
||||
return format_recovery_key(normalized)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STEGO BACKUP - Hide recovery key in an image using Stegasoo itself
|
||||
# =============================================================================
|
||||
|
||||
# Fixed credentials for recovery key stego (internal, not user-facing)
|
||||
# These are hardcoded - security is in the obscurity of the stego image
|
||||
_RECOVERY_STEGO_PASSPHRASE = "stegasoo-recovery-v1"
|
||||
_RECOVERY_STEGO_PIN = "314159" # Pi digits - fixed, not secret
|
||||
|
||||
# Size limits for carrier image
|
||||
STEGO_BACKUP_MIN_SIZE = 50 * 1024 # 50 KB
|
||||
STEGO_BACKUP_MAX_SIZE = 2 * 1024 * 1024 # 2 MB
|
||||
|
||||
|
||||
def create_stego_backup(
|
||||
recovery_key: str,
|
||||
carrier_image: bytes,
|
||||
) -> bytes:
|
||||
"""
|
||||
Hide recovery key in an image using Stegasoo steganography.
|
||||
|
||||
Uses the same image as both carrier and reference for simplicity.
|
||||
Fixed internal passphrase, no PIN required - obscurity is the security.
|
||||
|
||||
Args:
|
||||
recovery_key: The recovery key to hide
|
||||
carrier_image: JPEG image bytes (50KB-2MB, used as carrier AND reference)
|
||||
|
||||
Returns:
|
||||
PNG image with hidden recovery key
|
||||
|
||||
Raises:
|
||||
ValueError: If image size out of range or invalid format
|
||||
"""
|
||||
from .encode import encode
|
||||
|
||||
# Validate image size
|
||||
size = len(carrier_image)
|
||||
if size < STEGO_BACKUP_MIN_SIZE:
|
||||
raise ValueError(f"Image too small: {size // 1024}KB (min 50KB)")
|
||||
if size > STEGO_BACKUP_MAX_SIZE:
|
||||
raise ValueError(f"Image too large: {size // 1024}KB (max 2MB)")
|
||||
|
||||
# Normalize key for embedding
|
||||
formatted_key = format_recovery_key(recovery_key)
|
||||
|
||||
# Encode using Stegasoo - same image as carrier and reference
|
||||
result = encode(
|
||||
message=formatted_key,
|
||||
reference_photo=carrier_image, # Same image for simplicity
|
||||
carrier_image=carrier_image,
|
||||
passphrase=_RECOVERY_STEGO_PASSPHRASE,
|
||||
pin=_RECOVERY_STEGO_PIN,
|
||||
)
|
||||
|
||||
debug.print(f"Created stego backup: {len(result.stego_image)} bytes")
|
||||
return result.stego_image
|
||||
|
||||
|
||||
def extract_stego_backup(
|
||||
stego_image: bytes,
|
||||
reference_photo: bytes,
|
||||
) -> str | None:
|
||||
"""
|
||||
Extract recovery key from a stego backup image.
|
||||
|
||||
Args:
|
||||
stego_image: The stego image containing hidden key
|
||||
reference_photo: Original reference photo (same as was used for carrier)
|
||||
|
||||
Returns:
|
||||
Extracted recovery key (formatted), or None if extraction fails
|
||||
"""
|
||||
from .decode import decode
|
||||
from .exceptions import DecryptionError
|
||||
|
||||
try:
|
||||
result = decode(
|
||||
stego_image=stego_image,
|
||||
reference_photo=reference_photo,
|
||||
passphrase=_RECOVERY_STEGO_PASSPHRASE,
|
||||
pin=_RECOVERY_STEGO_PIN,
|
||||
)
|
||||
|
||||
# Validate it's a proper recovery key
|
||||
extracted = result.message or ""
|
||||
formatted = format_recovery_key(extracted)
|
||||
debug.print(f"Extracted recovery key from stego: {get_recovery_fingerprint(formatted)}")
|
||||
return formatted
|
||||
|
||||
except (DecryptionError, ValueError) as e:
|
||||
debug.print(f"Stego backup extraction failed: {e}")
|
||||
return None
|
||||
|
||||
# Recovery key format: same as channel key (32 chars, 8 groups of 4)
|
||||
RECOVERY_KEY_LENGTH = 32
|
||||
RECOVERY_KEY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
|
||||
def generate_recovery_key() -> str:
|
||||
"""
|
||||
Generate a new random recovery key.
|
||||
|
||||
Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
|
||||
(32 alphanumeric characters with dashes)
|
||||
|
||||
Returns:
|
||||
Formatted recovery key string
|
||||
|
||||
Example:
|
||||
>>> key = generate_recovery_key()
|
||||
>>> len(key)
|
||||
39
|
||||
>>> key.count('-')
|
||||
7
|
||||
"""
|
||||
# Generate 32 random alphanumeric characters
|
||||
raw_key = "".join(
|
||||
secrets.choice(RECOVERY_KEY_ALPHABET)
|
||||
for _ in range(RECOVERY_KEY_LENGTH)
|
||||
)
|
||||
|
||||
# Format with dashes every 4 characters
|
||||
formatted = "-".join(
|
||||
raw_key[i:i + 4]
|
||||
for i in range(0, RECOVERY_KEY_LENGTH, 4)
|
||||
)
|
||||
|
||||
debug.print(f"Generated recovery key: {formatted[:4]}-••••-...-{formatted[-4:]}")
|
||||
return formatted
|
||||
|
||||
|
||||
def normalize_recovery_key(key: str) -> str:
|
||||
"""
|
||||
Normalize a recovery key for validation/hashing.
|
||||
|
||||
Removes dashes, spaces, converts to uppercase.
|
||||
|
||||
Args:
|
||||
key: Raw key input (may have dashes, spaces, mixed case)
|
||||
|
||||
Returns:
|
||||
Normalized key (32 uppercase alphanumeric chars)
|
||||
|
||||
Raises:
|
||||
ValueError: If key has invalid length or characters
|
||||
|
||||
Example:
|
||||
>>> normalize_recovery_key("abcd-1234-efgh-5678-ijkl-9012-mnop-3456")
|
||||
"ABCD1234EFGH5678IJKL9012MNOP3456"
|
||||
"""
|
||||
# Remove dashes and spaces, uppercase
|
||||
clean = key.replace("-", "").replace(" ", "").upper()
|
||||
|
||||
# Validate length
|
||||
if len(clean) != RECOVERY_KEY_LENGTH:
|
||||
raise ValueError(
|
||||
f"Recovery key must be {RECOVERY_KEY_LENGTH} characters "
|
||||
f"(got {len(clean)})"
|
||||
)
|
||||
|
||||
# Validate characters
|
||||
if not all(c in RECOVERY_KEY_ALPHABET for c in clean):
|
||||
raise ValueError(
|
||||
"Recovery key must contain only letters A-Z and digits 0-9"
|
||||
)
|
||||
|
||||
return clean
|
||||
|
||||
|
||||
def format_recovery_key(key: str) -> str:
|
||||
"""
|
||||
Format a recovery key with dashes for display.
|
||||
|
||||
Args:
|
||||
key: Raw or normalized key
|
||||
|
||||
Returns:
|
||||
Formatted key (XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX)
|
||||
|
||||
Example:
|
||||
>>> format_recovery_key("ABCD1234EFGH5678IJKL9012MNOP3456")
|
||||
"ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
|
||||
"""
|
||||
clean = normalize_recovery_key(key)
|
||||
return "-".join(clean[i:i + 4] for i in range(0, RECOVERY_KEY_LENGTH, 4))
|
||||
|
||||
|
||||
def hash_recovery_key(key: str) -> str:
|
||||
"""
|
||||
Hash a recovery key for secure storage.
|
||||
|
||||
Uses SHA-256 with a fixed salt prefix. The hash is stored in the
|
||||
database; the original key is never stored.
|
||||
|
||||
Args:
|
||||
key: Recovery key (formatted or raw)
|
||||
|
||||
Returns:
|
||||
Hex-encoded hash string (64 chars)
|
||||
|
||||
Example:
|
||||
>>> key = "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
|
||||
>>> len(hash_recovery_key(key))
|
||||
64
|
||||
"""
|
||||
clean = normalize_recovery_key(key)
|
||||
|
||||
# Use a fixed salt prefix for recovery keys
|
||||
# This differentiates from other hashes in the system
|
||||
salted = f"stegasoo-recovery-v1:{clean}"
|
||||
|
||||
hash_bytes = hashlib.sha256(salted.encode("utf-8")).digest()
|
||||
hash_hex = hash_bytes.hex()
|
||||
|
||||
debug.print(f"Hashed recovery key: {hash_hex[:8]}...")
|
||||
return hash_hex
|
||||
|
||||
|
||||
def verify_recovery_key(key: str, stored_hash: str) -> bool:
|
||||
"""
|
||||
Verify a recovery key against a stored hash.
|
||||
|
||||
Args:
|
||||
key: User-provided recovery key
|
||||
stored_hash: Hash from database
|
||||
|
||||
Returns:
|
||||
True if key matches, False otherwise
|
||||
|
||||
Example:
|
||||
>>> key = generate_recovery_key()
|
||||
>>> h = hash_recovery_key(key)
|
||||
>>> verify_recovery_key(key, h)
|
||||
True
|
||||
>>> verify_recovery_key("WRONG-KEY!", h)
|
||||
False
|
||||
"""
|
||||
try:
|
||||
computed_hash = hash_recovery_key(key)
|
||||
# Use constant-time comparison to prevent timing attacks
|
||||
matches = secrets.compare_digest(computed_hash, stored_hash)
|
||||
debug.print(f"Recovery key verification: {'success' if matches else 'failed'}")
|
||||
return matches
|
||||
except ValueError:
|
||||
# Invalid key format
|
||||
debug.print("Recovery key verification: invalid format")
|
||||
return False
|
||||
|
||||
|
||||
def get_recovery_fingerprint(key: str) -> str:
|
||||
"""
|
||||
Get a short fingerprint for display (first and last 4 chars).
|
||||
|
||||
Args:
|
||||
key: Recovery key
|
||||
|
||||
Returns:
|
||||
Fingerprint like "ABCD-••••-...-3456"
|
||||
|
||||
Example:
|
||||
>>> get_recovery_fingerprint("ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456")
|
||||
"ABCD-••••-••••-••••-••••-••••-••••-3456"
|
||||
"""
|
||||
formatted = format_recovery_key(key)
|
||||
parts = formatted.split("-")
|
||||
masked = [parts[0]] + ["••••"] * 6 + [parts[-1]]
|
||||
return "-".join(masked)
|
||||
|
||||
|
||||
def generate_recovery_qr(key: str) -> bytes:
|
||||
"""
|
||||
Generate a QR code image for the recovery key.
|
||||
|
||||
The key is obfuscated using XOR with Stegasoo's magic headers,
|
||||
so scanning the QR shows gibberish instead of the actual key.
|
||||
|
||||
Args:
|
||||
key: Recovery key
|
||||
|
||||
Returns:
|
||||
PNG image bytes
|
||||
|
||||
Raises:
|
||||
ImportError: If qrcode library not available
|
||||
|
||||
Example:
|
||||
>>> key = generate_recovery_key()
|
||||
>>> png_bytes = generate_recovery_qr(key)
|
||||
>>> len(png_bytes) > 0
|
||||
True
|
||||
"""
|
||||
try:
|
||||
import qrcode
|
||||
except ImportError:
|
||||
raise ImportError("qrcode library required: pip install qrcode[pil]")
|
||||
|
||||
# Obfuscate so scanning shows gibberish, not the actual key
|
||||
obfuscated = obfuscate_key(key)
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(obfuscated)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
debug.print(f"Generated recovery QR (obfuscated): {len(buffer.getvalue())} bytes")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def extract_key_from_qr(image_data: bytes) -> str | None:
|
||||
"""
|
||||
Extract recovery key from a QR code image.
|
||||
|
||||
Handles both obfuscated (STEGO:...) and plain key formats.
|
||||
|
||||
Args:
|
||||
image_data: PNG/JPEG image bytes containing QR code
|
||||
|
||||
Returns:
|
||||
Extracted and validated recovery key, or None if not found/invalid
|
||||
|
||||
Example:
|
||||
>>> key = generate_recovery_key()
|
||||
>>> qr = generate_recovery_qr(key)
|
||||
>>> extract_key_from_qr(qr) == format_recovery_key(key)
|
||||
True
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
from pyzbar import pyzbar
|
||||
except ImportError:
|
||||
debug.print("pyzbar/PIL not available for QR reading")
|
||||
return None
|
||||
|
||||
try:
|
||||
img = Image.open(BytesIO(image_data))
|
||||
decoded = pyzbar.decode(img)
|
||||
|
||||
for obj in decoded:
|
||||
data = obj.data.decode("utf-8").strip()
|
||||
|
||||
# Try deobfuscation first (handles both obfuscated and plain)
|
||||
result = deobfuscate_key(data)
|
||||
if result:
|
||||
debug.print(f"Extracted recovery key from QR: {get_recovery_fingerprint(result)}")
|
||||
return result
|
||||
|
||||
debug.print("No valid recovery key found in QR")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
debug.print(f"QR extraction error: {e}")
|
||||
return None
|
||||
Reference in New Issue
Block a user