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/),
|
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).
|
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
|
## [4.0.2] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### 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.
|
Complete command-line interface reference for Stegasoo steganography operations.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Installation](#installation)
|
- [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)
|
- [Quick Start](#quick-start)
|
||||||
- [Commands](#commands)
|
- [Commands](#commands)
|
||||||
- [generate](#generate-command)
|
- [generate](#generate-command)
|
||||||
@@ -13,10 +13,11 @@ Complete command-line interface reference for Stegasoo steganography operations.
|
|||||||
- [decode](#decode-command)
|
- [decode](#decode-command)
|
||||||
- [verify](#verify-command)
|
- [verify](#verify-command)
|
||||||
- [channel](#channel-command)
|
- [channel](#channel-command)
|
||||||
|
- [admin](#admin-command)
|
||||||
|
- [tools](#tools-command)
|
||||||
- [info](#info-command)
|
- [info](#info-command)
|
||||||
- [compare](#compare-command)
|
- [compare](#compare-command)
|
||||||
- [modes](#modes-command)
|
- [modes](#modes-command)
|
||||||
- [strip-metadata](#strip-metadata-command)
|
|
||||||
- [Channel Keys](#channel-keys)
|
- [Channel Keys](#channel-keys)
|
||||||
- [Embedding Modes](#embedding-modes)
|
- [Embedding Modes](#embedding-modes)
|
||||||
- [Security Factors](#security-factors)
|
- [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
|
## 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 |
|
| 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 |
|
| CLI management | New `stegasoo channel` command group |
|
||||||
| Flexible override | Use server config, explicit key, or public mode |
|
| 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
|
## 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
|
```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` |
|
| generate | ✓ | ✓ | - | ✓ | CLI has `stegasoo generate` |
|
||||||
|
|
||||||
Priority order: Developer/CLI → API integrator → WebUI end-user
|
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.
|
Complete guide for the Stegasoo web-based steganography interface.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Overview](#overview)
|
- [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)
|
- [Authentication & HTTPS](#authentication--https)
|
||||||
|
- [Admin Recovery](#admin-recovery)
|
||||||
|
- [Multi-User Support](#multi-user-support)
|
||||||
- [Installation & Setup](#installation--setup)
|
- [Installation & Setup](#installation--setup)
|
||||||
- [Pages & Features](#pages--features)
|
- [Pages & Features](#pages--features)
|
||||||
- [Home Page](#home-page)
|
- [Home Page](#home-page)
|
||||||
- [Generate Credentials](#generate-credentials)
|
- [Generate Credentials](#generate-credentials)
|
||||||
- [Encode Message](#encode-message)
|
- [Encode Message](#encode-message)
|
||||||
- [Decode Message](#decode-message)
|
- [Decode Message](#decode-message)
|
||||||
|
- [Tools Page](#tools-page)
|
||||||
|
- [Account Page](#account-page)
|
||||||
- [About Page](#about-page)
|
- [About Page](#about-page)
|
||||||
- [Embedding Modes](#embedding-modes)
|
- [Embedding Modes](#embedding-modes)
|
||||||
- [DCT Mode (Default)](#dct-mode-default)
|
- [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
|
## 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 |
|
| 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 |
|
| **First-run setup** | Wizard to create admin account on first access |
|
||||||
| **Account management** | Change password page |
|
| **Account management** | Change password page |
|
||||||
| **Optional HTTPS** | Auto-generated self-signed certificates |
|
| **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
|
## Installation & Setup
|
||||||
|
|
||||||
### From PyPI
|
### 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
|
### About Page
|
||||||
|
|
||||||
**URL:** `/about`
|
**URL:** `/about`
|
||||||
@@ -556,10 +776,10 @@ If decryption fails:
|
|||||||
Information about the Stegasoo project, security model, and credits.
|
Information about the Stegasoo project, security model, and credits.
|
||||||
|
|
||||||
Includes:
|
Includes:
|
||||||
- Version information (v3.3.0)
|
- Version information (v4.1.0)
|
||||||
- Recent UI improvements
|
- Feature highlights
|
||||||
- Security model overview
|
- 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_by_id,
|
||||||
get_user_channel_keys,
|
get_user_channel_keys,
|
||||||
get_username,
|
get_username,
|
||||||
|
has_recovery_key,
|
||||||
|
get_recovery_key_hash,
|
||||||
|
clear_recovery_key,
|
||||||
is_admin,
|
is_admin,
|
||||||
is_authenticated,
|
is_authenticated,
|
||||||
login_required,
|
login_required,
|
||||||
@@ -55,6 +58,8 @@ from auth import (
|
|||||||
logout_user,
|
logout_user,
|
||||||
reset_user_password,
|
reset_user_password,
|
||||||
save_channel_key,
|
save_channel_key,
|
||||||
|
set_recovery_key_hash,
|
||||||
|
verify_and_reset_admin_password,
|
||||||
update_channel_key_last_used,
|
update_channel_key_last_used,
|
||||||
update_channel_key_name,
|
update_channel_key_name,
|
||||||
user_exists,
|
user_exists,
|
||||||
@@ -1586,7 +1591,7 @@ def logout():
|
|||||||
|
|
||||||
@app.route("/setup", methods=["GET", "POST"])
|
@app.route("/setup", methods=["GET", "POST"])
|
||||||
def setup():
|
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):
|
if not app.config.get("AUTH_ENABLED", True):
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
@@ -1608,14 +1613,219 @@ def setup():
|
|||||||
if user:
|
if user:
|
||||||
login_user(user)
|
login_user(user)
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
flash("Admin account created successfully!", "success")
|
# Redirect to recovery key setup (Step 2)
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("setup_recovery"))
|
||||||
else:
|
else:
|
||||||
flash(message, "error")
|
flash(message, "error")
|
||||||
|
|
||||||
return render_template("setup.html")
|
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"])
|
@app.route("/account", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def account():
|
def account():
|
||||||
@@ -1641,6 +1851,7 @@ def account():
|
|||||||
username=current_user.username,
|
username=current_user.username,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
is_admin=current_user.is_admin,
|
is_admin=current_user.is_admin,
|
||||||
|
has_recovery=has_recovery_key(),
|
||||||
channel_keys=channel_keys,
|
channel_keys=channel_keys,
|
||||||
max_channel_keys=MAX_CHANNEL_KEYS,
|
max_channel_keys=MAX_CHANNEL_KEYS,
|
||||||
can_save_key=can_save_channel_key(current_user.id),
|
can_save_key=can_save_channel_key(current_user.id),
|
||||||
|
|||||||
@@ -94,8 +94,9 @@ def init_db():
|
|||||||
# Fresh install - create new schema
|
# Fresh install - create new schema
|
||||||
_create_schema(db)
|
_create_schema(db)
|
||||||
else:
|
else:
|
||||||
# Existing install - check for new tables (channel_keys migration)
|
# Existing install - check for new tables (migrations)
|
||||||
_ensure_channel_keys_table(db)
|
_ensure_channel_keys_table(db)
|
||||||
|
_ensure_app_settings_table(db)
|
||||||
|
|
||||||
|
|
||||||
def _create_schema(db: sqlite3.Connection):
|
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);
|
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()
|
db.commit()
|
||||||
|
|
||||||
@@ -177,6 +187,131 @@ def _ensure_channel_keys_table(db: sqlite3.Connection):
|
|||||||
db.commit()
|
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
|
# User Queries
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -25,6 +25,45 @@
|
|||||||
<i class="bi bi-people me-2"></i>Manage Users
|
<i class="bi bi-people me-2"></i>Manage Users
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 %}
|
{% endif %}
|
||||||
|
|
||||||
<h6 class="text-muted mb-3">Change Password</h6>
|
<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;">
|
<div class="toast-container position-fixed end-0 p-3" style="z-index: 1100; top: 70px;">
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% for category, message in messages %}
|
{% 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="d-flex">
|
||||||
<div class="toast-body">
|
<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>
|
<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
|
<i class="bi bi-box-arrow-in-right me-2"></i>Login
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</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]
|
[project]
|
||||||
name = "stegasoo"
|
name = "stegasoo"
|
||||||
version = "4.0.1"
|
version = "4.1.0"
|
||||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -964,6 +964,162 @@ def tools_exif(image, clear, set_fields, output, as_json):
|
|||||||
raise click.UsageError(str(e))
|
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():
|
def main():
|
||||||
"""Entry point for CLI."""
|
"""Entry point for CLI."""
|
||||||
cli(obj={})
|
cli(obj={})
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from pathlib import Path
|
|||||||
# VERSION
|
# VERSION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
__version__ = "4.0.2"
|
__version__ = "4.1.0"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# FILE FORMAT
|
# FILE FORMAT
|
||||||
@@ -234,6 +234,14 @@ DCT_MAGIC_HEADER = b"\x89DCT" # Magic header for DCT mode
|
|||||||
DCT_FORMAT_VERSION = 1
|
DCT_FORMAT_VERSION = 1
|
||||||
DCT_STEP_SIZE = 8 # QIM quantization step
|
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 embedding modes
|
||||||
VALID_EMBED_MODES = {EMBED_MODE_LSB, EMBED_MODE_DCT}
|
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