Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
664362bea5 | ||
|
|
4733e3b4dd | ||
|
|
24aec00613 | ||
|
|
0e0aa996bc | ||
|
|
255ae4f30d | ||
|
|
7647ca11d1 | ||
|
|
01e9e5af0a | ||
|
|
39e5daa022 | ||
|
|
54e097c050 | ||
|
|
a3ff8dace1 | ||
|
|
e4cf96bb7c | ||
|
|
597c95070c | ||
|
|
dba5a08476 | ||
|
|
6ceda6c287 | ||
|
|
c2575f973b | ||
|
|
8208ec2955 | ||
|
|
909dc14a92 | ||
|
|
bb91e41d3d | ||
|
|
c54a96894c | ||
|
|
da044017d7 | ||
|
|
d0ec99d5b5 | ||
|
|
aac8037c04 | ||
|
|
7a5092b945 | ||
|
|
e52a709080 | ||
|
|
70fe8fce62 | ||
|
|
d44575deec | ||
|
|
d0d48236ff | ||
|
|
5891285493 | ||
|
|
5501c7e0ba | ||
|
|
038fd6ceac | ||
|
|
8622f1a850 | ||
|
|
710b3a6a98 | ||
|
|
c965a5f8da | ||
|
|
00cda4d929 | ||
|
|
05e2286d02 | ||
|
|
46cbf98a23 | ||
|
|
58673c04fe | ||
|
|
dd07972014 | ||
|
|
1f40eeff9e | ||
|
|
dc09bac489 | ||
|
|
46489dd276 | ||
|
|
9088caa23d | ||
|
|
75b6203525 | ||
|
|
404d7885f4 | ||
|
|
a8db991052 | ||
|
|
ea2948e5d2 | ||
|
|
05278ca55f | ||
|
|
c551078c37 | ||
|
|
b7d86201ca | ||
|
|
07b0bc0b75 | ||
|
|
d8b8e4f5c2 | ||
|
|
143a8bdc65 | ||
|
|
ac92fa36b5 | ||
|
|
c82dcf26f2 | ||
|
|
65a496a9d4 | ||
|
|
25a432fcf3 | ||
|
|
a58dd54ba8 | ||
|
|
05c542d808 | ||
|
|
5e5d6e60de | ||
|
|
d898f6d7b1 | ||
|
|
00dd15b8fb | ||
|
|
419b491737 | ||
|
|
b568026253 | ||
|
|
127d3e54a6 | ||
|
|
de41c0731e | ||
|
|
f3d5699e15 | ||
|
|
298f387c9a | ||
|
|
fcb71303df | ||
|
|
abcff74dd4 | ||
|
|
355a988405 | ||
|
|
fb55878727 | ||
|
|
81d3f37f09 | ||
|
|
3537e8cdf9 | ||
|
|
d71f615d66 | ||
|
|
ed1d230b4e | ||
|
|
13f145c3d5 | ||
|
|
80dc22f150 | ||
|
|
01f0173dd4 | ||
|
|
5df9b9dac8 | ||
|
|
2f1ac3a747 | ||
|
|
8e5f01754f | ||
|
|
823b8824ea | ||
|
|
f4c1aa1912 | ||
|
|
e502f42fb8 | ||
|
|
08e42719ee | ||
|
|
21023099b0 | ||
|
|
8a41796d1b | ||
|
|
7b33501495 | ||
|
|
a8f6ae1dd2 | ||
|
|
b199f03f83 | ||
|
|
b97622956c | ||
|
|
3044c08fe3 | ||
|
|
5042c7d555 | ||
|
|
aa8788168e | ||
|
|
899d043892 | ||
|
|
6fb63edc61 | ||
|
|
e74f12c24d | ||
|
|
272d0e6ef0 | ||
|
|
f38bf4a1c6 | ||
|
|
fee3133f9c | ||
|
|
b058d8bf66 | ||
|
|
916a2e0e7b | ||
|
|
cccb40dc3a | ||
|
|
b60880c8b3 | ||
|
|
c96c595c78 | ||
|
|
e129c38fd8 | ||
|
|
0d7b5a14cb | ||
|
|
45b99d2c5e | ||
|
|
c6f816d61f | ||
|
|
83e9bd6fa1 | ||
|
|
5188492c77 | ||
|
|
8bb70e5667 | ||
|
|
82ac1dcda4 | ||
|
|
464e13567d | ||
|
|
0b19a41b5e | ||
|
|
61c5178752 | ||
|
|
6b1b306f61 | ||
|
|
267547caba | ||
|
|
2ff28034f5 | ||
|
|
4cba75fe06 | ||
|
|
d03b3dea4b |
14
.gitignore
vendored
@@ -54,7 +54,7 @@ htmlcov/
|
|||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.local
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Distribution
|
# Distribution
|
||||||
@@ -65,11 +65,15 @@ htmlcov/
|
|||||||
test_data/*.png
|
test_data/*.png
|
||||||
|
|
||||||
# Dev scripts (local convenience scripts)
|
# Dev scripts (local convenience scripts)
|
||||||
build.sh
|
scripts/
|
||||||
rbld_containers.sh
|
|
||||||
quick_web.sh
|
|
||||||
project_stats.sh
|
|
||||||
|
|
||||||
# Web UI auth database and SSL certs
|
# Web UI auth database and SSL certs
|
||||||
frontends/web/instance/
|
frontends/web/instance/
|
||||||
frontends/web/certs/
|
frontends/web/certs/
|
||||||
|
rpi/inject-wifi.sh
|
||||||
|
|
||||||
|
# RPi image build artifacts
|
||||||
|
*.img
|
||||||
|
*.img.xz
|
||||||
|
*.img.zst
|
||||||
|
pishrink.sh
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
3.12.0
|
3.12
|
||||||
|
|||||||
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
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
115
INSTALL.md
@@ -435,24 +435,85 @@ pip install stegasoo[all]
|
|||||||
|
|
||||||
### Raspberry Pi
|
### Raspberry Pi
|
||||||
|
|
||||||
Stegasoo works on Raspberry Pi 4 (2GB+ RAM recommended):
|
Stegasoo works on Raspberry Pi 4/5 (4GB+ RAM recommended for Web UI).
|
||||||
|
|
||||||
|
#### Step 1: Install System Dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# System dependencies
|
sudo apt-get update
|
||||||
sudo apt-get install python3-dev libzbar0 libjpeg-dev
|
sudo apt-get install -y \
|
||||||
|
build-essential \
|
||||||
# Create venv with Python 3.12 (if available, or 3.11)
|
git \
|
||||||
python3 -m venv venv
|
libssl-dev \
|
||||||
source venv/bin/activate
|
zlib1g-dev \
|
||||||
|
libbz2-dev \
|
||||||
# Install (may take a while to compile)
|
libreadline-dev \
|
||||||
pip install stegasoo[cli]
|
libsqlite3-dev \
|
||||||
|
libncursesw5-dev \
|
||||||
# For web/api, ensure enough RAM
|
xz-utils \
|
||||||
pip install stegasoo[web] # Needs ~768MB free
|
tk-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
libxmlsec1-dev \
|
||||||
|
libffi-dev \
|
||||||
|
liblzma-dev \
|
||||||
|
libzbar0 \
|
||||||
|
libjpeg-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
**Running the Web UI on Pi:**
|
#### Step 2: Install Python 3.12 via pyenv
|
||||||
|
|
||||||
|
Raspberry Pi OS ships with Python 3.13, which is **not compatible** with jpegio. Install Python 3.12:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install pyenv
|
||||||
|
curl https://pyenv.run | bash
|
||||||
|
|
||||||
|
# Add to ~/.bashrc
|
||||||
|
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
||||||
|
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
||||||
|
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
|
||||||
|
source ~/.bashrc
|
||||||
|
|
||||||
|
# Install Python 3.12 (takes ~10 minutes on Pi 5)
|
||||||
|
pyenv install 3.12
|
||||||
|
pyenv global 3.12
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: Build jpegio for ARM
|
||||||
|
|
||||||
|
The upstream jpegio has x86-specific build flags. Patch and build from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone jpegio
|
||||||
|
git clone https://github.com/dwgoon/jpegio.git
|
||||||
|
cd jpegio
|
||||||
|
|
||||||
|
# Patch for ARM (removes x86-specific -m64 flag)
|
||||||
|
sed -i "s/cargs.append('-m64')/pass # ARM fix/" setup.py
|
||||||
|
|
||||||
|
# Build and install
|
||||||
|
pip install .
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4: Install Stegasoo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone Stegasoo
|
||||||
|
git clone https://github.com/adlee-was-taken/stegasoo.git
|
||||||
|
cd stegasoo
|
||||||
|
|
||||||
|
# Create venv with Python 3.12
|
||||||
|
~/.pyenv/versions/3.12.*/bin/python -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Install (jpegio already installed, skip it)
|
||||||
|
pip install -e ".[web]" --no-deps
|
||||||
|
pip install argon2-cffi cryptography pillow flask gunicorn scipy numpy pyzbar qrcode
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 5: Run the Web UI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontends/web
|
cd frontends/web
|
||||||
|
|
||||||
@@ -465,12 +526,30 @@ export STEGASOO_HOSTNAME=raspberrypi.local
|
|||||||
|
|
||||||
# Start server
|
# Start server
|
||||||
python app.py
|
python app.py
|
||||||
|
# Access at http://<pi-ip>:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
**Notes:**
|
#### Verify Installation
|
||||||
- Argon2 operations will be slower on Pi due to memory-hardness
|
|
||||||
- First run will prompt you to create an admin account
|
```bash
|
||||||
- HTTPS generates a self-signed certificate (browsers will warn)
|
python -c "
|
||||||
|
import stegasoo
|
||||||
|
from stegasoo.dct_steganography import has_jpegio_support
|
||||||
|
print(f'Stegasoo: {stegasoo.__version__}')
|
||||||
|
print(f'Argon2: {stegasoo.has_argon2()}')
|
||||||
|
print(f'DCT: {stegasoo.has_dct_support()}')
|
||||||
|
print(f'jpegio: {has_jpegio_support()}')
|
||||||
|
"
|
||||||
|
# Expected: All True
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Notes
|
||||||
|
|
||||||
|
- **RAM**: Web UI needs ~768MB free for Argon2 + scipy operations
|
||||||
|
- **Performance**: Argon2 operations take 3-5 seconds on Pi 5 (vs ~2s on desktop)
|
||||||
|
- **Python 3.13**: Not supported due to jpegio C extension incompatibility
|
||||||
|
- **First run**: Will prompt you to create an admin account
|
||||||
|
- **HTTPS**: Generates self-signed certificate (browsers will warn)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
538
PLAN-4.1.0.md
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
# Stegasoo 4.1.0 Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Version 4.1.0 is a feature release focusing on small-group deployment improvements and new utilities.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. ~~**Multi-User Support** - Admin can create up to 16 users for shared deployments~~ ✅ DONE
|
||||||
|
2. **Channel Key QR** - Easy visual sharing of channel keys via QR codes
|
||||||
|
3. ~~**CLI Channel Commands** - Manage channel keys from command line~~ ✅ DONE
|
||||||
|
4. **Advanced Tools** - Image/stego utilities (TBD)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 1: Multi-User Support ✅ COMPLETED
|
||||||
|
|
||||||
|
> Implemented in commit 7b33501. All requirements met.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- 16 users + 1 admin maximum (17 total)
|
||||||
|
- First user created at setup is always admin
|
||||||
|
- Admin can add/delete users, reset passwords
|
||||||
|
- Regular users can only change their own password
|
||||||
|
- No self-registration (admin-invite only)
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
|
||||||
|
**Update User model in `frontends/web/models.py`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
class User(db.Model):
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
username = Column(String(80), unique=True, nullable=False)
|
||||||
|
password_hash = Column(String(255), nullable=False)
|
||||||
|
role = Column(String(20), default='user') # 'admin' or 'user'
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration:** Add `role` and `created_at` columns. Existing users get `role='admin'`.
|
||||||
|
|
||||||
|
### New Routes
|
||||||
|
|
||||||
|
| Route | Method | Access | Description |
|
||||||
|
|-------|--------|--------|-------------|
|
||||||
|
| `/admin/users` | GET | admin | List all users |
|
||||||
|
| `/admin/users/new` | GET, POST | admin | Create user form |
|
||||||
|
| `/admin/users/<id>/delete` | POST | admin | Delete user |
|
||||||
|
| `/admin/users/<id>/reset-password` | POST | admin | Generate temp password |
|
||||||
|
|
||||||
|
### New Decorator
|
||||||
|
|
||||||
|
```python
|
||||||
|
# auth.py
|
||||||
|
def admin_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
if current_user.role != 'admin':
|
||||||
|
flash('Admin access required', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI Changes
|
||||||
|
|
||||||
|
**Navigation (for admin users):**
|
||||||
|
- Add "Users" link in navbar (visible only to admin)
|
||||||
|
|
||||||
|
**Account page (`/account`):**
|
||||||
|
- Admin sees link to user management
|
||||||
|
- All users see their own password change form
|
||||||
|
|
||||||
|
**New template: `templates/admin/users.html`:**
|
||||||
|
- Table: Username | Role | Created | Actions
|
||||||
|
- Actions: Reset Password, Delete (disabled for self)
|
||||||
|
- "Add User" button (disabled if at 16 user limit)
|
||||||
|
- Show count: "3 of 16 users"
|
||||||
|
|
||||||
|
**New template: `templates/admin/user_new.html`:**
|
||||||
|
- Username field (email-style allowed)
|
||||||
|
- Password field (auto-populated with random 8-char, admin can override)
|
||||||
|
- Submit → confirmation page shows password once with copy button
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Username: 3-80 chars, alphanumeric + underscore/hyphen + @/. for email-style
|
||||||
|
- Password: 8+ chars (same as current)
|
||||||
|
- Can't delete yourself
|
||||||
|
- Can't demote the last admin
|
||||||
|
- Deleting user immediately invalidates their sessions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 2: Channel Key QR
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
|
||||||
|
**About page additions:**
|
||||||
|
|
||||||
|
If `STEGASOO_CHANNEL_KEY` environment variable is set:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Channel Key │
|
||||||
|
│ │
|
||||||
|
│ ██████████████ Your server uses a │
|
||||||
|
│ ██ ██ private channel key. │
|
||||||
|
│ ██ ██████ ██ Share this QR with │
|
||||||
|
│ ██ ██████ ██ others to join. │
|
||||||
|
│ ██ ██ │
|
||||||
|
│ ██████████████ [Copy Key] [Download]│
|
||||||
|
│ │
|
||||||
|
│ Key: abc123...xyz │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- QR generated server-side using `qrcode` library
|
||||||
|
- "Copy Key" copies text to clipboard
|
||||||
|
- "Download QR" saves as PNG
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# about route addition
|
||||||
|
@app.route('/about')
|
||||||
|
def about():
|
||||||
|
channel_key = os.environ.get('STEGASOO_CHANNEL_KEY', '')
|
||||||
|
channel_qr_b64 = None
|
||||||
|
if channel_key:
|
||||||
|
# Generate QR as base64 PNG
|
||||||
|
qr = qrcode.make(channel_key)
|
||||||
|
buffer = BytesIO()
|
||||||
|
qr.save(buffer, format='PNG')
|
||||||
|
channel_qr_b64 = base64.b64encode(buffer.getvalue()).decode()
|
||||||
|
return render_template('about.html',
|
||||||
|
channel_key=channel_key,
|
||||||
|
channel_qr=channel_qr_b64)
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
|
||||||
|
**New command group: `stegasoo channel`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a new channel key
|
||||||
|
stegasoo channel generate
|
||||||
|
# Output:
|
||||||
|
# Channel Key: stg_abc123...xyz789
|
||||||
|
#
|
||||||
|
# ██████████████████
|
||||||
|
# ██ ██
|
||||||
|
# ██ ██████████ ██
|
||||||
|
# ...
|
||||||
|
#
|
||||||
|
# Set in environment: export STEGASOO_CHANNEL_KEY="stg_abc123..."
|
||||||
|
|
||||||
|
# Show current key (from env or argument)
|
||||||
|
stegasoo channel show
|
||||||
|
# Output:
|
||||||
|
# Channel Key: stg_abc123...xyz789
|
||||||
|
|
||||||
|
# Display QR in terminal (ASCII)
|
||||||
|
stegasoo channel qr
|
||||||
|
# Output: ASCII QR code
|
||||||
|
|
||||||
|
# Save QR as PNG
|
||||||
|
stegasoo channel qr -o channel-key.png
|
||||||
|
# Output: Saved to channel-key.png
|
||||||
|
|
||||||
|
# Explicit format selection
|
||||||
|
stegasoo channel qr --format ascii # Terminal (default)
|
||||||
|
stegasoo channel qr --format png -o - # PNG to stdout
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation notes:**
|
||||||
|
|
||||||
|
- Use `qrcode[pil]` for PNG output
|
||||||
|
- Use `qrcode` with `print_ascii()` for terminal
|
||||||
|
- Read key from `--key` argument or `STEGASOO_CHANNEL_KEY` env var
|
||||||
|
- `generate` uses existing `generate_channel_key()` from `stegasoo.channel`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Changes Summary
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `frontends/web/templates/admin/users.html` | User management page |
|
||||||
|
| `frontends/web/templates/admin/user_new.html` | Add user form |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `frontends/web/models.py` | Add `role`, `created_at` to User |
|
||||||
|
| `frontends/web/auth.py` | Add `@admin_required`, user management routes |
|
||||||
|
| `frontends/web/templates/base.html` | Add Users link for admins |
|
||||||
|
| `frontends/web/templates/account.html` | Add admin link |
|
||||||
|
| `frontends/web/templates/about.html` | Add channel key QR section |
|
||||||
|
| `src/stegasoo/cli.py` | Add `channel` command group |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
### Multi-User
|
||||||
|
|
||||||
|
1. Fresh install → first user is admin
|
||||||
|
2. Admin can create users up to limit (16)
|
||||||
|
3. Admin can't create 17th user (shows error)
|
||||||
|
4. Regular user can log in, encode/decode
|
||||||
|
5. Regular user can't access `/admin/users`
|
||||||
|
6. Admin can reset user password
|
||||||
|
7. Admin can delete user
|
||||||
|
8. Admin can't delete self
|
||||||
|
9. Existing 4.0.2 databases upgrade correctly (single user becomes admin)
|
||||||
|
|
||||||
|
### Channel Key QR
|
||||||
|
|
||||||
|
1. About page shows nothing if no channel key
|
||||||
|
2. About page shows QR + key if channel key set
|
||||||
|
3. Copy button works
|
||||||
|
4. Download gives valid PNG
|
||||||
|
5. QR scans correctly to key value
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
1. `channel generate` creates valid key + shows QR
|
||||||
|
2. `channel show` displays current key
|
||||||
|
3. `channel qr` outputs ASCII to terminal
|
||||||
|
4. `channel qr -o file.png` saves PNG
|
||||||
|
5. Commands work with `--key` override
|
||||||
|
6. Commands read from env var
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 3: Advanced Tools
|
||||||
|
|
||||||
|
### Included Tools
|
||||||
|
|
||||||
|
| Tool | Web | CLI | Description |
|
||||||
|
|------|-----|-----|-------------|
|
||||||
|
| **Capacity Calculator** | ✓ | ✓ | Upload image → show DCT/LSB capacity |
|
||||||
|
| **Metadata Stripper** | ✓ | ✓ | Remove EXIF/metadata from image |
|
||||||
|
| **Stego Detector** | ✓ | ✓ | Analyze image for signs of hidden data |
|
||||||
|
| **Image Compare** | ✓ | - | Side-by-side before/after diff |
|
||||||
|
| **Header Peek** | ✓ | ✓ | Check for Stegasoo header without decrypting |
|
||||||
|
| **Batch Mode** | - | ✓ | Encode/decode multiple files |
|
||||||
|
|
||||||
|
### Web UI: `/tools` Page
|
||||||
|
|
||||||
|
New page with card-based layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 🛠️ Advanced Tools │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ 📏 Capacity │ │ 🧹 Metadata │ │
|
||||||
|
│ │ Calculator │ │ Stripper │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ Check how much │ │ Remove EXIF │ │
|
||||||
|
│ │ data fits │ │ before encoding │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ 🔍 Stego │ │ 🔎 Header │ │
|
||||||
|
│ │ Detector │ │ Peek │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ Analyze image │ │ Check for │ │
|
||||||
|
│ │ for hidden data │ │ Stegasoo data │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────┐ │
|
||||||
|
│ │ ⚖️ Image │ │
|
||||||
|
│ │ Compare │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Before/after │ │
|
||||||
|
│ │ diff view │ │
|
||||||
|
│ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Each card opens a modal or expands inline for the tool interface.
|
||||||
|
|
||||||
|
### CLI Structure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Capacity calculator
|
||||||
|
stegasoo capacity image.jpg
|
||||||
|
stegasoo capacity image.jpg --format json
|
||||||
|
|
||||||
|
# Metadata stripper
|
||||||
|
stegasoo strip image.jpg # Output to image_stripped.jpg
|
||||||
|
stegasoo strip image.jpg -o clean.jpg # Custom output
|
||||||
|
stegasoo strip image.jpg --in-place # Overwrite original
|
||||||
|
|
||||||
|
# Stego detector
|
||||||
|
stegasoo detect image.jpg
|
||||||
|
stegasoo detect image.jpg --verbose # Detailed analysis
|
||||||
|
|
||||||
|
# Header peek
|
||||||
|
stegasoo peek image.jpg
|
||||||
|
# Output: "Stegasoo DCT header detected" or "No Stegasoo header found"
|
||||||
|
|
||||||
|
# Batch mode
|
||||||
|
stegasoo encode --batch manifest.json # JSON with files + credentials
|
||||||
|
stegasoo decode --batch input_dir/ --out output_dir/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Details
|
||||||
|
|
||||||
|
#### Capacity Calculator
|
||||||
|
- Input: Image file
|
||||||
|
- Output: Dimensions, megapixels, DCT capacity, LSB capacity
|
||||||
|
- Web: Upload zone + results panel
|
||||||
|
- CLI: Table or JSON output
|
||||||
|
|
||||||
|
#### Metadata Stripper
|
||||||
|
- Input: Image file
|
||||||
|
- Output: Clean image (EXIF/metadata removed)
|
||||||
|
- Show what was removed (camera model, GPS, etc.)
|
||||||
|
- Preserve image quality
|
||||||
|
|
||||||
|
#### Stego Detector
|
||||||
|
- Input: Image file
|
||||||
|
- Analysis:
|
||||||
|
- Chi-square analysis (LSB detection)
|
||||||
|
- DCT coefficient histogram analysis
|
||||||
|
- Visual inspection hints
|
||||||
|
- Output: Likelihood score + findings
|
||||||
|
- Note: Detection is probabilistic, not definitive
|
||||||
|
|
||||||
|
#### Image Compare
|
||||||
|
- Input: Two images (original + stego)
|
||||||
|
- Output:
|
||||||
|
- Side-by-side view
|
||||||
|
- Difference overlay (amplified)
|
||||||
|
- Pixel-level stats (PSNR, SSIM)
|
||||||
|
- Web only (visual tool)
|
||||||
|
|
||||||
|
#### Header Peek
|
||||||
|
- Input: Image file
|
||||||
|
- Output: Header found (yes/no), mode (DCT/LSB), embedded size estimate
|
||||||
|
- Does NOT decrypt - just checks for valid header structure
|
||||||
|
- Useful for "is this a stego image?" without credentials
|
||||||
|
|
||||||
|
#### Batch Mode
|
||||||
|
- CLI only
|
||||||
|
- Manifest file (JSON) or directory-based
|
||||||
|
- Progress bar for multiple files
|
||||||
|
- Error handling per-file (continue on failure)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
### Database Migration
|
||||||
|
|
||||||
|
For existing 4.0.2 installations:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# migrations/add_user_role.py
|
||||||
|
def upgrade():
|
||||||
|
# Add columns with defaults
|
||||||
|
op.add_column('user', sa.Column('role', sa.String(20), default='user'))
|
||||||
|
op.add_column('user', sa.Column('created_at', sa.DateTime))
|
||||||
|
|
||||||
|
# Set existing users as admin (they were the first user)
|
||||||
|
op.execute("UPDATE user SET role = 'admin' WHERE role IS NULL")
|
||||||
|
op.execute("UPDATE user SET created_at = datetime('now') WHERE created_at IS NULL")
|
||||||
|
```
|
||||||
|
|
||||||
|
Or simpler: detect on startup, update schema automatically (current pattern).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Per-user channel keys
|
||||||
|
- User groups/teams
|
||||||
|
- API authentication tokens
|
||||||
|
- User activity logging
|
||||||
|
- Password complexity rules beyond length
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
| Component | Complexity |
|
||||||
|
|-----------|------------|
|
||||||
|
| Database schema change | Low |
|
||||||
|
| Admin routes + templates | Medium |
|
||||||
|
| Access control decorator | Low |
|
||||||
|
| About page QR | Low |
|
||||||
|
| CLI channel commands | Medium |
|
||||||
|
| Advanced Tools (TBD) | Medium-High |
|
||||||
|
| Testing | Medium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Temp password flow:** Password field auto-populates with random 8-char password. Admin can override if desired. Show password once on confirmation page.
|
||||||
|
|
||||||
|
2. **Session handling:** Yes - deleting a user immediately invalidates their active sessions (ban hammer).
|
||||||
|
|
||||||
|
3. **Username rules:** Sane requirements, email-style allowed. Validation: 3-80 chars, alphanumeric, underscore, hyphen, @ and . for email-style.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approval
|
||||||
|
|
||||||
|
- [x] Plan reviewed
|
||||||
|
- [x] Questions resolved
|
||||||
|
- [x] Ready to implement
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] Multi-User Support (commit 7b33501)
|
||||||
|
- [x] Channel Key QR (Web UI) - added QR generator on About page
|
||||||
|
- [x] CLI Channel Commands
|
||||||
|
- [x] Saved Channel Keys (Web UI) - users can save/manage channel keys
|
||||||
|
- [x] Advanced Tools - Image Security Toolkit
|
||||||
|
- [x] CLI: `stegasoo tools capacity/strip/peek/exif`
|
||||||
|
- [x] API: `/api/tools/capacity`, `/api/tools/peek`, `/api/tools/exif/*`
|
||||||
|
- [x] WebUI: Tools page with tabbed interface
|
||||||
|
- [x] EXIF Editor with inline editing, clear all, save/download
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Improvements (4.1.0)
|
||||||
|
|
||||||
|
### Consolidated Channel Key Resolution
|
||||||
|
|
||||||
|
Moved `resolve_channel_key()` from 3 duplicate implementations to single source of truth in `src/stegasoo/channel.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Library: src/stegasoo/channel.py
|
||||||
|
def resolve_channel_key(value, *, file_path=None, no_channel=False) -> str | None:
|
||||||
|
"""Unified channel key resolution - returns None (auto), "" (public), or key."""
|
||||||
|
|
||||||
|
def get_channel_response_info(channel_key) -> dict:
|
||||||
|
"""Get channel info dict for API/WebUI responses."""
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontends now use thin wrappers that translate exceptions to their context (Click/HTTP).
|
||||||
|
|
||||||
|
### DCT Payload Pre-Check
|
||||||
|
|
||||||
|
Added `will_fit_by_mode()` pre-check to WebUI encode to fail fast with helpful error message instead of cryptic exception deep in DCT processing.
|
||||||
|
|
||||||
|
### EXIF Tools (Library Layer)
|
||||||
|
|
||||||
|
Added to `src/stegasoo/utils.py`:
|
||||||
|
- `read_image_exif(image_data)` - Read EXIF metadata as dict
|
||||||
|
- `write_image_exif(image_data, updates)` - Update EXIF fields (JPEG only)
|
||||||
|
|
||||||
|
Dependencies added: `piexif>=1.1.0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Action Item: Architectural Review ✅ DONE
|
||||||
|
|
||||||
|
Reviewed modules for consistency with Library → CLI → API → WebUI pattern:
|
||||||
|
|
||||||
|
| Module | Library | CLI | API | WebUI | Status |
|
||||||
|
|--------|---------|-----|-----|-------|--------|
|
||||||
|
| encode | ✓ | ✓ | ✓ | ✓ | Consistent |
|
||||||
|
| decode | ✓ | ✓ | ✓ | ✓ | Consistent |
|
||||||
|
| channel | ✓ | ✓ | ✓ | ✓ | Consolidated resolve_channel_key |
|
||||||
|
| tools | ✓ | ✓ | ✓ | ✓ | Complete |
|
||||||
|
| generate | ✓ | ✓ | - | ✓ | CLI has `stegasoo generate` |
|
||||||
|
|
||||||
|
Priority order: Developer/CLI → API integrator → WebUI end-user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Recovery System (4.1.0) ✅ DONE
|
||||||
|
|
||||||
|
Password reset capability for locked-out admins with multiple backup options.
|
||||||
|
|
||||||
|
### Library Layer (`src/stegasoo/recovery.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Key generation and validation
|
||||||
|
generate_recovery_key() -> str # XXXX-XXXX-XXXX-... (32 chars)
|
||||||
|
hash_recovery_key(key) -> str # SHA-256 for storage
|
||||||
|
verify_recovery_key(key, hash) -> bool
|
||||||
|
|
||||||
|
# QR code (obfuscated - scans as gibberish)
|
||||||
|
obfuscate_key(key) -> str # XOR with RECOVERY_OBFUSCATION_KEY
|
||||||
|
deobfuscate_key(data) -> str | None
|
||||||
|
generate_recovery_qr(key) -> bytes # PNG with obfuscated data
|
||||||
|
extract_key_from_qr(image) -> str | None
|
||||||
|
|
||||||
|
# Stego backup (hide key in an image)
|
||||||
|
create_stego_backup(key, carrier_image) -> bytes
|
||||||
|
extract_stego_backup(stego_image, reference) -> str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database (`app_settings` table)
|
||||||
|
|
||||||
|
- `recovery_key_hash` - SHA-256 of recovery key (or null if disabled)
|
||||||
|
|
||||||
|
### Web Routes
|
||||||
|
|
||||||
|
| Route | Method | Description |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| `/setup/recovery` | GET, POST | Step 2 of initial setup |
|
||||||
|
| `/recover` | GET, POST | Password reset page |
|
||||||
|
| `/recover/stego` | POST | Extract key from stego backup |
|
||||||
|
| `/account/recovery/regenerate` | GET, POST | Generate new key |
|
||||||
|
| `/account/recovery/disable` | POST | Remove recovery option |
|
||||||
|
| `/account/recovery/stego-backup` | POST | Create stego backup |
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stegasoo admin recover --db path/to/stegasoo.db # Reset password
|
||||||
|
stegasoo admin generate-key [--qr] # Generate key (reference)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Model
|
||||||
|
|
||||||
|
1. Recovery key shown once during setup - only hash stored
|
||||||
|
2. QR codes XOR'd with `RECOVERY_OBFUSCATION_KEY` (fixed in constants.py)
|
||||||
|
3. Stego backups use fixed internal passphrase/PIN - security is obscurity
|
||||||
|
4. Instance-bound: recovery key hash must match in target database
|
||||||
|
5. Options: text file, QR image, stego image, or no recovery (most secure)
|
||||||
221
PLAN-4.1.2.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Stegasoo 4.1.2 Plan
|
||||||
|
|
||||||
|
## Release Theme
|
||||||
|
Polish and UX improvements after the 4.1.1 stability release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Real Progress Bar for Encode/Decode
|
||||||
|
|
||||||
|
**Status:** Planned
|
||||||
|
|
||||||
|
**Problem:** Users see elapsed time but no indication of how far along the operation is. Long DCT encodes on Pi can take 2-3 minutes with no feedback.
|
||||||
|
|
||||||
|
**Solution:** Polling + progress file approach
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
|
||||||
|
1. **dct_steganography.py** - Write progress during block loop:
|
||||||
|
```python
|
||||||
|
if progress_file and block_num % 50 == 0:
|
||||||
|
with open(progress_file, 'w') as f:
|
||||||
|
json.dump({"current": block_num, "total": total_blocks, "phase": "embedding"}, f)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **app.py** - New endpoints:
|
||||||
|
- `POST /encode` returns `job_id`, starts subprocess
|
||||||
|
- `GET /encode/progress/<job_id>` returns progress JSON
|
||||||
|
- `GET /encode/result/<job_id>` returns final result when done
|
||||||
|
|
||||||
|
3. **Subprocess wrapper** - Pass progress file path to encode/decode functions
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
|
||||||
|
1. **stegasoo.js** - After form submit:
|
||||||
|
- Show progress bar (Bootstrap progress component)
|
||||||
|
- Poll `/encode/progress/{job_id}` every 500ms
|
||||||
|
- Update bar width and percentage text
|
||||||
|
- Show phase (hashing, embedding, encoding, etc.)
|
||||||
|
|
||||||
|
2. **Templates** - Add progress bar markup to encode.html and decode.html
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
- `src/stegasoo/dct_steganography.py`
|
||||||
|
- `frontends/web/app.py`
|
||||||
|
- `frontends/web/static/js/stegasoo.js`
|
||||||
|
- `frontends/web/templates/encode.html`
|
||||||
|
- `frontends/web/templates/decode.html`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Granular Decode Error Messages
|
||||||
|
|
||||||
|
**Status:** Planned
|
||||||
|
|
||||||
|
**Problem:** Decode failures show generic "Decryption failed" - users don't know if it's wrong photo, wrong passphrase, wrong PIN, corrupted image, or format mismatch.
|
||||||
|
|
||||||
|
**Solution:** Bubble up specific error types from library to UI
|
||||||
|
|
||||||
|
### Library Level (`src/stegasoo/`)
|
||||||
|
|
||||||
|
1. **Custom exception classes:**
|
||||||
|
```python
|
||||||
|
class StegasooError(Exception): pass
|
||||||
|
class InvalidMagicBytesError(StegasooError): pass
|
||||||
|
class DecryptionError(StegasooError): pass
|
||||||
|
class ReedSolomonError(StegasooError): pass
|
||||||
|
class PayloadTooLargeError(StegasooError): pass
|
||||||
|
class InvalidHeaderError(StegasooError): pass
|
||||||
|
class NoDataFoundError(StegasooError): pass
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Raise specific exceptions** in decode paths:
|
||||||
|
- Magic bytes mismatch → "Not a Stegasoo image or wrong mode (LSB/DCT)"
|
||||||
|
- RS decode failure → "Image corrupted beyond repair"
|
||||||
|
- AES-GCM auth fail → "Wrong credentials (photo/passphrase/PIN)"
|
||||||
|
- Header parse fail → "Invalid or corrupted header"
|
||||||
|
- No stego data → "No hidden data found in image"
|
||||||
|
|
||||||
|
3. **Error codes** for programmatic handling:
|
||||||
|
```python
|
||||||
|
class ErrorCode(Enum):
|
||||||
|
INVALID_MAGIC = "invalid_magic"
|
||||||
|
DECRYPTION_FAILED = "decryption_failed"
|
||||||
|
RS_FAILED = "rs_failed"
|
||||||
|
# etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web UI Level (`frontends/web/`)
|
||||||
|
|
||||||
|
1. **app.py** - Catch specific exceptions, return error type:
|
||||||
|
```python
|
||||||
|
except InvalidMagicBytesError:
|
||||||
|
flash("This doesn't appear to be a Stegasoo image, or mode mismatch", "danger")
|
||||||
|
except DecryptionError:
|
||||||
|
flash("Wrong credentials - check reference photo, passphrase, and PIN", "warning")
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **decode.html** - Error-specific help text:
|
||||||
|
- Wrong credentials → "Double-check your reference photo matches exactly"
|
||||||
|
- Corrupted → "Image may have been re-saved or compressed"
|
||||||
|
- Mode mismatch → "Try switching between Auto/DCT/LSB"
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
- `src/stegasoo/__init__.py` (export exceptions)
|
||||||
|
- `src/stegasoo/exceptions.py` (new file)
|
||||||
|
- `src/stegasoo/dct_steganography.py`
|
||||||
|
- `src/stegasoo/steganography.py` (LSB)
|
||||||
|
- `frontends/web/app.py`
|
||||||
|
- `frontends/web/templates/decode.html`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mobile-Responsive Polish
|
||||||
|
|
||||||
|
**Status:** Planned
|
||||||
|
|
||||||
|
**Problem:** UI works on mobile but has rough edges - cramped buttons, hard-to-tap targets, awkward layouts on small screens.
|
||||||
|
|
||||||
|
**Solution:** Targeted CSS/layout fixes for mobile breakpoints
|
||||||
|
|
||||||
|
### Areas to Improve
|
||||||
|
|
||||||
|
1. **Encode/Decode Forms:**
|
||||||
|
- Stack image drop zones vertically on mobile (currently side-by-side)
|
||||||
|
- Larger touch targets for file inputs
|
||||||
|
- Full-width buttons on small screens
|
||||||
|
- Passphrase input readable at smaller sizes
|
||||||
|
|
||||||
|
2. **Navigation:**
|
||||||
|
- Hamburger menu for mobile navbar (if not already)
|
||||||
|
- Sticky header doesn't eat too much screen
|
||||||
|
- Easy thumb reach for main actions
|
||||||
|
|
||||||
|
3. **Results/Output:**
|
||||||
|
- Download buttons full-width on mobile
|
||||||
|
- QR codes sized appropriately
|
||||||
|
- Click-to-copy message box works well with touch
|
||||||
|
|
||||||
|
4. **Drop Zones:**
|
||||||
|
- Larger tap targets
|
||||||
|
- Visual feedback for touch (not just hover)
|
||||||
|
- Camera integration hint on mobile ("Tap to take photo or choose file")
|
||||||
|
|
||||||
|
### Testing Targets
|
||||||
|
- iPhone SE (small)
|
||||||
|
- iPhone 14 (medium)
|
||||||
|
- iPad (tablet)
|
||||||
|
- Android Chrome
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
- `frontends/web/static/css/style.css` (or new mobile.css)
|
||||||
|
- `frontends/web/templates/encode.html`
|
||||||
|
- `frontends/web/templates/decode.html`
|
||||||
|
- `frontends/web/templates/base.html` (navbar)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Progress bar works on localhost
|
||||||
|
- [ ] Progress bar works on Pi (slower, more visible)
|
||||||
|
- [ ] Cancellation handling (what if user navigates away?)
|
||||||
|
- [ ] Error states display correctly
|
||||||
|
- [ ] Smoke test passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Forced First-Login Setup
|
||||||
|
|
||||||
|
**Status:** Planned
|
||||||
|
|
||||||
|
**Problem:** Users can navigate the app without creating an admin account first. Should force password setup before anything else.
|
||||||
|
|
||||||
|
**Solution:** Middleware/decorator that redirects to setup page if no users exist.
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
- `frontends/web/app.py` (add before_request check)
|
||||||
|
- `frontends/web/templates/setup.html` (ensure it blocks other nav)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dropzone UX Fixes
|
||||||
|
|
||||||
|
**Status:** Planned
|
||||||
|
|
||||||
|
**Problem:** Dropzone has some interaction bugs:
|
||||||
|
- Dropzone doesn't clear properly if first QR image fails
|
||||||
|
- Can't click on image preview to replace file (have to click surrounding border)
|
||||||
|
|
||||||
|
**Solution:** Fix JS event handling and state management
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
- `frontends/web/static/js/stegasoo.js`
|
||||||
|
- `frontends/web/static/css/style.css` (clickable preview)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Smoke Test Benchmarking
|
||||||
|
|
||||||
|
**Status:** Planned
|
||||||
|
|
||||||
|
**Problem:** No way to measure encode/decode performance or track regressions.
|
||||||
|
|
||||||
|
**Solution:** Add timing to smoke tests using `hyperfine` or `time`.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Benchmark encode/decode on test images
|
||||||
|
- Output timing stats (min/max/avg)
|
||||||
|
- Optional `--benchmark` flag for smoke-test.sh
|
||||||
|
- Compare NVMe vs SD card, overclocked vs stock
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
- `rpi/smoke-test.sh`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Keep 4.1.2 focused - 6 small features
|
||||||
|
- Don't break DCT compatibility (4.1.1 RS format is stable)
|
||||||
|
- Test on Pi before release
|
||||||
42
PLAN-4.1.3.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Stegasoo 4.1.3 Plan
|
||||||
|
|
||||||
|
## Release Theme
|
||||||
|
Performance and admin features.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. DCT Performance Optimizations
|
||||||
|
|
||||||
|
**Status:** Planned
|
||||||
|
|
||||||
|
**Problem:** DCT encode/decode can be slow on Pi, especially for large images.
|
||||||
|
|
||||||
|
**Ideas:**
|
||||||
|
- Vectorize block processing with NumPy
|
||||||
|
- Reduce Python loop overhead
|
||||||
|
- Parallel block processing (multiprocessing?)
|
||||||
|
- Profile and identify bottlenecks
|
||||||
|
- Consider Cython for hot paths
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. User Management UI
|
||||||
|
|
||||||
|
**Status:** Planned
|
||||||
|
|
||||||
|
**Problem:** No way for admin to manage users via UI. Currently need direct DB access.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- List all users
|
||||||
|
- Create new user (admin only)
|
||||||
|
- Delete user (admin only)
|
||||||
|
- Reset user password
|
||||||
|
- User activity/last login
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- These are heavier lifts than 4.1.2
|
||||||
|
- Profile before optimizing
|
||||||
|
- Consider security implications of user management
|
||||||
261
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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -135,6 +151,19 @@ On first run with HTTPS enabled:
|
|||||||
|
|
||||||
**Note:** Browsers will show a security warning for self-signed certificates. This is expected for home network use.
|
**Note:** Browsers will show a security warning for self-signed certificates. This is expected for home network use.
|
||||||
|
|
||||||
|
**Tip:** To avoid browser warnings, use [mkcert](https://github.com/FiloSottile/mkcert) to generate locally-trusted certificates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install mkcert and create local CA (one-time)
|
||||||
|
mkcert -install
|
||||||
|
|
||||||
|
# Generate trusted certs for your Pi
|
||||||
|
mkcert -key-file key.pem -cert-file cert.pem stegasoo.local localhost 127.0.0.1 YOUR_PI_IP
|
||||||
|
|
||||||
|
# Copy to certs directory
|
||||||
|
mv key.pem cert.pem frontends/web/certs/
|
||||||
|
```
|
||||||
|
|
||||||
### Disabling Authentication
|
### Disabling Authentication
|
||||||
|
|
||||||
For development or trusted networks:
|
For development or trusted networks:
|
||||||
@@ -169,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
|
||||||
@@ -536,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`
|
||||||
@@ -543,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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
170
check_scipy.py
@@ -1,170 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Diagnostic script to check for scipy/numpy issues.
|
|
||||||
Run this BEFORE starting the web app.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python check_scipy.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
print(f"Python version: {sys.version}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Check numpy
|
|
||||||
try:
|
|
||||||
import numpy as np
|
|
||||||
print(f"NumPy version: {np.__version__}")
|
|
||||||
print(f"NumPy config:")
|
|
||||||
np.show_config()
|
|
||||||
except ImportError as e:
|
|
||||||
print(f"NumPy not installed: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"NumPy error: {e}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("-" * 50)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Check scipy
|
|
||||||
try:
|
|
||||||
import scipy
|
|
||||||
print(f"SciPy version: {scipy.__version__}")
|
|
||||||
except ImportError as e:
|
|
||||||
print(f"SciPy not installed: {e}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Check PIL
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
print(f"Pillow version: {Image.__version__}")
|
|
||||||
except ImportError as e:
|
|
||||||
print(f"Pillow not installed: {e}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("-" * 50)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Test scipy DCT directly
|
|
||||||
print("Testing scipy DCT...")
|
|
||||||
try:
|
|
||||||
from scipy.fftpack import dct, idct
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
# Create test array
|
|
||||||
test = np.random.rand(8, 8).astype(np.float64)
|
|
||||||
print(f"Input array shape: {test.shape}, dtype: {test.dtype}")
|
|
||||||
|
|
||||||
# Test 1D DCT
|
|
||||||
row = test[0, :]
|
|
||||||
result = dct(row, norm='ortho')
|
|
||||||
print(f"1D DCT result shape: {result.shape}, dtype: {result.dtype}")
|
|
||||||
|
|
||||||
# Test 2D DCT (the potentially problematic operation)
|
|
||||||
result2d = dct(dct(test.T, norm='ortho').T, norm='ortho')
|
|
||||||
print(f"2D DCT result shape: {result2d.shape}, dtype: {result2d.dtype}")
|
|
||||||
|
|
||||||
# Test inverse
|
|
||||||
recovered = idct(idct(result2d.T, norm='ortho').T, norm='ortho')
|
|
||||||
error = np.max(np.abs(test - recovered))
|
|
||||||
print(f"Round-trip error: {error}")
|
|
||||||
|
|
||||||
if error < 1e-10:
|
|
||||||
print("✓ scipy DCT working correctly")
|
|
||||||
else:
|
|
||||||
print("⚠ scipy DCT has precision issues")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ scipy DCT failed: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("-" * 50)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Test with larger array (more like real image processing)
|
|
||||||
print("Testing with larger arrays (512x512)...")
|
|
||||||
try:
|
|
||||||
from scipy.fftpack import dct, idct
|
|
||||||
import numpy as np
|
|
||||||
import gc
|
|
||||||
|
|
||||||
# Simulate processing many 8x8 blocks
|
|
||||||
large_array = np.random.rand(512, 512).astype(np.float64)
|
|
||||||
print(f"Large array shape: {large_array.shape}, size: {large_array.nbytes} bytes")
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for y in range(0, 512, 8):
|
|
||||||
for x in range(0, 512, 8):
|
|
||||||
block = large_array[y:y+8, x:x+8].copy()
|
|
||||||
dct_block = dct(dct(block.T, norm='ortho').T, norm='ortho')
|
|
||||||
recovered = idct(idct(dct_block.T, norm='ortho').T, norm='ortho')
|
|
||||||
large_array[y:y+8, x:x+8] = recovered
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
print(f"Processed {count} blocks successfully")
|
|
||||||
|
|
||||||
del large_array
|
|
||||||
gc.collect()
|
|
||||||
|
|
||||||
print("✓ Large array processing completed")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Large array processing failed: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("-" * 50)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Test PIL with large image
|
|
||||||
print("Testing PIL with large image...")
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
import io
|
|
||||||
|
|
||||||
# Create a large test image
|
|
||||||
img = Image.new('RGB', (4000, 3000), color=(128, 128, 128))
|
|
||||||
|
|
||||||
# Save to bytes
|
|
||||||
buffer = io.BytesIO()
|
|
||||||
img.save(buffer, format='PNG')
|
|
||||||
img_bytes = buffer.getvalue()
|
|
||||||
print(f"Test image size: {len(img_bytes)} bytes")
|
|
||||||
|
|
||||||
# Re-open and process
|
|
||||||
buffer2 = io.BytesIO(img_bytes)
|
|
||||||
img2 = Image.open(buffer2)
|
|
||||||
print(f"Re-opened image: {img2.size}, mode: {img2.mode}")
|
|
||||||
|
|
||||||
# Convert to numpy array
|
|
||||||
import numpy as np
|
|
||||||
arr = np.array(img2)
|
|
||||||
print(f"NumPy array: {arr.shape}, dtype: {arr.dtype}")
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
img.close()
|
|
||||||
img2.close()
|
|
||||||
buffer.close()
|
|
||||||
buffer2.close()
|
|
||||||
del arr
|
|
||||||
gc.collect()
|
|
||||||
|
|
||||||
print("✓ PIL large image test completed")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ PIL test failed: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 50)
|
|
||||||
print("Diagnostics complete")
|
|
||||||
print()
|
|
||||||
print("If no errors above but web app still crashes, try:")
|
|
||||||
print("1. pip install --upgrade scipy numpy pillow")
|
|
||||||
print("2. pip install scipy==1.11.4 numpy==1.26.4 # Known stable versions")
|
|
||||||
print("3. Check if using conda vs pip (mixing can cause issues)")
|
|
||||||
BIN
data/WebUI.webp
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 37 KiB |
BIN
data/WebUI_About.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
data/WebUI_Account.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 32 KiB |
BIN
data/WebUI_Login.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
data/WebUI_Setup.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
361
docs/TEMPLATES.md
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
# Stegasoo Web Templates Specification
|
||||||
|
|
||||||
|
Quick reference for all Jinja2 templates in `frontends/web/templates/`.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Layout](#layout)
|
||||||
|
- [Auth & Setup](#auth--setup)
|
||||||
|
- [Core Features](#core-features)
|
||||||
|
- [Tools & Account](#tools--account)
|
||||||
|
- [Admin](#admin)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
### `base.html`
|
||||||
|
**Purpose:** Master layout template - all pages extend this.
|
||||||
|
|
||||||
|
| Block | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `{% block title %}` | Page title |
|
||||||
|
| `{% block content %}` | Main page content |
|
||||||
|
| `{% block scripts %}` | Page-specific JS |
|
||||||
|
|
||||||
|
**Key Elements:**
|
||||||
|
- `nav.navbar` - Bootstrap 5 navbar with logo, links, auth buttons
|
||||||
|
- `div.toast-container` - Flash message toasts (10s auto-dismiss)
|
||||||
|
- `main.container` - Content wrapper
|
||||||
|
- `footer` - Copyright + version
|
||||||
|
|
||||||
|
**Variables:** `is_authenticated`, `username`, `is_admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth & Setup
|
||||||
|
|
||||||
|
### `login.html`
|
||||||
|
**Route:** `/login`
|
||||||
|
|
||||||
|
**Form:** `POST /login`
|
||||||
|
- `username` - text input
|
||||||
|
- `password` - password input
|
||||||
|
- "Forgot password?" link to `/recover`
|
||||||
|
|
||||||
|
**JS:** `static/js/auth.js` - password toggle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `setup.html`
|
||||||
|
**Route:** `/setup` (first-run only)
|
||||||
|
|
||||||
|
**Form:** `POST /setup`
|
||||||
|
- `username` - admin username
|
||||||
|
- `password` - password (min 8 chars)
|
||||||
|
- `password_confirm` - confirmation
|
||||||
|
|
||||||
|
**JS:** Password confirmation validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `setup_recovery.html`
|
||||||
|
**Route:** `/setup/recovery`
|
||||||
|
|
||||||
|
**Form:** `POST /setup/recovery`
|
||||||
|
- `recovery_key` - hidden, pre-generated
|
||||||
|
- `action` - "save" or "skip"
|
||||||
|
- Checkbox confirmation required for save
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Recovery key display (readonly input)
|
||||||
|
- Copy to clipboard button
|
||||||
|
- QR code image (if available)
|
||||||
|
- Download options: text file, QR image
|
||||||
|
- Stego backup upload form
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `recover.html`
|
||||||
|
**Route:** `/recover`
|
||||||
|
|
||||||
|
**Form:** `POST /recover`
|
||||||
|
- `recovery_key` - textarea for key input
|
||||||
|
- `new_password` - new password
|
||||||
|
- `new_password_confirm` - confirmation
|
||||||
|
|
||||||
|
**Accordion:** "Extract from stego backup"
|
||||||
|
- `POST /recover/stego` with `stego_image` + `reference_image`
|
||||||
|
- Pre-fills recovery key on success
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `regenerate_recovery.html`
|
||||||
|
**Route:** `/account/recovery/regenerate` (admin only)
|
||||||
|
|
||||||
|
**Form:** `POST /account/recovery/regenerate`
|
||||||
|
- `recovery_key` - hidden field
|
||||||
|
- `action` - "save" or "cancel"
|
||||||
|
- Confirmation checkbox
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- New key display
|
||||||
|
- QR code (obfuscated)
|
||||||
|
- Download: text, QR, stego backup
|
||||||
|
- Warning if replacing existing key
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
### `index.html`
|
||||||
|
**Route:** `/`
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
- Hero section with tagline
|
||||||
|
- 3 action cards: Encode, Decode, Generate
|
||||||
|
- "How It Works" explainer section
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `generate.html`
|
||||||
|
**Route:** `/generate`
|
||||||
|
|
||||||
|
**Form:** `POST /generate`
|
||||||
|
- `words` - passphrase word count (3-12)
|
||||||
|
- `use_pin` - checkbox
|
||||||
|
- `pin_length` - PIN digits (6-9)
|
||||||
|
- `use_rsa` - checkbox
|
||||||
|
- `rsa_bits` - key size (2048/3072/4096)
|
||||||
|
|
||||||
|
**Output panels:**
|
||||||
|
- Passphrase display
|
||||||
|
- PIN display (if enabled)
|
||||||
|
- RSA key + QR (if enabled)
|
||||||
|
- Entropy calculator
|
||||||
|
|
||||||
|
**JS:** `static/js/generate.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `encode.html`
|
||||||
|
**Route:** `/encode`
|
||||||
|
|
||||||
|
**Form:** `POST /encode` (multipart)
|
||||||
|
- `reference_photo` - file upload (drag-drop zone)
|
||||||
|
- `carrier_image` - file upload (drag-drop zone)
|
||||||
|
- `mode` - radio: DCT (default) / LSB
|
||||||
|
- `dct_format` - PNG / JPEG
|
||||||
|
- `dct_color` - Color / Grayscale
|
||||||
|
- `payload_type` - radio: Text / File
|
||||||
|
- `message` - textarea (if text)
|
||||||
|
- `embed_file` - file input (if file)
|
||||||
|
- `passphrase` - text input
|
||||||
|
- `pin` - text input
|
||||||
|
- `rsa_key` / `rsa_key_qr` - file inputs
|
||||||
|
- `rsa_key_password` - password
|
||||||
|
- `channel_key` - select (saved keys) or manual input
|
||||||
|
|
||||||
|
**Panels:**
|
||||||
|
- Reference preview with "Hash Acquired" status
|
||||||
|
- Carrier preview with capacity info
|
||||||
|
- Character counter for message
|
||||||
|
|
||||||
|
**JS:** `static/js/encode.js`, `static/js/stegasoo.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `encode_result.html`
|
||||||
|
**Route:** `/encode/result/<file_id>`
|
||||||
|
|
||||||
|
**Elements:**
|
||||||
|
- Success message
|
||||||
|
- Stego image preview
|
||||||
|
- Download button
|
||||||
|
- Share button (Web Share API)
|
||||||
|
- Mode/capacity info
|
||||||
|
- "Encode Another" link
|
||||||
|
|
||||||
|
**Variables:** `file_id`, `filename`, `mode`, `capacity_used`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `decode.html`
|
||||||
|
**Route:** `/decode`
|
||||||
|
|
||||||
|
**Form:** `POST /decode` (multipart)
|
||||||
|
- `reference_photo` - file upload
|
||||||
|
- `stego_image` - file upload
|
||||||
|
- `passphrase` - text input
|
||||||
|
- `pin` - text input
|
||||||
|
- `rsa_key` / `rsa_key_qr` - file inputs
|
||||||
|
- `rsa_key_password` - password
|
||||||
|
- `channel_key` - select or manual
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
- Decoded message display
|
||||||
|
- File download (if file payload)
|
||||||
|
|
||||||
|
**JS:** `static/js/decode.js`, `static/js/stegasoo.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools & Account
|
||||||
|
|
||||||
|
### `tools.html`
|
||||||
|
**Route:** `/tools`
|
||||||
|
|
||||||
|
**Tabbed interface:**
|
||||||
|
|
||||||
|
| Tab | Endpoint | Description |
|
||||||
|
|-----|----------|-------------|
|
||||||
|
| Capacity | `POST /api/tools/capacity` | Image capacity analysis |
|
||||||
|
| Peek | `POST /api/tools/peek` | Check for Stegasoo header |
|
||||||
|
| Strip | `POST /api/tools/strip` | Remove hidden data |
|
||||||
|
| EXIF | `POST /api/tools/exif/*` | Metadata viewer/editor |
|
||||||
|
|
||||||
|
**EXIF Editor features:**
|
||||||
|
- Upload image → view all EXIF fields
|
||||||
|
- Inline editing (click field to edit)
|
||||||
|
- "Clear All" button
|
||||||
|
- "Save" / "Download" buttons
|
||||||
|
|
||||||
|
**JS:** `static/js/tools.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `account.html`
|
||||||
|
**Route:** `/account`
|
||||||
|
|
||||||
|
**Sections:**
|
||||||
|
|
||||||
|
1. **User Info** - Username, role badge, logout link
|
||||||
|
|
||||||
|
2. **Recovery Key** (admin only)
|
||||||
|
- Status: Configured / Not Set
|
||||||
|
- Generate/Regenerate button
|
||||||
|
- Disable button
|
||||||
|
|
||||||
|
3. **Password Change**
|
||||||
|
- `current_password`
|
||||||
|
- `new_password`
|
||||||
|
- `new_password_confirm`
|
||||||
|
|
||||||
|
4. **Saved Channel Keys**
|
||||||
|
- List of saved keys with edit/delete
|
||||||
|
- "Add Key" form (name + key)
|
||||||
|
- Max 10 keys per user
|
||||||
|
|
||||||
|
**Variables:** `username`, `is_admin`, `has_recovery`, `channel_keys`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `about.html`
|
||||||
|
**Route:** `/about`
|
||||||
|
|
||||||
|
**Sections:**
|
||||||
|
- Version info + feature badges
|
||||||
|
- Security model explanation
|
||||||
|
- Channel key QR (if configured)
|
||||||
|
- Dependency status table
|
||||||
|
- Credits + links
|
||||||
|
|
||||||
|
**Variables:** `version`, `has_dct`, `has_qr_write`, `has_qr_read`, `channel_key`, `channel_qr`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
|
||||||
|
### `admin/users.html`
|
||||||
|
**Route:** `/admin/users`
|
||||||
|
|
||||||
|
**Table columns:** Username | Role | Created | Actions
|
||||||
|
|
||||||
|
**Actions per user:**
|
||||||
|
- Reset Password button
|
||||||
|
- Delete button (disabled for self)
|
||||||
|
|
||||||
|
**Header:**
|
||||||
|
- User count: "X of 16 users"
|
||||||
|
- "Add User" button (modal trigger)
|
||||||
|
|
||||||
|
**Modal:** Add User form
|
||||||
|
- `username` input
|
||||||
|
- `role` select (admin/user)
|
||||||
|
- Auto-generated temp password display
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `admin/user_new.html`
|
||||||
|
**Route:** `/admin/users/new`
|
||||||
|
|
||||||
|
**Form:** `POST /admin/users/new`
|
||||||
|
- `username` - text input
|
||||||
|
- `role` - select (user/admin)
|
||||||
|
|
||||||
|
Redirects to `user_created.html` on success.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `admin/user_created.html`
|
||||||
|
**Route:** `/admin/users/created`
|
||||||
|
|
||||||
|
**Display:**
|
||||||
|
- Success message
|
||||||
|
- Username
|
||||||
|
- Temporary password (copy button)
|
||||||
|
- "User must change password on first login" notice
|
||||||
|
- Back to users link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `admin/password_reset.html`
|
||||||
|
**Route:** `/admin/users/<id>/password-reset`
|
||||||
|
|
||||||
|
**Display:**
|
||||||
|
- Success message
|
||||||
|
- New temporary password
|
||||||
|
- Copy button
|
||||||
|
- Back link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Drag-Drop Upload Zones
|
||||||
|
```html
|
||||||
|
<div class="upload-zone" id="referenceZone">
|
||||||
|
<input type="file" name="reference_photo" accept="image/*">
|
||||||
|
<div class="preview"></div>
|
||||||
|
<div class="status"></div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Password Toggle
|
||||||
|
```html
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" id="passwordInput">
|
||||||
|
<button onclick="togglePassword('passwordInput', this)">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toast Flash Messages
|
||||||
|
Rendered in `base.html`, auto-dismiss after 10 seconds:
|
||||||
|
- `success` → green
|
||||||
|
- `warning` → yellow
|
||||||
|
- `error` → red
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External JS Files
|
||||||
|
|
||||||
|
| File | Used By |
|
||||||
|
|------|---------|
|
||||||
|
| `static/js/stegasoo.js` | encode, decode, about |
|
||||||
|
| `static/js/auth.js` | login, setup, recover, account |
|
||||||
|
| `static/js/generate.js` | generate |
|
||||||
|
| `static/js/encode.js` | encode |
|
||||||
|
| `static/js/decode.js` | decode |
|
||||||
|
| `static/js/tools.js` | tools |
|
||||||
@@ -1,500 +0,0 @@
|
|||||||
# API Update Summary for v3.2.0
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The FastAPI REST API has been updated to align with Stegasoo v3.2.0's breaking changes:
|
|
||||||
1. **Removed date dependency** - No `date_str` field in requests
|
|
||||||
2. **Renamed day_phrase → passphrase** - Updated all request/response models
|
|
||||||
3. **Updated generation** - Now generates single passphrase instead of daily phrases
|
|
||||||
|
|
||||||
## Breaking Changes
|
|
||||||
|
|
||||||
### Request Model Changes
|
|
||||||
|
|
||||||
#### 1. EncodeRequest & EncodeFileRequest
|
|
||||||
|
|
||||||
**Before (v3.1.0):**
|
|
||||||
```python
|
|
||||||
class EncodeRequest(BaseModel):
|
|
||||||
message: str
|
|
||||||
reference_photo_base64: str
|
|
||||||
carrier_image_base64: str
|
|
||||||
day_phrase: str # ← Changed to passphrase
|
|
||||||
pin: str = ""
|
|
||||||
rsa_key_base64: Optional[str] = None
|
|
||||||
rsa_password: Optional[str] = None
|
|
||||||
date_str: Optional[str] = None # ← REMOVED
|
|
||||||
embed_mode: EmbedModeType = "lsb"
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (v3.2.0):**
|
|
||||||
```python
|
|
||||||
class EncodeRequest(BaseModel):
|
|
||||||
message: str
|
|
||||||
reference_photo_base64: str
|
|
||||||
carrier_image_base64: str
|
|
||||||
passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
|
|
||||||
pin: str = ""
|
|
||||||
rsa_key_base64: Optional[str] = None
|
|
||||||
rsa_password: Optional[str] = None
|
|
||||||
# date_str removed in v3.2.0
|
|
||||||
embed_mode: EmbedModeType = "lsb"
|
|
||||||
dct_output_format: DctOutputFormatType = "png"
|
|
||||||
dct_color_mode: DctColorModeType = "grayscale"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. DecodeRequest
|
|
||||||
|
|
||||||
**Before (v3.1.0):**
|
|
||||||
```python
|
|
||||||
class DecodeRequest(BaseModel):
|
|
||||||
stego_image_base64: str
|
|
||||||
reference_photo_base64: str
|
|
||||||
day_phrase: str # ← Changed to passphrase
|
|
||||||
pin: str = ""
|
|
||||||
rsa_key_base64: Optional[str] = None
|
|
||||||
rsa_password: Optional[str] = None
|
|
||||||
embed_mode: ExtractModeType = "auto"
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (v3.2.0):**
|
|
||||||
```python
|
|
||||||
class DecodeRequest(BaseModel):
|
|
||||||
stego_image_base64: str
|
|
||||||
reference_photo_base64: str
|
|
||||||
passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
|
|
||||||
pin: str = ""
|
|
||||||
rsa_key_base64: Optional[str] = None
|
|
||||||
rsa_password: Optional[str] = None
|
|
||||||
embed_mode: ExtractModeType = "auto"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. GenerateRequest
|
|
||||||
|
|
||||||
**Before (v3.1.0):**
|
|
||||||
```python
|
|
||||||
class GenerateRequest(BaseModel):
|
|
||||||
use_pin: bool = True
|
|
||||||
use_rsa: bool = False
|
|
||||||
pin_length: int = Field(default=6, ge=MIN_PIN_LENGTH, le=MAX_PIN_LENGTH)
|
|
||||||
rsa_bits: int = Field(default=2048)
|
|
||||||
words_per_phrase: int = Field(default=3, ge=MIN_PHRASE_WORDS, le=MAX_PHRASE_WORDS)
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (v3.2.0):**
|
|
||||||
```python
|
|
||||||
class GenerateRequest(BaseModel):
|
|
||||||
use_pin: bool = True
|
|
||||||
use_rsa: bool = False
|
|
||||||
pin_length: int = Field(default=6, ge=MIN_PIN_LENGTH, le=MAX_PIN_LENGTH)
|
|
||||||
rsa_bits: int = Field(default=2048)
|
|
||||||
words_per_passphrase: int = Field(
|
|
||||||
default=DEFAULT_PASSPHRASE_WORDS, # = 4, was 3
|
|
||||||
ge=MIN_PASSPHRASE_WORDS,
|
|
||||||
le=MAX_PASSPHRASE_WORDS,
|
|
||||||
description="Words per passphrase (v3.2.0: default increased to 4)"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response Model Changes
|
|
||||||
|
|
||||||
#### 1. GenerateResponse
|
|
||||||
|
|
||||||
**Before (v3.1.0):**
|
|
||||||
```python
|
|
||||||
class GenerateResponse(BaseModel):
|
|
||||||
phrases: dict[str, str] # Monday -> phrase, Tuesday -> phrase, etc.
|
|
||||||
pin: Optional[str] = None
|
|
||||||
rsa_key_pem: Optional[str] = None
|
|
||||||
entropy: dict[str, int]
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (v3.2.0):**
|
|
||||||
```python
|
|
||||||
class GenerateResponse(BaseModel):
|
|
||||||
passphrase: str = Field(description="Single passphrase (v3.2.0: no daily rotation)")
|
|
||||||
pin: Optional[str] = None
|
|
||||||
rsa_key_pem: Optional[str] = None
|
|
||||||
entropy: dict[str, int]
|
|
||||||
# Legacy field for compatibility
|
|
||||||
phrases: Optional[dict[str, str]] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Deprecated: Use 'passphrase' instead"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. EncodeResponse
|
|
||||||
|
|
||||||
**Before (v3.1.0):**
|
|
||||||
```python
|
|
||||||
class EncodeResponse(BaseModel):
|
|
||||||
stego_image_base64: str
|
|
||||||
filename: str
|
|
||||||
capacity_used_percent: float
|
|
||||||
date_used: str
|
|
||||||
day_of_week: str
|
|
||||||
embed_mode: str
|
|
||||||
output_format: str = "png"
|
|
||||||
color_mode: str = "color"
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (v3.2.0):**
|
|
||||||
```python
|
|
||||||
class EncodeResponse(BaseModel):
|
|
||||||
stego_image_base64: str
|
|
||||||
filename: str
|
|
||||||
capacity_used_percent: float
|
|
||||||
embed_mode: str
|
|
||||||
output_format: str = "png"
|
|
||||||
color_mode: str = "color"
|
|
||||||
# Legacy fields (no longer used in crypto)
|
|
||||||
date_used: Optional[str] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Deprecated: Date no longer used in v3.2.0"
|
|
||||||
)
|
|
||||||
day_of_week: Optional[str] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Deprecated: Date no longer used in v3.2.0"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Endpoint Changes
|
|
||||||
|
|
||||||
#### 1. POST /encode
|
|
||||||
|
|
||||||
**Before (v3.1.0):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Secret message",
|
|
||||||
"reference_photo_base64": "...",
|
|
||||||
"carrier_image_base64": "...",
|
|
||||||
"day_phrase": "apple forest thunder",
|
|
||||||
"date_str": "2025-01-15",
|
|
||||||
"pin": "123456",
|
|
||||||
"embed_mode": "lsb"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (v3.2.0):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Secret message",
|
|
||||||
"reference_photo_base64": "...",
|
|
||||||
"carrier_image_base64": "...",
|
|
||||||
"passphrase": "apple forest thunder mountain",
|
|
||||||
"pin": "123456",
|
|
||||||
"embed_mode": "lsb"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. POST /decode
|
|
||||||
|
|
||||||
**Before (v3.1.0):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"stego_image_base64": "...",
|
|
||||||
"reference_photo_base64": "...",
|
|
||||||
"day_phrase": "apple forest thunder",
|
|
||||||
"pin": "123456",
|
|
||||||
"embed_mode": "auto"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (v3.2.0):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"stego_image_base64": "...",
|
|
||||||
"reference_photo_base64": "...",
|
|
||||||
"passphrase": "apple forest thunder mountain",
|
|
||||||
"pin": "123456",
|
|
||||||
"embed_mode": "auto"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. POST /generate
|
|
||||||
|
|
||||||
**Response Before (v3.1.0):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"phrases": {
|
|
||||||
"Monday": "apple forest thunder",
|
|
||||||
"Tuesday": "banana river lightning",
|
|
||||||
...
|
|
||||||
},
|
|
||||||
"pin": "123456",
|
|
||||||
"rsa_key_pem": null,
|
|
||||||
"entropy": {
|
|
||||||
"phrase": 33,
|
|
||||||
"pin": 20,
|
|
||||||
"rsa": 0,
|
|
||||||
"total": 53
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response After (v3.2.0):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"passphrase": "apple forest thunder mountain",
|
|
||||||
"pin": "123456",
|
|
||||||
"rsa_key_pem": null,
|
|
||||||
"entropy": {
|
|
||||||
"passphrase": 44,
|
|
||||||
"pin": 20,
|
|
||||||
"rsa": 0,
|
|
||||||
"total": 64
|
|
||||||
},
|
|
||||||
"phrases": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. POST /encode/multipart
|
|
||||||
|
|
||||||
**Form Fields Before (v3.1.0):**
|
|
||||||
- `day_phrase` (required)
|
|
||||||
- `date_str` (optional)
|
|
||||||
- `reference_photo` (file)
|
|
||||||
- `carrier` (file)
|
|
||||||
- ...
|
|
||||||
|
|
||||||
**Form Fields After (v3.2.0):**
|
|
||||||
- `passphrase` (required) ← renamed from day_phrase
|
|
||||||
- `reference_photo` (file)
|
|
||||||
- `carrier` (file)
|
|
||||||
- ... (date_str removed)
|
|
||||||
|
|
||||||
**Response Headers Before (v3.1.0):**
|
|
||||||
```
|
|
||||||
X-Stegasoo-Date: 2025-01-15
|
|
||||||
X-Stegasoo-Day: Wednesday
|
|
||||||
X-Stegasoo-Capacity-Percent: 25.5
|
|
||||||
X-Stegasoo-Embed-Mode: lsb
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response Headers After (v3.2.0):**
|
|
||||||
```
|
|
||||||
X-Stegasoo-Capacity-Percent: 25.5
|
|
||||||
X-Stegasoo-Embed-Mode: lsb
|
|
||||||
X-Stegasoo-Output-Format: png
|
|
||||||
X-Stegasoo-Color-Mode: color
|
|
||||||
X-Stegasoo-Version: 3.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### New Status Endpoint Information
|
|
||||||
|
|
||||||
#### GET /
|
|
||||||
|
|
||||||
**Added to response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": "3.2.0",
|
|
||||||
...
|
|
||||||
"breaking_changes": {
|
|
||||||
"date_removed": "No date_str parameter needed - encode/decode anytime",
|
|
||||||
"passphrase_renamed": "day_phrase → passphrase (single passphrase, no daily rotation)",
|
|
||||||
"format_version": 4,
|
|
||||||
"backward_compatible": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration Guide for API Clients
|
|
||||||
|
|
||||||
### 1. Update Request Bodies
|
|
||||||
|
|
||||||
**Find and replace in client code:**
|
|
||||||
```javascript
|
|
||||||
// Before
|
|
||||||
{
|
|
||||||
day_phrase: "apple forest thunder",
|
|
||||||
date_str: "2025-01-15"
|
|
||||||
}
|
|
||||||
|
|
||||||
// After
|
|
||||||
{
|
|
||||||
passphrase: "apple forest thunder mountain"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Update Response Handling
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
const response = await fetch('/encode', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: "secret",
|
|
||||||
day_phrase: "words",
|
|
||||||
date_str: "2025-01-15",
|
|
||||||
...
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log(data.date_used); // "2025-01-15"
|
|
||||||
console.log(data.day_of_week); // "Wednesday"
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
const response = await fetch('/encode', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: "secret",
|
|
||||||
passphrase: "longer words here now",
|
|
||||||
// date_str removed
|
|
||||||
...
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
// date_used and day_of_week are null in v3.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Update Generate Endpoint Usage
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
const creds = await fetch('/generate', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ use_pin: true })
|
|
||||||
}).then(r => r.json());
|
|
||||||
|
|
||||||
// Use Monday's phrase
|
|
||||||
const mondayPhrase = creds.phrases['Monday'];
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
const creds = await fetch('/generate', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ use_pin: true })
|
|
||||||
}).then(r => r.json());
|
|
||||||
|
|
||||||
// Use single passphrase
|
|
||||||
const passphrase = creds.passphrase;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Update Multipart Requests
|
|
||||||
|
|
||||||
**Before (JavaScript fetch):**
|
|
||||||
```javascript
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('day_phrase', 'apple forest thunder');
|
|
||||||
formData.append('date_str', '2025-01-15');
|
|
||||||
formData.append('reference_photo', refPhotoFile);
|
|
||||||
formData.append('carrier', carrierFile);
|
|
||||||
formData.append('message', 'secret');
|
|
||||||
formData.append('pin', '123456');
|
|
||||||
|
|
||||||
const response = await fetch('/encode/multipart', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (JavaScript fetch):**
|
|
||||||
```javascript
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('passphrase', 'apple forest thunder mountain');
|
|
||||||
// date_str removed
|
|
||||||
formData.append('reference_photo', refPhotoFile);
|
|
||||||
formData.append('carrier', carrierFile);
|
|
||||||
formData.append('message', 'secret');
|
|
||||||
formData.append('pin', '123456');
|
|
||||||
|
|
||||||
const response = await fetch('/encode/multipart', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
### Endpoints to Test
|
|
||||||
|
|
||||||
- [ ] GET / - Returns v3.2.0 with breaking_changes info
|
|
||||||
- [ ] GET /modes - Returns mode information
|
|
||||||
- [ ] POST /generate - Returns single passphrase
|
|
||||||
- [ ] POST /encode - Works without date_str
|
|
||||||
- [ ] POST /encode/file - Works without date_str
|
|
||||||
- [ ] POST /decode - Works without date_str
|
|
||||||
- [ ] POST /encode/multipart - Accepts passphrase instead of day_phrase
|
|
||||||
- [ ] POST /decode/multipart - Accepts passphrase instead of day_phrase
|
|
||||||
- [ ] POST /compare - Still works
|
|
||||||
- [ ] POST /will-fit - Still works
|
|
||||||
- [ ] POST /image/info - Still works
|
|
||||||
- [ ] POST /extract-key-from-qr - Still works
|
|
||||||
|
|
||||||
### Validation Tests
|
|
||||||
|
|
||||||
- [ ] Reject requests with `day_phrase` field (should get validation error)
|
|
||||||
- [ ] Reject requests with `date_str` field (should be ignored or error)
|
|
||||||
- [ ] Accept requests with `passphrase` field
|
|
||||||
- [ ] Generate response includes `passphrase` field
|
|
||||||
- [ ] Generate response has `phrases` as null
|
|
||||||
- [ ] Encode response has `date_used` and `day_of_week` as null
|
|
||||||
- [ ] Multipart encode works with new field names
|
|
||||||
- [ ] Response headers updated correctly
|
|
||||||
|
|
||||||
## OpenAPI/Swagger Documentation
|
|
||||||
|
|
||||||
The FastAPI auto-generated documentation (/docs and /redoc) will automatically reflect the changes:
|
|
||||||
|
|
||||||
1. **Models updated** - Request/response schemas show new field names
|
|
||||||
2. **Descriptions updated** - Field descriptions mention v3.2.0 changes
|
|
||||||
3. **Examples updated** - Interactive API explorer uses new field names
|
|
||||||
|
|
||||||
Users can browse to `/docs` to see the updated API specification.
|
|
||||||
|
|
||||||
## Backward Compatibility
|
|
||||||
|
|
||||||
**Breaking Change:** API v3.2.0 is NOT backward compatible with v3.1.0
|
|
||||||
|
|
||||||
Clients using the old API will encounter:
|
|
||||||
1. **Validation errors** - Missing required `passphrase` field
|
|
||||||
2. **Unexpected responses** - `phrases` field will be null
|
|
||||||
3. **Changed behavior** - Date fields no longer populated
|
|
||||||
|
|
||||||
### Migration Timeline Recommendation
|
|
||||||
|
|
||||||
1. **Deploy v3.2.0 API** to staging
|
|
||||||
2. **Update client applications** to use new field names
|
|
||||||
3. **Test thoroughly** with staging API
|
|
||||||
4. **Deploy v3.2.0 API** to production
|
|
||||||
5. **Notify users** of breaking changes
|
|
||||||
|
|
||||||
Alternatively, run v3.1.0 and v3.2.0 APIs side-by-side on different paths:
|
|
||||||
- `/api/v3.1/` - Old API
|
|
||||||
- `/api/v3.2/` - New API
|
|
||||||
|
|
||||||
## Constants Updates
|
|
||||||
|
|
||||||
Used in validation:
|
|
||||||
```python
|
|
||||||
from stegasoo.constants import (
|
|
||||||
MIN_PASSPHRASE_WORDS, # = 3
|
|
||||||
MAX_PASSPHRASE_WORDS, # = 12
|
|
||||||
DEFAULT_PASSPHRASE_WORDS, # = 4 (increased from 3)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Messages
|
|
||||||
|
|
||||||
All error messages updated:
|
|
||||||
- "day_phrase is required" → "passphrase is required"
|
|
||||||
- References to "phrase" now mean "passphrase"
|
|
||||||
|
|
||||||
## Implementation Status
|
|
||||||
|
|
||||||
✅ All request models updated
|
|
||||||
✅ All response models updated
|
|
||||||
✅ All endpoints updated
|
|
||||||
✅ Multipart endpoints updated
|
|
||||||
✅ Status endpoint shows breaking changes
|
|
||||||
✅ Constants imported correctly
|
|
||||||
✅ Error handling updated
|
|
||||||
✅ No references to day_phrase in user-facing text
|
|
||||||
✅ No date_str parameters accepted
|
|
||||||
|
|
||||||
Ready for deployment!
|
|
||||||
@@ -49,7 +49,6 @@ from stegasoo import (
|
|||||||
generate_credentials,
|
generate_credentials,
|
||||||
get_channel_status,
|
get_channel_status,
|
||||||
has_argon2,
|
has_argon2,
|
||||||
has_channel_key,
|
|
||||||
has_dct_support,
|
has_dct_support,
|
||||||
set_channel_key,
|
set_channel_key,
|
||||||
validate_channel_key,
|
validate_channel_key,
|
||||||
@@ -406,11 +405,7 @@ def _resolve_channel_key(channel_key: str | None) -> str | None:
|
|||||||
"""
|
"""
|
||||||
Resolve channel key from API parameter.
|
Resolve channel key from API parameter.
|
||||||
|
|
||||||
Args:
|
Wrapper around library's resolve_channel_key with HTTP exception handling.
|
||||||
channel_key: API parameter value
|
|
||||||
- None: Use server-configured key (auto mode)
|
|
||||||
- "": Public mode (no channel key)
|
|
||||||
- "XXXX-...": Explicit key
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Resolved channel key to pass to encode/decode
|
Resolved channel key to pass to encode/decode
|
||||||
@@ -418,44 +413,27 @@ def _resolve_channel_key(channel_key: str | None) -> str | None:
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: If key format is invalid
|
HTTPException: If key format is invalid
|
||||||
"""
|
"""
|
||||||
if channel_key is None:
|
from stegasoo.channel import resolve_channel_key
|
||||||
# Auto mode - use server config
|
|
||||||
return None
|
|
||||||
|
|
||||||
if channel_key == "":
|
try:
|
||||||
# Public mode
|
return resolve_channel_key(channel_key)
|
||||||
return ""
|
except (ValueError, FileNotFoundError) as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
# Explicit key - validate format
|
|
||||||
if not validate_channel_key(channel_key):
|
|
||||||
raise HTTPException(
|
|
||||||
400, "Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
|
|
||||||
)
|
|
||||||
|
|
||||||
return channel_key
|
|
||||||
|
|
||||||
|
|
||||||
def _get_channel_info(channel_key: str | None) -> tuple[str, str | None]:
|
def _get_channel_info(channel_key: str | None) -> tuple[str, str | None]:
|
||||||
"""
|
"""
|
||||||
Get channel mode and fingerprint for response.
|
Get channel mode and fingerprint for response.
|
||||||
|
|
||||||
|
Uses library's get_channel_response_info for consistent formatting.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(mode, fingerprint) tuple
|
(mode, fingerprint) tuple
|
||||||
"""
|
"""
|
||||||
if channel_key == "":
|
from stegasoo.channel import get_channel_response_info
|
||||||
return "public", None
|
|
||||||
|
|
||||||
if channel_key is not None:
|
info = get_channel_response_info(channel_key)
|
||||||
# Explicit key
|
return info["mode"], info.get("fingerprint")
|
||||||
fingerprint = f"{channel_key[:4]}-••••-••••-••••-••••-••••-••••-{channel_key[-4:]}"
|
|
||||||
return "private", fingerprint
|
|
||||||
|
|
||||||
# Auto mode - check server config
|
|
||||||
if has_channel_key():
|
|
||||||
status = get_channel_status()
|
|
||||||
return "private", status.get("fingerprint")
|
|
||||||
|
|
||||||
return "public", None
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -168,37 +168,25 @@ def resolve_channel_key_option(
|
|||||||
"""
|
"""
|
||||||
Resolve channel key from CLI options.
|
Resolve channel key from CLI options.
|
||||||
|
|
||||||
|
Wrapper around library's resolve_channel_key with Click exception handling.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
None: Use server-configured key (auto mode)
|
None: Use server-configured key (auto mode)
|
||||||
"": Public mode (no channel key)
|
"": Public mode (no channel key)
|
||||||
str: Explicit channel key
|
str: Explicit channel key
|
||||||
"""
|
"""
|
||||||
if no_channel:
|
from stegasoo.channel import resolve_channel_key
|
||||||
return "" # Public mode
|
|
||||||
|
|
||||||
if channel_file:
|
try:
|
||||||
# Load from file
|
return resolve_channel_key(
|
||||||
path = Path(channel_file)
|
value=channel,
|
||||||
if not path.exists():
|
file_path=channel_file,
|
||||||
raise click.ClickException(f"Channel key file not found: {channel_file}")
|
no_channel=no_channel,
|
||||||
key = path.read_text().strip()
|
|
||||||
if not validate_channel_key(key):
|
|
||||||
raise click.ClickException(f"Invalid channel key format in file: {channel_file}")
|
|
||||||
return key
|
|
||||||
|
|
||||||
if channel:
|
|
||||||
if channel.lower() == "auto":
|
|
||||||
return None # Use server config
|
|
||||||
# Explicit key provided
|
|
||||||
if not validate_channel_key(channel):
|
|
||||||
raise click.ClickException(
|
|
||||||
"Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX\n"
|
|
||||||
"Generate a new key with: stegasoo channel generate"
|
|
||||||
)
|
)
|
||||||
return channel
|
except FileNotFoundError as e:
|
||||||
|
raise click.ClickException(str(e))
|
||||||
# Default: use server-configured key (auto mode)
|
except ValueError as e:
|
||||||
return None
|
raise click.ClickException(str(e))
|
||||||
|
|
||||||
|
|
||||||
def format_channel_status_line(quiet: bool = False) -> str | None:
|
def format_channel_status_line(quiet: bool = False) -> str | None:
|
||||||
|
|||||||
16
frontends/web/.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Stegasoo Web UI Configuration
|
||||||
|
# Copy this file to .env and customize
|
||||||
|
|
||||||
|
# Authentication (v4.0.2+)
|
||||||
|
STEGASOO_AUTH_ENABLED=true
|
||||||
|
STEGASOO_HTTPS_ENABLED=false
|
||||||
|
STEGASOO_HOSTNAME=localhost
|
||||||
|
STEGASOO_PORT=5000
|
||||||
|
|
||||||
|
# Channel Key (format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX)
|
||||||
|
# Generate with: stegasoo generate --channel-key
|
||||||
|
# Leave empty for public mode
|
||||||
|
STEGASOO_CHANNEL_KEY=
|
||||||
|
|
||||||
|
# Flask settings
|
||||||
|
FLASK_ENV=production
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
# Web Frontend Update Summary for v3.2.0
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Flask web frontend has been updated to align with Stegasoo v3.2.0's breaking changes:
|
|
||||||
1. **Removed date dependency** - No date selection or tracking in UI
|
|
||||||
2. **Renamed day_phrase → passphrase** - Updated all forms and templates
|
|
||||||
3. **Increased default words** - From 3 to 4 for better security
|
|
||||||
|
|
||||||
## Key Changes
|
|
||||||
|
|
||||||
### 1. Form Parameter Changes
|
|
||||||
|
|
||||||
#### Generate Page
|
|
||||||
|
|
||||||
**Before (v3.1.0):**
|
|
||||||
```python
|
|
||||||
words_per_phrase = int(request.form.get('words_per_phrase', 3))
|
|
||||||
# Generated daily phrases for all days of the week
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (v3.2.0):**
|
|
||||||
```python
|
|
||||||
words_per_passphrase = int(request.form.get('words_per_passphrase', 4))
|
|
||||||
# Generates single passphrase
|
|
||||||
```
|
|
||||||
|
|
||||||
**Template variables changed:**
|
|
||||||
- `phrases` → `passphrase` (single string instead of dict)
|
|
||||||
- `words_per_phrase` → `words_per_passphrase`
|
|
||||||
- `phrase_entropy` → `passphrase_entropy`
|
|
||||||
- Removed `days` variable (no longer needed)
|
|
||||||
|
|
||||||
#### Encode Page
|
|
||||||
|
|
||||||
**Before (v3.1.0):**
|
|
||||||
```python
|
|
||||||
day_phrase = request.form.get('day_phrase', '')
|
|
||||||
client_date = request.form.get('client_date', '').strip()
|
|
||||||
day_of_week = get_today_day() # Used in template
|
|
||||||
|
|
||||||
encode_result = encode(
|
|
||||||
...,
|
|
||||||
day_phrase=day_phrase,
|
|
||||||
date_str=date_str,
|
|
||||||
...
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (v3.2.0):**
|
|
||||||
```python
|
|
||||||
passphrase = request.form.get('passphrase', '')
|
|
||||||
# No client_date or day_of_week needed
|
|
||||||
|
|
||||||
encode_result = encode(
|
|
||||||
...,
|
|
||||||
passphrase=passphrase, # Renamed
|
|
||||||
# date_str removed
|
|
||||||
...
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Decode Page
|
|
||||||
|
|
||||||
**Before (v3.1.0):**
|
|
||||||
```python
|
|
||||||
day_phrase = request.form.get('day_phrase', '')
|
|
||||||
stego_date = request.form.get('stego_date', '').strip()
|
|
||||||
|
|
||||||
decode_result = decode(
|
|
||||||
...,
|
|
||||||
day_phrase=day_phrase,
|
|
||||||
date_str=stego_date if stego_date else None,
|
|
||||||
...
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (v3.2.0):**
|
|
||||||
```python
|
|
||||||
passphrase = request.form.get('passphrase', '')
|
|
||||||
# No stego_date needed
|
|
||||||
|
|
||||||
decode_result = decode(
|
|
||||||
...,
|
|
||||||
passphrase=passphrase, # Renamed
|
|
||||||
# date_str removed
|
|
||||||
...
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Template Context Updates
|
|
||||||
|
|
||||||
**inject_globals() changes:**
|
|
||||||
|
|
||||||
**Added:**
|
|
||||||
```python
|
|
||||||
'min_passphrase_words': MIN_PASSPHRASE_WORDS,
|
|
||||||
'recommended_passphrase_words': RECOMMENDED_PASSPHRASE_WORDS,
|
|
||||||
'default_passphrase_words': DEFAULT_PASSPHRASE_WORDS,
|
|
||||||
```
|
|
||||||
|
|
||||||
**Used for:**
|
|
||||||
- Showing passphrase length requirements
|
|
||||||
- Default values in generate form
|
|
||||||
- Validation messages
|
|
||||||
|
|
||||||
### 3. Validation Updates
|
|
||||||
|
|
||||||
**Added passphrase validation:**
|
|
||||||
```python
|
|
||||||
from stegasoo import validate_passphrase
|
|
||||||
|
|
||||||
# In encode_page()
|
|
||||||
result = validate_passphrase(passphrase)
|
|
||||||
if not result.is_valid:
|
|
||||||
flash(result.error_message, 'error')
|
|
||||||
return ...
|
|
||||||
|
|
||||||
# Show warning if passphrase is short
|
|
||||||
if result.warning:
|
|
||||||
flash(result.warning, 'warning')
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Error Message Updates
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```python
|
|
||||||
flash('Day phrase is required', 'error')
|
|
||||||
flash('Decryption failed. Check your phrase, PIN...', 'error')
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```python
|
|
||||||
flash('Passphrase is required', 'error')
|
|
||||||
flash('Decryption failed. Check your passphrase, PIN...', 'error')
|
|
||||||
```
|
|
||||||
|
|
||||||
## Template Changes Needed
|
|
||||||
|
|
||||||
These Flask routes will need corresponding template updates:
|
|
||||||
|
|
||||||
### generate.html
|
|
||||||
|
|
||||||
**Changes needed:**
|
|
||||||
```html
|
|
||||||
<!-- Before -->
|
|
||||||
<label for="words_per_phrase">Words per phrase</label>
|
|
||||||
<input type="number" name="words_per_phrase" value="3">
|
|
||||||
|
|
||||||
{% if generated %}
|
|
||||||
<h3>Daily Phrases</h3>
|
|
||||||
{% for day in days %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ day }}</td>
|
|
||||||
<td>{{ phrases[day] }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- After -->
|
|
||||||
<label for="words_per_passphrase">Words per passphrase</label>
|
|
||||||
<input type="number" name="words_per_passphrase" value="{{ default_passphrase_words }}">
|
|
||||||
|
|
||||||
{% if generated %}
|
|
||||||
<h3>Passphrase</h3>
|
|
||||||
<div class="passphrase-display">
|
|
||||||
<code>{{ passphrase }}</code>
|
|
||||||
<p class="help-text">Use this passphrase to encode and decode messages (no date needed!)</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Entropy display:**
|
|
||||||
```html
|
|
||||||
<!-- Before -->
|
|
||||||
<li>Phrase entropy: {{ phrase_entropy }} bits</li>
|
|
||||||
|
|
||||||
<!-- After -->
|
|
||||||
<li>Passphrase entropy: {{ passphrase_entropy }} bits ({{ words_per_passphrase }} words)</li>
|
|
||||||
```
|
|
||||||
|
|
||||||
### encode.html
|
|
||||||
|
|
||||||
**Changes needed:**
|
|
||||||
```html
|
|
||||||
<!-- Before -->
|
|
||||||
<label for="day_phrase">Day Phrase</label>
|
|
||||||
<input type="text" name="day_phrase" required>
|
|
||||||
|
|
||||||
<label for="client_date">Encoding Date (Optional)</label>
|
|
||||||
<input type="date" name="client_date">
|
|
||||||
<p class="help-text">Defaults to today: {{ day_of_week }}</p>
|
|
||||||
|
|
||||||
<!-- After -->
|
|
||||||
<label for="passphrase">Passphrase</label>
|
|
||||||
<input type="text" name="passphrase" required
|
|
||||||
placeholder="Enter at least {{ recommended_passphrase_words }} words">
|
|
||||||
<p class="help-text">
|
|
||||||
v3.2.0: No date needed! Use your passphrase anytime.
|
|
||||||
</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
### decode.html
|
|
||||||
|
|
||||||
**Changes needed:**
|
|
||||||
```html
|
|
||||||
<!-- Before -->
|
|
||||||
<label for="day_phrase">Day Phrase</label>
|
|
||||||
<input type="text" name="day_phrase" required>
|
|
||||||
|
|
||||||
<label for="stego_date">Encoding Date</label>
|
|
||||||
<input type="date" name="stego_date" id="stego_date">
|
|
||||||
<p class="help-text">Will be auto-detected from filename if possible</p>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Auto-detect date from filename
|
|
||||||
stegoInput.addEventListener('change', function() {
|
|
||||||
const filename = this.files[0]?.name || '';
|
|
||||||
const dateMatch = filename.match(/_(\d{4})(\d{2})(\d{2})/);
|
|
||||||
if (dateMatch) {
|
|
||||||
document.getElementById('stego_date').value =
|
|
||||||
`${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- After -->
|
|
||||||
<label for="passphrase">Passphrase</label>
|
|
||||||
<input type="text" name="passphrase" required
|
|
||||||
placeholder="Enter your passphrase">
|
|
||||||
<p class="help-text">
|
|
||||||
v3.2.0: No date needed to decode!
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Remove date detection script -->
|
|
||||||
```
|
|
||||||
|
|
||||||
### index.html
|
|
||||||
|
|
||||||
**Changes needed:**
|
|
||||||
```html
|
|
||||||
<!-- Before -->
|
|
||||||
<p>Generate daily passphrases and security credentials</p>
|
|
||||||
<p>Hide messages using day-specific phrases</p>
|
|
||||||
|
|
||||||
<!-- After -->
|
|
||||||
<p>Generate passphrases and security credentials</p>
|
|
||||||
<p>v3.2.0: Simplified - no more daily rotation!</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
### about.html
|
|
||||||
|
|
||||||
**Add v3.2.0 section:**
|
|
||||||
```html
|
|
||||||
<h2>Version 3.2.0 Changes</h2>
|
|
||||||
<ul>
|
|
||||||
<li><strong>No date dependency</strong> - Encode and decode anytime without tracking dates</li>
|
|
||||||
<li><strong>Single passphrase</strong> - No more daily rotation, just remember one strong passphrase</li>
|
|
||||||
<li><strong>Better security</strong> - Default passphrase length increased to 4 words</li>
|
|
||||||
<li><strong>Asynchronous ready</strong> - Perfect for dead drops and delayed delivery</li>
|
|
||||||
</ul>
|
|
||||||
```
|
|
||||||
|
|
||||||
## JavaScript Changes Needed
|
|
||||||
|
|
||||||
### Remove date-related code:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// REMOVE THIS (date detection from filename)
|
|
||||||
function detectDateFromFilename(filename) {
|
|
||||||
const match = filename.match(/_(\d{4})(\d{2})(\d{2})/);
|
|
||||||
if (match) {
|
|
||||||
return `${match[1]}-${match[2]}-${match[3]}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// REMOVE THIS (day-of-week display)
|
|
||||||
function updateDayOfWeek() {
|
|
||||||
const dateInput = document.getElementById('client_date');
|
|
||||||
const dayDisplay = document.getElementById('day_display');
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update validation:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Before
|
|
||||||
const dayPhrase = document.getElementById('day_phrase').value;
|
|
||||||
if (!dayPhrase || dayPhrase.trim().length === 0) {
|
|
||||||
alert('Day phrase is required');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// After
|
|
||||||
const passphrase = document.getElementById('passphrase').value;
|
|
||||||
if (!passphrase || passphrase.trim().length === 0) {
|
|
||||||
alert('Passphrase is required');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add word count validation
|
|
||||||
const words = passphrase.trim().split(/\s+/);
|
|
||||||
if (words.length < {{ min_passphrase_words }}) {
|
|
||||||
alert(`Passphrase should have at least {{ recommended_passphrase_words }} words`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## CSS Updates
|
|
||||||
|
|
||||||
Add styling for passphrase warnings:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.passphrase-display {
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.passphrase-display code {
|
|
||||||
font-size: 1.2em;
|
|
||||||
color: #2c3e50;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-text.v3-2-0 {
|
|
||||||
color: #3498db;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash.warning {
|
|
||||||
background-color: #fff3cd;
|
|
||||||
border-left: 4px solid #ffc107;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration Notes for Users
|
|
||||||
|
|
||||||
Add to templates:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<h4>⚠️ v3.2.0 Breaking Changes</h4>
|
|
||||||
<p>If you have messages encoded with v3.1.0:</p>
|
|
||||||
<ul>
|
|
||||||
<li>They cannot be decoded with v3.2.0</li>
|
|
||||||
<li>You need the original v3.1.0 installation to decode them</li>
|
|
||||||
<li>After decoding, you can re-encode with v3.2.0</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Form Field Summary
|
|
||||||
|
|
||||||
### Changed Field Names
|
|
||||||
|
|
||||||
| Old Name (v3.1.0) | New Name (v3.2.0) | Type |
|
|
||||||
|-------------------|-------------------|------|
|
|
||||||
| `day_phrase` | `passphrase` | text input |
|
|
||||||
| `words_per_phrase` | `words_per_passphrase` | number input |
|
|
||||||
| `client_date` | (removed) | date input |
|
|
||||||
| `stego_date` | (removed) | date input |
|
|
||||||
|
|
||||||
### New Validation Attributes
|
|
||||||
|
|
||||||
```html
|
|
||||||
<input type="text" name="passphrase"
|
|
||||||
required
|
|
||||||
minlength="{{ min_passphrase_words * 4 }}"
|
|
||||||
placeholder="Enter at least {{ recommended_passphrase_words }} words"
|
|
||||||
pattern="^\s*\S+(\s+\S+){3,}.*$"
|
|
||||||
title="Please enter at least 4 words">
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Generate page creates single passphrase
|
|
||||||
- [ ] Generate page shows correct entropy (4 words = 44 bits)
|
|
||||||
- [ ] Generate page doesn't show day names
|
|
||||||
- [ ] Encode page accepts passphrase (not day_phrase)
|
|
||||||
- [ ] Encode page doesn't have date selection
|
|
||||||
- [ ] Encode page shows v3.2.0 help text
|
|
||||||
- [ ] Decode page accepts passphrase
|
|
||||||
- [ ] Decode page doesn't have date input
|
|
||||||
- [ ] Decode page doesn't auto-detect date from filename
|
|
||||||
- [ ] Error messages say "passphrase" not "day phrase"
|
|
||||||
- [ ] Validation shows warnings for short passphrases
|
|
||||||
- [ ] QR code functionality still works
|
|
||||||
- [ ] DCT mode options still work
|
|
||||||
- [ ] All flash messages updated
|
|
||||||
|
|
||||||
## Implementation Status
|
|
||||||
|
|
||||||
✅ Flask routes updated
|
|
||||||
✅ Form parameter names changed
|
|
||||||
✅ Function calls updated
|
|
||||||
✅ Validation added for passphrases
|
|
||||||
✅ Error messages updated
|
|
||||||
✅ Template context updated
|
|
||||||
⏳ Templates need updating (generate.html, encode.html, decode.html, index.html, about.html)
|
|
||||||
⏳ JavaScript needs updating
|
|
||||||
⏳ CSS styling for v3.2.0 features
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
**To test the Flask app:**
|
|
||||||
```bash
|
|
||||||
cd frontends/web
|
|
||||||
python app.py
|
|
||||||
# Visit http://localhost:5000
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key user-facing changes:**
|
|
||||||
1. Generate: Shows one passphrase, not 7 daily phrases
|
|
||||||
2. Encode: No date selection, just passphrase
|
|
||||||
3. Decode: No date needed, just passphrase
|
|
||||||
|
|
||||||
**Benefits to highlight:**
|
|
||||||
- ✅ Simpler UI (fewer fields)
|
|
||||||
- ✅ No date tracking needed
|
|
||||||
- ✅ Encode today, decode anytime
|
|
||||||
- ✅ Perfect for asynchronous communications
|
|
||||||
@@ -30,13 +30,40 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from auth import (
|
from auth import (
|
||||||
|
MAX_CHANNEL_KEYS,
|
||||||
|
MAX_USERS,
|
||||||
|
admin_required,
|
||||||
|
can_create_user,
|
||||||
|
can_save_channel_key,
|
||||||
change_password,
|
change_password,
|
||||||
|
create_admin_user,
|
||||||
create_user,
|
create_user,
|
||||||
|
delete_channel_key,
|
||||||
|
delete_user,
|
||||||
|
generate_temp_password,
|
||||||
|
get_all_users,
|
||||||
|
get_channel_key_by_id,
|
||||||
|
get_current_user,
|
||||||
|
get_non_admin_count,
|
||||||
|
get_user_by_id,
|
||||||
|
get_user_channel_keys,
|
||||||
get_username,
|
get_username,
|
||||||
|
has_recovery_key,
|
||||||
|
get_recovery_key_hash,
|
||||||
|
clear_recovery_key,
|
||||||
|
is_admin,
|
||||||
is_authenticated,
|
is_authenticated,
|
||||||
login_required,
|
login_required,
|
||||||
|
login_user,
|
||||||
|
logout_user,
|
||||||
|
reset_user_password,
|
||||||
|
save_channel_key,
|
||||||
|
set_recovery_key_hash,
|
||||||
|
verify_and_reset_admin_password,
|
||||||
|
update_channel_key_last_used,
|
||||||
|
update_channel_key_name,
|
||||||
user_exists,
|
user_exists,
|
||||||
verify_password,
|
verify_user_password,
|
||||||
)
|
)
|
||||||
from auth import (
|
from auth import (
|
||||||
init_app as init_auth,
|
init_app as init_auth,
|
||||||
@@ -144,7 +171,18 @@ subprocess_stego = SubprocessStego(timeout=180) # 3 minute timeout for large im
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Persist secret key so sessions survive restarts
|
||||||
|
_instance_path = Path(app.instance_path)
|
||||||
|
_instance_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
_secret_key_file = _instance_path / ".secret_key"
|
||||||
|
if _secret_key_file.exists():
|
||||||
|
app.secret_key = _secret_key_file.read_text().strip()
|
||||||
|
else:
|
||||||
app.secret_key = secrets.token_hex(32)
|
app.secret_key = secrets.token_hex(32)
|
||||||
|
_secret_key_file.write_text(app.secret_key)
|
||||||
|
_secret_key_file.chmod(0o600)
|
||||||
|
|
||||||
app.config["MAX_CONTENT_LENGTH"] = MAX_FILE_SIZE
|
app.config["MAX_CONTENT_LENGTH"] = MAX_FILE_SIZE
|
||||||
|
|
||||||
# Auth configuration from environment
|
# Auth configuration from environment
|
||||||
@@ -170,6 +208,13 @@ def inject_globals():
|
|||||||
# Get channel status (v4.0.0)
|
# Get channel status (v4.0.0)
|
||||||
channel_status = get_channel_status()
|
channel_status = get_channel_status()
|
||||||
|
|
||||||
|
# Get saved channel keys for authenticated users (v4.2.0)
|
||||||
|
saved_channel_keys = []
|
||||||
|
if is_authenticated():
|
||||||
|
current_user = get_current_user()
|
||||||
|
if current_user:
|
||||||
|
saved_channel_keys = get_user_channel_keys(current_user.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"max_message_chars": MAX_MESSAGE_CHARS,
|
"max_message_chars": MAX_MESSAGE_CHARS,
|
||||||
@@ -193,6 +238,10 @@ def inject_globals():
|
|||||||
"auth_enabled": app.config.get("AUTH_ENABLED", True),
|
"auth_enabled": app.config.get("AUTH_ENABLED", True),
|
||||||
"is_authenticated": is_authenticated(),
|
"is_authenticated": is_authenticated(),
|
||||||
"username": get_username() if is_authenticated() else None,
|
"username": get_username() if is_authenticated() else None,
|
||||||
|
# NEW in v4.1.0 - Admin state
|
||||||
|
"is_admin": is_admin(),
|
||||||
|
# NEW in v4.2.0 - Saved channel keys
|
||||||
|
"saved_channel_keys": saved_channel_keys,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -233,21 +282,20 @@ def resolve_channel_key_form(channel_key_value: str) -> str:
|
|||||||
"""
|
"""
|
||||||
Resolve channel key from form input.
|
Resolve channel key from form input.
|
||||||
|
|
||||||
Args:
|
Wrapper around library's resolve_channel_key for subprocess compatibility.
|
||||||
channel_key_value: Form value ('auto', 'none', or explicit key)
|
Returns string values for subprocess_stego ('auto', 'none', or explicit key).
|
||||||
|
|
||||||
Returns:
|
|
||||||
Value to pass to subprocess_stego ('auto', 'none', or explicit key)
|
|
||||||
"""
|
"""
|
||||||
if not channel_key_value or channel_key_value == "auto":
|
from stegasoo.channel import resolve_channel_key
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = resolve_channel_key(channel_key_value)
|
||||||
|
if result is None:
|
||||||
return "auto"
|
return "auto"
|
||||||
elif channel_key_value == "none":
|
elif result == "":
|
||||||
return "none"
|
return "none"
|
||||||
else:
|
else:
|
||||||
# Explicit key - validate format
|
return result
|
||||||
if validate_channel_key(channel_key_value):
|
except (ValueError, FileNotFoundError):
|
||||||
return channel_key_value
|
|
||||||
else:
|
|
||||||
# Invalid format, fall back to auto
|
# Invalid format, fall back to auto
|
||||||
return "auto"
|
return "auto"
|
||||||
|
|
||||||
@@ -372,7 +420,7 @@ def api_channel_validate():
|
|||||||
|
|
||||||
Returns JSON with validation result.
|
Returns JSON with validation result.
|
||||||
"""
|
"""
|
||||||
key = request.form.get("key", "") or request.json.get("key", "") if request.is_json else ""
|
key = request.form.get("key", "") or (request.json.get("key", "") if request.is_json else "")
|
||||||
|
|
||||||
if not key:
|
if not key:
|
||||||
return jsonify({"valid": False, "error": "No key provided"})
|
return jsonify({"valid": False, "error": "No key provided"})
|
||||||
@@ -884,6 +932,25 @@ def encode_page():
|
|||||||
flash(result.error_message, "error")
|
flash(result.error_message, "error")
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
# Pre-check payload capacity BEFORE encode (fail fast)
|
||||||
|
from stegasoo.steganography import will_fit_by_mode
|
||||||
|
|
||||||
|
payload_size = len(payload.data) if hasattr(payload, "data") else len(payload.encode("utf-8"))
|
||||||
|
fit_check = will_fit_by_mode(payload_size, carrier_data, embed_mode=embed_mode)
|
||||||
|
if not fit_check.get("fits", True):
|
||||||
|
error_msg = (
|
||||||
|
f"Payload too large for {embed_mode.upper()} mode. "
|
||||||
|
f"Payload: {payload_size:,} bytes, "
|
||||||
|
f"Capacity: {fit_check.get('capacity', 0):,} bytes"
|
||||||
|
)
|
||||||
|
# Suggest alternative mode
|
||||||
|
if embed_mode == "dct":
|
||||||
|
alt_check = will_fit_by_mode(payload_size, carrier_data, embed_mode="lsb")
|
||||||
|
if alt_check.get("fits"):
|
||||||
|
error_msg += " - Try LSB mode instead."
|
||||||
|
flash(error_msg, "error")
|
||||||
|
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
# v4.0.0: Include channel_key parameter
|
# v4.0.0: Include channel_key parameter
|
||||||
# Use subprocess-isolated encode to prevent crashes
|
# Use subprocess-isolated encode to prevent crashes
|
||||||
if payload_type == "file" and payload_file and payload_file.filename:
|
if payload_type == "file" and payload_file and payload_file.filename:
|
||||||
@@ -1206,7 +1273,7 @@ def decode_page():
|
|||||||
|
|
||||||
except DecryptionError:
|
except DecryptionError:
|
||||||
flash(
|
flash(
|
||||||
"Decryption failed. Check your passphrase, PIN, RSA key, reference photo, and channel key.",
|
"Decryption failed. Check passphrase, PIN, RSA key, reference photo, and channel key.",
|
||||||
"error",
|
"error",
|
||||||
)
|
)
|
||||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
@@ -1244,6 +1311,171 @@ def about():
|
|||||||
return render_template("about.html", has_argon2=has_argon2(), has_qrcode_read=HAS_QRCODE_READ)
|
return render_template("about.html", has_argon2=has_argon2(), has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TOOLS ROUTES (v4.1.0)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/tools")
|
||||||
|
@login_required
|
||||||
|
def tools():
|
||||||
|
"""Advanced tools page."""
|
||||||
|
return render_template("tools.html", has_dct=has_dct_support())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tools/capacity", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_tools_capacity():
|
||||||
|
"""Calculate image capacity for steganography."""
|
||||||
|
from stegasoo.dct_steganography import estimate_capacity_comparison
|
||||||
|
|
||||||
|
carrier = request.files.get("image")
|
||||||
|
if not carrier:
|
||||||
|
return jsonify({"success": False, "error": "No image provided"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_data = carrier.read()
|
||||||
|
result = estimate_capacity_comparison(image_data)
|
||||||
|
result["success"] = True
|
||||||
|
result["filename"] = carrier.filename
|
||||||
|
result["megapixels"] = round((result["width"] * result["height"]) / 1_000_000, 2)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tools/strip-metadata", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_tools_strip_metadata():
|
||||||
|
"""Strip EXIF/metadata from image."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
from stegasoo.utils import strip_image_metadata
|
||||||
|
|
||||||
|
image_file = request.files.get("image")
|
||||||
|
if not image_file:
|
||||||
|
return jsonify({"success": False, "error": "No image provided"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_data = image_file.read()
|
||||||
|
clean_data = strip_image_metadata(image_data, output_format="PNG")
|
||||||
|
|
||||||
|
buffer = io.BytesIO(clean_data)
|
||||||
|
filename = image_file.filename.rsplit(".", 1)[0] + "_clean.png"
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
buffer,
|
||||||
|
mimetype="image/png",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=filename
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tools/exif", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_tools_exif():
|
||||||
|
"""Read EXIF metadata from image."""
|
||||||
|
from stegasoo.utils import read_image_exif
|
||||||
|
|
||||||
|
image_file = request.files.get("image")
|
||||||
|
if not image_file:
|
||||||
|
return jsonify({"success": False, "error": "No image provided"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_data = image_file.read()
|
||||||
|
exif = read_image_exif(image_data)
|
||||||
|
|
||||||
|
# Check if it's a JPEG (editable) or not
|
||||||
|
is_jpeg = image_data[:2] == b"\xff\xd8"
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"filename": image_file.filename,
|
||||||
|
"exif": exif,
|
||||||
|
"editable": is_jpeg,
|
||||||
|
"field_count": len(exif),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tools/exif/update", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_tools_exif_update():
|
||||||
|
"""Update EXIF fields in image."""
|
||||||
|
from stegasoo.utils import write_image_exif
|
||||||
|
|
||||||
|
image_file = request.files.get("image")
|
||||||
|
if not image_file:
|
||||||
|
return jsonify({"success": False, "error": "No image provided"}), 400
|
||||||
|
|
||||||
|
# Get updates from form data
|
||||||
|
updates_json = request.form.get("updates", "{}")
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
updates = json.loads(updates_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return jsonify({"success": False, "error": "Invalid updates JSON"}), 400
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return jsonify({"success": False, "error": "No updates provided"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_data = image_file.read()
|
||||||
|
updated_data = write_image_exif(image_data, updates)
|
||||||
|
|
||||||
|
# Return as downloadable file
|
||||||
|
buffer = io.BytesIO(updated_data)
|
||||||
|
return send_file(
|
||||||
|
buffer,
|
||||||
|
mimetype="image/jpeg",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f"exif_{image_file.filename}",
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tools/exif/clear", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_tools_exif_clear():
|
||||||
|
"""Remove all EXIF metadata from image."""
|
||||||
|
from stegasoo.utils import strip_image_metadata
|
||||||
|
|
||||||
|
image_file = request.files.get("image")
|
||||||
|
if not image_file:
|
||||||
|
return jsonify({"success": False, "error": "No image provided"}), 400
|
||||||
|
|
||||||
|
# Get desired output format (default to PNG for lossless)
|
||||||
|
output_format = request.form.get("format", "PNG").upper()
|
||||||
|
if output_format not in ("PNG", "JPEG", "BMP"):
|
||||||
|
output_format = "PNG"
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_data = image_file.read()
|
||||||
|
clean_data = strip_image_metadata(image_data, output_format=output_format)
|
||||||
|
|
||||||
|
# Determine extension and mimetype
|
||||||
|
ext_map = {"PNG": ("png", "image/png"), "JPEG": ("jpg", "image/jpeg"), "BMP": ("bmp", "image/bmp")}
|
||||||
|
ext, mimetype = ext_map.get(output_format, ("png", "image/png"))
|
||||||
|
|
||||||
|
# Return as downloadable file
|
||||||
|
stem = image_file.filename.rsplit(".", 1)[0] if "." in image_file.filename else image_file.filename
|
||||||
|
buffer = io.BytesIO(clean_data)
|
||||||
|
return send_file(
|
||||||
|
buffer,
|
||||||
|
mimetype=mimetype,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f"{stem}_clean.{ext}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# Add these two test routes anywhere in app.py after the app = Flask(...) line:
|
# Add these two test routes anywhere in app.py after the app = Flask(...) line:
|
||||||
|
|
||||||
|
|
||||||
@@ -1315,29 +1547,31 @@ def login():
|
|||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "")
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
if verify_password(password):
|
user = verify_user_password(username, password)
|
||||||
session["authenticated"] = True
|
if user:
|
||||||
|
login_user(user)
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
flash("Login successful", "success")
|
flash("Login successful", "success")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
else:
|
else:
|
||||||
flash("Invalid password", "error")
|
flash("Invalid username or password", "error")
|
||||||
|
|
||||||
return render_template("login.html", username=get_username())
|
return render_template("login.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/logout")
|
@app.route("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
"""Logout and clear session."""
|
"""Logout and clear session."""
|
||||||
session.clear()
|
logout_user()
|
||||||
flash("Logged out successfully", "success")
|
flash("Logged out successfully", "success")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/setup", methods=["GET", "POST"])
|
@app.route("/setup", methods=["GET", "POST"])
|
||||||
def setup():
|
def setup():
|
||||||
"""First-run setup page."""
|
"""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"))
|
||||||
|
|
||||||
@@ -1349,27 +1583,235 @@ def setup():
|
|||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
password_confirm = request.form.get("password_confirm", "")
|
password_confirm = request.form.get("password_confirm", "")
|
||||||
|
|
||||||
if len(password) < 8:
|
if password != password_confirm:
|
||||||
flash("Password must be at least 8 characters", "error")
|
|
||||||
elif password != password_confirm:
|
|
||||||
flash("Passwords do not match", "error")
|
flash("Passwords do not match", "error")
|
||||||
else:
|
else:
|
||||||
try:
|
success, message = create_admin_user(username, password)
|
||||||
create_user(username, password)
|
if success:
|
||||||
session["authenticated"] = True
|
# Auto-login the new admin
|
||||||
|
user = verify_user_password(username, password)
|
||||||
|
if 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"))
|
||||||
except Exception as e:
|
else:
|
||||||
flash(f"Error creating account: {e}", "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():
|
||||||
"""Account management page."""
|
"""Account management page."""
|
||||||
|
current_user = get_current_user()
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
current = request.form.get("current_password", "")
|
current = request.form.get("current_password", "")
|
||||||
new = request.form.get("new_password", "")
|
new = request.form.get("new_password", "")
|
||||||
@@ -1378,10 +1820,219 @@ def account():
|
|||||||
if new != new_confirm:
|
if new != new_confirm:
|
||||||
flash("New passwords do not match", "error")
|
flash("New passwords do not match", "error")
|
||||||
else:
|
else:
|
||||||
success, message = change_password(current, new)
|
success, message = change_password(current_user.id, current, new)
|
||||||
flash(message, "success" if success else "error")
|
flash(message, "success" if success else "error")
|
||||||
|
|
||||||
return render_template("account.html", username=get_username())
|
# Get saved channel keys
|
||||||
|
channel_keys = get_user_channel_keys(current_user.id)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"account.html",
|
||||||
|
username=current_user.username,
|
||||||
|
user=current_user,
|
||||||
|
is_admin=current_user.is_admin,
|
||||||
|
has_recovery=has_recovery_key(),
|
||||||
|
channel_keys=channel_keys,
|
||||||
|
max_channel_keys=MAX_CHANNEL_KEYS,
|
||||||
|
can_save_key=can_save_channel_key(current_user.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CHANNEL KEY MANAGEMENT ROUTES (v4.2.0)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/account/keys/save", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def account_save_key():
|
||||||
|
"""Save a new channel key."""
|
||||||
|
current_user = get_current_user()
|
||||||
|
name = request.form.get("key_name", "").strip()
|
||||||
|
channel_key = request.form.get("channel_key", "").strip()
|
||||||
|
|
||||||
|
# Normalize key format (remove dashes if present)
|
||||||
|
channel_key = channel_key.replace("-", "").lower()
|
||||||
|
|
||||||
|
success, message, key = save_channel_key(current_user.id, name, channel_key)
|
||||||
|
flash(message, "success" if success else "error")
|
||||||
|
return redirect(url_for("account"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/account/keys/<int:key_id>/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def account_delete_key(key_id):
|
||||||
|
"""Delete a saved channel key."""
|
||||||
|
current_user = get_current_user()
|
||||||
|
success, message = delete_channel_key(key_id, current_user.id)
|
||||||
|
flash(message, "success" if success else "error")
|
||||||
|
return redirect(url_for("account"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/account/keys/<int:key_id>/rename", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def account_rename_key(key_id):
|
||||||
|
"""Rename a saved channel key."""
|
||||||
|
current_user = get_current_user()
|
||||||
|
new_name = request.form.get("new_name", "").strip()
|
||||||
|
success, message = update_channel_key_name(key_id, current_user.id, new_name)
|
||||||
|
flash(message, "success" if success else "error")
|
||||||
|
return redirect(url_for("account"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/channel/keys")
|
||||||
|
@login_required
|
||||||
|
def api_channel_keys():
|
||||||
|
"""Get saved channel keys for current user (JSON API)."""
|
||||||
|
current_user = get_current_user()
|
||||||
|
keys = get_user_channel_keys(current_user.id)
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"id": k.id,
|
||||||
|
"name": k.name,
|
||||||
|
"fingerprint": f"{k.channel_key[:4]}...{k.channel_key[-4:]}",
|
||||||
|
"channel_key": k.channel_key,
|
||||||
|
"last_used_at": k.last_used_at,
|
||||||
|
}
|
||||||
|
for k in keys
|
||||||
|
],
|
||||||
|
"can_save": can_save_channel_key(current_user.id),
|
||||||
|
"max_keys": MAX_CHANNEL_KEYS,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/channel/keys/<int:key_id>/use", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_channel_key_use(key_id):
|
||||||
|
"""Mark a channel key as used (updates last_used_at)."""
|
||||||
|
current_user = get_current_user()
|
||||||
|
key = get_channel_key_by_id(key_id, current_user.id)
|
||||||
|
if not key:
|
||||||
|
return jsonify({"success": False, "error": "Key not found"}), 404
|
||||||
|
|
||||||
|
update_channel_key_last_used(key_id, current_user.id)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ADMIN ROUTES (v4.1.0)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/users")
|
||||||
|
@admin_required
|
||||||
|
def admin_users():
|
||||||
|
"""User management page (admin only)."""
|
||||||
|
users = get_all_users()
|
||||||
|
current_user = get_current_user()
|
||||||
|
return render_template(
|
||||||
|
"admin/users.html",
|
||||||
|
users=users,
|
||||||
|
current_user=current_user,
|
||||||
|
user_count=get_non_admin_count(),
|
||||||
|
max_users=MAX_USERS,
|
||||||
|
can_create=can_create_user(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/users/new", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def admin_user_new():
|
||||||
|
"""Create new user (admin only)."""
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "")
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
|
||||||
|
success, message, user = create_user(username, password)
|
||||||
|
|
||||||
|
# Check if AJAX request
|
||||||
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||||
|
if success:
|
||||||
|
return jsonify({"success": True, "username": username, "password": password})
|
||||||
|
else:
|
||||||
|
return jsonify({"success": False, "error": message})
|
||||||
|
|
||||||
|
# Regular form submission fallback
|
||||||
|
if success:
|
||||||
|
flash(f"User '{username}' created successfully", "success")
|
||||||
|
session["temp_password"] = password
|
||||||
|
session["temp_username"] = username
|
||||||
|
return redirect(url_for("admin_user_created"))
|
||||||
|
else:
|
||||||
|
flash(message, "error")
|
||||||
|
|
||||||
|
# Generate a temp password for the form
|
||||||
|
temp_password = generate_temp_password()
|
||||||
|
return render_template("admin/user_new.html", temp_password=temp_password)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/users/created")
|
||||||
|
@admin_required
|
||||||
|
def admin_user_created():
|
||||||
|
"""Show created user confirmation with password."""
|
||||||
|
username = session.pop("temp_username", None)
|
||||||
|
password = session.pop("temp_password", None)
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return redirect(url_for("admin_users"))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/user_created.html",
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/users/<int:user_id>/delete", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def admin_user_delete(user_id):
|
||||||
|
"""Delete a user (admin only)."""
|
||||||
|
current_user = get_current_user()
|
||||||
|
success, message = delete_user(user_id, current_user.id)
|
||||||
|
flash(message, "success" if success else "error")
|
||||||
|
return redirect(url_for("admin_users"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/users/<int:user_id>/reset-password", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def admin_user_reset_password(user_id):
|
||||||
|
"""Reset a user's password (admin only)."""
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
flash("User not found", "error")
|
||||||
|
return redirect(url_for("admin_users"))
|
||||||
|
|
||||||
|
# Generate new password
|
||||||
|
new_password = generate_temp_password()
|
||||||
|
success, message = reset_user_password(user_id, new_password)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# Store for display
|
||||||
|
session["temp_password"] = new_password
|
||||||
|
session["temp_username"] = user.username
|
||||||
|
return redirect(url_for("admin_user_password_reset"))
|
||||||
|
else:
|
||||||
|
flash(message, "error")
|
||||||
|
return redirect(url_for("admin_users"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/users/password-reset")
|
||||||
|
@admin_required
|
||||||
|
def admin_user_password_reset():
|
||||||
|
"""Show password reset confirmation."""
|
||||||
|
username = session.pop("temp_username", None)
|
||||||
|
password = session.pop("temp_password", None)
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return redirect(url_for("admin_users"))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/password_reset.html",
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1405,9 +2056,10 @@ if __name__ == "__main__":
|
|||||||
else:
|
else:
|
||||||
print("Authentication disabled")
|
print("Authentication disabled")
|
||||||
|
|
||||||
|
port = int(os.environ.get("STEGASOO_PORT", "5000"))
|
||||||
app.run(
|
app.run(
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=5000,
|
port=port,
|
||||||
debug=False,
|
debug=False,
|
||||||
ssl_context=ssl_context,
|
ssl_context=ssl_context,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
Stegasoo Authentication Module
|
Stegasoo Authentication Module (v4.1.0)
|
||||||
|
|
||||||
Single-admin authentication with Argon2 password hashing.
|
Multi-user authentication with role-based access control.
|
||||||
Uses Flask sessions for authentication state and SQLite3 for storage.
|
- Admin user created at first-run setup
|
||||||
|
- Admin can create up to 16 additional users
|
||||||
|
- Uses Argon2id password hashing
|
||||||
|
- Flask sessions for authentication state
|
||||||
|
- SQLite3 for user storage
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import string
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import VerifyMismatchError
|
||||||
from flask import current_app, g, redirect, session, url_for
|
from flask import current_app, flash, g, redirect, session, url_for
|
||||||
|
|
||||||
# Argon2 password hasher (lighter than stegasoo's 256MB for faster login)
|
# Argon2 password hasher (lighter than stegasoo's 256MB for faster login)
|
||||||
ph = PasswordHasher(
|
ph = PasswordHasher(
|
||||||
@@ -22,6 +29,26 @@ ph = PasswordHasher(
|
|||||||
salt_len=16,
|
salt_len=16,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
MAX_USERS = 16 # Plus 1 admin = 17 total
|
||||||
|
MAX_CHANNEL_KEYS = 10 # Per user
|
||||||
|
ROLE_ADMIN = "admin"
|
||||||
|
ROLE_USER = "user"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class User:
|
||||||
|
"""User data class."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
return self.role == ROLE_ADMIN
|
||||||
|
|
||||||
|
|
||||||
def get_db_path() -> Path:
|
def get_db_path() -> Path:
|
||||||
"""Get database path in Flask instance folder."""
|
"""Get database path in Flask instance folder."""
|
||||||
@@ -46,13 +73,65 @@ def close_db(e=None):
|
|||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""Initialize database schema."""
|
"""Initialize database schema with migration support."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
|
|
||||||
|
# Check if we need to migrate from old single-user schema
|
||||||
|
cursor = db.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'"
|
||||||
|
)
|
||||||
|
has_old_table = cursor.fetchone() is not None
|
||||||
|
|
||||||
|
cursor = db.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||||
|
)
|
||||||
|
has_new_table = cursor.fetchone() is not None
|
||||||
|
|
||||||
|
if has_old_table and not has_new_table:
|
||||||
|
# Migrate from old schema
|
||||||
|
_migrate_from_single_user(db)
|
||||||
|
elif not has_new_table:
|
||||||
|
# Fresh install - create new schema
|
||||||
|
_create_schema(db)
|
||||||
|
else:
|
||||||
|
# Existing install - check for new tables (migrations)
|
||||||
|
_ensure_channel_keys_table(db)
|
||||||
|
_ensure_app_settings_table(db)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_schema(db: sqlite3.Connection):
|
||||||
|
"""Create the multi-user schema."""
|
||||||
db.executescript("""
|
db.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS admin_user (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT NOT NULL DEFAULT 'admin',
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_channel_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
channel_key TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(user_id, channel_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -60,76 +139,770 @@ def init_db():
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def user_exists() -> bool:
|
def _migrate_from_single_user(db: sqlite3.Connection):
|
||||||
"""Check if admin user has been created."""
|
"""Migrate from old single-user admin_user table to multi-user users table."""
|
||||||
|
# Create new table
|
||||||
|
_create_schema(db)
|
||||||
|
|
||||||
|
# Copy admin user from old table
|
||||||
|
old_user = db.execute(
|
||||||
|
"SELECT username, password_hash, created_at FROM admin_user WHERE id = 1"
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if old_user:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (username, password_hash, role, created_at)
|
||||||
|
VALUES (?, ?, 'admin', ?)
|
||||||
|
""",
|
||||||
|
(old_user["username"], old_user["password_hash"], old_user["created_at"]),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Drop old table
|
||||||
|
db.execute("DROP TABLE admin_user")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_channel_keys_table(db: sqlite3.Connection):
|
||||||
|
"""Ensure user_channel_keys table exists (migration for existing installs)."""
|
||||||
|
cursor = db.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_channel_keys'"
|
||||||
|
)
|
||||||
|
if cursor.fetchone() is None:
|
||||||
|
db.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_channel_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
channel_key TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(user_id, channel_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_keys_user ON user_channel_keys(user_id);
|
||||||
|
""")
|
||||||
|
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()
|
db = get_db()
|
||||||
result = db.execute("SELECT 1 FROM admin_user WHERE id = 1").fetchone()
|
row = db.execute(
|
||||||
return result is not None
|
"SELECT value FROM app_settings WHERE key = ?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
return row["value"] if row else None
|
||||||
|
|
||||||
|
|
||||||
def create_user(username: str, password: str):
|
def set_app_setting(key: str, value: str) -> None:
|
||||||
"""Create admin user (first-run setup)."""
|
"""Set an app-level setting value."""
|
||||||
if user_exists():
|
|
||||||
raise ValueError("Admin user already exists")
|
|
||||||
|
|
||||||
password_hash = ph.hash(password)
|
|
||||||
db = get_db()
|
db = get_db()
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO admin_user (id, username, password_hash) VALUES (1, ?, ?)",
|
"""
|
||||||
(username, password_hash),
|
INSERT INTO app_settings (key, value)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(key, value, value),
|
||||||
)
|
)
|
||||||
db.commit()
|
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
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def any_users_exist() -> bool:
|
||||||
|
"""Check if any users have been created (for first-run detection)."""
|
||||||
|
db = get_db()
|
||||||
|
result = db.execute("SELECT 1 FROM users LIMIT 1").fetchone()
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
|
||||||
|
def user_exists() -> bool:
|
||||||
|
"""Alias for any_users_exist() for backwards compatibility."""
|
||||||
|
return any_users_exist()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_count() -> int:
|
||||||
|
"""Get total number of users."""
|
||||||
|
db = get_db()
|
||||||
|
result = db.execute("SELECT COUNT(*) FROM users").fetchone()
|
||||||
|
return result[0] if result else 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_non_admin_count() -> int:
|
||||||
|
"""Get number of non-admin users."""
|
||||||
|
db = get_db()
|
||||||
|
result = db.execute("SELECT COUNT(*) FROM users WHERE role != 'admin'").fetchone()
|
||||||
|
return result[0] if result else 0
|
||||||
|
|
||||||
|
|
||||||
|
def can_create_user() -> bool:
|
||||||
|
"""Check if we can create more users (within limit)."""
|
||||||
|
return get_non_admin_count() < MAX_USERS
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_id(user_id: int) -> User | None:
|
||||||
|
"""Get user by ID."""
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT id, username, role, created_at FROM users WHERE id = ?", (user_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return User(
|
||||||
|
id=row["id"],
|
||||||
|
username=row["username"],
|
||||||
|
role=row["role"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_username(username: str) -> User | None:
|
||||||
|
"""Get user by username."""
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT id, username, role, created_at FROM users WHERE username = ?",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return User(
|
||||||
|
id=row["id"],
|
||||||
|
username=row["username"],
|
||||||
|
role=row["role"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_users() -> list[User]:
|
||||||
|
"""Get all users, admins first, then by creation date."""
|
||||||
|
db = get_db()
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, role, created_at FROM users
|
||||||
|
ORDER BY role = 'admin' DESC, created_at ASC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=row["id"],
|
||||||
|
username=row["username"],
|
||||||
|
role=row["role"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user() -> User | None:
|
||||||
|
"""Get the currently logged-in user from session."""
|
||||||
|
user_id = session.get("user_id")
|
||||||
|
if user_id:
|
||||||
|
return get_user_by_id(user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_username() -> str:
|
def get_username() -> str:
|
||||||
"""Get the admin username."""
|
"""Get current user's username (backwards compatibility)."""
|
||||||
db = get_db()
|
user = get_current_user()
|
||||||
row = db.execute("SELECT username FROM admin_user WHERE id = 1").fetchone()
|
return user.username if user else "unknown"
|
||||||
return row["username"] if row else "admin"
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(password: str) -> bool:
|
# =============================================================================
|
||||||
"""Verify password against stored hash."""
|
# Authentication
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def verify_user_password(username: str, password: str) -> User | None:
|
||||||
|
"""
|
||||||
|
Verify password for a user.
|
||||||
|
|
||||||
|
Returns User if valid, None if invalid.
|
||||||
|
Also rehashes password if needed.
|
||||||
|
"""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
row = db.execute("SELECT password_hash FROM admin_user WHERE id = 1").fetchone()
|
row = db.execute(
|
||||||
|
"SELECT id, username, role, created_at, password_hash FROM users WHERE username = ?",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
return False
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ph.verify(row["password_hash"], password)
|
ph.verify(row["password_hash"], password)
|
||||||
|
|
||||||
# Rehash if parameters changed
|
# Rehash if parameters changed
|
||||||
if ph.check_needs_rehash(row["password_hash"]):
|
if ph.check_needs_rehash(row["password_hash"]):
|
||||||
new_hash = ph.hash(password)
|
new_hash = ph.hash(password)
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE admin_user SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1",
|
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
(new_hash,),
|
(new_hash, row["id"]),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
|
||||||
|
return User(
|
||||||
|
id=row["id"],
|
||||||
|
username=row["username"],
|
||||||
|
role=row["role"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
)
|
||||||
except VerifyMismatchError:
|
except VerifyMismatchError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str) -> bool:
|
||||||
|
"""Verify password for current user (backwards compatibility)."""
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
return False
|
return False
|
||||||
|
result = verify_user_password(user.username, password)
|
||||||
|
return result is not None
|
||||||
def change_password(current_password: str, new_password: str) -> tuple[bool, str]:
|
|
||||||
"""Change admin password. Returns (success, message)."""
|
|
||||||
if not verify_password(current_password):
|
|
||||||
return False, "Current password is incorrect"
|
|
||||||
|
|
||||||
if len(new_password) < 8:
|
|
||||||
return False, "New password must be at least 8 characters"
|
|
||||||
|
|
||||||
new_hash = ph.hash(new_password)
|
|
||||||
db = get_db()
|
|
||||||
db.execute(
|
|
||||||
"UPDATE admin_user SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1",
|
|
||||||
(new_hash,),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
return True, "Password changed successfully"
|
|
||||||
|
|
||||||
|
|
||||||
def is_authenticated() -> bool:
|
def is_authenticated() -> bool:
|
||||||
"""Check if current session is authenticated."""
|
"""Check if current session is authenticated."""
|
||||||
return session.get("authenticated", False)
|
return session.get("user_id") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin() -> bool:
|
||||||
|
"""Check if current user is an admin."""
|
||||||
|
user = get_current_user()
|
||||||
|
return user.is_admin if user else False
|
||||||
|
|
||||||
|
|
||||||
|
def login_user(user: User):
|
||||||
|
"""Set up session for logged-in user."""
|
||||||
|
session["user_id"] = user.id
|
||||||
|
session["username"] = user.username
|
||||||
|
session["role"] = user.role
|
||||||
|
# Legacy compatibility
|
||||||
|
session["authenticated"] = True
|
||||||
|
|
||||||
|
|
||||||
|
def logout_user():
|
||||||
|
"""Clear session for logout."""
|
||||||
|
session.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# User Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def generate_temp_password(length: int = 8) -> str:
|
||||||
|
"""Generate a random temporary password."""
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_username(username: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate username format.
|
||||||
|
|
||||||
|
Rules: 3-80 chars, alphanumeric + underscore/hyphen + @/. for email-style
|
||||||
|
"""
|
||||||
|
if not username:
|
||||||
|
return False, "Username is required"
|
||||||
|
|
||||||
|
if len(username) < 3:
|
||||||
|
return False, "Username must be at least 3 characters"
|
||||||
|
|
||||||
|
if len(username) > 80:
|
||||||
|
return False, "Username must be at most 80 characters"
|
||||||
|
|
||||||
|
# Allow: alphanumeric, underscore, hyphen, @, . (for email-style)
|
||||||
|
allowed = set(string.ascii_letters + string.digits + "_-@.")
|
||||||
|
if not all(c in allowed for c in username):
|
||||||
|
return False, "Username can only contain letters, numbers, underscore, hyphen, @ and ."
|
||||||
|
|
||||||
|
# Must start with letter or number
|
||||||
|
if username[0] not in string.ascii_letters + string.digits:
|
||||||
|
return False, "Username must start with a letter or number"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password(password: str) -> tuple[bool, str]:
|
||||||
|
"""Validate password requirements."""
|
||||||
|
if not password:
|
||||||
|
return False, "Password is required"
|
||||||
|
|
||||||
|
if len(password) < 8:
|
||||||
|
return False, "Password must be at least 8 characters"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
username: str, password: str, role: str = ROLE_USER
|
||||||
|
) -> tuple[bool, str, User | None]:
|
||||||
|
"""
|
||||||
|
Create a new user.
|
||||||
|
|
||||||
|
Returns (success, message, user).
|
||||||
|
"""
|
||||||
|
# Validate username
|
||||||
|
valid, msg = validate_username(username)
|
||||||
|
if not valid:
|
||||||
|
return False, msg, None
|
||||||
|
|
||||||
|
# Validate password
|
||||||
|
valid, msg = validate_password(password)
|
||||||
|
if not valid:
|
||||||
|
return False, msg, None
|
||||||
|
|
||||||
|
# Check if username already exists
|
||||||
|
if get_user_by_username(username):
|
||||||
|
return False, "Username already exists", None
|
||||||
|
|
||||||
|
# Check user limit (only for non-admin users)
|
||||||
|
if role != ROLE_ADMIN and not can_create_user():
|
||||||
|
return False, f"Maximum of {MAX_USERS} users reached", None
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
password_hash = ph.hash(password)
|
||||||
|
db = get_db()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (username, password_hash, role)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(username, password_hash, role),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
user = get_user_by_id(cursor.lastrowid)
|
||||||
|
return True, "User created successfully", user
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False, "Username already exists", None
|
||||||
|
|
||||||
|
|
||||||
|
def create_admin_user(username: str, password: str) -> tuple[bool, str]:
|
||||||
|
"""Create the initial admin user (first-run setup)."""
|
||||||
|
if any_users_exist():
|
||||||
|
return False, "Admin user already exists"
|
||||||
|
|
||||||
|
success, msg, _ = create_user(username, password, ROLE_ADMIN)
|
||||||
|
return success, msg
|
||||||
|
|
||||||
|
|
||||||
|
def change_password(
|
||||||
|
user_id: int, current_password: str, new_password: str
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Change a user's password (requires current password)."""
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return False, "User not found"
|
||||||
|
|
||||||
|
# Verify current password
|
||||||
|
if not verify_user_password(user.username, current_password):
|
||||||
|
return False, "Current password is incorrect"
|
||||||
|
|
||||||
|
# Validate new password
|
||||||
|
valid, msg = validate_password(new_password)
|
||||||
|
if not valid:
|
||||||
|
return False, msg
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
new_hash = ph.hash(new_password)
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
(new_hash, user_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True, "Password changed successfully"
|
||||||
|
|
||||||
|
|
||||||
|
def reset_user_password(user_id: int, new_password: str) -> tuple[bool, str]:
|
||||||
|
"""Reset a user's password (admin function, no current password required)."""
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return False, "User not found"
|
||||||
|
|
||||||
|
# Validate new password
|
||||||
|
valid, msg = validate_password(new_password)
|
||||||
|
if not valid:
|
||||||
|
return False, msg
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
new_hash = ph.hash(new_password)
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
(new_hash, user_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Invalidate user's sessions
|
||||||
|
invalidate_user_sessions(user_id)
|
||||||
|
|
||||||
|
return True, "Password reset successfully"
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(user_id: int, current_user_id: int) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Delete a user.
|
||||||
|
|
||||||
|
Cannot delete yourself or the last admin.
|
||||||
|
"""
|
||||||
|
if user_id == current_user_id:
|
||||||
|
return False, "Cannot delete yourself"
|
||||||
|
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return False, "User not found"
|
||||||
|
|
||||||
|
# Check if this is the last admin
|
||||||
|
if user.role == ROLE_ADMIN:
|
||||||
|
db = get_db()
|
||||||
|
admin_count = db.execute(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
|
||||||
|
).fetchone()[0]
|
||||||
|
if admin_count <= 1:
|
||||||
|
return False, "Cannot delete the last admin"
|
||||||
|
|
||||||
|
# Invalidate user's sessions before deletion
|
||||||
|
invalidate_user_sessions(user_id)
|
||||||
|
|
||||||
|
# Delete user
|
||||||
|
db = get_db()
|
||||||
|
db.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True, f"User '{user.username}' deleted"
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_user_sessions(user_id: int):
|
||||||
|
"""
|
||||||
|
Invalidate all sessions for a user.
|
||||||
|
|
||||||
|
This is called when a user is deleted or their password is reset.
|
||||||
|
Since we use server-side sessions, we increment a "session version"
|
||||||
|
that's checked on each request.
|
||||||
|
"""
|
||||||
|
# For Flask's default session (client-side), we can't truly invalidate.
|
||||||
|
# But we can add a check - store a "valid_from" timestamp in the DB
|
||||||
|
# and compare against session creation time.
|
||||||
|
#
|
||||||
|
# For now, we'll use a simpler approach: store invalidated user IDs
|
||||||
|
# in app config (memory) which gets checked by login_required.
|
||||||
|
#
|
||||||
|
# This works for single-process deployments (like RPi).
|
||||||
|
# For multi-process, would need Redis or DB-backed sessions.
|
||||||
|
|
||||||
|
if "invalidated_users" not in current_app.config:
|
||||||
|
current_app.config["invalidated_users"] = set()
|
||||||
|
|
||||||
|
current_app.config["invalidated_users"].add(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def is_session_valid() -> bool:
|
||||||
|
"""Check if current session is still valid (user not deleted/invalidated)."""
|
||||||
|
user_id = session.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if user was invalidated
|
||||||
|
invalidated = current_app.config.get("invalidated_users", set())
|
||||||
|
if user_id in invalidated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if user still exists
|
||||||
|
if not get_user_by_id(user_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Channel Keys
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChannelKey:
|
||||||
|
"""Saved channel key data class."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
name: str
|
||||||
|
channel_key: str
|
||||||
|
created_at: str
|
||||||
|
last_used_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_channel_keys(user_id: int) -> list[ChannelKey]:
|
||||||
|
"""Get all saved channel keys for a user, most recently used first."""
|
||||||
|
db = get_db()
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, user_id, name, channel_key, created_at, last_used_at
|
||||||
|
FROM user_channel_keys
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY last_used_at DESC NULLS LAST, created_at DESC
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
ChannelKey(
|
||||||
|
id=row["id"],
|
||||||
|
user_id=row["user_id"],
|
||||||
|
name=row["name"],
|
||||||
|
channel_key=row["channel_key"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
last_used_at=row["last_used_at"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_key_by_id(key_id: int, user_id: int) -> ChannelKey | None:
|
||||||
|
"""Get a specific channel key (ensures user owns it)."""
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, user_id, name, channel_key, created_at, last_used_at
|
||||||
|
FROM user_channel_keys
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
""",
|
||||||
|
(key_id, user_id),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return ChannelKey(
|
||||||
|
id=row["id"],
|
||||||
|
user_id=row["user_id"],
|
||||||
|
name=row["name"],
|
||||||
|
channel_key=row["channel_key"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
last_used_at=row["last_used_at"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_key_count(user_id: int) -> int:
|
||||||
|
"""Get count of saved channel keys for a user."""
|
||||||
|
db = get_db()
|
||||||
|
result = db.execute(
|
||||||
|
"SELECT COUNT(*) FROM user_channel_keys WHERE user_id = ?", (user_id,)
|
||||||
|
).fetchone()
|
||||||
|
return result[0] if result else 0
|
||||||
|
|
||||||
|
|
||||||
|
def can_save_channel_key(user_id: int) -> bool:
|
||||||
|
"""Check if user can save more channel keys (within limit)."""
|
||||||
|
return get_channel_key_count(user_id) < MAX_CHANNEL_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
def save_channel_key(
|
||||||
|
user_id: int, name: str, channel_key: str
|
||||||
|
) -> tuple[bool, str, ChannelKey | None]:
|
||||||
|
"""
|
||||||
|
Save a channel key for a user.
|
||||||
|
|
||||||
|
Returns (success, message, key).
|
||||||
|
"""
|
||||||
|
# Validate name
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
return False, "Key name is required", None
|
||||||
|
if len(name) > 50:
|
||||||
|
return False, "Key name must be at most 50 characters", None
|
||||||
|
|
||||||
|
# Validate channel key format (hex string)
|
||||||
|
channel_key = channel_key.strip().lower()
|
||||||
|
if not channel_key:
|
||||||
|
return False, "Channel key is required", None
|
||||||
|
if not all(c in "0123456789abcdef" for c in channel_key):
|
||||||
|
return False, "Invalid channel key format", None
|
||||||
|
|
||||||
|
# Check limit
|
||||||
|
if not can_save_channel_key(user_id):
|
||||||
|
return False, f"Maximum of {MAX_CHANNEL_KEYS} saved keys reached", None
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_channel_keys (user_id, name, channel_key)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(user_id, name, channel_key),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
key = get_channel_key_by_id(cursor.lastrowid, user_id)
|
||||||
|
return True, "Channel key saved", key
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False, "This channel key is already saved", None
|
||||||
|
|
||||||
|
|
||||||
|
def update_channel_key_name(
|
||||||
|
key_id: int, user_id: int, new_name: str
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Update the name of a saved channel key."""
|
||||||
|
new_name = new_name.strip()
|
||||||
|
if not new_name:
|
||||||
|
return False, "Key name is required"
|
||||||
|
if len(new_name) > 50:
|
||||||
|
return False, "Key name must be at most 50 characters"
|
||||||
|
|
||||||
|
key = get_channel_key_by_id(key_id, user_id)
|
||||||
|
if not key:
|
||||||
|
return False, "Channel key not found"
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"UPDATE user_channel_keys SET name = ? WHERE id = ? AND user_id = ?",
|
||||||
|
(new_name, key_id, user_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return True, "Key name updated"
|
||||||
|
|
||||||
|
|
||||||
|
def update_channel_key_last_used(key_id: int, user_id: int):
|
||||||
|
"""Update the last_used_at timestamp for a channel key."""
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE user_channel_keys
|
||||||
|
SET last_used_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
""",
|
||||||
|
(key_id, user_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_channel_key(key_id: int, user_id: int) -> tuple[bool, str]:
|
||||||
|
"""Delete a saved channel key."""
|
||||||
|
key = get_channel_key_by_id(key_id, user_id)
|
||||||
|
if not key:
|
||||||
|
return False, "Channel key not found"
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM user_channel_keys WHERE id = ? AND user_id = ?",
|
||||||
|
(key_id, user_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return True, f"Key '{key.name}' deleted"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Decorators
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def login_required(f):
|
def login_required(f):
|
||||||
@@ -142,18 +915,62 @@ def login_required(f):
|
|||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
# Check for first-run setup
|
# Check for first-run setup
|
||||||
if not user_exists():
|
if not any_users_exist():
|
||||||
return redirect(url_for("setup"))
|
return redirect(url_for("setup"))
|
||||||
|
|
||||||
# Check authentication
|
# Check authentication
|
||||||
if not is_authenticated():
|
if not is_authenticated():
|
||||||
return redirect(url_for("login"))
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
# Check if session is still valid (user not deleted)
|
||||||
|
if not is_session_valid():
|
||||||
|
logout_user()
|
||||||
|
flash("Your session has expired. Please log in again.", "warning")
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
"""Decorator to require admin role for a route."""
|
||||||
|
|
||||||
|
@functools.wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
# Check if auth is enabled
|
||||||
|
if not current_app.config.get("AUTH_ENABLED", True):
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
# Check for first-run setup
|
||||||
|
if not any_users_exist():
|
||||||
|
return redirect(url_for("setup"))
|
||||||
|
|
||||||
|
# Check authentication
|
||||||
|
if not is_authenticated():
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
# Check if session is still valid
|
||||||
|
if not is_session_valid():
|
||||||
|
logout_user()
|
||||||
|
flash("Your session has expired. Please log in again.", "warning")
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
# Check admin role
|
||||||
|
if not is_admin():
|
||||||
|
flash("Admin access required", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# App Initialization
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def init_app(app):
|
def init_app(app):
|
||||||
"""Initialize auth module with Flask app."""
|
"""Initialize auth module with Flask app."""
|
||||||
app.teardown_appcontext(close_db)
|
app.teardown_appcontext(close_db)
|
||||||
|
|||||||
142
frontends/web/static/js/auth.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Stegasoo Authentication Pages JavaScript
|
||||||
|
* Handles login, setup, account, and admin user management pages
|
||||||
|
*/
|
||||||
|
|
||||||
|
const StegasooAuth = {
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PASSWORD VISIBILITY TOGGLE
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle password field visibility
|
||||||
|
* @param {string} inputId - ID of the password input
|
||||||
|
* @param {HTMLElement} btn - The toggle button element
|
||||||
|
*/
|
||||||
|
togglePassword(inputId, btn) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
const icon = btn.querySelector('i');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
if (input.type === 'password') {
|
||||||
|
input.type = 'text';
|
||||||
|
icon?.classList.replace('bi-eye', 'bi-eye-slash');
|
||||||
|
} else {
|
||||||
|
input.type = 'password';
|
||||||
|
icon?.classList.replace('bi-eye-slash', 'bi-eye');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PASSWORD CONFIRMATION VALIDATION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize password confirmation validation on a form
|
||||||
|
* @param {string} formId - ID of the form
|
||||||
|
* @param {string} passwordId - ID of the password field
|
||||||
|
* @param {string} confirmId - ID of the confirmation field
|
||||||
|
*/
|
||||||
|
initPasswordConfirmation(formId, passwordId, confirmId) {
|
||||||
|
const form = document.getElementById(formId);
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
const password = document.getElementById(passwordId)?.value;
|
||||||
|
const confirm = document.getElementById(confirmId)?.value;
|
||||||
|
|
||||||
|
if (password !== confirm) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Passwords do not match');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// COPY TO CLIPBOARD
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy field value to clipboard with visual feedback
|
||||||
|
* @param {string} fieldId - ID of the input field to copy
|
||||||
|
*/
|
||||||
|
copyField(fieldId) {
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (!field) return;
|
||||||
|
|
||||||
|
field.select();
|
||||||
|
navigator.clipboard.writeText(field.value).then(() => {
|
||||||
|
const btn = field.nextElementSibling;
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const originalHTML = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="bi bi-check"></i>';
|
||||||
|
setTimeout(() => btn.innerHTML = originalHTML, 1000);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PASSWORD GENERATION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random password
|
||||||
|
* @param {number} length - Password length (default 8)
|
||||||
|
* @returns {string} Generated password
|
||||||
|
*/
|
||||||
|
generatePassword(length = 8) {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let password = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate password and update input field
|
||||||
|
* @param {string} inputId - ID of the password input
|
||||||
|
* @param {number} length - Password length
|
||||||
|
*/
|
||||||
|
regeneratePassword(inputId = 'passwordInput', length = 8) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (input) {
|
||||||
|
input.value = this.generatePassword(length);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// DELETE CONFIRMATION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm deletion with a prompt
|
||||||
|
* @param {string} itemName - Name of item being deleted
|
||||||
|
* @param {string} formId - ID of the form to submit if confirmed
|
||||||
|
* @returns {boolean} True if confirmed
|
||||||
|
*/
|
||||||
|
confirmDelete(itemName, formId = null) {
|
||||||
|
const confirmed = confirm(`Are you sure you want to delete "${itemName}"? This cannot be undone.`);
|
||||||
|
if (confirmed && formId) {
|
||||||
|
const form = document.getElementById(formId);
|
||||||
|
form?.submit();
|
||||||
|
}
|
||||||
|
return confirmed;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make togglePassword available globally for onclick handlers
|
||||||
|
function togglePassword(inputId, btn) {
|
||||||
|
StegasooAuth.togglePassword(inputId, btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make copyField available globally for onclick handlers
|
||||||
|
function copyField(fieldId) {
|
||||||
|
StegasooAuth.copyField(fieldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make regeneratePassword available globally for onclick handlers
|
||||||
|
function regeneratePassword() {
|
||||||
|
StegasooAuth.regeneratePassword();
|
||||||
|
}
|
||||||
285
frontends/web/static/js/generate.js
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* Stegasoo Generate Page JavaScript
|
||||||
|
* Handles credential generation form and display
|
||||||
|
*/
|
||||||
|
|
||||||
|
const StegasooGenerate = {
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// FORM CONTROLS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the words range slider
|
||||||
|
*/
|
||||||
|
initWordsSlider() {
|
||||||
|
const wordsRange = document.getElementById('wordsRange');
|
||||||
|
const wordsValue = document.getElementById('wordsValue');
|
||||||
|
|
||||||
|
wordsRange?.addEventListener('input', function() {
|
||||||
|
const bits = this.value * 11;
|
||||||
|
wordsValue.textContent = `${this.value} words (~${bits} bits)`;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize PIN/RSA option toggles
|
||||||
|
*/
|
||||||
|
initOptionToggles() {
|
||||||
|
const usePinCheck = document.getElementById('usePinCheck');
|
||||||
|
const useRsaCheck = document.getElementById('useRsaCheck');
|
||||||
|
const pinOptions = document.getElementById('pinOptions');
|
||||||
|
const rsaOptions = document.getElementById('rsaOptions');
|
||||||
|
const rsaQrWarning = document.getElementById('rsaQrWarning');
|
||||||
|
const rsaBitsSelect = document.getElementById('rsaBitsSelect');
|
||||||
|
|
||||||
|
usePinCheck?.addEventListener('change', function() {
|
||||||
|
pinOptions?.classList.toggle('d-none', !this.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
useRsaCheck?.addEventListener('change', function() {
|
||||||
|
rsaOptions?.classList.toggle('d-none', !this.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
// RSA key size QR warning (>3072 bits)
|
||||||
|
rsaBitsSelect?.addEventListener('change', function() {
|
||||||
|
rsaQrWarning?.classList.toggle('d-none', parseInt(this.value) <= 3072);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// CREDENTIAL VISIBILITY
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
pinHidden: false,
|
||||||
|
passphraseHidden: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle PIN visibility
|
||||||
|
*/
|
||||||
|
togglePinVisibility() {
|
||||||
|
const pinDigits = document.getElementById('pinDigits');
|
||||||
|
const icon = document.getElementById('pinToggleIcon');
|
||||||
|
const text = document.getElementById('pinToggleText');
|
||||||
|
|
||||||
|
this.pinHidden = !this.pinHidden;
|
||||||
|
pinDigits?.classList.toggle('blurred', this.pinHidden);
|
||||||
|
|
||||||
|
if (icon) icon.className = this.pinHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
||||||
|
if (text) text.textContent = this.pinHidden ? 'Show' : 'Hide';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle passphrase visibility
|
||||||
|
*/
|
||||||
|
togglePassphraseVisibility() {
|
||||||
|
const display = document.getElementById('passphraseDisplay');
|
||||||
|
const icon = document.getElementById('passphraseToggleIcon');
|
||||||
|
const text = document.getElementById('passphraseToggleText');
|
||||||
|
|
||||||
|
this.passphraseHidden = !this.passphraseHidden;
|
||||||
|
display?.classList.toggle('blurred', this.passphraseHidden);
|
||||||
|
|
||||||
|
if (icon) icon.className = this.passphraseHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
||||||
|
if (text) text.textContent = this.passphraseHidden ? 'Show' : 'Hide';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// MEMORY AID STORY GENERATION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
currentStoryTemplate: 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Story templates organized by word count (3-12 words supported)
|
||||||
|
*/
|
||||||
|
storyTemplates: {
|
||||||
|
3: [
|
||||||
|
w => `The ${w[0]} ${w[1]} ${w[2]}.`,
|
||||||
|
w => `${w[0]} loves ${w[1]} and ${w[2]}.`,
|
||||||
|
w => `A ${w[0]} found a ${w[1]} near the ${w[2]}.`,
|
||||||
|
w => `${w[0]}, ${w[1]}, ${w[2]} — never forget.`,
|
||||||
|
w => `The ${w[0]} hid the ${w[1]} under the ${w[2]}.`,
|
||||||
|
],
|
||||||
|
4: [
|
||||||
|
w => `${w[0]} and ${w[1]} discovered a ${w[2]} made of ${w[3]}.`,
|
||||||
|
w => `The ${w[0]} ${w[1]} ate ${w[2]} for ${w[3]}.`,
|
||||||
|
w => `In the ${w[0]}, a ${w[1]} met a ${w[2]} carrying ${w[3]}.`,
|
||||||
|
w => `${w[0]} said "${w[1]}" while holding a ${w[2]} ${w[3]}.`,
|
||||||
|
w => `The secret: ${w[0]}, ${w[1]}, ${w[2]}, ${w[3]}.`,
|
||||||
|
],
|
||||||
|
5: [
|
||||||
|
w => `${w[0]} traveled to ${w[1]} seeking the ${w[2]} of ${w[3]} and ${w[4]}.`,
|
||||||
|
w => `The ${w[0]} ${w[1]} lived in a ${w[2]} house with ${w[3]} ${w[4]}.`,
|
||||||
|
w => `"${w[0]}!" shouted ${w[1]} as the ${w[2]} ${w[3]} flew toward ${w[4]}.`,
|
||||||
|
w => `Captain ${w[0]} sailed the ${w[1]} ${w[2]} searching for ${w[3]} ${w[4]}.`,
|
||||||
|
w => `In ${w[0]} kingdom, ${w[1]} guards protected the ${w[2]} ${w[3]} ${w[4]}.`,
|
||||||
|
],
|
||||||
|
6: [
|
||||||
|
w => `${w[0]} met ${w[1]} at the ${w[2]}. Together they found ${w[3]}, ${w[4]}, and ${w[5]}.`,
|
||||||
|
w => `The ${w[0]} ${w[1]} wore a ${w[2]} hat while eating ${w[3]} ${w[4]} ${w[5]}.`,
|
||||||
|
w => `Detective ${w[0]} found ${w[1]} ${w[2]} near the ${w[3]} ${w[4]} ${w[5]}.`,
|
||||||
|
w => `In the ${w[0]} ${w[1]}, a ${w[2]} ${w[3]} sang about ${w[4]} ${w[5]}.`,
|
||||||
|
w => `Chef ${w[0]} combined ${w[1]}, ${w[2]}, ${w[3]}, ${w[4]}, and ${w[5]}.`,
|
||||||
|
],
|
||||||
|
7: [
|
||||||
|
w => `${w[0]} and ${w[1]} walked through the ${w[2]} ${w[3]} to find the ${w[4]} ${w[5]} ${w[6]}.`,
|
||||||
|
w => `The ${w[0]} professor studied ${w[1]} ${w[2]} while drinking ${w[3]} ${w[4]} with ${w[5]} ${w[6]}.`,
|
||||||
|
w => `"${w[0]} ${w[1]}!" yelled ${w[2]} as ${w[3]} ${w[4]} attacked the ${w[5]} ${w[6]}.`,
|
||||||
|
w => `In ${w[0]}, King ${w[1]} decreed that ${w[2]} ${w[3]} must honor ${w[4]} ${w[5]} ${w[6]}.`,
|
||||||
|
],
|
||||||
|
8: [
|
||||||
|
w => `${w[0]} ${w[1]} and ${w[2]} ${w[3]} met at the ${w[4]} ${w[5]} to discuss ${w[6]} ${w[7]}.`,
|
||||||
|
w => `The ${w[0]} ${w[1]} ${w[2]} traveled from ${w[3]} to ${w[4]} carrying ${w[5]} ${w[6]} ${w[7]}.`,
|
||||||
|
w => `${w[0]} discovered that ${w[1]} ${w[2]} plus ${w[3]} ${w[4]} equals ${w[5]} ${w[6]} ${w[7]}.`,
|
||||||
|
],
|
||||||
|
9: [
|
||||||
|
w => `${w[0]} ${w[1]} ${w[2]} watched as ${w[3]} ${w[4]} ${w[5]} danced with ${w[6]} ${w[7]} ${w[8]}.`,
|
||||||
|
w => `In the ${w[0]} ${w[1]} ${w[2]}, three friends — ${w[3]}, ${w[4]}, ${w[5]} — found ${w[6]} ${w[7]} ${w[8]}.`,
|
||||||
|
w => `The recipe: ${w[0]}, ${w[1]}, ${w[2]}, ${w[3]}, ${w[4]}, ${w[5]}, ${w[6]}, ${w[7]}, ${w[8]}.`,
|
||||||
|
],
|
||||||
|
10: [
|
||||||
|
w => `${w[0]} ${w[1]} told ${w[2]} ${w[3]} about the ${w[4]} ${w[5]} ${w[6]} hidden in ${w[7]} ${w[8]} ${w[9]}.`,
|
||||||
|
w => `The ${w[0]} ${w[1]} ${w[2]} ${w[3]} ${w[4]} lived beside ${w[5]} ${w[6]} ${w[7]} ${w[8]} ${w[9]}.`,
|
||||||
|
],
|
||||||
|
11: [
|
||||||
|
w => `${w[0]} ${w[1]} ${w[2]} and ${w[3]} ${w[4]} ${w[5]} discovered ${w[6]} ${w[7]} ${w[8]} ${w[9]} ${w[10]}.`,
|
||||||
|
w => `In ${w[0]} ${w[1]}, the ${w[2]} ${w[3]} ${w[4]} sang of ${w[5]} ${w[6]} ${w[7]} ${w[8]} ${w[9]} ${w[10]}.`,
|
||||||
|
],
|
||||||
|
12: [
|
||||||
|
w => `${w[0]} ${w[1]} ${w[2]} met ${w[3]} ${w[4]} ${w[5]} at the ${w[6]} ${w[7]} ${w[8]} ${w[9]} ${w[10]} ${w[11]}.`,
|
||||||
|
w => `The twelve treasures: ${w[0]}, ${w[1]}, ${w[2]}, ${w[3]}, ${w[4]}, ${w[5]}, ${w[6]}, ${w[7]}, ${w[8]}, ${w[9]}, ${w[10]}, ${w[11]}.`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap word in highlight span
|
||||||
|
*/
|
||||||
|
hl(word) {
|
||||||
|
return `<span class="passphrase-word">${word}</span>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a memory story for given words
|
||||||
|
* @param {string[]} words - Array of passphrase words
|
||||||
|
* @param {number|null} idx - Template index (null for current)
|
||||||
|
* @returns {string} HTML story
|
||||||
|
*/
|
||||||
|
generateStory(words, idx = null) {
|
||||||
|
const count = words.length;
|
||||||
|
if (count === 0) return '';
|
||||||
|
|
||||||
|
// Clamp to supported range (3-12)
|
||||||
|
const templateKey = Math.max(3, Math.min(12, count));
|
||||||
|
const templates = this.storyTemplates[templateKey];
|
||||||
|
|
||||||
|
if (!templates || templates.length === 0) {
|
||||||
|
// Fallback: just list the words
|
||||||
|
return words.map(w => this.hl(w)).join(' — ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateIdx = (idx ?? this.currentStoryTemplate) % templates.length;
|
||||||
|
// Apply highlighting to words
|
||||||
|
const highlighted = words.map(w => this.hl(w));
|
||||||
|
return templates[templateIdx](highlighted);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle memory aid visibility
|
||||||
|
* @param {string[]} words - Passphrase words array
|
||||||
|
*/
|
||||||
|
toggleMemoryAid(words) {
|
||||||
|
const container = document.getElementById('memoryAidContainer');
|
||||||
|
const icon = document.getElementById('memoryAidIcon');
|
||||||
|
const text = document.getElementById('memoryAidText');
|
||||||
|
|
||||||
|
const isHidden = container?.classList.contains('d-none');
|
||||||
|
container?.classList.toggle('d-none', !isHidden);
|
||||||
|
|
||||||
|
if (icon) icon.className = isHidden ? 'bi bi-lightbulb-fill' : 'bi bi-lightbulb';
|
||||||
|
if (text) text.textContent = isHidden ? 'Hide Aid' : 'Memory Aid';
|
||||||
|
|
||||||
|
if (isHidden) {
|
||||||
|
document.getElementById('memoryStory').innerHTML = this.generateStory(words);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate story with next template
|
||||||
|
* @param {string[]} words - Passphrase words array
|
||||||
|
*/
|
||||||
|
regenerateStory(words) {
|
||||||
|
const count = words.length;
|
||||||
|
const templateKey = Math.max(3, Math.min(12, count));
|
||||||
|
const templates = this.storyTemplates[templateKey] || [];
|
||||||
|
this.currentStoryTemplate = (this.currentStoryTemplate + 1) % Math.max(1, templates.length);
|
||||||
|
document.getElementById('memoryStory').innerHTML = this.generateStory(words, this.currentStoryTemplate);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// QR CODE PRINTING
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print QR code in new window
|
||||||
|
*/
|
||||||
|
printQrCode() {
|
||||||
|
const qrImg = document.getElementById('qrCodeImage');
|
||||||
|
if (!qrImg) return;
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
printWindow.document.write(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Stegasoo RSA Key QR Code</title>
|
||||||
|
<style>
|
||||||
|
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; font-family: sans-serif; }
|
||||||
|
img { max-width: 400px; }
|
||||||
|
.warning { margin-top: 20px; padding: 10px; border: 2px solid #ff9800; background: #fff3e0; max-width: 400px; text-align: center; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Stegasoo RSA Private Key</h2>
|
||||||
|
<img src="${qrImg.src}" alt="RSA Key QR Code">
|
||||||
|
<div class="warning">
|
||||||
|
<strong>Warning:</strong> This QR code contains your unencrypted RSA private key.
|
||||||
|
Store securely and destroy after use.
|
||||||
|
</div>
|
||||||
|
<script>window.onload = function() { window.print(); }<\/script>
|
||||||
|
</body>
|
||||||
|
</html>`);
|
||||||
|
printWindow.document.close();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// INITIALIZATION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize generate form page
|
||||||
|
*/
|
||||||
|
initForm() {
|
||||||
|
this.initWordsSlider();
|
||||||
|
this.initOptionToggles();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global function wrappers for onclick handlers
|
||||||
|
function togglePinVisibility() {
|
||||||
|
StegasooGenerate.togglePinVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePassphraseVisibility() {
|
||||||
|
StegasooGenerate.togglePassphraseVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function printQrCode() {
|
||||||
|
StegasooGenerate.printQrCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-init form controls
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (document.querySelector('[data-page="generate"]')) {
|
||||||
|
StegasooGenerate.initForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -119,7 +119,11 @@ const Stegasoo = {
|
|||||||
if (isScanContainer || isPixelContainer) {
|
if (isScanContainer || isPixelContainer) {
|
||||||
labelEl.classList.add('d-none');
|
labelEl.classList.add('d-none');
|
||||||
} else {
|
} else {
|
||||||
labelEl.innerHTML = '<i class="bi bi-check-circle text-success me-1"></i>' + file.name;
|
labelEl.textContent = '';
|
||||||
|
const icon = document.createElement('i');
|
||||||
|
icon.className = 'bi bi-check-circle text-success me-1';
|
||||||
|
labelEl.appendChild(icon);
|
||||||
|
labelEl.appendChild(document.createTextNode(file.name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,18 +765,43 @@ const Stegasoo = {
|
|||||||
const customInputId = config.customInputId || 'channelCustomInput';
|
const customInputId = config.customInputId || 'channelCustomInput';
|
||||||
const keyInputId = config.keyInputId || 'channelKeyInput';
|
const keyInputId = config.keyInputId || 'channelKeyInput';
|
||||||
const generateBtnId = config.generateBtnId;
|
const generateBtnId = config.generateBtnId;
|
||||||
|
const serverInfoId = config.serverInfoId || 'channelServerInfo';
|
||||||
|
|
||||||
const select = document.getElementById(selectId);
|
const select = document.getElementById(selectId);
|
||||||
const customInput = document.getElementById(customInputId);
|
const customInput = document.getElementById(customInputId);
|
||||||
const keyInput = document.getElementById(keyInputId);
|
const keyInput = document.getElementById(keyInputId);
|
||||||
const generateBtn = generateBtnId ? document.getElementById(generateBtnId) : null;
|
const generateBtn = generateBtnId ? document.getElementById(generateBtnId) : null;
|
||||||
|
const serverInfo = document.getElementById(serverInfoId);
|
||||||
|
|
||||||
// Show/hide custom input based on selection
|
// Show/hide custom input and server info based on selection
|
||||||
const updateVisibility = () => {
|
const updateVisibility = () => {
|
||||||
const isCustom = select?.value === 'custom';
|
const value = select?.value;
|
||||||
|
const isCustom = value === 'custom';
|
||||||
|
const isPublic = value === 'none';
|
||||||
|
const isAuto = value === 'auto';
|
||||||
|
|
||||||
|
// Custom input visibility
|
||||||
customInput?.classList.toggle('d-none', !isCustom);
|
customInput?.classList.toggle('d-none', !isCustom);
|
||||||
if (isCustom && keyInput) {
|
if (isCustom && keyInput) {
|
||||||
keyInput.focus();
|
keyInput.focus();
|
||||||
|
// Pulse highlight effect
|
||||||
|
customInput?.classList.add('channel-highlight');
|
||||||
|
setTimeout(() => customInput?.classList.remove('channel-highlight'), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server info: show for auto, hide for custom, show "no key" for public
|
||||||
|
if (serverInfo) {
|
||||||
|
if (isAuto) {
|
||||||
|
serverInfo.innerHTML = '<i class="bi bi-shield-lock me-1"></i>Server: <code>' + (serverInfo.dataset.fingerprint || '••••-••••-···-••••-••••') + '</code>';
|
||||||
|
serverInfo.className = 'small text-success mt-2';
|
||||||
|
serverInfo.classList.remove('d-none');
|
||||||
|
} else if (isPublic) {
|
||||||
|
serverInfo.innerHTML = '<i class="bi bi-globe me-1"></i>No channel key will be used';
|
||||||
|
serverInfo.className = 'small text-muted mt-2';
|
||||||
|
serverInfo.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
serverInfo.classList.add('d-none');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -815,6 +844,14 @@ const Stegasoo = {
|
|||||||
// Set the select value to the actual key for form submission
|
// Set the select value to the actual key for form submission
|
||||||
select.value = keyInput.value;
|
select.value = keyInput.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track saved key usage (fire-and-forget)
|
||||||
|
const selectedOption = select?.selectedOptions?.[0];
|
||||||
|
const keyId = selectedOption?.dataset?.keyId;
|
||||||
|
if (keyId) {
|
||||||
|
fetch(`/api/channel/keys/${keyId}/use`, { method: 'POST' }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -882,7 +919,16 @@ const Stegasoo = {
|
|||||||
}
|
}
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Encoding...';
|
const startTime = Date.now();
|
||||||
|
const updateTimer = () => {
|
||||||
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
const mins = Math.floor(elapsed / 60);
|
||||||
|
const secs = elapsed % 60;
|
||||||
|
const timeStr = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
|
||||||
|
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Encoding... ${timeStr}`;
|
||||||
|
};
|
||||||
|
updateTimer();
|
||||||
|
setInterval(updateTimer, 1000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -900,7 +946,8 @@ const Stegasoo = {
|
|||||||
this.initChannelKey({
|
this.initChannelKey({
|
||||||
selectId: 'channelSelectDec',
|
selectId: 'channelSelectDec',
|
||||||
customInputId: 'channelCustomInputDec',
|
customInputId: 'channelCustomInputDec',
|
||||||
keyInputId: 'channelKeyInputDec'
|
keyInputId: 'channelKeyInputDec',
|
||||||
|
serverInfoId: 'channelServerInfoDec'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form submission with channel key validation and mode display
|
// Form submission with channel key validation and mode display
|
||||||
@@ -914,7 +961,16 @@ const Stegasoo = {
|
|||||||
const selectedMode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'auto';
|
const selectedMode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'auto';
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Decoding (${selectedMode.toUpperCase()})...`;
|
const startTime = Date.now();
|
||||||
|
const updateTimer = () => {
|
||||||
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
const mins = Math.floor(elapsed / 60);
|
||||||
|
const secs = elapsed % 60;
|
||||||
|
const timeStr = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
|
||||||
|
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Decoding (${selectedMode.toUpperCase()})... ${timeStr}`;
|
||||||
|
};
|
||||||
|
updateTimer();
|
||||||
|
setInterval(updateTimer, 1000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
CSS Variables
|
CSS Variables
|
||||||
---------------------------------------------------------------------------- */
|
---------------------------------------------------------------------------- */
|
||||||
:root {
|
:root {
|
||||||
--gradient-start: #667eea;
|
--gradient-start: #4a2860;
|
||||||
--gradient-end: #764ba2;
|
--gradient-end: #5570d4;
|
||||||
--bg-dark-1: #1a1a2e;
|
--bg-dark-1: #1a1a2e;
|
||||||
--bg-dark-2: #16213e;
|
--bg-dark-2: #16213e;
|
||||||
--bg-dark-3: #0f3460;
|
--bg-dark-3: #0f3460;
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
--overlay-dark: rgba(0, 0, 0, 0.3);
|
--overlay-dark: rgba(0, 0, 0, 0.3);
|
||||||
--overlay-light: rgba(255, 255, 255, 0.05);
|
--overlay-light: rgba(255, 255, 255, 0.05);
|
||||||
--day-highlight: #E3FF54; /* Bright yellow/green for day of week */
|
--day-highlight: #E3FF54; /* Bright yellow/green for day of week */
|
||||||
|
--header-gold: #fee862; /* Halfway between light straw and 24k gold */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------------------
|
/* ----------------------------------------------------------------------------
|
||||||
@@ -140,6 +141,67 @@ body {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-header h5 {
|
||||||
|
color: var(--header-gold);
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.33);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-gold {
|
||||||
|
color: var(--header-gold);
|
||||||
|
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two-choice toggle buttons - gradient-matched colors + equal width */
|
||||||
|
.btn-group .btn-outline-primary,
|
||||||
|
.btn-group .btn-outline-secondary {
|
||||||
|
flex: 1 1 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.btn-group .btn-outline-primary:hover,
|
||||||
|
.btn-group .btn-outline-secondary:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channel key highlight pulse */
|
||||||
|
.channel-highlight {
|
||||||
|
animation: channel-pulse 0.4s ease;
|
||||||
|
}
|
||||||
|
@keyframes channel-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(254, 232, 98, 0); }
|
||||||
|
20% { box-shadow: 0 0 9px 1px rgba(254, 232, 98, 0.19); }
|
||||||
|
40% { box-shadow: 0 0 9px 1px rgba(254, 232, 98, 0.19); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(254, 232, 98, 0); }
|
||||||
|
}
|
||||||
|
.btn-group .btn-outline-primary:first-of-type,
|
||||||
|
.btn-group .btn-outline-secondary:first-of-type {
|
||||||
|
color: #6b4d8a;
|
||||||
|
border-color: #6b4d8a;
|
||||||
|
border-right: 1px dashed rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
.btn-group .btn-outline-primary:last-of-type,
|
||||||
|
.btn-group .btn-outline-secondary:last-of-type {
|
||||||
|
color: #4a62a8;
|
||||||
|
border-color: #4a62a8;
|
||||||
|
}
|
||||||
|
.btn-group .btn-check:checked + .btn-outline-primary:first-of-type,
|
||||||
|
.btn-group .btn-check:checked + .btn-outline-secondary:first-of-type {
|
||||||
|
background-color: #6b4d8a;
|
||||||
|
border-color: #6b4d8a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-group .btn-check:checked + .btn-outline-primary:last-of-type,
|
||||||
|
.btn-group .btn-check:checked + .btn-outline-secondary:last-of-type {
|
||||||
|
background-color: #4a62a8;
|
||||||
|
border-color: #4a62a8;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override small warning text to use header gold */
|
||||||
|
.text-warning.small {
|
||||||
|
color: var(--header-gold) !important;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.33);
|
||||||
|
}
|
||||||
|
|
||||||
.card-link .card-header.text-center {
|
.card-link .card-header.text-center {
|
||||||
padding-top: 0.5rem !important;
|
padding-top: 0.5rem !important;
|
||||||
padding-bottom: 0.5rem !important;
|
padding-bottom: 0.5rem !important;
|
||||||
@@ -443,10 +505,10 @@ footer {
|
|||||||
/* Enhance the gradient on hover for dramatic effect */
|
/* Enhance the gradient on hover for dramatic effect */
|
||||||
.card-link:hover .card-header.text-center {
|
.card-link:hover .card-header.text-center {
|
||||||
background: linear-gradient(135deg,
|
background: linear-gradient(135deg,
|
||||||
var(--gradient-start) 0%,
|
#3d2050 0%,
|
||||||
#5a67d8 20%,
|
var(--gradient-start) 30%,
|
||||||
var(--gradient-end) 80%,
|
var(--gradient-end) 70%,
|
||||||
#8a2be2 100%);
|
#6680e0 100%);
|
||||||
box-shadow: inset 0 0 20px rgba(255, 215, 0, 0.1);
|
box-shadow: inset 0 0 20px rgba(255, 215, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,7 +551,7 @@ footer {
|
|||||||
|
|
||||||
.card-link:hover .feature-card {
|
.card-link:hover .feature-card {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-5px);
|
||||||
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.3);
|
box-shadow: 0 10px 40px rgba(74, 40, 96, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------------------
|
/* ----------------------------------------------------------------------------
|
||||||
|
|||||||
0
frontends/web/stegasoo_users.db
Normal file
@@ -65,7 +65,7 @@
|
|||||||
<li class="mb-2">
|
<li class="mb-2">
|
||||||
<i class="bi bi-check-circle text-success me-2"></i>
|
<i class="bi bi-check-circle text-success me-2"></i>
|
||||||
<strong>Channel Keys</strong>
|
<strong>Channel Keys</strong>
|
||||||
<span class="badge bg-info ms-1">v4.0</span>
|
<span class="badge bg-info ms-1">v4.1</span>
|
||||||
<br><small class="text-muted">Group/deployment isolation</small>
|
<br><small class="text-muted">Group/deployment isolation</small>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -100,6 +100,7 @@
|
|||||||
<li><strong>Output:</strong> JPEG or PNG</li>
|
<li><strong>Output:</strong> JPEG or PNG</li>
|
||||||
<li><strong>Color:</strong> Color or grayscale</li>
|
<li><strong>Color:</strong> Color or grayscale</li>
|
||||||
<li><strong>Speed:</strong> ~2s</li>
|
<li><strong>Speed:</strong> ~2s</li>
|
||||||
|
<li><strong>Error Correction:</strong> Reed-Solomon <span class="badge bg-info ms-1">v4.1</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
<hr>
|
<hr>
|
||||||
<div class="small">
|
<div class="small">
|
||||||
@@ -250,7 +251,7 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0">
|
<h5 class="mb-0">
|
||||||
<i class="bi bi-broadcast me-2"></i>Channel Keys
|
<i class="bi bi-broadcast me-2"></i>Channel Keys
|
||||||
<span class="badge bg-info ms-2">v4.0</span>
|
<span class="badge bg-info ms-2">v4.1</span>
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -316,19 +317,55 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if channel_configured %}
|
{% if channel_configured %}
|
||||||
<div class="alert alert-success mt-3 mb-0">
|
<div class="alert alert-success mt-3 mb-3">
|
||||||
<i class="bi bi-shield-lock me-2"></i>
|
<i class="bi bi-shield-lock me-2"></i>
|
||||||
<strong>This server has a channel key configured:</strong>
|
<strong>This server has a channel key configured:</strong>
|
||||||
<code class="ms-2">{{ channel_fingerprint }}</code>
|
<code class="ms-2">{{ channel_fingerprint }}</code>
|
||||||
<span class="text-muted ms-2">({{ channel_source }})</span>
|
<span class="text-muted ms-2">({{ channel_source }})</span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-info mt-3 mb-0">
|
<div class="alert alert-info mt-3 mb-3">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
This server is running in <strong>public mode</strong>.
|
This server is running in <strong>public mode</strong>.
|
||||||
Set <code>STEGASOO_CHANNEL_KEY</code> to enable server-wide channel isolation.
|
Set <code>STEGASOO_CHANNEL_KEY</code> to enable server-wide channel isolation.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Channel Key QR Generator -->
|
||||||
|
<div class="card bg-dark border-secondary">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-qr-code me-2"></i>Share Channel Key via QR
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="small text-muted mb-3">Generate a QR code to share a channel key with others.</p>
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label small">Channel Key</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control font-monospace" id="channelKeyQrInput"
|
||||||
|
placeholder="Enter or generate a key">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="channelKeyQrGenerate"
|
||||||
|
title="Generate random key">
|
||||||
|
<i class="bi bi-shuffle"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<button class="btn btn-primary w-100" type="button" id="channelKeyQrShow">
|
||||||
|
<i class="bi bi-qr-code me-1"></i>Show QR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center mt-3 d-none" id="channelKeyQrContainer">
|
||||||
|
<canvas id="channelKeyQrCanvas" class="bg-white p-2 rounded"></canvas>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" id="channelKeyQrDownload">
|
||||||
|
<i class="bi bi-download me-1"></i>Download PNG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -347,6 +384,13 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>4.1.0</strong></td>
|
||||||
|
<td>
|
||||||
|
<strong>Reed-Solomon error correction</strong> for DCT mode (corrects up to 16 byte errors per 223-byte chunk),
|
||||||
|
majority voting on length headers, improved robustness with problematic carrier images
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>4.0.0</strong></td>
|
<td><strong>4.0.0</strong></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -469,28 +513,77 @@
|
|||||||
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Limits & Specs</h5>
|
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Limits & Specs</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-dark table-striped small">
|
<!-- Key Specs - Always Visible -->
|
||||||
|
<div class="row text-center mb-4">
|
||||||
|
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||||
|
<div class="p-3 bg-dark rounded h-100">
|
||||||
|
<i class="bi bi-file-earmark text-primary fs-3 d-block mb-2"></i>
|
||||||
|
<div class="small text-muted">Max Payload</div>
|
||||||
|
<strong>{{ max_payload_kb }} KB</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||||
|
<div class="p-3 bg-dark rounded h-100">
|
||||||
|
<i class="bi bi-image text-info fs-3 d-block mb-2"></i>
|
||||||
|
<div class="small text-muted">Max Carrier</div>
|
||||||
|
<strong>24 MP</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||||
|
<div class="p-3 bg-dark rounded h-100">
|
||||||
|
<i class="bi bi-soundwave text-warning fs-3 d-block mb-2"></i>
|
||||||
|
<div class="small text-muted">DCT Capacity</div>
|
||||||
|
<strong>~75 KB/MP</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||||
|
<div class="p-3 bg-dark rounded h-100">
|
||||||
|
<i class="bi bi-grid-3x3 text-success fs-3 d-block mb-2"></i>
|
||||||
|
<div class="small text-muted">LSB Capacity</div>
|
||||||
|
<strong>~375 KB/MP</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||||
|
<div class="p-3 bg-dark rounded h-100">
|
||||||
|
<i class="bi bi-shield-check text-danger fs-3 d-block mb-2"></i>
|
||||||
|
<div class="small text-muted">Encryption</div>
|
||||||
|
<strong>AES-256</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||||
|
<div class="p-3 bg-dark rounded h-100">
|
||||||
|
<i class="bi bi-bandaid text-info fs-3 d-block mb-2"></i>
|
||||||
|
<div class="small text-muted">Error Correction</div>
|
||||||
|
<strong>Reed-Solomon</strong>
|
||||||
|
<span class="badge bg-info ms-1">v4.1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Correction Detail -->
|
||||||
|
<div class="alert alert-info small mb-4">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
<strong>Reed-Solomon Error Correction:</strong> DCT mode corrects up to 16 byte errors per 223-byte chunk.
|
||||||
|
Handles problematic carrier images with uniform areas that cause unstable DCT coefficients.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- More Specs - Accordion -->
|
||||||
|
<div class="accordion" id="specsAccordion">
|
||||||
|
<div class="accordion-item bg-dark">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed bg-dark text-light py-2" type="button"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#moreSpecs">
|
||||||
|
<i class="bi bi-list-ul me-2"></i>More Specifications
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="moreSpecs" class="accordion-collapse collapse" data-bs-parent="#specsAccordion">
|
||||||
|
<div class="accordion-body p-0">
|
||||||
|
<table class="table table-dark table-striped small mb-0">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td><i class="bi bi-file-text me-2"></i>Max text</td>
|
<td><i class="bi bi-file-text me-2"></i>Max text</td>
|
||||||
<td><strong>2M characters</strong></td>
|
<td><strong>2M characters</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td><i class="bi bi-file-earmark me-2"></i>Max file</td>
|
|
||||||
<td><strong>{{ max_payload_kb }} KB</strong></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><i class="bi bi-image me-2"></i>Max carrier</td>
|
|
||||||
<td><strong>24 MP</strong> (~6000x4000)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><i class="bi bi-soundwave me-2"></i>DCT capacity</td>
|
|
||||||
<td><strong>~75 KB/MP</strong></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><i class="bi bi-grid-3x3 me-2"></i>LSB capacity</td>
|
|
||||||
<td><strong>~375 KB/MP</strong></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><i class="bi bi-upload me-2"></i>Max upload</td>
|
<td><i class="bi bi-upload me-2"></i>Max upload</td>
|
||||||
<td><strong>30 MB</strong></td>
|
<td><strong>30 MB</strong></td>
|
||||||
@@ -517,13 +610,77 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><i class="bi bi-box me-2"></i>Built with</td>
|
<td><i class="bi bi-box me-2"></i>Built with</td>
|
||||||
<td>Flask, Pillow, NumPy, SciPy, jpegio, cryptography, argon2-cffi</td>
|
<td>Flask, Pillow, NumPy, SciPy, jpegio, reedsolo, cryptography, argon2-cffi</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<!-- QR Code library for channel key sharing -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const input = document.getElementById('channelKeyQrInput');
|
||||||
|
const generateBtn = document.getElementById('channelKeyQrGenerate');
|
||||||
|
const showBtn = document.getElementById('channelKeyQrShow');
|
||||||
|
const container = document.getElementById('channelKeyQrContainer');
|
||||||
|
const canvas = document.getElementById('channelKeyQrCanvas');
|
||||||
|
const downloadBtn = document.getElementById('channelKeyQrDownload');
|
||||||
|
|
||||||
|
// Generate random key
|
||||||
|
generateBtn?.addEventListener('click', function() {
|
||||||
|
if (input && typeof Stegasoo !== 'undefined') {
|
||||||
|
input.value = Stegasoo.generateChannelKey();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show QR code
|
||||||
|
showBtn?.addEventListener('click', function() {
|
||||||
|
const key = input?.value?.trim().replace(/-/g, '');
|
||||||
|
if (!key || key.length !== 32) {
|
||||||
|
alert('Please enter a valid 32-character channel key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format key with dashes for QR
|
||||||
|
const formatted = key.match(/.{4}/g)?.join('-') || key;
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
if (typeof QRCode !== 'undefined' && canvas) {
|
||||||
|
QRCode.toCanvas(canvas, formatted, {
|
||||||
|
width: 200,
|
||||||
|
margin: 2,
|
||||||
|
color: { dark: '#000', light: '#fff' }
|
||||||
|
}, function(error) {
|
||||||
|
if (error) {
|
||||||
|
console.error('QR generation error:', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container?.classList.remove('d-none');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download QR as PNG
|
||||||
|
downloadBtn?.addEventListener('click', function() {
|
||||||
|
if (canvas) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = 'stegasoo-channel-key.png';
|
||||||
|
link.href = canvas.toDataURL('image/png');
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -12,8 +12,60 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-muted mb-4">
|
<p class="text-muted mb-4">
|
||||||
Logged in as <strong>{{ username }}</strong>
|
Logged in as <strong>{{ username }}</strong>
|
||||||
|
{% if is_admin %}
|
||||||
|
<span class="badge bg-warning text-dark ms-2">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>Admin
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-primary w-100">
|
||||||
|
<i class="bi bi-people me-2"></i>Manage Users
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recovery Key Management (Admin only) -->
|
||||||
|
<div class="card bg-dark mb-4">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-shield-lock me-2"></i>
|
||||||
|
<strong>Recovery Key</strong>
|
||||||
|
{% if has_recovery %}
|
||||||
|
<span class="badge bg-success ms-2">Configured</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary ms-2">Not Set</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="{{ url_for('regenerate_recovery') }}" class="btn btn-outline-warning"
|
||||||
|
onclick="return confirm('Generate a new recovery key? This will invalidate any existing key.')">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>
|
||||||
|
{{ 'Regenerate' if has_recovery else 'Generate' }}
|
||||||
|
</a>
|
||||||
|
{% if has_recovery %}
|
||||||
|
<form method="POST" action="{{ url_for('disable_recovery') }}" style="display:inline;">
|
||||||
|
<button type="submit" class="btn btn-outline-danger"
|
||||||
|
onclick="return confirm('Disable recovery? If you forget your password, you will NOT be able to recover your account.')">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted d-block mt-2">
|
||||||
|
{% if has_recovery %}
|
||||||
|
Allows password reset if you're locked out.
|
||||||
|
{% else %}
|
||||||
|
No recovery option - most secure, but no password reset possible.
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<h6 class="text-muted mb-3">Change Password</h6>
|
<h6 class="text-muted mb-3">Change Password</h6>
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('account') }}" id="accountForm">
|
<form method="POST" action="{{ url_for('account') }}" id="accountForm">
|
||||||
@@ -65,38 +117,118 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<hr class="my-4">
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Saved Channel Keys Section -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-key-fill me-2"></i>Saved Channel Keys</h5>
|
||||||
|
<span class="badge bg-secondary">{{ channel_keys|length }} / {{ max_channel_keys }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if channel_keys %}
|
||||||
|
<div class="list-group list-group-flush mb-3">
|
||||||
|
{% for key in channel_keys %}
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<div>
|
||||||
|
<strong>{{ key.name }}</strong>
|
||||||
|
<br>
|
||||||
|
<code class="small text-muted">{{ key.channel_key[:4] }}...{{ key.channel_key[-4:] }}</code>
|
||||||
|
{% if key.last_used_at %}
|
||||||
|
<span class="text-muted small ms-2">Last used: {{ key.last_used_at[:10] }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button type="button" class="btn btn-outline-secondary"
|
||||||
|
onclick="renameKey({{ key.id }}, '{{ key.name }}')"
|
||||||
|
title="Rename">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<form method="POST" action="{{ url_for('account_delete_key', key_id=key.id) }}"
|
||||||
|
style="display:inline;"
|
||||||
|
onsubmit="return confirm('Delete key "{{ key.name }}"?')">
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="Delete">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-3">No saved channel keys. Save keys for quick access on encode/decode pages.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_save_key %}
|
||||||
|
<hr>
|
||||||
|
<h6 class="text-muted mb-3">Add New Key</h6>
|
||||||
|
<form method="POST" action="{{ url_for('account_save_key') }}">
|
||||||
|
<div class="row g-2 mb-2">
|
||||||
|
<div class="col-5">
|
||||||
|
<input type="text" name="key_name" class="form-control form-control-sm"
|
||||||
|
placeholder="Key name" required maxlength="50">
|
||||||
|
</div>
|
||||||
|
<div class="col-7">
|
||||||
|
<input type="text" name="channel_key" class="form-control form-control-sm font-monospace"
|
||||||
|
placeholder="Channel key (32 hex chars)" required
|
||||||
|
pattern="[0-9a-fA-F\-]{32,39}" title="32 hex characters">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Save Key
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info mb-0 small">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Maximum of {{ max_channel_keys }} keys reached. Delete a key to add more.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logout -->
|
||||||
|
<div class="mt-4">
|
||||||
<a href="{{ url_for('logout') }}" class="btn btn-outline-danger w-100">
|
<a href="{{ url_for('logout') }}" class="btn btn-outline-danger w-100">
|
||||||
<i class="bi bi-box-arrow-left me-2"></i>Logout
|
<i class="bi bi-box-arrow-left me-2"></i>Logout
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Rename Modal -->
|
||||||
|
<div class="modal fade" id="renameModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-sm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="POST" id="renameForm">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title">Rename Key</h6>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="text" name="new_name" class="form-control" id="renameInput"
|
||||||
|
required maxlength="50">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Rename</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
function togglePassword(inputId, btn) {
|
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
|
||||||
const input = document.getElementById(inputId);
|
|
||||||
const icon = btn.querySelector('i');
|
|
||||||
if (input.type === 'password') {
|
|
||||||
input.type = 'text';
|
|
||||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
|
||||||
} else {
|
|
||||||
input.type = 'password';
|
|
||||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('accountForm')?.addEventListener('submit', function(e) {
|
function renameKey(keyId, currentName) {
|
||||||
const newPass = document.getElementById('newPasswordInput').value;
|
document.getElementById('renameInput').value = currentName;
|
||||||
const confirm = document.getElementById('newPasswordConfirmInput').value;
|
document.getElementById('renameForm').action = '/account/keys/' + keyId + '/rename';
|
||||||
if (newPass !== confirm) {
|
new bootstrap.Modal(document.getElementById('renameModal')).show();
|
||||||
e.preventDefault();
|
|
||||||
alert('New passwords do not match');
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
50
frontends/web/templates/admin/password_reset.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Password Reset - Stegasoo{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6 col-lg-5">
|
||||||
|
<div class="card border-warning">
|
||||||
|
<div class="card-header bg-warning text-dark">
|
||||||
|
<i class="bi bi-key fs-4 me-2"></i>
|
||||||
|
<span class="fs-5">Password Reset</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
<strong>Important:</strong> This password will only be shown once.
|
||||||
|
Make sure to share it with <strong>{{ username }}</strong> securely.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted">
|
||||||
|
The user's sessions have been invalidated. They will need to log in
|
||||||
|
again with the new password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label text-muted small">New Password for {{ username }}</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control form-control-lg font-monospace"
|
||||||
|
value="{{ password }}" readonly id="passwordField">
|
||||||
|
<button class="btn btn-outline-secondary" type="button"
|
||||||
|
onclick="copyField('passwordField')" title="Copy password">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid">
|
||||||
|
<a href="{{ url_for('admin_users') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-arrow-left me-2"></i>Back to Users
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
60
frontends/web/templates/admin/user_created.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}User Created - Stegasoo{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6 col-lg-5">
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="card-header bg-success text-white">
|
||||||
|
<i class="bi bi-check-circle fs-4 me-2"></i>
|
||||||
|
<span class="fs-5">User Created Successfully</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
<strong>Important:</strong> This password will only be shown once.
|
||||||
|
Make sure to share it with the user securely.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label text-muted small">Username</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control form-control-lg font-monospace"
|
||||||
|
value="{{ username }}" readonly id="usernameField">
|
||||||
|
<button class="btn btn-outline-secondary" type="button"
|
||||||
|
onclick="copyField('usernameField')" title="Copy username">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label text-muted small">Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control form-control-lg font-monospace"
|
||||||
|
value="{{ password }}" readonly id="passwordField">
|
||||||
|
<button class="btn btn-outline-secondary" type="button"
|
||||||
|
onclick="copyField('passwordField')" title="Copy password">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="{{ url_for('admin_user_new') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-person-plus me-2"></i>Add Another User
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-2"></i>Back to Users
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
166
frontends/web/templates/admin/user_new.html
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Add User - 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">
|
||||||
|
<i class="bi bi-person-plus fs-4 me-2"></i>
|
||||||
|
<span class="fs-5">Add New User</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="createUserForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">
|
||||||
|
<i class="bi bi-person me-1"></i> Username
|
||||||
|
</label>
|
||||||
|
<input type="text" name="username" id="usernameInput" class="form-control"
|
||||||
|
placeholder="e.g., john_doe or john@example.com"
|
||||||
|
pattern="[a-zA-Z0-9][a-zA-Z0-9_\-@.]{2,79}"
|
||||||
|
title="3-80 characters, letters/numbers/underscore/hyphen/@/."
|
||||||
|
required autofocus>
|
||||||
|
<div class="form-text">
|
||||||
|
Letters, numbers, underscore, hyphen, @ and . allowed.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">
|
||||||
|
<i class="bi bi-key me-1"></i> Password
|
||||||
|
</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" name="password" id="passwordInput"
|
||||||
|
class="form-control" value="{{ temp_password }}"
|
||||||
|
minlength="8" required>
|
||||||
|
<button class="btn btn-outline-secondary" type="button"
|
||||||
|
onclick="regeneratePassword()" title="Generate new password">
|
||||||
|
<i class="bi bi-arrow-repeat"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
Auto-generated password. You can edit or regenerate it.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="errorAlert" class="alert alert-danger d-none"></div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary flex-grow-1" id="createBtn">
|
||||||
|
<i class="bi bi-person-check me-2"></i>Create User
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Modal -->
|
||||||
|
<div class="modal fade" id="successModal" tabindex="-1" data-bs-backdrop="static">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content border-success">
|
||||||
|
<div class="modal-header bg-success text-white">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>User Created
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-warning mb-3 py-2">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
Password shown once. Copy it now.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label text-muted small mb-1">Username</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control font-monospace"
|
||||||
|
id="createdUsername" readonly>
|
||||||
|
<button class="btn btn-outline-secondary" type="button"
|
||||||
|
onclick="copyField('createdUsername')" title="Copy">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label text-muted small mb-1">Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control font-monospace"
|
||||||
|
id="createdPassword" readonly>
|
||||||
|
<button class="btn btn-outline-secondary" type="button"
|
||||||
|
onclick="copyField('createdPassword')" title="Copy">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end gap-2">
|
||||||
|
<button type="button" class="btn btn-primary" onclick="addAnother()">
|
||||||
|
<i class="bi bi-person-plus me-1"></i>Add Another
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary">
|
||||||
|
Done
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('createUserForm');
|
||||||
|
const errorAlert = document.getElementById('errorAlert');
|
||||||
|
const createBtn = document.getElementById('createBtn');
|
||||||
|
const successModal = new bootstrap.Modal(document.getElementById('successModal'));
|
||||||
|
|
||||||
|
form.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
errorAlert.classList.add('d-none');
|
||||||
|
createBtn.disabled = true;
|
||||||
|
createBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('{{ url_for("admin_user_new") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('createdUsername').value = data.username;
|
||||||
|
document.getElementById('createdPassword').value = data.password;
|
||||||
|
successModal.show();
|
||||||
|
} else {
|
||||||
|
errorAlert.textContent = data.error;
|
||||||
|
errorAlert.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorAlert.textContent = 'An error occurred. Please try again.';
|
||||||
|
errorAlert.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
createBtn.disabled = false;
|
||||||
|
createBtn.innerHTML = '<i class="bi bi-person-check me-2"></i>Create User';
|
||||||
|
});
|
||||||
|
|
||||||
|
function addAnother() {
|
||||||
|
successModal.hide();
|
||||||
|
document.getElementById('usernameInput').value = '';
|
||||||
|
regeneratePassword();
|
||||||
|
document.getElementById('usernameInput').focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
95
frontends/web/templates/admin/users.html
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Manage Users - Stegasoo{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-10 col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-people fs-4 me-2"></i>
|
||||||
|
<span class="fs-5">User Management</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
{{ user_count }} / {{ max_users }} users
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if can_create %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="{{ url_for('admin_user_new') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-person-plus me-2"></i>Add User
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-warning mb-4">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
Maximum of {{ max_users }} users reached.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i class="bi bi-person me-2"></i>
|
||||||
|
{{ user.username }}
|
||||||
|
{% if user.id == current_user.id %}
|
||||||
|
<span class="badge bg-info ms-2">You</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if user.is_admin %}
|
||||||
|
<span class="badge bg-warning text-dark">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>Admin
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">User</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
{{ user.created_at[:10] if user.created_at else 'Unknown' }}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if user.id != current_user.id %}
|
||||||
|
<form method="POST" action="{{ url_for('admin_user_reset_password', user_id=user.id) }}"
|
||||||
|
class="d-inline" onsubmit="return confirm('Reset password for {{ user.username }}?')">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-warning" title="Reset Password">
|
||||||
|
<i class="bi bi-key"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{ url_for('admin_user_delete', user_id=user.id) }}"
|
||||||
|
class="d-inline" onsubmit="return confirm('Delete user {{ user.username }}? This cannot be undone.')">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete User">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-muted small">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Admins can add up to {{ max_users }} regular users.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -14,7 +14,10 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="36" class="me-2">
|
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="36" class="me-2">
|
||||||
<span class="fw-bold">Stegasoo</span>
|
<span style="position: relative; display: inline-block; margin-top: -14px;">
|
||||||
|
<span class="fw-bold title-gold">Stegasoo</span>
|
||||||
|
<span class="badge bg-success" style="position: absolute; font-size: 0.45rem; bottom: -8px; right: 6px;">v4.1</span>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
@@ -38,6 +41,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/about"><i class="bi bi-info-circle me-1"></i> About</a>
|
<a class="nav-link" href="/about"><i class="bi bi-info-circle me-1"></i> About</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/tools"><i class="bi bi-tools me-1"></i> Tools</a>
|
||||||
|
</li>
|
||||||
{% if auth_enabled %}
|
{% if auth_enabled %}
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
@@ -46,6 +52,9 @@
|
|||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
||||||
<li><a class="dropdown-item" href="/account"><i class="bi bi-gear me-2"></i>Account</a></li>
|
<li><a class="dropdown-item" href="/account"><i class="bi bi-gear me-2"></i>Account</a></li>
|
||||||
|
{% if is_admin %}
|
||||||
|
<li><a class="dropdown-item" href="/admin/users"><i class="bi bi-people me-2"></i>Users</a></li>
|
||||||
|
{% endif %}
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li>
|
<li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -62,17 +71,22 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="container py-5">
|
<main class="container py-5">
|
||||||
|
<!-- Toast notifications container -->
|
||||||
|
<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) %}
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
{% for category, message in messages %}
|
||||||
<div class="alert alert-{{ 'danger' if category == 'error' else ('warning' if category == 'warning' else 'success') }} alert-dismissible fade show" role="alert">
|
<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="10000">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">
|
||||||
<i class="bi bi-{{ 'exclamation-triangle' if category == 'error' else ('exclamation-circle' if category == 'warning' else 'check-circle') }} me-2"></i>
|
<i class="bi bi-{{ 'exclamation-triangle' if category == 'error' else ('exclamation-circle' if category == 'warning' else 'check-circle') }} me-2"></i>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
@@ -87,6 +101,10 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// Initialize toasts (auto-hide after delay)
|
||||||
|
document.querySelectorAll('.toast').forEach(el => new bootstrap.Toast(el));
|
||||||
|
</script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -120,13 +120,11 @@
|
|||||||
<h6><i class="bi bi-check-circle me-2"></i>Message Decrypted Successfully!</h6>
|
<h6><i class="bi bi-check-circle me-2"></i>Message Decrypted Successfully!</h6>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="form-label text-muted">Decoded Message:</label>
|
<label class="form-label text-muted">Decoded Message: <small class="text-secondary">(click to copy)</small></label>
|
||||||
<div class="alert-message p-3 rounded bg-dark border border-secondary mb-2" id="decodedContent" style="white-space: pre-wrap;">{{ decoded_message }}</div>
|
<div class="alert-message p-3 rounded bg-dark border border-secondary mb-3" id="decodedContent" style="white-space: pre-wrap; cursor: pointer; transition: border-color 0.2s;"
|
||||||
<div class="d-flex justify-content-end mb-3">
|
onclick="navigator.clipboard.writeText(this.innerText).then(() => { this.style.borderColor = '#198754'; this.dataset.origText = this.innerHTML; this.innerHTML = '<i class=\'bi bi-check-circle text-success\'></i> Copied to clipboard!'; setTimeout(() => { this.innerHTML = this.dataset.origText; this.style.borderColor = ''; }, 1500); }).catch(() => alert('Failed to copy'))"
|
||||||
<button class="btn btn-sm btn-outline-light" onclick="navigator.clipboard.writeText(document.getElementById('decodedContent').innerText).then(() => { this.innerHTML = '<i class=\'bi bi-check\'></i> Copied!'; setTimeout(() => this.innerHTML = '<i class=\'bi bi-clipboard\'></i> Copy', 2000); }).catch(() => alert('Failed to copy'))">
|
onmouseover="this.style.borderColor = '#6c757d'"
|
||||||
<i class="bi bi-clipboard"></i> Copy
|
onmouseout="this.style.borderColor = ''">{{ decoded_message }}</div>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="/decode" class="btn btn-outline-light w-100">
|
<a href="/decode" class="btn btn-outline-light w-100">
|
||||||
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
|
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
|
||||||
@@ -344,26 +342,33 @@
|
|||||||
<div class="security-box h-100">
|
<div class="security-box h-100">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
<i class="bi bi-broadcast me-1"></i> Channel
|
<i class="bi bi-broadcast me-1"></i> Channel
|
||||||
<span class="badge bg-info ms-1">v4.0</span>
|
<span class="badge bg-info ms-1">v4.1</span>
|
||||||
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
|
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<select class="form-select" name="channel_key" id="channelSelectDec">
|
<select class="form-select" name="channel_key" id="channelSelectDec">
|
||||||
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
|
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
|
||||||
<option value="none">Public</option>
|
<option value="none">Public</option>
|
||||||
<option value="custom">Custom</option>
|
{% if saved_channel_keys %}
|
||||||
|
<optgroup label="Saved Keys">
|
||||||
|
{% for key in saved_channel_keys %}
|
||||||
|
<option value="{{ key.channel_key }}" data-key-id="{{ key.id }}">{{ key.name }} ({{ key.channel_key[:4] }}...)</option>
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
<option value="custom">Custom...</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Server channel indicator (compact) -->
|
<!-- Server channel indicator (compact) -->
|
||||||
{% if channel_configured %}
|
<div class="small text-success mt-2 {% if not channel_configured %}d-none{% endif %}" id="channelServerInfoDec" data-fingerprint="{{ (channel_fingerprint[:4] if channel_fingerprint else '') }}-••••-···-••••-{{ channel_fingerprint[-4:] if channel_fingerprint else '' }}">
|
||||||
<div class="small text-success mt-2">
|
{% if channel_configured and channel_fingerprint %}
|
||||||
<i class="bi bi-shield-lock me-1"></i>
|
<i class="bi bi-shield-lock me-1"></i>
|
||||||
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
|
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Custom Channel Key Input (shown when Custom selected) -->
|
<!-- Custom Channel Key Input (shown when Custom selected) -->
|
||||||
<div class="mb-4 d-none" id="channelCustomInputDec">
|
<div class="mb-4 d-none" id="channelCustomInputDec">
|
||||||
|
|||||||
@@ -411,26 +411,33 @@
|
|||||||
<div class="security-box h-100">
|
<div class="security-box h-100">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
<i class="bi bi-broadcast me-1"></i> Channel
|
<i class="bi bi-broadcast me-1"></i> Channel
|
||||||
<span class="badge bg-info ms-1">v4.0</span>
|
<span class="badge bg-info ms-1">v4.1</span>
|
||||||
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
|
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<select class="form-select" name="channel_key" id="channelSelect">
|
<select class="form-select" name="channel_key" id="channelSelect">
|
||||||
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
|
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
|
||||||
<option value="none">Public</option>
|
<option value="none">Public</option>
|
||||||
<option value="custom">Custom</option>
|
{% if saved_channel_keys %}
|
||||||
|
<optgroup label="Saved Keys">
|
||||||
|
{% for key in saved_channel_keys %}
|
||||||
|
<option value="{{ key.channel_key }}" data-key-id="{{ key.id }}">{{ key.name }} ({{ key.channel_key[:4] }}...)</option>
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
<option value="custom">Custom...</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Server channel indicator (compact) -->
|
<!-- Server channel indicator (compact) -->
|
||||||
{% if channel_configured %}
|
<div class="small text-success mt-2 {% if not channel_configured %}d-none{% endif %}" id="channelServerInfo" data-fingerprint="{{ (channel_fingerprint[:4] if channel_fingerprint else '') }}-••••-···-••••-{{ channel_fingerprint[-4:] if channel_fingerprint else '' }}">
|
||||||
<div class="small text-success mt-2" id="channelServerInfo">
|
{% if channel_configured and channel_fingerprint %}
|
||||||
<i class="bi bi-shield-lock me-1"></i>
|
<i class="bi bi-shield-lock me-1"></i>
|
||||||
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
|
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Custom Channel Key Input (shown when Custom selected) -->
|
<!-- Custom Channel Key Input (shown when Custom selected) -->
|
||||||
<div class="mb-4 d-none" id="channelCustomInput">
|
<div class="mb-4 d-none" id="channelCustomInput">
|
||||||
|
|||||||
@@ -74,22 +74,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="my-4">
|
<button type="submit" class="btn btn-primary btn-lg w-100 mt-4">
|
||||||
|
<i class="bi bi-shuffle me-2"></i>Generate Credentials
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<!-- Channel Key Generation (v4.0.0) -->
|
<!-- Channel Key Accordion (Advanced) -->
|
||||||
<div class="mb-4">
|
<div class="accordion mt-4" id="advancedAccordion">
|
||||||
<label class="form-label">
|
<div class="accordion-item bg-dark">
|
||||||
<i class="bi bi-broadcast me-1"></i> Channel Key
|
<h2 class="accordion-header">
|
||||||
<span class="badge bg-info ms-1">v4.0</span>
|
<button class="accordion-button collapsed bg-dark text-light" type="button"
|
||||||
<a href="{{ url_for('about') }}#channel-keys" class="text-muted ms-2" title="Learn about channel keys">
|
data-bs-toggle="collapse" data-bs-target="#channelKeyCollapse">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-broadcast me-2"></i>Channel Key
|
||||||
</a>
|
<span class="badge bg-info ms-2">Advanced</span>
|
||||||
</label>
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="channelKeyCollapse" class="accordion-collapse collapse" data-bs-parent="#advancedAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Channel keys create private encoding channels. Only users with the same key can decode each other's images.
|
||||||
|
<a href="{{ url_for('about') }}#channel-keys" class="text-info">Learn more</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||||
<input type="text" class="form-control font-monospace" id="channelKeyGenerated"
|
<input type="text" class="form-control font-monospace" id="channelKeyGenerated"
|
||||||
placeholder="Click Generate" readonly>
|
placeholder="Click Generate to create a key" readonly>
|
||||||
<button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn">
|
<button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn">
|
||||||
<i class="bi bi-shuffle me-1"></i>Generate
|
<i class="bi bi-shuffle me-1"></i>Generate
|
||||||
</button>
|
</button>
|
||||||
@@ -97,13 +107,14 @@
|
|||||||
<i class="bi bi-clipboard"></i>
|
<i class="bi bi-clipboard"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">For private groups: generate, then use <strong>Custom</strong> mode when encoding/decoding.</div>
|
<div class="form-text mt-2">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
After generating, configure this key in your server's environment or use <strong>Custom</strong> channel mode when encoding/decoding.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg w-100 mt-3">
|
|
||||||
<i class="bi bi-shuffle me-2"></i>Generate Credentials
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Generated Credentials Display -->
|
<!-- Generated Credentials Display -->
|
||||||
@@ -498,61 +509,12 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/generate.js') }}"></script>
|
||||||
// ============================================================================
|
|
||||||
// GENERATE PAGE - Form Controls
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// Words range slider
|
|
||||||
const wordsRange = document.getElementById('wordsRange');
|
|
||||||
const wordsValue = document.getElementById('wordsValue');
|
|
||||||
|
|
||||||
wordsRange?.addEventListener('input', function() {
|
|
||||||
const bits = this.value * 11;
|
|
||||||
wordsValue.textContent = `${this.value} words (~${bits} bits)`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle PIN/RSA options visibility
|
|
||||||
const usePinCheck = document.getElementById('usePinCheck');
|
|
||||||
const useRsaCheck = document.getElementById('useRsaCheck');
|
|
||||||
const pinOptions = document.getElementById('pinOptions');
|
|
||||||
const rsaOptions = document.getElementById('rsaOptions');
|
|
||||||
const rsaQrWarning = document.getElementById('rsaQrWarning');
|
|
||||||
const rsaBitsSelect = document.getElementById('rsaBitsSelect');
|
|
||||||
|
|
||||||
usePinCheck?.addEventListener('change', function() {
|
|
||||||
pinOptions?.classList.toggle('d-none', !this.checked);
|
|
||||||
});
|
|
||||||
|
|
||||||
useRsaCheck?.addEventListener('change', function() {
|
|
||||||
rsaOptions?.classList.toggle('d-none', !this.checked);
|
|
||||||
});
|
|
||||||
|
|
||||||
// RSA key size QR warning (>3072 bits)
|
|
||||||
rsaBitsSelect?.addEventListener('change', function() {
|
|
||||||
rsaQrWarning?.classList.toggle('d-none', parseInt(this.value) <= 3072);
|
|
||||||
});
|
|
||||||
|
|
||||||
{% if generated %}
|
{% if generated %}
|
||||||
// ============================================================================
|
<script>
|
||||||
// GENERATE PAGE - Credential Display
|
// Page-specific data from Jinja
|
||||||
// ============================================================================
|
const passphraseWords = '{{ passphrase|default("", true) }}'.split(' ').filter(w => w.length > 0);
|
||||||
|
|
||||||
// PIN visibility toggle
|
|
||||||
let pinHidden = false;
|
|
||||||
function togglePinVisibility() {
|
|
||||||
const pinDigits = document.getElementById('pinDigits');
|
|
||||||
const icon = document.getElementById('pinToggleIcon');
|
|
||||||
const text = document.getElementById('pinToggleText');
|
|
||||||
|
|
||||||
pinHidden = !pinHidden;
|
|
||||||
pinDigits?.classList.toggle('blurred', pinHidden);
|
|
||||||
|
|
||||||
if (icon) icon.className = pinHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
|
||||||
if (text) text.textContent = pinHidden ? 'Show' : 'Hide';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy PIN
|
|
||||||
function copyPin() {
|
function copyPin() {
|
||||||
Stegasoo.copyToClipboard(
|
Stegasoo.copyToClipboard(
|
||||||
'{{ pin|default("", true) }}',
|
'{{ pin|default("", true) }}',
|
||||||
@@ -561,21 +523,6 @@ function copyPin() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passphrase visibility toggle
|
|
||||||
let passphraseHidden = false;
|
|
||||||
function togglePassphraseVisibility() {
|
|
||||||
const display = document.getElementById('passphraseDisplay');
|
|
||||||
const icon = document.getElementById('passphraseToggleIcon');
|
|
||||||
const text = document.getElementById('passphraseToggleText');
|
|
||||||
|
|
||||||
passphraseHidden = !passphraseHidden;
|
|
||||||
display?.classList.toggle('blurred', passphraseHidden);
|
|
||||||
|
|
||||||
if (icon) icon.className = passphraseHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
|
||||||
if (text) text.textContent = passphraseHidden ? 'Show' : 'Hide';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy passphrase
|
|
||||||
function copyPassphrase() {
|
function copyPassphrase() {
|
||||||
Stegasoo.copyToClipboard(
|
Stegasoo.copyToClipboard(
|
||||||
'{{ passphrase|default("", true) }}',
|
'{{ passphrase|default("", true) }}',
|
||||||
@@ -584,148 +531,13 @@ function copyPassphrase() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Memory Aid Story Generation - Templates by word count
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const passphrase = '{{ passphrase|default("", true) }}';
|
|
||||||
const passphraseWords = passphrase.split(' ').filter(w => w.length > 0);
|
|
||||||
let currentStoryTemplate = 0;
|
|
||||||
|
|
||||||
// Templates organized by word count (3-12 words supported)
|
|
||||||
const storyTemplatesByLength = {
|
|
||||||
3: [
|
|
||||||
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])}.`,
|
|
||||||
w => `${hl(w[0])} loves ${hl(w[1])} and ${hl(w[2])}.`,
|
|
||||||
w => `A ${hl(w[0])} found a ${hl(w[1])} near the ${hl(w[2])}.`,
|
|
||||||
w => `${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])} — never forget.`,
|
|
||||||
w => `The ${hl(w[0])} hid the ${hl(w[1])} under the ${hl(w[2])}.`,
|
|
||||||
],
|
|
||||||
4: [
|
|
||||||
w => `${hl(w[0])} and ${hl(w[1])} discovered a ${hl(w[2])} made of ${hl(w[3])}.`,
|
|
||||||
w => `The ${hl(w[0])} ${hl(w[1])} ate ${hl(w[2])} for ${hl(w[3])}.`,
|
|
||||||
w => `In the ${hl(w[0])}, a ${hl(w[1])} met a ${hl(w[2])} carrying ${hl(w[3])}.`,
|
|
||||||
w => `${hl(w[0])} said "${hl(w[1])}" while holding a ${hl(w[2])} ${hl(w[3])}.`,
|
|
||||||
w => `The secret: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}.`,
|
|
||||||
],
|
|
||||||
5: [
|
|
||||||
w => `${hl(w[0])} traveled to ${hl(w[1])} seeking the ${hl(w[2])} of ${hl(w[3])} and ${hl(w[4])}.`,
|
|
||||||
w => `The ${hl(w[0])} ${hl(w[1])} lived in a ${hl(w[2])} house with ${hl(w[3])} ${hl(w[4])}.`,
|
|
||||||
w => `"${hl(w[0])}!" shouted ${hl(w[1])} as the ${hl(w[2])} ${hl(w[3])} flew toward ${hl(w[4])}.`,
|
|
||||||
w => `Captain ${hl(w[0])} sailed the ${hl(w[1])} ${hl(w[2])} searching for ${hl(w[3])} ${hl(w[4])}.`,
|
|
||||||
w => `In ${hl(w[0])} kingdom, ${hl(w[1])} guards protected the ${hl(w[2])} ${hl(w[3])} ${hl(w[4])}.`,
|
|
||||||
],
|
|
||||||
6: [
|
|
||||||
w => `${hl(w[0])} met ${hl(w[1])} at the ${hl(w[2])}. Together they found ${hl(w[3])}, ${hl(w[4])}, and ${hl(w[5])}.`,
|
|
||||||
w => `The ${hl(w[0])} ${hl(w[1])} wore a ${hl(w[2])} hat while eating ${hl(w[3])} ${hl(w[4])} ${hl(w[5])}.`,
|
|
||||||
w => `Detective ${hl(w[0])} found ${hl(w[1])} ${hl(w[2])} near the ${hl(w[3])} ${hl(w[4])} ${hl(w[5])}.`,
|
|
||||||
w => `In the ${hl(w[0])} ${hl(w[1])}, a ${hl(w[2])} ${hl(w[3])} sang about ${hl(w[4])} ${hl(w[5])}.`,
|
|
||||||
w => `Chef ${hl(w[0])} combined ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, and ${hl(w[5])}.`,
|
|
||||||
],
|
|
||||||
7: [
|
|
||||||
w => `${hl(w[0])} and ${hl(w[1])} walked through the ${hl(w[2])} ${hl(w[3])} to find the ${hl(w[4])} ${hl(w[5])} ${hl(w[6])}.`,
|
|
||||||
w => `The ${hl(w[0])} professor studied ${hl(w[1])} ${hl(w[2])} while drinking ${hl(w[3])} ${hl(w[4])} with ${hl(w[5])} ${hl(w[6])}.`,
|
|
||||||
w => `"${hl(w[0])} ${hl(w[1])}!" yelled ${hl(w[2])} as ${hl(w[3])} ${hl(w[4])} attacked the ${hl(w[5])} ${hl(w[6])}.`,
|
|
||||||
w => `In ${hl(w[0])}, King ${hl(w[1])} decreed that ${hl(w[2])} ${hl(w[3])} must honor ${hl(w[4])} ${hl(w[5])} ${hl(w[6])}.`,
|
|
||||||
],
|
|
||||||
8: [
|
|
||||||
w => `${hl(w[0])} ${hl(w[1])} and ${hl(w[2])} ${hl(w[3])} met at the ${hl(w[4])} ${hl(w[5])} to discuss ${hl(w[6])} ${hl(w[7])}.`,
|
|
||||||
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])} traveled from ${hl(w[3])} to ${hl(w[4])} carrying ${hl(w[5])} ${hl(w[6])} ${hl(w[7])}.`,
|
|
||||||
w => `${hl(w[0])} discovered that ${hl(w[1])} ${hl(w[2])} plus ${hl(w[3])} ${hl(w[4])} equals ${hl(w[5])} ${hl(w[6])} ${hl(w[7])}.`,
|
|
||||||
],
|
|
||||||
9: [
|
|
||||||
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} watched as ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} danced with ${hl(w[6])} ${hl(w[7])} ${hl(w[8])}.`,
|
|
||||||
w => `In the ${hl(w[0])} ${hl(w[1])} ${hl(w[2])}, three friends — ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])} — found ${hl(w[6])} ${hl(w[7])} ${hl(w[8])}.`,
|
|
||||||
w => `The recipe: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])}, ${hl(w[6])}, ${hl(w[7])}, ${hl(w[8])}.`,
|
|
||||||
],
|
|
||||||
10: [
|
|
||||||
w => `${hl(w[0])} ${hl(w[1])} told ${hl(w[2])} ${hl(w[3])} about the ${hl(w[4])} ${hl(w[5])} ${hl(w[6])} hidden in ${hl(w[7])} ${hl(w[8])} ${hl(w[9])}.`,
|
|
||||||
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])} ${hl(w[3])} ${hl(w[4])} lived beside ${hl(w[5])} ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])}.`,
|
|
||||||
],
|
|
||||||
11: [
|
|
||||||
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} and ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} discovered ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])}.`,
|
|
||||||
w => `In ${hl(w[0])} ${hl(w[1])}, the ${hl(w[2])} ${hl(w[3])} ${hl(w[4])} sang of ${hl(w[5])} ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])}.`,
|
|
||||||
],
|
|
||||||
12: [
|
|
||||||
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} met ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} at the ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])} ${hl(w[11])}.`,
|
|
||||||
w => `The twelve treasures: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])}, ${hl(w[6])}, ${hl(w[7])}, ${hl(w[8])}, ${hl(w[9])}, ${hl(w[10])}, ${hl(w[11])}.`,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function hl(word) {
|
|
||||||
return `<span class="passphrase-word">${word}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateStory(idx = null) {
|
|
||||||
const count = passphraseWords.length;
|
|
||||||
if (count === 0) return '';
|
|
||||||
|
|
||||||
// Clamp to supported range (3-12)
|
|
||||||
const templateKey = Math.max(3, Math.min(12, count));
|
|
||||||
const templates = storyTemplatesByLength[templateKey];
|
|
||||||
|
|
||||||
if (!templates || templates.length === 0) {
|
|
||||||
// Fallback: just list the words
|
|
||||||
return passphraseWords.map(w => hl(w)).join(' — ');
|
|
||||||
}
|
|
||||||
|
|
||||||
const templateIdx = (idx ?? currentStoryTemplate) % templates.length;
|
|
||||||
return templates[templateIdx](passphraseWords);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMemoryAid() {
|
function toggleMemoryAid() {
|
||||||
const container = document.getElementById('memoryAidContainer');
|
StegasooGenerate.toggleMemoryAid(passphraseWords);
|
||||||
const icon = document.getElementById('memoryAidIcon');
|
|
||||||
const text = document.getElementById('memoryAidText');
|
|
||||||
|
|
||||||
const isHidden = container?.classList.contains('d-none');
|
|
||||||
container?.classList.toggle('d-none', !isHidden);
|
|
||||||
|
|
||||||
if (icon) icon.className = isHidden ? 'bi bi-lightbulb-fill' : 'bi bi-lightbulb';
|
|
||||||
if (text) text.textContent = isHidden ? 'Hide Aid' : 'Memory Aid';
|
|
||||||
|
|
||||||
if (isHidden) {
|
|
||||||
document.getElementById('memoryStory').innerHTML = generateStory();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function regenerateStory() {
|
function regenerateStory() {
|
||||||
const count = passphraseWords.length;
|
StegasooGenerate.regenerateStory(passphraseWords);
|
||||||
const templateKey = Math.max(3, Math.min(12, count));
|
|
||||||
const templates = storyTemplatesByLength[templateKey] || [];
|
|
||||||
currentStoryTemplate = (currentStoryTemplate + 1) % Math.max(1, templates.length);
|
|
||||||
document.getElementById('memoryStory').innerHTML = generateStory(currentStoryTemplate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print QR code
|
|
||||||
function printQrCode() {
|
|
||||||
const qrImg = document.getElementById('qrCodeImage');
|
|
||||||
if (!qrImg) return;
|
|
||||||
|
|
||||||
const printWindow = window.open('', '_blank');
|
|
||||||
printWindow.document.write(`<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Stegasoo RSA Key QR Code</title>
|
|
||||||
<style>
|
|
||||||
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; font-family: sans-serif; }
|
|
||||||
img { max-width: 400px; }
|
|
||||||
.warning { margin-top: 20px; padding: 10px; border: 2px solid #ff9800; background: #fff3e0; max-width: 400px; text-align: center; font-size: 12px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h2>Stegasoo RSA Private Key</h2>
|
|
||||||
<img src="${qrImg.src}" alt="RSA Key QR Code">
|
|
||||||
<div class="warning">
|
|
||||||
<strong>⚠️ SECURITY WARNING</strong><br>
|
|
||||||
This QR code contains your unencrypted RSA private key.<br>
|
|
||||||
Store securely and destroy after use.
|
|
||||||
</div>
|
|
||||||
<script>window.onload = function() { window.print(); }<\/script>
|
|
||||||
</body>
|
|
||||||
</html>`);
|
|
||||||
printWindow.document.close();
|
|
||||||
}
|
|
||||||
{% endif %}
|
|
||||||
</script>
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
<div class="d-flex align-items-end justify-content-center gap-4">
|
<div class="d-flex align-items-end justify-content-center gap-4">
|
||||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="155">
|
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="155">
|
||||||
<div style="margin-bottom: 40px;">
|
<div style="margin-bottom: 40px;">
|
||||||
<h1 class="display-4 fw-bold mb-2">
|
<h1 class="display-4 fw-bold mb-2 title-gold">
|
||||||
Stegasoo
|
Stegasoo
|
||||||
<span class="badge bg-success fs-6 ms-2">v4.0</span>
|
<span class="badge bg-success fs-6 ms-2">v4.1</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="lead text-muted mb-0">Hide encrypted data in plain sight.</p>
|
<p class="lead text-muted mb-0">Hide encrypted data in plain sight.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
<li class="mb-1">
|
<li class="mb-1">
|
||||||
<i class="bi bi-broadcast text-success me-2"></i>
|
<i class="bi bi-broadcast text-success me-2"></i>
|
||||||
<strong>Channel keys</strong> for group isolation
|
<strong>Channel keys</strong> for group isolation
|
||||||
<span class="badge bg-info ms-1">v4.0</span>
|
<span class="badge bg-info ms-1">v4.1</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<i class="bi bi-person me-1"></i> Username
|
<i class="bi bi-person me-1"></i> Username
|
||||||
</label>
|
</label>
|
||||||
<input type="text" name="username" class="form-control"
|
<input type="text" name="username" class="form-control"
|
||||||
value="{{ username }}" readonly>
|
placeholder="Enter your username" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="password" name="password" class="form-control"
|
<input type="password" name="password" class="form-control"
|
||||||
id="passwordInput" required autofocus>
|
id="passwordInput" required>
|
||||||
<button class="btn btn-outline-secondary" type="button"
|
<button class="btn btn-outline-secondary" type="button"
|
||||||
onclick="togglePassword('passwordInput', this)">
|
onclick="togglePassword('passwordInput', this)">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></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>
|
||||||
@@ -45,17 +51,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
function togglePassword(inputId, btn) {
|
|
||||||
const input = document.getElementById(inputId);
|
|
||||||
const icon = btn.querySelector('i');
|
|
||||||
if (input.type === 'password') {
|
|
||||||
input.type = 'text';
|
|
||||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
|
||||||
} else {
|
|
||||||
input.type = 'password';
|
|
||||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
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
@@ -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 %}
|
||||||
@@ -69,26 +69,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
function togglePassword(inputId, btn) {
|
StegasooAuth.initPasswordConfirmation('setupForm', 'passwordInput', 'passwordConfirmInput');
|
||||||
const input = document.getElementById(inputId);
|
|
||||||
const icon = btn.querySelector('i');
|
|
||||||
if (input.type === 'password') {
|
|
||||||
input.type = 'text';
|
|
||||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
|
||||||
} else {
|
|
||||||
input.type = 'password';
|
|
||||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('setupForm')?.addEventListener('submit', function(e) {
|
|
||||||
const pass = document.getElementById('passwordInput').value;
|
|
||||||
const confirm = document.getElementById('passwordConfirmInput').value;
|
|
||||||
if (pass !== confirm) {
|
|
||||||
e.preventDefault();
|
|
||||||
alert('Passwords do not match');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
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 %}
|
||||||
756
frontends/web/templates/tools.html
Normal file
@@ -0,0 +1,756 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Tools - Stegasoo{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
/* Tool drop zone - compact */
|
||||||
|
.tool-drop-zone {
|
||||||
|
position: relative;
|
||||||
|
min-height: 120px;
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-drop-zone.drag-over {
|
||||||
|
border-color: #63b3ed;
|
||||||
|
background: rgba(99, 179, 237, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-drop-zone input[type="file"] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-drop-zone .drop-label {
|
||||||
|
text-align: center;
|
||||||
|
padding: 25px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-drop-zone .drop-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-drop-zone.drag-over .drop-icon {
|
||||||
|
color: #63b3ed;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview state */
|
||||||
|
.tool-drop-zone.has-file .drop-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-drop-zone .preview-container {
|
||||||
|
display: none;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-drop-zone.has-file .preview-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-drop-zone .preview-thumb {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid rgba(99, 179, 237, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-drop-zone .preview-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-drop-zone .preview-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #63b3ed;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-drop-zone .preview-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-drop-zone .preview-clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 20;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-drop-zone .preview-clear:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Result panels */
|
||||||
|
.result-panel {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* EXIF table styling */
|
||||||
|
.exif-table {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-table th {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-input {
|
||||||
|
background: rgba(0, 0, 0, 0.3) !important;
|
||||||
|
border: 1px solid rgba(99, 179, 237, 0.3) !important;
|
||||||
|
color: #63b3ed !important;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-input:focus {
|
||||||
|
border-color: #63b3ed !important;
|
||||||
|
box-shadow: 0 0 10px rgba(99, 179, 237, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Processing state */
|
||||||
|
.processing .tool-drop-zone {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing .btn {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool section visibility */
|
||||||
|
.tool-section { display: none; }
|
||||||
|
.tool-section.active { display: block; }
|
||||||
|
|
||||||
|
/* Green→amber gradient (12.5% lighter) */
|
||||||
|
.tool-tabs .btn-outline-primary {
|
||||||
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.tool-tabs .btn-outline-primary:nth-of-type(1) {
|
||||||
|
color: #40d770;
|
||||||
|
border-color: #40d770;
|
||||||
|
}
|
||||||
|
.tool-tabs .btn-outline-primary:nth-of-type(2) {
|
||||||
|
color: #96da2c;
|
||||||
|
border-color: #96da2c;
|
||||||
|
}
|
||||||
|
.tool-tabs .btn-outline-primary:nth-of-type(3) {
|
||||||
|
color: #fdda64;
|
||||||
|
border-color: #fdda64;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-tabs .btn-outline-primary:nth-of-type(1):hover {
|
||||||
|
background-color: rgba(64, 215, 112, 0.15);
|
||||||
|
}
|
||||||
|
.tool-tabs .btn-outline-primary:nth-of-type(2):hover {
|
||||||
|
background-color: rgba(150, 218, 44, 0.15);
|
||||||
|
}
|
||||||
|
.tool-tabs .btn-outline-primary:nth-of-type(3):hover {
|
||||||
|
background-color: rgba(253, 218, 100, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-tabs .btn-check:checked + .btn-outline-primary:nth-of-type(1) {
|
||||||
|
background-color: #40d770;
|
||||||
|
border-color: #40d770;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
.tool-tabs .btn-check:checked + .btn-outline-primary:nth-of-type(2) {
|
||||||
|
background-color: #96da2c;
|
||||||
|
border-color: #96da2c;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
.tool-tabs .btn-check:checked + .btn-outline-primary:nth-of-type(3) {
|
||||||
|
background-color: #fdda64;
|
||||||
|
border-color: #fdda64;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-tools me-2"></i>Image Security Toolkit</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Tool Selector Tabs -->
|
||||||
|
<div class="btn-group tool-tabs w-100 mb-4" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="tool_type" id="toolCapacity" value="capacity" checked>
|
||||||
|
<label class="btn btn-outline-primary" for="toolCapacity">
|
||||||
|
<i class="bi bi-rulers me-1"></i> Capacity
|
||||||
|
</label>
|
||||||
|
<input type="radio" class="btn-check" name="tool_type" id="toolExif" value="exif">
|
||||||
|
<label class="btn btn-outline-primary" for="toolExif">
|
||||||
|
<i class="bi bi-card-text me-1"></i> EXIF
|
||||||
|
</label>
|
||||||
|
<input type="radio" class="btn-check" name="tool_type" id="toolStrip" value="strip">
|
||||||
|
<label class="btn btn-outline-primary" for="toolStrip">
|
||||||
|
<i class="bi bi-eraser me-1"></i> Strip
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- CAPACITY CALCULATOR -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="tool-section active" id="capacitySection">
|
||||||
|
<p class="text-muted small mb-3">Check how much data can be hidden in an image</p>
|
||||||
|
|
||||||
|
<div class="tool-drop-zone" id="capacityZone">
|
||||||
|
<input type="file" accept="image/*" id="capacityFile">
|
||||||
|
<div class="drop-label">
|
||||||
|
<i class="bi bi-image drop-icon d-block mb-2"></i>
|
||||||
|
<span class="text-muted">Drop image or click to browse</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-container">
|
||||||
|
<img class="preview-thumb" id="capacityThumb">
|
||||||
|
<div class="preview-info">
|
||||||
|
<div class="preview-name" id="capacityName">image.jpg</div>
|
||||||
|
<div class="preview-meta" id="capacityMeta">-- × -- · -- MB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary preview-clear d-none" id="capacityClear">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div class="result-panel p-3 mt-3 d-none" id="capacityResult">
|
||||||
|
<div class="row text-center">
|
||||||
|
<div class="col-6 col-md-3 mb-3 mb-md-0">
|
||||||
|
<div class="text-muted small">Dimensions</div>
|
||||||
|
<div class="fw-bold" id="capDimensions">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 mb-3 mb-md-0">
|
||||||
|
<div class="text-muted small">Megapixels</div>
|
||||||
|
<div class="fw-bold" id="capMegapixels">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="text-muted small">LSB Capacity</div>
|
||||||
|
<div class="fw-bold text-primary" id="capLsb">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="text-muted small">DCT Capacity</div>
|
||||||
|
<div class="fw-bold text-warning" id="capDct">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- EXIF EDITOR -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="tool-section" id="exifSection">
|
||||||
|
<p class="text-muted small mb-3">View, edit, or remove image metadata</p>
|
||||||
|
|
||||||
|
<div class="tool-drop-zone" id="exifZone">
|
||||||
|
<input type="file" accept="image/*" id="exifFile">
|
||||||
|
<div class="drop-label">
|
||||||
|
<i class="bi bi-card-image drop-icon d-block mb-2"></i>
|
||||||
|
<span class="text-muted">Drop image or click to browse</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-container">
|
||||||
|
<img class="preview-thumb" id="exifThumb">
|
||||||
|
<div class="preview-info">
|
||||||
|
<div class="preview-name" id="exifName">image.jpg</div>
|
||||||
|
<div class="preview-meta"><span id="exifFieldCount">0</span> metadata fields</div>
|
||||||
|
<div id="exifNotEditable" class="text-warning small d-none">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>Non-JPEG: clear only
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary preview-clear d-none" id="exifClear">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EXIF Data Editor -->
|
||||||
|
<div id="exifEditor" class="d-none mt-3">
|
||||||
|
<div class="table-responsive result-panel" style="max-height: 250px; overflow-y: auto;">
|
||||||
|
<table class="table table-sm table-dark table-hover exif-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 35%">Field</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th style="width: 40px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="exifTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="exifEmpty" class="result-panel text-muted text-center py-4 d-none">
|
||||||
|
<i class="bi bi-inbox fs-4 d-block mb-2"></i>No metadata found
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="d-flex gap-2 mt-3 pt-3 border-top border-secondary">
|
||||||
|
<button type="button" class="btn btn-outline-danger" id="exifClearAll">
|
||||||
|
<i class="bi bi-trash me-1"></i>Clear All
|
||||||
|
</button>
|
||||||
|
<div class="ms-auto d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="exifDiscard">
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="exifSave" disabled>
|
||||||
|
<i class="bi bi-download me-1"></i>Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- STRIP METADATA -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="tool-section" id="stripSection">
|
||||||
|
<p class="text-muted small mb-3">Remove all EXIF data and get a clean image</p>
|
||||||
|
|
||||||
|
<div class="tool-drop-zone" id="stripZone">
|
||||||
|
<input type="file" accept="image/*" id="stripFile">
|
||||||
|
<div class="drop-label">
|
||||||
|
<i class="bi bi-file-earmark-x drop-icon d-block mb-2"></i>
|
||||||
|
<span class="text-muted">Drop image or click to browse</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-container">
|
||||||
|
<img class="preview-thumb" id="stripThumb">
|
||||||
|
<div class="preview-info">
|
||||||
|
<div class="preview-name" id="stripName">image.jpg</div>
|
||||||
|
<div class="preview-meta" id="stripMeta">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary preview-clear d-none" id="stripClearBtn">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Format selector and action -->
|
||||||
|
<div id="stripOptions" class="d-none mt-3">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<label class="form-label mb-0 small text-muted">Output:</label>
|
||||||
|
<select class="form-select form-select-sm" id="stripFormat" style="width: auto;">
|
||||||
|
<option value="PNG" selected>PNG (lossless)</option>
|
||||||
|
<option value="JPEG">JPEG</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-danger ms-auto" id="stripAction">
|
||||||
|
<i class="bi bi-eraser me-1"></i>Strip & Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// ============================================================================
|
||||||
|
// TAB SWITCHING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const toolRadios = document.querySelectorAll('input[name="tool_type"]');
|
||||||
|
const toolSections = {
|
||||||
|
capacity: document.getElementById('capacitySection'),
|
||||||
|
exif: document.getElementById('exifSection'),
|
||||||
|
strip: document.getElementById('stripSection')
|
||||||
|
};
|
||||||
|
|
||||||
|
function switchTool() {
|
||||||
|
const selected = document.querySelector('input[name="tool_type"]:checked').value;
|
||||||
|
Object.entries(toolSections).forEach(([key, section]) => {
|
||||||
|
section.classList.toggle('active', key === selected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toolRadios.forEach(radio => radio.addEventListener('change', switchTool));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SHARED - Drop zone helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function setupDropZone(zoneId, fileInputId, onFile) {
|
||||||
|
const zone = document.getElementById(zoneId);
|
||||||
|
const input = document.getElementById(fileInputId);
|
||||||
|
if (!zone || !input) return;
|
||||||
|
|
||||||
|
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
|
||||||
|
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
|
||||||
|
zone.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
zone.classList.remove('drag-over');
|
||||||
|
if (e.dataTransfer.files[0]) {
|
||||||
|
input.files = e.dataTransfer.files;
|
||||||
|
input.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('change', function() {
|
||||||
|
if (this.files[0]) onFile(this.files[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPreview(zoneId, file, thumbId, nameId, metaText, clearBtnId) {
|
||||||
|
const zone = document.getElementById(zoneId);
|
||||||
|
const thumb = document.getElementById(thumbId);
|
||||||
|
const name = document.getElementById(nameId);
|
||||||
|
const clearBtn = document.getElementById(clearBtnId);
|
||||||
|
|
||||||
|
zone.classList.add('has-file');
|
||||||
|
name.textContent = file.name;
|
||||||
|
|
||||||
|
if (metaText) {
|
||||||
|
const metaEl = name.nextElementSibling;
|
||||||
|
if (metaEl) metaEl.textContent = metaText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => thumb.src = e.target.result;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
clearBtn?.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDropZone(zoneId, fileInputId, clearBtnId, extraCleanup) {
|
||||||
|
const zone = document.getElementById(zoneId);
|
||||||
|
const input = document.getElementById(fileInputId);
|
||||||
|
const clearBtn = document.getElementById(clearBtnId);
|
||||||
|
|
||||||
|
zone?.classList.remove('has-file');
|
||||||
|
if (input) input.value = '';
|
||||||
|
clearBtn?.classList.add('d-none');
|
||||||
|
if (extraCleanup) extraCleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CAPACITY CALCULATOR
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
setupDropZone('capacityZone', 'capacityFile', async (file) => {
|
||||||
|
showPreview('capacityZone', file, 'capacityThumb', 'capacityName', formatBytes(file.size), 'capacityClear');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tools/capacity', { method: 'POST', body: formData });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('capacityMeta').textContent =
|
||||||
|
`${data.width} × ${data.height} · ${formatBytes(file.size)}`;
|
||||||
|
document.getElementById('capDimensions').textContent = `${data.width} × ${data.height}`;
|
||||||
|
document.getElementById('capMegapixels').textContent = data.megapixels + ' MP';
|
||||||
|
document.getElementById('capLsb').textContent = data.lsb.capacity_kb.toFixed(1) + ' KB';
|
||||||
|
document.getElementById('capDct').textContent = data.dct.available
|
||||||
|
? data.dct.capacity_kb.toFixed(1) + ' KB'
|
||||||
|
: 'N/A';
|
||||||
|
document.getElementById('capacityResult').classList.remove('d-none');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('capacityClear')?.addEventListener('click', () => {
|
||||||
|
clearDropZone('capacityZone', 'capacityFile', 'capacityClear', () => {
|
||||||
|
document.getElementById('capacityResult').classList.add('d-none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXIF EDITOR
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let exifOriginalData = {};
|
||||||
|
let exifCurrentData = {};
|
||||||
|
let exifEditable = false;
|
||||||
|
let exifCurrentFile = null;
|
||||||
|
|
||||||
|
setupDropZone('exifZone', 'exifFile', async (file) => {
|
||||||
|
exifCurrentFile = file;
|
||||||
|
showPreview('exifZone', file, 'exifThumb', 'exifName', '', 'exifClear');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tools/exif', { method: 'POST', body: formData });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
exifOriginalData = JSON.parse(JSON.stringify(data.exif));
|
||||||
|
exifCurrentData = JSON.parse(JSON.stringify(data.exif));
|
||||||
|
exifEditable = data.editable;
|
||||||
|
|
||||||
|
document.getElementById('exifFieldCount').textContent = data.field_count;
|
||||||
|
document.getElementById('exifNotEditable').classList.toggle('d-none', data.editable);
|
||||||
|
document.getElementById('exifEditor').classList.remove('d-none');
|
||||||
|
|
||||||
|
renderExifTable();
|
||||||
|
updateSaveButton();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('exifClear')?.addEventListener('click', () => {
|
||||||
|
clearDropZone('exifZone', 'exifFile', 'exifClear', () => {
|
||||||
|
document.getElementById('exifEditor').classList.add('d-none');
|
||||||
|
exifCurrentFile = null;
|
||||||
|
exifOriginalData = {};
|
||||||
|
exifCurrentData = {};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderExifTable() {
|
||||||
|
const tbody = document.getElementById('exifTable');
|
||||||
|
const empty = document.getElementById('exifEmpty');
|
||||||
|
const entries = Object.entries(exifCurrentData).sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
empty.classList.add('d-none');
|
||||||
|
tbody.innerHTML = entries.map(([key, value]) => {
|
||||||
|
let displayVal = typeof value === 'object' ? JSON.stringify(value) : value;
|
||||||
|
if (typeof displayVal === 'string' && displayVal.length > 50) {
|
||||||
|
displayVal = displayVal.substring(0, 47) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableFields = ['Make', 'Model', 'Software', 'Artist', 'Copyright', 'ImageDescription', 'DateTime', 'DateTimeOriginal', 'DateTimeDigitized', 'UserComment', 'LensMake', 'LensModel'];
|
||||||
|
const canEdit = exifEditable && editableFields.includes(key) && typeof value === 'string';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr data-field="${key}">
|
||||||
|
<td class="text-muted small">${key}</td>
|
||||||
|
<td class="font-monospace small">
|
||||||
|
${canEdit
|
||||||
|
? `<input type="text" class="form-control form-control-sm exif-input"
|
||||||
|
value="${String(value).replace(/"/g, '"')}" data-field="${key}">`
|
||||||
|
: `<span title="${String(displayVal)}">${displayVal}</span>`
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
${canEdit
|
||||||
|
? `<button class="btn btn-sm btn-outline-danger border-0 exif-delete" data-field="${key}" title="Remove">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
tbody.querySelectorAll('.exif-input').forEach(input => {
|
||||||
|
input.addEventListener('input', function() {
|
||||||
|
exifCurrentData[this.dataset.field] = this.value;
|
||||||
|
updateSaveButton();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.querySelectorAll('.exif-delete').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
delete exifCurrentData[this.dataset.field];
|
||||||
|
renderExifTable();
|
||||||
|
updateSaveButton();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSaveButton() {
|
||||||
|
const changed = JSON.stringify(exifCurrentData) !== JSON.stringify(exifOriginalData);
|
||||||
|
document.getElementById('exifSave').disabled = !changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('exifClearAll')?.addEventListener('click', async function() {
|
||||||
|
if (!exifCurrentFile) return;
|
||||||
|
if (!confirm('Remove all metadata from this image?')) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', exifCurrentFile);
|
||||||
|
formData.append('format', 'PNG');
|
||||||
|
|
||||||
|
const btn = this;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Clearing...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tools/exif/clear', { method: 'POST', body: formData });
|
||||||
|
if (res.ok) {
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || 'clean.png';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
exifCurrentData = {};
|
||||||
|
exifOriginalData = {};
|
||||||
|
renderExifTable();
|
||||||
|
} else {
|
||||||
|
alert('Failed to clear metadata');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to clear metadata: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-trash me-1"></i>Clear All';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('exifDiscard')?.addEventListener('click', function() {
|
||||||
|
exifCurrentData = JSON.parse(JSON.stringify(exifOriginalData));
|
||||||
|
renderExifTable();
|
||||||
|
updateSaveButton();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('exifSave')?.addEventListener('click', async function() {
|
||||||
|
if (!exifCurrentFile || !exifEditable) return;
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
for (const [key, val] of Object.entries(exifCurrentData)) {
|
||||||
|
if (exifOriginalData[key] !== val) updates[key] = val;
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(exifOriginalData)) {
|
||||||
|
if (!(key in exifCurrentData)) updates[key] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', exifCurrentFile);
|
||||||
|
formData.append('updates', JSON.stringify(updates));
|
||||||
|
|
||||||
|
const btn = this;
|
||||||
|
const originalHtml = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tools/exif/update', { method: 'POST', body: formData });
|
||||||
|
if (res.ok) {
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || 'updated.jpg';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
exifOriginalData = JSON.parse(JSON.stringify(exifCurrentData));
|
||||||
|
updateSaveButton();
|
||||||
|
} else {
|
||||||
|
alert('Failed to save');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to save changes: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalHtml;
|
||||||
|
updateSaveButton();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STRIP METADATA
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let stripCurrentFile = null;
|
||||||
|
|
||||||
|
setupDropZone('stripZone', 'stripFile', (file) => {
|
||||||
|
stripCurrentFile = file;
|
||||||
|
showPreview('stripZone', file, 'stripThumb', 'stripName', formatBytes(file.size), 'stripClearBtn');
|
||||||
|
document.getElementById('stripMeta').textContent = formatBytes(file.size);
|
||||||
|
document.getElementById('stripOptions').classList.remove('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('stripClearBtn')?.addEventListener('click', () => {
|
||||||
|
clearDropZone('stripZone', 'stripFile', 'stripClearBtn', () => {
|
||||||
|
document.getElementById('stripOptions').classList.add('d-none');
|
||||||
|
stripCurrentFile = null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('stripAction')?.addEventListener('click', async function() {
|
||||||
|
if (!stripCurrentFile) return;
|
||||||
|
|
||||||
|
const format = document.getElementById('stripFormat').value;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', stripCurrentFile);
|
||||||
|
formData.append('format', format);
|
||||||
|
|
||||||
|
const btn = this;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Processing...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tools/exif/clear', { method: 'POST', body: formData });
|
||||||
|
if (res.ok) {
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || `clean.${format.toLowerCase()}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} else {
|
||||||
|
alert('Failed to strip metadata');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to strip metadata: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-eraser me-1"></i>Strip & Download';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
1
instance/.secret_key
Normal file
@@ -0,0 +1 @@
|
|||||||
|
6a7378172fc0ec37143720f09a4ca34e83ec2409893aa8cd79ace5b78a64276c
|
||||||
BIN
instance/stegasoo.db
Normal file
@@ -1,289 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Minimal Flask app to isolate the crash.
|
|
||||||
Run with: python minimal_flask_crash.py
|
|
||||||
|
|
||||||
Then test with:
|
|
||||||
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test1
|
|
||||||
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test2
|
|
||||||
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test3
|
|
||||||
"""
|
|
||||||
|
|
||||||
import io
|
|
||||||
import gc
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
# Minimal imports first
|
|
||||||
from flask import Flask, request, jsonify
|
|
||||||
from PIL import Image
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB
|
|
||||||
|
|
||||||
# Check for jpegio
|
|
||||||
try:
|
|
||||||
import jpegio as jio
|
|
||||||
HAS_JPEGIO = True
|
|
||||||
print("jpegio: available")
|
|
||||||
except ImportError:
|
|
||||||
HAS_JPEGIO = False
|
|
||||||
print("jpegio: NOT available")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/test1', methods=['POST'])
|
|
||||||
def test1_pil_only():
|
|
||||||
"""Test 1: PIL only, no jpegio, no scipy"""
|
|
||||||
carrier = request.files.get('carrier')
|
|
||||||
if not carrier:
|
|
||||||
return jsonify({'error': 'No carrier'}), 400
|
|
||||||
|
|
||||||
data = carrier.read()
|
|
||||||
print(f"[test1] Read {len(data)} bytes")
|
|
||||||
|
|
||||||
img = Image.open(io.BytesIO(data))
|
|
||||||
width, height = img.size
|
|
||||||
fmt = img.format
|
|
||||||
img.close()
|
|
||||||
print(f"[test1] Image: {width}x{height} {fmt}")
|
|
||||||
|
|
||||||
gc.collect()
|
|
||||||
print("[test1] Returning response...")
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'test': 'pil_only',
|
|
||||||
'width': width,
|
|
||||||
'height': height,
|
|
||||||
'format': fmt,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/test2', methods=['POST'])
|
|
||||||
def test2_multiple_opens():
|
|
||||||
"""Test 2: Open image multiple times like compare_modes does"""
|
|
||||||
carrier = request.files.get('carrier')
|
|
||||||
if not carrier:
|
|
||||||
return jsonify({'error': 'No carrier'}), 400
|
|
||||||
|
|
||||||
data = carrier.read()
|
|
||||||
print(f"[test2] Read {len(data)} bytes")
|
|
||||||
|
|
||||||
# First open
|
|
||||||
img1 = Image.open(io.BytesIO(data))
|
|
||||||
width, height = img1.size
|
|
||||||
img1.close()
|
|
||||||
print(f"[test2] Open 1: {width}x{height}")
|
|
||||||
|
|
||||||
# Second open
|
|
||||||
img2 = Image.open(io.BytesIO(data))
|
|
||||||
pixels = img2.size[0] * img2.size[1]
|
|
||||||
img2.close()
|
|
||||||
print(f"[test2] Open 2: {pixels} pixels")
|
|
||||||
|
|
||||||
# Third open
|
|
||||||
img3 = Image.open(io.BytesIO(data))
|
|
||||||
blocks = (img3.size[0] // 8) * (img3.size[1] // 8)
|
|
||||||
img3.close()
|
|
||||||
print(f"[test2] Open 3: {blocks} blocks")
|
|
||||||
|
|
||||||
gc.collect()
|
|
||||||
print("[test2] Returning response...")
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'test': 'multiple_opens',
|
|
||||||
'width': width,
|
|
||||||
'height': height,
|
|
||||||
'pixels': pixels,
|
|
||||||
'blocks': blocks,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/test3', methods=['POST'])
|
|
||||||
def test3_with_jpegio():
|
|
||||||
"""Test 3: Include jpegio operations"""
|
|
||||||
if not HAS_JPEGIO:
|
|
||||||
return jsonify({'error': 'jpegio not available'}), 501
|
|
||||||
|
|
||||||
carrier = request.files.get('carrier')
|
|
||||||
if not carrier:
|
|
||||||
return jsonify({'error': 'No carrier'}), 400
|
|
||||||
|
|
||||||
data = carrier.read()
|
|
||||||
print(f"[test3] Read {len(data)} bytes")
|
|
||||||
|
|
||||||
# Check if JPEG
|
|
||||||
img = Image.open(io.BytesIO(data))
|
|
||||||
is_jpeg = img.format == 'JPEG'
|
|
||||||
width, height = img.size
|
|
||||||
img.close()
|
|
||||||
print(f"[test3] Image: {width}x{height}, JPEG: {is_jpeg}")
|
|
||||||
|
|
||||||
if not is_jpeg:
|
|
||||||
return jsonify({'error': 'Not a JPEG'}), 400
|
|
||||||
|
|
||||||
# Write to temp file
|
|
||||||
fd, temp_path = tempfile.mkstemp(suffix='.jpg')
|
|
||||||
os.write(fd, data)
|
|
||||||
os.close(fd)
|
|
||||||
print(f"[test3] Temp file: {temp_path}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Read with jpegio
|
|
||||||
jpeg = jio.read(temp_path)
|
|
||||||
print(f"[test3] jpegio.read() OK")
|
|
||||||
|
|
||||||
coef = jpeg.coef_arrays[0]
|
|
||||||
coef_shape = coef.shape
|
|
||||||
print(f"[test3] Coef shape: {coef_shape}")
|
|
||||||
|
|
||||||
# Count positions like the real code does
|
|
||||||
positions = 0
|
|
||||||
h, w = coef.shape
|
|
||||||
for row in range(h):
|
|
||||||
for col in range(w):
|
|
||||||
if (row % 8 == 0) and (col % 8 == 0):
|
|
||||||
continue
|
|
||||||
if abs(coef[row, col]) >= 2:
|
|
||||||
positions += 1
|
|
||||||
print(f"[test3] Usable positions: {positions}")
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
del coef
|
|
||||||
del jpeg
|
|
||||||
print(f"[test3] Deleted jpegio objects")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
os.unlink(temp_path)
|
|
||||||
print(f"[test3] Removed temp file")
|
|
||||||
|
|
||||||
gc.collect()
|
|
||||||
print("[test3] Returning response...")
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'test': 'with_jpegio',
|
|
||||||
'width': width,
|
|
||||||
'height': height,
|
|
||||||
'coef_shape': list(coef_shape),
|
|
||||||
'positions': positions,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/test4', methods=['POST'])
|
|
||||||
def test4_numpy_array_from_pil():
|
|
||||||
"""Test 4: Create numpy array from PIL image (like DCT does)"""
|
|
||||||
carrier = request.files.get('carrier')
|
|
||||||
if not carrier:
|
|
||||||
return jsonify({'error': 'No carrier'}), 400
|
|
||||||
|
|
||||||
data = carrier.read()
|
|
||||||
print(f"[test4] Read {len(data)} bytes")
|
|
||||||
|
|
||||||
img = Image.open(io.BytesIO(data))
|
|
||||||
width, height = img.size
|
|
||||||
print(f"[test4] Image: {width}x{height}")
|
|
||||||
|
|
||||||
# Convert to grayscale and numpy array
|
|
||||||
gray = img.convert('L')
|
|
||||||
arr = np.array(gray, dtype=np.float64, copy=True)
|
|
||||||
print(f"[test4] Array: {arr.shape} {arr.dtype}")
|
|
||||||
|
|
||||||
# Close PIL images
|
|
||||||
gray.close()
|
|
||||||
img.close()
|
|
||||||
print(f"[test4] PIL closed")
|
|
||||||
|
|
||||||
# Do some numpy operations
|
|
||||||
mean_val = float(np.mean(arr))
|
|
||||||
std_val = float(np.std(arr))
|
|
||||||
print(f"[test4] Stats: mean={mean_val:.2f}, std={std_val:.2f}")
|
|
||||||
|
|
||||||
# Clear array
|
|
||||||
del arr
|
|
||||||
gc.collect()
|
|
||||||
print("[test4] Returning response...")
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'test': 'numpy_from_pil',
|
|
||||||
'width': width,
|
|
||||||
'height': height,
|
|
||||||
'mean': mean_val,
|
|
||||||
'std': std_val,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/test5', methods=['POST'])
|
|
||||||
def test5_file_read_keep_reference():
|
|
||||||
"""Test 5: Keep reference to file data in request scope"""
|
|
||||||
carrier = request.files.get('carrier')
|
|
||||||
if not carrier:
|
|
||||||
return jsonify({'error': 'No carrier'}), 400
|
|
||||||
|
|
||||||
# Don't read into local variable - read directly each time
|
|
||||||
# This mimics potential issues with Flask's file handling
|
|
||||||
|
|
||||||
print(f"[test5] File object: {carrier}")
|
|
||||||
|
|
||||||
# Read once
|
|
||||||
carrier.seek(0)
|
|
||||||
data1 = carrier.read()
|
|
||||||
print(f"[test5] First read: {len(data1)} bytes")
|
|
||||||
|
|
||||||
img = Image.open(io.BytesIO(data1))
|
|
||||||
width, height = img.size
|
|
||||||
img.close()
|
|
||||||
|
|
||||||
# Try to read again (should be empty or need seek)
|
|
||||||
data2 = carrier.read()
|
|
||||||
print(f"[test5] Second read (no seek): {len(data2)} bytes")
|
|
||||||
|
|
||||||
carrier.seek(0)
|
|
||||||
data3 = carrier.read()
|
|
||||||
print(f"[test5] Third read (after seek): {len(data3)} bytes")
|
|
||||||
|
|
||||||
gc.collect()
|
|
||||||
print("[test5] Returning response...")
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'test': 'file_handling',
|
|
||||||
'width': width,
|
|
||||||
'height': height,
|
|
||||||
'read1': len(data1),
|
|
||||||
'read2': len(data2),
|
|
||||||
'read3': len(data3),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.after_request
|
|
||||||
def after_request(response):
|
|
||||||
"""Log after each request"""
|
|
||||||
print(f"[after_request] Response status: {response.status}")
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.teardown_request
|
|
||||||
def teardown_request(exception):
|
|
||||||
"""Log during teardown"""
|
|
||||||
if exception:
|
|
||||||
print(f"[teardown] Exception: {exception}")
|
|
||||||
else:
|
|
||||||
print("[teardown] Clean teardown")
|
|
||||||
gc.collect()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("MINIMAL FLASK CRASH TEST")
|
|
||||||
print("=" * 60)
|
|
||||||
print("\nTest endpoints:")
|
|
||||||
print(" /test1 - PIL only")
|
|
||||||
print(" /test2 - Multiple PIL opens")
|
|
||||||
print(" /test3 - With jpegio")
|
|
||||||
print(" /test4 - NumPy array from PIL")
|
|
||||||
print(" /test5 - File handling test")
|
|
||||||
print("\nUsage:")
|
|
||||||
print(' curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test1')
|
|
||||||
print("=" * 60 + "\n")
|
|
||||||
|
|
||||||
app.run(host='0.0.0.0', port=5001, debug=False, threaded=False)
|
|
||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "stegasoo"
|
name = "stegasoo"
|
||||||
version = "4.0.1"
|
version = "4.1.1"
|
||||||
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"
|
||||||
@@ -48,10 +48,12 @@ dct = [
|
|||||||
"numpy>=2.0.0",
|
"numpy>=2.0.0",
|
||||||
"scipy>=1.10.0",
|
"scipy>=1.10.0",
|
||||||
"jpegio>=0.2.0",
|
"jpegio>=0.2.0",
|
||||||
|
"reedsolo>=1.7.0",
|
||||||
]
|
]
|
||||||
cli = [
|
cli = [
|
||||||
"click>=8.0.0",
|
"click>=8.0.0",
|
||||||
"qrcode>=7.30"
|
"qrcode>=7.30",
|
||||||
|
"piexif>=1.1.0",
|
||||||
]
|
]
|
||||||
compression = [
|
compression = [
|
||||||
"lz4>=4.0.0",
|
"lz4>=4.0.0",
|
||||||
@@ -61,10 +63,12 @@ web = [
|
|||||||
"gunicorn>=21.0.0",
|
"gunicorn>=21.0.0",
|
||||||
"qrcode>=7.3.0",
|
"qrcode>=7.3.0",
|
||||||
"pyzbar>=0.1.9",
|
"pyzbar>=0.1.9",
|
||||||
|
"piexif>=1.1.0",
|
||||||
# Include DCT support for web UI
|
# Include DCT support for web UI
|
||||||
"numpy>=2.0.0",
|
"numpy>=2.0.0",
|
||||||
"scipy>=1.10.0",
|
"scipy>=1.10.0",
|
||||||
"jpegio>=0.2.0",
|
"jpegio>=0.2.0",
|
||||||
|
"reedsolo>=1.7.0",
|
||||||
]
|
]
|
||||||
api = [
|
api = [
|
||||||
"fastapi>=0.100.0",
|
"fastapi>=0.100.0",
|
||||||
@@ -76,6 +80,7 @@ api = [
|
|||||||
"numpy>=2.0.0",
|
"numpy>=2.0.0",
|
||||||
"scipy>=1.10.0",
|
"scipy>=1.10.0",
|
||||||
"jpegio>=0.2.0",
|
"jpegio>=0.2.0",
|
||||||
|
"reedsolo>=1.7.0",
|
||||||
]
|
]
|
||||||
all = [
|
all = [
|
||||||
"stegasoo[cli,web,api,dct,compression]",
|
"stegasoo[cli,web,api,dct,compression]",
|
||||||
|
|||||||
128
rpi/BUILD_IMAGE.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Stegasoo Pi Image Build Workflow
|
||||||
|
|
||||||
|
Quick reference for building a distributable SD card image.
|
||||||
|
|
||||||
|
## Step 1: Flash Fresh Raspbian
|
||||||
|
|
||||||
|
Use rpi-imager with these settings:
|
||||||
|
- **OS**: Raspberry Pi OS Lite (64-bit)
|
||||||
|
- **Hostname**: `stegasoo`
|
||||||
|
- **Enable SSH**: Yes (password auth)
|
||||||
|
- **Username**: `admin`
|
||||||
|
- **Password**: `stegasoo`
|
||||||
|
- **WiFi**: Configure for your network (sanitize script removes it later)
|
||||||
|
|
||||||
|
## Step 2: Boot & SSH In
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Wait for Pi to boot (~60 seconds), then:
|
||||||
|
ssh admin@stegasoo.local
|
||||||
|
# or use IP from router DHCP list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Pre-Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Take ownership of /opt (for pyenv, jpegio builds)
|
||||||
|
sudo chown admin:admin /opt
|
||||||
|
|
||||||
|
# Install git (not included in Lite image)
|
||||||
|
sudo apt-get update && sudo apt-get install -y git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Clone & Run Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git
|
||||||
|
cd stegasoo
|
||||||
|
./rpi/setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This takes ~15-20 minutes and installs:
|
||||||
|
- Python 3.12 via pyenv
|
||||||
|
- jpegio (patched for ARM)
|
||||||
|
- Stegasoo with web UI
|
||||||
|
- Systemd service
|
||||||
|
|
||||||
|
## Step 5: Test It Works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start stegasoo
|
||||||
|
curl -k https://localhost:5000
|
||||||
|
# Should return HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Sanitize for Distribution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full sanitize (for final image - removes WiFi, shuts down)
|
||||||
|
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
||||||
|
|
||||||
|
# Or soft reset (for testing - keeps WiFi, reboots)
|
||||||
|
sudo /opt/stegasoo/rpi/sanitize-for-image.sh --soft
|
||||||
|
```
|
||||||
|
|
||||||
|
This removes:
|
||||||
|
- WiFi credentials (unless `--soft`)
|
||||||
|
- SSH host keys (regenerate on boot)
|
||||||
|
- SSH authorized keys
|
||||||
|
- Bash history
|
||||||
|
- Stegasoo auth database
|
||||||
|
- Logs and temp files
|
||||||
|
|
||||||
|
The script validates all cleanup steps before finishing.
|
||||||
|
|
||||||
|
## Step 7: Copy the Image
|
||||||
|
|
||||||
|
Remove SD card, insert into your Linux machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find the SD card device (CAREFUL!)
|
||||||
|
lsblk
|
||||||
|
|
||||||
|
# Copy (replace sdX with actual device, e.g., sda)
|
||||||
|
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 8: Shrink & Compress
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Optional: Shrink image (saves space)
|
||||||
|
wget https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh
|
||||||
|
chmod +x pishrink.sh
|
||||||
|
sudo ./pishrink.sh stegasoo-rpi-*.img
|
||||||
|
|
||||||
|
# Compress (zstd is faster than xz with similar ratio)
|
||||||
|
zstd -19 -T0 stegasoo-rpi-*.img
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 9: Distribute
|
||||||
|
|
||||||
|
Upload `.img.zst` to GitHub Releases.
|
||||||
|
|
||||||
|
Users can flash with:
|
||||||
|
```bash
|
||||||
|
# Linux
|
||||||
|
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||||
|
|
||||||
|
# Or use rpi-imager "Use custom" option
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Command Summary
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On Pi (after SSH):
|
||||||
|
sudo chown admin:admin /opt
|
||||||
|
sudo apt-get update && sudo apt-get install -y git
|
||||||
|
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git
|
||||||
|
cd stegasoo && ./rpi/setup.sh
|
||||||
|
sudo systemctl start stegasoo
|
||||||
|
curl -k https://localhost:5000
|
||||||
|
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
||||||
|
|
||||||
|
# On your machine:
|
||||||
|
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
||||||
|
zstd -19 -T0 stegasoo-rpi-*.img
|
||||||
|
```
|
||||||
202
rpi/README.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# Stegasoo Raspberry Pi
|
||||||
|
|
||||||
|
Scripts and resources for deploying Stegasoo on Raspberry Pi.
|
||||||
|
|
||||||
|
## Quick Install
|
||||||
|
|
||||||
|
On a fresh Raspberry Pi OS Lite (64-bit) installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pre-setup (git not included in Lite image)
|
||||||
|
sudo chown $USER:$USER /opt
|
||||||
|
sudo apt-get update && sudo apt-get install -y git
|
||||||
|
|
||||||
|
# Clone and run setup
|
||||||
|
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git /opt/stegasoo
|
||||||
|
cd /opt/stegasoo
|
||||||
|
./rpi/setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the Setup Script Does
|
||||||
|
|
||||||
|
1. **Installs system dependencies** - build tools, libraries
|
||||||
|
2. **Installs Python 3.12** - via pyenv (Pi OS ships with 3.13 which is incompatible)
|
||||||
|
3. **Builds jpegio for ARM** - patches x86-specific flags
|
||||||
|
4. **Installs Stegasoo** - with web UI and all dependencies
|
||||||
|
5. **Creates systemd service** - auto-starts on boot
|
||||||
|
6. **Enables the service** - ready to start
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Raspberry Pi 4 or 5
|
||||||
|
- Raspberry Pi OS Lite (64-bit) - Bookworm or later
|
||||||
|
- 4GB+ RAM recommended (2GB minimum)
|
||||||
|
- ~2GB free disk space
|
||||||
|
- Internet connection
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
On a Pi 4 at 2GHz with USB 3.0 NVMe, expect ~60 seconds to encode/decode a 10MB JPEG with full encryption (passphrase + PIN + reference photo).
|
||||||
|
|
||||||
|
## Pre-built Image Defaults
|
||||||
|
|
||||||
|
If using a pre-built image from GitHub Releases:
|
||||||
|
|
||||||
|
- **Default login**: `admin` / `stegasoo`
|
||||||
|
- **Hostname**: `stegasoo.local`
|
||||||
|
- **First boot**: A setup wizard runs on first SSH login
|
||||||
|
|
||||||
|
> **Security note**: Change the default password after setup with `passwd`
|
||||||
|
|
||||||
|
## After Installation
|
||||||
|
|
||||||
|
### Start the Service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start stegasoo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status stegasoo
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u stegasoo -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access Web UI
|
||||||
|
|
||||||
|
Open in browser: `http://<pi-ip>:5000`
|
||||||
|
|
||||||
|
On first access, you'll create an admin account.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit the systemd service to change settings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl edit stegasoo
|
||||||
|
```
|
||||||
|
|
||||||
|
Add overrides:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
Environment="STEGASOO_AUTH_ENABLED=true"
|
||||||
|
Environment="STEGASOO_HTTPS_ENABLED=true"
|
||||||
|
Environment="STEGASOO_HOSTNAME=stegasoo.local"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart stegasoo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop stegasoo
|
||||||
|
sudo systemctl disable stegasoo
|
||||||
|
sudo rm /etc/systemd/system/stegasoo.service
|
||||||
|
rm -rf /opt/stegasoo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-built Images
|
||||||
|
|
||||||
|
Check [GitHub Releases](https://github.com/adlee-was-taken/stegasoo/releases) for pre-built SD card images.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building Your Own Image
|
||||||
|
|
||||||
|
To create a distributable SD card image:
|
||||||
|
|
||||||
|
### 1. Flash Fresh Raspberry Pi OS
|
||||||
|
|
||||||
|
Use rpi-imager to flash Raspberry Pi OS (64-bit) to an SD card.
|
||||||
|
|
||||||
|
In advanced settings, set:
|
||||||
|
- Hostname: `stegasoo`
|
||||||
|
- Enable SSH (password auth for initial setup)
|
||||||
|
- Username/password (temporary, will work for any user)
|
||||||
|
- Skip WiFi for distributable image
|
||||||
|
|
||||||
|
### 2. Boot and Run Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into the Pi
|
||||||
|
ssh admin@stegasoo.local
|
||||||
|
|
||||||
|
# Pre-setup
|
||||||
|
sudo chown admin:admin /opt
|
||||||
|
sudo apt-get update && sudo apt-get install -y git
|
||||||
|
|
||||||
|
# Clone and run setup
|
||||||
|
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git /opt/stegasoo
|
||||||
|
cd /opt/stegasoo
|
||||||
|
./rpi/setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test It Works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start stegasoo
|
||||||
|
curl -k https://localhost:5000 # Should return HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Sanitize for Distribution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full sanitize (removes WiFi, shuts down for imaging)
|
||||||
|
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
||||||
|
|
||||||
|
# Or soft reset (keeps WiFi for testing, reboots)
|
||||||
|
sudo /opt/stegasoo/rpi/sanitize-for-image.sh --soft
|
||||||
|
```
|
||||||
|
|
||||||
|
This removes:
|
||||||
|
- WiFi credentials (unless `--soft`)
|
||||||
|
- SSH host keys (regenerate on boot)
|
||||||
|
- SSH authorized keys
|
||||||
|
- Bash history
|
||||||
|
- Stegasoo auth database (users create their own admin)
|
||||||
|
- Logs and temp files
|
||||||
|
|
||||||
|
The script validates cleanup and reports any issues.
|
||||||
|
|
||||||
|
### 5. Create the Image
|
||||||
|
|
||||||
|
After Pi shuts down, remove SD card and on another Linux machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find SD card device (BE CAREFUL - wrong device = data loss!)
|
||||||
|
lsblk
|
||||||
|
|
||||||
|
# Copy (replace sdX with your SD card)
|
||||||
|
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
||||||
|
|
||||||
|
# Shrink the image (optional but recommended)
|
||||||
|
wget https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh
|
||||||
|
chmod +x pishrink.sh
|
||||||
|
sudo ./pishrink.sh stegasoo-rpi-*.img
|
||||||
|
|
||||||
|
# Compress (zstd is faster than xz with similar compression)
|
||||||
|
zstd -19 -T0 stegasoo-rpi-*.img
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Distribute
|
||||||
|
|
||||||
|
Upload the `.img.zst` file to GitHub Releases.
|
||||||
|
|
||||||
|
Users flash with:
|
||||||
|
```bash
|
||||||
|
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use rpi-imager's "Use custom" option.
|
||||||
455
rpi/first-boot-wizard.sh
Executable file
@@ -0,0 +1,455 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Stegasoo First Boot Wizard
|
||||||
|
# Runs on first SSH login to configure the pre-installed Stegasoo image
|
||||||
|
#
|
||||||
|
# This script is triggered by /etc/profile.d/stegasoo-wizard.sh
|
||||||
|
# After completion, it removes itself to prevent re-running
|
||||||
|
#
|
||||||
|
# Uses gum (Charm.sh) for beautiful TUI - install with:
|
||||||
|
# sudo apt install gum OR go install github.com/charmbracelet/gum@latest
|
||||||
|
#
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
INSTALL_DIR="/opt/stegasoo"
|
||||||
|
FLAG_FILE="/etc/stegasoo-first-boot"
|
||||||
|
PROFILE_HOOK="/etc/profile.d/stegasoo-wizard.sh"
|
||||||
|
|
||||||
|
# Check if this is first boot
|
||||||
|
if [ ! -f "$FLAG_FILE" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for gum, fall back to basic prompts if not available
|
||||||
|
if ! command -v gum &>/dev/null; then
|
||||||
|
echo "Error: gum not found. Install with: sudo apt install gum"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gum styling - terminal green buttons with bold dark text
|
||||||
|
export GUM_CONFIRM_SELECTED_BACKGROUND="46"
|
||||||
|
export GUM_CONFIRM_SELECTED_FOREGROUND="232"
|
||||||
|
export GUM_CONFIRM_SELECTED_BOLD="true"
|
||||||
|
export GUM_CONFIRM_UNSELECTED_BACKGROUND="238"
|
||||||
|
export GUM_CONFIRM_UNSELECTED_FOREGROUND="255"
|
||||||
|
|
||||||
|
clear
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Welcome
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
gum style \
|
||||||
|
--border double \
|
||||||
|
--border-foreground 212 \
|
||||||
|
--padding "1 2" \
|
||||||
|
--margin "1" \
|
||||||
|
--align center \
|
||||||
|
" . * . . * . * . * . * ." \
|
||||||
|
" ___ _____ ___ ___ _ ___ ___ ___ " \
|
||||||
|
" / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\" \
|
||||||
|
" \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |" \
|
||||||
|
" |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/" \
|
||||||
|
"" \
|
||||||
|
" * . * . * . * . * . *" \
|
||||||
|
"" \
|
||||||
|
"First Boot Wizard"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 245 "This wizard will help you configure your Stegasoo server."
|
||||||
|
gum style --foreground 245 "You can reconfigure later by editing /etc/systemd/system/stegasoo.service"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
gum confirm "Ready to begin setup?" || exit 0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Configuration Variables
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
ENABLE_HTTPS="false"
|
||||||
|
USE_PORT_443="false"
|
||||||
|
CHANNEL_KEY=""
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 1: HTTPS Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
clear
|
||||||
|
gum style \
|
||||||
|
--foreground 212 --bold \
|
||||||
|
"Step 1 of 4: HTTPS Configuration"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
gum style --foreground 245 "\
|
||||||
|
HTTPS encrypts all traffic between your browser and this server
|
||||||
|
using a self-signed certificate.
|
||||||
|
|
||||||
|
NOTE: Your browser will show a security warning because the
|
||||||
|
certificate is self-signed. This is normal for home networks."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if gum confirm "Enable HTTPS?" --default=true; then
|
||||||
|
ENABLE_HTTPS="true"
|
||||||
|
gum style --foreground 82 "✓ HTTPS will be enabled"
|
||||||
|
else
|
||||||
|
gum style --foreground 214 "→ Using HTTP (unencrypted)"
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 2: Port Configuration (only if HTTPS)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||||
|
clear
|
||||||
|
gum style \
|
||||||
|
--foreground 212 --bold \
|
||||||
|
"Step 2 of 4: Port Configuration"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
gum style --foreground 245 "\
|
||||||
|
The standard HTTPS port is 443, which means you can access
|
||||||
|
Stegasoo without specifying a port in the URL.
|
||||||
|
|
||||||
|
Port 443: https://stegasoo.local
|
||||||
|
Port 5000: https://stegasoo.local:5000
|
||||||
|
|
||||||
|
NOTE: Port 443 requires an iptables redirect rule."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if gum confirm "Use standard port 443?" --default=true; then
|
||||||
|
USE_PORT_443="true"
|
||||||
|
gum style --foreground 82 "✓ Port 443 will be configured"
|
||||||
|
else
|
||||||
|
gum style --foreground 214 "→ Using port 5000"
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 3: Channel Key Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
clear
|
||||||
|
gum style \
|
||||||
|
--foreground 212 --bold \
|
||||||
|
"Step 3 of 4: Channel Key Configuration"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
gum style --foreground 245 "\
|
||||||
|
A channel key creates a private encoding channel.
|
||||||
|
|
||||||
|
WITHOUT a key: Anyone with Stegasoo can decode your images
|
||||||
|
WITH a key: Only people with YOUR key can decode
|
||||||
|
|
||||||
|
This is useful if you want to share encoded images only with
|
||||||
|
specific people (family, team, etc)."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if gum confirm "Generate a private channel key?" --default=false; then
|
||||||
|
echo ""
|
||||||
|
# Generate key to temp file (gum spin doesn't capture stdout well)
|
||||||
|
KEY_FILE=$(mktemp)
|
||||||
|
ERR_FILE=$(mktemp)
|
||||||
|
VENV_PYTHON="$INSTALL_DIR/venv/bin/python"
|
||||||
|
gum spin --spinner dot --title "Generating channel key..." -- \
|
||||||
|
bash -c "'$VENV_PYTHON' -c 'from stegasoo.channel import generate_channel_key; print(generate_channel_key())' > '$KEY_FILE' 2>'$ERR_FILE'"
|
||||||
|
|
||||||
|
CHANNEL_KEY=$(cat "$KEY_FILE" 2>/dev/null | head -1)
|
||||||
|
KEY_ERROR=$(cat "$ERR_FILE" 2>/dev/null)
|
||||||
|
rm -f "$KEY_FILE" "$ERR_FILE"
|
||||||
|
|
||||||
|
if [ -n "$CHANNEL_KEY" ] && [[ "$CHANNEL_KEY" =~ ^[A-Za-z0-9] ]]; then
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 82 "✓ Channel key generated!"
|
||||||
|
echo ""
|
||||||
|
gum style \
|
||||||
|
--border rounded \
|
||||||
|
--border-foreground 226 \
|
||||||
|
--padding "1 2" \
|
||||||
|
--foreground 226 --bold \
|
||||||
|
"$CHANNEL_KEY"
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 196 --bold \
|
||||||
|
"*** IMPORTANT: Write down or copy this key NOW! ***"
|
||||||
|
gum style --foreground 196 \
|
||||||
|
"You'll need to share it with anyone who should decode" \
|
||||||
|
"your images. This key won't be shown again."
|
||||||
|
echo ""
|
||||||
|
gum confirm "I've saved the key" --default=true --affirmative="Continue" --negative=""
|
||||||
|
else
|
||||||
|
gum style --foreground 196 "Failed to generate key. Using public mode."
|
||||||
|
if [ -n "$KEY_ERROR" ]; then
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 245 "Error details:"
|
||||||
|
echo "$KEY_ERROR"
|
||||||
|
fi
|
||||||
|
CHANNEL_KEY=""
|
||||||
|
echo ""
|
||||||
|
gum confirm "Continue" --default=true --affirmative="OK" --negative=""
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
gum style --foreground 214 "→ Using public mode"
|
||||||
|
sleep 0.5
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 4: Overclock Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
ENABLE_OVERCLOCK="false"
|
||||||
|
NEEDS_RESTART="false"
|
||||||
|
|
||||||
|
# Detect Pi model
|
||||||
|
PI_MODEL=$(cat /proc/device-tree/model 2>/dev/null | tr -d '\0')
|
||||||
|
|
||||||
|
if [[ "$PI_MODEL" == *"Raspberry Pi 4"* ]] || [[ "$PI_MODEL" == *"Raspberry Pi 5"* ]]; then
|
||||||
|
clear
|
||||||
|
gum style \
|
||||||
|
--foreground 212 --bold \
|
||||||
|
"Step 4 of 4: Performance Tuning"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
gum style --foreground 245 "\
|
||||||
|
Detected: $PI_MODEL
|
||||||
|
|
||||||
|
Overclocking can improve DCT encode/decode performance.
|
||||||
|
This is ONLY recommended if you have active cooling:
|
||||||
|
• Heatsink + Fan
|
||||||
|
• Active cooler case
|
||||||
|
|
||||||
|
Without cooling, the Pi may throttle or become unstable."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if gum confirm "Do you have active cooling (heatsink + fan)?" --default=false; then
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 245 "\
|
||||||
|
Recommended overclock settings:
|
||||||
|
• Pi 4: 2.0 GHz (stock 1.5 GHz) - ~33% faster
|
||||||
|
• Pi 5: 2.8 GHz (stock 2.4 GHz) - ~17% faster"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if gum confirm "Enable overclock?" --default=true; then
|
||||||
|
ENABLE_OVERCLOCK="true"
|
||||||
|
NEEDS_RESTART="true"
|
||||||
|
gum style --foreground 82 "✓ Overclock will be enabled (restart required)"
|
||||||
|
else
|
||||||
|
gum style --foreground 214 "→ Running at stock speed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
gum style --foreground 214 "→ Skipping overclock (no active cooling)"
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
else
|
||||||
|
# Not a Pi 4/5, skip overclock
|
||||||
|
:
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Apply Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
clear
|
||||||
|
gum style \
|
||||||
|
--foreground 212 --bold \
|
||||||
|
"Applying Configuration..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Find the stegasoo user (whoever owns the install dir)
|
||||||
|
STEGASOO_USER=$(stat -c '%U' "$INSTALL_DIR" 2>/dev/null || echo "pi")
|
||||||
|
|
||||||
|
gum spin --spinner dot --title "Updating systemd service..." -- bash -c "
|
||||||
|
sudo tee /etc/systemd/system/stegasoo.service >/dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stegasoo Web UI
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$STEGASOO_USER
|
||||||
|
WorkingDirectory=$INSTALL_DIR/frontends/web
|
||||||
|
Environment=\"PATH=$INSTALL_DIR/venv/bin:/usr/bin\"
|
||||||
|
Environment=\"STEGASOO_AUTH_ENABLED=true\"
|
||||||
|
Environment=\"STEGASOO_HTTPS_ENABLED=$ENABLE_HTTPS\"
|
||||||
|
Environment=\"STEGASOO_PORT=5000\"
|
||||||
|
Environment=\"STEGASOO_CHANNEL_KEY=$CHANNEL_KEY\"
|
||||||
|
ExecStart=$INSTALL_DIR/venv/bin/python app.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
"
|
||||||
|
gum style --foreground 82 "✓ Service configured"
|
||||||
|
|
||||||
|
# Setup port 443 if requested
|
||||||
|
if [ "$USE_PORT_443" = "true" ]; then
|
||||||
|
gum spin --spinner dot --title "Setting up port 443 redirect..." -- bash -c "
|
||||||
|
if ! command -v iptables &>/dev/null; then
|
||||||
|
sudo apt-get install -y iptables >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
if ! sudo iptables -t nat -C PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 5000 2>/dev/null; then
|
||||||
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 5000
|
||||||
|
fi
|
||||||
|
sudo sh -c 'iptables-save > /etc/iptables.rules'
|
||||||
|
sudo tee /etc/systemd/system/iptables-restore.service >/dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Restore iptables rules
|
||||||
|
Before=network-pre.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/sbin/iptables-restore /etc/iptables.rules
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
sudo systemctl enable iptables-restore.service >/dev/null 2>&1
|
||||||
|
"
|
||||||
|
gum style --foreground 82 "✓ Port 443 redirect configured"
|
||||||
|
fi
|
||||||
|
|
||||||
|
gum spin --spinner dot --title "Reloading systemd..." -- sudo systemctl daemon-reload
|
||||||
|
gum style --foreground 82 "✓ Systemd reloaded"
|
||||||
|
|
||||||
|
# Apply overclock if requested
|
||||||
|
if [ "$ENABLE_OVERCLOCK" = "true" ]; then
|
||||||
|
gum spin --spinner dot --title "Configuring overclock..." -- bash -c "
|
||||||
|
CONFIG_FILE='/boot/firmware/config.txt'
|
||||||
|
# Fallback for older Pi OS
|
||||||
|
if [ ! -f \"\$CONFIG_FILE\" ]; then
|
||||||
|
CONFIG_FILE='/boot/config.txt'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if overclock already configured
|
||||||
|
if ! grep -q '^over_voltage=' \"\$CONFIG_FILE\" 2>/dev/null; then
|
||||||
|
# Detect Pi model for appropriate settings
|
||||||
|
PI_MODEL=\$(cat /proc/device-tree/model 2>/dev/null | tr -d '\0')
|
||||||
|
|
||||||
|
echo '' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||||
|
echo '# Overclock (configured by Stegasoo wizard)' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||||
|
|
||||||
|
if [[ \"\$PI_MODEL\" == *'Raspberry Pi 5'* ]]; then
|
||||||
|
# Pi 5 overclock
|
||||||
|
echo 'over_voltage=4' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||||
|
echo 'arm_freq=2800' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||||
|
else
|
||||||
|
# Pi 4 overclock
|
||||||
|
echo 'over_voltage=6' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||||
|
echo 'arm_freq=2000' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||||
|
echo 'gpu_freq=700' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
gum style --foreground 82 "✓ Overclock configured"
|
||||||
|
fi
|
||||||
|
|
||||||
|
gum spin --spinner dot --title "Starting Stegasoo..." -- bash -c "sudo systemctl restart stegasoo && sleep 2"
|
||||||
|
|
||||||
|
if systemctl is-active --quiet stegasoo; then
|
||||||
|
gum style --foreground 82 "✓ Stegasoo started successfully"
|
||||||
|
else
|
||||||
|
gum style --foreground 196 "✗ Failed to start (check: journalctl -u stegasoo)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
gum spin --spinner dot --title "Cleaning up wizard..." -- bash -c "
|
||||||
|
sudo rm -f '$FLAG_FILE'
|
||||||
|
sudo rm -f '$PROFILE_HOOK'
|
||||||
|
"
|
||||||
|
gum style --foreground 82 "✓ Wizard complete"
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Final Summary
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
clear
|
||||||
|
|
||||||
|
PI_IP=$(hostname -I | awk '{print $1}')
|
||||||
|
HOSTNAME=$(hostname)
|
||||||
|
|
||||||
|
# Build the access URL
|
||||||
|
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||||
|
if [ "$USE_PORT_443" = "true" ]; then
|
||||||
|
ACCESS_URL="https://$PI_IP/setup"
|
||||||
|
ACCESS_URL_LOCAL="https://$HOSTNAME.local/setup"
|
||||||
|
else
|
||||||
|
ACCESS_URL="https://$PI_IP:5000/setup"
|
||||||
|
ACCESS_URL_LOCAL="https://$HOSTNAME.local:5000/setup"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ACCESS_URL="http://$PI_IP:5000/setup"
|
||||||
|
ACCESS_URL_LOCAL="http://$HOSTNAME.local:5000/setup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
gum style \
|
||||||
|
--border double \
|
||||||
|
--border-foreground 82 \
|
||||||
|
--padding "1 2" \
|
||||||
|
--margin "1" \
|
||||||
|
--align center \
|
||||||
|
" . * . . * . * . * . * ." \
|
||||||
|
" ___ _____ ___ ___ _ ___ ___ ___" \
|
||||||
|
" / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\" \
|
||||||
|
" \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |" \
|
||||||
|
" |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/" \
|
||||||
|
"" \
|
||||||
|
" * . * . * . * . * . *" \
|
||||||
|
"" \
|
||||||
|
"Setup Complete!"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 82 --bold "Create your admin account:"
|
||||||
|
gum style --foreground 226 " $ACCESS_URL"
|
||||||
|
gum style --foreground 245 " $ACCESS_URL_LOCAL (if mDNS works)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ -n "$CHANNEL_KEY" ]; then
|
||||||
|
gum style --foreground 82 --bold "Channel Key:"
|
||||||
|
gum style --foreground 226 " $CHANNEL_KEY"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
gum style --foreground 82 --bold "First Steps:"
|
||||||
|
gum style --foreground 255 \
|
||||||
|
" 1. Open the URL above in your browser" \
|
||||||
|
" 2. Accept the security warning (self-signed cert)" \
|
||||||
|
" 3. Create your admin account" \
|
||||||
|
" 4. Start encoding secret messages!"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
gum style --foreground 82 --bold "Useful Commands:"
|
||||||
|
gum style --foreground 245 \
|
||||||
|
" sudo systemctl status stegasoo # Check status" \
|
||||||
|
" sudo systemctl restart stegasoo # Restart" \
|
||||||
|
" journalctl -u stegasoo -f # View logs"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
gum style --foreground 212 --bold "Enjoy Stegasoo!"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prompt for restart if overclock was enabled
|
||||||
|
if [ "$NEEDS_RESTART" = "true" ]; then
|
||||||
|
echo ""
|
||||||
|
gum style \
|
||||||
|
--border rounded \
|
||||||
|
--border-foreground 226 \
|
||||||
|
--padding "1 2" \
|
||||||
|
--foreground 226 \
|
||||||
|
"Restart Required" \
|
||||||
|
"" \
|
||||||
|
"Overclock settings require a restart to take effect."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if gum confirm "Restart now?" --default=true; then
|
||||||
|
gum style --foreground 82 "Restarting in 3 seconds..."
|
||||||
|
sleep 3
|
||||||
|
sudo reboot
|
||||||
|
else
|
||||||
|
gum style --foreground 214 "Remember to restart later for overclock to take effect:"
|
||||||
|
gum style --foreground 245 " sudo reboot"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
227
rpi/flash-image.sh
Executable file
@@ -0,0 +1,227 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Flash Stegasoo image to SD card
|
||||||
|
# Auto-detects SD card, decompresses and writes with progress
|
||||||
|
#
|
||||||
|
# Usage: ./flash-image.sh <image.img.xz> [device]
|
||||||
|
# ./flash-image.sh <image.img> [device]
|
||||||
|
#
|
||||||
|
# If device is specified, skips auto-detection (useful for large drives)
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Check for required tools
|
||||||
|
for cmd in dd pv lsblk; do
|
||||||
|
if ! command -v $cmd &> /dev/null; then
|
||||||
|
echo -e "${RED}Error: $cmd is required but not installed.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check for root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for image argument
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo -e "${RED}Usage: $0 <image.img.xz|image.img> [device]${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 stegasoo-rpi-20260103.img.xz # auto-detect SD card"
|
||||||
|
echo " $0 stegasoo-rpi-20260103.img.xz /dev/sdb # specify device"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
IMAGE="$1"
|
||||||
|
MANUAL_DEVICE="$2"
|
||||||
|
|
||||||
|
if [ ! -f "$IMAGE" ]; then
|
||||||
|
echo -e "${RED}Error: Image file not found: $IMAGE${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect compression
|
||||||
|
COMPRESSED=false
|
||||||
|
COMP_TYPE=""
|
||||||
|
if [[ "$IMAGE" == *.xz ]]; then
|
||||||
|
COMPRESSED=true
|
||||||
|
COMP_TYPE="xz"
|
||||||
|
if ! command -v xzcat &> /dev/null; then
|
||||||
|
echo -e "${RED}Error: xz is required for .xz files but not installed.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif [[ "$IMAGE" == *.zst ]]; then
|
||||||
|
COMPRESSED=true
|
||||||
|
COMP_TYPE="zst"
|
||||||
|
if ! command -v zstdcat &> /dev/null; then
|
||||||
|
echo -e "${RED}Error: zstd is required for .zst files but not installed.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif [[ "$IMAGE" == *.gz ]]; then
|
||||||
|
COMPRESSED=true
|
||||||
|
COMP_TYPE="gz"
|
||||||
|
if ! command -v zcat &> /dev/null; then
|
||||||
|
echo -e "${RED}Error: gzip is required for .gz files but not installed.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}"
|
||||||
|
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Stegasoo SD Card Flasher ║"
|
||||||
|
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo -e "${NC}"
|
||||||
|
|
||||||
|
echo -e "Image: ${YELLOW}$IMAGE${NC}"
|
||||||
|
echo -e "Size: ${YELLOW}$(du -h "$IMAGE" | awk '{print $1}')${NC}"
|
||||||
|
if [ "$COMPRESSED" = true ]; then
|
||||||
|
echo -e "Type: ${YELLOW}Compressed (will decompress on-the-fly)${NC}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Use manual device or auto-detect
|
||||||
|
if [ -n "$MANUAL_DEVICE" ]; then
|
||||||
|
# Manual device specified
|
||||||
|
if [ ! -b "$MANUAL_DEVICE" ]; then
|
||||||
|
echo -e "${RED}Error: $MANUAL_DEVICE is not a block device${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SELECTED="$MANUAL_DEVICE"
|
||||||
|
echo -e "Using specified device: ${YELLOW}$SELECTED${NC}"
|
||||||
|
echo ""
|
||||||
|
lsblk "$SELECTED" -o NAME,SIZE,TYPE,MODEL
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
# Auto-detect SD card candidates
|
||||||
|
echo -e "${BOLD}Scanning for SD cards...${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
declare -a CANDIDATES
|
||||||
|
declare -a CANDIDATE_INFO
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
DEV=$(echo "$line" | awk '{print $1}')
|
||||||
|
SIZE=$(echo "$line" | awk '{print $2}')
|
||||||
|
TYPE=$(echo "$line" | awk '{print $3}')
|
||||||
|
TRAN=$(echo "$line" | awk '{print $4}')
|
||||||
|
MODEL=$(echo "$line" | awk '{print $5" "$6" "$7}' | xargs)
|
||||||
|
|
||||||
|
# Skip if it's the root filesystem
|
||||||
|
if mount | grep -q "^/dev/${DEV}[0-9]* on / "; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Skip if any partition is mounted as root
|
||||||
|
ROOT_DEV=$(mount | grep " on / " | awk '{print $1}' | sed 's/[0-9]*$//')
|
||||||
|
if [[ "/dev/$DEV" == "$ROOT_DEV" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get size in bytes for reliable comparison
|
||||||
|
SIZE_BYTES=$(lsblk -b -d -o SIZE -n "/dev/$DEV" 2>/dev/null | tr -d ' ')
|
||||||
|
SIZE_GB_INT=$((SIZE_BYTES / 1073741824)) # 1024^3
|
||||||
|
|
||||||
|
# Check if size is in SD card range (8GB - 128GB)
|
||||||
|
if [ "$SIZE_GB_INT" -ge 8 ] && [ "$SIZE_GB_INT" -le 128 ]; then
|
||||||
|
CANDIDATES+=("/dev/$DEV")
|
||||||
|
CANDIDATE_INFO+=("$SIZE $TYPE ${TRAN:-???} $MODEL")
|
||||||
|
fi
|
||||||
|
done < <(lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL -n | grep "disk")
|
||||||
|
|
||||||
|
if [ ${#CANDIDATES[@]} -eq 0 ]; then
|
||||||
|
echo -e "${RED}No SD card candidates found.${NC}"
|
||||||
|
echo "Looking for USB/removable disks between 8GB and 128GB."
|
||||||
|
echo ""
|
||||||
|
echo "Available disks:"
|
||||||
|
lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Tip: Specify device manually: $0 $IMAGE /dev/sdX${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}Found ${#CANDIDATES[@]} candidate(s):${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for i in "${!CANDIDATES[@]}"; do
|
||||||
|
echo -e " ${BOLD}[$((i+1))]${NC} ${CANDIDATES[$i]} - ${CANDIDATE_INFO[$i]}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ ${#CANDIDATES[@]} -eq 1 ]; then
|
||||||
|
SELECTED="${CANDIDATES[0]}"
|
||||||
|
echo -e "Auto-selected: ${YELLOW}$SELECTED${NC}"
|
||||||
|
else
|
||||||
|
read -p "Select device [1-${#CANDIDATES[@]}]: " -r
|
||||||
|
if [[ ! $REPLY =~ ^[0-9]+$ ]] || [ "$REPLY" -lt 1 ] || [ "$REPLY" -gt ${#CANDIDATES[@]} ]; then
|
||||||
|
echo -e "${RED}Invalid selection.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SELECTED="${CANDIDATES[$((REPLY-1))]}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show current partitions
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Current partitions on $SELECTED:${NC}"
|
||||||
|
lsblk "$SELECTED" -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Unmount any mounted partitions
|
||||||
|
MOUNTED=$(mount | grep "^${SELECTED}" | awk '{print $1}' || true)
|
||||||
|
if [ -n "$MOUNTED" ]; then
|
||||||
|
echo -e "${YELLOW}Unmounting partitions...${NC}"
|
||||||
|
for part in $MOUNTED; do
|
||||||
|
umount "$part" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Final confirmation
|
||||||
|
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${RED}║ WARNING: ALL DATA ON THIS DEVICE WILL BE DESTROYED! ║${NC}"
|
||||||
|
echo -e "${RED}║ $SELECTED ║${NC}"
|
||||||
|
echo -e "${RED}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
read -p "Type 'yes' to continue: " -r
|
||||||
|
if [[ ! $REPLY == "yes" ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Flashing image to $SELECTED...${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$COMPRESSED" = true ]; then
|
||||||
|
case "$COMP_TYPE" in
|
||||||
|
xz) pv "$IMAGE" | xzcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
||||||
|
zst) pv "$IMAGE" | zstdcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
||||||
|
gz) pv "$IMAGE" | zcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
pv "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Syncing...${NC}"
|
||||||
|
sync
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${GREEN}║ Flash Complete! ║${NC}"
|
||||||
|
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "You can now remove the SD card and boot your Raspberry Pi."
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Tip:${NC} On first boot, SSH in and the setup wizard will run automatically."
|
||||||
|
echo ""
|
||||||
57
rpi/patches/README.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# RPi Patches
|
||||||
|
|
||||||
|
This directory contains patches for dependencies that need modifications to build on ARM64.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
patches/
|
||||||
|
<package>/
|
||||||
|
arm64.patch # Standard unified diff patch file
|
||||||
|
apply-patch.sh # Script with fallback strategies
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
The `apply-patch.sh` script tries multiple strategies in order:
|
||||||
|
|
||||||
|
1. **Patch file** - Apply the `.patch` file using `patch -p1`
|
||||||
|
2. **Sed fallback** - Use sed for simple string replacements
|
||||||
|
3. **Python fallback** - Use regex for flexible pattern matching
|
||||||
|
|
||||||
|
This layered approach handles:
|
||||||
|
- Exact matches (patch file works)
|
||||||
|
- Minor upstream changes (sed catches variations)
|
||||||
|
- Significant changes (Python regex is most flexible)
|
||||||
|
- Already patched files (detected and skipped)
|
||||||
|
|
||||||
|
## Adding a New Patch
|
||||||
|
|
||||||
|
1. Create a directory: `patches/<package>/`
|
||||||
|
2. Create the patch file: `git diff > arm64.patch`
|
||||||
|
3. Create `apply-patch.sh` with appropriate fallback logic
|
||||||
|
4. Update `setup.sh` to call the patch script
|
||||||
|
|
||||||
|
## jpegio Patch
|
||||||
|
|
||||||
|
The jpegio library includes x86-specific `-m64` compiler flags that fail on ARM64.
|
||||||
|
The patch removes these flags by replacing:
|
||||||
|
|
||||||
|
```python
|
||||||
|
cargs.append('-m64')
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
pass # ARM64: removed x86-specific -m64 flag
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating Patches
|
||||||
|
|
||||||
|
When upstream changes break a patch:
|
||||||
|
|
||||||
|
1. Clone the new version
|
||||||
|
2. Make the necessary modifications
|
||||||
|
3. Generate a new patch: `diff -u original modified > arm64.patch`
|
||||||
|
4. Test on a fresh Pi install
|
||||||
105
rpi/patches/jpegio/apply-patch.sh
Executable file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Apply ARM64 patch to jpegio
|
||||||
|
# This script tries multiple strategies to remove the x86-specific -m64 flag
|
||||||
|
#
|
||||||
|
# Usage: ./apply-patch.sh /path/to/jpegio
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
JPEGIO_DIR="${1:-.}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PATCH_FILE="$SCRIPT_DIR/arm64.patch"
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
cd "$JPEGIO_DIR"
|
||||||
|
|
||||||
|
echo "Applying ARM64 patch to jpegio..."
|
||||||
|
|
||||||
|
# Strategy 1: Try the standard patch file
|
||||||
|
if [ -f "$PATCH_FILE" ]; then
|
||||||
|
echo " Trying patch file..."
|
||||||
|
if patch -p1 --dry-run < "$PATCH_FILE" >/dev/null 2>&1; then
|
||||||
|
patch -p1 < "$PATCH_FILE"
|
||||||
|
echo -e " ${GREEN}✓ Patch applied successfully${NC}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}Patch file didn't apply cleanly, trying fallback...${NC}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strategy 2: Sed replacement (handles any number of occurrences)
|
||||||
|
if grep -q "cargs.append('-m64')" setup.py 2>/dev/null; then
|
||||||
|
echo " Using sed fallback..."
|
||||||
|
sed -i "s/cargs.append('-m64')/pass # ARM64: removed x86-specific -m64 flag/g" setup.py
|
||||||
|
|
||||||
|
# Verify the fix
|
||||||
|
if grep -q "cargs.append('-m64')" setup.py; then
|
||||||
|
echo -e " ${RED}✗ Sed replacement failed${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓ Sed fallback successful${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strategy 3: Check if already patched
|
||||||
|
if grep -q "ARM64: removed" setup.py 2>/dev/null; then
|
||||||
|
echo -e " ${GREEN}✓ Already patched${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strategy 4: Python-based patching (most flexible)
|
||||||
|
echo " Using Python fallback..."
|
||||||
|
python3 << 'PYTHON_PATCH'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open('setup.py', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
original = content
|
||||||
|
|
||||||
|
# Pattern 1: Direct replacement
|
||||||
|
content = re.sub(
|
||||||
|
r"cargs\.append\(['\"]+-m64['\"]+\)",
|
||||||
|
"pass # ARM64: removed x86-specific -m64 flag",
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern 2: Handle variations with different quotes or spacing
|
||||||
|
content = re.sub(
|
||||||
|
r"cargs\.append\s*\(\s*['\"]+-m64['\"]+\s*\)",
|
||||||
|
"pass # ARM64: removed x86-specific -m64 flag",
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
if content == original:
|
||||||
|
# Check if already patched or pattern not found
|
||||||
|
if "ARM64: removed" in content:
|
||||||
|
print("Already patched")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("Warning: -m64 pattern not found in setup.py")
|
||||||
|
print("This may indicate jpegio's structure has changed significantly")
|
||||||
|
sys.exit(0) # Don't fail - maybe they removed it upstream
|
||||||
|
|
||||||
|
with open('setup.py', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
print("Python patch applied")
|
||||||
|
PYTHON_PATCH
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo -e " ${GREEN}✓ Python fallback successful${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${RED}✗ All patching strategies failed${NC}"
|
||||||
|
echo "Please check jpegio's setup.py manually"
|
||||||
|
exit 1
|
||||||
17
rpi/patches/jpegio/arm64.patch
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
--- a/setup.py
|
||||||
|
+++ b/setup.py
|
||||||
|
@@ -69,12 +69,12 @@
|
||||||
|
largs.append('-mmacosx-version-min=10.9')
|
||||||
|
|
||||||
|
if arch == 'x64':
|
||||||
|
- cargs.append('-m64')
|
||||||
|
+ pass # ARM64: removed x86-specific -m64 flag
|
||||||
|
elif sys.platform == 'linux':
|
||||||
|
cargs.extend(['-w', '-fPIC'])
|
||||||
|
|
||||||
|
if arch == 'x64':
|
||||||
|
- cargs.append('-m64')
|
||||||
|
+ pass # ARM64: removed x86-specific -m64 flag
|
||||||
|
dname_libjpeg = 'libjpeg'
|
||||||
|
|
||||||
|
# end of if-else
|
||||||
207
rpi/pull-image.sh
Executable file
@@ -0,0 +1,207 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Pull Stegasoo image from SD card
|
||||||
|
# Auto-detects SD card, copies with progress, shrinks, and compresses
|
||||||
|
#
|
||||||
|
# Usage: ./pull-image.sh [output-name] [device]
|
||||||
|
# Output will be: stegasoo-rpi-YYYYMMDD.img.zst (or custom name)
|
||||||
|
# Use .img extension to skip compression: ./pull-image.sh foo.img
|
||||||
|
#
|
||||||
|
# If device is specified, skips auto-detection (useful for large drives)
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Check for required tools
|
||||||
|
for cmd in dd pv zstd lsblk; do
|
||||||
|
if ! command -v $cmd &> /dev/null; then
|
||||||
|
echo -e "${RED}Error: $cmd is required but not installed.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check for root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Output filename and optional device
|
||||||
|
if [ -n "$1" ]; then
|
||||||
|
OUTPUT="$1"
|
||||||
|
else
|
||||||
|
OUTPUT="stegasoo-rpi-$(date +%Y%m%d).img.zst"
|
||||||
|
fi
|
||||||
|
MANUAL_DEVICE="$2"
|
||||||
|
|
||||||
|
# Check if output ends in .img (skip compression) or .zst (compress)
|
||||||
|
SKIP_COMPRESS=false
|
||||||
|
if [[ "$OUTPUT" == *.img ]]; then
|
||||||
|
IMG_FILE="$OUTPUT"
|
||||||
|
SKIP_COMPRESS=true
|
||||||
|
elif [[ "$OUTPUT" == *.zst ]]; then
|
||||||
|
IMG_FILE="${OUTPUT%.zst}"
|
||||||
|
else
|
||||||
|
# No recognized extension, add .img.zst
|
||||||
|
IMG_FILE="${OUTPUT}.img"
|
||||||
|
OUTPUT="${OUTPUT}.img.zst"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}"
|
||||||
|
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Stegasoo SD Card Image Puller ║"
|
||||||
|
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo -e "${NC}"
|
||||||
|
|
||||||
|
# Use manual device or auto-detect
|
||||||
|
if [ -n "$MANUAL_DEVICE" ]; then
|
||||||
|
# Manual device specified
|
||||||
|
if [ ! -b "$MANUAL_DEVICE" ]; then
|
||||||
|
echo -e "${RED}Error: $MANUAL_DEVICE is not a block device${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SELECTED="$MANUAL_DEVICE"
|
||||||
|
echo -e "Using specified device: ${YELLOW}$SELECTED${NC}"
|
||||||
|
echo ""
|
||||||
|
lsblk "$SELECTED" -o NAME,SIZE,TYPE,MODEL
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
# Auto-detect SD card candidates
|
||||||
|
# Looking for: USB/removable, 8-128GB, not mounted as root filesystem
|
||||||
|
echo -e "${BOLD}Scanning for SD cards...${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
declare -a CANDIDATES
|
||||||
|
declare -a CANDIDATE_INFO
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
DEV=$(echo "$line" | awk '{print $1}')
|
||||||
|
SIZE=$(echo "$line" | awk '{print $2}')
|
||||||
|
TYPE=$(echo "$line" | awk '{print $3}')
|
||||||
|
TRAN=$(echo "$line" | awk '{print $4}')
|
||||||
|
MODEL=$(echo "$line" | awk '{print $5" "$6" "$7}' | xargs)
|
||||||
|
|
||||||
|
# Skip if it's the root filesystem
|
||||||
|
if mount | grep -q "^/dev/${DEV}[0-9]* on / "; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Skip if any partition is mounted as root
|
||||||
|
ROOT_DEV=$(mount | grep " on / " | awk '{print $1}' | sed 's/[0-9]*$//')
|
||||||
|
if [[ "/dev/$DEV" == "$ROOT_DEV" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get size in bytes for reliable comparison
|
||||||
|
SIZE_BYTES=$(lsblk -b -d -o SIZE -n "/dev/$DEV" 2>/dev/null | tr -d ' ')
|
||||||
|
SIZE_GB_INT=$((SIZE_BYTES / 1073741824)) # 1024^3
|
||||||
|
|
||||||
|
# Check if size is in SD card range (8GB - 128GB)
|
||||||
|
if [ "$SIZE_GB_INT" -ge 8 ] && [ "$SIZE_GB_INT" -le 128 ]; then
|
||||||
|
CANDIDATES+=("/dev/$DEV")
|
||||||
|
CANDIDATE_INFO+=("$SIZE $TYPE ${TRAN:-???} $MODEL")
|
||||||
|
fi
|
||||||
|
done < <(lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL -n | grep "disk")
|
||||||
|
|
||||||
|
if [ ${#CANDIDATES[@]} -eq 0 ]; then
|
||||||
|
echo -e "${RED}No SD card candidates found.${NC}"
|
||||||
|
echo "Looking for USB/removable disks between 8GB and 128GB."
|
||||||
|
echo ""
|
||||||
|
echo "Available disks:"
|
||||||
|
lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Tip: Specify device manually: $0 output.img.zst /dev/sdX${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}Found ${#CANDIDATES[@]} candidate(s):${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for i in "${!CANDIDATES[@]}"; do
|
||||||
|
echo -e " ${BOLD}[$((i+1))]${NC} ${CANDIDATES[$i]} - ${CANDIDATE_INFO[$i]}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ ${#CANDIDATES[@]} -eq 1 ]; then
|
||||||
|
SELECTED="${CANDIDATES[0]}"
|
||||||
|
echo -e "Auto-selected: ${YELLOW}$SELECTED${NC}"
|
||||||
|
else
|
||||||
|
read -p "Select device [1-${#CANDIDATES[@]}]: " -r
|
||||||
|
if [[ ! $REPLY =~ ^[0-9]+$ ]] || [ "$REPLY" -lt 1 ] || [ "$REPLY" -gt ${#CANDIDATES[@]} ]; then
|
||||||
|
echo -e "${RED}Invalid selection.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SELECTED="${CANDIDATES[$((REPLY-1))]}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show partitions
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Partitions on $SELECTED:${NC}"
|
||||||
|
lsblk "$SELECTED" -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Final confirmation
|
||||||
|
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${RED}║ WARNING: This will read the ENTIRE device: ║${NC}"
|
||||||
|
echo -e "${RED}║ $SELECTED ║${NC}"
|
||||||
|
echo -e "${RED}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
|
||||||
|
echo ""
|
||||||
|
read -p "Continue? [y/N] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get device size for pv
|
||||||
|
DEV_SIZE=$(blockdev --getsize64 "$SELECTED")
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}[1/3]${NC} Copying image from $SELECTED..."
|
||||||
|
dd if="$SELECTED" bs=4M status=none | pv -s "$DEV_SIZE" > "$IMG_FILE"
|
||||||
|
sync
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}[2/3]${NC} Shrinking image..."
|
||||||
|
if command -v pishrink.sh &> /dev/null; then
|
||||||
|
pishrink.sh "$IMG_FILE"
|
||||||
|
elif [ -f "./pishrink.sh" ]; then
|
||||||
|
bash ./pishrink.sh "$IMG_FILE"
|
||||||
|
elif [ -f "../pishrink.sh" ]; then
|
||||||
|
bash ../pishrink.sh "$IMG_FILE"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}pishrink.sh not found, skipping shrink step.${NC}"
|
||||||
|
echo "Download from: https://github.com/Drewsif/PiShrink"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ "$SKIP_COMPRESS" = true ]; then
|
||||||
|
echo -e "${GREEN}[3/3]${NC} Skipping compression (.img output)"
|
||||||
|
FINAL_SIZE=$(du -h "$IMG_FILE" | awk '{print $1}')
|
||||||
|
OUTPUT="$IMG_FILE"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}[3/3]${NC} Compressing with zstd..."
|
||||||
|
pv "$IMG_FILE" | zstd -19 -T0 -q > "$OUTPUT"
|
||||||
|
rm -f "$IMG_FILE"
|
||||||
|
FINAL_SIZE=$(du -h "$OUTPUT" | awk '{print $1}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${GREEN}║ Image Complete! ║${NC}"
|
||||||
|
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
|
||||||
|
echo -e "Size: ${YELLOW}$FINAL_SIZE${NC}"
|
||||||
|
echo ""
|
||||||
594
rpi/sanitize-for-image.sh
Executable file
@@ -0,0 +1,594 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Sanitize Raspberry Pi for SD Card Image Distribution
|
||||||
|
# Run this BEFORE creating an image with dd
|
||||||
|
#
|
||||||
|
# This script removes:
|
||||||
|
# - WiFi credentials (unless --soft)
|
||||||
|
# - SSH host keys (will regenerate on boot)
|
||||||
|
# - SSH authorized keys
|
||||||
|
# - User-specific data
|
||||||
|
# - Bash history
|
||||||
|
# - Logs
|
||||||
|
# - Stegasoo auth database (users will create their own admin)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sudo ./sanitize-for-image.sh # Full sanitize for image distribution
|
||||||
|
# sudo ./sanitize-for-image.sh --soft # Soft reset (keeps WiFi for testing)
|
||||||
|
# sudo ./sanitize-for-image.sh --soft --reboot # Soft reset and auto-reboot
|
||||||
|
# sudo ./sanitize-for-image.sh --reboot # Full sanitize and auto-shutdown
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
GRAY='\033[0;90m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Show help
|
||||||
|
show_help() {
|
||||||
|
echo "Stegasoo Sanitize Script - Prepare Pi for SD Card Imaging"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: sudo $0 [options]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " -h, --help Show this help message"
|
||||||
|
echo " -s, --soft Soft reset (keeps WiFi for testing)"
|
||||||
|
echo " -r, --reboot Auto-reboot/shutdown when done"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " sudo $0 # Full sanitize, prompts for shutdown"
|
||||||
|
echo " sudo $0 --soft # Keep WiFi, reset everything else"
|
||||||
|
echo " sudo $0 --soft --reboot # Soft reset, auto-reboot"
|
||||||
|
echo " sudo $0 --reboot # Full sanitize, auto-shutdown"
|
||||||
|
echo ""
|
||||||
|
echo "Config override:"
|
||||||
|
echo " Set STEGASOO_DIR to specify a custom install location:"
|
||||||
|
echo " export STEGASOO_DIR=\"/home/pi/stegasoo\""
|
||||||
|
echo " sudo -E $0"
|
||||||
|
echo ""
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
SOFT_RESET=false
|
||||||
|
AUTO_REBOOT=false
|
||||||
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
|
-h|--help) show_help ;;
|
||||||
|
--soft|-s) SOFT_RESET=true ;;
|
||||||
|
--reboot|-r) AUTO_REBOOT=true ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
clear
|
||||||
|
echo ""
|
||||||
|
echo -e "${GRAY} . * . . * . * . * . * .${NC}"
|
||||||
|
echo -e "${CYAN} ___ _____ ___ ___ _ ___ ___ ___${NC}"
|
||||||
|
echo -e "${CYAN} / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\${NC}"
|
||||||
|
echo -e "${CYAN} \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |${NC}"
|
||||||
|
echo -e "${CYAN} |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${GRAY} * . * . * . * . * . *${NC}"
|
||||||
|
echo ""
|
||||||
|
if [ "$SOFT_RESET" = true ]; then
|
||||||
|
echo -e "${CYAN} Soft Reset (Factory)${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${CYAN} Sanitize for Imaging${NC}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$SOFT_RESET" = true ]; then
|
||||||
|
echo " WiFi credentials will be KEPT for continued testing."
|
||||||
|
echo " Everything else will be reset to first-boot state."
|
||||||
|
else
|
||||||
|
echo " This will remove ALL personal data for imaging."
|
||||||
|
echo " The system will shut down when complete."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$AUTO_REBOOT" = false ]; then
|
||||||
|
# Flush input buffer before prompt
|
||||||
|
read -t 0.1 -n 10000 discard </dev/tty 2>/dev/null || true
|
||||||
|
read -p "Continue? This cannot be undone! [y/N] " -n 1 -r </dev/tty
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Track validation results
|
||||||
|
VALIDATION_ERRORS=0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 1: WiFi Credentials
|
||||||
|
# =============================================================================
|
||||||
|
if [ "$SOFT_RESET" = true ]; then
|
||||||
|
echo -e "${GREEN}[1/11]${NC} Keeping WiFi credentials (soft reset)..."
|
||||||
|
echo " WiFi config preserved"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}[1/11]${NC} Removing WiFi credentials..."
|
||||||
|
|
||||||
|
# Remove from rootfs
|
||||||
|
if [ -f /etc/wpa_supplicant/wpa_supplicant.conf ]; then
|
||||||
|
cat > /etc/wpa_supplicant/wpa_supplicant.conf << 'EOF'
|
||||||
|
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
|
||||||
|
update_config=1
|
||||||
|
country=US
|
||||||
|
|
||||||
|
# Add your WiFi network here on first boot:
|
||||||
|
# network={
|
||||||
|
# ssid="YourNetworkName"
|
||||||
|
# psk="YourPassword"
|
||||||
|
# }
|
||||||
|
EOF
|
||||||
|
echo " Cleared /etc/wpa_supplicant/wpa_supplicant.conf"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove from boot partition (headless setup file)
|
||||||
|
BOOT_PART=$(findmnt -n -o SOURCE /boot/firmware 2>/dev/null || findmnt -n -o SOURCE /boot 2>/dev/null || echo "")
|
||||||
|
if [ -n "$BOOT_PART" ]; then
|
||||||
|
BOOT_MOUNT=$(findmnt -n -o TARGET "$BOOT_PART" 2>/dev/null || echo "/boot")
|
||||||
|
rm -f "$BOOT_MOUNT/wpa_supplicant.conf" 2>/dev/null || true
|
||||||
|
echo " Removed boot partition WiFi config"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove NetworkManager connections (RPi OS Bookworm+)
|
||||||
|
if [ -d /etc/NetworkManager/system-connections ]; then
|
||||||
|
# Remove all WiFi connections (files containing type=wifi)
|
||||||
|
for conn in /etc/NetworkManager/system-connections/*; do
|
||||||
|
if [ -f "$conn" ] && grep -q "type=wifi" "$conn" 2>/dev/null; then
|
||||||
|
rm -f "$conn"
|
||||||
|
echo " Removed NetworkManager: $(basename "$conn")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove netplan WiFi configs (Ubuntu-based systems)
|
||||||
|
if [ -d /etc/netplan ]; then
|
||||||
|
for np in /etc/netplan/*.yaml; do
|
||||||
|
if [ -f "$np" ] && grep -q "wifis:" "$np" 2>/dev/null; then
|
||||||
|
rm -f "$np"
|
||||||
|
echo " Removed netplan: $(basename "$np")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# Also remove NM-generated netplan files (contain WiFi SSIDs)
|
||||||
|
rm -f /etc/netplan/90-NM-*.yaml 2>/dev/null && echo " Removed netplan NM configs"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 2: SSH Authorized Keys
|
||||||
|
# =============================================================================
|
||||||
|
echo -e "${GREEN}[2/11]${NC} Removing SSH authorized keys..."
|
||||||
|
for user_home in /home/*; do
|
||||||
|
if [ -d "$user_home/.ssh" ]; then
|
||||||
|
rm -f "$user_home/.ssh/authorized_keys"
|
||||||
|
rm -f "$user_home/.ssh/known_hosts"
|
||||||
|
echo " Cleared $user_home/.ssh/"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
rm -f /root/.ssh/authorized_keys /root/.ssh/known_hosts 2>/dev/null || true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 3: SSH Host Keys
|
||||||
|
# =============================================================================
|
||||||
|
echo -e "${GREEN}[3/11]${NC} Removing SSH host keys (will regenerate on first boot)..."
|
||||||
|
rm -f /etc/ssh/ssh_host_*
|
||||||
|
|
||||||
|
# Create a first-boot service to regenerate SSH keys
|
||||||
|
cat > /etc/systemd/system/regenerate-ssh-keys.service <<'SSHEOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Regenerate SSH host keys on first boot
|
||||||
|
Before=ssh.service
|
||||||
|
ConditionPathExists=!/etc/ssh/ssh_host_ed25519_key
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/ssh-keygen -A
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SSHEOF
|
||||||
|
|
||||||
|
systemctl enable regenerate-ssh-keys.service 2>/dev/null || true
|
||||||
|
echo " SSH host keys removed (will regenerate on first boot)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 4: Bash History
|
||||||
|
# =============================================================================
|
||||||
|
echo -e "${GREEN}[4/11]${NC} Clearing bash history..."
|
||||||
|
for user_home in /home/*; do
|
||||||
|
rm -f "$user_home/.bash_history"
|
||||||
|
rm -f "$user_home/.python_history"
|
||||||
|
done
|
||||||
|
rm -f /root/.bash_history /root/.python_history 2>/dev/null || true
|
||||||
|
history -c 2>/dev/null || true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 5: Stegasoo User Data
|
||||||
|
# =============================================================================
|
||||||
|
echo -e "${GREEN}[5/11]${NC} Removing Stegasoo user data..."
|
||||||
|
# Remove auth database (users create their own admin on first run)
|
||||||
|
rm -rf /opt/stegasoo/frontends/web/instance/ 2>/dev/null
|
||||||
|
rm -rf /home/*/stegasoo/frontends/web/instance/
|
||||||
|
# Remove SSL certs (will be regenerated)
|
||||||
|
rm -rf /opt/stegasoo/frontends/web/certs/ 2>/dev/null
|
||||||
|
rm -rf /home/*/stegasoo/frontends/web/certs/
|
||||||
|
# Remove any .env files with channel keys
|
||||||
|
rm -f /opt/stegasoo/frontends/web/.env 2>/dev/null
|
||||||
|
rm -f /home/*/stegasoo/frontends/web/.env
|
||||||
|
# Reset port 443 redirect (user reconfigures in wizard)
|
||||||
|
if systemctl is-enabled --quiet iptables-restore 2>/dev/null; then
|
||||||
|
systemctl disable iptables-restore 2>/dev/null || true
|
||||||
|
rm -f /etc/systemd/system/iptables-restore.service
|
||||||
|
rm -f /etc/iptables.rules
|
||||||
|
iptables -t nat -F PREROUTING 2>/dev/null || true
|
||||||
|
echo " Port 443 redirect cleared"
|
||||||
|
fi
|
||||||
|
echo " Stegasoo instance data cleared"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 6: First-Boot Wizard Setup
|
||||||
|
# =============================================================================
|
||||||
|
echo -e "${GREEN}[6/11]${NC} Setting up first-boot wizard..."
|
||||||
|
|
||||||
|
# Find stegasoo install directory (prefer /opt/stegasoo)
|
||||||
|
STEGASOO_DIR=""
|
||||||
|
if [ -d /opt/stegasoo ]; then
|
||||||
|
STEGASOO_DIR="/opt/stegasoo"
|
||||||
|
else
|
||||||
|
STEGASOO_DIR=$(ls -d /home/*/stegasoo 2>/dev/null | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$STEGASOO_DIR" ]; then
|
||||||
|
# Last resort fallback
|
||||||
|
if [ -d /root/stegasoo ]; then
|
||||||
|
STEGASOO_DIR="/root/stegasoo"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
STEGASOO_USER=$(stat -c '%U' "$STEGASOO_DIR" 2>/dev/null || echo "pi")
|
||||||
|
echo " Stegasoo directory: $STEGASOO_DIR"
|
||||||
|
echo " Stegasoo user: $STEGASOO_USER"
|
||||||
|
|
||||||
|
# Check and repair venv if needed (paths break when moving directories)
|
||||||
|
if [ -n "$STEGASOO_DIR" ] && [ -d "$STEGASOO_DIR/venv" ]; then
|
||||||
|
VENV_PYTHON="$STEGASOO_DIR/venv/bin/python"
|
||||||
|
# Check if venv python works and has stegasoo installed
|
||||||
|
if ! "$VENV_PYTHON" -c "import stegasoo" 2>/dev/null; then
|
||||||
|
echo " Venv broken or stegasoo not installed, rebuilding..."
|
||||||
|
rm -rf "$STEGASOO_DIR/venv"
|
||||||
|
|
||||||
|
# Find Python 3.12 (prefer pyenv, fall back to system)
|
||||||
|
USER_HOME=$(eval echo "~$STEGASOO_USER")
|
||||||
|
PYENV_PYTHON="$USER_HOME/.pyenv/versions/3.12*/bin/python"
|
||||||
|
if compgen -G "$PYENV_PYTHON" > /dev/null 2>&1; then
|
||||||
|
PYTHON_BIN=$(ls $PYENV_PYTHON 2>/dev/null | head -1)
|
||||||
|
echo " Using pyenv Python: $PYTHON_BIN"
|
||||||
|
elif command -v python3.12 &>/dev/null; then
|
||||||
|
PYTHON_BIN="python3.12"
|
||||||
|
echo " Using system Python 3.12"
|
||||||
|
else
|
||||||
|
PYTHON_BIN="python3"
|
||||||
|
echo " Warning: Python 3.12 not found, using $($PYTHON_BIN --version)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo -u "$STEGASOO_USER" "$PYTHON_BIN" -m venv "$STEGASOO_DIR/venv"
|
||||||
|
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet --upgrade pip setuptools wheel
|
||||||
|
|
||||||
|
# On ARM64, jpegio needs patching before install
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then
|
||||||
|
echo " Building jpegio for ARM64 (this may take a minute)..."
|
||||||
|
# Install build deps
|
||||||
|
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet cython numpy
|
||||||
|
JPEGIO_DIR="/tmp/jpegio-build-$$"
|
||||||
|
rm -rf "$JPEGIO_DIR"
|
||||||
|
if git clone https://github.com/dwgoon/jpegio.git "$JPEGIO_DIR" 2>/dev/null; then
|
||||||
|
# Apply patch to remove -m64 flag
|
||||||
|
if [ -f "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
|
||||||
|
bash "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
|
||||||
|
else
|
||||||
|
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
|
||||||
|
fi
|
||||||
|
# Change ownership so user can build
|
||||||
|
chown -R "$STEGASOO_USER:$STEGASOO_USER" "$JPEGIO_DIR"
|
||||||
|
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install "$JPEGIO_DIR"
|
||||||
|
rm -rf "$JPEGIO_DIR"
|
||||||
|
else
|
||||||
|
echo " Warning: Failed to clone jpegio, DCT mode may not work"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]"
|
||||||
|
echo " Venv rebuilt and stegasoo installed"
|
||||||
|
else
|
||||||
|
echo " Venv OK"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure PATH hook exists for stegasoo CLI and scripts
|
||||||
|
if [ ! -f /etc/profile.d/stegasoo-path.sh ]; then
|
||||||
|
echo " Creating PATH hook..."
|
||||||
|
cat > /etc/profile.d/stegasoo-path.sh <<'PATHEOF'
|
||||||
|
# Stegasoo CLI and scripts
|
||||||
|
if [ -d /opt/stegasoo/venv/bin ]; then
|
||||||
|
export PATH="/opt/stegasoo/venv/bin:$PATH"
|
||||||
|
fi
|
||||||
|
if [ -d /opt/stegasoo/rpi ]; then
|
||||||
|
export PATH="/opt/stegasoo/rpi:$PATH"
|
||||||
|
fi
|
||||||
|
PATHEOF
|
||||||
|
chmod 644 /etc/profile.d/stegasoo-path.sh
|
||||||
|
echo " Installed PATH hook to /etc/profile.d/"
|
||||||
|
else
|
||||||
|
echo " PATH hook OK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$STEGASOO_DIR" ] && [ -f "$STEGASOO_DIR/rpi/stegasoo-wizard.sh" ]; then
|
||||||
|
# Install the profile.d hook
|
||||||
|
cp "$STEGASOO_DIR/rpi/stegasoo-wizard.sh" /etc/profile.d/stegasoo-wizard.sh
|
||||||
|
chmod 644 /etc/profile.d/stegasoo-wizard.sh
|
||||||
|
echo " Installed wizard hook to /etc/profile.d/"
|
||||||
|
|
||||||
|
# Create the first-boot flag
|
||||||
|
touch /etc/stegasoo-first-boot
|
||||||
|
echo " Created /etc/stegasoo-first-boot flag"
|
||||||
|
|
||||||
|
# Reset systemd service to defaults (wizard will reconfigure)
|
||||||
|
cat > /etc/systemd/system/stegasoo.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stegasoo Web UI
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$STEGASOO_USER
|
||||||
|
WorkingDirectory=$STEGASOO_DIR/frontends/web
|
||||||
|
Environment="PATH=$STEGASOO_DIR/venv/bin:/usr/bin"
|
||||||
|
Environment="STEGASOO_AUTH_ENABLED=true"
|
||||||
|
Environment="STEGASOO_HTTPS_ENABLED=false"
|
||||||
|
Environment="STEGASOO_PORT=5000"
|
||||||
|
Environment="STEGASOO_CHANNEL_KEY="
|
||||||
|
ExecStart=$STEGASOO_DIR/venv/bin/python app.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo " Reset systemd service to defaults"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}ERROR: Could not find wizard script${NC}"
|
||||||
|
echo " STEGASOO_DIR: $STEGASOO_DIR"
|
||||||
|
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 7: Logs
|
||||||
|
# =============================================================================
|
||||||
|
echo -e "${GREEN}[7/11]${NC} Clearing logs..."
|
||||||
|
journalctl --rotate 2>/dev/null || true
|
||||||
|
journalctl --vacuum-time=1s 2>/dev/null || true
|
||||||
|
rm -rf /var/log/*.log /var/log/*.gz /var/log/*.[0-9] 2>/dev/null || true
|
||||||
|
rm -rf /var/log/apt/* 2>/dev/null || true
|
||||||
|
rm -rf /var/log/journal/* 2>/dev/null || true
|
||||||
|
find /var/log -type f -name "*.log" -delete 2>/dev/null || true
|
||||||
|
echo " Logs cleared"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 8: Temporary Files
|
||||||
|
# =============================================================================
|
||||||
|
echo -e "${GREEN}[8/11]${NC} Clearing temporary files..."
|
||||||
|
rm -rf /tmp/* 2>/dev/null || true
|
||||||
|
rm -rf /var/tmp/* 2>/dev/null || true
|
||||||
|
echo " Temp files cleared"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 9: Package Cache
|
||||||
|
# =============================================================================
|
||||||
|
echo -e "${GREEN}[9/11]${NC} Clearing package cache..."
|
||||||
|
apt-get clean 2>/dev/null || true
|
||||||
|
rm -rf /var/cache/apt/archives/* 2>/dev/null || true
|
||||||
|
echo " Package cache cleared"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 10: Remove Overclock Settings
|
||||||
|
# =============================================================================
|
||||||
|
if [ "$SOFT_RESET" = true ]; then
|
||||||
|
echo -e "${GREEN}[10/11]${NC} Keeping overclock settings (soft reset)..."
|
||||||
|
echo " Overclock config preserved"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}[10/11]${NC} Removing overclock settings..."
|
||||||
|
CONFIG_FILE="/boot/firmware/config.txt"
|
||||||
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
CONFIG_FILE="/boot/config.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$CONFIG_FILE" ]; then
|
||||||
|
# Remove overclock-related lines
|
||||||
|
if grep -q "over_voltage\|arm_freq\|gpu_freq" "$CONFIG_FILE" 2>/dev/null; then
|
||||||
|
# Create temp file without overclock lines
|
||||||
|
grep -v "^over_voltage=\|^arm_freq=\|^gpu_freq=\|^# Overclock" "$CONFIG_FILE" > "${CONFIG_FILE}.tmp"
|
||||||
|
mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
|
||||||
|
echo " Removed overclock settings from $CONFIG_FILE"
|
||||||
|
else
|
||||||
|
echo " No overclock settings found"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Config file not found, skipping"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 11: Final Sync
|
||||||
|
# =============================================================================
|
||||||
|
echo -e "${GREEN}[11/11]${NC} Final sync..."
|
||||||
|
rm -f /root/.bash_history 2>/dev/null || true
|
||||||
|
sync
|
||||||
|
echo " Filesystem synced"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Validation
|
||||||
|
# =============================================================================
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}Validating sanitization...${NC}"
|
||||||
|
|
||||||
|
# Check first-boot flag
|
||||||
|
if [ -f /etc/stegasoo-first-boot ]; then
|
||||||
|
echo -e " ${GREEN}[PASS]${NC} First-boot flag exists"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[FAIL]${NC} First-boot flag missing"
|
||||||
|
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check profile.d hook
|
||||||
|
if [ -f /etc/profile.d/stegasoo-wizard.sh ]; then
|
||||||
|
echo -e " ${GREEN}[PASS]${NC} Wizard hook installed"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[FAIL]${NC} Wizard hook missing"
|
||||||
|
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check SSH host keys removed
|
||||||
|
if ls /etc/ssh/ssh_host_* 1>/dev/null 2>&1; then
|
||||||
|
echo -e " ${RED}[FAIL]${NC} SSH host keys still present"
|
||||||
|
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo -e " ${GREEN}[PASS]${NC} SSH host keys removed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Stegasoo instance data removed
|
||||||
|
DB_FOUND=false
|
||||||
|
if ls /opt/stegasoo/frontends/web/instance/*.db 1>/dev/null 2>&1; then
|
||||||
|
DB_FOUND=true
|
||||||
|
fi
|
||||||
|
if ls /home/*/stegasoo/frontends/web/instance/*.db 1>/dev/null 2>&1; then
|
||||||
|
DB_FOUND=true
|
||||||
|
fi
|
||||||
|
if [ "$DB_FOUND" = true ]; then
|
||||||
|
echo -e " ${RED}[FAIL]${NC} Stegasoo database still present"
|
||||||
|
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo -e " ${GREEN}[PASS]${NC} Stegasoo database removed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check WiFi (only for full sanitize)
|
||||||
|
if [ "$SOFT_RESET" = false ]; then
|
||||||
|
WIFI_FOUND=false
|
||||||
|
|
||||||
|
# Check wpa_supplicant
|
||||||
|
if grep -q "psk=" /etc/wpa_supplicant/wpa_supplicant.conf 2>/dev/null; then
|
||||||
|
WIFI_FOUND=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check NetworkManager
|
||||||
|
for conn in /etc/NetworkManager/system-connections/*; do
|
||||||
|
if [ -f "$conn" ] && grep -q "type=wifi" "$conn" 2>/dev/null; then
|
||||||
|
WIFI_FOUND=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check netplan
|
||||||
|
for np in /etc/netplan/*.yaml; do
|
||||||
|
if [ -f "$np" ] && grep -q "wifis:" "$np" 2>/dev/null; then
|
||||||
|
WIFI_FOUND=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# Check NM-generated netplan
|
||||||
|
if ls /etc/netplan/90-NM-*.yaml 1>/dev/null 2>&1; then
|
||||||
|
WIFI_FOUND=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$WIFI_FOUND" = true ]; then
|
||||||
|
echo -e " ${RED}[FAIL]${NC} WiFi credentials still present"
|
||||||
|
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo -e " ${GREEN}[PASS]${NC} WiFi credentials cleared"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}[SKIP]${NC} WiFi check (soft reset mode)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check authorized_keys removed
|
||||||
|
AUTH_KEYS_FOUND=false
|
||||||
|
for user_home in /home/*; do
|
||||||
|
if [ -f "$user_home/.ssh/authorized_keys" ]; then
|
||||||
|
AUTH_KEYS_FOUND=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$AUTH_KEYS_FOUND" = true ]; then
|
||||||
|
echo -e " ${RED}[FAIL]${NC} SSH authorized_keys still present"
|
||||||
|
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo -e " ${GREEN}[PASS]${NC} SSH authorized_keys removed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Summary
|
||||||
|
# =============================================================================
|
||||||
|
echo ""
|
||||||
|
if [ $VALIDATION_ERRORS -eq 0 ]; then
|
||||||
|
echo -e "${BOLD}Sanitization Complete!${NC}"
|
||||||
|
echo -e "${GREEN}-------------------------------------------------------${NC}"
|
||||||
|
echo -e " ${GREEN}All validation checks passed.${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${BOLD}Sanitization Complete with Errors${NC}"
|
||||||
|
echo -e "${RED}-------------------------------------------------------${NC}"
|
||||||
|
echo -e " ${RED}$VALIDATION_ERRORS validation check(s) failed${NC}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$SOFT_RESET" = true ]; then
|
||||||
|
echo -e "${CYAN}Soft reset complete.${NC}"
|
||||||
|
echo "You can now reboot to test the first-boot wizard."
|
||||||
|
echo ""
|
||||||
|
if [ "$AUTO_REBOOT" = true ]; then
|
||||||
|
echo "Rebooting..."
|
||||||
|
exec reboot
|
||||||
|
fi
|
||||||
|
# Flush input buffer and pause before prompt
|
||||||
|
read -t 0.1 -n 10000 discard </dev/tty 2>/dev/null || true
|
||||||
|
sleep 0.3
|
||||||
|
read -p "Reboot now? [y/N] " -n 1 -r </dev/tty
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
exec reboot
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "The system is ready for imaging."
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Next steps:${NC}"
|
||||||
|
echo " 1. Shut down: sudo shutdown -h now"
|
||||||
|
echo " 2. Remove SD card"
|
||||||
|
echo " 3. On another machine, copy with:"
|
||||||
|
echo " sudo dd if=/dev/sdX of=stegasoo-rpi.img bs=4M status=progress"
|
||||||
|
echo " 4. Compress: zstd -19 stegasoo-rpi.img"
|
||||||
|
echo ""
|
||||||
|
if [ "$AUTO_REBOOT" = true ]; then
|
||||||
|
echo "Shutting down..."
|
||||||
|
exec shutdown -h now
|
||||||
|
fi
|
||||||
|
# Flush input buffer and pause before prompt
|
||||||
|
read -t 0.1 -n 10000 discard </dev/tty 2>/dev/null || true
|
||||||
|
sleep 0.3
|
||||||
|
read -p "Shut down now? [y/N] " -n 1 -r </dev/tty
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
exec shutdown -h now
|
||||||
|
fi
|
||||||
|
fi
|
||||||
531
rpi/setup.sh
Executable file
@@ -0,0 +1,531 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Stegasoo Raspberry Pi Setup Script
|
||||||
|
# Tested on: Raspberry Pi 4/5 with Raspberry Pi OS (64-bit)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# curl -sSL https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.1/rpi/setup.sh | bash
|
||||||
|
# # or
|
||||||
|
# wget -qO- https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.1/rpi/setup.sh | bash
|
||||||
|
#
|
||||||
|
# What this script does:
|
||||||
|
# 1. Installs system dependencies
|
||||||
|
# 2. Installs Python 3.12 via pyenv (Pi OS ships with 3.13 which is incompatible)
|
||||||
|
# 3. Patches and builds jpegio for ARM
|
||||||
|
# 4. Installs Stegasoo with web UI
|
||||||
|
# 5. Creates systemd service for auto-start
|
||||||
|
# 6. Enables the service
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
GRAY='\033[0;90m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Show help
|
||||||
|
show_help() {
|
||||||
|
echo "Stegasoo Raspberry Pi Setup Script"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: $0 [options]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " -h, --help Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Configuration:"
|
||||||
|
echo " Config files are loaded in order (later overrides earlier):"
|
||||||
|
echo " 1. /etc/stegasoo.conf"
|
||||||
|
echo " 2. ~/.config/stegasoo/stegasoo.conf"
|
||||||
|
echo " 3. Environment variables"
|
||||||
|
echo ""
|
||||||
|
echo " Available variables:"
|
||||||
|
echo " INSTALL_DIR Install location (default: /opt/stegasoo)"
|
||||||
|
echo " PYTHON_VERSION Python version (default: 3.12)"
|
||||||
|
echo " STEGASOO_REPO Git repo URL"
|
||||||
|
echo " STEGASOO_BRANCH Git branch (default: 4.1)"
|
||||||
|
echo ""
|
||||||
|
echo " Example:"
|
||||||
|
echo " export INSTALL_DIR=\"/home/pi/stegasoo\""
|
||||||
|
echo " ./setup.sh"
|
||||||
|
echo ""
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse args
|
||||||
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
|
-h|--help) show_help ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Default configuration
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
|
||||||
|
PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
|
||||||
|
STEGASOO_REPO="${STEGASOO_REPO:-https://github.com/adlee-was-taken/stegasoo.git}"
|
||||||
|
STEGASOO_BRANCH="${STEGASOO_BRANCH:-4.1}"
|
||||||
|
JPEGIO_REPO="https://github.com/dwgoon/jpegio.git"
|
||||||
|
|
||||||
|
# Load config files (system, then user - user overrides system)
|
||||||
|
for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf"; do
|
||||||
|
if [ -f "$config_file" ]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$config_file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
clear
|
||||||
|
echo ""
|
||||||
|
echo -e "${GRAY} . * . . * . * . * . * .${NC}"
|
||||||
|
echo -e "${CYAN} ___ _____ ___ ___ _ ___ ___ ___${NC}"
|
||||||
|
echo -e "${CYAN} / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\${NC}"
|
||||||
|
echo -e "${CYAN} \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |${NC}"
|
||||||
|
echo -e "${CYAN} |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${GRAY} * . * . * . * . * . *${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN} Raspberry Pi Setup${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " This will install Stegasoo with full DCT support"
|
||||||
|
echo " Estimated time: 15-20 minutes on Pi 5"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if running on ARM
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [[ "$ARCH" != "aarch64" && "$ARCH" != "arm64" ]]; then
|
||||||
|
echo -e "${RED}Error: This script is for ARM64 systems (Raspberry Pi).${NC}"
|
||||||
|
echo "Detected architecture: $ARCH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check available memory
|
||||||
|
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
|
||||||
|
if [ "$TOTAL_MEM" -lt 2000 ]; then
|
||||||
|
echo -e "${YELLOW}Warning: Less than 2GB RAM detected ($TOTAL_MEM MB).${NC}"
|
||||||
|
echo "Stegasoo Web UI requires ~768MB for Argon2 operations."
|
||||||
|
echo "Consider using a Pi with more RAM for best results."
|
||||||
|
read -p "Continue anyway? [y/N] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create /opt/stegasoo with proper permissions
|
||||||
|
echo -e "${GREEN}[1/12]${NC} Setting up install directory..."
|
||||||
|
if [ ! -d "$INSTALL_DIR" ]; then
|
||||||
|
sudo mkdir -p "$INSTALL_DIR"
|
||||||
|
sudo chown "$USER:$USER" "$INSTALL_DIR"
|
||||||
|
echo " Created $INSTALL_DIR"
|
||||||
|
else
|
||||||
|
# Ensure current user owns it
|
||||||
|
sudo chown "$USER:$USER" "$INSTALL_DIR"
|
||||||
|
echo " $INSTALL_DIR exists, updated ownership"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}[2/12]${NC} Installing system dependencies..."
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
libssl-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
libbz2-dev \
|
||||||
|
libreadline-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
libncursesw5-dev \
|
||||||
|
xz-utils \
|
||||||
|
tk-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
libxmlsec1-dev \
|
||||||
|
libffi-dev \
|
||||||
|
liblzma-dev \
|
||||||
|
libzbar0 \
|
||||||
|
libjpeg-dev \
|
||||||
|
python3-dev \
|
||||||
|
btop
|
||||||
|
|
||||||
|
echo -e "${GREEN}[3/12]${NC} Installing gum (TUI toolkit)..."
|
||||||
|
# Add Charm repo for gum
|
||||||
|
if ! command -v gum &>/dev/null; then
|
||||||
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
|
curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gum
|
||||||
|
else
|
||||||
|
echo " gum already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}[4/12]${NC} Installing pyenv and Python $PYTHON_VERSION..."
|
||||||
|
|
||||||
|
# Install pyenv if not present
|
||||||
|
if [ ! -d "$HOME/.pyenv" ]; then
|
||||||
|
curl https://pyenv.run | bash
|
||||||
|
|
||||||
|
# Add pyenv to current shell
|
||||||
|
export PYENV_ROOT="$HOME/.pyenv"
|
||||||
|
export PATH="$PYENV_ROOT/bin:$PATH"
|
||||||
|
eval "$(pyenv init -)"
|
||||||
|
|
||||||
|
# Add to .bashrc if not already there
|
||||||
|
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
|
||||||
|
echo '' >> ~/.bashrc
|
||||||
|
echo '# pyenv' >> ~/.bashrc
|
||||||
|
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
||||||
|
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
||||||
|
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "pyenv already installed, skipping..."
|
||||||
|
export PYENV_ROOT="$HOME/.pyenv"
|
||||||
|
export PATH="$PYENV_ROOT/bin:$PATH"
|
||||||
|
eval "$(pyenv init -)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Python 3.12 if not present
|
||||||
|
if ! pyenv versions | grep -q "$PYTHON_VERSION"; then
|
||||||
|
echo "Building Python $PYTHON_VERSION (this takes ~10 minutes)..."
|
||||||
|
pyenv install $PYTHON_VERSION
|
||||||
|
fi
|
||||||
|
pyenv global $PYTHON_VERSION
|
||||||
|
|
||||||
|
# Verify Python version
|
||||||
|
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
||||||
|
if [ "$INSTALLED_PY" != "$PYTHON_VERSION" ]; then
|
||||||
|
echo -e "${RED}Error: Python $PYTHON_VERSION not active. Got: $INSTALLED_PY${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}[5/12]${NC} Cloning Stegasoo..."
|
||||||
|
|
||||||
|
# Clone Stegasoo first (needed for jpegio patch script)
|
||||||
|
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||||
|
echo " Stegasoo directory exists, updating..."
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
git fetch origin
|
||||||
|
git checkout "$STEGASOO_BRANCH"
|
||||||
|
git pull origin "$STEGASOO_BRANCH"
|
||||||
|
else
|
||||||
|
git clone -b "$STEGASOO_BRANCH" "$STEGASOO_REPO" "$INSTALL_DIR"
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}[6/12]${NC} Creating Python virtual environment..."
|
||||||
|
|
||||||
|
# Create venv with pyenv Python (not system Python)
|
||||||
|
# Use pyenv which to get actual path (handles 3.12 -> 3.12.12 mapping)
|
||||||
|
PYENV_PYTHON=$(pyenv which python)
|
||||||
|
echo " Using Python: $PYENV_PYTHON"
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
"$PYENV_PYTHON" -m venv venv
|
||||||
|
fi
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Verify we're using the right Python
|
||||||
|
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
||||||
|
echo " venv Python: $VENV_PY"
|
||||||
|
|
||||||
|
echo -e "${GREEN}[7/12]${NC} Building jpegio for ARM..."
|
||||||
|
|
||||||
|
# Clone jpegio
|
||||||
|
JPEGIO_DIR="/tmp/jpegio-build"
|
||||||
|
rm -rf "$JPEGIO_DIR"
|
||||||
|
git clone "$JPEGIO_REPO" "$JPEGIO_DIR"
|
||||||
|
|
||||||
|
# Apply ARM64 patch
|
||||||
|
if [ -f "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
|
||||||
|
bash "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
|
||||||
|
else
|
||||||
|
echo " Applying inline ARM64 patch..."
|
||||||
|
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$JPEGIO_DIR"
|
||||||
|
|
||||||
|
# Build jpegio into venv
|
||||||
|
pip install --upgrade pip setuptools wheel cython numpy
|
||||||
|
pip install .
|
||||||
|
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
rm -rf "$JPEGIO_DIR"
|
||||||
|
|
||||||
|
echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..."
|
||||||
|
|
||||||
|
# Install dependencies (jpegio already in venv, won't re-download)
|
||||||
|
pip install -e ".[web]"
|
||||||
|
|
||||||
|
echo -e "${GREEN}[9/12]${NC} Creating systemd service..."
|
||||||
|
|
||||||
|
# Create systemd service file
|
||||||
|
sudo tee /etc/systemd/system/stegasoo.service > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stegasoo Web UI
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$USER
|
||||||
|
WorkingDirectory=$INSTALL_DIR/frontends/web
|
||||||
|
Environment="PATH=$INSTALL_DIR/venv/bin:/usr/bin"
|
||||||
|
Environment="STEGASOO_AUTH_ENABLED=true"
|
||||||
|
Environment="STEGASOO_HTTPS_ENABLED=false"
|
||||||
|
Environment="STEGASOO_PORT=5000"
|
||||||
|
ExecStart=$INSTALL_DIR/venv/bin/python app.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo -e "${GREEN}[10/12]${NC} Enabling service..."
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable stegasoo.service
|
||||||
|
|
||||||
|
echo -e "${GREEN}[11/12]${NC} Adding stegasoo to PATH..."
|
||||||
|
|
||||||
|
# Add stegasoo venv and rpi scripts to PATH for all users
|
||||||
|
sudo tee /etc/profile.d/stegasoo-path.sh > /dev/null <<'PATHEOF'
|
||||||
|
# Stegasoo CLI and scripts
|
||||||
|
if [ -d /opt/stegasoo/venv/bin ]; then
|
||||||
|
export PATH="/opt/stegasoo/venv/bin:$PATH"
|
||||||
|
fi
|
||||||
|
if [ -d /opt/stegasoo/rpi ]; then
|
||||||
|
export PATH="/opt/stegasoo/rpi:$PATH"
|
||||||
|
fi
|
||||||
|
PATHEOF
|
||||||
|
sudo chmod 644 /etc/profile.d/stegasoo-path.sh
|
||||||
|
echo " Added /opt/stegasoo/venv/bin and /opt/stegasoo/rpi to PATH"
|
||||||
|
|
||||||
|
echo -e "${GREEN}[12/12]${NC} Setting up login banner..."
|
||||||
|
|
||||||
|
# Create dynamic MOTD script
|
||||||
|
sudo tee /etc/profile.d/stegasoo-motd.sh > /dev/null <<'MOTDEOF'
|
||||||
|
# Stegasoo login banner
|
||||||
|
if systemctl is-active --quiet stegasoo 2>/dev/null; then
|
||||||
|
PI_IP=$(hostname -I | awk '{print $1}')
|
||||||
|
# Check if HTTPS and port 443 are configured
|
||||||
|
if systemctl show stegasoo -p Environment 2>/dev/null | grep -q "STEGASOO_HTTPS_ENABLED=true"; then
|
||||||
|
# Check for port 443 redirect (iptables-restore service means 443 is configured)
|
||||||
|
if systemctl is-enabled --quiet iptables-restore 2>/dev/null; then
|
||||||
|
STEGASOO_URL="https://$PI_IP"
|
||||||
|
else
|
||||||
|
STEGASOO_URL="https://$PI_IP:5000"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
STEGASOO_URL="http://$PI_IP:5000"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo -e "\033[0;36m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||||
|
echo -e "\033[0;36m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
||||||
|
echo -e "\033[0;36m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
|
||||||
|
echo -e "\033[0;36m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
||||||
|
echo ""
|
||||||
|
echo -e " \033[0;32m●\033[0m Stegasoo is running"
|
||||||
|
echo -e " \033[0;33m$STEGASOO_URL\033[0m"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo -e " \033[0;31m●\033[0m Stegasoo is not running"
|
||||||
|
echo -e " Start with: sudo systemctl start stegasoo"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
MOTDEOF
|
||||||
|
sudo chmod 644 /etc/profile.d/stegasoo-motd.sh
|
||||||
|
echo " Created login banner"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Installation Complete!${NC}"
|
||||||
|
echo -e "${BLUE}-------------------------------------------------------${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "Stegasoo installed to: ${YELLOW}$INSTALL_DIR${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Interactive Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo -e "${BOLD}Configuration${NC}"
|
||||||
|
echo -e "${BLUE}-------------------------------------------------------${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Track configuration choices
|
||||||
|
ENABLE_HTTPS="false"
|
||||||
|
USE_PORT_443="false"
|
||||||
|
CHANNEL_KEY=""
|
||||||
|
|
||||||
|
# --- HTTPS Configuration ---
|
||||||
|
echo -e "${GREEN}HTTPS Configuration${NC}"
|
||||||
|
echo " HTTPS encrypts traffic with a self-signed certificate."
|
||||||
|
echo " (Browser will show a security warning - this is normal for self-signed certs)"
|
||||||
|
echo ""
|
||||||
|
read -p "Enable HTTPS? [y/N] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
ENABLE_HTTPS="true"
|
||||||
|
echo -e " ${GREEN}✓${NC} HTTPS will be enabled"
|
||||||
|
|
||||||
|
# --- Port 443 Configuration ---
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Port Configuration${NC}"
|
||||||
|
echo " Standard HTTPS port is 443 (no port needed in URL)."
|
||||||
|
echo " This requires iptables to redirect 443 → 5000."
|
||||||
|
echo ""
|
||||||
|
read -p "Use standard port 443? [y/N] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
USE_PORT_443="true"
|
||||||
|
echo -e " ${GREEN}✓${NC} Port 443 will be configured"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}→${NC} Using default port 5000"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}→${NC} Using HTTP (unencrypted)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Channel Key Configuration ---
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Channel Key Configuration${NC}"
|
||||||
|
echo " A channel key creates a private encoding channel."
|
||||||
|
echo " Only users with the same key can decode each other's images."
|
||||||
|
echo ""
|
||||||
|
read -p "Generate a private channel key? [y/N] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
# Generate channel key using the CLI
|
||||||
|
CHANNEL_KEY=$($INSTALL_DIR/venv/bin/python -c "from stegasoo.channel import generate_channel_key; print(generate_channel_key())")
|
||||||
|
echo -e " ${GREEN}✓${NC} Channel key generated: ${YELLOW}$CHANNEL_KEY${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${RED}IMPORTANT: Save this key!${NC} You'll need to share it with anyone"
|
||||||
|
echo " who should be able to decode your images."
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}→${NC} Using public mode (no channel isolation)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Apply Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}Applying configuration...${NC}"
|
||||||
|
|
||||||
|
# Update systemd service with configuration
|
||||||
|
sudo tee /etc/systemd/system/stegasoo.service > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stegasoo Web UI
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$USER
|
||||||
|
WorkingDirectory=$INSTALL_DIR/frontends/web
|
||||||
|
Environment="PATH=$INSTALL_DIR/venv/bin:/usr/bin"
|
||||||
|
Environment="STEGASOO_AUTH_ENABLED=true"
|
||||||
|
Environment="STEGASOO_HTTPS_ENABLED=$ENABLE_HTTPS"
|
||||||
|
Environment="STEGASOO_PORT=5000"
|
||||||
|
Environment="STEGASOO_CHANNEL_KEY=$CHANNEL_KEY"
|
||||||
|
ExecStart=$INSTALL_DIR/venv/bin/python app.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Setup port 443 redirect if requested
|
||||||
|
if [ "$USE_PORT_443" = "true" ]; then
|
||||||
|
echo " Setting up port 443 redirect..."
|
||||||
|
|
||||||
|
# Install iptables if needed
|
||||||
|
if ! command -v iptables &> /dev/null; then
|
||||||
|
sudo apt-get install -y iptables
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add redirect rule
|
||||||
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 5000
|
||||||
|
sudo sh -c 'iptables-save > /etc/iptables.rules'
|
||||||
|
|
||||||
|
# Create systemd service to restore rules on boot
|
||||||
|
sudo tee /etc/systemd/system/iptables-restore.service > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Restore iptables rules
|
||||||
|
Before=network-pre.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/sbin/iptables-restore /etc/iptables.rules
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
sudo systemctl enable iptables-restore.service
|
||||||
|
echo -e " ${GREEN}✓${NC} Port 443 redirect configured"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Final Summary
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Setup Complete!${NC}"
|
||||||
|
echo -e "${BLUE}-------------------------------------------------------${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
PI_IP=$(hostname -I | awk '{print $1}')
|
||||||
|
|
||||||
|
echo -e "${GREEN}Create your admin account:${NC}"
|
||||||
|
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||||
|
if [ "$USE_PORT_443" = "true" ]; then
|
||||||
|
echo -e " ${YELLOW}https://$PI_IP/setup${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}https://$PI_IP:5000/setup${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ -n "$CHANNEL_KEY" ]; then
|
||||||
|
echo -e "${GREEN}Channel Key:${NC}"
|
||||||
|
echo -e " ${YELLOW}$CHANNEL_KEY${NC}"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}Commands:${NC}"
|
||||||
|
echo " Start: sudo systemctl start stegasoo"
|
||||||
|
echo " Stop: sudo systemctl stop stegasoo"
|
||||||
|
echo " Status: sudo systemctl status stegasoo"
|
||||||
|
echo " Logs: journalctl -u stegasoo -f"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Offer to start now
|
||||||
|
read -p "Start Stegasoo now? [Y/n] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||||
|
sudo systemctl start stegasoo
|
||||||
|
sleep 2
|
||||||
|
if systemctl is-active --quiet stegasoo; then
|
||||||
|
echo -e "${GREEN}✓ Stegasoo is running!${NC}"
|
||||||
|
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||||
|
if [ "$USE_PORT_443" = "true" ]; then
|
||||||
|
echo -e " Create admin: ${YELLOW}https://$PI_IP/setup${NC}"
|
||||||
|
else
|
||||||
|
echo -e " Create admin: ${YELLOW}https://$PI_IP:5000/setup${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e " Create admin: ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Failed to start. Check logs:${NC} journalctl -u stegasoo -f"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
541
rpi/smoke-test.sh
Executable file
@@ -0,0 +1,541 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Stegasoo Pi Image Smoke Test
|
||||||
|
# Automated testing of a fresh Pi image
|
||||||
|
#
|
||||||
|
# Usage: ./smoke-test.sh [ip] [--https] [--443] [--port=PORT]
|
||||||
|
# Default IP: 192.168.0.4
|
||||||
|
# --https Use HTTPS (port 5000)
|
||||||
|
# --443 Use HTTPS on port 443
|
||||||
|
# --port=N Specify custom port
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
PI_IP="192.168.0.4"
|
||||||
|
HTTPS=false
|
||||||
|
PORT=5000
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
|
--https) HTTPS=true ;;
|
||||||
|
--443) HTTPS=true; PORT=443 ;;
|
||||||
|
--port=*) PORT="${arg#*=}" ;;
|
||||||
|
--*) ;; # Ignore other flags
|
||||||
|
*)
|
||||||
|
# If it looks like an IP, use it
|
||||||
|
if [[ "$arg" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
PI_IP="$arg"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$HTTPS" = true ]; then
|
||||||
|
if [ "$PORT" = "443" ]; then
|
||||||
|
BASE_URL="https://$PI_IP"
|
||||||
|
else
|
||||||
|
BASE_URL="https://$PI_IP:$PORT"
|
||||||
|
fi
|
||||||
|
CURL_OPTS="-k" # Allow self-signed certs
|
||||||
|
else
|
||||||
|
BASE_URL="http://$PI_IP:$PORT"
|
||||||
|
CURL_OPTS=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test credentials
|
||||||
|
ADMIN_USER="admin"
|
||||||
|
ADMIN_PASS="stegasoo"
|
||||||
|
REGULAR_USER="smokeuser"
|
||||||
|
REGULAR_PASS="SmokeUser123!"
|
||||||
|
|
||||||
|
# Temp files
|
||||||
|
COOKIE_JAR=$(mktemp)
|
||||||
|
COOKIE_JAR_USER=$(mktemp)
|
||||||
|
TEST_IMAGE=$(mktemp --suffix=.png)
|
||||||
|
ENCODED_IMAGE=$(mktemp --suffix=.png)
|
||||||
|
RESPONSE=$(mktemp)
|
||||||
|
|
||||||
|
ENCODED_IMAGE_USER=$(mktemp --suffix=.png)
|
||||||
|
QR_IMAGE=$(mktemp --suffix=.png)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$COOKIE_JAR" "$COOKIE_JAR_USER" "$TEST_IMAGE" "$ENCODED_IMAGE" "$ENCODED_IMAGE_USER" "$QR_IMAGE" "$RESPONSE"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Create a simple test image (red square)
|
||||||
|
create_test_image() {
|
||||||
|
if command -v convert &>/dev/null; then
|
||||||
|
convert -size 100x100 xc:red "$TEST_IMAGE"
|
||||||
|
elif command -v python3 &>/dev/null; then
|
||||||
|
python3 -c "
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.new('RGB', (100, 100), color='red')
|
||||||
|
img.save('$TEST_IMAGE')
|
||||||
|
"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Warning: No image tool available, skipping encode/decode tests${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Results tracking
|
||||||
|
TESTS_PASSED=0
|
||||||
|
TESTS_FAILED=0
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
echo -e " ${GREEN}[PASS]${NC} $1"
|
||||||
|
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo -e " ${RED}[FAIL]${NC} $1"
|
||||||
|
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
skip() {
|
||||||
|
echo -e " ${YELLOW}[SKIP]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Header
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${CYAN}║ Stegasoo Pi Image Smoke Test ║${NC}"
|
||||||
|
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "Target: ${YELLOW}$BASE_URL${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test 1: Web UI Reachable
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo -e "${BOLD}[1/9] Web UI Accessibility${NC}"
|
||||||
|
|
||||||
|
if curl $CURL_OPTS -s -o /dev/null -w "%{http_code}" "$BASE_URL" | grep -q "200\|302"; then
|
||||||
|
pass "Web UI is reachable"
|
||||||
|
else
|
||||||
|
fail "Web UI not reachable at $BASE_URL"
|
||||||
|
echo -e "${RED}Cannot continue without web access. Is the Pi running?${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if redirected to setup (first run) or login
|
||||||
|
REDIRECT=$(curl $CURL_OPTS -s -o /dev/null -w "%{redirect_url}" "$BASE_URL")
|
||||||
|
if echo "$REDIRECT" | grep -q "setup"; then
|
||||||
|
pass "Redirected to setup (fresh install)"
|
||||||
|
NEEDS_SETUP=true
|
||||||
|
elif echo "$REDIRECT" | grep -q "login"; then
|
||||||
|
pass "Redirected to login (already configured)"
|
||||||
|
NEEDS_SETUP=false
|
||||||
|
else
|
||||||
|
# Check page content
|
||||||
|
if curl $CURL_OPTS -s "$BASE_URL" | grep -q "setup\|Setup\|Create.*Admin"; then
|
||||||
|
pass "Setup page detected"
|
||||||
|
NEEDS_SETUP=true
|
||||||
|
else
|
||||||
|
pass "Login page detected"
|
||||||
|
NEEDS_SETUP=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test 2: Create Admin User (if needed)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}[2/9] Admin Setup${NC}"
|
||||||
|
|
||||||
|
if [ "$NEEDS_SETUP" = true ]; then
|
||||||
|
# Get CSRF token from setup page
|
||||||
|
SETUP_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR" "$BASE_URL/setup")
|
||||||
|
CSRF_TOKEN=$(echo "$SETUP_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||||
|
|
||||||
|
if [ -z "$CSRF_TOKEN" ]; then
|
||||||
|
# Try alternate pattern
|
||||||
|
CSRF_TOKEN=$(echo "$SETUP_PAGE" | grep -oP 'csrf_token.*?value="\K[^"]+' || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create admin user
|
||||||
|
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
|
||||||
|
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||||
|
-X POST "$BASE_URL/setup" \
|
||||||
|
-d "username=$ADMIN_USER" \
|
||||||
|
-d "password=$ADMIN_PASS" \
|
||||||
|
-d "password_confirm=$ADMIN_PASS" \
|
||||||
|
-d "csrf_token=$CSRF_TOKEN")
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
if curl $CURL_OPTS -s "$BASE_URL" | grep -q "login\|Login"; then
|
||||||
|
pass "Admin user created successfully"
|
||||||
|
else
|
||||||
|
pass "Setup completed (assuming success)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "Failed to create admin user (HTTP $HTTP_CODE)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "Setup already complete"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test 3: Admin Login
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}[3/9] Admin Authentication${NC}"
|
||||||
|
|
||||||
|
# Get login page and CSRF
|
||||||
|
LOGIN_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR" "$BASE_URL/login")
|
||||||
|
CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||||
|
|
||||||
|
# Try login as admin
|
||||||
|
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
|
||||||
|
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||||
|
-X POST "$BASE_URL/login" \
|
||||||
|
-d "username=$ADMIN_USER" \
|
||||||
|
-d "password=$ADMIN_PASS" \
|
||||||
|
-d "csrf_token=$CSRF_TOKEN" \
|
||||||
|
-L)
|
||||||
|
|
||||||
|
# Check if we're logged in by accessing a protected page
|
||||||
|
if curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/" | grep -qi "encode\|decode\|logout"; then
|
||||||
|
pass "Admin login successful"
|
||||||
|
ADMIN_LOGGED_IN=true
|
||||||
|
else
|
||||||
|
fail "Admin login failed"
|
||||||
|
ADMIN_LOGGED_IN=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test 4: Admin Encode/Decode
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}[4/9] Admin Encode/Decode${NC}"
|
||||||
|
|
||||||
|
if [ "$ADMIN_LOGGED_IN" = true ]; then
|
||||||
|
ENCODE_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/encode")
|
||||||
|
|
||||||
|
if echo "$ENCODE_PAGE" | grep -qi "encode\|message\|image\|upload"; then
|
||||||
|
pass "Encode page loads"
|
||||||
|
else
|
||||||
|
fail "Encode page not accessible"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try actual encoding if we have image tools
|
||||||
|
if create_test_image 2>/dev/null; then
|
||||||
|
CSRF_TOKEN=$(echo "$ENCODE_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||||
|
|
||||||
|
# For encode: use same image as reference_photo and carrier (for simplicity)
|
||||||
|
# First POST (no redirect follow), get Location header, then GET result page
|
||||||
|
ENCODE_RESULT=$(curl $CURL_OPTS -s -D - -o /dev/null \
|
||||||
|
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||||
|
-X POST "$BASE_URL/encode" \
|
||||||
|
-F "reference_photo=@$TEST_IMAGE" \
|
||||||
|
-F "carrier=@$TEST_IMAGE" \
|
||||||
|
-F "message=Admin smoke test" \
|
||||||
|
-F "passphrase=smoke test phrase" \
|
||||||
|
-F "pin=123456" \
|
||||||
|
-F "csrf_token=$CSRF_TOKEN")
|
||||||
|
|
||||||
|
# Extract redirect location
|
||||||
|
RESULT_LOCATION=$(echo "$ENCODE_RESULT" | grep -i "^location:" | tr -d '\r' | awk '{print $2}')
|
||||||
|
|
||||||
|
if [ -n "$RESULT_LOCATION" ]; then
|
||||||
|
# GET the result page
|
||||||
|
RESULT_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL$RESULT_LOCATION")
|
||||||
|
|
||||||
|
# Look for download link in result page
|
||||||
|
DOWNLOAD_URL=$(echo "$RESULT_PAGE" | grep -oP 'href="(/encode/download/[^"]+)"' | head -1 | grep -oP '/encode/download/[^"]+')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$DOWNLOAD_URL" ]; then
|
||||||
|
# Download the encoded image
|
||||||
|
HTTP_CODE=$(curl $CURL_OPTS -s -o "$ENCODED_IMAGE" -w "%{http_code}" \
|
||||||
|
-b "$COOKIE_JAR" "$BASE_URL$DOWNLOAD_URL")
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ] && file "$ENCODED_IMAGE" | grep -qi "image\|PNG\|JPEG"; then
|
||||||
|
pass "Admin encoding works"
|
||||||
|
|
||||||
|
# Now decode it
|
||||||
|
DECODE_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/decode")
|
||||||
|
CSRF_TOKEN=$(echo "$DECODE_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||||
|
|
||||||
|
DECODED=$(curl $CURL_OPTS -s \
|
||||||
|
-b "$COOKIE_JAR" \
|
||||||
|
-X POST "$BASE_URL/decode" \
|
||||||
|
-F "reference_photo=@$TEST_IMAGE" \
|
||||||
|
-F "stego_image=@$ENCODED_IMAGE" \
|
||||||
|
-F "passphrase=smoke test phrase" \
|
||||||
|
-F "pin=123456" \
|
||||||
|
-F "csrf_token=$CSRF_TOKEN")
|
||||||
|
|
||||||
|
if echo "$DECODED" | grep -q "Admin smoke test"; then
|
||||||
|
pass "Admin decoding works"
|
||||||
|
else
|
||||||
|
fail "Admin decode failed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "Failed to download encoded image (HTTP $HTTP_CODE)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Check for error messages in result page
|
||||||
|
ERROR_MSG=$(echo "$RESULT_PAGE" | grep -oP 'toast-body">[^<]*<[^>]*>[^<]*' | head -1)
|
||||||
|
if [ -n "$ERROR_MSG" ]; then
|
||||||
|
fail "Encoding failed: $ERROR_MSG"
|
||||||
|
else
|
||||||
|
fail "No download link found in encode result"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "Encode/Decode (no image tools)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "Admin encode/decode (not logged in)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test 5: Create Regular User
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}[5/9] Create Regular User${NC}"
|
||||||
|
|
||||||
|
if [ "$ADMIN_LOGGED_IN" = true ]; then
|
||||||
|
# Check if there's a user management page
|
||||||
|
USERS_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/users" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if echo "$USERS_PAGE" | grep -qi "user\|create\|add"; then
|
||||||
|
CSRF_TOKEN=$(echo "$USERS_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
|
||||||
|
-b "$COOKIE_JAR" \
|
||||||
|
-X POST "$BASE_URL/users/create" \
|
||||||
|
-d "username=$REGULAR_USER" \
|
||||||
|
-d "password=$REGULAR_PASS" \
|
||||||
|
-d "password_confirm=$REGULAR_PASS" \
|
||||||
|
-d "csrf_token=$CSRF_TOKEN")
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
pass "Regular user created"
|
||||||
|
USER_CREATED=true
|
||||||
|
else
|
||||||
|
# Try alternate endpoint
|
||||||
|
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
|
||||||
|
-b "$COOKIE_JAR" \
|
||||||
|
-X POST "$BASE_URL/register" \
|
||||||
|
-d "username=$REGULAR_USER" \
|
||||||
|
-d "password=$REGULAR_PASS" \
|
||||||
|
-d "password_confirm=$REGULAR_PASS" \
|
||||||
|
-d "csrf_token=$CSRF_TOKEN")
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
pass "Regular user created (via register)"
|
||||||
|
USER_CREATED=true
|
||||||
|
else
|
||||||
|
fail "Failed to create regular user"
|
||||||
|
USER_CREATED=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "User creation (no user management page)"
|
||||||
|
USER_CREATED=false
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "User creation (admin not logged in)"
|
||||||
|
USER_CREATED=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test 6: Regular User Login & Encode/Decode
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}[6/9] Regular User Workflow${NC}"
|
||||||
|
|
||||||
|
if [ "$USER_CREATED" = true ]; then
|
||||||
|
# Logout admin first (get fresh session)
|
||||||
|
curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/logout" >/dev/null
|
||||||
|
|
||||||
|
# Login as regular user
|
||||||
|
LOGIN_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR_USER" "$BASE_URL/login")
|
||||||
|
CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
|
||||||
|
-b "$COOKIE_JAR_USER" -c "$COOKIE_JAR_USER" \
|
||||||
|
-X POST "$BASE_URL/login" \
|
||||||
|
-d "username=$REGULAR_USER" \
|
||||||
|
-d "password=$REGULAR_PASS" \
|
||||||
|
-d "csrf_token=$CSRF_TOKEN" \
|
||||||
|
-L)
|
||||||
|
|
||||||
|
if curl $CURL_OPTS -s -b "$COOKIE_JAR_USER" "$BASE_URL/" | grep -qi "encode\|decode\|logout"; then
|
||||||
|
pass "Regular user login successful"
|
||||||
|
|
||||||
|
# Try encode/decode as regular user
|
||||||
|
if [ -f "$TEST_IMAGE" ]; then
|
||||||
|
ENCODE_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR_USER" "$BASE_URL/encode")
|
||||||
|
CSRF_TOKEN=$(echo "$ENCODE_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl $CURL_OPTS -s -o "$ENCODED_IMAGE_USER" -w "%{http_code}" \
|
||||||
|
-b "$COOKIE_JAR_USER" \
|
||||||
|
-X POST "$BASE_URL/encode" \
|
||||||
|
-F "reference_photo=@$TEST_IMAGE" \
|
||||||
|
-F "carrier=@$TEST_IMAGE" \
|
||||||
|
-F "message=User smoke test" \
|
||||||
|
-F "passphrase=user test phrase" \
|
||||||
|
-F "pin=567890" \
|
||||||
|
-F "csrf_token=$CSRF_TOKEN")
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ] && [ -s "$ENCODED_IMAGE_USER" ] && file "$ENCODED_IMAGE_USER" | grep -qi "image\|PNG"; then
|
||||||
|
pass "Regular user encoding works"
|
||||||
|
else
|
||||||
|
fail "Regular user encoding failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "Regular user login failed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "Regular user workflow (user not created)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test 7: Password Recovery QR
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}[7/9] Password Recovery QR${NC}"
|
||||||
|
|
||||||
|
# Re-login as admin
|
||||||
|
LOGIN_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR" "$BASE_URL/login")
|
||||||
|
CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||||
|
|
||||||
|
curl $CURL_OPTS -s -o /dev/null \
|
||||||
|
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||||
|
-X POST "$BASE_URL/login" \
|
||||||
|
-d "username=$ADMIN_USER" \
|
||||||
|
-d "password=$ADMIN_PASS" \
|
||||||
|
-d "csrf_token=$CSRF_TOKEN" \
|
||||||
|
-L
|
||||||
|
|
||||||
|
# Check for recovery QR endpoint
|
||||||
|
RECOVERY_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/recovery" 2>/dev/null ||
|
||||||
|
curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/settings" 2>/dev/null ||
|
||||||
|
curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/account" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if echo "$RECOVERY_PAGE" | grep -qi "recovery\|qr\|backup"; then
|
||||||
|
pass "Recovery page accessible"
|
||||||
|
|
||||||
|
# Try to get QR image
|
||||||
|
QR_URL=$(echo "$RECOVERY_PAGE" | grep -oP 'src="[^"]*qr[^"]*"' | head -1 | sed 's/src="//;s/"$//' || echo "")
|
||||||
|
|
||||||
|
if [ -n "$QR_URL" ]; then
|
||||||
|
if [[ "$QR_URL" != http* ]]; then
|
||||||
|
QR_URL="$BASE_URL$QR_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl $CURL_OPTS -s -o "$QR_IMAGE" -w "%{http_code}" -b "$COOKIE_JAR" "$QR_URL")
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ] && [ -s "$QR_IMAGE" ]; then
|
||||||
|
if file "$QR_IMAGE" | grep -qi "image\|PNG"; then
|
||||||
|
pass "Recovery QR code generated"
|
||||||
|
else
|
||||||
|
fail "QR endpoint returned non-image"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "Failed to fetch QR code"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "QR code URL not found in page"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "Password recovery (no recovery page found)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test 8: System Health
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}[8/9] System Health${NC}"
|
||||||
|
|
||||||
|
# Check if stegasoo CLI works via SSH (optional)
|
||||||
|
if command -v sshpass &>/dev/null; then
|
||||||
|
CLI_VERSION=$(sshpass -p 'stegasoo' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||||
|
admin@$PI_IP "stegasoo --version" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -n "$CLI_VERSION" ]; then
|
||||||
|
pass "CLI accessible: $CLI_VERSION"
|
||||||
|
else
|
||||||
|
skip "CLI check (SSH failed or CLI not in PATH)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "CLI check (sshpass not installed)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check service status via SSH
|
||||||
|
if command -v sshpass &>/dev/null; then
|
||||||
|
SERVICE_STATUS=$(sshpass -p 'stegasoo' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||||
|
admin@$PI_IP "systemctl is-active stegasoo" 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
if [ "$SERVICE_STATUS" = "active" ]; then
|
||||||
|
pass "Stegasoo service is active"
|
||||||
|
else
|
||||||
|
fail "Stegasoo service status: $SERVICE_STATUS"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "Service check (sshpass not installed)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test 9: Cleanup
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}[9/9] Cleanup${NC}"
|
||||||
|
|
||||||
|
# Just verify we can still access the site
|
||||||
|
if curl $CURL_OPTS -s -o /dev/null -w "%{http_code}" "$BASE_URL" | grep -q "200\|302"; then
|
||||||
|
pass "Site still accessible after tests"
|
||||||
|
else
|
||||||
|
fail "Site not accessible after tests"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Summary
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
TOTAL=$((TESTS_PASSED + TESTS_FAILED))
|
||||||
|
|
||||||
|
if [ $TESTS_FAILED -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}${BOLD}All tests passed!${NC} ($TESTS_PASSED/$TOTAL)"
|
||||||
|
else
|
||||||
|
echo -e "${RED}${BOLD}Some tests failed${NC} ($TESTS_PASSED passed, $TESTS_FAILED failed)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "Target: $BASE_URL"
|
||||||
|
echo -e "Admin user: $ADMIN_USER"
|
||||||
|
echo -e "Regular user: $REGULAR_USER"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
exit $TESTS_FAILED
|
||||||
17
rpi/stegasoo-wizard.sh
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Stegasoo First Boot Wizard Trigger
|
||||||
|
# This file goes in /etc/profile.d/ and runs the wizard on first login
|
||||||
|
|
||||||
|
if [ -f /etc/stegasoo-first-boot ]; then
|
||||||
|
# Find the wizard script (check /opt first, then home dirs)
|
||||||
|
WIZARD=""
|
||||||
|
if [ -f /opt/stegasoo/rpi/first-boot-wizard.sh ]; then
|
||||||
|
WIZARD="/opt/stegasoo/rpi/first-boot-wizard.sh"
|
||||||
|
else
|
||||||
|
WIZARD=$(ls /home/*/stegasoo/rpi/first-boot-wizard.sh 2>/dev/null | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$WIZARD" ]; then
|
||||||
|
bash "$WIZARD"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
30
rpi/stegasoo.conf.example
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Stegasoo Raspberry Pi Configuration
|
||||||
|
# Copy this file to /etc/stegasoo.conf or ~/.config/stegasoo/stegasoo.conf
|
||||||
|
#
|
||||||
|
# You can also override these by exporting environment variables:
|
||||||
|
# export STEGASOO_INSTALL_DIR="/custom/path"
|
||||||
|
# ./setup.sh
|
||||||
|
|
||||||
|
# Installation directory (default: /opt/stegasoo)
|
||||||
|
#INSTALL_DIR="/opt/stegasoo"
|
||||||
|
|
||||||
|
# Python version to install via pyenv (default: 3.12)
|
||||||
|
#PYTHON_VERSION="3.12"
|
||||||
|
|
||||||
|
# Git repository URL
|
||||||
|
#STEGASOO_REPO="https://github.com/adlee-was-taken/stegasoo.git"
|
||||||
|
|
||||||
|
# Git branch to checkout (default: 4.1)
|
||||||
|
#STEGASOO_BRANCH="4.1"
|
||||||
|
|
||||||
|
# Web UI port (default: 5000)
|
||||||
|
#STEGASOO_PORT="5000"
|
||||||
|
|
||||||
|
# Enable HTTPS (default: false, configured via wizard)
|
||||||
|
#STEGASOO_HTTPS_ENABLED="false"
|
||||||
|
|
||||||
|
# Enable authentication (default: true)
|
||||||
|
#STEGASOO_AUTH_ENABLED="true"
|
||||||
|
|
||||||
|
# Channel key for private channels (default: none)
|
||||||
|
#STEGASOO_CHANNEL_KEY=""
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
# Stegasoo v3.2.0 - Complete Change Summary
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This update makes two major breaking changes to Stegasoo:
|
|
||||||
1. **Remove date dependency** - Date no longer used in cryptographic operations
|
|
||||||
2. **Rename day_phrase → passphrase** - Reflects removal of daily rotation requirement
|
|
||||||
|
|
||||||
## Version Information
|
|
||||||
|
|
||||||
- **Previous**: v3.1.0 (date-dependent, day_phrase)
|
|
||||||
- **Current**: v3.2.0 (date-independent, passphrase)
|
|
||||||
- **Format Version**: 3 → 4 (breaking change)
|
|
||||||
- **Compatibility**: NOT backward compatible with v3.1.0
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
### Core Files (MUST UPDATE)
|
|
||||||
|
|
||||||
1. **crypto.py** ✅ Updated
|
|
||||||
- Removed `date_str` parameter from all functions
|
|
||||||
- Renamed `day_phrase` → `passphrase` in all functions
|
|
||||||
- Removed date from key derivation material
|
|
||||||
- Simplified header format (no date field)
|
|
||||||
- Updated error messages
|
|
||||||
|
|
||||||
2. **constants.py** ✅ Updated
|
|
||||||
- Version: `__version__ = "3.2.0"`
|
|
||||||
- Format: `FORMAT_VERSION = 4`
|
|
||||||
- Added passphrase constants:
|
|
||||||
- `MIN_PASSPHRASE_WORDS = 3`
|
|
||||||
- `MAX_PASSPHRASE_WORDS = 12`
|
|
||||||
- `DEFAULT_PASSPHRASE_WORDS = 4` (increased from 3)
|
|
||||||
- `RECOMMENDED_PASSPHRASE_WORDS = 4`
|
|
||||||
- Kept legacy aliases for transition
|
|
||||||
|
|
||||||
3. **models.py** ✅ Updated
|
|
||||||
- `Credentials`: Changed from `phrases: dict` → `passphrase: str`
|
|
||||||
- `EncodeInput`: Renamed `day_phrase` → `passphrase`, removed `date_str`
|
|
||||||
- `DecodeInput`: Renamed `day_phrase` → `passphrase`
|
|
||||||
- `EncodeResult`: Made `date_used` optional (cosmetic only)
|
|
||||||
- `DecodeResult`: `date_encoded` always None in v3.2.0
|
|
||||||
- `ValidationResult`: Added `warning` field
|
|
||||||
|
|
||||||
4. **validation.py** ✅ Updated
|
|
||||||
- Renamed `validate_phrase()` → `validate_passphrase()`
|
|
||||||
- Added word count validation with warnings
|
|
||||||
- Recommends 4+ words for good security
|
|
||||||
- Updated error messages
|
|
||||||
|
|
||||||
### Files Needing Updates
|
|
||||||
|
|
||||||
5. **__init__.py** - Public API
|
|
||||||
- [ ] `encode()`: Remove `date_str`, rename `day_phrase` → `passphrase`
|
|
||||||
- [ ] `encode_file()`: Same changes
|
|
||||||
- [ ] `encode_bytes()`: Same changes
|
|
||||||
- [ ] `decode()`: Remove `date_str`, rename `day_phrase` → `passphrase`
|
|
||||||
- [ ] `decode_text()`: Same changes
|
|
||||||
- [ ] Update all docstrings
|
|
||||||
|
|
||||||
6. **keygen.py** - Key generation
|
|
||||||
- [ ] `generate_day_phrases()` → `generate_passphrases()` or keep with new implementation
|
|
||||||
- [ ] `generate_credentials()`: Update to use single passphrase
|
|
||||||
- [ ] Update `Credentials` creation
|
|
||||||
|
|
||||||
7. **batch.py** - Batch operations
|
|
||||||
- [ ] `BatchCredentials`: Rename `day_phrase` → `passphrase`
|
|
||||||
- [ ] Update all batch functions
|
|
||||||
|
|
||||||
8. **cli.py** - Command line
|
|
||||||
- [ ] `--phrase` → `--passphrase` (or keep `--phrase` for simplicity)
|
|
||||||
- [ ] Update help text
|
|
||||||
- [ ] Update credentials dict creation
|
|
||||||
|
|
||||||
9. **steganography.py** - No changes needed
|
|
||||||
- Uses keys from crypto module, doesn't directly handle phrases/dates
|
|
||||||
|
|
||||||
10. **dct_steganography.py** - No changes needed
|
|
||||||
- Uses keys from crypto module
|
|
||||||
|
|
||||||
### Optional/Documentation Files
|
|
||||||
|
|
||||||
11. **utils.py** - Keep as-is (organizational functions)
|
|
||||||
12. **debug.py** - No changes needed
|
|
||||||
13. **exceptions.py** - No changes needed
|
|
||||||
14. **compression.py** - No changes needed
|
|
||||||
15. **qr_utils.py** - No changes needed
|
|
||||||
|
|
||||||
## Key Changes Breakdown
|
|
||||||
|
|
||||||
### 1. Function Signatures
|
|
||||||
|
|
||||||
**Before (v3.1.0):**
|
|
||||||
```python
|
|
||||||
def derive_hybrid_key(
|
|
||||||
photo_data: bytes,
|
|
||||||
day_phrase: str,
|
|
||||||
date_str: str,
|
|
||||||
salt: bytes,
|
|
||||||
pin: str = "",
|
|
||||||
rsa_key_data: Optional[bytes] = None
|
|
||||||
) -> bytes:
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (v3.2.0):**
|
|
||||||
```python
|
|
||||||
def derive_hybrid_key(
|
|
||||||
photo_data: bytes,
|
|
||||||
passphrase: str,
|
|
||||||
salt: bytes,
|
|
||||||
pin: str = "",
|
|
||||||
rsa_key_data: Optional[bytes] = None
|
|
||||||
) -> bytes:
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Key Derivation Material
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```python
|
|
||||||
key_material = (
|
|
||||||
photo_hash +
|
|
||||||
day_phrase.lower().encode() +
|
|
||||||
pin.encode() +
|
|
||||||
date_str.encode() + # ← REMOVED
|
|
||||||
salt
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```python
|
|
||||||
key_material = (
|
|
||||||
photo_hash +
|
|
||||||
passphrase.lower().encode() +
|
|
||||||
pin.encode() +
|
|
||||||
salt
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Header Format
|
|
||||||
|
|
||||||
**Before (v3.1.0):** 66+ bytes
|
|
||||||
```
|
|
||||||
[Magic:4][Version:1][DateLen:1][Date:10][Salt:32][IV:12][Tag:16][Ciphertext]
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (v3.2.0):** 65 bytes
|
|
||||||
```
|
|
||||||
[Magic:4][Version:1][Salt:32][IV:12][Tag:16][Ciphertext]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Public API
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```python
|
|
||||||
# Encoding
|
|
||||||
result = encode(
|
|
||||||
message="Secret",
|
|
||||||
reference_photo=photo,
|
|
||||||
carrier_image=carrier,
|
|
||||||
day_phrase="apple forest thunder",
|
|
||||||
pin="123456",
|
|
||||||
date_str="2025-01-15"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Decoding
|
|
||||||
decoded = decode(
|
|
||||||
stego_image=stego,
|
|
||||||
reference_photo=photo,
|
|
||||||
day_phrase="apple forest thunder",
|
|
||||||
pin="123456",
|
|
||||||
date_str="2025-01-15"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```python
|
|
||||||
# Encoding
|
|
||||||
result = encode(
|
|
||||||
message="Secret",
|
|
||||||
reference_photo=photo,
|
|
||||||
carrier_image=carrier,
|
|
||||||
passphrase="apple forest thunder mountain",
|
|
||||||
pin="123456"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Decoding
|
|
||||||
decoded = decode(
|
|
||||||
stego_image=stego,
|
|
||||||
reference_photo=photo,
|
|
||||||
passphrase="apple forest thunder mountain",
|
|
||||||
pin="123456"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration Path
|
|
||||||
|
|
||||||
### For Users with v3.1.0 Messages
|
|
||||||
|
|
||||||
1. **Before upgrading**, decode all messages with v3.1.0:
|
|
||||||
```bash
|
|
||||||
# Using v3.1.0
|
|
||||||
python decode_all.py
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Save the decoded content
|
|
||||||
|
|
||||||
3. Upgrade to v3.2.0
|
|
||||||
|
|
||||||
4. Re-encode with v3.2.0 if needed
|
|
||||||
|
|
||||||
### For Developers
|
|
||||||
|
|
||||||
1. Update the 4 core files: crypto.py, constants.py, models.py, validation.py
|
|
||||||
|
|
||||||
2. Update remaining files in order:
|
|
||||||
- `__init__.py` (public API - critical)
|
|
||||||
- `keygen.py` (credential generation)
|
|
||||||
- `batch.py` (batch operations)
|
|
||||||
- `cli.py` (command line)
|
|
||||||
|
|
||||||
3. Run tests to verify:
|
|
||||||
```bash
|
|
||||||
pytest tests/ -v
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Update documentation and examples
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
### Simplicity
|
|
||||||
- ❌ Before: 3 parameters (day_phrase, pin, date)
|
|
||||||
- ✅ After: 2 parameters (passphrase, pin)
|
|
||||||
|
|
||||||
### User Experience
|
|
||||||
- ❌ Before: "What date did I encode this?" "Which day's phrase?"
|
|
||||||
- ✅ After: Just use your passphrase
|
|
||||||
|
|
||||||
### Asynchronous Ready
|
|
||||||
- ❌ Before: Must know encoding date
|
|
||||||
- ✅ After: Decode anytime
|
|
||||||
|
|
||||||
### Less Metadata
|
|
||||||
- ❌ Before: Date stored in header
|
|
||||||
- ✅ After: No temporal metadata
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
### Entropy Comparison
|
|
||||||
|
|
||||||
**v3.1.0:**
|
|
||||||
- Photo hash: ~128 bits
|
|
||||||
- Day phrase (3 words): ~33 bits
|
|
||||||
- PIN (6 digits): ~20 bits
|
|
||||||
- Date: ~33 bits (10 digits)
|
|
||||||
- **Total: ~214 bits**
|
|
||||||
|
|
||||||
**v3.2.0:**
|
|
||||||
- Photo hash: ~128 bits
|
|
||||||
- Passphrase (4 words): ~44 bits
|
|
||||||
- PIN (6 digits): ~20 bits
|
|
||||||
- **Total: ~192 bits**
|
|
||||||
|
|
||||||
**Mitigation:** Recommend longer passphrases (4-5 words vs 3)
|
|
||||||
|
|
||||||
### Best Practices for v3.2.0
|
|
||||||
|
|
||||||
1. **Use 4+ word passphrases** (increased from 3)
|
|
||||||
2. **Keep using PINs** (additional 20 bits)
|
|
||||||
3. **Protect reference photo** (still critical)
|
|
||||||
4. **Consider RSA keys** for highest security
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Unit tests pass
|
|
||||||
- [ ] Integration tests pass
|
|
||||||
- [ ] Encode/decode round-trip works
|
|
||||||
- [ ] File payloads work
|
|
||||||
- [ ] LSB mode works
|
|
||||||
- [ ] DCT mode works
|
|
||||||
- [ ] Batch operations work
|
|
||||||
- [ ] CLI commands work
|
|
||||||
- [ ] Error messages are clear
|
|
||||||
- [ ] Validation works correctly
|
|
||||||
- [ ] No references to "day_phrase" remain
|
|
||||||
- [ ] No date parameters remain (except cosmetic)
|
|
||||||
|
|
||||||
## Documentation Updates Needed
|
|
||||||
|
|
||||||
- [ ] README.md - Update all examples
|
|
||||||
- [ ] API documentation - Update function signatures
|
|
||||||
- [ ] Tutorials - Remove date parameters
|
|
||||||
- [ ] CHANGELOG.md - Add v3.2.0 entry
|
|
||||||
- [ ] Migration guide - How to upgrade from v3.1.0
|
|
||||||
- [ ] Examples directory - Update all scripts
|
|
||||||
|
|
||||||
## Backward Compatibility Strategy
|
|
||||||
|
|
||||||
### Option 1: Clean Break (Recommended)
|
|
||||||
- No compatibility code
|
|
||||||
- Clear version separation
|
|
||||||
- Users must migrate manually
|
|
||||||
|
|
||||||
### Option 2: Temporary Wrapper
|
|
||||||
```python
|
|
||||||
def encode(
|
|
||||||
message,
|
|
||||||
reference_photo,
|
|
||||||
carrier_image,
|
|
||||||
passphrase: str = None,
|
|
||||||
day_phrase: str = None, # Deprecated
|
|
||||||
date_str: str = None, # Deprecated
|
|
||||||
pin: str = "",
|
|
||||||
...
|
|
||||||
):
|
|
||||||
if day_phrase and not passphrase:
|
|
||||||
import warnings
|
|
||||||
warnings.warn("day_phrase deprecated, use passphrase", DeprecationWarning)
|
|
||||||
passphrase = day_phrase
|
|
||||||
|
|
||||||
if date_str:
|
|
||||||
warnings.warn("date_str no longer used", DeprecationWarning)
|
|
||||||
|
|
||||||
# ... rest of function
|
|
||||||
```
|
|
||||||
|
|
||||||
## Release Checklist
|
|
||||||
|
|
||||||
- [ ] All files updated
|
|
||||||
- [ ] Tests passing
|
|
||||||
- [ ] Documentation updated
|
|
||||||
- [ ] Migration guide written
|
|
||||||
- [ ] CHANGELOG.md updated
|
|
||||||
- [ ] Version bumped to 3.2.0
|
|
||||||
- [ ] Git tag created: v3.2.0
|
|
||||||
- [ ] PyPI package published
|
|
||||||
- [ ] Release notes published
|
|
||||||
- [ ] Users notified of breaking changes
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
### Search and Replace Patterns
|
|
||||||
|
|
||||||
Safe to replace globally:
|
|
||||||
- `day_phrase` → `passphrase`
|
|
||||||
- `day phrase` → `passphrase`
|
|
||||||
- `Day phrase` → `Passphrase`
|
|
||||||
- `DEFAULT_PHRASE_WORDS` → `DEFAULT_PASSPHRASE_WORDS`
|
|
||||||
|
|
||||||
Do NOT replace:
|
|
||||||
- `DAY_NAMES` (keep for utilities)
|
|
||||||
- `get_day_from_date` (keep for utilities)
|
|
||||||
- `generate_day_phrases` (rename function itself)
|
|
||||||
|
|
||||||
### Error Message Updates
|
|
||||||
|
|
||||||
- "Day phrase is required" → "Passphrase is required"
|
|
||||||
- "Check your phrase, PIN" → "Check your passphrase, PIN"
|
|
||||||
- "the day's phrase" → "the passphrase"
|
|
||||||
- "today's passphrase" → "passphrase"
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions during migration:
|
|
||||||
1. Check the migration guide
|
|
||||||
2. Review the comparison document
|
|
||||||
3. Look at updated examples
|
|
||||||
4. File an issue on GitHub
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Status:**
|
|
||||||
✅ Core files updated (crypto, constants, models, validation)
|
|
||||||
⏳ Remaining files need updates (__init__, keygen, batch, cli)
|
|
||||||
📝 Documentation updates pending
|
|
||||||
@@ -372,6 +372,124 @@ def has_channel_key() -> bool:
|
|||||||
return get_channel_key() is not None
|
return get_channel_key() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_channel_key(
|
||||||
|
value: str | None = None,
|
||||||
|
*,
|
||||||
|
file_path: str | Path | None = None,
|
||||||
|
no_channel: bool = False,
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
Resolve a channel key from user input (unified for all frontends).
|
||||||
|
|
||||||
|
This consolidates channel key resolution logic used by CLI, API, and WebUI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Input value:
|
||||||
|
- 'auto' or None: Use server-configured key
|
||||||
|
- 'none' or '': Public mode (no channel key)
|
||||||
|
- explicit key: Validate and use
|
||||||
|
file_path: Path to file containing channel key
|
||||||
|
no_channel: If True, return "" for public mode (overrides value)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None: Use server-configured key (auto mode)
|
||||||
|
"": Public mode (no channel key)
|
||||||
|
str: Explicit valid channel key
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If key format is invalid
|
||||||
|
FileNotFoundError: If file_path doesn't exist
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> resolve_channel_key("auto") # -> None
|
||||||
|
>>> resolve_channel_key("none") # -> ""
|
||||||
|
>>> resolve_channel_key(no_channel=True) # -> ""
|
||||||
|
>>> resolve_channel_key("ABCD-1234-...") # -> "ABCD-1234-..."
|
||||||
|
>>> resolve_channel_key(file_path="key.txt") # reads from file
|
||||||
|
"""
|
||||||
|
debug.print(f"resolve_channel_key: value={value}, file_path={file_path}, no_channel={no_channel}")
|
||||||
|
|
||||||
|
# no_channel flag takes precedence
|
||||||
|
if no_channel:
|
||||||
|
debug.print("resolve_channel_key: public mode (no_channel=True)")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Read from file if provided
|
||||||
|
if file_path:
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"Channel key file not found: {file_path}")
|
||||||
|
key = path.read_text().strip()
|
||||||
|
if not validate_channel_key(key):
|
||||||
|
raise ValueError(f"Invalid channel key format in file: {file_path}")
|
||||||
|
debug.print(f"resolve_channel_key: from file -> {get_channel_fingerprint(key)}")
|
||||||
|
return format_channel_key(key)
|
||||||
|
|
||||||
|
# Handle value string
|
||||||
|
if value is None or value.lower() == "auto":
|
||||||
|
debug.print("resolve_channel_key: auto mode (server config)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if value == "" or value.lower() == "none":
|
||||||
|
debug.print("resolve_channel_key: public mode (explicit none)")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Explicit key - validate
|
||||||
|
if validate_channel_key(value):
|
||||||
|
formatted = format_channel_key(value)
|
||||||
|
debug.print(f"resolve_channel_key: explicit key -> {get_channel_fingerprint(formatted)}")
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
"Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX\n"
|
||||||
|
"Generate a new key with: stegasoo channel generate"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_response_info(channel_key: str | None) -> dict:
|
||||||
|
"""
|
||||||
|
Get channel info for API/WebUI responses.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel_key: Resolved channel key (None=auto, ""=public, str=explicit)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with mode, fingerprint, and display info
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> info = get_channel_response_info("ABCD-1234-...")
|
||||||
|
>>> info['mode']
|
||||||
|
'explicit'
|
||||||
|
"""
|
||||||
|
if channel_key is None:
|
||||||
|
# Auto mode - check server config
|
||||||
|
server_key = get_channel_key()
|
||||||
|
if server_key:
|
||||||
|
return {
|
||||||
|
"mode": "private",
|
||||||
|
"fingerprint": get_channel_fingerprint(server_key),
|
||||||
|
"source": "server",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"mode": "public",
|
||||||
|
"fingerprint": None,
|
||||||
|
"source": "server",
|
||||||
|
}
|
||||||
|
|
||||||
|
if channel_key == "":
|
||||||
|
return {
|
||||||
|
"mode": "public",
|
||||||
|
"fingerprint": None,
|
||||||
|
"source": "explicit",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "private",
|
||||||
|
"fingerprint": get_channel_fingerprint(channel_key),
|
||||||
|
"source": "explicit",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CLI SUPPORT
|
# CLI SUPPORT
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -56,7 +56,14 @@ def cli(ctx, json_output):
|
|||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.argument("image", type=click.Path(exists=True))
|
@click.argument("carrier", type=click.Path(exists=True))
|
||||||
|
@click.option(
|
||||||
|
"-r",
|
||||||
|
"--reference",
|
||||||
|
required=True,
|
||||||
|
type=click.Path(exists=True),
|
||||||
|
help="Reference photo (shared secret)",
|
||||||
|
)
|
||||||
@click.option("-m", "--message", help="Message to encode")
|
@click.option("-m", "--message", help="Message to encode")
|
||||||
@click.option(
|
@click.option(
|
||||||
"-f",
|
"-f",
|
||||||
@@ -86,18 +93,20 @@ def cli(ctx, json_output):
|
|||||||
@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
|
@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def encode(
|
def encode(
|
||||||
ctx, image, message, file_payload, output, passphrase, pin, compress, algorithm, dry_run
|
ctx, carrier, reference, message, file_payload, output, passphrase, pin, compress, algorithm, dry_run
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Encode a message or file into an image.
|
Encode a message or file into an image.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
stegasoo encode photo.png -m "Secret message" --passphrase --pin
|
stegasoo encode photo.png -r ref.jpg -m "Secret message" --passphrase --pin
|
||||||
|
|
||||||
stegasoo encode photo.png -f secret.pdf -o encoded.png
|
stegasoo encode photo.png -r ref.jpg -f secret.pdf -o encoded.png
|
||||||
"""
|
"""
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from .encode import encode as stegasoo_encode
|
||||||
|
from .encode import encode_file as stegasoo_encode_file
|
||||||
|
|
||||||
if not message and not file_payload:
|
if not message and not file_payload:
|
||||||
raise click.UsageError("Either --message or --file is required")
|
raise click.UsageError("Either --message or --file is required")
|
||||||
@@ -123,13 +132,14 @@ def encode(
|
|||||||
payload_type = "text"
|
payload_type = "text"
|
||||||
|
|
||||||
# Get image capacity
|
# Get image capacity
|
||||||
with Image.open(image) as img:
|
with Image.open(carrier) as img:
|
||||||
width, height = img.size
|
width, height = img.size
|
||||||
capacity_bytes = (width * height * 3 // 8) - 69 # v3.2.0: corrected overhead
|
capacity_bytes = (width * height * 3 // 8) - 69 # v3.2.0: corrected overhead
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
result = {
|
result = {
|
||||||
"image": image,
|
"carrier": carrier,
|
||||||
|
"reference": reference,
|
||||||
"dimensions": f"{width}x{height}",
|
"dimensions": f"{width}x{height}",
|
||||||
"capacity_bytes": capacity_bytes,
|
"capacity_bytes": capacity_bytes,
|
||||||
"payload_type": payload_type,
|
"payload_type": payload_type,
|
||||||
@@ -142,7 +152,8 @@ def encode(
|
|||||||
if ctx.obj.get("json"):
|
if ctx.obj.get("json"):
|
||||||
click.echo(json.dumps(result, indent=2))
|
click.echo(json.dumps(result, indent=2))
|
||||||
else:
|
else:
|
||||||
click.echo(f"Image: {image} ({width}x{height})")
|
click.echo(f"Carrier: {carrier} ({width}x{height})")
|
||||||
|
click.echo(f"Reference: {reference}")
|
||||||
click.echo(f"Capacity: {capacity_bytes:,} bytes ({capacity_bytes//1024} KB)")
|
click.echo(f"Capacity: {capacity_bytes:,} bytes ({capacity_bytes//1024} KB)")
|
||||||
click.echo(f"Payload: {payload_size:,} bytes ({payload_type})")
|
click.echo(f"Payload: {payload_size:,} bytes ({payload_type})")
|
||||||
click.echo(f"Compression: {algorithm_name(compression_algo)}")
|
click.echo(f"Compression: {algorithm_name(compression_algo)}")
|
||||||
@@ -150,16 +161,46 @@ def encode(
|
|||||||
click.echo(f"Status: {'✓ Fits' if result['fits'] else '✗ Too large'}")
|
click.echo(f"Status: {'✓ Fits' if result['fits'] else '✗ Too large'}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Actual encoding would happen here
|
# Read input files
|
||||||
# For now, show what would be done
|
with open(reference, "rb") as f:
|
||||||
output = output or f"{Path(image).stem}_encoded.png"
|
reference_data = f.read()
|
||||||
|
with open(carrier, "rb") as f:
|
||||||
|
carrier_data = f.read()
|
||||||
|
|
||||||
|
# Determine output path
|
||||||
|
output = output or f"{Path(carrier).stem}_encoded.png"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if file_payload:
|
||||||
|
# Encode file
|
||||||
|
result = stegasoo_encode_file(
|
||||||
|
filepath=file_payload,
|
||||||
|
reference_photo=reference_data,
|
||||||
|
carrier_image=carrier_data,
|
||||||
|
passphrase=passphrase,
|
||||||
|
pin=pin,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Encode message
|
||||||
|
result = stegasoo_encode(
|
||||||
|
message=message,
|
||||||
|
reference_photo=reference_data,
|
||||||
|
carrier_image=carrier_data,
|
||||||
|
passphrase=passphrase,
|
||||||
|
pin=pin,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write output
|
||||||
|
with open(output, "wb") as f:
|
||||||
|
f.write(result.stego_image)
|
||||||
|
|
||||||
if ctx.obj.get("json"):
|
if ctx.obj.get("json"):
|
||||||
click.echo(
|
click.echo(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"input": image,
|
"carrier": carrier,
|
||||||
|
"reference": reference,
|
||||||
"output": output,
|
"output": output,
|
||||||
"payload_type": payload_type,
|
"payload_type": payload_type,
|
||||||
"compression": algorithm_name(compression_algo),
|
"compression": algorithm_name(compression_algo),
|
||||||
@@ -169,38 +210,110 @@ def encode(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
click.echo(f"✓ Encoded {payload_type} to {output}")
|
click.echo(f"✓ Encoded {payload_type} to {output}")
|
||||||
|
click.echo(f" Reference: {reference}")
|
||||||
click.echo(f" Compression: {algorithm_name(compression_algo)}")
|
click.echo(f" Compression: {algorithm_name(compression_algo)}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if ctx.obj.get("json"):
|
||||||
|
click.echo(json.dumps({"status": "error", "error": str(e)}, indent=2))
|
||||||
|
else:
|
||||||
|
click.echo(f"✗ Encoding failed: {e}", err=True)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.argument("image", type=click.Path(exists=True))
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
|
@click.option(
|
||||||
|
"-r",
|
||||||
|
"--reference",
|
||||||
|
required=True,
|
||||||
|
type=click.Path(exists=True),
|
||||||
|
help="Reference photo (shared secret)",
|
||||||
|
)
|
||||||
@click.option("--passphrase", prompt=True, hide_input=True, help="Passphrase")
|
@click.option("--passphrase", prompt=True, hide_input=True, help="Passphrase")
|
||||||
@click.option("--pin", prompt=True, hide_input=True, help="PIN code")
|
@click.option("--pin", prompt=True, hide_input=True, help="PIN code")
|
||||||
@click.option("-o", "--output", type=click.Path(), help="Output path for file payloads")
|
@click.option("-o", "--output", type=click.Path(), help="Output path for file payloads")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def decode(ctx, image, passphrase, pin, output):
|
def decode(ctx, image, reference, passphrase, pin, output):
|
||||||
"""
|
"""
|
||||||
Decode a message or file from an image.
|
Decode a message or file from an image.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
stegasoo decode encoded.png --passphrase --pin
|
stegasoo decode encoded.png -r ref.jpg --passphrase --pin
|
||||||
|
|
||||||
stegasoo decode encoded.png -o ./extracted/
|
stegasoo decode encoded.png -r ref.jpg -o ./extracted/
|
||||||
"""
|
"""
|
||||||
# Actual decoding would happen here
|
from .decode import decode as stegasoo_decode
|
||||||
result = {
|
|
||||||
"status": "success",
|
# Read input files
|
||||||
"image": image,
|
with open(image, "rb") as f:
|
||||||
"payload_type": "text",
|
stego_data = f.read()
|
||||||
"message": "[Decoded message would appear here]",
|
with open(reference, "rb") as f:
|
||||||
}
|
reference_data = f.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = stegasoo_decode(
|
||||||
|
stego_image=stego_data,
|
||||||
|
reference_photo=reference_data,
|
||||||
|
passphrase=passphrase,
|
||||||
|
pin=pin,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.is_file:
|
||||||
|
# File payload
|
||||||
|
filename = result.filename or "decoded_file"
|
||||||
|
output_path = Path(output) / filename if output else Path(filename)
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(result.file_data)
|
||||||
|
|
||||||
if ctx.obj.get("json"):
|
if ctx.obj.get("json"):
|
||||||
click.echo(json.dumps(result, indent=2))
|
click.echo(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"image": image,
|
||||||
|
"reference": reference,
|
||||||
|
"payload_type": "file",
|
||||||
|
"filename": filename,
|
||||||
|
"output": str(output_path),
|
||||||
|
"size": len(result.file_data),
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
click.echo(f"✓ Extracted file: {output_path}")
|
||||||
|
click.echo(f" Size: {len(result.file_data):,} bytes")
|
||||||
|
else:
|
||||||
|
# Text message
|
||||||
|
if ctx.obj.get("json"):
|
||||||
|
click.echo(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"image": image,
|
||||||
|
"reference": reference,
|
||||||
|
"payload_type": "text",
|
||||||
|
"message": result.message,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
click.echo(f"Decoded from {image}:")
|
click.echo(f"Decoded from {image}:")
|
||||||
click.echo(result["message"])
|
click.echo(result.message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if ctx.obj.get("json"):
|
||||||
|
click.echo(json.dumps({"status": "error", "error": str(e)}, indent=2))
|
||||||
|
else:
|
||||||
|
click.echo(f"✗ Decoding failed: {e}", err=True)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -398,16 +511,21 @@ def batch_check(ctx, images, recursive):
|
|||||||
@click.option(
|
@click.option(
|
||||||
"--pin-length", default=DEFAULT_PIN_LENGTH, help=f"PIN length (default: {DEFAULT_PIN_LENGTH})"
|
"--pin-length", default=DEFAULT_PIN_LENGTH, help=f"PIN length (default: {DEFAULT_PIN_LENGTH})"
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--channel-key", is_flag=True, help="Also generate a 256-bit channel key"
|
||||||
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def generate(ctx, words, pin_length):
|
def generate(ctx, words, pin_length, channel_key):
|
||||||
"""
|
"""
|
||||||
Generate random credentials (passphrase + PIN).
|
Generate random credentials (passphrase + PIN + optional channel key).
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
stegasoo generate
|
stegasoo generate
|
||||||
|
|
||||||
stegasoo generate --words 6 --pin-length 8
|
stegasoo generate --words 6 --pin-length 8
|
||||||
|
|
||||||
|
stegasoo generate --channel-key
|
||||||
"""
|
"""
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
@@ -451,11 +569,18 @@ def generate(ctx, words, pin_length):
|
|||||||
"pin_length": pin_length,
|
"pin_length": pin_length,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Generate channel key if requested
|
||||||
|
if channel_key:
|
||||||
|
from .channel import generate_channel_key
|
||||||
|
result["channel_key"] = generate_channel_key()
|
||||||
|
|
||||||
if ctx.obj.get("json"):
|
if ctx.obj.get("json"):
|
||||||
click.echo(json.dumps(result, indent=2))
|
click.echo(json.dumps(result, indent=2))
|
||||||
else:
|
else:
|
||||||
click.echo(f"Passphrase: {passphrase}")
|
click.echo(f"Passphrase: {passphrase}")
|
||||||
click.echo(f"PIN: {pin}")
|
click.echo(f"PIN: {pin}")
|
||||||
|
if channel_key:
|
||||||
|
click.echo(f"Channel Key: {result['channel_key']}")
|
||||||
click.echo("\n⚠️ Save these credentials securely - they cannot be recovered!")
|
click.echo("\n⚠️ Save these credentials securely - they cannot be recovered!")
|
||||||
|
|
||||||
|
|
||||||
@@ -489,6 +614,625 @@ def info(ctx):
|
|||||||
click.echo(f" • Max file payload: {MAX_FILE_PAYLOAD_SIZE:,} bytes")
|
click.echo(f" • Max file payload: {MAX_FILE_PAYLOAD_SIZE:,} bytes")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CHANNEL KEY COMMANDS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
@click.pass_context
|
||||||
|
def channel(ctx):
|
||||||
|
"""
|
||||||
|
Manage channel keys for deployment isolation.
|
||||||
|
|
||||||
|
Channel keys bind encode/decode operations to a specific group or deployment.
|
||||||
|
Messages encoded with one channel key can only be decoded by systems with
|
||||||
|
the same channel key.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo channel generate
|
||||||
|
|
||||||
|
stegasoo channel show
|
||||||
|
|
||||||
|
stegasoo channel qr
|
||||||
|
|
||||||
|
stegasoo channel qr -o channel-key.png
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@channel.command("generate")
|
||||||
|
@click.option("--save", is_flag=True, help="Save to project config file")
|
||||||
|
@click.option("--save-user", is_flag=True, help="Save to user config (~/.stegasoo/)")
|
||||||
|
@click.pass_context
|
||||||
|
def channel_generate(ctx, save, save_user):
|
||||||
|
"""
|
||||||
|
Generate a new random channel key.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo channel generate
|
||||||
|
|
||||||
|
stegasoo channel generate --save
|
||||||
|
|
||||||
|
stegasoo channel generate --save-user
|
||||||
|
"""
|
||||||
|
from .channel import generate_channel_key, set_channel_key
|
||||||
|
|
||||||
|
key = generate_channel_key()
|
||||||
|
|
||||||
|
if ctx.obj.get("json"):
|
||||||
|
result = {"channel_key": key}
|
||||||
|
if save or save_user:
|
||||||
|
location = "user" if save_user else "project"
|
||||||
|
path = set_channel_key(key, location)
|
||||||
|
result["saved_to"] = str(path)
|
||||||
|
click.echo(json.dumps(result, indent=2))
|
||||||
|
else:
|
||||||
|
click.echo("Generated channel key:")
|
||||||
|
click.echo(f" {key}")
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
if save or save_user:
|
||||||
|
location = "user" if save_user else "project"
|
||||||
|
path = set_channel_key(key, location)
|
||||||
|
click.echo(f"Saved to: {path}")
|
||||||
|
else:
|
||||||
|
click.echo("To use this key:")
|
||||||
|
click.echo(f' export STEGASOO_CHANNEL_KEY="{key}"')
|
||||||
|
click.echo()
|
||||||
|
click.echo("Or save to config:")
|
||||||
|
click.echo(" stegasoo channel generate --save")
|
||||||
|
|
||||||
|
|
||||||
|
@channel.command("show")
|
||||||
|
@click.option("--key", "explicit_key", help="Show this key instead of configured one")
|
||||||
|
@click.pass_context
|
||||||
|
def channel_show(ctx, explicit_key):
|
||||||
|
"""
|
||||||
|
Show the current channel key.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo channel show
|
||||||
|
|
||||||
|
stegasoo channel show --key "ABCD-1234-..."
|
||||||
|
"""
|
||||||
|
from .channel import format_channel_key, get_channel_status, validate_channel_key
|
||||||
|
|
||||||
|
if explicit_key:
|
||||||
|
if not validate_channel_key(explicit_key):
|
||||||
|
click.echo("Error: Invalid channel key format", err=True)
|
||||||
|
raise SystemExit(1)
|
||||||
|
key = format_channel_key(explicit_key)
|
||||||
|
source = "command line"
|
||||||
|
else:
|
||||||
|
status = get_channel_status()
|
||||||
|
if not status["configured"]:
|
||||||
|
if ctx.obj.get("json"):
|
||||||
|
click.echo(json.dumps({"configured": False, "mode": "public"}))
|
||||||
|
else:
|
||||||
|
click.echo("No channel key configured (public mode)")
|
||||||
|
return
|
||||||
|
key = status["key"]
|
||||||
|
source = status["source"]
|
||||||
|
|
||||||
|
if ctx.obj.get("json"):
|
||||||
|
click.echo(json.dumps({"channel_key": key, "source": source}))
|
||||||
|
else:
|
||||||
|
click.echo(f"Channel key: {key}")
|
||||||
|
click.echo(f"Source: {source}")
|
||||||
|
|
||||||
|
|
||||||
|
@channel.command("status")
|
||||||
|
@click.pass_context
|
||||||
|
def channel_status(ctx):
|
||||||
|
"""
|
||||||
|
Show channel key status and configuration.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo channel status
|
||||||
|
|
||||||
|
stegasoo --json channel status
|
||||||
|
"""
|
||||||
|
from .channel import get_channel_status
|
||||||
|
|
||||||
|
status = get_channel_status()
|
||||||
|
|
||||||
|
if ctx.obj.get("json"):
|
||||||
|
click.echo(json.dumps(status, indent=2))
|
||||||
|
else:
|
||||||
|
click.echo(f"Mode: {status['mode'].upper()}")
|
||||||
|
if status["configured"]:
|
||||||
|
click.echo(f"Fingerprint: {status['fingerprint']}")
|
||||||
|
click.echo(f"Source: {status['source']}")
|
||||||
|
else:
|
||||||
|
click.echo("No channel key configured")
|
||||||
|
click.echo()
|
||||||
|
click.echo("To set up a channel key:")
|
||||||
|
click.echo(" stegasoo channel generate --save")
|
||||||
|
|
||||||
|
|
||||||
|
@channel.command("qr")
|
||||||
|
@click.option("--key", "explicit_key", help="Generate QR for this key instead of configured one")
|
||||||
|
@click.option(
|
||||||
|
"--format",
|
||||||
|
"output_format",
|
||||||
|
type=click.Choice(["ascii", "png"]),
|
||||||
|
default="ascii",
|
||||||
|
help="Output format (default: ascii)",
|
||||||
|
)
|
||||||
|
@click.option("-o", "--output", type=click.Path(), help="Output file (PNG format, or - for stdout)")
|
||||||
|
@click.pass_context
|
||||||
|
def channel_qr(ctx, explicit_key, output_format, output):
|
||||||
|
"""
|
||||||
|
Display channel key as QR code.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo channel qr
|
||||||
|
|
||||||
|
stegasoo channel qr -o channel-key.png
|
||||||
|
|
||||||
|
stegasoo channel qr --format png -o - > key.png
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .channel import format_channel_key, get_channel_key, validate_channel_key
|
||||||
|
|
||||||
|
# Get the key to display
|
||||||
|
if explicit_key:
|
||||||
|
if not validate_channel_key(explicit_key):
|
||||||
|
click.echo("Error: Invalid channel key format", err=True)
|
||||||
|
raise SystemExit(1)
|
||||||
|
key = format_channel_key(explicit_key)
|
||||||
|
else:
|
||||||
|
key = get_channel_key()
|
||||||
|
if not key:
|
||||||
|
click.echo("Error: No channel key configured", err=True)
|
||||||
|
click.echo("Generate one with: stegasoo channel generate", err=True)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
# Import qrcode
|
||||||
|
try:
|
||||||
|
import qrcode
|
||||||
|
except ImportError:
|
||||||
|
click.echo("Error: qrcode library not installed", err=True)
|
||||||
|
click.echo("Install with: pip install qrcode[pil]", err=True)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
# Determine output mode
|
||||||
|
if output:
|
||||||
|
output_format = "png" # Force PNG when output file specified
|
||||||
|
|
||||||
|
if output_format == "png":
|
||||||
|
# Generate PNG QR code (requires Pillow)
|
||||||
|
try:
|
||||||
|
import PIL # noqa: F401 - check Pillow is available
|
||||||
|
except ImportError:
|
||||||
|
click.echo("Error: PIL/Pillow not installed for PNG output", err=True)
|
||||||
|
click.echo("Install with: pip install Pillow", err=True)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
qr = qrcode.QRCode(
|
||||||
|
version=1,
|
||||||
|
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||||
|
box_size=10,
|
||||||
|
border=4,
|
||||||
|
)
|
||||||
|
qr.add_data(key)
|
||||||
|
qr.make(fit=True)
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
|
||||||
|
if output == "-":
|
||||||
|
# Write to stdout
|
||||||
|
img.save(sys.stdout.buffer, format="PNG")
|
||||||
|
elif output:
|
||||||
|
# Write to file
|
||||||
|
img.save(output)
|
||||||
|
click.echo(f"Saved QR code to: {output}", err=True)
|
||||||
|
else:
|
||||||
|
# No output specified but PNG format requested - error
|
||||||
|
click.echo("Error: PNG format requires -o/--output", err=True)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# ASCII output to terminal
|
||||||
|
qr = qrcode.QRCode(
|
||||||
|
version=1,
|
||||||
|
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||||
|
box_size=1,
|
||||||
|
border=2,
|
||||||
|
)
|
||||||
|
qr.add_data(key)
|
||||||
|
qr.make(fit=True)
|
||||||
|
|
||||||
|
click.echo()
|
||||||
|
click.echo(f"Channel Key: {key}")
|
||||||
|
click.echo()
|
||||||
|
qr.print_ascii(invert=True)
|
||||||
|
click.echo()
|
||||||
|
click.echo("Scan this QR code to share the channel key.")
|
||||||
|
|
||||||
|
|
||||||
|
@channel.command("clear")
|
||||||
|
@click.option("--project", is_flag=True, help="Only clear project config")
|
||||||
|
@click.option("--user", is_flag=True, help="Only clear user config")
|
||||||
|
@click.pass_context
|
||||||
|
def channel_clear(ctx, project, user):
|
||||||
|
"""
|
||||||
|
Remove channel key configuration.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo channel clear
|
||||||
|
|
||||||
|
stegasoo channel clear --project
|
||||||
|
|
||||||
|
stegasoo channel clear --user
|
||||||
|
"""
|
||||||
|
from .channel import clear_channel_key
|
||||||
|
|
||||||
|
if project and user:
|
||||||
|
location = "all"
|
||||||
|
elif project:
|
||||||
|
location = "project"
|
||||||
|
elif user:
|
||||||
|
location = "user"
|
||||||
|
else:
|
||||||
|
location = "all"
|
||||||
|
|
||||||
|
deleted = clear_channel_key(location)
|
||||||
|
|
||||||
|
if ctx.obj.get("json"):
|
||||||
|
click.echo(json.dumps({"deleted": [str(p) for p in deleted]}))
|
||||||
|
else:
|
||||||
|
if deleted:
|
||||||
|
click.echo(f"Removed channel key from: {', '.join(str(p) for p in deleted)}")
|
||||||
|
else:
|
||||||
|
click.echo("No channel key files found")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TOOLS COMMANDS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
@click.pass_context
|
||||||
|
def tools(ctx):
|
||||||
|
"""Image security tools."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("capacity")
|
||||||
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||||||
|
def tools_capacity(image, as_json):
|
||||||
|
"""Show steganography capacity for an image.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
stegasoo tools capacity photo.jpg
|
||||||
|
"""
|
||||||
|
from .dct_steganography import estimate_capacity_comparison
|
||||||
|
|
||||||
|
with open(image, "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
result = estimate_capacity_comparison(image_data)
|
||||||
|
result["filename"] = Path(image).name
|
||||||
|
result["megapixels"] = round((result["width"] * result["height"]) / 1_000_000, 2)
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
click.echo(json.dumps(result, indent=2))
|
||||||
|
else:
|
||||||
|
click.echo(f"\n {result['filename']}")
|
||||||
|
click.echo(f" {'─' * 40}")
|
||||||
|
click.echo(f" Dimensions: {result['width']} × {result['height']}")
|
||||||
|
click.echo(f" Megapixels: {result['megapixels']} MP")
|
||||||
|
click.echo(f" {'─' * 40}")
|
||||||
|
click.echo(f" LSB Capacity: {result['lsb']['capacity_kb']:.1f} KB")
|
||||||
|
if result['dct']['available']:
|
||||||
|
click.echo(f" DCT Capacity: {result['dct']['capacity_kb']:.1f} KB")
|
||||||
|
else:
|
||||||
|
click.echo(" DCT Capacity: N/A (scipy required)")
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("strip")
|
||||||
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
|
@click.option("-o", "--output", type=click.Path(), help="Output file (default: <name>_clean.png)")
|
||||||
|
@click.option("--format", "fmt", type=click.Choice(["png", "bmp"]), default="png", help="Output format")
|
||||||
|
def tools_strip(image, output, fmt):
|
||||||
|
"""Strip EXIF/metadata from an image.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
stegasoo tools strip photo.jpg
|
||||||
|
stegasoo tools strip photo.jpg -o clean.png
|
||||||
|
"""
|
||||||
|
from .utils import strip_image_metadata
|
||||||
|
|
||||||
|
with open(image, "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
clean_data = strip_image_metadata(image_data, output_format=fmt.upper())
|
||||||
|
|
||||||
|
if not output:
|
||||||
|
stem = Path(image).stem
|
||||||
|
output = f"{stem}_clean.{fmt}"
|
||||||
|
|
||||||
|
with open(output, "wb") as f:
|
||||||
|
f.write(clean_data)
|
||||||
|
|
||||||
|
click.echo(f"Saved clean image to: {output}")
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("peek")
|
||||||
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||||||
|
def tools_peek(image, as_json):
|
||||||
|
"""Check if image contains Stegasoo hidden data.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
stegasoo tools peek suspicious.jpg
|
||||||
|
"""
|
||||||
|
from .steganography import peek_image
|
||||||
|
|
||||||
|
with open(image, "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
result = peek_image(image_data)
|
||||||
|
result["filename"] = Path(image).name
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
click.echo(json.dumps(result))
|
||||||
|
else:
|
||||||
|
if result["has_stegasoo"]:
|
||||||
|
click.echo(f"\n ✓ Stegasoo data detected in {result['filename']}")
|
||||||
|
click.echo(f" Mode: {result['mode'].upper()}")
|
||||||
|
else:
|
||||||
|
click.echo(f"\n ✗ No Stegasoo header found in {result['filename']}")
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("exif")
|
||||||
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
|
@click.option("--clear", is_flag=True, help="Remove all EXIF metadata")
|
||||||
|
@click.option("--set", "set_fields", multiple=True, help="Set EXIF field (e.g. --set Artist=John)")
|
||||||
|
@click.option("-o", "--output", type=click.Path(), help="Output file (required for modifications)")
|
||||||
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||||||
|
def tools_exif(image, clear, set_fields, output, as_json):
|
||||||
|
"""View or edit EXIF metadata.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo tools exif photo.jpg
|
||||||
|
|
||||||
|
stegasoo tools exif photo.jpg --clear -o clean.jpg
|
||||||
|
|
||||||
|
stegasoo tools exif photo.jpg --set Artist="John Doe" -o updated.jpg
|
||||||
|
"""
|
||||||
|
from .utils import read_image_exif, strip_image_metadata, write_image_exif
|
||||||
|
|
||||||
|
with open(image, "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
# View mode (no modifications)
|
||||||
|
if not clear and not set_fields:
|
||||||
|
exif = read_image_exif(image_data)
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
click.echo(json.dumps(exif, indent=2, default=str))
|
||||||
|
else:
|
||||||
|
click.echo(f"\n EXIF Metadata: {Path(image).name}")
|
||||||
|
click.echo(f" {'─' * 45}")
|
||||||
|
if not exif:
|
||||||
|
click.echo(" No EXIF metadata found")
|
||||||
|
else:
|
||||||
|
for key, value in sorted(exif.items()):
|
||||||
|
# Skip complex nested structures for display
|
||||||
|
if isinstance(value, dict):
|
||||||
|
click.echo(f" {key}: [complex data]")
|
||||||
|
elif isinstance(value, list):
|
||||||
|
click.echo(f" {key}: {value}")
|
||||||
|
else:
|
||||||
|
# Truncate long values
|
||||||
|
str_val = str(value)
|
||||||
|
if len(str_val) > 50:
|
||||||
|
str_val = str_val[:47] + "..."
|
||||||
|
click.echo(f" {key}: {str_val}")
|
||||||
|
click.echo()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Modification mode - require output file
|
||||||
|
if not output:
|
||||||
|
raise click.UsageError("Output file required for modifications (use -o/--output)")
|
||||||
|
|
||||||
|
if clear:
|
||||||
|
# Strip all metadata
|
||||||
|
clean_data = strip_image_metadata(image_data, output_format="JPEG")
|
||||||
|
with open(output, "wb") as f:
|
||||||
|
f.write(clean_data)
|
||||||
|
click.echo(f"Cleared EXIF metadata, saved to: {output}")
|
||||||
|
elif set_fields:
|
||||||
|
# Parse field=value pairs
|
||||||
|
updates = {}
|
||||||
|
for field in set_fields:
|
||||||
|
if "=" not in field:
|
||||||
|
raise click.UsageError(f"Invalid format: {field} (use Field=Value)")
|
||||||
|
key, val = field.split("=", 1)
|
||||||
|
updates[key.strip()] = val.strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated_data = write_image_exif(image_data, updates)
|
||||||
|
with open(output, "wb") as f:
|
||||||
|
f.write(updated_data)
|
||||||
|
click.echo(f"Updated {len(updates)} EXIF field(s), saved to: {output}")
|
||||||
|
except ValueError as 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}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
"""
|
"""
|
||||||
DCT Domain Steganography Module (v3.2.0-patch2)
|
DCT Domain Steganography Module (v4.1.0)
|
||||||
|
|
||||||
Embeds data in DCT coefficients with two approaches:
|
Embeds data in DCT coefficients with two approaches:
|
||||||
1. PNG output: Scipy-based DCT transform (grayscale or color)
|
1. PNG output: Scipy-based DCT transform (grayscale or color)
|
||||||
2. JPEG output: jpegio-based coefficient manipulation (if available)
|
2. JPEG output: jpegio-based coefficient manipulation (if available)
|
||||||
|
|
||||||
|
v4.1.0 Changes:
|
||||||
|
- Reed-Solomon error correction protects against bit errors in problematic blocks
|
||||||
|
- Majority voting on length headers (3 copies) for additional robustness
|
||||||
|
- RS can correct up to 16 byte errors per 223-byte chunk
|
||||||
|
|
||||||
v3.2.0-patch2 Changes:
|
v3.2.0-patch2 Changes:
|
||||||
- Chunked processing for large images to avoid heap corruption
|
- Chunked processing for large images to avoid heap corruption
|
||||||
- Process image in vertical strips to limit memory per operation
|
- Process image in vertical strips to limit memory per operation
|
||||||
- Isolated DCT operations with fresh array allocations
|
- Isolated DCT operations with fresh array allocations
|
||||||
- Workaround for scipy.fftpack memory issues
|
- Workaround for scipy.fftpack memory issues
|
||||||
|
|
||||||
Requires: scipy (for PNG mode), optionally jpegio (for JPEG mode)
|
Requires: scipy (for PNG mode), optionally jpegio (for JPEG mode), reedsolo (for error correction)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import gc
|
import gc
|
||||||
@@ -102,6 +107,13 @@ JPEGIO_MAGIC = b"JPGS"
|
|||||||
JPEGIO_MIN_COEF_MAGNITUDE = 2
|
JPEGIO_MIN_COEF_MAGNITUDE = 2
|
||||||
JPEGIO_EMBED_CHANNEL = 0
|
JPEGIO_EMBED_CHANNEL = 0
|
||||||
FLAG_COLOR_MODE = 0x01
|
FLAG_COLOR_MODE = 0x01
|
||||||
|
FLAG_RS_PROTECTED = 0x02 # Reed-Solomon error correction enabled
|
||||||
|
|
||||||
|
# Reed-Solomon settings - 32 symbols can correct up to 16 byte errors per 223-byte chunk
|
||||||
|
RS_NSYM = 32
|
||||||
|
RS_LENGTH_HEADER_SIZE = 8 # 8 bytes: 4 for raw_payload_length + 4 for rs_payload_length
|
||||||
|
RS_LENGTH_COPIES = 3 # Store length header 3 times for majority voting
|
||||||
|
RS_LENGTH_PREFIX_SIZE = RS_LENGTH_HEADER_SIZE * RS_LENGTH_COPIES # Total: 24 bytes
|
||||||
|
|
||||||
# Chunking settings for large images
|
# Chunking settings for large images
|
||||||
MAX_CHUNK_HEIGHT = 512 # Process in 512-pixel tall strips
|
MAX_CHUNK_HEIGHT = 512 # Process in 512-pixel tall strips
|
||||||
@@ -167,6 +179,44 @@ def has_jpegio_support() -> bool:
|
|||||||
return HAS_JPEGIO
|
return HAS_JPEGIO
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# REED-SOLOMON ERROR CORRECTION
|
||||||
|
# Protects against bit errors in problematic image blocks
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Check for reedsolo availability
|
||||||
|
try:
|
||||||
|
from reedsolo import RSCodec, ReedSolomonError
|
||||||
|
|
||||||
|
HAS_REEDSOLO = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_REEDSOLO = False
|
||||||
|
RSCodec = None
|
||||||
|
ReedSolomonError = None
|
||||||
|
|
||||||
|
|
||||||
|
def _rs_encode(data: bytes) -> bytes:
|
||||||
|
"""Add Reed-Solomon error correction symbols to data."""
|
||||||
|
if not HAS_REEDSOLO:
|
||||||
|
return data # No protection if reedsolo not available
|
||||||
|
rs = RSCodec(RS_NSYM)
|
||||||
|
return bytes(rs.encode(data))
|
||||||
|
|
||||||
|
|
||||||
|
def _rs_decode(data: bytes) -> bytes:
|
||||||
|
"""Decode Reed-Solomon protected data, correcting errors if possible."""
|
||||||
|
if not HAS_REEDSOLO:
|
||||||
|
return data # No decoding if reedsolo not available
|
||||||
|
rs = RSCodec(RS_NSYM)
|
||||||
|
try:
|
||||||
|
decoded, _, errata_pos = rs.decode(data)
|
||||||
|
if errata_pos:
|
||||||
|
pass # Errors were corrected
|
||||||
|
return bytes(decoded)
|
||||||
|
except ReedSolomonError as e:
|
||||||
|
raise ValueError(f"Reed-Solomon decoding failed: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SAFE DCT FUNCTIONS
|
# SAFE DCT FUNCTIONS
|
||||||
# These create fresh arrays to avoid scipy memory corruption issues
|
# These create fresh arrays to avoid scipy memory corruption issues
|
||||||
@@ -436,7 +486,17 @@ def calculate_dct_capacity(image_data: bytes) -> DCTCapacityInfo:
|
|||||||
bits_per_block = len(DEFAULT_EMBED_POSITIONS)
|
bits_per_block = len(DEFAULT_EMBED_POSITIONS)
|
||||||
total_bits = total_blocks * bits_per_block
|
total_bits = total_blocks * bits_per_block
|
||||||
total_bytes = total_bits // 8
|
total_bytes = total_bits // 8
|
||||||
usable_bytes = max(0, total_bytes - HEADER_SIZE)
|
# Account for header and RS overhead
|
||||||
|
# RS format: [24-byte length prefix (3 copies)] + RS(header + data)
|
||||||
|
# RS adds RS_NSYM bytes per 223-byte chunk (255 - RS_NSYM = 223)
|
||||||
|
# Conservatively estimate RS overhead as ~15% + one chunk minimum
|
||||||
|
if HAS_REEDSOLO:
|
||||||
|
# Overhead = 24 (prefix) + 10 (header) + RS overhead
|
||||||
|
# Simplify: base overhead = 24 + 10 + 32 + 15% margin for larger data
|
||||||
|
overhead = RS_LENGTH_PREFIX_SIZE + HEADER_SIZE + RS_NSYM + 20
|
||||||
|
else:
|
||||||
|
overhead = HEADER_SIZE
|
||||||
|
usable_bytes = max(0, total_bytes - overhead)
|
||||||
|
|
||||||
return DCTCapacityInfo(
|
return DCTCapacityInfo(
|
||||||
width=width,
|
width=width,
|
||||||
@@ -538,9 +598,20 @@ def _embed_scipy_dct_safe(
|
|||||||
|
|
||||||
flags = FLAG_COLOR_MODE if color_mode == "color" else 0
|
flags = FLAG_COLOR_MODE if color_mode == "color" else 0
|
||||||
|
|
||||||
# Prepare payload bits
|
# Build raw payload (header + data)
|
||||||
header = _create_header(len(data), flags)
|
header = _create_header(len(data), flags)
|
||||||
payload = header + data
|
raw_payload = header + data
|
||||||
|
|
||||||
|
# Apply Reed-Solomon error correction to entire payload if available
|
||||||
|
if HAS_REEDSOLO:
|
||||||
|
rs_payload = _rs_encode(raw_payload)
|
||||||
|
# Format: [length_header x 3 for majority voting] + [RS-encoded payload]
|
||||||
|
# Each length_header is 8 bytes: 4 for raw_payload_length + 4 for rs_payload_length
|
||||||
|
length_header = struct.pack(">II", len(raw_payload), len(rs_payload))
|
||||||
|
length_prefix = length_header * RS_LENGTH_COPIES # Repeat 3 times
|
||||||
|
payload = length_prefix + rs_payload
|
||||||
|
else:
|
||||||
|
payload = raw_payload
|
||||||
bits = []
|
bits = []
|
||||||
for byte in payload:
|
for byte in payload:
|
||||||
for i in range(7, -1, -1):
|
for i in range(7, -1, -1):
|
||||||
@@ -761,8 +832,19 @@ def _embed_jpegio(
|
|||||||
all_positions = _jpegio_get_usable_positions(coef_array)
|
all_positions = _jpegio_get_usable_positions(coef_array)
|
||||||
order = _jpegio_generate_order(len(all_positions), seed)
|
order = _jpegio_generate_order(len(all_positions), seed)
|
||||||
|
|
||||||
|
# Build raw payload (header + data)
|
||||||
header = _jpegio_create_header(len(data), flags)
|
header = _jpegio_create_header(len(data), flags)
|
||||||
payload = header + data
|
raw_payload = header + data
|
||||||
|
|
||||||
|
# Apply Reed-Solomon error correction to entire payload if available
|
||||||
|
if HAS_REEDSOLO:
|
||||||
|
rs_payload = _rs_encode(raw_payload)
|
||||||
|
# Format: [length_header x 3 for majority voting] + [RS-encoded payload]
|
||||||
|
length_header = struct.pack(">II", len(raw_payload), len(rs_payload))
|
||||||
|
length_prefix = length_header * RS_LENGTH_COPIES
|
||||||
|
payload = length_prefix + rs_payload
|
||||||
|
else:
|
||||||
|
payload = raw_payload
|
||||||
|
|
||||||
bits = []
|
bits = []
|
||||||
for byte in payload:
|
for byte in payload:
|
||||||
@@ -851,9 +933,12 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
del channel
|
del channel
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
h, w = padded.shape
|
# Use ORIGINAL image dimensions for block calculations (must match embed)
|
||||||
blocks_x = w // BLOCK_SIZE
|
# Embed uses width // BLOCK_SIZE, not padded width
|
||||||
num_blocks = (h // BLOCK_SIZE) * blocks_x
|
h, w = padded.shape # Padded dimensions for bounds checking
|
||||||
|
blocks_x = width // BLOCK_SIZE
|
||||||
|
blocks_y = height // BLOCK_SIZE
|
||||||
|
num_blocks = blocks_y * blocks_x
|
||||||
|
|
||||||
block_order = _generate_block_order(num_blocks, seed)
|
block_order = _generate_block_order(num_blocks, seed)
|
||||||
|
|
||||||
@@ -889,6 +974,69 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
del padded
|
del padded
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
|
# Try RS-protected format first (has 24-byte length prefix: 3 copies of 8-byte header)
|
||||||
|
if HAS_REEDSOLO and len(all_bits) >= RS_LENGTH_PREFIX_SIZE * 8:
|
||||||
|
# Extract length prefix (24 bytes: 3 copies of 8-byte header for majority voting)
|
||||||
|
length_prefix_bits = all_bits[: RS_LENGTH_PREFIX_SIZE * 8]
|
||||||
|
length_prefix_bytes = bytes(
|
||||||
|
[
|
||||||
|
sum(length_prefix_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
|
||||||
|
for i in range(RS_LENGTH_PREFIX_SIZE)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract 3 copies and use majority voting
|
||||||
|
copies = []
|
||||||
|
for i in range(RS_LENGTH_COPIES):
|
||||||
|
start = i * RS_LENGTH_HEADER_SIZE
|
||||||
|
end = start + RS_LENGTH_HEADER_SIZE
|
||||||
|
copies.append(length_prefix_bytes[start:end])
|
||||||
|
|
||||||
|
# Count occurrences of each unique copy
|
||||||
|
from collections import Counter
|
||||||
|
counter = Counter(copies)
|
||||||
|
best_header, count = counter.most_common(1)[0]
|
||||||
|
|
||||||
|
# Only proceed if we have at least 2 matching copies (majority)
|
||||||
|
if count >= 2:
|
||||||
|
raw_payload_length, rs_encoded_length = struct.unpack(">II", best_header)
|
||||||
|
else:
|
||||||
|
# No majority - try first copy as fallback
|
||||||
|
raw_payload_length, rs_encoded_length = struct.unpack(">II", copies[0])
|
||||||
|
|
||||||
|
# Sanity check: both lengths should be reasonable
|
||||||
|
max_reasonable = (len(all_bits) // 8) - RS_LENGTH_PREFIX_SIZE
|
||||||
|
if (raw_payload_length > 0 and raw_payload_length <= max_reasonable and
|
||||||
|
rs_encoded_length > 0 and rs_encoded_length <= max_reasonable and
|
||||||
|
rs_encoded_length >= raw_payload_length):
|
||||||
|
# This looks like RS-protected format
|
||||||
|
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
|
||||||
|
|
||||||
|
if len(all_bits) >= total_bits_needed:
|
||||||
|
rs_bits = all_bits[RS_LENGTH_PREFIX_SIZE * 8 : total_bits_needed]
|
||||||
|
rs_encoded = bytes(
|
||||||
|
[
|
||||||
|
sum(rs_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
|
||||||
|
for i in range(rs_encoded_length)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# RS decode to get header + data
|
||||||
|
raw_payload = _rs_decode(rs_encoded)
|
||||||
|
|
||||||
|
# Parse header from decoded payload
|
||||||
|
_, flags, data_length = _parse_header(
|
||||||
|
[((raw_payload[i // 8] >> (7 - i % 8)) & 1) for i in range(HEADER_SIZE * 8)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract data
|
||||||
|
data = raw_payload[HEADER_SIZE : HEADER_SIZE + data_length]
|
||||||
|
return data
|
||||||
|
except (ValueError, struct.error):
|
||||||
|
pass # Fall through to legacy format
|
||||||
|
|
||||||
|
# Legacy format: header not protected by RS
|
||||||
_, flags, data_length = _parse_header(all_bits)
|
_, flags, data_length = _parse_header(all_bits)
|
||||||
data_bits = all_bits[HEADER_SIZE * 8 : (HEADER_SIZE + data_length) * 8]
|
data_bits = all_bits[HEADER_SIZE * 8 : (HEADER_SIZE + data_length) * 8]
|
||||||
|
|
||||||
@@ -919,6 +1067,72 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
all_positions = _jpegio_get_usable_positions(coef_array)
|
all_positions = _jpegio_get_usable_positions(coef_array)
|
||||||
order = _jpegio_generate_order(len(all_positions), seed)
|
order = _jpegio_generate_order(len(all_positions), seed)
|
||||||
|
|
||||||
|
# Try RS-protected format first (has 24-byte length prefix: 3 copies for majority voting)
|
||||||
|
if HAS_REEDSOLO and len(all_positions) >= RS_LENGTH_PREFIX_SIZE * 8:
|
||||||
|
# Extract length prefix (24 bytes: 3 copies of 8-byte header)
|
||||||
|
length_prefix_bits = []
|
||||||
|
for pos_idx in order[: RS_LENGTH_PREFIX_SIZE * 8]:
|
||||||
|
row, col = all_positions[pos_idx]
|
||||||
|
coef = coef_array[row, col]
|
||||||
|
length_prefix_bits.append(coef & 1)
|
||||||
|
|
||||||
|
length_prefix_bytes = bytes(
|
||||||
|
[
|
||||||
|
sum(length_prefix_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
|
||||||
|
for i in range(RS_LENGTH_PREFIX_SIZE)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract 3 copies and use majority voting
|
||||||
|
from collections import Counter
|
||||||
|
copies = []
|
||||||
|
for i in range(RS_LENGTH_COPIES):
|
||||||
|
start = i * RS_LENGTH_HEADER_SIZE
|
||||||
|
end = start + RS_LENGTH_HEADER_SIZE
|
||||||
|
copies.append(length_prefix_bytes[start:end])
|
||||||
|
|
||||||
|
counter = Counter(copies)
|
||||||
|
best_header, count = counter.most_common(1)[0]
|
||||||
|
|
||||||
|
if count >= 2:
|
||||||
|
raw_payload_length, rs_encoded_length = struct.unpack(">II", best_header)
|
||||||
|
else:
|
||||||
|
raw_payload_length, rs_encoded_length = struct.unpack(">II", copies[0])
|
||||||
|
|
||||||
|
# Sanity check
|
||||||
|
max_reasonable = (len(all_positions) // 8) - RS_LENGTH_PREFIX_SIZE
|
||||||
|
if (raw_payload_length > 0 and raw_payload_length <= max_reasonable and
|
||||||
|
rs_encoded_length > 0 and rs_encoded_length <= max_reasonable and
|
||||||
|
rs_encoded_length >= raw_payload_length):
|
||||||
|
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
|
||||||
|
|
||||||
|
if len(all_positions) >= total_bits_needed:
|
||||||
|
# Extract RS-encoded data
|
||||||
|
all_bits = []
|
||||||
|
for bit_idx, pos_idx in enumerate(order):
|
||||||
|
if bit_idx >= total_bits_needed:
|
||||||
|
break
|
||||||
|
row, col = all_positions[pos_idx]
|
||||||
|
coef = coef_array[row, col]
|
||||||
|
all_bits.append(coef & 1)
|
||||||
|
|
||||||
|
rs_bits = all_bits[RS_LENGTH_PREFIX_SIZE * 8 :]
|
||||||
|
rs_encoded = bytes(
|
||||||
|
[
|
||||||
|
sum(rs_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
|
||||||
|
for i in range(rs_encoded_length)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_payload = _rs_decode(rs_encoded)
|
||||||
|
_, flags, data_length = _jpegio_parse_header(raw_payload[:HEADER_SIZE])
|
||||||
|
data = raw_payload[HEADER_SIZE : HEADER_SIZE + data_length]
|
||||||
|
return data
|
||||||
|
except (ValueError, struct.error):
|
||||||
|
pass # Fall through to legacy format
|
||||||
|
|
||||||
|
# Legacy format: header not protected by RS
|
||||||
header_bits = []
|
header_bits = []
|
||||||
for pos_idx in order[: HEADER_SIZE * 8]:
|
for pos_idx in order[: HEADER_SIZE * 8]:
|
||||||
row, col = all_positions[pos_idx]
|
row, col = all_positions[pos_idx]
|
||||||
@@ -933,7 +1147,6 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
)
|
)
|
||||||
|
|
||||||
_, flags, data_length = _jpegio_parse_header(header_bytes)
|
_, flags, data_length = _jpegio_parse_header(header_bytes)
|
||||||
|
|
||||||
total_bits_needed = (HEADER_SIZE + data_length) * 8
|
total_bits_needed = (HEADER_SIZE + data_length) * 8
|
||||||
|
|
||||||
all_bits = []
|
all_bits = []
|
||||||
@@ -945,7 +1158,6 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
all_bits.append(coef & 1)
|
all_bits.append(coef & 1)
|
||||||
|
|
||||||
data_bits = all_bits[HEADER_SIZE * 8 :]
|
data_bits = all_bits[HEADER_SIZE * 8 :]
|
||||||
|
|
||||||
data = bytes(
|
data = bytes(
|
||||||
[
|
[
|
||||||
sum(data_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
|
sum(data_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def encode(
|
|||||||
output_format: str | None = None,
|
output_format: str | None = None,
|
||||||
embed_mode: str = EMBED_MODE_LSB,
|
embed_mode: str = EMBED_MODE_LSB,
|
||||||
dct_output_format: str = "png",
|
dct_output_format: str = "png",
|
||||||
dct_color_mode: str = "grayscale",
|
dct_color_mode: str = "color",
|
||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
) -> EncodeResult:
|
) -> EncodeResult:
|
||||||
"""
|
"""
|
||||||
@@ -158,7 +158,7 @@ def encode_file(
|
|||||||
filename_override: str | None = None,
|
filename_override: str | None = None,
|
||||||
embed_mode: str = EMBED_MODE_LSB,
|
embed_mode: str = EMBED_MODE_LSB,
|
||||||
dct_output_format: str = "png",
|
dct_output_format: str = "png",
|
||||||
dct_color_mode: str = "grayscale",
|
dct_color_mode: str = "color",
|
||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
) -> EncodeResult:
|
) -> EncodeResult:
|
||||||
"""
|
"""
|
||||||
@@ -215,7 +215,7 @@ def encode_bytes(
|
|||||||
mime_type: str | None = None,
|
mime_type: str | None = None,
|
||||||
embed_mode: str = EMBED_MODE_LSB,
|
embed_mode: str = EMBED_MODE_LSB,
|
||||||
dct_output_format: str = "png",
|
dct_output_format: str = "png",
|
||||||
dct_color_mode: str = "grayscale",
|
dct_color_mode: str = "color",
|
||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
) -> EncodeResult:
|
) -> EncodeResult:
|
||||||
"""
|
"""
|
||||||
|
|||||||
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
|
||||||
@@ -525,7 +525,7 @@ def embed_in_image(
|
|||||||
output_format: str | None = None,
|
output_format: str | None = None,
|
||||||
embed_mode: str = EMBED_MODE_LSB,
|
embed_mode: str = EMBED_MODE_LSB,
|
||||||
dct_output_format: str = DCT_OUTPUT_PNG,
|
dct_output_format: str = DCT_OUTPUT_PNG,
|
||||||
dct_color_mode: str = "grayscale",
|
dct_color_mode: str = "color",
|
||||||
) -> tuple[bytes, Union[EmbedStats, "DCTEmbedStats"], str]:
|
) -> tuple[bytes, Union[EmbedStats, "DCTEmbedStats"], str]:
|
||||||
"""
|
"""
|
||||||
Embed data into an image using specified mode.
|
Embed data into an image using specified mode.
|
||||||
@@ -567,8 +567,8 @@ def embed_in_image(
|
|||||||
|
|
||||||
# Validate DCT color mode (v3.0.1)
|
# Validate DCT color mode (v3.0.1)
|
||||||
if dct_color_mode not in ("grayscale", "color"):
|
if dct_color_mode not in ("grayscale", "color"):
|
||||||
debug.print(f"Invalid dct_color_mode '{dct_color_mode}', defaulting to grayscale")
|
debug.print(f"Invalid dct_color_mode '{dct_color_mode}', defaulting to color")
|
||||||
dct_color_mode = "grayscale"
|
dct_color_mode = "color"
|
||||||
|
|
||||||
dct_mod = _get_dct_module()
|
dct_mod = _get_dct_module()
|
||||||
|
|
||||||
@@ -930,3 +930,82 @@ def is_lossless_format(image_data: bytes) -> bool:
|
|||||||
is_lossless = fmt is not None and fmt.upper() in LOSSLESS_FORMATS
|
is_lossless = fmt is not None and fmt.upper() in LOSSLESS_FORMATS
|
||||||
debug.print(f"Image is lossless: {is_lossless} (format: {fmt})")
|
debug.print(f"Image is lossless: {is_lossless} (format: {fmt})")
|
||||||
return is_lossless
|
return is_lossless
|
||||||
|
|
||||||
|
|
||||||
|
def peek_image(image_data: bytes) -> dict:
|
||||||
|
"""
|
||||||
|
Check if an image contains Stegasoo hidden data without decrypting.
|
||||||
|
|
||||||
|
Attempts to detect LSB and DCT headers by extracting the first few bytes
|
||||||
|
and looking for Stegasoo magic markers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_data: Raw image bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with:
|
||||||
|
- has_stegasoo: bool - True if header detected
|
||||||
|
- mode: str or None - 'lsb', 'dct', or None
|
||||||
|
- confidence: str - 'high', 'low', or None
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> result = peek_image(suspicious_image_bytes)
|
||||||
|
>>> if result['has_stegasoo']:
|
||||||
|
... print(f"Found {result['mode']} data!")
|
||||||
|
"""
|
||||||
|
from .constants import EMBED_MODE_DCT, EMBED_MODE_LSB
|
||||||
|
|
||||||
|
result = {"has_stegasoo": False, "mode": None, "confidence": None}
|
||||||
|
|
||||||
|
# Try LSB extraction (look for header bytes)
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
pixels = list(img.getdata())
|
||||||
|
img.close()
|
||||||
|
|
||||||
|
# Extract first 32 bits (4 bytes) from LSB
|
||||||
|
extracted = []
|
||||||
|
for i in range(32):
|
||||||
|
if i < len(pixels):
|
||||||
|
pixel = pixels[i]
|
||||||
|
if isinstance(pixel, tuple):
|
||||||
|
extracted.append(pixel[0] & 1)
|
||||||
|
else:
|
||||||
|
extracted.append(pixel & 1)
|
||||||
|
|
||||||
|
# Convert bits to bytes
|
||||||
|
header_bytes = bytearray()
|
||||||
|
for i in range(0, len(extracted), 8):
|
||||||
|
byte = 0
|
||||||
|
for j in range(8):
|
||||||
|
if i + j < len(extracted):
|
||||||
|
byte = (byte << 1) | extracted[i + j]
|
||||||
|
header_bytes.append(byte)
|
||||||
|
|
||||||
|
# Check for LSB magic: \x89ST3
|
||||||
|
if bytes(header_bytes[:4]) == b"\x89ST3":
|
||||||
|
result["has_stegasoo"] = True
|
||||||
|
result["mode"] = EMBED_MODE_LSB
|
||||||
|
result["confidence"] = "high"
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try DCT extraction (requires scipy/jpegio)
|
||||||
|
try:
|
||||||
|
from .dct_steganography import HAS_JPEGIO, HAS_SCIPY
|
||||||
|
|
||||||
|
if HAS_SCIPY or HAS_JPEGIO:
|
||||||
|
from .dct_steganography import extract_from_dct
|
||||||
|
|
||||||
|
# Extract first few bytes to check header
|
||||||
|
extracted = extract_from_dct(image_data, seed=b"\x00" * 32, length=4)
|
||||||
|
if extracted == b"\x89DCT":
|
||||||
|
result["has_stegasoo"] = True
|
||||||
|
result["mode"] = EMBED_MODE_DCT
|
||||||
|
result["confidence"] = "high"
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|||||||
@@ -18,6 +18,159 @@ from .constants import DAY_NAMES
|
|||||||
from .debug import debug
|
from .debug import debug
|
||||||
|
|
||||||
|
|
||||||
|
def read_image_exif(image_data: bytes) -> dict:
|
||||||
|
"""
|
||||||
|
Read EXIF metadata from an image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_data: Raw image bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with EXIF fields (tag names as keys)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> exif = read_image_exif(photo_bytes)
|
||||||
|
>>> print(exif.get('Make')) # Camera manufacturer
|
||||||
|
"""
|
||||||
|
from PIL.ExifTags import GPSTAGS, TAGS
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
exif_data = img._getexif()
|
||||||
|
|
||||||
|
if exif_data:
|
||||||
|
for tag_id, value in exif_data.items():
|
||||||
|
tag = TAGS.get(tag_id, str(tag_id))
|
||||||
|
|
||||||
|
# Handle GPS data specially
|
||||||
|
if tag == "GPSInfo" and isinstance(value, dict):
|
||||||
|
gps = {}
|
||||||
|
for gps_tag_id, gps_value in value.items():
|
||||||
|
gps_tag = GPSTAGS.get(gps_tag_id, str(gps_tag_id))
|
||||||
|
# Convert tuples/IFDRational to simple types
|
||||||
|
if hasattr(gps_value, "numerator"):
|
||||||
|
gps[gps_tag] = float(gps_value)
|
||||||
|
elif isinstance(gps_value, tuple):
|
||||||
|
gps[gps_tag] = [
|
||||||
|
float(v) if hasattr(v, "numerator") else v
|
||||||
|
for v in gps_value
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
gps[gps_tag] = gps_value
|
||||||
|
result[tag] = gps
|
||||||
|
# Convert IFDRational to float
|
||||||
|
elif hasattr(value, "numerator"):
|
||||||
|
result[tag] = float(value)
|
||||||
|
# Convert bytes to string if possible
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
try:
|
||||||
|
result[tag] = value.decode("utf-8", errors="replace").strip("\x00")
|
||||||
|
except Exception:
|
||||||
|
result[tag] = f"<{len(value)} bytes>"
|
||||||
|
# Handle tuples of IFDRational
|
||||||
|
elif isinstance(value, tuple) and value and hasattr(value[0], "numerator"):
|
||||||
|
result[tag] = [float(v) for v in value]
|
||||||
|
else:
|
||||||
|
result[tag] = value
|
||||||
|
|
||||||
|
img.close()
|
||||||
|
except Exception as e:
|
||||||
|
debug.print(f"Error reading EXIF: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def write_image_exif(image_data: bytes, exif_updates: dict) -> bytes:
|
||||||
|
"""
|
||||||
|
Write/update EXIF metadata in a JPEG image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_data: Raw JPEG image bytes
|
||||||
|
exif_updates: Dict of EXIF fields to update (tag names as keys)
|
||||||
|
Use None as value to delete a field
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Image bytes with updated EXIF
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If image is not JPEG or piexif not available
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> updated = write_image_exif(jpeg_bytes, {"Artist": "John Doe"})
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import piexif
|
||||||
|
except ImportError:
|
||||||
|
raise ValueError("piexif required for EXIF editing: pip install piexif")
|
||||||
|
|
||||||
|
# Verify it's a JPEG
|
||||||
|
if not image_data[:2] == b"\xff\xd8":
|
||||||
|
raise ValueError("EXIF editing only supported for JPEG images")
|
||||||
|
|
||||||
|
debug.print(f"Writing EXIF updates: {list(exif_updates.keys())}")
|
||||||
|
|
||||||
|
# Load existing EXIF
|
||||||
|
try:
|
||||||
|
exif_dict = piexif.load(image_data)
|
||||||
|
except Exception:
|
||||||
|
# No existing EXIF, start fresh
|
||||||
|
exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None}
|
||||||
|
|
||||||
|
# Map common tag names to piexif IFD and tag IDs
|
||||||
|
tag_mapping = {
|
||||||
|
# 0th IFD (main image)
|
||||||
|
"Make": (piexif.ImageIFD.Make, "0th"),
|
||||||
|
"Model": (piexif.ImageIFD.Model, "0th"),
|
||||||
|
"Software": (piexif.ImageIFD.Software, "0th"),
|
||||||
|
"Artist": (piexif.ImageIFD.Artist, "0th"),
|
||||||
|
"Copyright": (piexif.ImageIFD.Copyright, "0th"),
|
||||||
|
"ImageDescription": (piexif.ImageIFD.ImageDescription, "0th"),
|
||||||
|
"DateTime": (piexif.ImageIFD.DateTime, "0th"),
|
||||||
|
"Orientation": (piexif.ImageIFD.Orientation, "0th"),
|
||||||
|
# Exif IFD
|
||||||
|
"DateTimeOriginal": (piexif.ExifIFD.DateTimeOriginal, "Exif"),
|
||||||
|
"DateTimeDigitized": (piexif.ExifIFD.DateTimeDigitized, "Exif"),
|
||||||
|
"UserComment": (piexif.ExifIFD.UserComment, "Exif"),
|
||||||
|
"ExposureTime": (piexif.ExifIFD.ExposureTime, "Exif"),
|
||||||
|
"FNumber": (piexif.ExifIFD.FNumber, "Exif"),
|
||||||
|
"ISOSpeedRatings": (piexif.ExifIFD.ISOSpeedRatings, "Exif"),
|
||||||
|
"FocalLength": (piexif.ExifIFD.FocalLength, "Exif"),
|
||||||
|
"LensMake": (piexif.ExifIFD.LensMake, "Exif"),
|
||||||
|
"LensModel": (piexif.ExifIFD.LensModel, "Exif"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for tag_name, value in exif_updates.items():
|
||||||
|
if tag_name not in tag_mapping:
|
||||||
|
debug.print(f"Unknown EXIF tag: {tag_name}, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
tag_id, ifd = tag_mapping[tag_name]
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
# Delete the tag
|
||||||
|
if tag_id in exif_dict[ifd]:
|
||||||
|
del exif_dict[ifd][tag_id]
|
||||||
|
debug.print(f"Deleted EXIF tag: {tag_name}")
|
||||||
|
else:
|
||||||
|
# Set the tag (encode strings as bytes)
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.encode("utf-8")
|
||||||
|
exif_dict[ifd][tag_id] = value
|
||||||
|
debug.print(f"Set EXIF tag: {tag_name}")
|
||||||
|
|
||||||
|
# Serialize EXIF and insert into image
|
||||||
|
exif_bytes = piexif.dump(exif_dict)
|
||||||
|
output = io.BytesIO()
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
img.save(output, "JPEG", exif=exif_bytes, quality=95)
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
debug.print(f"EXIF updated: {len(image_data)} -> {len(output.getvalue())} bytes")
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def strip_image_metadata(image_data: bytes, output_format: str = "PNG") -> bytes:
|
def strip_image_metadata(image_data: bytes, output_format: str = "PNG") -> bytes:
|
||||||
"""
|
"""
|
||||||
Remove all metadata (EXIF, ICC profiles, etc.) from an image.
|
Remove all metadata (EXIF, ICC profiles, etc.) from an image.
|
||||||
|
|||||||
BIN
test_data/carrier2.jpg
Normal file
|
After Width: | Height: | Size: 9.7 MiB |
BIN
test_data/rpi_20260102.jpg
Normal file
|
After Width: | Height: | Size: 236 KiB |
@@ -1,528 +0,0 @@
|
|||||||
# Stegasoo v4.0.0 Release Checklist
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This checklist covers functionality testing for the v4.0.0 release.
|
|
||||||
|
|
||||||
### Changes in v4.0.0
|
|
||||||
|
|
||||||
| Change | v3.2.0 | v4.0.0 |
|
|
||||||
|--------|--------|--------|
|
|
||||||
| Python version | 3.10-3.12 | 3.10-3.12 (3.13 NOT supported) |
|
|
||||||
| JPEG handling | Could crash on quality=100 | Normalized before jpegio |
|
|
||||||
| Header size | 65 bytes | 65 bytes (unchanged) |
|
|
||||||
| API | passphrase, no date_str | Same (no breaking changes) |
|
|
||||||
| Format version | 4 | 4 (compatible with v3.2.0) |
|
|
||||||
|
|
||||||
### Key Points
|
|
||||||
- **No breaking API changes from v3.2.0**
|
|
||||||
- **v4.0 CAN decode v3.2.0 images** (same format version)
|
|
||||||
- **v4.0 CANNOT decode v3.1.x or earlier images**
|
|
||||||
- **Python 3.13 is NOT supported** (jpegio C extension ABI incompatibility)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Pre-Release Checks
|
|
||||||
|
|
||||||
### 1.1 Python Version
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python --version # Must be 3.10, 3.11, or 3.12
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Python version is 3.10, 3.11, or 3.12
|
|
||||||
- [ ] NOT Python 3.13 (jpegio will crash)
|
|
||||||
|
|
||||||
### 1.2 Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip list | grep -E "jpegio|scipy|pillow|argon2"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] jpegio installed (for DCT JPEG support)
|
|
||||||
- [ ] scipy installed (for DCT mode)
|
|
||||||
- [ ] pillow installed
|
|
||||||
- [ ] argon2-cffi installed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Core Library Tests
|
|
||||||
|
|
||||||
### 2.1 Run Unit Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/stegasoo
|
|
||||||
pytest tests/ -v
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] All tests pass
|
|
||||||
- [ ] No deprecation warnings for removed parameters
|
|
||||||
|
|
||||||
### 2.2 JPEG Normalization Test (NEW in v4.0)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -c "
|
|
||||||
from PIL import Image
|
|
||||||
import io
|
|
||||||
from stegasoo import encode, decode
|
|
||||||
|
|
||||||
# Create quality=100 JPEG (triggers normalization)
|
|
||||||
img = Image.new('RGB', (400, 400), 'red')
|
|
||||||
buf = io.BytesIO()
|
|
||||||
img.save(buf, format='JPEG', quality=100)
|
|
||||||
jpeg_data = buf.getvalue()
|
|
||||||
|
|
||||||
# This should NOT crash (v3.2.0 would crash here)
|
|
||||||
result = encode(
|
|
||||||
message='Test quality 100',
|
|
||||||
reference_photo=jpeg_data,
|
|
||||||
carrier_image=jpeg_data,
|
|
||||||
passphrase='test phrase four words',
|
|
||||||
pin='123456',
|
|
||||||
embed_mode='dct'
|
|
||||||
)
|
|
||||||
print('✓ Quality=100 JPEG encode OK')
|
|
||||||
|
|
||||||
decoded = decode(
|
|
||||||
stego_image=result.stego_image,
|
|
||||||
reference_photo=jpeg_data,
|
|
||||||
passphrase='test phrase four words',
|
|
||||||
pin='123456'
|
|
||||||
)
|
|
||||||
assert decoded.message == 'Test quality 100'
|
|
||||||
print('✓ Quality=100 JPEG decode OK')
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Quality=100 JPEG encoding works (no crash)
|
|
||||||
- [ ] Quality=100 JPEG decoding works
|
|
||||||
|
|
||||||
### 2.3 Large Image Test (NEW in v4.0)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -c "
|
|
||||||
from PIL import Image
|
|
||||||
import io
|
|
||||||
from stegasoo import encode, decode
|
|
||||||
|
|
||||||
# Create large image (similar to 14MB real photo)
|
|
||||||
img = Image.new('RGB', (4000, 3000), 'blue')
|
|
||||||
buf = io.BytesIO()
|
|
||||||
img.save(buf, format='PNG')
|
|
||||||
large_image = buf.getvalue()
|
|
||||||
print(f'Test image size: {len(large_image) / 1024 / 1024:.1f} MB')
|
|
||||||
|
|
||||||
result = encode(
|
|
||||||
message='Large image test',
|
|
||||||
reference_photo=large_image,
|
|
||||||
carrier_image=large_image,
|
|
||||||
passphrase='large image test phrase',
|
|
||||||
pin='123456'
|
|
||||||
)
|
|
||||||
print('✓ Large image encode OK')
|
|
||||||
|
|
||||||
decoded = decode(
|
|
||||||
stego_image=result.stego_image,
|
|
||||||
reference_photo=large_image,
|
|
||||||
passphrase='large image test phrase',
|
|
||||||
pin='123456'
|
|
||||||
)
|
|
||||||
assert decoded.message == 'Large image test'
|
|
||||||
print('✓ Large image decode OK')
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Large image (12MP+) encoding works
|
|
||||||
- [ ] Large image decoding works
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Docker Build Tests
|
|
||||||
|
|
||||||
### 3.1 Base Image Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build base image (one-time, 5-10 min)
|
|
||||||
sudo docker build -f Dockerfile.base -t stegasoo-base:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Base image builds successfully
|
|
||||||
- [ ] jpegio + scipy + numpy verification passes
|
|
||||||
|
|
||||||
### 3.2 Application Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Fast build using base image
|
|
||||||
sudo docker-compose build
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Web container builds
|
|
||||||
- [ ] API container builds
|
|
||||||
|
|
||||||
### 3.3 Container Startup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo docker-compose up -d
|
|
||||||
sudo docker-compose logs
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Web container starts without errors
|
|
||||||
- [ ] API container starts without errors
|
|
||||||
- [ ] No import errors in logs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Web UI Tests (`http://localhost:5000`)
|
|
||||||
|
|
||||||
### 4.1 Home Page
|
|
||||||
|
|
||||||
- [ ] v4.0 badge visible
|
|
||||||
- [ ] "Learn More" button is white/visible
|
|
||||||
- [ ] No references to "day phrase" or dates
|
|
||||||
|
|
||||||
### 4.2 Generate Page (`/generate`)
|
|
||||||
|
|
||||||
- [ ] Default is 4 words
|
|
||||||
- [ ] Single passphrase generated (not 7 daily)
|
|
||||||
- [ ] PIN toggle shows/hides digits
|
|
||||||
- [ ] Memory aid generator works
|
|
||||||
|
|
||||||
### 4.3 Encode Page (`/encode`)
|
|
||||||
|
|
||||||
- [ ] Passphrase field has blue glow on focus
|
|
||||||
- [ ] PIN field has orange glow on focus
|
|
||||||
- [ ] PIN box is 180px wide (fits LastPass icon)
|
|
||||||
- [ ] Passphrase font shrinks for long input (stepped)
|
|
||||||
- [ ] RSA .pem/QR toggle works
|
|
||||||
- [ ] QR image preview shows when selected
|
|
||||||
- [ ] DCT mode options appear when selected
|
|
||||||
- [ ] Encoding works (LSB mode)
|
|
||||||
- [ ] Encoding works (DCT mode)
|
|
||||||
|
|
||||||
### 4.4 Decode Page (`/decode`)
|
|
||||||
|
|
||||||
- [ ] Same styling as encode (glowing inputs)
|
|
||||||
- [ ] RSA .pem/QR toggle works (matches encode layout)
|
|
||||||
- [ ] QR image preview shows when selected
|
|
||||||
- [ ] Copy button is below message (not overlapping)
|
|
||||||
- [ ] Decoding works (LSB mode)
|
|
||||||
- [ ] Decoding works (DCT mode)
|
|
||||||
- [ ] Auto mode detection works
|
|
||||||
|
|
||||||
### 4.5 About Page (`/about`)
|
|
||||||
|
|
||||||
- [ ] Version history table present
|
|
||||||
- [ ] v4.0.0 entry in table
|
|
||||||
- [ ] Python 3.10-3.12 requirement noted
|
|
||||||
- [ ] No marketing language ("military-grade" removed)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. API Tests (`http://localhost:8000`)
|
|
||||||
|
|
||||||
### 5.1 Status Endpoint
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8000/
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Returns version "4.0.0"
|
|
||||||
- [ ] No import errors
|
|
||||||
|
|
||||||
### 5.2 Generate Endpoint
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8000/generate \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"use_pin": true}'
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Returns single `passphrase` string
|
|
||||||
- [ ] Returns 4 words by default
|
|
||||||
|
|
||||||
### 5.3 OpenAPI Docs
|
|
||||||
|
|
||||||
- [ ] `/docs` loads (Swagger UI)
|
|
||||||
- [ ] `/redoc` loads (ReDoc)
|
|
||||||
- [ ] All endpoints documented
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. CLI Tests
|
|
||||||
|
|
||||||
### 6.1 Version
|
|
||||||
|
|
||||||
```bash
|
|
||||||
stegasoo --version
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Shows 4.0.0
|
|
||||||
|
|
||||||
### 6.2 Generate
|
|
||||||
|
|
||||||
```bash
|
|
||||||
stegasoo generate --pin --words 4
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Single passphrase output
|
|
||||||
- [ ] 4 words generated
|
|
||||||
|
|
||||||
### 6.3 Encode/Decode Roundtrip
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate test image
|
|
||||||
python -c "from PIL import Image; Image.new('RGB', (200,200), 'red').save('/tmp/test.png')"
|
|
||||||
|
|
||||||
# Encode
|
|
||||||
stegasoo encode \
|
|
||||||
-r /tmp/test.png \
|
|
||||||
-c /tmp/test.png \
|
|
||||||
-p "cli test phrase here" \
|
|
||||||
--pin 123456 \
|
|
||||||
-m "CLI roundtrip test" \
|
|
||||||
-o /tmp/stego.png
|
|
||||||
|
|
||||||
# Decode
|
|
||||||
stegasoo decode \
|
|
||||||
-r /tmp/test.png \
|
|
||||||
-s /tmp/stego.png \
|
|
||||||
-p "cli test phrase here" \
|
|
||||||
--pin 123456
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Encode succeeds
|
|
||||||
- [ ] Decode returns correct message
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Cross-Version Compatibility
|
|
||||||
|
|
||||||
### 7.1 v3.2.0 Compatibility
|
|
||||||
|
|
||||||
- [ ] v4.0 can decode v3.2.0 images (same format version 4)
|
|
||||||
|
|
||||||
### 7.2 v3.1.x Incompatibility
|
|
||||||
|
|
||||||
- [ ] v4.0 fails gracefully on v3.1.x images
|
|
||||||
- [ ] Error message is clear
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Documentation Review
|
|
||||||
|
|
||||||
### 8.1 Updated Files
|
|
||||||
|
|
||||||
- [ ] README.md - v4.0 references
|
|
||||||
- [ ] INSTALL.md - Python 3.13 warning prominent
|
|
||||||
- [ ] SECURITY.md - v4.0 changes documented
|
|
||||||
- [ ] UNDER_THE_HOOD.md - JPEG normalization section
|
|
||||||
|
|
||||||
### 8.2 Template Updates
|
|
||||||
|
|
||||||
- [ ] All 7 templates updated
|
|
||||||
- [ ] No v3.x badges remaining
|
|
||||||
- [ ] Version history in About page
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Quick Smoke Test Script
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# v4.0.0 Smoke Test
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== Stegasoo v4.0.0 Smoke Test ==="
|
|
||||||
|
|
||||||
# Check version
|
|
||||||
echo "1. Checking version..."
|
|
||||||
python -c "import stegasoo; assert stegasoo.__version__.startswith('4.'), f'Wrong version: {stegasoo.__version__}'; print(f'✓ Version: {stegasoo.__version__}')"
|
|
||||||
|
|
||||||
# Check Python version
|
|
||||||
echo "2. Checking Python version..."
|
|
||||||
python -c "
|
|
||||||
import sys
|
|
||||||
v = sys.version_info
|
|
||||||
assert v.major == 3 and 10 <= v.minor <= 12, f'Python {v.major}.{v.minor} not supported'
|
|
||||||
print(f'✓ Python {v.major}.{v.minor}.{v.micro}')
|
|
||||||
"
|
|
||||||
|
|
||||||
# Check DCT support
|
|
||||||
echo "3. Checking DCT support..."
|
|
||||||
python -c "
|
|
||||||
from stegasoo import has_dct_support
|
|
||||||
from stegasoo.dct_steganography import has_jpegio_support
|
|
||||||
print(f' DCT (scipy): {has_dct_support()}')
|
|
||||||
print(f' JPEG native (jpegio): {has_jpegio_support()}')
|
|
||||||
assert has_dct_support(), 'DCT not available'
|
|
||||||
print('✓ DCT support OK')
|
|
||||||
"
|
|
||||||
|
|
||||||
# Test encode/decode roundtrip
|
|
||||||
echo "4. Testing encode/decode roundtrip..."
|
|
||||||
python -c "
|
|
||||||
from stegasoo import encode, decode
|
|
||||||
from PIL import Image
|
|
||||||
import io
|
|
||||||
|
|
||||||
img = Image.new('RGB', (200, 200), color='blue')
|
|
||||||
buf = io.BytesIO()
|
|
||||||
img.save(buf, format='PNG')
|
|
||||||
test_image = buf.getvalue()
|
|
||||||
|
|
||||||
result = encode(
|
|
||||||
message='Hello v4.0.0!',
|
|
||||||
reference_photo=test_image,
|
|
||||||
carrier_image=test_image,
|
|
||||||
passphrase='test phrase four words',
|
|
||||||
pin='123456'
|
|
||||||
)
|
|
||||||
|
|
||||||
decoded = decode(
|
|
||||||
stego_image=result.stego_image,
|
|
||||||
reference_photo=test_image,
|
|
||||||
passphrase='test phrase four words',
|
|
||||||
pin='123456'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert decoded.message == 'Hello v4.0.0!', f'Got: {decoded.message}'
|
|
||||||
print('✓ LSB roundtrip OK')
|
|
||||||
"
|
|
||||||
|
|
||||||
# Test DCT mode
|
|
||||||
echo "5. Testing DCT mode..."
|
|
||||||
python -c "
|
|
||||||
from stegasoo import encode, decode
|
|
||||||
from PIL import Image
|
|
||||||
import io
|
|
||||||
|
|
||||||
img = Image.new('RGB', (400, 400), color='green')
|
|
||||||
buf = io.BytesIO()
|
|
||||||
img.save(buf, format='PNG')
|
|
||||||
test_image = buf.getvalue()
|
|
||||||
|
|
||||||
result = encode(
|
|
||||||
message='DCT v4.0 test',
|
|
||||||
reference_photo=test_image,
|
|
||||||
carrier_image=test_image,
|
|
||||||
passphrase='dct test phrase here',
|
|
||||||
pin='123456',
|
|
||||||
embed_mode='dct'
|
|
||||||
)
|
|
||||||
|
|
||||||
decoded = decode(
|
|
||||||
stego_image=result.stego_image,
|
|
||||||
reference_photo=test_image,
|
|
||||||
passphrase='dct test phrase here',
|
|
||||||
pin='123456'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert decoded.message == 'DCT v4.0 test'
|
|
||||||
print('✓ DCT roundtrip OK')
|
|
||||||
"
|
|
||||||
|
|
||||||
# Test JPEG quality=100 (v4.0 fix)
|
|
||||||
echo "6. Testing JPEG quality=100 handling..."
|
|
||||||
python -c "
|
|
||||||
from stegasoo import encode, decode
|
|
||||||
from PIL import Image
|
|
||||||
import io
|
|
||||||
|
|
||||||
img = Image.new('RGB', (400, 400), color='red')
|
|
||||||
buf = io.BytesIO()
|
|
||||||
img.save(buf, format='JPEG', quality=100)
|
|
||||||
jpeg_q100 = buf.getvalue()
|
|
||||||
|
|
||||||
result = encode(
|
|
||||||
message='Quality 100 test',
|
|
||||||
reference_photo=jpeg_q100,
|
|
||||||
carrier_image=jpeg_q100,
|
|
||||||
passphrase='jpeg quality test here',
|
|
||||||
pin='123456',
|
|
||||||
embed_mode='dct'
|
|
||||||
)
|
|
||||||
|
|
||||||
decoded = decode(
|
|
||||||
stego_image=result.stego_image,
|
|
||||||
reference_photo=jpeg_q100,
|
|
||||||
passphrase='jpeg quality test here',
|
|
||||||
pin='123456'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert decoded.message == 'Quality 100 test'
|
|
||||||
print('✓ JPEG quality=100 OK (v4.0 fix working)')
|
|
||||||
"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== All smoke tests passed! ==="
|
|
||||||
echo "Ready for release."
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Release Steps
|
|
||||||
|
|
||||||
### 10.1 Final Checks
|
|
||||||
|
|
||||||
- [ ] All tests pass
|
|
||||||
- [ ] All Docker containers work
|
|
||||||
- [ ] Documentation updated
|
|
||||||
- [ ] Version bumped in `constants.py` and `pyproject.toml`
|
|
||||||
|
|
||||||
### 10.2 Git
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git status # Review changes
|
|
||||||
git commit -m "v4.0.0: JPEG normalization, Python 3.12, UI polish"
|
|
||||||
git tag v4.0.0
|
|
||||||
git push origin main --tags
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Changes committed
|
|
||||||
- [ ] Tag created
|
|
||||||
- [ ] Pushed to remote
|
|
||||||
|
|
||||||
### 10.3 Release Notes
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## v4.0.0
|
|
||||||
|
|
||||||
### What's New
|
|
||||||
- **JPEG Normalization**: Quality=100 JPEGs now work with DCT mode
|
|
||||||
- **Python 3.12**: Recommended version (3.13 NOT supported due to jpegio)
|
|
||||||
- **UI Polish**: Glowing input fields, better layout, version history
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Fixed jpegio crash on quality=100 JPEG images
|
|
||||||
- Fixed QR code input on decode page
|
|
||||||
- Fixed passphrase font sizing (stepped instead of smooth)
|
|
||||||
|
|
||||||
### Breaking Changes
|
|
||||||
- Python 3.13 is NOT supported
|
|
||||||
|
|
||||||
### Compatibility
|
|
||||||
- v4.0 can decode v3.2.0 images (same format)
|
|
||||||
- v4.0 CANNOT decode v3.1.x or earlier
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sign-Off
|
|
||||||
|
|
||||||
| Area | Tested By | Date | Status |
|
|
||||||
|------|-----------|------|--------|
|
|
||||||
| Python/Dependencies | | | ☐ |
|
|
||||||
| Unit Tests | | | ☐ |
|
|
||||||
| Docker Build | | | ☐ |
|
|
||||||
| Web UI | | | ☐ |
|
|
||||||
| API | | | ☐ |
|
|
||||||
| CLI | | | ☐ |
|
|
||||||
| Documentation | | | ☐ |
|
|
||||||
|
|
||||||
**Release Approved:** ☐
|
|
||||||
|
|
||||||
**Released By:** _________________
|
|
||||||
|
|
||||||
**Release Date:** _________________
|
|
||||||
@@ -50,8 +50,17 @@ def png_image():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def large_png_image():
|
def large_png_image():
|
||||||
"""Create a larger test PNG image for DCT mode."""
|
"""Create a larger test PNG image for DCT mode.
|
||||||
img = Image.new("RGB", (400, 400), color="blue")
|
|
||||||
|
Uses noise instead of solid color to ensure DCT color mode works.
|
||||||
|
Solid colors cause coefficient drift during RGB conversion that
|
||||||
|
can exceed the quantization step and corrupt embedded data.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
# Create random noise image (ensures varied Y channel values)
|
||||||
|
np.random.seed(42) # Reproducible
|
||||||
|
data = np.random.randint(0, 256, (400, 400, 3), dtype=np.uint8)
|
||||||
|
img = Image.fromarray(data, 'RGB')
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
img.save(buf, format="PNG")
|
img.save(buf, format="PNG")
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
|
|||||||