121 Commits

Author SHA1 Message Date
Aaron D. Lee
664362bea5 Add Pi 4 performance note to README
Some checks failed
Release / test (push) Failing after 30s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped
2026-01-05 14:48:58 -05:00
Aaron D. Lee
4733e3b4dd Add dropzone UX fixes to 4.1.2 plan 2026-01-05 14:44:06 -05:00
Aaron D. Lee
24aec00613 Add smoke test benchmarking to 4.1.2 plan 2026-01-05 14:37:58 -05:00
Aaron D. Lee
0e0aa996bc Reset port 443 redirect during sanitize
Clear iptables-restore service and rules so wizard can reconfigure
fresh. Fixes MOTD showing wrong port after re-running wizard.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 14:01:31 -05:00
Aaron D. Lee
255ae4f30d Fix MOTD port 443 detection (check service not iptables)
iptables requires root to read NAT rules. Instead check if the
iptables-restore service is enabled, which indicates 443 redirect
was configured.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:56:02 -05:00
Aaron D. Lee
7647ca11d1 Add login banner showing Stegasoo URL
On SSH login, shows ASCII logo and active URL if service is running,
or instructions to start if not.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:44:09 -05:00
Aaron D. Lee
01e9e5af0a Rename carrier2.JPG to carrier2.jpg 2026-01-05 13:39:30 -05:00
Aaron D. Lee
39e5daa022 Show /setup URL in setup scripts
Direct users to the admin account creation page instead of root URL.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:38:36 -05:00
Aaron D. Lee
54e097c050 Fix pyenv Python path resolution in setup.sh
Use `pyenv which python` instead of hardcoded path to handle
version mapping (3.12 -> 3.12.12).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:30:30 -05:00
Aaron D. Lee
a3ff8dace1 Fix Pi setup: use /opt/stegasoo, flexible Python version
- Change .python-version from 3.12.0 to 3.12 (matches any 3.12.x)
- Update docs to use /opt/stegasoo instead of ~/stegasoo
- Add pre-setup steps: chown /opt, install git
- Renumber BUILD_IMAGE.md steps (now 9 steps)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:16:31 -05:00
Aaron D. Lee
e4cf96bb7c Add 4.1.3 plan stub (heavier features)
Reserved for:
1. DCT performance optimizations
2. User management UI (admin CRUD)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:29:08 -05:00
Aaron D. Lee
597c95070c Add 4.1.2 release plan
Three focused features:
1. Real progress bar for encode/decode (polling + progress file)
2. Granular decode error messages (custom exceptions, specific UI feedback)
3. Mobile-responsive polish (touch targets, stacked layouts, camera hints)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:28:47 -05:00
Aaron D. Lee
dba5a08476 Add --443 flag to smoke test for HTTPS on port 443
- Better argument parsing for IP, --https, --443, --port=N
- Port 443 omits port from URL for cleaner output
- Ignore unknown flags instead of treating as IP

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:15:17 -05:00
Aaron D. Lee
6ceda6c287 Add overclock removal to sanitize script
- New step 10/11 removes overclock settings from /boot/firmware/config.txt
- Removes over_voltage, arm_freq, gpu_freq lines
- Skipped in soft reset mode (preserves for testing)
- Distributable image should let users configure via wizard

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:01:24 -05:00
Aaron D. Lee
c2575f973b Redesign Limits & Specs section with key stats and accordion
- Show 6 key specs prominently as cards (Payload, Carrier, DCT/LSB capacity, Encryption, Error Correction)
- Add Reed-Solomon error correction info with v4.1 badge
- Move secondary specs to collapsible accordion
- Add reedsolo to "Built with" list

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:52:46 -05:00
Aaron D. Lee
8208ec2955 Add btop, overclock wizard option, click-to-copy decode UI
- setup.sh: Add btop to apt install for temp monitoring
- first-boot-wizard: Add Step 4 for overclock configuration
  - Detects Pi 4/5 model
  - Asks about active cooling
  - Offers appropriate overclock settings (2.0GHz Pi4, 2.8GHz Pi5)
  - Prompts for restart if enabled
- decode.html: Make message box click-to-copy, remove separate button
  - Shows "(click to copy)" hint
  - Visual feedback on hover and copy

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:48:59 -05:00
Aaron D. Lee
909dc14a92 Fix setup.sh jpegio build order and add python3-dev
- Clone stegasoo BEFORE building jpegio (need patch script)
- Create venv with explicit pyenv Python path
- Build jpegio INTO venv (not globally)
- Add python3-dev to apt dependencies
- Update step count from 9 to 11

This fixes the issue where jpegio was built globally, then pip
tried to reinstall unpatched jpegio from PyPI into the venv.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:43:10 -05:00
Aaron D. Lee
bb91e41d3d Update default branch from main to 4.1 in Pi docs
Branch 4.1 includes Reed-Solomon error correction for DCT
steganography which is required for reliable operation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 22:59:32 -05:00
Aaron D. Lee
c54a96894c Bump version to 4.1.1
- Reed-Solomon error correction for DCT mode
- Elapsed time counter on encode/decode buttons
- Increased timeout to 300s for slow devices

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 22:45:05 -05:00
Aaron D. Lee
da044017d7 Add elapsed time counter to encode/decode buttons
Shows running timer (e.g., "Encoding... 1:23") so users know
the operation is still working and not frozen.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 22:44:05 -05:00
Aaron D. Lee
d0ec99d5b5 Add Reed-Solomon error correction to DCT steganography
- Add reedsolo library for RS error correction (32 symbols = 16 byte correction per 223-byte chunk)
- Protect entire payload (header + data) with RS encoding
- Store 3 copies of length header with majority voting for robustness
- Handle RS chunking overhead (varies based on data size)
- Update capacity calculation to account for RS overhead (24 bytes prefix + variable RS overhead)
- Add RS to dct, web, and api optional dependencies
- Update about.html with v4.1.0 Reed-Solomon feature
- Update module docstring

This fixes DCT decode failures with certain carrier images that have
uniform areas causing unstable DCT coefficients.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 22:28:58 -05:00
Aaron D. Lee
aac8037c04 Fix DCT steganography for non-8-aligned images and set color mode default
- Fix block calculation mismatch in DCT extract (use original dimensions)
- Change default dct_color_mode from "grayscale" to "color"
- Update DCT test to use noise image instead of solid color
- Remove debug logging from encode/decode paths

The block calculation fix ensures extract uses the same block positions
as embed for images whose dimensions aren't divisible by 8. This was
causing decode failures on the Pi web UI with 1195x671 images.

Color mode is now the default since it preserves the original image
colors. The test fixture now uses a random noise image because solid
color images cause coefficient drift during YCbCr/RGB conversion that
can corrupt embedded data.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 21:36:59 -05:00
Aaron D. Lee
7a5092b945 Add embed_mode to debug logging 2026-01-04 21:12:20 -05:00
Aaron D. Lee
e52a709080 Forward worker stderr to main process for debugging 2026-01-04 21:06:04 -05:00
Aaron D. Lee
70fe8fce62 Add worker debug logging for channel key resolution 2026-01-04 21:00:32 -05:00
Aaron D. Lee
d44575deec Add encode channel_key debug logging 2026-01-04 20:57:08 -05:00
Aaron D. Lee
d0d48236ff Add decode result and channel_key debug logging 2026-01-04 20:52:13 -05:00
Aaron D. Lee
5891285493 Fix missing hashlib import in download route 2026-01-04 20:49:11 -05:00
Aaron D. Lee
5501c7e0ba Add more debug logging for stego file tracking
Log stego file size and hash at:
- Encode result storage
- Download time

This will help identify if files are corrupted during
download/upload cycle.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:47:12 -05:00
Aaron D. Lee
038fd6ceac Fix decode debug logging to use stderr for systemd journal
Debug prints need file=sys.stderr to appear in journalctl output.
Encode route was fixed but decode was still using plain print().

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:42:24 -05:00
Aaron D. Lee
8622f1a850 Add debug logging for encode/decode file hashes
Temporary debug output to help trace reference photo byte mismatches.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:38:13 -05:00
Aaron D. Lee
710b3a6a98 Implement CLI encode/decode with reference photo support
- Add required -r/--reference option to encode command
- Add required -r/--reference option to decode command
- Replace stub implementations with actual library calls
- CLI now properly encodes and decodes messages/files
- Fix smoke test form field names and add proper redirect handling

The CLI encode/decode were stubs that didn't actually work.
Now they properly use the stegasoo library functions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:25:59 -05:00
Aaron D. Lee
c965a5f8da Fix channel_fingerprint None check in templates
Handle case where channel_fingerprint is None when no channel
key is configured, preventing TypeError on encode/decode pages.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:01:30 -05:00
Aaron D. Lee
00cda4d929 Enhance smoke test and fix banner alignment
Smoke test now includes:
- Admin user creation and login
- Regular user creation and workflow
- Encode/decode tests for both user types
- Password recovery QR test
- System health checks

Also fixes Setup Complete banner alignment.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:53:59 -05:00
Aaron D. Lee
05e2286d02 Add smoke test script for Pi image validation
Automated tests for fresh Pi images:
- Web UI accessibility
- Admin user creation
- Login authentication
- Encode/decode functionality
- Service health via SSH

Usage: ./smoke-test.sh [ip] [--https]

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:48:49 -05:00
Aaron D. Lee
46cbf98a23 Small fix 2026-01-04 19:36:06 -05:00
Aaron D. Lee
58673c04fe Add gum TUI toolkit installation to setup.sh
Required for first-boot wizard TUI. Adds Charm repo and installs gum.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:30:11 -05:00
Aaron D. Lee
dd07972014 Align sparkle banners consistently across all RPI scripts
- 4 spaces before sparkle lines
- 3 spaces before logo top line
- 2 spaces before logo body

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:28:03 -05:00
Aaron D. Lee
1f40eeff9e Add option to skip compression in pull-image.sh
Use .img extension to skip zstd compression:
  ./pull-image.sh stegasoo.img

Use .img.zst to compress (default behavior):
  ./pull-image.sh stegasoo.img.zst

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:27:26 -05:00
Aaron D. Lee
dc09bac489 Fix logo top line alignment (3 spaces for ASCII art)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:22:17 -05:00
Aaron D. Lee
46489dd276 Fix bottom sparkle line alignment
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:21:40 -05:00
Aaron D. Lee
9088caa23d Fix top sparkle line alignment in setup and sanitize
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:19:40 -05:00
Aaron D. Lee
75b6203525 Make PATH hook a check/fix step in sanitize
Now checks if /etc/profile.d/stegasoo-path.sh exists and creates
it if missing, rather than always overwriting.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:03:13 -05:00
Aaron D. Lee
404d7885f4 Add PATH hook to sanitize script for pre-built images
Ensures stegasoo CLI and rpi scripts are in PATH for images
created with sanitize-for-image.sh.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:02:28 -05:00
Aaron D. Lee
a8db991052 Add stegasoo CLI and rpi scripts to PATH
Creates /etc/profile.d/stegasoo-path.sh to add:
- /opt/stegasoo/venv/bin (stegasoo CLI)
- /opt/stegasoo/rpi (setup.sh, sanitize-for-image.sh, etc)

Users can now run 'stegasoo' and the rpi scripts from anywhere.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:57:02 -05:00
Aaron D. Lee
ea2948e5d2 Add spacing between logo and bottom sparkle row
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:54:24 -05:00
Aaron D. Lee
05278ca55f Fix ASCII banner alignment - remove extra leading space
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:53:42 -05:00
Aaron D. Lee
c551078c37 Add sparkle ASCII banners to all RPI scripts
Replace the complex dot pattern with cleaner sparkle-style banners
using . * . patterns above and below the STEGASOO logo. Also fixes
duplicate logo lines that were present in setup.sh and sanitize.sh.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:52:01 -05:00
Aaron D. Lee
b7d86201ca Use bright green (46) for buttons
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:46:07 -05:00
Aaron D. Lee
07b0bc0b75 Use terminal green (34) instead of lime (82)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:45:44 -05:00
Aaron D. Lee
d8b8e4f5c2 Add bold text for selected buttons
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:43:20 -05:00
Aaron D. Lee
143a8bdc65 Fix button text contrast - dark text on lime, white on gray
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:42:39 -05:00
Aaron D. Lee
ac92fa36b5 Style gum confirm buttons with lime green
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:41:39 -05:00
Aaron D. Lee
c82dcf26f2 Fix jpegio build directory handling
- Use fixed path instead of mktemp
- Remove dir before clone to ensure clean state
- chown to user before pip install
- Check clone success before proceeding

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:31:56 -05:00
Aaron D. Lee
65a496a9d4 Add jpegio ARM64 patch to venv rebuild
Clone and patch jpegio to remove -m64 flag on ARM64 before
installing. Also install build deps (cython, numpy) first.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:29:03 -05:00
Aaron D. Lee
25a432fcf3 Use Python 3.12 for venv rebuild
Check for pyenv Python 3.12 first, then system python3.12,
then fall back to python3 with warning.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:28:17 -05:00
Aaron D. Lee
a58dd54ba8 Add venv repair step to sanitize script
Check if venv is broken or missing stegasoo module and rebuild
if needed. Venv paths break when directory is moved.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:26:48 -05:00
Aaron D. Lee
05c542d808 Use full venv python path for channel key generation
Don't rely on source activate - use direct path to venv python.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:25:55 -05:00
Aaron D. Lee
5e5d6e60de Fix channel key generation and show errors
- Capture stderr separately to show error details
- Validate key format before accepting
- Wait for user confirmation on error

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:22:18 -05:00
Aaron D. Lee
d898f6d7b1 Replace emoji with ASCII art for terminal compatibility
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:21:24 -05:00
Aaron D. Lee
00dd15b8fb Rewrite first-boot wizard using gum TUI
Replace whiptail with gum (Charm.sh) for beautiful, modern TUI.
Features spinners, styled boxes, colored text, and reliable prompts.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:16:57 -05:00
Aaron D. Lee
419b491737 Rewrite first-boot wizard using whiptail TUI
Replace manual read prompts with whiptail dialogs for reliable
user input. Whiptail is pre-installed on Raspberry Pi OS.

Features:
- Modal dialogs that properly wait for input
- Progress gauge for key generation and setup
- Cleaner, more professional look
- No more input buffer issues

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:11:48 -05:00
Aaron D. Lee
b568026253 Add comments explaining input buffer flush
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:06:59 -05:00
Aaron D. Lee
127d3e54a6 Fix input buffer issues in first-boot wizard
Add input buffer flush before all read prompts to prevent
leftover keystrokes from auto-answering wizard questions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:06:04 -05:00
Aaron D. Lee
de41c0731e Fix input prompt dropping issue in sanitize script
Add input buffer flush before each read prompt to prevent
leftover keystrokes from auto-answering prompts. Also add
small delay before reboot/shutdown prompts.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 15:51:05 -05:00
Aaron D. Lee
f3d5699e15 Add config file support and help to RPi scripts
- Add stegasoo.conf.example with all configurable options
- setup.sh: Add -h/--help, load config from /etc and ~/.config
- setup.sh: Support STEGASOO_BRANCH for non-main branches
- sanitize-for-image.sh: Add -h/--help with usage examples

Config files are loaded in order:
1. /etc/stegasoo.conf (system-wide)
2. ~/.config/stegasoo/stegasoo.conf (per-user)
3. Environment variables (highest priority)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 15:45:31 -05:00
Aaron D. Lee
298f387c9a Move default install location to /opt/stegasoo
- setup.sh: Install to /opt/stegasoo with proper permissions
- first-boot-wizard.sh: Use /opt/stegasoo
- stegasoo-wizard.sh: Check /opt first, fallback to home dirs
- sanitize-for-image.sh: Handle both /opt and home locations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 15:43:42 -05:00
Aaron D. Lee
fcb71303df Update version badges from v4.0 to v4.1
- Update all v4.0 badges to v4.1 across templates
- Add cute v4.1 badge under Stegasoo title in navbar

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 15:35:36 -05:00
Aaron D. Lee
abcff74dd4 Fix hover gradient direction on big buttons
- Align hover gradient with flipped eggplant→blue direction
- Update box-shadow to use eggplant purple

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 15:24:41 -05:00
Aaron D. Lee
355a988405 Add dynamic channel selector feedback with pulse highlight
- Channel select shows contextual info: Auto (server key), Public (no key), Custom (hidden)
- Gold pulse highlight on custom channel input when selected
- Smooth 0.4s animation with subtle glow effect
- Updated encode.html and decode.html with data-fingerprint attributes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 15:21:02 -05:00
Aaron D. Lee
fb55878727 Polish UI styling across site
- Flip gradient to purple→blue (eggplant #4a2860 → blue #5570d4)
- Add gold title styling (.title-gold) for Stegasoo branding
- Style two-choice toggles with gradient-matched purple/blue colors
- Equal-width toggle buttons with hover highlight
- Tools page: green→amber tab gradient with dark background
- Dashed separator between toggle options

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 14:56:08 -05:00
Aaron D. Lee
81d3f37f09 Refine Tools page tab colors and site-wide styling
- Green→lime→yellow gradient across tool tabs (#55df85→#c4f26a→#fdde4a)
- Complements blue→purple header gradient
- Fine-tuned header gold (#fee862) with subtle drop shadow
- Apply gold styling to .text-warning.small site-wide

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:45:40 -05:00
Aaron D. Lee
3537e8cdf9 Redesign Tools page UI and refine site-wide styling
- Consolidate Tools into single card with tab toggle (Capacity/EXIF/Strip)
- Remove non-functional Peek feature (requires keys due to PRNG scattering)
- Add lime green (#a3e635) tool tab styling
- Add light straw gold (#fee862) card header text site-wide
- Add subtle drop shadow to headers and warning text
- Match Tools page styling to Encode/Decode pages

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:24:12 -05:00
Aaron D. Lee
d71f615d66 Improve EXIF tool error handling and UX
- Add loading spinner feedback for Clear All and Save buttons
- Show error alerts when requests fail instead of silent failure
- Detect session expiration and redirect to login
- Update UI to show empty state after clearing metadata
- Fix download by properly appending anchor to DOM

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:23:27 -05:00
Aaron D. Lee
ed1d230b4e Add template specification documentation
docs/TEMPLATES.md - Quick reference for all Jinja2 templates,
their routes, form fields, and JS dependencies.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 03:09:02 -05:00
Aaron D. Lee
13f145c3d5 Reduce toast notification delay to 10 seconds
Quick and snappy UX - 10s is plenty to read a notification.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 02:30:02 -05:00
Aaron D. Lee
80dc22f150 Add Admin Recovery System with multiple backup options
- Recovery key generation (32-char alphanumeric, dashed format)
- Multiple backup methods: text file, QR code, stego image
- QR codes obfuscated with XOR (RECOVERY_OBFUSCATION_KEY constant)
- Stego backup hides key in image using Stegasoo itself
- CLI: `stegasoo admin recover --db path/to/db`
- Web routes: /recover, /account/recovery/regenerate
- Toast notifications now auto-dismiss after 20s with fade
- Updated WEB_UI.md and CLI.md documentation for v4.1.0

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 02:27:06 -05:00
Aaron D. Lee
01f0173dd4 Add EXIF Editor, consolidate channel key resolution
EXIF Editor (Library → CLI → API → WebUI):
- src/stegasoo/utils.py: read_image_exif(), write_image_exif()
- CLI: stegasoo tools exif [--clear|--set Field=Value]
- API: /api/tools/exif, /api/tools/exif/update, /api/tools/exif/clear
- WebUI: EXIF Editor tab with inline editing, clear all, save/download

Architectural consolidation:
- Moved resolve_channel_key() to src/stegasoo/channel.py (was duplicated in 3 frontends)
- Added get_channel_response_info() for consistent API/WebUI responses
- Frontends now use thin wrappers that translate exceptions

DCT improvements:
- Added will_fit_by_mode() pre-check to WebUI encode (fail fast)
- Suggests LSB mode when DCT capacity exceeded

Dependencies:
- Added piexif>=1.1.0 for EXIF editing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 01:16:33 -05:00
Aaron D. Lee
5df9b9dac8 Add Image Security Toolkit (tools)
Library:
- Add peek_image() to detect Stegasoo headers without decrypting

CLI:
- stegasoo tools capacity <image> - show LSB/DCT capacity
- stegasoo tools strip <image> - remove EXIF metadata
- stegasoo tools peek <image> - detect hidden data

API:
- POST /api/tools/capacity
- POST /api/tools/strip-metadata
- POST /api/tools/peek

WebUI:
- /tools page with tabbed interface (login required)
- Basic implementation - needs polish (dropzones, better results)

Architecture: Library -> CLI -> API -> WebUI pattern

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:54:40 -05:00
Aaron D. Lee
2f1ac3a747 Switch flash messages to toast notifications
- Simple single-line toasts in top-right corner
- Positioned below navbar (70px from top)
- Auto-dismiss after 4 seconds
- Color-coded: green success, yellow warning, red error

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:27:32 -05:00
Aaron D. Lee
8e5f01754f Improve user creation UX with modal dialog
- Replace redirect flow with AJAX + modal popup
- Show credentials side-by-side (username | password)
- Compact warning message and right-aligned action buttons
- Add Another resets form, Done returns to user list
- Narrow flash messages to match card width

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:10:48 -05:00
Aaron D. Lee
823b8824ea Add saved channel keys feature for Web UI users
- Database: Add user_channel_keys table with CASCADE delete
- Auth: Add CRUD functions for channel key management (10 keys/user limit)
- Routes: Add key save/delete/rename endpoints and JSON API
- Account page: Add saved keys section with add/rename/delete UI
- Encode/Decode: Add saved keys to channel key dropdown (optgroup)
- About page: Add Channel Key QR generator for sharing keys
- Track last_used_at when saved keys are used

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:47:59 -05:00
Aaron D. Lee
f4c1aa1912 Refactor: Extract inline JS to external files
New JS files:
- auth.js: Password toggle, confirmation validation, copy, regenerate
- generate.js: Form controls, credential display, memory story generation

Updated templates to use external JS:
- login.html, setup.html, account.html
- admin/user_new.html, user_created.html, password_reset.html
- generate.html (now uses generate.js + minimal Jinja-dependent inline)

Core stegasoo.js (943 lines) unchanged - already handles encode/decode

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:33:17 -05:00
Aaron D. Lee
e502f42fb8 Add netplan WiFi cleanup to sanitize script
Some RPi OS variants store WiFi credentials in /etc/netplan/*.yaml
files, particularly NetworkManager-generated configs (90-NM-*.yaml).

- Remove netplan WiFi configs during sanitization
- Update validation to check netplan location
- Covers wpa_supplicant, NetworkManager, and netplan now

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:22:09 -05:00
Aaron D. Lee
08e42719ee Fix WiFi sanitization for NetworkManager (RPi OS Bookworm+)
Modern Raspberry Pi OS uses NetworkManager instead of wpa_supplicant.
WiFi connections are stored in /etc/NetworkManager/system-connections/.

- Add removal of NetworkManager WiFi connections
- Update validation to check both locations
- Fixes WiFi credentials being baked into distributable images

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:10:50 -05:00
Aaron D. Lee
21023099b0 Add CLI channel command group for channel key management
New commands:
- stegasoo channel generate [--save|--save-user]
- stegasoo channel show [--key KEY]
- stegasoo channel status
- stegasoo channel qr [--key KEY] [-o FILE] [--format ascii|png]
- stegasoo channel clear [--project|--user]

Features:
- ASCII QR code output for terminal display
- PNG QR code export for sharing
- JSON output mode (--json flag)
- Explicit key override for all commands

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:09:07 -05:00
Aaron D. Lee
8a41796d1b Update plan: mark multi-user support as completed
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:53:08 -05:00
Aaron D. Lee
7b33501495 Add multi-user support with admin user management
- Rewrite auth.py for multi-user schema (users table with roles)
- Auto-migrate from single-user admin_user table to new schema
- Add @admin_required decorator for protected routes
- Admin routes: /admin/users, /admin/users/new, delete, reset-password
- New templates: admin/users.html, user_new.html, user_created.html, password_reset.html
- Update login.html for username field, base.html and account.html for admin nav
- Max 16 users + 1 admin, session invalidation on delete/password reset

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:52:39 -05:00
Aaron D. Lee
a8f6ae1dd2 Add 4.1.0 feature plan
- Multi-user support (16+1 admin)
- Channel key QR codes (web + CLI)
- Advanced tools: capacity calc, metadata stripper, stego detector,
  image compare, header peek, batch mode

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:21:23 -05:00
Aaron D. Lee
b199f03f83 Add --reboot flag to sanitize script for full automation
Skips all prompts when passed, auto-reboots (soft reset) or
auto-shutdowns (full sanitize) when complete.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:50:43 -05:00
Aaron D. Lee
b97622956c Fix read prompts and reboot/shutdown in sanitize script
- Add </dev/tty to read commands for reliable terminal input
- Use exec for reboot/shutdown to prevent returning to shell

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:47:01 -05:00
Aaron D. Lee
3044c08fe3 Replace tail/head labels with ~~~~ in banners
Keep the wave decorations but remove the stegosaurus labels.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:43:22 -05:00
Aaron D. Lee
5042c7d555 Add ASCII banner to setup.sh and sanitize-for-image.sh
Consistent branding across all RPi scripts with the stegosaurus
plate banner, gray dots, and cyan accents.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:38:40 -05:00
Aaron D. Lee
aa8788168e Banner tweak (manual) 2026-01-03 21:36:04 -05:00
Aaron D. Lee
899d043892 Swap dot pattern after 2-space padding shift
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:34:11 -05:00
Aaron D. Lee
6fb63edc61 Add 2-space padding before trailing dots in banner
Consistent spacing between letter content and dot pattern.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:33:33 -05:00
Aaron D. Lee
e74f12c24d Fix dot pattern direction - continue from left side
Lines starting with · should end with · pattern,
lines starting with . should end with . pattern.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:32:22 -05:00
Aaron D. Lee
272d0e6ef0 Fix dot alignment on right side of ASCII banner
Consistent spacing between letter content and trailing dots.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:30:56 -05:00
Aaron D. Lee
f38bf4a1c6 Fix escape sequences in ASCII banner
Double backslashes needed to prevent \033 from being escaped
by preceding backslash characters in echo -e output.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:27:57 -05:00
Aaron D. Lee
fee3133f9c Double up letter lines in ASCII banner for bolder look
Each row of STEGASOO letters is now duplicated for a thicker,
CRT scanline-style effect.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:25:19 -05:00
Aaron D. Lee
b058d8bf66 Refine ASCII banner: gray dots, cyan accents, 2-row plates
- Dots now gray with STEGASOO letters, plates, and labels in cyan
- Diamond plates simplified to 2 rows (/\ \/)
- Cleaner visual hierarchy

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:21:32 -05:00
Aaron D. Lee
916a2e0e7b Fix SSH key regeneration service hanging on boot
Remove ExecStartPost that was calling systemctl restart ssh, which
caused a deadlock. The Before=ssh.service ordering ensures keys are
generated before ssh starts - no restart needed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:15:41 -05:00
Aaron D. Lee
cccb40dc3a Update RPi scripts with new ASCII art banner and simpler headers
- Add stegosaurus-themed ASCII art with diamond plates and halftone dots
- Replace box-drawing characters with simple dashes for headers
- Consistent styling across first-boot-wizard.sh, setup.sh, sanitize-for-image.sh

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:13:38 -05:00
Aaron D. Lee
b60880c8b3 Add SSH key regeneration service to sanitize script
Creates a systemd service that regenerates SSH host keys on first boot,
fixing the issue where SSH would fail after sanitization.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:37:39 -05:00
Aaron D. Lee
c96c595c78 Add robust jpegio ARM64 patching system
- Create rpi/patches/ directory with multi-strategy patching
- Patch tries: patch file → sed → Python regex → already-patched detection
- Fix jpegio patch to handle multiple -m64 occurrences
- Update docs to use wget instead of curl|bash (stdin conflict with read)
- Update SSH examples to use admin@stegasoo.local

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:22:55 -05:00
Aaron D. Lee
e129c38fd8 Clean up debug scripts and update RPi docs
- Delete debug/diagnostic scripts (minimal_flask_crash.py, check_scipy.py)
- Delete old version summary markdown files
- Update RPi docs with default creds (admin/stegasoo)
- Add --soft flag documentation for sanitize script
- Switch compression from xz to zstd
- Add RPi image artifacts to .gitignore
- Improve sanitize-for-image.sh with validation and soft reset mode

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:55:37 -05:00
Aaron D. Lee
0d7b5a14cb Improve RPi image scripts
- flash-image.sh: Add optional device argument to bypass auto-detection
- flash-image.sh/pull-image.sh: Remove bc dependency, use bash integer math
- sanitize-for-image.sh: Add better debugging and verification for wizard setup

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 03:16:01 -05:00
Aaron D. Lee
45b99d2c5e Switch image scripts to zstd compression
- pull-image.sh now uses zstd -19 instead of xz -9 (much faster, similar ratio)
- flash-image.sh supports .zst, .xz, and .gz formats
- Default output is now .img.zst

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 00:56:41 -05:00
Aaron D. Lee
c6f816d61f Add pull-image.sh and flash-image.sh helper scripts
- pull-image.sh: Auto-detects SD card, copies with pv progress, runs pishrink, compresses with xz
- flash-image.sh: Auto-detects SD card, flashes .img.xz/.img with pv progress
- Both scripts auto-detect 8-128GB USB drives and skip root filesystem
- Safety confirmations before destructive operations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 00:49:07 -05:00
Aaron D. Lee
83e9bd6fa1 Fix XSS vulnerability, request parsing bug, and session persistence
- Fix XSS in stegasoo.js: use textContent instead of innerHTML for filenames
- Fix operator precedence in channel key parsing (form data was ignored)
- Persist Flask secret key to instance/.secret_key so sessions survive restarts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 00:08:06 -05:00
Aaron D. Lee
5188492c77 Fix bold text escape codes in first-boot wizard 2026-01-02 23:26:44 -05:00
Aaron D. Lee
8bb70e5667 Add first-boot wizard for pre-built RPi images
- Create first-boot-wizard.sh with interactive step-by-step setup
  - Step 1: HTTPS configuration
  - Step 2: Port 443 configuration (if HTTPS enabled)
  - Step 3: Channel key generation
  - ASCII art banner and clear summaries
- Create stegasoo-wizard.sh profile.d hook to trigger wizard on SSH login
- Update sanitize-for-image.sh to:
  - Install wizard hook in /etc/profile.d/
  - Create first-boot flag file
  - Reset service to defaults for fresh config

Users who flash a pre-built image will see the wizard on first SSH login.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 23:24:20 -05:00
Aaron D. Lee
82ac1dcda4 Add interactive configuration prompts to RPi setup script
- Prompt for HTTPS enable/disable
- Prompt for port 443 with iptables redirect
- Prompt for channel key generation
- Offer to start service immediately
- Show summary with configured URL and channel key

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 23:20:32 -05:00
Aaron D. Lee
464e13567d Add STEGASOO_PORT env var, improve RPi setup output, channel key accordion
- Add STEGASOO_PORT environment variable support (default: 5000)
- Update .env.example with port and fix channel key format docs
- Move channel key generation to collapsible accordion in Generate page
- Improve RPi setup.sh output with HTTPS and channel key instructions
- Add rpi/BUILD_IMAGE.md workflow documentation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 23:11:04 -05:00
Aaron D. Lee
0b19a41b5e Add sanitize script for distributable Pi images
- rpi/sanitize-for-image.sh: Removes personal data before imaging
  - Clears WiFi credentials
  - Removes SSH keys
  - Clears Stegasoo auth database
  - Removes logs, history, temp files
- Updated rpi/README.md with full image building workflow

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 22:06:14 -05:00
Aaron D. Lee
61c5178752 Fix channel key generation to use correct format
Use generate_channel_key() from channel module instead of hex
Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 22:02:33 -05:00
Aaron D. Lee
6b1b306f61 Add --channel-key flag to generate command
- stegasoo generate --channel-key now outputs a 256-bit hex key
- Also added .env.example template for Web UI configuration

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 21:56:02 -05:00
Aaron D. Lee
267547caba Add Raspberry Pi setup script and documentation
- rpi/setup.sh: One-command install for Pi 4/5
  - Installs pyenv + Python 3.12
  - Patches and builds jpegio for ARM
  - Creates systemd service for auto-start
- rpi/README.md: Usage instructions

Install with: curl -sSL https://raw.githubusercontent.com/.../setup.sh | bash

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 21:45:31 -05:00
Aaron D. Lee
2ff28034f5 Add comprehensive Raspberry Pi installation instructions
- Step-by-step guide for Pi 4/5 deployment
- pyenv setup for Python 3.12 (Pi OS ships with 3.13)
- jpegio ARM build patch (sed one-liner for -m64 flag)
- Full verification steps

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 21:42:27 -05:00
Aaron D. Lee
4cba75fe06 Move dev scripts to scripts/ directory
Consolidated all local dev scripts into scripts/ subdirectory.
Updated .gitignore to ignore entire scripts/ folder.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 20:28:09 -05:00
Aaron D. Lee
d03b3dea4b Update Web UI screenshots for v4.0.2
Refreshed all README screenshots with current UI styling.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 20:16:01 -05:00
79 changed files with 10932 additions and 2948 deletions

14
.gitignore vendored
View File

@@ -54,7 +54,7 @@ htmlcov/
# Environment
.env
.env.*
.env.local
*.log
# Distribution
@@ -65,11 +65,15 @@ htmlcov/
test_data/*.png
# Dev scripts (local convenience scripts)
build.sh
rbld_containers.sh
quick_web.sh
project_stats.sh
scripts/
# Web UI auth database and SSL certs
frontends/web/instance/
frontends/web/certs/
rpi/inject-wifi.sh
# RPi image build artifacts
*.img
*.img.xz
*.img.zst
pishrink.sh

View File

@@ -1 +1 @@
3.12.0
3.12

View File

@@ -5,6 +5,38 @@ All notable changes to Stegasoo will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org).
## [4.1.0] - 2026-01-04
### Added
- **Admin Recovery System**: Password reset for locked-out admins
- Recovery key generated during setup (32-char alphanumeric)
- Multiple backup options: text file, QR code, stego image
- QR codes obfuscated (XOR'd with magic header hash)
- Stego backups hide key in an image using Stegasoo itself
- CLI: `stegasoo admin recover --db path/to/db`
- **EXIF Editor**: Full metadata editing in Tools page
- View all EXIF fields from uploaded image
- Inline editing of individual fields
- Clear all metadata with one click
- Download cleaned image
- CLI: `stegasoo tools exif image.jpg [--clear] [--set Field=Value]`
- **Multi-User Support**: Admin can create up to 16 additional users
- Role-based access control (admin/user)
- Admin user management page
- Temp password generation for new users
- **Saved Channel Keys**: Users can save/manage channel keys in account page
### Changed
- **Architecture**: Consolidated `resolve_channel_key()` to library layer
- Single source of truth in `src/stegasoo/channel.py`
- CLI, API, WebUI now use thin wrappers
- **DCT Pre-Check**: Fail fast with helpful error before expensive encoding
- **Toast Notifications**: Auto-dismiss after 20 seconds with fade animation
- `RECOVERY_OBFUSCATION_KEY` constant added to `constants.py`
### Fixed
- DCT payload size error now caught early with clear message
## [4.0.2] - 2026-01-02
### Added

180
CLI.md
View File

@@ -1,11 +1,11 @@
# Stegasoo CLI Documentation (v4.0.2)
# Stegasoo CLI Documentation (v4.1.0)
Complete command-line interface reference for Stegasoo steganography operations.
## Table of Contents
- [Installation](#installation)
- [What's New in v4.0.0](#whats-new-in-v400)
- [What's New in v4.1.0](#whats-new-in-v410)
- [Quick Start](#quick-start)
- [Commands](#commands)
- [generate](#generate-command)
@@ -13,10 +13,11 @@ Complete command-line interface reference for Stegasoo steganography operations.
- [decode](#decode-command)
- [verify](#verify-command)
- [channel](#channel-command)
- [admin](#admin-command)
- [tools](#tools-command)
- [info](#info-command)
- [compare](#compare-command)
- [modes](#modes-command)
- [strip-metadata](#strip-metadata-command)
- [Channel Keys](#channel-keys)
- [Embedding Modes](#embedding-modes)
- [Security Factors](#security-factors)
@@ -65,9 +66,28 @@ stegasoo channel show
---
## What's New in v4.1.0
Version 4.1.0 adds **admin recovery** and **tools** commands:
| Feature | Description |
|---------|-------------|
| Admin recovery | Reset admin password using recovery key |
| EXIF tools | View, edit, and strip image metadata |
| Peek tool | Quick stego detection check |
| Strip tool | Remove hidden data from images |
**New commands:**
- `stegasoo admin recover` - Reset admin password with recovery key
- `stegasoo tools exif` - View/edit EXIF metadata
- `stegasoo tools peek` - Check for hidden data
- `stegasoo tools strip` - Remove stego data from image
---
## What's New in v4.0.0
Version 4.0.0 adds **channel key** support for deployment/group isolation:
Version 4.0.0 added **channel key** support for deployment/group isolation:
| Feature | Description |
|---------|-------------|
@@ -76,14 +96,6 @@ Version 4.0.0 adds **channel key** support for deployment/group isolation:
| CLI management | New `stegasoo channel` command group |
| Flexible override | Use server config, explicit key, or public mode |
**Key benefits:**
- ✅ Isolate messages between teams, deployments, or groups
- ✅ Same credentials can't decode messages from different channels
- ✅ Backward compatible (public mode = no channel key)
- ✅ Easy key distribution via environment variables or config files
**Breaking change:** v4.0.0 messages (with channel key) cannot be decoded by v3.x installations.
---
## Quick Start
@@ -495,12 +507,150 @@ Now also displays channel key status.
---
### Strip-Metadata Command
### Admin Command
Remove all metadata from an image.
Manage Web UI admin accounts and recovery.
#### Subcommands
| Subcommand | Description |
|------------|-------------|
| `recover` | Reset admin password using recovery key |
#### admin recover
Reset the admin password for a Web UI database.
```bash
stegasoo strip-metadata IMAGE [OPTIONS]
stegasoo admin recover --db PATH [OPTIONS]
```
| Option | Short | Type | Required | Description |
|--------|-------|------|----------|-------------|
| `--db` | `-d` | path | ✓ | Path to stegasoo.db file |
| `--key` | `-k` | string | | Recovery key (prompted if not provided) |
| `--password` | `-p` | string | | New password (prompted if not provided) |
**Examples:**
```bash
# Interactive mode (prompts for key and password)
stegasoo admin recover --db frontends/web/instance/stegasoo.db
# Non-interactive mode
stegasoo admin recover \
--db /path/to/stegasoo.db \
--key "XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" \
--password "NewSecurePassword123"
```
**Recovery process:**
1. The recovery key is verified against the database hash
2. If valid, the admin password is reset
3. User can now log in with the new password
**Note:** Recovery keys are instance-bound. A key from one database won't work on another.
---
### Tools Command
Image utilities and analysis tools.
#### Subcommands
| Subcommand | Description |
|------------|-------------|
| `exif` | View/edit EXIF metadata |
| `peek` | Check for hidden data |
| `strip` | Remove stego data from image |
#### tools exif
View and edit EXIF metadata in images.
```bash
stegasoo tools exif IMAGE [OPTIONS]
```
| Option | Type | Description |
|--------|------|-------------|
| `--clear` | flag | Remove all EXIF metadata |
| `--set FIELD=VALUE` | string | Set a specific EXIF field |
| `--output` / `-o` | path | Output filename (default: overwrites input) |
| `--json` | flag | Output as JSON |
**Examples:**
```bash
# View all EXIF data
stegasoo tools exif photo.jpg
# View as JSON
stegasoo tools exif photo.jpg --json
# Clear all metadata
stegasoo tools exif photo.jpg --clear -o clean.jpg
# Set specific fields
stegasoo tools exif photo.jpg \
--set "Artist=John Doe" \
--set "Copyright=2026" \
-o tagged.jpg
# Remove GPS data only
stegasoo tools exif photo.jpg \
--set "GPSLatitude=" \
--set "GPSLongitude=" \
-o no-gps.jpg
```
#### tools peek
Check if an image contains hidden Stegasoo data.
```bash
stegasoo tools peek IMAGE [OPTIONS]
```
| Option | Type | Description |
|--------|------|-------------|
| `--json` | flag | Output as JSON |
| `--quiet` / `-q` | flag | Exit code only (0=found, 1=not found) |
**Examples:**
```bash
# Check for hidden data
stegasoo tools peek suspicious.png
# Script-friendly check
if stegasoo tools peek image.png -q; then
echo "Contains hidden data"
fi
```
#### tools strip
Remove hidden stego data from an image (destructive).
```bash
stegasoo tools strip IMAGE [OPTIONS]
```
| Option | Type | Description |
|--------|------|-------------|
| `--output` / `-o` | path | Output filename |
| `--force` / `-f` | flag | Overwrite without confirmation |
**Examples:**
```bash
# Strip and save to new file
stegasoo tools strip stego.png -o clean.png
# Strip in place (with confirmation)
stegasoo tools strip stego.png
```
---

View File

@@ -435,24 +435,85 @@ pip install stegasoo[all]
### 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
# System dependencies
sudo apt-get install python3-dev libzbar0 libjpeg-dev
# Create venv with Python 3.12 (if available, or 3.11)
python3 -m venv venv
source venv/bin/activate
# Install (may take a while to compile)
pip install stegasoo[cli]
# For web/api, ensure enough RAM
pip install stegasoo[web] # Needs ~768MB free
sudo apt-get update
sudo apt-get install -y \
build-essential \
git \
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
```
**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
cd frontends/web
@@ -465,12 +526,30 @@ export STEGASOO_HOSTNAME=raspberrypi.local
# Start server
python app.py
# Access at http://<pi-ip>:5000
```
**Notes:**
- Argon2 operations will be slower on Pi due to memory-hardness
- First run will prompt you to create an admin account
- HTTPS generates a self-signed certificate (browsers will warn)
#### Verify Installation
```bash
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
View 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
View 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
View 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
View File

@@ -1,18 +1,22 @@
# Stegasoo Web UI Documentation (v4.0.2)
# Stegasoo Web UI Documentation (v4.1.0)
Complete guide for the Stegasoo web-based steganography interface.
## Table of Contents
- [Overview](#overview)
- [What's New in v4.0.2](#whats-new-in-v402)
- [What's New in v4.1.0](#whats-new-in-v410)
- [Authentication & HTTPS](#authentication--https)
- [Admin Recovery](#admin-recovery)
- [Multi-User Support](#multi-user-support)
- [Installation & Setup](#installation--setup)
- [Pages & Features](#pages--features)
- [Home Page](#home-page)
- [Generate Credentials](#generate-credentials)
- [Encode Message](#encode-message)
- [Decode Message](#decode-message)
- [Tools Page](#tools-page)
- [Account Page](#account-page)
- [About Page](#about-page)
- [Embedding Modes](#embedding-modes)
- [DCT Mode (Default)](#dct-mode-default)
@@ -54,9 +58,29 @@ Built with Flask, Bootstrap 5, and a modern dark theme.
---
## What's New in v4.1.0
Version 4.1.0 adds admin recovery, multi-user support, and new tools:
| Feature | Description |
|---------|-------------|
| **Admin Recovery** | Password reset using secure recovery key |
| **Multi-User Support** | Up to 16 users with role-based access |
| **EXIF Editor** | View, edit, and strip image metadata |
| **Saved Channel Keys** | Users can save/manage channel keys in account |
| **Toast Improvements** | Auto-dismiss after 20 seconds with fade |
**Key benefits:**
- ✅ Never get locked out - recovery key backup options
- ✅ Share access with team members (admin/user roles)
- ✅ Full EXIF metadata control in Tools page
- ✅ Persistent channel key storage per user
---
## What's New in v4.0.2
Version 4.0.2 adds authentication and HTTPS support for secure home network deployment:
Version 4.0.2 added authentication and HTTPS support:
| Feature | Description |
|---------|-------------|
@@ -64,14 +88,6 @@ Version 4.0.2 adds authentication and HTTPS support for secure home network depl
| **First-run setup** | Wizard to create admin account on first access |
| **Account management** | Change password page |
| **Optional HTTPS** | Auto-generated self-signed certificates |
| **UI improvements** | Larger QR previews, consistent panel styling |
**Key benefits:**
- ✅ Secure your Web UI with username/password
- ✅ No manual database setup - automatic on first run
- ✅ HTTPS with auto-generated certs for home networks
- ✅ Configurable via environment variables
- ✅ Improved readability of QR preview panels
---
@@ -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.
**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
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
### 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
**URL:** `/about`
@@ -543,10 +776,10 @@ If decryption fails:
Information about the Stegasoo project, security model, and credits.
Includes:
- Version information (v3.3.0)
- Recent UI improvements
- Version information (v4.1.0)
- Feature highlights
- Security model overview
- Dependency status (Argon2, QR code support)
- Dependency status (Argon2, scipy/DCT, QR code support)
---

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 37 KiB

BIN
data/WebUI_About.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
data/WebUI_Account.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 32 KiB

BIN
data/WebUI_Login.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
data/WebUI_Setup.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

361
docs/TEMPLATES.md Normal file
View 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 |

View File

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

View File

@@ -49,7 +49,6 @@ from stegasoo import (
generate_credentials,
get_channel_status,
has_argon2,
has_channel_key,
has_dct_support,
set_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.
Args:
channel_key: API parameter value
- None: Use server-configured key (auto mode)
- "": Public mode (no channel key)
- "XXXX-...": Explicit key
Wrapper around library's resolve_channel_key with HTTP exception handling.
Returns:
Resolved channel key to pass to encode/decode
@@ -418,44 +413,27 @@ def _resolve_channel_key(channel_key: str | None) -> str | None:
Raises:
HTTPException: If key format is invalid
"""
if channel_key is None:
# Auto mode - use server config
return None
from stegasoo.channel import resolve_channel_key
if channel_key == "":
# Public mode
return ""
# 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
try:
return resolve_channel_key(channel_key)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(400, str(e))
def _get_channel_info(channel_key: str | None) -> tuple[str, str | None]:
"""
Get channel mode and fingerprint for response.
Uses library's get_channel_response_info for consistent formatting.
Returns:
(mode, fingerprint) tuple
"""
if channel_key == "":
return "public", None
from stegasoo.channel import get_channel_response_info
if channel_key is not None:
# Explicit key
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
info = get_channel_response_info(channel_key)
return info["mode"], info.get("fingerprint")
# ============================================================================

View File

@@ -168,37 +168,25 @@ def resolve_channel_key_option(
"""
Resolve channel key from CLI options.
Wrapper around library's resolve_channel_key with Click exception handling.
Returns:
None: Use server-configured key (auto mode)
"": Public mode (no channel key)
str: Explicit channel key
"""
if no_channel:
return "" # Public mode
from stegasoo.channel import resolve_channel_key
if channel_file:
# Load from file
path = Path(channel_file)
if not path.exists():
raise click.ClickException(f"Channel key file not found: {channel_file}")
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
# Default: use server-configured key (auto mode)
return None
try:
return resolve_channel_key(
value=channel,
file_path=channel_file,
no_channel=no_channel,
)
except FileNotFoundError as e:
raise click.ClickException(str(e))
except ValueError as e:
raise click.ClickException(str(e))
def format_channel_status_line(quiet: bool = False) -> str | None:

View 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

View File

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

View File

@@ -30,13 +30,40 @@ import time
from pathlib import Path
from auth import (
MAX_CHANNEL_KEYS,
MAX_USERS,
admin_required,
can_create_user,
can_save_channel_key,
change_password,
create_admin_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,
has_recovery_key,
get_recovery_key_hash,
clear_recovery_key,
is_admin,
is_authenticated,
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,
verify_password,
verify_user_password,
)
from auth import (
init_app as init_auth,
@@ -144,7 +171,18 @@ subprocess_stego = SubprocessStego(timeout=180) # 3 minute timeout for large im
# ============================================================================
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
# 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)
_secret_key_file.write_text(app.secret_key)
_secret_key_file.chmod(0o600)
app.config["MAX_CONTENT_LENGTH"] = MAX_FILE_SIZE
# Auth configuration from environment
@@ -170,6 +208,13 @@ def inject_globals():
# Get channel status (v4.0.0)
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 {
"version": __version__,
"max_message_chars": MAX_MESSAGE_CHARS,
@@ -193,6 +238,10 @@ def inject_globals():
"auth_enabled": app.config.get("AUTH_ENABLED", True),
"is_authenticated": is_authenticated(),
"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,23 +282,22 @@ def resolve_channel_key_form(channel_key_value: str) -> str:
"""
Resolve channel key from form input.
Args:
channel_key_value: Form value ('auto', 'none', or explicit key)
Returns:
Value to pass to subprocess_stego ('auto', 'none', or explicit key)
Wrapper around library's resolve_channel_key for subprocess compatibility.
Returns string values for subprocess_stego ('auto', 'none', or explicit key).
"""
if not channel_key_value or channel_key_value == "auto":
return "auto"
elif channel_key_value == "none":
return "none"
else:
# Explicit key - validate format
if validate_channel_key(channel_key_value):
return channel_key_value
else:
# Invalid format, fall back to auto
from stegasoo.channel import resolve_channel_key
try:
result = resolve_channel_key(channel_key_value)
if result is None:
return "auto"
elif result == "":
return "none"
else:
return result
except (ValueError, FileNotFoundError):
# Invalid format, fall back to auto
return "auto"
def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes:
@@ -372,7 +420,7 @@ def api_channel_validate():
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:
return jsonify({"valid": False, "error": "No key provided"})
@@ -884,6 +932,25 @@ def encode_page():
flash(result.error_message, "error")
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
# Use subprocess-isolated encode to prevent crashes
if payload_type == "file" and payload_file and payload_file.filename:
@@ -1206,7 +1273,7 @@ def decode_page():
except DecryptionError:
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",
)
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)
# ============================================================================
# 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:
@@ -1315,29 +1547,31 @@ def login():
return redirect(url_for("index"))
if request.method == "POST":
username = request.form.get("username", "")
password = request.form.get("password", "")
if verify_password(password):
session["authenticated"] = True
user = verify_user_password(username, password)
if user:
login_user(user)
session.permanent = True
flash("Login successful", "success")
return redirect(url_for("index"))
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")
def logout():
"""Logout and clear session."""
session.clear()
logout_user()
flash("Logged out successfully", "success")
return redirect(url_for("index"))
@app.route("/setup", methods=["GET", "POST"])
def setup():
"""First-run setup page."""
"""First-run setup page - create admin account (Step 1)."""
if not app.config.get("AUTH_ENABLED", True):
return redirect(url_for("index"))
@@ -1349,27 +1583,235 @@ def setup():
password = request.form.get("password", "")
password_confirm = request.form.get("password_confirm", "")
if len(password) < 8:
flash("Password must be at least 8 characters", "error")
elif password != password_confirm:
if password != password_confirm:
flash("Passwords do not match", "error")
else:
try:
create_user(username, password)
session["authenticated"] = True
session.permanent = True
flash("Admin account created successfully!", "success")
return redirect(url_for("index"))
except Exception as e:
flash(f"Error creating account: {e}", "error")
success, message = create_admin_user(username, password)
if success:
# Auto-login the new admin
user = verify_user_password(username, password)
if user:
login_user(user)
session.permanent = True
# Redirect to recovery key setup (Step 2)
return redirect(url_for("setup_recovery"))
else:
flash(message, "error")
return render_template("setup.html")
@app.route("/setup/recovery", methods=["GET", "POST"])
@login_required
def setup_recovery():
"""Recovery key setup page (Step 2 of initial setup)."""
from stegasoo.recovery import generate_recovery_key, hash_recovery_key, generate_recovery_qr
import base64
# Only allow during initial setup (no recovery key yet, first admin)
if has_recovery_key():
return redirect(url_for("index"))
current_user = get_current_user()
if current_user.role != "admin":
return redirect(url_for("index"))
if request.method == "POST":
action = request.form.get("action")
if action == "skip":
# No recovery key - most secure but no way to recover
flash("Setup complete. No recovery key configured.", "warning")
return redirect(url_for("index"))
elif action == "save":
# User confirmed they saved the key
recovery_key = request.form.get("recovery_key")
if recovery_key:
key_hash = hash_recovery_key(recovery_key)
set_recovery_key_hash(key_hash)
flash("Setup complete. Recovery key saved.", "success")
return redirect(url_for("index"))
# Generate a new key to show
recovery_key = generate_recovery_key()
# Generate QR code as base64
try:
qr_bytes = generate_recovery_qr(recovery_key)
qr_base64 = base64.b64encode(qr_bytes).decode("utf-8")
except ImportError:
qr_base64 = None
return render_template(
"setup_recovery.html",
recovery_key=recovery_key,
qr_base64=qr_base64,
)
@app.route("/recover", methods=["GET", "POST"])
def recover():
"""Password recovery page - reset password using recovery key."""
# Don't show if no recovery key configured
if not get_recovery_key_hash():
flash("No recovery key configured for this instance", "error")
return redirect(url_for("login"))
if request.method == "POST":
recovery_key = request.form.get("recovery_key", "").strip()
new_password = request.form.get("new_password", "")
new_password_confirm = request.form.get("new_password_confirm", "")
if not recovery_key:
flash("Please enter your recovery key", "error")
elif new_password != new_password_confirm:
flash("Passwords do not match", "error")
elif len(new_password) < 8:
flash("Password must be at least 8 characters", "error")
else:
success, message = verify_and_reset_admin_password(recovery_key, new_password)
if success:
flash("Password reset successfully. Please login.", "success")
return redirect(url_for("login"))
else:
flash(message, "error")
return render_template("recover.html")
@app.route("/account/recovery/regenerate", methods=["GET", "POST"])
@login_required
@admin_required
def regenerate_recovery():
"""Generate a new recovery key (replaces existing one)."""
from stegasoo.recovery import generate_recovery_key, hash_recovery_key, generate_recovery_qr
import base64
if request.method == "POST":
action = request.form.get("action")
if action == "cancel":
flash("Recovery key generation cancelled", "warning")
return redirect(url_for("account"))
elif action == "save":
# User confirmed they saved the key
recovery_key = request.form.get("recovery_key")
if recovery_key:
key_hash = hash_recovery_key(recovery_key)
set_recovery_key_hash(key_hash)
flash("New recovery key saved successfully", "success")
return redirect(url_for("account"))
# Generate a new key to show
recovery_key = generate_recovery_key()
# Generate QR code as base64
try:
qr_bytes = generate_recovery_qr(recovery_key)
qr_base64 = base64.b64encode(qr_bytes).decode("utf-8")
except ImportError:
qr_base64 = None
return render_template(
"regenerate_recovery.html",
recovery_key=recovery_key,
qr_base64=qr_base64,
has_existing=has_recovery_key(),
)
@app.route("/account/recovery/disable", methods=["POST"])
@login_required
@admin_required
def disable_recovery():
"""Disable recovery key (no password reset possible)."""
if clear_recovery_key():
flash("Recovery key disabled. Password reset is no longer possible.", "warning")
else:
flash("No recovery key was configured", "error")
return redirect(url_for("account"))
@app.route("/account/recovery/stego-backup", methods=["POST"])
@login_required
@admin_required
def create_stego_backup():
"""Create stego backup - hide recovery key in an image."""
from stegasoo.recovery import create_stego_backup as make_backup
recovery_key = request.form.get("recovery_key", "")
if not recovery_key:
flash("No recovery key provided", "error")
return redirect(url_for("regenerate_recovery"))
if "carrier_image" not in request.files:
flash("No image uploaded", "error")
return redirect(url_for("regenerate_recovery"))
carrier_file = request.files["carrier_image"]
if not carrier_file.filename:
flash("No image selected", "error")
return redirect(url_for("regenerate_recovery"))
try:
carrier_data = carrier_file.read()
stego_data = make_backup(recovery_key, carrier_data)
# Return as downloadable PNG
buffer = io.BytesIO(stego_data)
return send_file(
buffer,
mimetype="image/png",
as_attachment=True,
download_name="stegasoo-recovery-backup.png",
)
except ValueError as e:
flash(str(e), "error")
return redirect(url_for("regenerate_recovery"))
@app.route("/recover/stego", methods=["POST"])
def recover_from_stego():
"""Extract recovery key from stego backup image."""
from stegasoo.recovery import extract_stego_backup
if "stego_image" not in request.files or "reference_image" not in request.files:
flash("Both stego image and reference image are required", "error")
return redirect(url_for("recover"))
stego_file = request.files["stego_image"]
reference_file = request.files["reference_image"]
if not stego_file.filename or not reference_file.filename:
flash("Both images must be selected", "error")
return redirect(url_for("recover"))
try:
stego_data = stego_file.read()
reference_data = reference_file.read()
extracted_key = extract_stego_backup(stego_data, reference_data)
if extracted_key:
# Return the key to pre-fill the recovery form
return render_template("recover.html", prefilled_key=extracted_key)
else:
flash("Could not extract recovery key. Check images are correct.", "error")
return redirect(url_for("recover"))
except Exception as e:
flash(f"Extraction failed: {e}", "error")
return redirect(url_for("recover"))
@app.route("/account", methods=["GET", "POST"])
@login_required
def account():
"""Account management page."""
current_user = get_current_user()
if request.method == "POST":
current = request.form.get("current_password", "")
new = request.form.get("new_password", "")
@@ -1378,10 +1820,219 @@ def account():
if new != new_confirm:
flash("New passwords do not match", "error")
else:
success, message = change_password(current, new)
success, message = change_password(current_user.id, current, new)
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:
print("Authentication disabled")
port = int(os.environ.get("STEGASOO_PORT", "5000"))
app.run(
host="0.0.0.0",
port=5000,
port=port,
debug=False,
ssl_context=ssl_context,
)

View File

@@ -1,17 +1,24 @@
"""
Stegasoo Authentication Module
Stegasoo Authentication Module (v4.1.0)
Single-admin authentication with Argon2 password hashing.
Uses Flask sessions for authentication state and SQLite3 for storage.
Multi-user authentication with role-based access control.
- 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 secrets
import sqlite3
import string
from dataclasses import dataclass
from pathlib import Path
from argon2 import PasswordHasher
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)
ph = PasswordHasher(
@@ -22,6 +29,26 @@ ph = PasswordHasher(
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:
"""Get database path in Flask instance folder."""
@@ -46,13 +73,65 @@ def close_db(e=None):
def init_db():
"""Initialize database schema."""
"""Initialize database schema with migration support."""
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("""
CREATE TABLE IF NOT EXISTS admin_user (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL DEFAULT 'admin',
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
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,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
@@ -60,76 +139,770 @@ def init_db():
db.commit()
def user_exists() -> bool:
"""Check if admin user has been created."""
def _migrate_from_single_user(db: sqlite3.Connection):
"""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()
result = db.execute("SELECT 1 FROM admin_user WHERE id = 1").fetchone()
return result is not None
row = db.execute(
"SELECT value FROM app_settings WHERE key = ?", (key,)
).fetchone()
return row["value"] if row else None
def create_user(username: str, password: str):
"""Create admin user (first-run setup)."""
if user_exists():
raise ValueError("Admin user already exists")
password_hash = ph.hash(password)
def set_app_setting(key: str, value: str) -> None:
"""Set an app-level setting value."""
db = get_db()
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()
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:
"""Get the admin username."""
db = get_db()
row = db.execute("SELECT username FROM admin_user WHERE id = 1").fetchone()
return row["username"] if row else "admin"
"""Get current user's username (backwards compatibility)."""
user = get_current_user()
return user.username if user else "unknown"
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()
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:
return False
return None
try:
ph.verify(row["password_hash"], password)
# Rehash if parameters changed
if ph.check_needs_rehash(row["password_hash"]):
new_hash = ph.hash(password)
db.execute(
"UPDATE admin_user SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1",
(new_hash,),
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_hash, row["id"]),
)
db.commit()
return True
return User(
id=row["id"],
username=row["username"],
role=row["role"],
created_at=row["created_at"],
)
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
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"
result = verify_user_password(user.username, password)
return result is not None
def is_authenticated() -> bool:
"""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):
@@ -142,18 +915,62 @@ def login_required(f):
return f(*args, **kwargs)
# Check for first-run setup
if not user_exists():
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 (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 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):
"""Initialize auth module with Flask app."""
app.teardown_appcontext(close_db)

View 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();
}

View 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(' &mdash; ');
}
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();
}
});

View File

@@ -119,7 +119,11 @@ const Stegasoo = {
if (isScanContainer || isPixelContainer) {
labelEl.classList.add('d-none');
} 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 keyInputId = config.keyInputId || 'channelKeyInput';
const generateBtnId = config.generateBtnId;
const serverInfoId = config.serverInfoId || 'channelServerInfo';
const select = document.getElementById(selectId);
const customInput = document.getElementById(customInputId);
const keyInput = document.getElementById(keyInputId);
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 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);
if (isCustom && keyInput) {
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
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;
},
@@ -882,7 +919,16 @@ const Stegasoo = {
}
if (btn) {
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({
selectId: 'channelSelectDec',
customInputId: 'channelCustomInputDec',
keyInputId: 'channelKeyInputDec'
keyInputId: 'channelKeyInputDec',
serverInfoId: 'channelServerInfoDec'
});
// 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';
if (btn) {
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);
}
});
},

View File

@@ -6,8 +6,8 @@
CSS Variables
---------------------------------------------------------------------------- */
:root {
--gradient-start: #667eea;
--gradient-end: #764ba2;
--gradient-start: #4a2860;
--gradient-end: #5570d4;
--bg-dark-1: #1a1a2e;
--bg-dark-2: #16213e;
--bg-dark-3: #0f3460;
@@ -16,6 +16,7 @@
--overlay-dark: rgba(0, 0, 0, 0.3);
--overlay-light: rgba(255, 255, 255, 0.05);
--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;
}
.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 {
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
@@ -442,11 +504,11 @@ footer {
/* Enhance the gradient on hover for dramatic effect */
.card-link:hover .card-header.text-center {
background: linear-gradient(135deg,
var(--gradient-start) 0%,
#5a67d8 20%,
var(--gradient-end) 80%,
#8a2be2 100%);
background: linear-gradient(135deg,
#3d2050 0%,
var(--gradient-start) 30%,
var(--gradient-end) 70%,
#6680e0 100%);
box-shadow: inset 0 0 20px rgba(255, 215, 0, 0.1);
}
@@ -489,7 +551,7 @@ footer {
.card-link:hover .feature-card {
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);
}
/* ----------------------------------------------------------------------------

View File

View File

@@ -65,7 +65,7 @@
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<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>
</li>
</ul>
@@ -100,6 +100,7 @@
<li><strong>Output:</strong> JPEG or PNG</li>
<li><strong>Color:</strong> Color or grayscale</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>
<hr>
<div class="small">
@@ -250,7 +251,7 @@
<div class="card-header">
<h5 class="mb-0">
<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>
</div>
<div class="card-body">
@@ -316,19 +317,55 @@
</div>
{% 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>
<strong>This server has a channel key configured:</strong>
<code class="ms-2">{{ channel_fingerprint }}</code>
<span class="text-muted ms-2">({{ channel_source }})</span>
</div>
{% 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>
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.
</div>
{% 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>
@@ -347,11 +384,18 @@
</tr>
</thead>
<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>
<td><strong>4.0.0</strong></td>
<td>
<strong>Channel keys</strong> for group/deployment isolation,
DCT default, simplified auth, passphrase replaces day_phrase,
DCT default, simplified auth, passphrase replaces day_phrase,
4-word default, JPEG fix, large image support, subprocess isolation, Python 3.10-3.12
</td>
</tr>
@@ -469,61 +513,174 @@
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Limits &amp; Specs</h5>
</div>
<div class="card-body">
<table class="table table-dark table-striped small">
<tbody>
<tr>
<td><i class="bi bi-file-text me-2"></i>Max text</td>
<td><strong>2M characters</strong></td>
</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>
<td><i class="bi bi-upload me-2"></i>Max upload</td>
<td><strong>30 MB</strong></td>
</tr>
<tr>
<td><i class="bi bi-clock me-2"></i>File expiry</td>
<td><strong>5 min</strong></td>
</tr>
<tr>
<td><i class="bi bi-key me-2"></i>PIN</td>
<td><strong>6-9 digits</strong></td>
</tr>
<tr>
<td><i class="bi bi-shield me-2"></i>RSA keys</td>
<td><strong>2048, 3072, 4096 bit</strong></td>
</tr>
<tr>
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>
<td><strong>3-12 words</strong> (BIP-39)</td>
</tr>
<tr>
<td><i class="bi bi-code me-2"></i>Python Version</td>
<td><strong>3.10-3.12</strong></td>
</tr>
<tr>
<td><i class="bi bi-box me-2"></i>Built with</td>
<td>Flask, Pillow, NumPy, SciPy, jpegio, cryptography, argon2-cffi</td>
</tr>
</tbody>
</table>
<!-- 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>
<tr>
<td><i class="bi bi-file-text me-2"></i>Max text</td>
<td><strong>2M characters</strong></td>
</tr>
<tr>
<td><i class="bi bi-upload me-2"></i>Max upload</td>
<td><strong>30 MB</strong></td>
</tr>
<tr>
<td><i class="bi bi-clock me-2"></i>File expiry</td>
<td><strong>5 min</strong></td>
</tr>
<tr>
<td><i class="bi bi-key me-2"></i>PIN</td>
<td><strong>6-9 digits</strong></td>
</tr>
<tr>
<td><i class="bi bi-shield me-2"></i>RSA keys</td>
<td><strong>2048, 3072, 4096 bit</strong></td>
</tr>
<tr>
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>
<td><strong>3-12 words</strong> (BIP-39)</td>
</tr>
<tr>
<td><i class="bi bi-code me-2"></i>Python Version</td>
<td><strong>3.10-3.12</strong></td>
</tr>
<tr>
<td><i class="bi bi-box me-2"></i>Built with</td>
<td>Flask, Pillow, NumPy, SciPy, jpegio, reedsolo, cryptography, argon2-cffi</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% 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 %}

View File

@@ -12,8 +12,60 @@
<div class="card-body">
<p class="text-muted mb-4">
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>
{% 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>
<form method="POST" action="{{ url_for('account') }}" id="accountForm">
@@ -65,38 +117,118 @@
</button>
</form>
<hr class="my-4">
<a href="{{ url_for('logout') }}" class="btn btn-outline-danger w-100">
<i class="bi bi-box-arrow-left me-2"></i>Logout
</a>
</div>
</div>
<!-- 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 &quot;{{ key.name }}&quot;?')">
<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">
<i class="bi bi-box-arrow-left me-2"></i>Logout
</a>
</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>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<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');
}
}
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
document.getElementById('accountForm')?.addEventListener('submit', function(e) {
const newPass = document.getElementById('newPasswordInput').value;
const confirm = document.getElementById('newPasswordConfirmInput').value;
if (newPass !== confirm) {
e.preventDefault();
alert('New passwords do not match');
}
});
function renameKey(keyId, currentName) {
document.getElementById('renameInput').value = currentName;
document.getElementById('renameForm').action = '/account/keys/' + keyId + '/rename';
new bootstrap.Modal(document.getElementById('renameModal')).show();
}
</script>
{% endblock %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -14,7 +14,10 @@
<div class="container">
<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">
<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>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
@@ -38,6 +41,9 @@
<li class="nav-item">
<a class="nav-link" href="/about"><i class="bi bi-info-circle me-1"></i> About</a>
</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 is_authenticated %}
<li class="nav-item dropdown">
@@ -46,6 +52,9 @@
</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
<li><a class="dropdown-item" href="/account"><i class="bi bi-gear me-2"></i>Account</a></li>
{% 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><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li>
</ul>
@@ -62,18 +71,23 @@
</nav>
<main class="container py-5">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<!-- 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) %}
{% 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">
<i class="bi bi-{{ 'exclamation-triangle' if category == 'error' else ('exclamation-circle' if category == 'warning' else 'check-circle') }} me-2"></i>
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<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>
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% endwith %}
</div>
{% block content %}{% endblock %}
</main>
@@ -87,6 +101,10 @@
</footer>
<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 %}
</body>
</html>

View File

@@ -120,13 +120,11 @@
<h6><i class="bi bi-check-circle me-2"></i>Message Decrypted Successfully!</h6>
</div>
<label class="form-label text-muted">Decoded Message:</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="d-flex justify-content-end mb-3">
<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'))">
<i class="bi bi-clipboard"></i> Copy
</button>
</div>
<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-3" id="decodedContent" style="white-space: pre-wrap; cursor: pointer; transition: border-color 0.2s;"
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'))"
onmouseover="this.style.borderColor = '#6c757d'"
onmouseout="this.style.borderColor = ''">{{ decoded_message }}</div>
<a href="/decode" class="btn btn-outline-light w-100">
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
@@ -344,23 +342,30 @@
<div class="security-box h-100">
<label class="form-label">
<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>
</label>
<select class="form-select" name="channel_key" id="channelSelectDec">
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</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>
<!-- Server channel indicator (compact) -->
{% if channel_configured %}
<div class="small text-success mt-2">
<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 '' }}">
{% if channel_configured and channel_fingerprint %}
<i class="bi bi-shield-lock me-1"></i>
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>

View File

@@ -411,23 +411,30 @@
<div class="security-box h-100">
<label class="form-label">
<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>
</label>
<select class="form-select" name="channel_key" id="channelSelect">
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</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>
<!-- Server channel indicator (compact) -->
{% if channel_configured %}
<div class="small text-success mt-2" id="channelServerInfo">
<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 '' }}">
{% if channel_configured and channel_fingerprint %}
<i class="bi bi-shield-lock me-1"></i>
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>

View File

@@ -74,36 +74,47 @@
</div>
</div>
<hr class="my-4">
<!-- Channel Key Generation (v4.0.0) -->
<div class="mb-4">
<label class="form-label">
<i class="bi bi-broadcast me-1"></i> Channel Key
<span class="badge bg-info ms-1">v4.0</span>
<a href="{{ url_for('about') }}#channel-keys" class="text-muted ms-2" title="Learn about channel keys">
<i class="bi bi-question-circle"></i>
</a>
</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="text" class="form-control font-monospace" id="channelKeyGenerated"
placeholder="Click Generate" readonly>
<button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn">
<i class="bi bi-shuffle me-1"></i>Generate
</button>
<button class="btn btn-outline-secondary" type="button" id="copyChannelKeyBtn" disabled title="Copy to clipboard">
<i class="bi bi-clipboard"></i>
</button>
</div>
<div class="form-text">For private groups: generate, then use <strong>Custom</strong> mode when encoding/decoding.</div>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100 mt-3">
<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 Accordion (Advanced) -->
<div class="accordion mt-4" id="advancedAccordion">
<div class="accordion-item bg-dark">
<h2 class="accordion-header">
<button class="accordion-button collapsed bg-dark text-light" type="button"
data-bs-toggle="collapse" data-bs-target="#channelKeyCollapse">
<i class="bi bi-broadcast me-2"></i>Channel Key
<span class="badge bg-info ms-2">Advanced</span>
</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">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="text" class="form-control font-monospace" id="channelKeyGenerated"
placeholder="Click Generate to create a key" readonly>
<button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn">
<i class="bi bi-shuffle me-1"></i>Generate
</button>
<button class="btn btn-outline-secondary" type="button" id="copyChannelKeyBtn" disabled title="Copy to clipboard">
<i class="bi bi-clipboard"></i>
</button>
</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>
{% else %}
<!-- Generated Credentials Display -->
@@ -498,61 +509,12 @@
{% block scripts %}
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
<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);
});
<script src="{{ url_for('static', filename='js/generate.js') }}"></script>
{% if generated %}
// ============================================================================
// GENERATE PAGE - Credential Display
// ============================================================================
<script>
// 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() {
Stegasoo.copyToClipboard(
'{{ 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() {
Stegasoo.copyToClipboard(
'{{ 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(' &mdash; ');
}
const templateIdx = (idx ?? currentStoryTemplate) % templates.length;
return templates[templateIdx](passphraseWords);
}
function toggleMemoryAid() {
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 = generateStory();
}
StegasooGenerate.toggleMemoryAid(passphraseWords);
}
function regenerateStory() {
const count = passphraseWords.length;
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);
StegasooGenerate.regenerateStory(passphraseWords);
}
// 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>
{% endif %}
{% endblock %}

View File

@@ -9,9 +9,9 @@
<div class="d-flex align-items-end justify-content-center gap-4">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="155">
<div style="margin-bottom: 40px;">
<h1 class="display-4 fw-bold mb-2">
<h1 class="display-4 fw-bold mb-2 title-gold">
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>
<p class="lead text-muted mb-0">Hide encrypted data in plain sight.</p>
</div>
@@ -162,7 +162,7 @@
<li class="mb-1">
<i class="bi bi-broadcast text-success me-2"></i>
<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>
</ul>
</div>

View File

@@ -17,7 +17,7 @@
<i class="bi bi-person me-1"></i> Username
</label>
<input type="text" name="username" class="form-control"
value="{{ username }}" readonly>
placeholder="Enter your username" required autofocus>
</div>
<div class="mb-4">
@@ -26,7 +26,7 @@
</label>
<div class="input-group">
<input type="password" name="password" class="form-control"
id="passwordInput" required autofocus>
id="passwordInput" required>
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('passwordInput', this)">
<i class="bi bi-eye"></i>
@@ -38,6 +38,12 @@
<i class="bi bi-box-arrow-in-right me-2"></i>Login
</button>
</form>
<div class="text-center mt-3">
<a href="{{ url_for('recover') }}" class="text-muted small">
<i class="bi bi-key me-1"></i> Forgot password?
</a>
</div>
</div>
</div>
</div>
@@ -45,17 +51,5 @@
{% endblock %}
{% block scripts %}
<script>
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const icon = btn.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('bi-eye', 'bi-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('bi-eye-slash', 'bi-eye');
}
}
</script>
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
{% endblock %}

View 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 %}

View 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 %}

View File

@@ -69,26 +69,8 @@
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script>
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const icon = btn.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('bi-eye', 'bi-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('bi-eye-slash', 'bi-eye');
}
}
document.getElementById('setupForm')?.addEventListener('submit', function(e) {
const pass = document.getElementById('passwordInput').value;
const confirm = document.getElementById('passwordConfirmInput').value;
if (pass !== confirm) {
e.preventDefault();
alert('Passwords do not match');
}
});
StegasooAuth.initPasswordConfirmation('setupForm', 'passwordInput', 'passwordConfirmInput');
</script>
{% endblock %}

View 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 %}

View 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, '&quot;')}" 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
View File

@@ -0,0 +1 @@
6a7378172fc0ec37143720f09a4ca34e83ec2409893aa8cd79ace5b78a64276c

BIN
instance/stegasoo.db Normal file

Binary file not shown.

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

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "stegasoo"
version = "4.0.1"
version = "4.1.1"
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
readme = "README.md"
license = "MIT"
@@ -48,10 +48,12 @@ dct = [
"numpy>=2.0.0",
"scipy>=1.10.0",
"jpegio>=0.2.0",
"reedsolo>=1.7.0",
]
cli = [
"click>=8.0.0",
"qrcode>=7.30"
"qrcode>=7.30",
"piexif>=1.1.0",
]
compression = [
"lz4>=4.0.0",
@@ -61,10 +63,12 @@ web = [
"gunicorn>=21.0.0",
"qrcode>=7.3.0",
"pyzbar>=0.1.9",
"piexif>=1.1.0",
# Include DCT support for web UI
"numpy>=2.0.0",
"scipy>=1.10.0",
"jpegio>=0.2.0",
"reedsolo>=1.7.0",
]
api = [
"fastapi>=0.100.0",
@@ -76,6 +80,7 @@ api = [
"numpy>=2.0.0",
"scipy>=1.10.0",
"jpegio>=0.2.0",
"reedsolo>=1.7.0",
]
all = [
"stegasoo[cli,web,api,dct,compression]",

128
rpi/BUILD_IMAGE.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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=""

View File

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

View File

@@ -372,6 +372,124 @@ def has_channel_key() -> bool:
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
# =============================================================================

View File

@@ -56,7 +56,14 @@ def cli(ctx, json_output):
@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(
"-f",
@@ -86,18 +93,20 @@ def cli(ctx, json_output):
@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
@click.pass_context
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.
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 .encode import encode as stegasoo_encode
from .encode import encode_file as stegasoo_encode_file
if not message and not file_payload:
raise click.UsageError("Either --message or --file is required")
@@ -123,13 +132,14 @@ def encode(
payload_type = "text"
# Get image capacity
with Image.open(image) as img:
with Image.open(carrier) as img:
width, height = img.size
capacity_bytes = (width * height * 3 // 8) - 69 # v3.2.0: corrected overhead
if dry_run:
result = {
"image": image,
"carrier": carrier,
"reference": reference,
"dimensions": f"{width}x{height}",
"capacity_bytes": capacity_bytes,
"payload_type": payload_type,
@@ -142,7 +152,8 @@ def encode(
if ctx.obj.get("json"):
click.echo(json.dumps(result, indent=2))
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"Payload: {payload_size:,} bytes ({payload_type})")
click.echo(f"Compression: {algorithm_name(compression_algo)}")
@@ -150,57 +161,159 @@ def encode(
click.echo(f"Status: {'✓ Fits' if result['fits'] else '✗ Too large'}")
return
# Actual encoding would happen here
# For now, show what would be done
output = output or f"{Path(image).stem}_encoded.png"
# Read input files
with open(reference, "rb") as f:
reference_data = f.read()
with open(carrier, "rb") as f:
carrier_data = f.read()
if ctx.obj.get("json"):
click.echo(
json.dumps(
{
"status": "success",
"input": image,
"output": output,
"payload_type": payload_type,
"compression": algorithm_name(compression_algo),
},
indent=2,
# 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:
click.echo(f"✓ Encoded {payload_type} to {output}")
click.echo(f" Compression: {algorithm_name(compression_algo)}")
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"):
click.echo(
json.dumps(
{
"status": "success",
"carrier": carrier,
"reference": reference,
"output": output,
"payload_type": payload_type,
"compression": algorithm_name(compression_algo),
},
indent=2,
)
)
else:
click.echo(f"✓ Encoded {payload_type} to {output}")
click.echo(f" Reference: {reference}")
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()
@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("--pin", prompt=True, hide_input=True, help="PIN code")
@click.option("-o", "--output", type=click.Path(), help="Output path for file payloads")
@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.
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
result = {
"status": "success",
"image": image,
"payload_type": "text",
"message": "[Decoded message would appear here]",
}
from .decode import decode as stegasoo_decode
if ctx.obj.get("json"):
click.echo(json.dumps(result, indent=2))
else:
click.echo(f"Decoded from {image}:")
click.echo(result["message"])
# Read input files
with open(image, "rb") as f:
stego_data = f.read()
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"):
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:
click.echo(f"Decoded from {image}:")
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(
"--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
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:
stegasoo generate
stegasoo generate --words 6 --pin-length 8
stegasoo generate --channel-key
"""
import secrets
@@ -451,11 +569,18 @@ def generate(ctx, words, 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"):
click.echo(json.dumps(result, indent=2))
else:
click.echo(f"Passphrase: {passphrase}")
click.echo(f"PIN: {pin}")
click.echo(f"Passphrase: {passphrase}")
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!")
@@ -489,6 +614,625 @@ def info(ctx):
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():
"""Entry point for CLI."""
cli(obj={})

View File

@@ -25,7 +25,7 @@ from pathlib import Path
# VERSION
# ============================================================================
__version__ = "4.0.2"
__version__ = "4.1.0"
# ============================================================================
# FILE FORMAT
@@ -234,6 +234,14 @@ DCT_MAGIC_HEADER = b"\x89DCT" # Magic header for DCT mode
DCT_FORMAT_VERSION = 1
DCT_STEP_SIZE = 8 # QIM quantization step
# Recovery key obfuscation - FIXED value for admin recovery QR codes
# SHA256("\x89ST3\x89DCT") - hardcoded so it never changes even if headers are added
# Used to XOR recovery keys in QR codes so they scan as gibberish
RECOVERY_OBFUSCATION_KEY = bytes.fromhex(
"d6c70bce27780db942562550e9fe1459"
"9dfdb8421f5acc79696b05db4e7afbd2"
) # 32 bytes
# Valid embedding modes
VALID_EMBED_MODES = {EMBED_MODE_LSB, EMBED_MODE_DCT}

View File

@@ -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:
1. PNG output: Scipy-based DCT transform (grayscale or color)
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:
- Chunked processing for large images to avoid heap corruption
- Process image in vertical strips to limit memory per operation
- Isolated DCT operations with fresh array allocations
- 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
@@ -102,6 +107,13 @@ JPEGIO_MAGIC = b"JPGS"
JPEGIO_MIN_COEF_MAGNITUDE = 2
JPEGIO_EMBED_CHANNEL = 0
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
MAX_CHUNK_HEIGHT = 512 # Process in 512-pixel tall strips
@@ -167,6 +179,44 @@ def has_jpegio_support() -> bool:
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
# 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)
total_bits = total_blocks * bits_per_block
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(
width=width,
@@ -538,9 +598,20 @@ def _embed_scipy_dct_safe(
flags = FLAG_COLOR_MODE if color_mode == "color" else 0
# Prepare payload bits
# Build raw payload (header + data)
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 = []
for byte in payload:
for i in range(7, -1, -1):
@@ -761,8 +832,19 @@ def _embed_jpegio(
all_positions = _jpegio_get_usable_positions(coef_array)
order = _jpegio_generate_order(len(all_positions), seed)
# Build raw payload (header + data)
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 = []
for byte in payload:
@@ -851,9 +933,12 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
del channel
gc.collect()
h, w = padded.shape
blocks_x = w // BLOCK_SIZE
num_blocks = (h // BLOCK_SIZE) * blocks_x
# Use ORIGINAL image dimensions for block calculations (must match embed)
# Embed uses width // BLOCK_SIZE, not padded width
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)
@@ -889,6 +974,69 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
del padded
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)
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)
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 = []
for pos_idx in order[: HEADER_SIZE * 8]:
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)
total_bits_needed = (HEADER_SIZE + data_length) * 8
all_bits = []
@@ -945,7 +1158,6 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
all_bits.append(coef & 1)
data_bits = all_bits[HEADER_SIZE * 8 :]
data = bytes(
[
sum(data_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))

View File

@@ -35,7 +35,7 @@ def encode(
output_format: str | None = None,
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = "png",
dct_color_mode: str = "grayscale",
dct_color_mode: str = "color",
channel_key: str | bool | None = None,
) -> EncodeResult:
"""
@@ -158,7 +158,7 @@ def encode_file(
filename_override: str | None = None,
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = "png",
dct_color_mode: str = "grayscale",
dct_color_mode: str = "color",
channel_key: str | bool | None = None,
) -> EncodeResult:
"""
@@ -215,7 +215,7 @@ def encode_bytes(
mime_type: str | None = None,
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = "png",
dct_color_mode: str = "grayscale",
dct_color_mode: str = "color",
channel_key: str | bool | None = None,
) -> EncodeResult:
"""

453
src/stegasoo/recovery.py Normal file
View 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

View File

@@ -525,7 +525,7 @@ def embed_in_image(
output_format: str | None = None,
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = DCT_OUTPUT_PNG,
dct_color_mode: str = "grayscale",
dct_color_mode: str = "color",
) -> tuple[bytes, Union[EmbedStats, "DCTEmbedStats"], str]:
"""
Embed data into an image using specified mode.
@@ -567,8 +567,8 @@ def embed_in_image(
# Validate DCT color mode (v3.0.1)
if dct_color_mode not in ("grayscale", "color"):
debug.print(f"Invalid dct_color_mode '{dct_color_mode}', defaulting to grayscale")
dct_color_mode = "grayscale"
debug.print(f"Invalid dct_color_mode '{dct_color_mode}', defaulting to color")
dct_color_mode = "color"
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
debug.print(f"Image is lossless: {is_lossless} (format: {fmt})")
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

View File

@@ -18,6 +18,159 @@ from .constants import DAY_NAMES
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:
"""
Remove all metadata (EXIF, ICC profiles, etc.) from an image.

BIN
test_data/carrier2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 MiB

BIN
test_data/rpi_20260102.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

View File

@@ -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:** _________________

View File

@@ -50,8 +50,17 @@ def png_image():
@pytest.fixture
def large_png_image():
"""Create a larger test PNG image for DCT mode."""
img = Image.new("RGB", (400, 400), color="blue")
"""Create a larger test PNG image for DCT mode.
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()
img.save(buf, format="PNG")
buf.seek(0)

BIN
xx_2.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 MiB