206 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
Aaron D. Lee
cf247d207f v4.0.2: Add Web UI authentication and optional HTTPS
Some checks failed
Release / test (push) Failing after 43s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped
- Add single-admin login with SQLite3 user storage
- First-run setup wizard for admin account creation
- Account management page for password changes
- Optional HTTPS with auto-generated self-signed certificates
- Configurable via STEGASOO_AUTH_ENABLED, STEGASOO_HTTPS_ENABLED env vars
- UI improvements: larger QR previews, consistent panel styling
- Update docker-compose.yml with auth config and persistent volumes
- Update all documentation for v4.0.2

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 20:00:47 -05:00
Aaron D. Lee
28d77957eb Bit of project management stuff. 2026-01-02 18:44:00 -05:00
Aaron D. Lee
89b4809489 Streamline README to focus on current features
Reduced from 433 to 123 lines by removing:
- Version history (now in CHANGELOG.md)
- Upgrade guides (now in CHANGELOG.md)
- Breaking changes sections
- Redundant examples
- Verbose project structure

README now focuses on: features, quick start, interfaces, security model.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:33:46 -05:00
Aaron D. Lee
79ab165b95 Add professional project structure and documentation
New files:
- LICENSE (MIT) - Required legal file
- CHANGELOG.md - Version history following Keep a Changelog
- CONTRIBUTING.md - Contributor guidelines
- CODE_OF_CONDUCT.md - Community standards
- .github/ISSUE_TEMPLATE/ - Bug report and feature request forms
- .github/PULL_REQUEST_TEMPLATE.md - PR checklist
- src/stegasoo/py.typed - PEP 561 type hint marker
- examples/ - Usage examples (basic, file embedding, channel keys)

Updated:
- README.md - Added CI status badges

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:23:08 -05:00
Aaron D. Lee
4194d6923a Remove backup files and add pattern to .gitignore
Deleted stale backup files:
- frontends/cli/main.py_old
- src/stegasoo/dct_steganography.py_old

Added gitignore patterns for common backup extensions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:13:04 -05:00
Aaron D. Lee
08e19a3bfd Remove obsolete debug/diagnostic scripts
Deleted one-off debugging scripts that are no longer needed:
- debug_jpegio.py - DCT/jpegio extraction debugger
- test_compare_capacity_flow.py - API flow crash diagnostic
- test_dct_crash.py - DCT crash diagnostic tool

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:10:29 -05:00
Aaron D. Lee
dea7472018 Remove build.sh from repo and update .gitignore
Dev convenience script should not be tracked.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:08:15 -05:00
Aaron D. Lee
e8863d15d7 Remove dev scripts from repo (already in .gitignore)
These are local dev convenience scripts that should not be tracked.
- quick_web.sh - Flask dev server launcher
- rbld_containers.sh - Docker rebuild helper

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:06:45 -05:00
Aaron D. Lee
e4256cd037 Catch ValueError in has_dct_support() for numpy incompatibility
The jpegio package raises ValueError when compiled against numpy 2.x
but numpy 1.x is installed at runtime. This catches the error gracefully
so tests don't fail on Python 3.10 environments with mismatched numpy.

Also removes stale steganography.py_old backup file.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 17:55:26 -05:00
Aaron D. Lee
948a582e5d Fix numpy binary incompatibility on Python 3.10
Add explicit numpy>=2.0.0 constraint to dct, web, and api extras.
Scipy/jpegio wheels are built against numpy 2.x, so we need to ensure
numpy 2.x is installed to avoid dtype size mismatch errors.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 17:47:44 -05:00
Aaron D. Lee
afa88bc73b Apply black formatter to all Python files
Reformatted 29 files for consistent code style and CI compliance.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 17:44:41 -05:00
Aaron D. Lee
221678d934 Moved all docs to root. 2026-01-02 17:41:27 -05:00
Aaron D. Lee
faf3efac0b Update documentation to v4.0.1
- README.md: Add v4.0.1 to version history
- API.md: Update title and version in examples to v4.0.1
- CLI.md: Update title to v4.0.1
- WEB_UI.md: Update to v4.0.1, document channel key dropdown and LED indicators

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 17:36:24 -05:00
Aaron D. Lee
9c45e0d0f8 Fix BatchCredentials tests: add required reference_photo
- Add sample_reference_photo fixture for test data
- Update sample_credentials fixture to include reference_photo
- Update all BatchCredentials test dicts to include reference_photo
- Add 'phrase' as legacy key in BatchCredentials.from_dict()

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 17:28:14 -05:00
Aaron D. Lee
6b21190f97 Lint cleanup: ruff fixes across entire codebase
- Strip trailing whitespace from all Python files
- Fix import sorting (I001) across all modules
- Convert Optional[X] to X | None syntax (UP045)
- Remove unused imports (F401)
- Convert lambda assignments to def functions (E731)
- Add TYPE_CHECKING import for forward references
- Update pyproject.toml ruff config:
  - Move select/ignore to [tool.ruff.lint] section
  - Add per-file ignores for DCT colorspace naming (N803/N806)
  - Add per-file ignores for __init__.py import structure (E402)
  - Exclude defunct test_routes.py
- Remove frontends/web/test_routes.py (defunct debug snippet)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 17:17:38 -05:00
Aaron D. Lee
d94ee7be90 Bump version to 4.0.1 with Web UI improvements
- Update version to 4.0.1 across constants.py, __init__.py, pyproject.toml, README
- Refactor channel key UI from radio buttons to select dropdown
- Add LED indicator and key capsule CSS styles
- Reorganize encode/decode forms: RSA key section moved up, PIN + Channel in row
- Streamline channel key JavaScript for dropdown-based selection

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 16:43:25 -05:00
Aaron D. Lee
6fa4b447db More snazzy 4.0 Web UI improvements. 2026-01-02 15:45:43 -05:00
Aaron D. Lee
1bb3589baf Lots of snazzy ui updates. 2026-01-02 13:18:58 -05:00
Aaron D. Lee
cfd1d8fb66 Snazzy ui updates. 2026-01-01 22:52:06 -05:00
Aaron D. Lee
c1beaf3611 Snazzy ui updates. 2026-01-01 22:51:53 -05:00
Aaron D. Lee
ef7478b30a A whoooole lotta 4.0.x fixes. 2026-01-01 22:18:13 -05:00
Aaron D. Lee
12929bf326 Release checklist and updated test scripts. 2026-01-01 14:04:55 -05:00
Aaron D. Lee
a001f227ec Bug fixes, CLI updates, docs. 2026-01-01 13:40:27 -05:00
Aaron D. Lee
3898031480 WebUI Fixes for 3.2 2026-01-01 03:39:44 -05:00
Aaron D. Lee
657cae0ae6 3.2.0 Big revamp 2026-01-01 03:14:35 -05:00
Aaron D. Lee
11fc8aab27 3.2.0 Big revamp 2026-01-01 03:14:27 -05:00
Aaron D. Lee
6d64c69f08 Home/about revamps. 2025-12-31 18:39:14 -05:00
Aaron D. Lee
6bd38ccf57 Added UNDER_THE_HOOD.md doc to explain the stego process. 2025-12-31 17:33:09 -05:00
Aaron D. Lee
d8fe25a121 Clean up old files. 2025-12-31 17:27:09 -05:00
Aaron D. Lee
3869391336 Updated screenshot for README.md 2025-12-31 17:24:23 -05:00
Aaron D. Lee
1b914f0409 Updated screenshot for README.md 2025-12-31 17:23:06 -05:00
Aaron D. Lee
2b7abc52c1 Updated screenshot for README.md 2025-12-31 17:21:07 -05:00
Aaron D. Lee
66f7d54db5 Updated encode page to not hide DCT/LSB selector, format tweaks. 2025-12-31 17:16:51 -05:00
Aaron D. Lee
34376b2dfe Version 3.0.2 full expirimental DCT support, jpegio for better jpg manipulation, etc. 2025-12-31 15:43:29 -05:00
Aaron D. Lee
4eefc946c4 Version 3.1.0 now with experimental DCT support. 2025-12-31 13:11:34 -05:00
Aaron D. Lee
e4a4a5e074 Small fixes, 2.2.1 2025-12-30 23:40:39 -05:00
Aaron D. Lee
50a7b10c63 Pinned the container, some other resiliancy stuff. 2025-12-30 23:31:17 -05:00
Aaron D. Lee
6de8130c8b Teeeeeweeeak. 2025-12-30 12:15:25 -05:00
Aaron D. Lee
5394967dce Homepage tweaks. 2025-12-30 12:02:31 -05:00
Aaron D. Lee
5274dd20ec Homepage tweaks. 2025-12-30 01:43:10 -05:00
Aaron D. Lee
1e98a13edf Fix logo stuff 2025-12-30 01:24:47 -05:00
Aaron D. Lee
a74c0b70ea Clean up tasks homie. 2025-12-30 01:00:20 -05:00
Aaron D. Lee
cf55acaf5a Back to prior logo. 2025-12-30 00:52:32 -05:00
Aaron D. Lee
aa9729b3b1 ok 2025-12-30 00:45:05 -05:00
Aaron D. Lee
5ed25f706f More CI/CD fixes and stuff (automation goodness). 2025-12-30 00:28:58 -05:00
Aaron D. Lee
72468e7972 Mypy fixes galore. 2025-12-30 00:23:09 -05:00
Aaron D. Lee
37a60d7174 Add CI/CD workflows and security policy 2025-12-30 00:08:22 -05:00
Aaron D. Lee
a7c2fcc1da Fixed container bugy nightmare (somehow). 2025-12-29 23:01:12 -05:00
Aaron D. Lee
1b9405389c Fixing container disaster. 2025-12-29 21:34:06 -05:00
Aaron D. Lee
ee44cfd46e I don't even know anymore. 2025-12-29 21:01:42 -05:00
Aaron D. Lee
40ce6d663c Revert "2.1.4 - Manual code cleanup stuff, version configued in 1ish place, etc."
This reverts commit c784140cde.
2025-12-29 21:00:44 -05:00
Aaron D. Lee
0aaeb7c6c7 Removed old dupe of dockerfile. 2025-12-29 18:57:33 -05:00
Aaron D. Lee
c34bc9ef78 Lil stuff 2025-12-29 18:20:19 -05:00
Aaron D. Lee
33dc69ce63 typo 2025-12-29 18:19:07 -05:00
Aaron D. Lee
c784140cde 2.1.4 - Manual code cleanup stuff, version configued in 1ish place, etc. 2025-12-29 18:16:10 -05:00
Aaron D. Lee
00763de780 Tagline update. 2025-12-29 17:03:51 -05:00
Aaron D. Lee
f35acfed06 Fixed QR functionality in the API container. 2025-12-29 15:08:48 -05:00
Aaron D. Lee
5217e86ca9 More README nonsense. 2025-12-29 14:50:03 -05:00
Aaron D. Lee
b1c343bfe3 More README nonsense. 2025-12-29 14:49:19 -05:00
Aaron D. Lee
e2a2d979f8 More README nonsense. 2025-12-29 14:47:57 -05:00
Aaron D. Lee
9aef50dbed More README nonsense. 2025-12-29 14:43:41 -05:00
Aaron D. Lee
b836692635 More README nonsense. 2025-12-29 14:40:48 -05:00
Aaron D. Lee
e8b23b0a87 More README nonsense. 2025-12-29 14:39:50 -05:00
Aaron D. Lee
79fb9f21f1 More README nonsense. 2025-12-29 14:37:38 -05:00
Aaron D. Lee
9ce4c3e385 More README nonsense. 2025-12-29 14:36:43 -05:00
Aaron D. Lee
63e2735d96 More README nonsense. 2025-12-29 14:31:38 -05:00
Aaron D. Lee
fdaffbd3bb More README nonsense. 2025-12-29 14:30:21 -05:00
Aaron D. Lee
0c7fa647f1 More README nonsense. 2025-12-29 14:29:07 -05:00
Aaron D. Lee
a5ee25b297 More README nonsense. 2025-12-29 14:25:48 -05:00
Aaron D. Lee
6bd18fd013 More README nonsense. 2025-12-29 12:44:29 -05:00
Aaron D. Lee
7c84e25378 More README nonsense. 2025-12-29 12:42:03 -05:00
Aaron D. Lee
e43b4defdd More hardcoded crap. 2025-12-29 12:32:24 -05:00
Aaron D. Lee
a7df211242 README formatting. 2025-12-29 12:28:30 -05:00
Aaron D. Lee
c9741c1da6 README formatting. 2025-12-29 12:23:04 -05:00
Aaron D. Lee
a318f16a0d New README 2025-12-29 12:19:03 -05:00
Aaron D. Lee
3bad80361a New README 2025-12-29 12:14:13 -05:00
Aaron D. Lee
9559a3c39f About page enhancements. 2025-12-29 12:08:15 -05:00
Aaron D. Lee
8b69c5d9e9 QR tweaks: WebUI only creates QR's with zipped keys now. 2025-12-29 11:45:01 -05:00
Aaron D. Lee
0dc44e2d7b UI tweaks: gently nudges user to leverage rotating daily phrases. 2025-12-29 11:31:01 -05:00
Aaron D. Lee
5bf477f2ad Other tweaks and such. 2025-12-29 09:13:24 -05:00
Aaron D. Lee
3c759c15d7 Other tweaks and such. 2025-12-29 00:04:47 -05:00
Aaron D. Lee
d937a43c13 Fixed more info bugs, need to use more constants, lol. 2025-12-28 22:55:23 -05:00
Aaron D. Lee
1c9c51e016 More info tweaks on about and index. 2025-12-28 22:36:10 -05:00
Aaron D. Lee
749fa00639 Merge branch 'main' of github.com:adlee-was-taken/stegasoo 2025-12-28 22:23:39 -05:00
Aaron D. Lee
f12544fd7f About and front page overhauls, other crap. 2025-12-28 22:23:20 -05:00
125 changed files with 32177 additions and 6149 deletions

228
.github/CI_CD_PRIMER.md vendored Normal file
View File

@@ -0,0 +1,228 @@
# CI/CD Primer for Stegasoo
## What is CI/CD?
**CI** = Continuous Integration
**CD** = Continuous Deployment
Think of it as a robot assistant that automatically:
1. **Tests your code** every time you push
2. **Checks formatting** so code stays consistent
3. **Publishes releases** when you tag a version
```
You push code → GitHub runs workflows → You get ✓ or ✗
```
---
## How It Works
### The Trigger
When you `git push` or create a Pull Request, GitHub looks for workflow files in:
```
.github/workflows/*.yml
```
Each `.yml` file defines a workflow - a series of steps to run.
### The Runners
GitHub provides free Linux/Mac/Windows VMs that:
1. Clone your repo
2. Set up Python
3. Run your commands
4. Report success/failure
You don't manage servers - GitHub does.
---
## Your Workflows
### 1. `test.yml` - Run Tests on Every Push
**When it runs:** Every push, every PR
**What it does:**
```
1. Spins up Ubuntu VM
2. Installs Python 3.10, 3.11, 3.12
3. Installs your package + dependencies
4. Runs pytest
5. Reports pass/fail
```
**You'll see:** Green ✓ or red ✗ on your commits
### 2. `lint.yml` - Check Code Style
**When it runs:** Every push, every PR
**What it does:**
```
1. Runs ruff (fast Python linter)
2. Checks black formatting
3. Fails if code isn't formatted
```
**Why:** Keeps code consistent, catches common bugs
### 3. `release.yml` - Publish to PyPI
**When it runs:** Only when you create a version tag
**What it does:**
```
1. Builds the package (wheel + sdist)
2. Uploads to PyPI
```
**You trigger it by:**
```bash
git tag v2.2.0
git push origin v2.2.0
```
---
## Day-to-Day Usage
### Normal Development
```bash
# Make changes
git add .
git commit -m "Add new feature"
git push
```
Then check GitHub → Actions tab → See if tests pass.
### If Tests Fail
1. Click the failed workflow
2. Click the failed job
3. Read the error log
4. Fix locally, push again
### Making a Release
```bash
# 1. Update version in pyproject.toml and constants.py
# 2. Commit the version bump
git add .
git commit -m "Bump version to 2.2.1"
git push
# 3. Create and push a tag
git tag v2.2.1
git push origin v2.2.1
# 4. GitHub automatically publishes to PyPI
```
---
## Reading the GitHub UI
### Actions Tab
```
Repository → Actions → [List of workflow runs]
```
Each run shows:
- ✓ Green checkmark = passed
- ✗ Red X = failed
- 🟡 Yellow dot = running
### Pull Request Checks
When you open a PR, you'll see:
```
┌─────────────────────────────────────────┐
│ All checks have passed │
│ ✓ test (3.10) — 45s │
│ ✓ test (3.11) — 42s │
│ ✓ test (3.12) — 44s │
│ ✓ lint — 12s │
└─────────────────────────────────────────┘
```
---
## Setting Up PyPI Publishing
For `release.yml` to work, you need to add a PyPI API token:
### One-Time Setup
1. **Create PyPI account** at https://pypi.org/account/register/
2. **Generate API token:**
- PyPI → Account Settings → API tokens
- Create token (scope: entire account or just stegasoo)
- Copy the token (starts with `pypi-`)
3. **Add to GitHub:**
- GitHub repo → Settings → Secrets and variables → Actions
- New repository secret
- Name: `PYPI_API_TOKEN`
- Value: paste the token
Now `release.yml` can publish automatically.
---
## Common Scenarios
### "Tests pass locally but fail in CI"
Usually means:
- Missing dependency in `pyproject.toml`
- Hardcoded path that doesn't exist in CI
- Test relies on local file
### "Lint is failing"
Run locally to see/fix:
```bash
# Check issues
ruff check src/
# Auto-fix what's possible
ruff check --fix src/
# Format code
black src/
```
### "I want to skip CI for a commit"
Add `[skip ci]` to commit message:
```bash
git commit -m "Update README [skip ci]"
```
---
## Costs
GitHub Actions is **free** for public repos.
For private repos: 2,000 minutes/month free, then paid.
---
## Summary
| Action | What Happens |
|--------|--------------|
| `git push` | Tests + lint run automatically |
| Open PR | Tests must pass before merge |
| `git tag v*` | Publishes to PyPI |
| Check results | GitHub → Actions tab |
That's it! Push code, check for green checkmarks.

98
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,98 @@
name: Bug Report
description: Report a bug or unexpected behavior
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the form below.
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is.
placeholder: Describe the bug...
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Run command '...'
2. Upload image '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
validations:
required: true
- type: dropdown
id: interface
attributes:
label: Interface
description: Which interface are you using?
options:
- CLI
- Web UI
- REST API
- Python Library
validations:
required: true
- type: input
id: version
attributes:
label: Stegasoo Version
description: Run `stegasoo --version` or check the web UI footer
placeholder: "4.0.1"
validations:
required: true
- type: input
id: python-version
attributes:
label: Python Version
description: Run `python --version`
placeholder: "3.11.0"
validations:
required: true
- type: input
id: os
attributes:
label: Operating System
placeholder: "Ubuntu 22.04 / Windows 11 / macOS 14"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Error Logs
description: Paste any relevant error messages or tracebacks.
render: shell
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, screenshots, or files here.

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Security Vulnerability
url: https://github.com/adlee-was-taken/stegasoo/security/advisories/new
about: Report security vulnerabilities privately
- name: Documentation
url: https://github.com/adlee-was-taken/stegasoo#readme
about: Check the documentation before opening an issue

View File

@@ -0,0 +1,62 @@
name: Feature Request
description: Suggest a new feature or enhancement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a feature! Please fill out the form below.
- type: textarea
id: problem
attributes:
label: Problem Statement
description: Is your feature request related to a problem? Describe it.
placeholder: I'm always frustrated when...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like.
placeholder: I would like to be able to...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Describe any alternative solutions or features you've considered.
- type: dropdown
id: interface
attributes:
label: Affected Interface(s)
description: Which interface(s) would this feature affect?
multiple: true
options:
- CLI
- Web UI
- REST API
- Python Library
- Core Library
- type: dropdown
id: priority
attributes:
label: Priority
description: How important is this feature to you?
options:
- Nice to have
- Would improve my workflow
- Critical for my use case
- type: textarea
id: context
attributes:
label: Additional Context
description: Add any other context, mockups, or examples here.

46
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,46 @@
## Description
<!-- Describe your changes in detail -->
## Type of Change
<!-- Mark the relevant option with an 'x' -->
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)
- [ ] CI/CD or build changes
## Related Issues
<!-- Link any related issues here -->
Fixes #
## Testing
<!-- Describe how you tested your changes -->
- [ ] I have added tests that prove my fix/feature works
- [ ] Existing tests pass locally with my changes
- [ ] I have tested manually (describe below)
### Manual Testing Steps
<!-- If applicable, describe manual testing performed -->
## Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have updated the documentation accordingly
- [ ] I have updated CHANGELOG.md (if user-facing changes)
- [ ] My changes generate no new warnings
- [ ] Any dependent changes have been merged and published
## Screenshots
<!-- If applicable, add screenshots to help explain your changes -->

63
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
# Check code style and formatting
name: Lint
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
jobs:
lint:
runs-on: ubuntu-latest
steps:
# 1. Get the code
- name: Checkout code
uses: actions/checkout@v4
# 2. Set up Python
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
# 3. Install linting tools
- name: Install linters
run: |
python -m pip install --upgrade pip
pip install ruff black
# 4. Run ruff (fast linter - catches bugs and style issues)
- name: Run ruff
run: |
ruff check src/ tests/ frontends/
# 5. Check black formatting (doesn't modify, just checks)
- name: Check black formatting
run: |
black --check src/ tests/ frontends/
# Type checking (optional but helpful)
typecheck:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install mypy
- name: Run mypy
run: |
mypy src/stegasoo --ignore-missing-imports
continue-on-error: true # Don't fail build on type errors (yet)

95
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
# Publish to PyPI when a version tag is pushed
name: Release
on:
push:
tags:
- 'v*' # Triggers on v1.0.0, v2.1.0, etc.
jobs:
# First, run tests to make sure everything works
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libzbar0
- name: Install package
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Run tests
run: pytest
# Then build and publish
publish:
needs: test # Only run if tests pass
runs-on: ubuntu-latest
# Required for PyPI trusted publishing (recommended)
permissions:
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install build tools
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Build package
run: python -m build
- name: Check package
run: twine check dist/*
# Option 1: Trusted Publishing (recommended, no token needed)
# Set this up at: https://pypi.org/manage/project/stegasoo/settings/publishing/
- name: Publish to PyPI (Trusted Publishing)
uses: pypa/gh-action-pypi-publish@release/v1
# No token needed if you configure trusted publishing on PyPI
# Option 2: API Token (uncomment if not using trusted publishing)
# - name: Publish to PyPI (API Token)
# env:
# TWINE_USERNAME: __token__
# TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
# run: twine upload dist/*
# Create GitHub Release with changelog
github-release:
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write # Needed to create releases
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
generate_release_notes: true
files: |
dist/*

53
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
# Run tests on every push and pull request
name: Tests
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false # Don't cancel other jobs if one fails
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
# 1. Get the code
- name: Checkout code
uses: actions/checkout@v4
# 2. Set up Python
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
# 3. Install system dependencies (for pyzbar QR reading)
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libzbar0
# 4. Install the package with all dependencies
- name: Install package
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
# 5. Run tests with coverage
- name: Run tests
run: |
pytest --cov=stegasoo --cov-report=xml --cov-report=term-missing
# 6. Upload coverage report (optional - integrates with codecov.io)
- name: Upload coverage
uses: codecov/codecov-action@v4
if: matrix.python-version == '3.11' # Only upload once
with:
files: ./coverage.xml
fail_ci_if_error: false # Don't fail if codecov is down

24
.gitignore vendored
View File

@@ -35,6 +35,12 @@ old_files/
*.swp
*.swo
# Backup files
*_old
*_old.*
*.bak
*.orig
# Testing
.pytest_cache/
.coverage
@@ -48,7 +54,7 @@ htmlcov/
# Environment
.env
.env.*
.env.local
*.log
# Distribution
@@ -56,4 +62,18 @@ htmlcov/
*.spec
# Output test files.
*.png
test_data/*.png
# Dev scripts (local convenience scripts)
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

40
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,40 @@
# Pre-commit hooks - run formatting/linting before each commit
# Install: pip install pre-commit && pre-commit install
# Manual run: pre-commit run --all-files
repos:
# Ruff - fast Python linter (replaces flake8, isort, etc.)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.6
hooks:
- id: ruff
args: [--fix] # Auto-fix what's possible
- id: ruff-format # Ruff's formatter (alternative to black)
# Black - code formatter (comment out if using ruff-format above)
# - repo: https://github.com/psf/black
# rev: 23.11.0
# hooks:
# - id: black
# Basic file hygiene
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace # Remove trailing spaces
- id: end-of-file-fixer # Ensure newline at EOF
- id: check-yaml # Validate YAML
- id: check-toml # Validate TOML
- id: check-added-large-files # Prevent giant files
args: ['--maxkb=1000']
- id: check-merge-conflict # No merge conflict markers
- id: debug-statements # No print() or pdb left behind
# Security checks
- repo: https://github.com/PyCQA/bandit
rev: 1.7.6
hooks:
- id: bandit
args: ["-c", "pyproject.toml"]
additional_dependencies: ["bandit[toml]"]
exclude: tests/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

882
API.md Normal file
View File

@@ -0,0 +1,882 @@
# Stegasoo REST API Documentation (v4.0.2)
Complete REST API reference for Stegasoo steganography operations.
## Table of Contents
- [Overview](#overview)
- [What's New in v4.0.0](#whats-new-in-v400)
- [Installation](#installation)
- [Base URL](#base-url)
- [Endpoints](#endpoints)
- [GET /](#get--status)
- [GET /modes](#get-modes)
- [GET /channel/status](#get-channelstatus)
- [POST /channel/generate](#post-channelgenerate)
- [POST /channel/set](#post-channelset)
- [DELETE /channel](#delete-channel)
- [POST /generate](#post-generate)
- [POST /encode](#post-encode-json)
- [POST /encode/file](#post-encodefile)
- [POST /encode/multipart](#post-encodemultipart)
- [POST /decode](#post-decode-json)
- [POST /decode/multipart](#post-decodemultipart)
- [POST /compare](#post-compare)
- [POST /will-fit](#post-will-fit)
- [POST /image/info](#post-imageinfo)
- [Channel Keys](#channel-keys)
- [Data Models](#data-models)
- [Error Handling](#error-handling)
- [Code Examples](#code-examples)
---
## Overview
The Stegasoo REST API provides programmatic access to all steganography operations:
- **Generate** credentials (passphrase, PINs, RSA keys)
- **Encode** messages or files into images (LSB or DCT mode)
- **Decode** messages or files from images (auto-detects mode)
- **Channel keys** for deployment/group isolation (v4.0.0)
- **Analyze** image capacity and compare modes
The API supports both JSON (base64-encoded images) and multipart form data (direct file uploads).
---
## What's New in v4.0.0
Version 4.0.0 adds **channel key** support for deployment/group isolation:
| Feature | Description |
|---------|-------------|
| Channel keys | 256-bit keys that isolate message groups |
| New endpoints | `/channel/status`, `/channel/generate`, `/channel/set`, `DELETE /channel` |
| Encode/decode param | `channel_key` parameter on all encode/decode endpoints |
| Response headers | `X-Stegasoo-Channel-Mode` and `X-Stegasoo-Channel-Fingerprint` |
**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)
**Breaking change:** v4.0.0 messages (with channel key) cannot be decoded by v3.x installations.
---
## Installation
### From PyPI
```bash
pip install stegasoo[api]
```
### Running the Server
**Development:**
```bash
cd frontends/api
python main.py
```
**Production:**
```bash
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
```
**Docker with channel key:**
```bash
STEGASOO_CHANNEL_KEY=XXXX-XXXX-... docker-compose up api
```
---
## Base URL
| Environment | URL |
|-------------|-----|
| Local Development | `http://localhost:8000` |
| Docker | `http://localhost:8000` |
| Production | Configure as needed |
---
## Endpoints
### GET / (Status)
Check API status and configuration.
#### Response
```json
{
"version": "4.0.2",
"has_argon2": true,
"has_qrcode_read": true,
"has_dct": true,
"max_payload_kb": 500,
"available_modes": ["lsb", "dct"],
"dct_features": {
"output_formats": ["png", "jpeg"],
"color_modes": ["grayscale", "color"]
},
"channel": {
"mode": "private",
"configured": true,
"fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456",
"source": "~/.stegasoo/channel.key"
},
"breaking_changes": {
"v4_channel_key": "Messages encoded with channel key require same key to decode",
"format_version": 5,
"backward_compatible": false
}
}
```
---
### GET /modes
Get available embedding modes and channel status.
#### Response
```json
{
"lsb": {
"available": true,
"name": "Spatial LSB",
"description": "Embed in pixel LSBs, outputs PNG/BMP",
"output_format": "PNG (color)",
"capacity_ratio": "100%"
},
"dct": {
"available": true,
"name": "DCT Domain",
"output_formats": ["png", "jpeg"],
"color_modes": ["grayscale", "color"],
"capacity_ratio": "~20% of LSB",
"requires": "scipy"
},
"channel": {
"mode": "private",
"configured": true,
"fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456"
}
}
```
---
### GET /channel/status
Get current channel key status. **New in v4.0.0.**
#### Query Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `reveal` | boolean | `false` | Include full key in response |
#### Response
```json
{
"mode": "private",
"configured": true,
"fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456",
"source": "~/.stegasoo/channel.key",
"key": null
}
```
With `reveal=true`:
```json
{
"mode": "private",
"configured": true,
"fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456",
"source": "~/.stegasoo/channel.key",
"key": "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
}
```
#### cURL Example
```bash
# Show status
curl http://localhost:8000/channel/status
# Reveal full key
curl "http://localhost:8000/channel/status?reveal=true"
```
---
### POST /channel/generate
Generate a new channel key. **New in v4.0.0.**
#### Query Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `save` | boolean | `false` | Save to user config |
| `save_project` | boolean | `false` | Save to project config |
#### Response
```json
{
"key": "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456",
"fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456",
"saved": true,
"save_location": "~/.stegasoo/channel.key"
}
```
#### cURL Examples
```bash
# Just generate (don't save)
curl -X POST http://localhost:8000/channel/generate
# Generate and save to user config
curl -X POST "http://localhost:8000/channel/generate?save=true"
# Generate and save to project config
curl -X POST "http://localhost:8000/channel/generate?save_project=true"
```
---
### POST /channel/set
Set/save a channel key to config. **New in v4.0.0.**
#### Request Body
```json
{
"key": "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456",
"location": "user"
}
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `key` | string | required | Channel key |
| `location` | string | `"user"` | `"user"` or `"project"` |
#### Response
```json
{
"success": true,
"location": "~/.stegasoo/channel.key",
"fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456"
}
```
---
### DELETE /channel
Clear channel key from config. **New in v4.0.0.**
#### Query Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `location` | string | `"user"` | `"user"`, `"project"`, or `"all"` |
#### Response
```json
{
"success": true,
"mode": "public",
"still_configured": false,
"remaining_source": null
}
```
#### cURL Example
```bash
# Clear user config
curl -X DELETE http://localhost:8000/channel
# Clear project config
curl -X DELETE "http://localhost:8000/channel?location=project"
# Clear all
curl -X DELETE "http://localhost:8000/channel?location=all"
```
---
### POST /generate
Generate credentials for encoding/decoding.
#### Request Body
```json
{
"use_pin": true,
"use_rsa": false,
"pin_length": 6,
"rsa_bits": 2048,
"words_per_passphrase": 4
}
```
#### Response
```json
{
"passphrase": "abandon ability able about",
"pin": "847293",
"rsa_key_pem": null,
"entropy": {
"passphrase": 44,
"pin": 19,
"rsa": 0,
"total": 63
}
}
```
---
### POST /encode (JSON)
Encode a text message into an image.
#### Request Body
```json
{
"message": "Secret message here",
"reference_photo_base64": "iVBORw0KGgo...",
"carrier_image_base64": "iVBORw0KGgo...",
"passphrase": "apple forest thunder mountain",
"pin": "123456",
"rsa_key_base64": null,
"rsa_password": null,
"channel_key": null,
"embed_mode": "lsb",
"dct_output_format": "png",
"dct_color_mode": "grayscale"
}
```
#### Channel Key Parameter (v4.0.0)
| Value | Effect |
|-------|--------|
| `null` | Auto mode - use server-configured key |
| `""` (empty string) | Public mode - no channel isolation |
| `"XXXX-XXXX-..."` | Explicit key - use this specific key |
#### Response
```json
{
"stego_image_base64": "iVBORw0KGgo...",
"filename": "a1b2c3d4.png",
"capacity_used_percent": 12.4,
"embed_mode": "lsb",
"output_format": "png",
"color_mode": "color",
"channel_mode": "private",
"channel_fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456"
}
```
---
### POST /encode/file
Encode a file into an image (JSON with base64).
Same parameters as `/encode`, plus:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `file_data_base64` | string | ✓ | Base64-encoded file data |
| `filename` | string | ✓ | Original filename |
| `mime_type` | string | | MIME type |
---
### POST /encode/multipart
Encode using multipart form data (file uploads).
#### Form Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `passphrase` | string | ✓ | Passphrase |
| `reference_photo` | file | ✓ | Reference photo |
| `carrier` | file | ✓ | Carrier image |
| `message` | string | * | Text message |
| `payload_file` | file | * | Binary file to embed |
| `pin` | string | | Static PIN |
| `rsa_key` | file | | RSA key (.pem) |
| `rsa_key_qr` | file | | RSA key (QR code image) |
| `rsa_password` | string | | RSA key password |
| `channel_key` | string | | `"auto"` (default), `"none"=public`, or explicit key |
| `embed_mode` | string | | `"lsb"` or `"dct"` |
| `dct_output_format` | string | | `"png"` or `"jpeg"` |
| `dct_color_mode` | string | | `"grayscale"` or `"color"` |
\* Provide either `message` or `payload_file`
#### Channel Key in Multipart
For form data, the channel_key field uses strings:
| Value | Effect |
|-------|--------|
| `"auto"` | Use server config (default) |
| `"none"` | Public mode |
| `"XXXX-XXXX-..."` | Explicit key |
#### Response
Returns the stego image directly with headers:
```http
HTTP/1.1 200 OK
Content-Type: image/png
Content-Disposition: attachment; filename=a1b2c3d4.png
X-Stegasoo-Capacity-Percent: 12.4
X-Stegasoo-Embed-Mode: lsb
X-Stegasoo-Channel-Mode: private
X-Stegasoo-Channel-Fingerprint: ABCD-••••-...-3456
X-Stegasoo-Version: 4.0.2
<binary image data>
```
#### cURL Examples
```bash
# Encode with auto channel key (default)
curl -X POST http://localhost:8000/encode/multipart \
-F "passphrase=apple forest thunder mountain" \
-F "pin=123456" \
-F "message=Secret message" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
# Encode with explicit channel key
curl -X POST http://localhost:8000/encode/multipart \
-F "passphrase=words here" \
-F "pin=123456" \
-F "message=Team message" \
-F "channel_key=ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
# Encode in public mode (no channel isolation)
curl -X POST http://localhost:8000/encode/multipart \
-F "passphrase=words here" \
-F "pin=123456" \
-F "message=Public message" \
-F "channel_key=none" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
```
---
### POST /decode (JSON)
Decode a message or file from a stego image.
#### Request Body
```json
{
"stego_image_base64": "iVBORw0KGgo...",
"reference_photo_base64": "iVBORw0KGgo...",
"passphrase": "apple forest thunder mountain",
"pin": "123456",
"rsa_key_base64": null,
"rsa_password": null,
"channel_key": null,
"embed_mode": "auto"
}
```
#### Response (Text)
```json
{
"payload_type": "text",
"message": "Secret message here",
"file_data_base64": null,
"filename": null,
"mime_type": null
}
```
#### Response (File)
```json
{
"payload_type": "file",
"message": null,
"file_data_base64": "UEsDBBQAAAA...",
"filename": "document.pdf",
"mime_type": "application/pdf"
}
```
---
### POST /decode/multipart
Decode using multipart form data.
#### Form Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `passphrase` | string | ✓ | Passphrase |
| `reference_photo` | file | ✓ | Reference photo |
| `stego_image` | file | ✓ | Stego image to decode |
| `pin` | string | | Static PIN |
| `rsa_key` | file | | RSA key (.pem) |
| `rsa_key_qr` | file | | RSA key (QR code image) |
| `rsa_password` | string | | RSA key password |
| `channel_key` | string | | `"auto"` (default), `"none"=public`, or explicit key |
| `embed_mode` | string | | `"auto"`, `"lsb"`, or `"dct"` |
---
## Channel Keys
### Overview
Channel keys provide **deployment/group isolation**. Messages encoded with a channel key can only be decoded with the same key.
### Key Format
```
ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
└──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘
8 groups of 4 alphanumeric characters (256 bits)
```
### Storage Locations
Keys are checked in order:
| Priority | Location | Best For |
|----------|----------|----------|
| 1 | `STEGASOO_CHANNEL_KEY` env var | Docker, CI/CD |
| 2 | `./config/channel.key` | Project-specific |
| 3 | `~/.stegasoo/channel.key` | User default |
### API Parameter Values
#### JSON Endpoints (`/encode`, `/decode`)
| Value | Effect |
|-------|--------|
| `null` | Auto - use server config |
| `""` | Public mode |
| `"XXXX-..."` | Explicit key |
#### Multipart Endpoints (`/encode/multipart`, `/decode/multipart`)
| Value | Effect |
|-------|--------|
| `"auto"` | Use server config (default) |
| `"none"` | Public mode |
| `"XXXX-..."` | Explicit key |
### Workflow Example
```bash
# 1. Generate a channel key for the team
KEY=$(curl -s -X POST http://localhost:8000/channel/generate | jq -r '.key')
echo "Team key: $KEY"
# 2. Distribute to team members (securely!)
# 3. Each deployment sets the key
export STEGASOO_CHANNEL_KEY=$KEY
# 4. Encode - automatically uses server key
curl -X POST http://localhost:8000/encode/multipart \
-F "passphrase=team passphrase" \
-F "pin=123456" \
-F "message=Team secret" \
-F "reference_photo=@ref.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
# 5. Decode - automatically uses server key
curl -X POST http://localhost:8000/decode/multipart \
-F "passphrase=team passphrase" \
-F "pin=123456" \
-F "reference_photo=@ref.jpg" \
-F "stego_image=@stego.png"
```
---
## Data Models
### ChannelStatusResponse
```json
{
"mode": "private",
"configured": true,
"fingerprint": "ABCD-••••-...-3456",
"source": "~/.stegasoo/channel.key",
"key": "ABCD-1234-..."
}
```
### EncodeResponse (v4.0.0)
```json
{
"stego_image_base64": "string",
"filename": "string",
"capacity_used_percent": 12.4,
"embed_mode": "lsb",
"output_format": "png",
"color_mode": "color",
"channel_mode": "private",
"channel_fingerprint": "ABCD-••••-...-3456"
}
```
### DecodeResponse
```json
{
"payload_type": "text",
"message": "string",
"file_data_base64": null,
"filename": null,
"mime_type": null
}
```
---
## Error Handling
### HTTP Status Codes
| Code | Meaning | Use Case |
|------|---------|----------|
| 200 | OK | Successful operation |
| 400 | Bad Request | Invalid input, capacity error, invalid channel key |
| 401 | Unauthorized | Decryption failed, channel key mismatch |
| 500 | Internal Error | Unexpected server error |
| 501 | Not Implemented | Feature unavailable |
### Channel Key Errors
| Status | Error | Cause |
|--------|-------|-------|
| 400 | "Invalid channel key format" | Key doesn't match `XXXX-XXXX-...` pattern |
| 401 | "Message encoded with channel key but none configured" | Need to provide channel key |
| 401 | "Message encoded without channel key" | Use `channel_key=""` or `"none"` |
---
## Code Examples
### Python
```python
import requests
BASE_URL = "http://localhost:8000"
# Check channel status
status = requests.get(f"{BASE_URL}/channel/status").json()
print(f"Channel mode: {status['mode']}")
print(f"Fingerprint: {status.get('fingerprint', 'N/A')}")
# Generate channel key
response = requests.post(f"{BASE_URL}/channel/generate?save=true")
key_info = response.json()
print(f"Generated: {key_info['fingerprint']}")
# Encode with channel key (auto from server)
with open("ref.jpg", "rb") as ref, open("carrier.png", "rb") as carrier:
response = requests.post(f"{BASE_URL}/encode/multipart", files={
"reference_photo": ref,
"carrier": carrier,
}, data={
"message": "Team secret",
"passphrase": "apple forest thunder",
"pin": "123456",
# channel_key defaults to "auto" (use server config)
})
with open("stego.png", "wb") as f:
f.write(response.content)
print(f"Channel mode: {response.headers.get('X-Stegasoo-Channel-Mode')}")
# Encode with explicit channel key
with open("ref.jpg", "rb") as ref, open("carrier.png", "rb") as carrier:
response = requests.post(f"{BASE_URL}/encode/multipart", files={
"reference_photo": ref,
"carrier": carrier,
}, data={
"message": "Using explicit key",
"passphrase": "words here",
"pin": "123456",
"channel_key": "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456",
})
# Decode
with open("ref.jpg", "rb") as ref, open("stego.png", "rb") as stego:
response = requests.post(f"{BASE_URL}/decode/multipart", files={
"reference_photo": ref,
"stego_image": stego,
}, data={
"passphrase": "apple forest thunder",
"pin": "123456",
# channel_key defaults to "auto"
})
result = response.json()
print(f"Decoded: {result.get('message')}")
```
### JavaScript
```javascript
const axios = require('axios');
const FormData = require('form-data');
const fs = require('fs');
const BASE_URL = 'http://localhost:8000';
async function main() {
// Check channel status
const status = await axios.get(`${BASE_URL}/channel/status`);
console.log('Channel:', status.data.mode);
// Encode with auto channel key
const form = new FormData();
form.append('passphrase', 'apple forest thunder');
form.append('pin', '123456');
form.append('message', 'Secret');
form.append('reference_photo', fs.createReadStream('ref.jpg'));
form.append('carrier', fs.createReadStream('carrier.png'));
// channel_key defaults to "auto" (use server config)
const response = await axios.post(`${BASE_URL}/encode/multipart`, form, {
headers: form.getHeaders(),
responseType: 'arraybuffer'
});
fs.writeFileSync('stego.png', response.data);
console.log('Channel mode:', response.headers['x-stegasoo-channel-mode']);
}
main();
```
### cURL / Bash
```bash
#!/bin/bash
BASE_URL="http://localhost:8000"
# Check channel status
echo "Channel status:"
curl -s "$BASE_URL/channel/status" | jq .
# Generate and save channel key
echo "Generating channel key..."
curl -s -X POST "$BASE_URL/channel/generate?save=true" | jq .
# Encode (channel_key defaults to "auto")
echo "Encoding..."
curl -s -X POST "$BASE_URL/encode/multipart" \
-F "passphrase=apple forest thunder" \
-F "pin=123456" \
-F "message=Secret message" \
-F "reference_photo=@ref.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
echo "Encoded to stego.png"
# Decode
echo "Decoding..."
curl -s -X POST "$BASE_URL/decode/multipart" \
-F "passphrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@ref.jpg" \
-F "stego_image=@stego.png" | jq .
```
---
## Docker Configuration
### docker-compose.yml
```yaml
x-common-env: &common-env
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
services:
api:
build:
context: .
target: api
ports:
- "8000:8000"
environment:
<<: *common-env
```
### .env (gitignored)
```bash
STEGASOO_CHANNEL_KEY=ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
```
### Generate key for .env
```bash
curl -s -X POST http://localhost:8000/channel/generate | \
jq -r '"STEGASOO_CHANNEL_KEY=\(.key)"' >> .env
```
---
## See Also
- [CLI Documentation](CLI.md) - Command-line interface
- [Web UI Documentation](WEB_UI.md) - Browser interface
- [README](../README.md) - Project overview

152
CHANGELOG.md Normal file
View File

@@ -0,0 +1,152 @@
# Changelog
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
- **Web UI Authentication**: Single-admin login with SQLite3 user storage
- First-run setup wizard for admin account creation
- Account management page for password changes
- `@login_required` decorator protects encode/decode/generate routes
- Argon2id password hashing (lighter 64MB for fast login)
- **Optional HTTPS**: Auto-generated self-signed certificates for home network deployment
- Configurable via `STEGASOO_HTTPS_ENABLED` environment variable
- Certificates stored in `frontends/web/certs/`
- New environment variables: `STEGASOO_AUTH_ENABLED`, `STEGASOO_HTTPS_ENABLED`, `STEGASOO_HOSTNAME`
### Changed
- PIN entry column widened in encode/decode forms (col-md-4 → col-md-6)
- Channel options column narrowed (col-md-8 → col-md-6)
- QR preview panels enlarged for better text readability
- Consistent font sizing across all preview panel banners (0.7rem filename, 0.6rem data, 0.65rem badges)
### Fixed
- QR preview text too small to read in encode/decode templates
- Inconsistent label sizes between reference/carrier/stego panels
## [4.0.1] - 2025-01-02
### Fixed
- Fixed numpy binary incompatibility on Python 3.10 (jpegio/scipy)
- Fixed BatchCredentials test failures with missing `reference_photo` parameter
- Graceful handling when DCT dependencies have version mismatches
### Changed
- Applied `ruff` linter fixes across entire codebase (~400 issues)
- Applied `black` formatter to all Python files
- Modernized type hints: `Optional[X]``X | None`
- Updated ruff config to use `[tool.ruff.lint]` section
- Moved documentation files to repository root
### Removed
- Removed obsolete debug/diagnostic scripts
- Cleaned up backup files and dev scripts
## [4.0.0] - 2024-12-29
### Added
- Refreshed Web UI with modern, snazzy interface
- Improved user experience across all pages
### Changed
- Major version bump for breaking API changes
- Simplified passphrase handling (single passphrase instead of day-based)
- Removed date_str parameter from encoding
### Fixed
- Various bug fixes for Web UI
- CLI updates and improvements
## [3.2.0] - 2024-12-28
### Added
- Big revamp of the encoding system
- Home and about page improvements
- UNDER_THE_HOOD.md documentation
### Changed
- Renamed `phrase``passphrase` in API
- Updated Web UI styling
## [3.0.2] - 2024-12-27
### Added
- Full experimental DCT steganography support
- jpegio integration for better JPEG manipulation
- DCT/LSB mode selector in Web UI
## [3.0.0] - 2024-12-25
### Added
- DCT (Discrete Cosine Transform) steganography mode
- Support for JPEG carriers without quality loss
- Channel key feature for private messaging
### Changed
- Complete rewrite of steganography engine
- New hybrid authentication system
## [2.0.0] - 2024-12-20
### Added
- Web UI frontend
- REST API (FastAPI)
- Batch processing support
- RSA key authentication option
### Changed
- Migrated to hybrid photo + passphrase + PIN authentication
## [1.0.0] - 2024-12-15
### Added
- Initial release
- LSB steganography
- AES-256-GCM encryption
- CLI interface
- Basic PIN authentication
[4.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.1...v4.0.2
[4.0.1]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.0...v4.0.1
[4.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v3.2.0...v4.0.0
[3.2.0]: https://github.com/adlee-was-taken/stegasoo/compare/v3.0.2...v3.2.0
[3.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v3.0.0...v3.0.2
[3.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v2.0.0...v3.0.0
[2.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v1.0.0...v2.0.0
[1.0.0]: https://github.com/adlee-was-taken/stegasoo/releases/tag/v1.0.0

909
CLI.md Normal file
View File

@@ -0,0 +1,909 @@
# 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.1.0](#whats-new-in-v410)
- [Quick Start](#quick-start)
- [Commands](#commands)
- [generate](#generate-command)
- [encode](#encode-command)
- [decode](#decode-command)
- [verify](#verify-command)
- [channel](#channel-command)
- [admin](#admin-command)
- [tools](#tools-command)
- [info](#info-command)
- [compare](#compare-command)
- [modes](#modes-command)
- [Channel Keys](#channel-keys)
- [Embedding Modes](#embedding-modes)
- [Security Factors](#security-factors)
- [Workflow Examples](#workflow-examples)
- [Piping & Scripting](#piping--scripting)
- [Error Handling](#error-handling)
- [Exit Codes](#exit-codes)
---
## Installation
### From PyPI
```bash
# CLI only
pip install stegasoo[cli]
# CLI with DCT support
pip install stegasoo[cli,dct]
# With all extras
pip install stegasoo[all]
```
### From Source
```bash
git clone https://github.com/example/stegasoo.git
cd stegasoo
pip install -e ".[cli,dct]"
```
### Verify Installation
```bash
stegasoo --version
stegasoo --help
# Check DCT support
python -c "from stegasoo import has_dct_support; print('DCT:', 'available' if has_dct_support() else 'requires scipy')"
# Check channel key status
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 added **channel key** support for deployment/group isolation:
| Feature | Description |
|---------|-------------|
| Channel keys | 256-bit keys that isolate message groups |
| Deployment isolation | Different deployments can't read each other's messages |
| CLI management | New `stegasoo channel` command group |
| Flexible override | Use server config, explicit key, or public mode |
---
## Quick Start
```bash
# 1. Generate credentials (do this once, memorize results)
stegasoo generate
# 2. (Optional) Set up channel key for deployment isolation
stegasoo channel generate --save
# 3. Encode a message (uses configured channel key automatically)
stegasoo encode \
--ref secret_photo.jpg \
--carrier meme.png \
--passphrase "apple forest thunder mountain" \
--pin 123456 \
--message "Meet at midnight"
# 4. Decode a message (uses same channel key)
stegasoo decode \
--ref secret_photo.jpg \
--stego stego_abc123.png \
--passphrase "apple forest thunder mountain" \
--pin 123456
# 5. Decode without channel key (public mode)
stegasoo decode \
--ref secret_photo.jpg \
--stego public_stego.png \
--passphrase "words here now" \
--pin 123456 \
--no-channel
```
---
## Commands
### Generate Command
Generate credentials for encoding/decoding operations.
#### Synopsis
```bash
stegasoo generate [OPTIONS]
```
#### Options
| Option | Short | Type | Default | Description |
|--------|-------|------|---------|-------------|
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
| `--pin-length` | | 6-9 | 6 | PIN length in digits |
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072, 4096) |
| `--words` | | 3-12 | 4 | Words in passphrase |
| `--output` | `-o` | path | | Save RSA key to file |
| `--password` | `-p` | string | | Password for RSA key file |
| `--json` | | flag | | Output as JSON |
#### Examples
```bash
# Basic generation with PIN (default)
stegasoo generate
# Generate with more words for higher security
stegasoo generate --words 6
# Generate with RSA key
stegasoo generate --rsa --rsa-bits 4096
# Save RSA key to encrypted file
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"
```
---
### Encode Command
Encode a secret message or file into an image.
#### Synopsis
```bash
stegasoo encode [OPTIONS]
```
#### Options
| Option | Short | Type | Required | Default | Description |
|--------|-------|------|----------|---------|-------------|
| `--ref` | `-r` | path | ✓ | | Reference photo |
| `--carrier` | `-c` | path | ✓ | | Carrier image |
| `--passphrase` | `-p` | string | ✓ | | Passphrase |
| `--message` | `-m` | string | | | Message to encode |
| `--message-file` | `-f` | path | | | Read message from file |
| `--embed-file` | `-e` | path | | | Embed a binary file |
| `--pin` | | string | * | | Static PIN (6-9 digits) |
| `--key` | `-k` | path | * | | RSA key file |
| `--key-qr` | | path | * | | RSA key from QR code |
| `--key-password` | | string | | | RSA key password |
| `--channel` | | string | | auto | Channel key (v4.0.0) |
| `--channel-file` | | path | | | Read channel key from file |
| `--no-channel` | | flag | | | Force public mode |
| `--output` | `-o` | path | | | Output filename |
| `--mode` | | choice | | `lsb` | Embedding mode |
| `--dct-format` | | choice | | `png` | DCT output format |
| `--dct-color` | | choice | | `grayscale` | DCT color mode |
| `--quiet` | `-q` | flag | | | Suppress output |
\* At least one of `--pin`, `--key`, or `--key-qr` is required.
#### Channel Key Options
| Option | Effect |
|--------|--------|
| *(none)* | Use server-configured key (auto mode) |
| `--channel KEY` | Use explicit channel key |
| `--channel auto` | Same as no option |
| `--channel-file F` | Read channel key from file |
| `--no-channel` | Force public mode (no isolation) |
#### Examples
```bash
# Basic encoding (uses server channel key if configured)
stegasoo encode \
-r photo.jpg -c meme.png \
-p "correct horse battery staple" \
--pin 847293 \
-m "The package arrives Tuesday"
# With explicit channel key
stegasoo encode \
-r photo.jpg -c meme.png \
-p "correct horse battery staple" \
--pin 847293 \
-m "Secret message" \
--channel ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
# Public mode (no channel isolation)
stegasoo encode \
-r photo.jpg -c meme.png \
-p "correct horse battery staple" \
--pin 847293 \
-m "Public message" \
--no-channel
# DCT mode for social media
stegasoo encode \
-r photo.jpg -c meme.png \
-p "words here" --pin 847293 \
-m "Secret" \
--mode dct --dct-format jpeg
```
---
### Decode Command
Decode a secret message or file from a stego image.
#### Synopsis
```bash
stegasoo decode [OPTIONS]
```
#### Options
| Option | Short | Type | Required | Default | Description |
|--------|-------|------|----------|---------|-------------|
| `--ref` | `-r` | path | ✓ | | Reference photo |
| `--stego` | `-s` | path | ✓ | | Stego image |
| `--passphrase` | `-p` | string | ✓ | | Passphrase |
| `--pin` | | string | * | | Static PIN |
| `--key` | `-k` | path | * | | RSA key file |
| `--key-qr` | | path | * | | RSA key from QR code |
| `--key-password` | | string | | | RSA key password |
| `--channel` | | string | | auto | Channel key (v4.0.0) |
| `--channel-file` | | path | | | Read channel key from file |
| `--no-channel` | | flag | | | Force public mode |
| `--output` | `-o` | path | | | Save output to file |
| `--mode` | | choice | | `auto` | Extraction mode |
| `--quiet` | `-q` | flag | | | Minimal output |
| `--force` | | flag | | | Overwrite existing file |
#### Examples
```bash
# Basic decoding (uses server channel key)
stegasoo decode \
-r photo.jpg -s stego.png \
-p "correct horse battery staple" \
--pin 847293
# With explicit channel key
stegasoo decode \
-r photo.jpg -s stego.png \
-p "words here" --pin 847293 \
--channel ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
# Decode public image (no channel key was used)
stegasoo decode \
-r photo.jpg -s stego.png \
-p "words here" --pin 847293 \
--no-channel
# Save to file
stegasoo decode \
-r photo.jpg -s stego.png \
-p "words" --pin 123456 \
-o decoded.txt
```
---
### Verify Command
Verify credentials without extracting the message.
#### Synopsis
```bash
stegasoo verify [OPTIONS]
```
#### Options
Same as `decode`, minus `--output` and `--force`. Adds `--json` for JSON output.
#### Examples
```bash
# Quick verification
stegasoo verify -r photo.jpg -s stego.png -p "phrase" --pin 123456
# With explicit channel key
stegasoo verify -r photo.jpg -s stego.png -p "phrase" --pin 123456 \
--channel ABCD-1234-...
# JSON output
stegasoo verify -r photo.jpg -s stego.png -p "phrase" --pin 123456 --json
```
---
### Channel Command
Manage channel keys for deployment/group isolation.
#### Subcommands
| Subcommand | Description |
|------------|-------------|
| `generate` | Create a new channel key |
| `show` | Display current channel key status |
| `set` | Save a channel key to config |
| `clear` | Remove channel key from config |
#### channel generate
```bash
stegasoo channel generate [OPTIONS]
```
| Option | Short | Description |
|--------|-------|-------------|
| `--save` | `-s` | Save to user config (~/.stegasoo/channel.key) |
| `--save-project` | | Save to project config (./config/channel.key) |
| `--env` | `-e` | Output as environment variable export |
| `--quiet` | `-q` | Output only the key |
**Examples:**
```bash
# Just display a new key
stegasoo channel generate
# Save to user config
stegasoo channel generate --save
# Add to .env file
stegasoo channel generate --env >> .env
# For scripts
KEY=$(stegasoo channel generate -q)
```
#### channel show
```bash
stegasoo channel show [OPTIONS]
```
| Option | Short | Description |
|--------|-------|-------------|
| `--reveal` | `-r` | Show full key (not just fingerprint) |
| `--json` | | Output as JSON |
**Examples:**
```bash
# Show status (fingerprint only)
stegasoo channel show
# Reveal full key
stegasoo channel show --reveal
# JSON for scripts
stegasoo channel show --json
```
**Output:**
```
─── CHANNEL KEY STATUS ───
Mode: PRIVATE
Fingerprint: ABCD-••••-••••-••••-••••-••••-••••-3456
Source: ~/.stegasoo/channel.key
Messages require this channel key to decode.
```
#### channel set
```bash
stegasoo channel set [KEY] [OPTIONS]
```
| Option | Short | Description |
|--------|-------|-------------|
| `--file` | `-f` | Read key from file |
| `--project` | `-p` | Save to project config instead of user |
**Examples:**
```bash
# Set from command line
stegasoo channel set ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
# Set from file
stegasoo channel set --file channel.key
# Set in project config
stegasoo channel set XXXX-... --project
```
#### channel clear
```bash
stegasoo channel clear [OPTIONS]
```
| Option | Short | Description |
|--------|-------|-------------|
| `--project` | `-p` | Clear project config |
| `--all` | | Clear both user and project configs |
| `--force` | `-f` | Skip confirmation |
**Examples:**
```bash
# Clear user config (with confirmation)
stegasoo channel clear
# Clear project config
stegasoo channel clear --project
# Clear all configs without confirmation
stegasoo channel clear --all --force
```
---
### Info Command
Show information about an image file.
```bash
stegasoo info IMAGE [OPTIONS]
```
---
### Compare Command
Compare embedding mode capacities for an image.
```bash
stegasoo compare IMAGE [OPTIONS]
```
---
### Modes Command
Show available embedding modes and their status.
```bash
stegasoo modes
```
Now also displays channel key status.
---
### Admin Command
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 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
```
---
## Channel Keys
Channel keys provide **deployment/group isolation** - messages encoded with a channel key can only be decoded by systems with the same key.
### Key Format
```
ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
└──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘
8 groups of 4 alphanumeric characters (256 bits)
```
### Storage Locations
Channel keys are checked in this order:
| Priority | Location | Best For |
|----------|----------|----------|
| 1 | `STEGASOO_CHANNEL_KEY` env var | Docker, CI/CD |
| 2 | `./config/channel.key` | Project-specific |
| 3 | `~/.stegasoo/channel.key` | User default |
### Modes
| Mode | Description | CLI Option |
|------|-------------|------------|
| **Auto** | Use server-configured key | *(default)* |
| **Explicit** | Use specific key | `--channel KEY` |
| **Public** | No channel isolation | `--no-channel` |
### Fingerprints
For security, full keys aren't displayed by default. Instead, a fingerprint is shown:
```
Full key: ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
Fingerprint: ABCD-••••-••••-••••-••••-••••-••••-3456
```
### Use Cases
**Team isolation:**
```bash
# Team A
export STEGASOO_CHANNEL_KEY=AAAA-1111-...
# Team B
export STEGASOO_CHANNEL_KEY=BBBB-2222-...
# Messages from Team A can only be decoded by Team A
```
**Development vs Production:**
```bash
# Development
./config/channel.key contains DEV-KEY-...
# Production
STEGASOO_CHANNEL_KEY=PROD-KEY-... in Docker
# Dev messages can't be decoded in production
```
**Public messages:**
```bash
# Anyone with credentials can decode
stegasoo encode ... --no-channel
stegasoo decode ... --no-channel
```
---
## Embedding Modes
### LSB Mode (Default)
```bash
stegasoo encode ... --mode lsb
```
| Aspect | Details |
|--------|---------|
| **Capacity** | ~375 KB for 1920×1080 |
| **Output** | PNG only |
| **Best For** | Maximum capacity |
### DCT Mode
```bash
stegasoo encode ... --mode dct --dct-format jpeg --dct-color color
```
| Aspect | Details |
|--------|---------|
| **Capacity** | ~65 KB for 1920×1080 |
| **Output** | PNG or JPEG |
| **Best For** | Social media, stealth |
---
## Security Factors
| Factor | Description | Entropy |
|--------|-------------|---------|
| Reference Photo | Shared image | ~80-256 bits |
| Passphrase | BIP-39 words | ~44 bits (4 words) |
| Static PIN | Numeric (6-9) | ~20 bits (6 digits) |
| RSA Key | Shared key file | ~128 bits |
| Channel Key (v4.0.0) | Deployment isolation | ~256 bits |
---
## Workflow Examples
### Team Setup with Channel Key
**Initial setup (team lead):**
```bash
# Generate team channel key
stegasoo channel generate -q > team_channel.key
# Distribute to team members securely
# (encrypted email, secure file share, etc.)
```
**Team member setup:**
```bash
# Save received key
stegasoo channel set --file team_channel.key
# Verify
stegasoo channel show
```
**Daily use:**
```bash
# Channel key is used automatically
stegasoo encode -r ref.jpg -c meme.png -p "phrase" --pin 123456 -m "Team message"
stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456
```
### Docker Deployment
**docker-compose.yml:**
```yaml
x-common-env: &common-env
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
services:
web:
environment:
<<: *common-env
api:
environment:
<<: *common-env
```
**.env (gitignored):**
```bash
STEGASOO_CHANNEL_KEY=ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
```
### CI/CD Pipeline
```bash
# Generate key for CI
CHANNEL_KEY=$(stegasoo channel generate -q)
# Use in pipeline
STEGASOO_CHANNEL_KEY=$CHANNEL_KEY stegasoo encode ...
```
---
## Piping & Scripting
### Extract channel key for scripts
```bash
# Get just the key
KEY=$(stegasoo channel show --json | jq -r '.key // empty')
# Get fingerprint
FINGERPRINT=$(stegasoo channel show --json | jq -r '.fingerprint // "none"')
# Check if configured
if stegasoo channel show --json | jq -e '.configured' > /dev/null; then
echo "Channel key is configured"
fi
```
### Generate and use immediately
```bash
# Generate, save, and use
stegasoo channel generate --save
stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 -m "message"
```
---
## Error Handling
### Channel Key Errors
| Error | Cause | Solution |
|-------|-------|----------|
| "Invalid channel key format" | Key doesn't match pattern | Use `stegasoo channel generate` |
| "Message encoded with channel key but none configured" | Missing channel key | Set key or use `--channel` |
| "Message encoded without channel key" | Used `--no-channel` to encode | Decode with `--no-channel` |
| "Channel key mismatch" | Wrong key | Verify correct key |
### Troubleshooting
```bash
# Check current channel status
stegasoo channel show
# Try decoding with explicit key
stegasoo decode ... --channel XXXX-XXXX-...
# Try decoding without channel key
stegasoo decode ... --no-channel
```
---
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | General error / decryption failed |
| 2 | Invalid arguments/options |
---
## Environment Variables
| Variable | Description |
|----------|-------------|
| `STEGASOO_CHANNEL_KEY` | Channel key for deployment isolation (v4.0.0) |
| `PYTHONPATH` | Include `src/` for development |
| `STEGASOO_DEBUG` | Enable debug output (set to `1`) |
---
## See Also
- [API Documentation](API.md) - Python API reference
- [Web UI Documentation](WEB_UI.md) - Browser interface guide
- [README](../README.md) - Project overview and security model

54
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,54 @@
# Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior:
* The use of sexualized language or imagery, and sexual attention or advances
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information without explicit permission
* Other conduct which could reasonably be considered inappropriate
## Enforcement Responsibilities
Project maintainers are responsible for clarifying and enforcing our standards
of acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the project maintainers. All complaints will be reviewed and
investigated promptly and fairly.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
version 2.0.

165
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,165 @@
# Contributing to Stegasoo
Thank you for your interest in contributing to Stegasoo! This document provides guidelines and information for contributors.
## Getting Started
### Prerequisites
- Python 3.10 or higher
- Git
- Docker (optional, for container testing)
### Development Setup
1. **Clone the repository**
```bash
git clone https://github.com/adlee-was-taken/stegasoo.git
cd stegasoo
```
2. **Create a virtual environment**
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. **Install development dependencies**
```bash
pip install -e ".[dev]"
```
4. **Install pre-commit hooks**
```bash
pre-commit install
```
## Development Workflow
### Code Style
We use the following tools to maintain code quality:
- **Black** - Code formatting (line length: 100)
- **Ruff** - Linting
- **MyPy** - Type checking
Run all checks before committing:
```bash
black src/ tests/ frontends/
ruff check src/ tests/ frontends/
mypy src/
```
### Running Tests
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov=stegasoo --cov-report=term-missing
# Run specific test file
pytest tests/test_stegasoo.py
```
### Type Hints
All new code should include type hints:
```python
def encode_message(
message: str,
carrier_image: bytes,
passphrase: str,
pin: str = "",
) -> EncodeResult:
...
```
## Making Changes
### Branch Naming
- `feature/description` - New features
- `fix/description` - Bug fixes
- `docs/description` - Documentation updates
- `refactor/description` - Code refactoring
### Commit Messages
Write clear, concise commit messages:
```
Add channel key validation for private messaging
- Implement validate_channel_key() function
- Add tests for valid/invalid key formats
- Update CLI to support --channel-key flag
```
### Pull Request Process
1. **Create a feature branch** from `main`
2. **Make your changes** with appropriate tests
3. **Ensure all checks pass** (tests, linting, formatting)
4. **Submit a PR** with a clear description
5. **Address review feedback** promptly
### PR Checklist
- [ ] Tests added/updated for changes
- [ ] Documentation updated if needed
- [ ] CHANGELOG.md updated for user-facing changes
- [ ] All CI checks passing
- [ ] No merge conflicts with `main`
## Project Structure
```
stegasoo/
├── src/stegasoo/ # Core library
│ ├── crypto.py # Encryption/decryption
│ ├── steganography.py # LSB embedding
│ ├── dct_steganography.py # DCT embedding
│ └── ...
├── frontends/
│ ├── cli/ # Command-line interface
│ ├── web/ # Flask web UI
│ └── api/ # FastAPI REST API
├── tests/ # Test suite
└── examples/ # Usage examples
```
## Reporting Issues
### Bug Reports
Please include:
- Python version and OS
- Stegasoo version (`stegasoo --version`)
- Minimal reproduction steps
- Expected vs actual behavior
- Error messages/tracebacks
### Feature Requests
Please include:
- Use case description
- Proposed solution (if any)
- Alternatives considered
## Security
If you discover a security vulnerability, please see [SECURITY.md](SECURITY.md) for responsible disclosure guidelines. **Do not open a public issue for security vulnerabilities.**
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
## Questions?
Feel free to open a discussion or issue if you have questions about contributing.
Thank you for helping make Stegasoo better!

View File

@@ -1,46 +1,63 @@
# Stegasoo Docker Image
# Multi-stage build for smaller image size
# Uses pre-built base image for fast rebuilds
#
# First time setup:
# docker build -f Dockerfile.base -t stegasoo-base:latest .
#
# Then build normally (fast!):
# docker-compose build
#
# Or if you don't have the base image, this falls back to building deps
# (slow, but works)
FROM python:3.11-slim as base
# ============================================================================
# ARG to switch between base image and full build
# ============================================================================
ARG USE_BASE_IMAGE=true
# ============================================================================
# Base stage - use pre-built image if available
# ============================================================================
FROM stegasoo-base:latest AS base-prebuilt
FROM python:3.12-slim AS base-full
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_ROOT_USER_ACTION=ignore
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
libc-dev \
libffi-dev \
libzbar0 \
libjpeg-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
# Install ALL dependencies (slow path)
RUN pip install --no-cache-dir \
cython numpy scipy>=1.10.0 jpegio>=0.2.0 \
argon2-cffi>=23.0.0 pillow>=10.0.0 cryptography>=41.0.0 \
flask>=3.0.0 gunicorn>=21.0.0 \
fastapi>=0.100.0 "uvicorn[standard]>=0.20.0" python-multipart>=0.0.6 \
qrcode>=7.3.0 pyzbar>=0.1.9 click>=8.0.0 lz4>=4.0.0
# ============================================================================
# Builder stage - install Python packages
# Select which base to use (default: prebuilt)
# ============================================================================
FROM base as builder
WORKDIR /build
# Copy package files (including README.md which pyproject.toml references)
COPY pyproject.toml README.md ./
COPY src/ src/
COPY data/ data/
# Install the package with web extras
RUN pip install --no-cache-dir ".[web]"
FROM base-prebuilt AS base
# ============================================================================
# Production stage - Web UI
# ============================================================================
FROM base as web
FROM base AS web
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy application files
# Copy application files (this is all that rebuilds normally!)
COPY src/ src/
COPY data/ data/
COPY frontends/web/ frontends/web/
@@ -64,22 +81,18 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
# Run with gunicorn
WORKDIR /app/frontends/web
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "60", "app:app"]
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]
# ============================================================================
# API stage - REST API
# ============================================================================
FROM base as api
FROM base AS api
WORKDIR /app
# Install API extras
COPY pyproject.toml README.md ./
# Copy application files
COPY src/ src/
COPY data/ data/
RUN pip install --no-cache-dir ".[api]"
# Copy API files
COPY frontends/api/ frontends/api/
# Create non-root user
@@ -103,17 +116,13 @@ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# ============================================================================
# CLI stage - Command line tool
# ============================================================================
FROM base as cli
FROM base AS cli
WORKDIR /app
# Install CLI extras
COPY pyproject.toml README.md ./
# Copy application files
COPY src/ src/
COPY data/ data/
RUN pip install --no-cache-dir ".[cli]"
# Copy CLI files
COPY frontends/cli/ frontends/cli/
# Create non-root user

55
Dockerfile.base Normal file
View File

@@ -0,0 +1,55 @@
# Stegasoo Base Image
# Contains all slow-to-compile dependencies (jpegio, scipy, argon2)
# Build once: docker build -f Dockerfile.base -t stegasoo-base:latest .
# Push to registry for team use: docker push yourregistry/stegasoo-base:latest
FROM python:3.12-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_ROOT_USER_ACTION=ignore
# Install system dependencies
# NOTE: g++ is required for jpegio C++ compilation
# NOTE: libjpeg-dev is required for jpegio
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
libc-dev \
libffi-dev \
libzbar0 \
libjpeg-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
# Install the slow-to-compile packages
# These rarely change, so they get cached in this base image
RUN pip install --no-cache-dir \
cython \
numpy \
scipy>=1.10.0 \
jpegio>=0.2.0 \
argon2-cffi>=23.0.0 \
pillow>=10.0.0 \
cryptography>=41.0.0
# Install web/api framework packages (also stable)
RUN pip install --no-cache-dir \
flask>=3.0.0 \
gunicorn>=21.0.0 \
fastapi>=0.100.0 \
"uvicorn[standard]>=0.20.0" \
python-multipart>=0.0.6 \
qrcode>=7.3.0 \
pyzbar>=0.1.9 \
click>=8.0.0 \
lz4>=4.0.0
# Verify key packages work
RUN python -c "import jpegio; import scipy; import numpy; print('jpegio + scipy + numpy OK')"
# Label for tracking
LABEL org.opencontainers.image.title="Stegasoo Base"
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
LABEL org.opencontainers.image.version="4.0.0"

825
INSTALL.md Normal file
View File

@@ -0,0 +1,825 @@
# Stegasoo Installation Guide
Complete installation instructions for all platforms and deployment methods.
## Table of Contents
- [Requirements](#requirements)
- [Quick Install](#quick-install)
- [Installation Methods](#installation-methods)
- [From Source (Development)](#from-source-development)
- [From PyPI](#from-pypi)
- [Docker](#docker)
- [Docker Compose](#docker-compose)
- [Optional Dependencies](#optional-dependencies)
- [Platform-Specific Notes](#platform-specific-notes)
- [Verification](#verification)
- [Troubleshooting](#troubleshooting)
---
## Requirements
### ⚠️ Python Version Requirements
| Python Version | Status | Notes |
|----------------|--------|-------|
| 3.10 | ✅ Supported | |
| 3.11 | ✅ Supported | Recommended |
| 3.12 | ✅ Supported | Recommended |
| 3.13 | ❌ **Not Supported** | jpegio C extension incompatible |
**Important:** Python 3.13 (released October 2024) is **not compatible** with jpegio due to C extension ABI changes. Use Python 3.12 or earlier.
### Minimum Requirements
| Requirement | Value |
|-------------|-------|
| Python | 3.10-3.12 |
| RAM | 512 MB minimum (256MB for Argon2) |
| Disk | ~100 MB |
### System Dependencies
**Linux (Debian/Ubuntu):**
```bash
sudo apt-get update
sudo apt-get install -y \
python3.12 \
python3.12-venv \
python3-pip \
python3-dev \
libzbar0 \
libjpeg-dev \
build-essential
```
**Linux (Arch):**
```bash
# Use pyenv for Python version management
curl https://pyenv.run | bash
pyenv install 3.12
pyenv local 3.12
sudo pacman -S zbar libjpeg-turbo base-devel
```
**macOS:**
```bash
brew install python@3.12 zbar jpeg
xcode-select --install # For compilation
```
**Windows:**
- Install Python 3.12 from [python.org](https://python.org)
- Install Visual Studio Build Tools for compilation
---
## Quick Install
```bash
# Clone and install everything
git clone https://github.com/adlee-was-taken/stegasoo.git
cd stegasoo
# Create venv with Python 3.12 (critical!)
python3.12 -m venv venv
source venv/bin/activate # Linux/macOS
# or: venv\Scripts\activate # Windows
# Install all dependencies
pip install -e ".[all]"
# Verify
stegasoo --version
python -c "from stegasoo import has_dct_support; print(f'DCT: {has_dct_support()}')"
```
---
## Installation Methods
### From Source (Development)
Best for development or customization.
```bash
# Clone the repository
git clone https://github.com/adlee-was-taken/stegasoo.git
cd stegasoo
# Create virtual environment with Python 3.12 (recommended)
python3.12 -m venv venv
source venv/bin/activate # Linux/macOS
# or: venv\Scripts\activate # Windows
# Verify Python version
python -V # Should show 3.12.x
# Install core library only
pip install -e .
# Install with specific extras
pip install -e ".[cli]" # Command-line interface
pip install -e ".[web]" # Flask web UI + DCT support
pip install -e ".[api]" # FastAPI REST API + DCT support
pip install -e ".[dct]" # DCT steganography only
pip install -e ".[compression]" # LZ4 compression
# Install everything
pip install -e ".[all]"
# Install with development tools
pip install -e ".[dev]"
```
### From PyPI
```bash
# Core only
pip install stegasoo
# With extras
pip install stegasoo[cli]
pip install stegasoo[web]
pip install stegasoo[api]
pip install stegasoo[all]
```
### Docker
Build and run individual containers.
#### Build Images
```bash
# Build all targets
docker build -t stegasoo-web --target web .
docker build -t stegasoo-api --target api .
docker build -t stegasoo-cli --target cli .
```
#### Run Web UI
```bash
docker run -d \
--name stegasoo-web \
-p 5000:5000 \
--memory=768m \
stegasoo-web
# Visit http://localhost:5000
```
#### Run REST API
```bash
docker run -d \
--name stegasoo-api \
-p 8000:8000 \
--memory=768m \
stegasoo-api
# Docs at http://localhost:8000/docs
```
#### Run CLI
```bash
# Interactive shell
docker run -it --rm stegasoo-cli /bin/bash
# Run commands directly
docker run --rm stegasoo-cli --help
docker run --rm stegasoo-cli generate --pin --words 4
# With volume for files
docker run --rm \
-v $(pwd)/images:/data \
stegasoo-cli encode \
-r /data/ref.jpg \
-c /data/carrier.png \
-p "passphrase words here more" \
--pin 123456 \
-m "Secret message" \
-o /data/stego.png
```
### Docker Compose
The easiest way to run all services.
#### Start All Services
```bash
# Start in background
docker-compose up -d
# Start specific service
docker-compose up -d web
docker-compose up -d api
# View logs
docker-compose logs -f
# Stop all
docker-compose down
```
#### Authentication Configuration (v4.0.2)
The Web UI supports optional authentication. Configure via environment variables:
```bash
# .env file (create in project root)
STEGASOO_AUTH_ENABLED=true # Enable login (default: true)
STEGASOO_HTTPS_ENABLED=false # Enable HTTPS (default: false)
STEGASOO_HOSTNAME=localhost # Hostname for SSL cert
STEGASOO_CHANNEL_KEY= # Optional channel key
# Then run
docker-compose up -d web
```
On first access, you'll be prompted to create an admin account. The database and SSL certs are persisted in Docker volumes.
#### Services
| Service | URL | Description |
|---------|-----|-------------|
| `web` | http://localhost:5000 | Flask Web UI |
| `api` | http://localhost:8000 | FastAPI REST API |
#### Build and Start
```bash
# Build images and start
docker-compose up -d --build
# Force rebuild (no cache)
docker-compose build --no-cache
docker-compose up -d
```
#### Resource Configuration
The `docker-compose.yml` includes resource limits:
```yaml
services:
web:
deploy:
resources:
limits:
memory: 768M # For Argon2 + scipy
reservations:
memory: 384M
```
Adjust based on your available RAM:
| Available RAM | Recommended Limit | Workers |
|---------------|-------------------|---------|
| 2 GB | 768M | 2 |
| 4 GB | 1G | 3 |
| 8 GB+ | 1.5G | 4 |
---
## Optional Dependencies
### DCT Steganography (scipy + jpegio)
DCT mode enables JPEG-resilient steganography. It's automatically included with `[web]`, `[api]`, and `[all]` extras.
#### Install via pip
```bash
# scipy is straightforward
pip install scipy numpy
# jpegio - MUST use Python 3.12 or earlier!
pip install jpegio
# If pip fails, build from source
pip install cython numpy
git clone https://github.com/dwgoon/jpegio.git
cd jpegio
python setup.py install
```
#### Linux Build Dependencies
```bash
sudo apt-get install -y \
build-essential \
python3-dev \
libjpeg-dev \
cython3
```
#### macOS Build Dependencies
```bash
brew install jpeg cython
```
#### Verify DCT Support
```python
from stegasoo import has_dct_support
from stegasoo.dct_steganography import has_jpegio_support
print(f"DCT support (scipy): {has_dct_support()}")
print(f"JPEG native (jpegio): {has_jpegio_support()}")
```
Expected output:
```
DCT support (scipy): True
JPEG native (jpegio): True
```
### Compression (lz4)
Optional LZ4 compression for messages:
```bash
pip install lz4
```
---
## Platform-Specific Notes
### Linux
Most straightforward installation. Use your package manager for system dependencies.
**Ubuntu/Debian:**
```bash
sudo apt-get install python3.12 python3.12-venv python3-dev libzbar0 libjpeg-dev
python3.12 -m venv venv
source venv/bin/activate
pip install stegasoo[all]
```
**Fedora/RHEL:**
```bash
sudo dnf install python3.12 python3-devel zbar libjpeg-devel
python3.12 -m venv venv
source venv/bin/activate
pip install stegasoo[all]
```
**Arch (using pyenv):**
```bash
# Install pyenv
curl https://pyenv.run | bash
# Add to ~/.bashrc or ~/.zshrc
export PATH="$HOME/.pyenv/bin:$PATH"
eval "$(pyenv init -)"
# Install Python 3.12
pyenv install 3.12
cd ~/Sources/stegasoo
pyenv local 3.12
# Create venv and install
python -m venv venv
source venv/bin/activate
pip install stegasoo[all]
```
### macOS
```bash
# Install Homebrew if needed
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install dependencies
brew install python@3.12 zbar jpeg
# Create venv
python3.12 -m venv venv
source venv/bin/activate
# Install Stegasoo
pip install stegasoo[all]
```
**Apple Silicon (M1/M2/M3):**
jpegio may need native compilation:
```bash
# Ensure you have native Python
arch -arm64 brew install python@3.12
arch -arm64 python3.12 -m venv venv
source venv/bin/activate
pip install jpegio
```
### Windows
1. Install Python 3.12 from [python.org](https://python.org) (NOT 3.13!)
2. Install Visual Studio Build Tools
3. Install from pip:
```powershell
python -m venv venv
.\venv\Scripts\activate
pip install stegasoo[all]
```
### Raspberry Pi
Stegasoo works on Raspberry Pi 4/5 (4GB+ RAM recommended for Web UI).
#### Step 1: Install System Dependencies
```bash
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
```
#### 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
# Optional: Enable authentication
export STEGASOO_AUTH_ENABLED=true
# Optional: Enable HTTPS for local network security
export STEGASOO_HTTPS_ENABLED=true
export STEGASOO_HOSTNAME=raspberrypi.local
# Start server
python app.py
# Access at http://<pi-ip>:5000
```
#### 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)
---
## Verification
### Check Installation
```bash
# CLI version
stegasoo --version
# Python import
python -c "import stegasoo; print(stegasoo.__version__)"
# Check Python version (must be 3.10-3.12)
python -V
```
### Check All Features
```python
#!/usr/bin/env python3
"""Verify Stegasoo installation."""
import sys
def check_feature(name, check_fn):
try:
result = check_fn()
status = "" if result else ""
print(f" {status} {name}: {result}")
return result
except Exception as e:
print(f"{name}: Error - {e}")
return False
print("Stegasoo Installation Check")
print("=" * 40)
# Python version check
py_version = sys.version_info
print(f"\nPython: {py_version.major}.{py_version.minor}.{py_version.micro}")
if py_version >= (3, 13):
print(" ⚠️ WARNING: Python 3.13+ not supported!")
print(" jpegio will not work. Use Python 3.12.")
elif py_version >= (3, 10):
print(" ✓ Python version OK")
else:
print(" ✗ Python 3.10+ required")
# Core
import stegasoo
print(f"\nStegasoo Version: {stegasoo.__version__}")
print("\nCore Features:")
check_feature("Argon2", lambda: stegasoo.has_argon2())
check_feature("Pillow", lambda: True) # Required, would fail import
print("\nOptional Features:")
check_feature("DCT (scipy)", stegasoo.has_dct_support)
try:
from stegasoo.dct_steganography import has_jpegio_support
check_feature("JPEG native (jpegio)", has_jpegio_support)
except ImportError:
print(" ✗ JPEG native (jpegio): Not installed")
try:
import lz4
check_feature("Compression (lz4)", lambda: True)
except ImportError:
print(" - Compression (lz4): Not installed (optional)")
try:
import pyzbar
check_feature("QR codes (pyzbar)", lambda: True)
except ImportError:
print(" - QR codes (pyzbar): Not installed (optional)")
print("\nInterfaces:")
try:
import click
check_feature("CLI", lambda: True)
except ImportError:
print(" ✗ CLI: Not installed")
try:
import flask
check_feature("Web UI", lambda: True)
except ImportError:
print(" - Web UI: Not installed")
try:
import fastapi
check_feature("REST API", lambda: True)
except ImportError:
print(" - REST API: Not installed")
print("\n" + "=" * 40)
print("Installation check complete!")
```
Save as `check_install.py` and run:
```bash
python check_install.py
```
### Test Encoding/Decoding
```bash
# Quick test with CLI
stegasoo generate --pin --words 4 --json > /tmp/creds.json
# Create test image
python -c "
from PIL import Image
img = Image.new('RGB', (256, 256), 'blue')
img.save('/tmp/test_carrier.png')
img.save('/tmp/test_ref.jpg')
"
# Encode
stegasoo encode \
-r /tmp/test_ref.jpg \
-c /tmp/test_carrier.png \
-p "test phrase words here" \
--pin 123456 \
-m "Hello, Stegasoo!" \
-o /tmp/test_stego.png
# Decode
stegasoo decode \
-r /tmp/test_ref.jpg \
-s /tmp/test_stego.png \
-p "test phrase words here" \
--pin 123456
```
---
## Troubleshooting
### Common Issues
#### "jpegio crashes" / "free(): invalid size" / Core dump
**This is the #1 issue!** You're using Python 3.13.
```bash
# Check your Python version
python -V
# If it shows 3.13, you need to use 3.12
# Option 1: Use pyenv
pyenv install 3.12
pyenv local 3.12
# Option 2: Use system Python 3.12
python3.12 -m venv venv
source venv/bin/activate
pip install -e ".[all]"
```
#### "No module named 'stegasoo'"
```bash
# Ensure you're in the right environment
which python
pip list | grep stegasoo
# Reinstall
pip install -e ".[all]"
```
#### "Argon2 not available"
```bash
# Install argon2-cffi
pip install argon2-cffi
# On Linux, may need:
sudo apt-get install libffi-dev
pip install --force-reinstall argon2-cffi
```
#### "jpegio not available" (not crash, just missing)
```bash
# Install build dependencies first
sudo apt-get install libjpeg-dev # Linux
brew install jpeg # macOS
# Then install jpegio
pip install cython numpy
pip install jpegio
# If still fails, build from source
git clone https://github.com/dwgoon/jpegio.git
cd jpegio
python setup.py install
```
#### "libzbar not found" (QR codes)
```bash
# Linux
sudo apt-get install libzbar0
# macOS
brew install zbar
# Then reinstall pyzbar
pip install --force-reinstall pyzbar
```
#### Docker: "Cannot allocate memory"
Argon2 needs 256MB per operation. Increase container memory:
```bash
# Docker run
docker run --memory=768m ...
# Docker Compose - edit docker-compose.yml
deploy:
resources:
limits:
memory: 768M
```
#### Slow performance
- **Argon2 is intentionally slow** - This is a security feature
- Expected encode/decode time: 2-5 seconds
- DCT mode adds ~1-2 seconds for transforms
- Large images (10MB+) may take 15-30 seconds
#### "Carrier image too small"
- LSB needs ~3 bits per pixel
- DCT needs ~0.25 bits per pixel
- For 50KB message: LSB needs ~136K pixels, DCT needs ~1.6M pixels
- Use larger carrier images or shorter messages
### Getting Help
1. Check the documentation:
- [README.md](README.md)
- [CLI.md](CLI.md)
- [API.md](API.md)
- [WEB_UI.md](WEB_UI.md)
2. Check existing issues on GitHub
3. Open a new issue with:
- Python version (`python --version`)
- OS and version
- Installation method
- Full error message
- Steps to reproduce
---
## Next Steps
After installation:
1. **Generate credentials**: `stegasoo generate --pin --words 4`
2. **Read the CLI docs**: [CLI.md](CLI.md)
3. **Try the Web UI**: `cd frontends/web && python app.py`
4. **Explore the API**: `cd frontends/api && python main.py`
Happy steganography! 🦕

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2025 Aaron D. Lee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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

297
README.md
View File

@@ -2,272 +2,121 @@
A secure steganography system for hiding encrypted messages in images using hybrid authentication.
![Python](https://img.shields.io/badge/Python-3.10+-blue)
![License](https://img.shields.io/badge/License-MIT-green)
[![Tests](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml/badge.svg)](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml)
[![Lint](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml/badge.svg)](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml)
![Python](https://img.shields.io/badge/Python-3.10--3.12-blue)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
![Security](https://img.shields.io/badge/Security-AES--256--GCM-red)
## Features
- 🔐 **AES-256-GCM** authenticated encryption
- 🧠 **Argon2id** memory-hard key derivation (256MB RAM requirement)
- 🎲 **Pseudo-random pixel selection** defeats steganalysis
- 📅 **Daily key rotation** with BIP-39 passphrases
- 🔑 **Multi-factor authentication**: PIN, RSA key, or both
- 🖼️ **Reference photo** as "something you have"
- 🌐 **Multiple interfaces**: CLI, Web UI, REST API
- **AES-256-GCM** authenticated encryption
- **Argon2id** memory-hard key derivation (256MB RAM requirement)
- **Pseudo-random pixel selection** defeats steganalysis
- **Multi-factor authentication**: Reference photo + passphrase + PIN/RSA key
- **Multiple interfaces**: CLI, Web UI, REST API
- **File embedding**: Hide any file type (PDF, ZIP, documents)
- **DCT steganography**: JPEG-resilient embedding for social media
- **Channel keys**: Private group communication channels
## Installation
## Embedding Modes
### From PyPI (coming soon)
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|------|------------------|----------------|----------|
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
| **LSB** | ~750 KB | No | Email, direct file transfer |
```bash
# Core library only
pip install stegasoo
## Web UI
# With CLI
pip install stegasoo[cli]
# With Web UI
pip install stegasoo[web]
# With REST API
pip install stegasoo[api]
# Everything
pip install stegasoo[all]
```
### From Source
```bash
git clone https://github.com/example/stegasoo.git
cd stegasoo
# Install with all extras
pip install -e ".[all]"
```
### Docker
```bash
# Web UI only
docker-compose up web
# REST API only
docker-compose up api
# Both
docker-compose up
```
| Home | Encode | Decode | Generate |
|:----:|:------:|:------:|:--------:|
| ![Home](data/WebUI.webp) | ![Encode](data/WebUI_Encode.webp) | ![Decode](data/WebUI_Decode.webp) | ![Generate](data/WebUI_Generate.webp) |
## Quick Start
### Python Library
```python
import stegasoo
```bash
# Install (Python 3.10-3.12)
pip install -e ".[all]"
# Generate credentials
creds = stegasoo.generate_credentials(use_pin=True, use_rsa=False)
print(f"Today's phrase: {creds.phrases['Monday']}")
print(f"PIN: {creds.pin}")
stegasoo generate --pin --words 4
# Encode a message
with open('secret_photo.jpg', 'rb') as f:
ref_photo = f.read()
with open('meme.png', 'rb') as f:
carrier = f.read()
result = stegasoo.encode(
message="Meet at midnight",
reference_photo=ref_photo,
carrier_image=carrier,
day_phrase="apple forest thunder",
pin="123456"
)
with open('stego.png', 'wb') as f:
f.write(result.stego_image)
# Decode a message
message = stegasoo.decode(
stego_image=result.stego_image,
reference_photo=ref_photo,
day_phrase="apple forest thunder",
pin="123456"
)
print(message) # "Meet at midnight"
```
### CLI
```bash
# Generate credentials
stegasoo generate --pin --words 3
# With RSA key
stegasoo generate --rsa --rsa-bits 4096 -o mykey.pem -p "secretpassword"
# Encode
stegasoo encode \
--ref photo.jpg \
--carrier meme.png \
--phrase "apple forest thunder" \
--ref my_photo.jpg \
--carrier meme.jpg \
--passphrase "apple forest thunder mountain" \
--pin 123456 \
--message "Secret message"
# Decode
stegasoo decode \
--ref photo.jpg \
--stego stego.png \
--phrase "apple forest thunder" \
--ref my_photo.jpg \
--stego stego_image.png \
--passphrase "apple forest thunder mountain" \
--pin 123456
# Pipe-friendly
echo "secret" | stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 > stego.png
stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -q
```
### Web UI
## Interfaces
```bash
# Development
cd frontends/web
python app.py
# Production
gunicorn --bind 0.0.0.0:5000 app:app
```
Visit http://localhost:5000
### REST API
```bash
# Development
cd frontends/api
python main.py
# Production
uvicorn main:app --host 0.0.0.0 --port 8000
```
API docs at http://localhost:8000/docs
#### Example API Calls
```bash
# Generate credentials
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{"use_pin": true, "use_rsa": false}'
# Encode (multipart)
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Secret" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@photo.jpg" \
-F "carrier=@meme.png" \
--output stego.png
# Decode (multipart)
curl -X POST http://localhost:8000/decode/multipart \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@photo.jpg" \
-F "stego_image=@stego.png"
```
| Interface | Start Command | Documentation |
|-----------|---------------|---------------|
| **CLI** | `stegasoo --help` | [CLI.md](CLI.md) |
| **Web UI** | `cd frontends/web && python app.py` | [WEB_UI.md](WEB_UI.md) |
| **REST API** | `cd frontends/api && uvicorn main:app` | [API.md](API.md) |
## Security Model
| Component | Entropy | Purpose |
|-----------|---------|---------|
| Reference Photo | ~80-256 bits | Something you have |
| Day Phrase (3 words) | ~33 bits | Something you know (rotates daily) |
| PIN (6 digits) | ~20 bits | Something you know (static) |
| RSA Key (2048-bit) | ~128 bits | Something you have |
| **Combined** | **133-400+ bits** | **Beyond brute force** |
### Attack Resistance
| Attack | Protection |
|--------|------------|
| Brute force | 2^133+ combinations |
| Rainbow tables | Random salt per message |
| Steganalysis | Random pixel selection |
| GPU cracking | Argon2id requires 256MB RAM per attempt |
| Side-channel | Constant-time operations in crypto |
## Project Structure
```
stegasoo/
├── src/stegasoo/ # Core library
├── __init__.py # Public API
│ ├── constants.py # Configuration
│ ├── crypto.py # Encryption/decryption
├── steganography.py # Image embedding
│ ├── keygen.py # Credential generation
│ ├── validation.py # Input validation
├── models.py # Data classes
│ ├── exceptions.py # Custom exceptions
│ └── utils.py # Utilities
├── frontends/
│ ├── web/ # Flask web UI
│ ├── cli/ # Command-line interface
│ └── api/ # FastAPI REST API
├── data/
│ └── bip39-words.txt # BIP-39 wordlist
├── pyproject.toml # Package configuration
├── Dockerfile # Multi-stage Docker build
└── docker-compose.yml # Container orchestration
Reference Photo ──┐
(~80-256 bits) │
├──► Argon2id KDF ──► AES-256-GCM Key
Passphrase ───────┤ (256MB RAM)
(~43-132 bits) │
PIN ──────────────┤
(~20-30 bits) │
RSA Key ──────────┘
(optional)
```
## Configuration
| Configuration | Entropy | Use Case |
|--------------|---------|----------|
| 4-word passphrase + 6-digit PIN | ~153 bits | Standard security |
| 4-word passphrase + PIN + RSA | ~280+ bits | Maximum security |
### Environment Variables
## Requirements
| Variable | Default | Description |
|----------|---------|-------------|
| `FLASK_ENV` | production | Flask environment |
| `PYTHONPATH` | - | Include src/ for development |
### Limits
| Limit | Value |
|-------|-------|
| Max image size | 4 megapixels |
| Max message size | 50 KB |
| Max file upload | 5 MB |
| PIN length | 6-9 digits |
| Phrase length | 3-12 words |
| RSA key sizes | 2048, 3072, 4096 bits |
| Requirement | Version |
|-------------|---------|
| Python | 3.10-3.12 |
| RAM | 512 MB+ |
## Development
```bash
# Install dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Format code
black src/ frontends/
ruff check src/ frontends/
# Type checking
mypy src/
black src/ tests/ frontends/
ruff check src/ tests/ frontends/
```
## Documentation
- [INSTALL.md](INSTALL.md) - Installation guide
- [CLI.md](CLI.md) - Command-line reference
- [API.md](API.md) - REST API documentation
- [WEB_UI.md](WEB_UI.md) - Web interface guide
- [SECURITY.md](SECURITY.md) - Security model details
- [UNDER_THE_HOOD.md](UNDER_THE_HOOD.md) - Technical deep-dive
- [CHANGELOG.md](CHANGELOG.md) - Version history
- [CONTRIBUTING.md](CONTRIBUTING.md) - Contributor guide
## License
MIT License - Use responsibly.
MIT License - see [LICENSE](LICENSE). Use responsibly.
## ⚠️ Disclaimer
---
This tool is for educational and legitimate privacy purposes only. Users are responsible for complying with applicable laws in their jurisdiction.
*This tool is for educational and legitimate privacy purposes. Users are responsible for complying with applicable laws.*

274
SECURITY.md Normal file
View File

@@ -0,0 +1,274 @@
# Security Policy
## Supported Versions
| Version | Supported | Notes |
| ------- | ------------------ | ----- |
| 4.x.x | ✅ Active | Current release |
| 3.x.x | ⚠️ Security fixes only | Upgrade recommended |
| 2.x.x | ❌ End of life | |
| 1.x.x | ❌ End of life | |
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please email: **security@example.com** (replace with your email)
Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Any suggested fixes
You should receive a response within 48 hours. We'll work with you to understand and address the issue.
---
# Threat Model
## What Stegasoo Protects
Stegasoo is designed to hide the **existence** of a secret message within an ordinary-looking image, protected by multi-factor authentication.
### Protection Goals
| Goal | How It's Achieved |
|------|-------------------|
| **Confidentiality** | AES-256-GCM encryption with Argon2id key derivation |
| **Steganography** | LSB/DCT embedding with pseudo-random pixel/coefficient selection |
| **Authentication** | Multi-factor: reference photo + passphrase + PIN (or RSA key) |
| **Integrity** | GCM authentication tag detects tampering |
### Security Factors
Stegasoo combines multiple authentication factors:
```
┌─────────────────────────────────────────────────────────────────┐
│ Key Derivation │
│ │
│ Reference Photo ───────┐ │
│ (something you have) │ │
│ ├──► Argon2id ──► AES-256 Key │
│ Passphrase ────────────┤ (256MB RAM) │
│ (something you know) │ │
│ │ │
│ PIN or RSA Key ────────┘ │
│ (second factor) │
└─────────────────────────────────────────────────────────────────┘
```
## Changes in v4.0
### Removed: Date-Based Key Rotation
**Previous versions (v3.x and earlier):**
- Required a date parameter for encode/decode
- Keys rotated daily based on "day phrase"
- Users had to remember which date they used
**Version 4.0:**
- No date dependency
- Single passphrase (no rotation)
- Simpler but slightly reduced entropy per-message
**Security Impact:**
- Minimal - the date only added ~10 bits of entropy
- Passphrase default increased from 3 to 4 words to compensate (+11 bits)
- Overall entropy remains similar or higher with 4-word default
### Renamed: day_phrase → passphrase
Terminology change only. No security impact.
## What Stegasoo Does NOT Protect Against
### 1. Statistical Steganalysis
**Risk:** Advanced analysis can detect that an image contains hidden data.
**Reality:** LSB steganography is detectable by:
- Chi-square analysis
- RS analysis
- Machine learning classifiers
**DCT mode is more resilient** but not undetectable.
**Mitigation:** Stegasoo uses pseudo-random pixel/coefficient selection, which helps but doesn't eliminate detectability.
**Recommendation:** Don't rely on Stegasoo if your adversary has:
- Access to the original carrier image
- Sophisticated forensic tools
- Motivation to analyze your specific images
### 2. Compromised Endpoints
**Risk:** If your device is compromised, the attacker can capture credentials.
**Not protected:**
- Keyloggers capturing your PIN/passphrase
- Screen capture of decoded messages
- Memory scraping during encode/decode
- Malware on sender or receiver device
**Recommendation:** Use on trusted devices only.
### 3. Reference Photo Exposure
**Risk:** The reference photo is a critical secret.
**If leaked:** Attacker only needs to guess/brute-force the passphrase + PIN.
**Recommendation:**
- Never share the reference photo
- Use a unique photo (not posted online)
- Store securely (encrypted drive, password manager)
### 4. Weak Credentials
**Risk:** Short PINs or common passphrases can be brute-forced.
| PIN Length | Combinations | Time to Brute Force* |
|------------|--------------|----------------------|
| 4 digits | 10,000 | Seconds |
| 6 digits | 1,000,000 | Minutes |
| 8 digits | 100,000,000 | Hours |
| 9 digits | 1,000,000,000| Days |
*With Argon2 (256MB RAM, 4 iterations), each attempt takes ~1 second, making brute force slow but not impossible for short PINs.
**Recommendation:**
- Use 8+ digit PINs
- Use 4+ word passphrases (v4.0 default)
- Consider RSA keys for high-security use cases
### 5. Image Modification
**Risk:** Lossy compression destroys hidden data.
**LSB mode - data is destroyed by:**
- JPEG compression
- Resizing
- Filters/effects
- Screenshots
- Social media upload
**DCT mode - more resilient but not immune:**
- Survives moderate JPEG recompression
- May fail with aggressive compression (quality < 70)
- Still destroyed by resizing, filters, screenshots
**Recommendation:**
- LSB: Always use lossless formats (PNG, BMP), direct transfer
- DCT: Use for social media, but test with your specific platform
### 6. Metadata Leakage
**Risk:** The stego image itself may reveal information.
**Potential leaks:**
- File creation timestamp
- Camera EXIF data (if carrier has it)
- File size changes
**Mitigation:** Stegasoo strips EXIF on output, but timestamps remain.
### 7. Traffic Analysis
**Risk:** The act of sending an image may be suspicious.
**Not protected:**
- Network observers seeing you send image files
- Email metadata showing sender/receiver
- Frequency analysis of communications
**Recommendation:** Use alongside normal image-sharing behavior.
## Cryptographic Details
### Encryption
| Component | Algorithm | Parameters |
|-----------|-----------|------------|
| Key Derivation | Argon2id | 256MB RAM, 4 iterations, 4 parallelism |
| Fallback KDF | PBKDF2-SHA256 | 600,000 iterations |
| Encryption | AES-256-GCM | 12-byte IV, 16-byte tag |
| Photo Hash | SHA-256 | Full image bytes |
### Pixel/Coefficient Selection
Selection key is derived from:
```
selection_key = SHA256(photo_hash || passphrase || pin/rsa_signature)
```
This prevents:
- Sequential embedding patterns
- Statistical detection of modified regions
### Message Format (v4.0)
```
┌──────────────────────────────────────────────────────────────────┐
│ Magic (4B) │ Version (1B) │ Salt (32B) │ IV (12B) │
├──────────────────────────────────────────────────────────────────┤
│ Encrypted Payload (AES-256-GCM) │
│ ├── Type (1B): 0x01=text, 0x02=file │
│ ├── Length (4B) │
│ ├── Data (variable) │
│ └── [Filename if file] (variable) │
├──────────────────────────────────────────────────────────────────┤
│ GCM Auth Tag (16B) │
└──────────────────────────────────────────────────────────────────┘
```
**Note:** v4.0 removed the date field from the header, reducing overhead by 10 bytes.
## Best Practices
### For Maximum Security
1. **Use RSA keys** instead of PINs for authentication
2. **Use unique reference photos** not available online
3. **Use long passphrases** (4+ random words, recommend 6+)
4. **Transfer via secure channels** (Signal, encrypted email)
5. **Delete stego images** after message is read
6. **Keep software updated** for security fixes
7. **Use DCT mode** for social media sharing
### For Casual Privacy
1. **6-digit PIN** is sufficient for non-adversarial use
2. **4-word passphrase** provides reasonable security (v4.0 default)
3. **PNG format** for LSB mode output
4. **Direct file transfer** (email attachment, AirDrop)
## Known Limitations
| Limitation | Impact | Status |
|------------|--------|--------|
| LSB is detectable | Statistical analysis can detect hidden data | By design (tradeoff for capacity) |
| No forward secrecy | Compromised key decrypts all messages | Use different keys per message for high security |
| No deniability | Single password = single message | Future: plausible deniability layers |
| Python 3.13 incompatible | jpegio C extension crashes | Use Python 3.12 or earlier |
## Security Audit Status
This software has **not** been professionally audited. Use at your own risk for sensitive applications.
If you're a security researcher interested in auditing Stegasoo, please reach out.
---
## Version History (Security Relevant)
| Version | Security Changes |
|---------|------------------|
| 4.0.0 | Removed date dependency, increased default passphrase to 4 words, added JPEG normalization |
| 3.2.0 | DCT color mode added |
| 3.0.0 | Added DCT steganography mode |
| 2.2.0 | Added compression (no security impact) |
| 2.1.0 | Upgraded to Argon2id, increased iterations |
| 2.0.0 | Added RSA key support |
| 1.0.0 | Initial release |

805
UNDER_THE_HOOD.md Normal file
View File

@@ -0,0 +1,805 @@
# Stegasoo Technical Deep Dive: Encoding & Decoding
A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work under the hood.
**Version 4.0** - Updated for simplified authentication (no date dependency)
---
## Table of Contents
1. [High-Level Overview](#high-level-overview)
2. [The Encoding Pipeline](#the-encoding-pipeline)
3. [The Decoding Pipeline](#the-decoding-pipeline)
4. [LSB Mode Deep Dive](#lsb-mode-deep-dive)
5. [DCT Mode Deep Dive](#dct-mode-deep-dive)
6. [Comparison Table](#comparison-table)
7. [Security Considerations](#security-considerations)
---
## High-Level Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEGASOO ARCHITECTURE (v4.0) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ INPUTS PROCESSING OUTPUT │
│ ─────── ────────── ────── │
│ │
│ Reference Photo ─┐ │
│ Passphrase ──────┼──► Argon2id KDF ──► AES-256 Key │
│ PIN/RSA Key ─────┘ │ │
│ ▼ │
│ Message/File ────────────────────────► AES-256-GCM ──► Ciphertext │
│ Encryption │ │
│ ▼ │
│ Carrier Image ───────────────────────────────────────► Embedding ──► Stego│
│ (LSB/DCT) Image │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### v4.0 Changes
| Change | v3.x | v4.0 |
|--------|------|------|
| Authentication | day_phrase + date | passphrase (no date) |
| Default words | 3 | 4 |
| Header size | 75 bytes | 65 bytes (no date field) |
| Python support | 3.10+ | 3.10-3.12 only |
### Module Responsibilities
| Module | File | Purpose |
|--------|------|---------|
| **Crypto** | `crypto.py` | Key derivation (Argon2id), AES-256-GCM encryption/decryption |
| **Steganography** | `steganography.py` | LSB pixel manipulation, capacity calculation |
| **DCT Steganography** | `dct_steganography.py` | Frequency-domain embedding, jpegio integration |
| **Compression** | `compression.py` | Optional LZ4 compression of payload |
| **Validation** | `validation.py` | Input validation, size limits |
| **Utils** | `utils.py` | Image hashing, format detection |
---
## The Encoding Pipeline
### Step 1: Input Collection & Validation
```python
# validation.py
def validate_encode_inputs(reference_photo, carrier, message, passphrase, pin, rsa_key):
# Check image dimensions (max 24 megapixels)
# Validate PIN format (6-9 digits)
# Validate passphrase (3-12 words from BIP-39)
# Check payload size vs carrier capacity
# Ensure reference != carrier (security)
```
### Step 2: Reference Photo Processing
```python
# utils.py
def get_image_hash(image_bytes: bytes) -> bytes:
"""
Generate deterministic hash from reference photo.
This is the 'something you have' factor.
"""
# Resize to 256x256 (normalize different resolutions)
# Convert to grayscale (normalize color variations)
# Apply slight blur (reduce JPEG artifact sensitivity)
# SHA-256 hash of processed pixels
return hashlib.sha256(processed_pixels).digest() # 32 bytes
```
**Why process the image?** Minor variations (JPEG recompression, slight crops) in the reference photo between sender and receiver would produce different hashes, breaking decryption. The preprocessing makes the hash more resilient.
### Step 3: Key Derivation (Argon2id)
```python
# crypto.py
def derive_key(reference_hash: bytes, passphrase: str, pin: str,
rsa_signature: bytes = None) -> bytes:
"""
Combine all authentication factors into one AES key.
v4.0: No date parameter - simplified authentication.
"""
# Concatenate all factors
key_material = reference_hash + passphrase.encode() + pin.encode()
if rsa_signature:
key_material += rsa_signature
# Argon2id parameters (memory-hard to resist GPU attacks)
# - Memory: 256 MB
# - Iterations: 4
# - Parallelism: 4
# - Output: 32 bytes (256 bits)
key = argon2.hash_password_raw(
password=key_material,
salt=random_salt, # 16 bytes, stored with ciphertext
time_cost=4,
memory_cost=262144, # 256 MB
parallelism=4,
hash_len=32,
type=argon2.Type.ID
)
return key # 32-byte AES-256 key
```
**Why Argon2id?**
- **Memory-hard**: Requires 256MB RAM per attempt, defeating GPU/ASIC attacks
- **Time-hard**: ~2-3 seconds per derivation
- **Side-channel resistant**: ID variant protects against timing attacks
### Step 4: Payload Preparation
```python
# compression.py (optional)
def prepare_payload(data: bytes, filename: str = None) -> bytes:
"""
Prepare the payload with metadata header.
"""
# Header format (variable length):
# [1 byte] - Flags (compression, file mode, etc.)
# [4 bytes] - Original data length (big-endian)
# [2 bytes] - Filename length (if file mode)
# [N bytes] - Filename (if file mode)
# [N bytes] - Data (possibly compressed)
header = struct.pack('>BI', flags, len(data))
if filename:
header += struct.pack('>H', len(filename)) + filename.encode()
# Optional LZ4 compression
if should_compress(data):
data = lz4.frame.compress(data)
flags |= FLAG_COMPRESSED
return header + data
```
### Step 5: AES-256-GCM Encryption
```python
# crypto.py
def encrypt(plaintext: bytes, key: bytes) -> bytes:
"""
Encrypt payload with AES-256-GCM.
Returns: salt + nonce + ciphertext + tag
"""
salt = os.urandom(16) # Random salt for key derivation
nonce = os.urandom(12) # Random nonce for GCM
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
ciphertext, tag = cipher.encrypt_and_digest(plaintext)
# Final encrypted blob:
# [16 bytes] Salt
# [12 bytes] Nonce
# [16 bytes] Auth Tag
# [N bytes] Ciphertext
return salt + nonce + tag + ciphertext
```
**Why GCM?**
- **Authenticated encryption**: Detects tampering
- **No padding oracle**: Stream cipher mode
- **Built-in integrity**: 128-bit authentication tag
### Step 6: Stego Header Construction
```python
# steganography.py / dct_steganography.py
def build_stego_header(encrypted_data: bytes, mode: str) -> bytes:
"""
Build the header that precedes embedded data.
v4.0: Simplified header (no date field)
"""
# Header format:
# [4 bytes] - Magic number: "STGO" (v4)
# [1 byte] - Version (0x04)
# [1 byte] - Mode (0x01=LSB, 0x02=DCT)
# [4 bytes] - Payload length
# [N bytes] - Encrypted payload
if mode == 'lsb':
magic = b'STGO\x04\x01' # v4, mode 1 (LSB)
else:
magic = b'STGO\x04\x02' # v4, mode 2 (DCT)
length = struct.pack('>I', len(encrypted_data))
return magic + length + encrypted_data
```
### Step 7: Embedding (Mode-Specific)
This is where LSB and DCT diverge. See detailed sections below.
---
## The Decoding Pipeline
### Step 1: Mode Detection
```python
def detect_mode(stego_image: bytes) -> str:
"""
Detect which embedding mode was used.
Checks format and magic bytes.
"""
img = Image.open(io.BytesIO(stego_image))
# JPEG images with JPGS magic = DCT mode with jpegio
if img.format == 'JPEG':
# Check for jpegio magic
return 'dct'
# PNG/BMP: Read first few bytes from LSB
# Check for STGO or DCTS magic
magic = extract_header_lsb(stego_image, 6)
if magic.startswith(b'STGO'):
mode_byte = magic[5]
return 'lsb' if mode_byte == 0x01 else 'dct'
elif magic.startswith(b'DCTS'):
return 'dct'
return 'lsb' # Default fallback
```
### Step 2: Key Re-derivation
```python
# Same process as encoding
def derive_key_for_decode(reference_hash, passphrase, pin, rsa_signature=None):
# Must use SAME parameters as encoding
# No date parameter in v4.0
return derive_key(reference_hash, passphrase, pin, rsa_signature)
```
### Step 3: Data Extraction
```python
def extract_data(stego_image: bytes, mode: str) -> bytes:
"""
Extract raw bytes from stego image.
Mode-specific extraction.
"""
if mode == 'dct':
return extract_from_dct(stego_image, pixel_key)
else:
return extract_from_lsb(stego_image, pixel_key)
```
### Step 4: Decryption & Payload Recovery
```python
def decrypt_and_recover(encrypted_data: bytes, key: bytes) -> Union[str, bytes]:
"""
Decrypt and extract original message/file.
"""
# Parse header
salt = encrypted_data[:16]
nonce = encrypted_data[16:28]
tag = encrypted_data[28:44]
ciphertext = encrypted_data[44:]
# Decrypt
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
# Decompress if needed
if plaintext[0] & FLAG_COMPRESSED:
plaintext = lz4.frame.decompress(plaintext[5:])
# Extract payload
return parse_payload(plaintext)
```
---
## LSB Mode Deep Dive
### How LSB Embedding Works
LSB (Least Significant Bit) embedding modifies the lowest bit of each color channel in selected pixels.
```
Original Pixel (RGB):
R: 11010110 G: 01101001 B: 10110100
↓ ↓ ↓
└─────────┴─────────┘
3 bits available
After embedding "101":
R: 1101011[1] G: 0110100[0] B: 1011010[1]
↑ ↑ ↑
modified modified modified
```
### Pixel Selection Algorithm
```python
def select_pixels(carrier_shape, num_bits, seed: bytes) -> List[Tuple[int, int, int]]:
"""
Generate pseudo-random pixel coordinates.
Distributes modifications across entire image.
"""
height, width, channels = carrier_shape
total_positions = height * width * 3 # RGB channels
# Use seed to generate reproducible random order
rng = np.random.RandomState(int.from_bytes(seed[:4], 'big'))
all_positions = np.arange(total_positions)
rng.shuffle(all_positions)
# Convert flat indices to (y, x, channel)
selected = []
for idx in all_positions[:num_bits]:
y = idx // (width * 3)
x = (idx % (width * 3)) // 3
c = idx % 3
selected.append((y, x, c))
return selected
```
### Embedding Process
```python
def embed_lsb(carrier: np.ndarray, data: bytes, seed: bytes) -> np.ndarray:
"""
Embed data using LSB substitution.
"""
bits = bytes_to_bits(data)
positions = select_pixels(carrier.shape, len(bits), seed)
stego = carrier.copy()
for i, (y, x, c) in enumerate(positions):
# Clear LSB and set to our bit
stego[y, x, c] = (stego[y, x, c] & 0xFE) | bits[i]
return stego
```
### Capacity Calculation
```python
def calculate_lsb_capacity(width: int, height: int) -> int:
"""
Calculate maximum payload size for LSB mode.
"""
total_bits = width * height * 3 # 3 bits per pixel (RGB)
header_bits = 10 * 8 # 10-byte stego header
available_bits = total_bits - header_bits
return available_bits // 8 # Convert to bytes
```
**Example capacities:**
- 1920×1080: ~770 KB
- 4000×3000: ~4.5 MB
- 800×600: ~180 KB
---
## DCT Mode Deep Dive
### How DCT Embedding Works
DCT (Discrete Cosine Transform) mode embeds data in the frequency-domain coefficients, making it resilient to JPEG compression.
```
Image Block (8×8 pixels)
DCT Transform
DCT Coefficients (8×8)
┌────────────────────┐
│ DC AC₁ AC₂ AC₃ ...│ ← Lower frequencies (top-left)
│ AC₄ AC₅ AC₆ ... │
│ ... ... │ ← Mid frequencies (embed here)
│ ... ... │
│ AC₆₃ ────│ ← Higher frequencies (bottom-right)
└────────────────────┘
Modify select ACs
IDCT Transform
Modified Image Block
```
### Coefficient Selection
```python
# dct_steganography.py
EMBED_POSITIONS = [
(0, 1), (1, 0), (2, 0), (1, 1), (0, 2), (0, 3), (1, 2), (2, 1), (3, 0),
(4, 0), (3, 1), (2, 2), (1, 3), (0, 4), (0, 5), (1, 4), (2, 3), (3, 2),
(4, 1), (5, 0), (5, 1), (4, 2), (3, 3), (2, 4), (1, 5), (0, 6), (0, 7),
(1, 6), (2, 5), (3, 4), (4, 3), (5, 2), (6, 1), (7, 0),
]
# Use positions 4-20 (mid-frequency, good balance)
DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20] # 16 positions per block
```
**Why mid-frequency?**
- DC coefficient (0,0): Too visible, contains brightness
- Low AC: Visible changes, but survives compression
- Mid AC: Best balance of invisibility + resilience
- High AC: Invisible but destroyed by compression
### Block Processing
```python
def embed_in_block(block: np.ndarray, bits: List[int]) -> np.ndarray:
"""
Embed bits in a single 8×8 block.
"""
# Forward DCT
dct_block = dct_2d(block)
# Embed using quantization
for i, pos in enumerate(DEFAULT_EMBED_POSITIONS):
if i >= len(bits):
break
coef = dct_block[pos[0], pos[1]]
# Quantize and modify LSB
quantized = round(coef / QUANT_STEP)
if (quantized % 2) != bits[i]:
quantized += 1 if coef > 0 else -1
dct_block[pos[0], pos[1]] = quantized * QUANT_STEP
# Inverse DCT
return idct_2d(dct_block)
```
### jpegio Integration (Native JPEG Output)
```python
def embed_jpegio(data: bytes, carrier_jpeg: bytes, seed: bytes) -> bytes:
"""
Embed directly in JPEG DCT coefficients using jpegio.
Preserves JPEG structure perfectly.
Note: Requires Python 3.12 or earlier (jpegio incompatible with 3.13)
"""
import jpegio as jio
# Normalize problematic JPEGs (quality=100 causes crashes)
carrier_jpeg = normalize_jpeg_for_jpegio(carrier_jpeg)
# Read existing JPEG coefficients
jpeg = jio.read(temp_file_from_bytes(carrier_jpeg))
coef_array = jpeg.coef_arrays[0] # Y channel
# Find usable coefficients (magnitude >= 2, non-DC)
positions = get_usable_positions(coef_array)
order = generate_order(len(positions), seed)
# Embed by modifying coefficient LSBs
bits = bytes_to_bits(data)
for i, pos_idx in enumerate(order[:len(bits)]):
row, col = positions[pos_idx]
coef = coef_array[row, col]
if (coef & 1) != bits[i]:
# Flip LSB while preserving sign
if coef > 0:
coef_array[row, col] = coef - 1 if (coef & 1) else coef + 1
else:
coef_array[row, col] = coef + 1 if (coef & 1) else coef - 1
# Write modified JPEG
jio.write(jpeg, output_path)
return read_bytes(output_path)
```
### JPEG Normalization (v4.0)
```python
def normalize_jpeg_for_jpegio(image_data: bytes) -> bytes:
"""
Normalize problematic JPEGs before jpegio processing.
JPEGs with quality=100 have quantization tables with all values=1,
which causes jpegio to crash. Re-save at quality 95.
"""
img = Image.open(io.BytesIO(image_data))
if img.format != 'JPEG':
return image_data
# Check if any quantization table has all values <= 1
needs_normalization = False
if hasattr(img, 'quantization'):
for table in img.quantization.values():
if max(table) <= 1:
needs_normalization = True
break
if not needs_normalization:
return image_data
# Re-save at safe quality
buffer = io.BytesIO()
img.save(buffer, format='JPEG', quality=95, subsampling=0)
return buffer.getvalue()
```
### DCT Capacity Calculation
```python
def calculate_dct_capacity(width: int, height: int) -> int:
"""
Calculate maximum payload for DCT mode.
"""
blocks_x = width // 8
blocks_y = height // 8
total_blocks = blocks_x * blocks_y
bits_per_block = len(DEFAULT_EMBED_POSITIONS) # 16
total_bits = total_blocks * bits_per_block
header_bits = 10 * 8 # Stego header
available_bits = total_bits - header_bits
return available_bits // 8
```
**Example capacities:**
- 1920×1080: ~64 KB
- 4000×3000: ~375 KB
- 800×600: ~14 KB
### Why DCT Survives JPEG Compression
```
Original JPEG: Stego JPEG: Re-compressed:
DCT coefficients Modified DCT Coefficients
preserved in coefficients re-quantized
file format still valid
│ │ │
▼ ▼ ▼
[DCT] ──────► [Modified] ──────► [Still
[coefs] [DCT coefs] Modified!]
LSB changes survive because they're embedded in
the frequency domain, not spatial pixel values.
```
### DCT Advantages
| Advantage | Description |
|-----------|-------------|
| **JPEG resilient** | Survives social media upload |
| **Better steganalysis resistance** | Harder to detect statistically |
| **Natural-looking output** | JPEG artifacts expected |
### DCT Limitations
| Limitation | Description |
|------------|-------------|
| **Lower capacity** | ~10% of LSB capacity |
| **Slower processing** | DCT transforms are compute-intensive |
| **Requires scipy/jpegio** | Additional dependencies |
| **Quality-dependent** | Heavy recompression still degrades data |
| **Python version** | jpegio requires Python 3.12 or earlier |
---
## Comparison Table
| Aspect | LSB Mode | DCT Mode |
|--------|----------|----------|
| **Capacity (1080p)** | ~770 KB | ~50 KB |
| **Output Format** | PNG only | PNG or JPEG |
| **Survives JPEG** | ❌ No | ✅ Yes |
| **Social Media** | ❌ Broken | ✅ Works |
| **Processing Speed** | Fast (~0.5s) | Slower (~2s) |
| **Dependencies** | Pillow, NumPy | + scipy, jpegio |
| **Color Support** | Full color | Color or Grayscale |
| **Detection Resistance** | Moderate | Better |
| **Best For** | Email, cloud storage | Social media, messaging |
| **Max Tested Image** | 14MB+ | 14MB+ |
---
## Security Considerations
### What Makes Stegasoo Secure?
```
MULTI-FACTOR AUTHENTICATION (v4.0)
──────────────────────────────────
Factor 1: Reference Photo ─┐
• 80-256 bits entropy │
• "Something you have" │
├──► Combined entropy: 133-400+ bits
Factor 2: Passphrase │ (Beyond brute force)
• 43-132 bits entropy │
• "Something you know" │
• 4 words default (v4.0) │
Factor 3: PIN │
• 20-30 bits entropy │
• "Something you know" │
Factor 4: RSA Key (optional) ─┘
• 112-128 bits entropy
• "Something you have"
MEMORY-HARD KDF (Argon2id)
──────────────────────────
• 256 MB RAM per attempt
• ~3 seconds per attempt
• Defeats GPU/ASIC attacks
• 10 attempts = 30 seconds, not 0.00001 seconds
AUTHENTICATED ENCRYPTION (AES-256-GCM)
──────────────────────────────────────
• 256-bit key (unbreakable)
• Built-in integrity check
• Detects tampering
• No padding oracle attacks
```
### Attack Surface Analysis
| Attack | LSB Protection | DCT Protection |
|--------|----------------|----------------|
| Visual inspection | ✅ Imperceptible | ✅ Imperceptible |
| File size analysis | ⚠️ PNG larger | ✅ JPEG natural |
| Histogram analysis | ⚠️ Slight anomalies | ✅ Normal JPEG |
| Chi-square attack | ⚠️ Detectable at scale | ✅ Resistant |
| RS steganalysis | ⚠️ Detectable | ✅ Resistant |
| JPEG recompression | ❌ Destroyed | ✅ Survives |
### Threat Model
**Stegasoo protects against:**
- ✅ Passive eavesdropping
- ✅ Casual inspection of images
- ✅ Basic forensic analysis
- ✅ Brute force key guessing
- ✅ JPEG recompression (DCT mode)
**Stegasoo does NOT protect against:**
- ⚠️ Targeted forensic analysis with original carrier
- ⚠️ Nation-state level steganalysis
- ⚠️ Rubber hose cryptanalysis (coercion)
- ⚠️ Compromise of reference photo or credentials
---
## Data Flow Diagrams
### Complete Encode Flow (v4.0)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ENCODE FLOW (v4.0) │
└──────────────────────────────────────────────────────────────────────────────┘
User Inputs Processing Output
─────────── ────────── ──────
Reference Photo ──────┐
├──► get_image_hash() ──► ref_hash (32 bytes)
│ │
Passphrase ───────────┤ ▼
├──► derive_key() ──────► aes_key (32 bytes)
PIN ──────────────────┤ (Argon2id) │
│ │
RSA Key (optional) ───┘ │
Message/File ──────────► prepare_payload() ──► encrypt() ──► ciphertext
(compress, header) (AES-GCM) │
build_stego_header()
(magic + length)
Carrier Image ─────────────────────────────────────────► embed()
│ │
┌───────────┴─────┴────────────┐
│ │
LSB Mode DCT Mode
│ │
▼ ▼
embed_lsb() embed_in_dct()
(pixel LSBs) (DCT coefficients)
│ │
▼ ▼
PNG Output PNG or JPEG
│ │
└──────────┬───────────────────┘
Stego Image
(downloadable)
```
### Complete Decode Flow (v4.0)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ DECODE FLOW (v4.0) │
└──────────────────────────────────────────────────────────────────────────────┘
User Inputs Processing Output
─────────── ────────── ──────
Reference Photo ──────┐
├──► get_image_hash() ──► ref_hash (32 bytes)
│ │
Passphrase ───────────┤ ▼
├──► derive_key() ──────► aes_key (32 bytes)
PIN ──────────────────┤ (Argon2id) │
│ (MUST MATCH!) │
RSA Key (optional) ───┘ │
Stego Image ──────────► detect_mode() ──────► extract()
(read magic) │ │
│ ┌─────────┴─────┴──────────┐
│ │ │
│ LSB Mode DCT Mode
│ │ │
│ ▼ ▼
│ extract_lsb() extract_from_dct()
│ │ │
│ └────────┬─────────────────┘
│ │
│ ▼
│ parse_stego_header()
│ (magic, length)
│ │
│ ▼
└────────► decrypt()
(AES-GCM)
decompress()
(if compressed)
extract_payload()
(handle file/text)
Original Message
or File
```
---
## Summary
**LSB Mode** is simpler, faster, and higher capacity - perfect for controlled channels where images won't be modified.
**DCT Mode** is more complex but survives real-world image processing - essential for social media and messaging apps.
Both modes share the same cryptographic foundation (Argon2id + AES-256-GCM) and multi-factor authentication, ensuring security regardless of embedding method.
The choice comes down to your use case:
- **Private channel?** → LSB (maximum capacity)
- **Public platform?** → DCT (maximum compatibility)
### v4.0 Simplifications
- **No more date tracking** - encode/decode anytime without remembering dates
- **Single passphrase** - no daily rotation to manage
- **Default 4 words** - better security out of the box
- **JPEG normalization** - handles quality=100 images automatically
- **Large image support** - tested with 14MB+ images

1333
WEB_UI.md Normal file

File diff suppressed because it is too large Load Diff

BIN
data/WebUI.webp Normal file

Binary file not shown.

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

BIN
data/WebUI_Decode.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
data/WebUI_Encode.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
data/WebUI_Generate.webp Normal file

Binary file not shown.

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

View File

@@ -1,5 +1,9 @@
version: '3.8'
# Shared environment variables
x-common-env: &common-env
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
services:
# ============================================================================
# Web UI (Flask)
@@ -12,14 +16,23 @@ services:
ports:
- "5000:5000"
environment:
- FLASK_ENV=production
<<: *common-env
FLASK_ENV: production
# Authentication (v4.0.2)
STEGASOO_AUTH_ENABLED: ${STEGASOO_AUTH_ENABLED:-true}
STEGASOO_HTTPS_ENABLED: ${STEGASOO_HTTPS_ENABLED:-false}
STEGASOO_HOSTNAME: ${STEGASOO_HOSTNAME:-localhost}
volumes:
# Persist auth database and SSL certs (v4.0.2)
- stegasoo-web-data:/app/frontends/web/instance
- stegasoo-web-certs:/app/frontends/web/certs
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M # Argon2 needs 256MB per operation
memory: 768M
reservations:
memory: 256M
memory: 384M
# ============================================================================
# REST API (FastAPI)
@@ -31,32 +44,19 @@ services:
container_name: stegasoo-api
ports:
- "8000:8000"
environment:
<<: *common-env
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
memory: 768M
reservations:
memory: 256M
memory: 384M
# ============================================================================
# Nginx Reverse Proxy (optional, for production)
# ============================================================================
# nginx:
# image: nginx:alpine
# container_name: stegasoo-nginx
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - ./nginx.conf:/etc/nginx/nginx.conf:ro
# - ./certs:/etc/nginx/certs:ro
# depends_on:
# - web
# - api
# restart: unless-stopped
# ============================================================================
# Development overrides
# ============================================================================
# Use: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
# Named volumes for persistent data
volumes:
stegasoo-web-data:
driver: local
stegasoo-web-certs:
driver: local

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 |

48
examples/README.md Normal file
View File

@@ -0,0 +1,48 @@
# Stegasoo Examples
This directory contains example scripts demonstrating how to use Stegasoo.
## Prerequisites
Install Stegasoo first:
```bash
pip install stegasoo
# Or for development:
pip install -e ".[all]"
```
## Examples
### basic_usage.py
Basic encode/decode workflow with a text message.
```bash
python basic_usage.py
```
### embed_file.py
Embed and extract files (documents, images, etc.) inside carrier images.
```bash
python embed_file.py
```
### channel_keys.py
Use channel keys to create private communication channels for groups.
```bash
python channel_keys.py
```
## Test Images
You'll need to provide your own images:
- `my_secret_photo.png` - Your reference photo (keep this secret!)
- `carrier.png` - The image that will carry your hidden message
For testing, you can use any PNG or BMP image. JPEG carriers are supported with DCT mode.

59
examples/basic_usage.py Normal file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""
Basic Stegasoo Usage Example
This example demonstrates how to encode and decode a secret message
using the Stegasoo library.
"""
from pathlib import Path
import stegasoo
def main():
# Load your images
# The reference photo is your "key" - keep it secret!
reference_photo = Path("my_secret_photo.png").read_bytes()
carrier_image = Path("carrier.png").read_bytes()
# Your secret message
message = "This is my secret message!"
# Your credentials
passphrase = "correct horse battery staple" # Use 4+ words
pin = "123456" # 6-9 digits
# === ENCODE ===
print("Encoding message...")
result = stegasoo.encode(
message=message,
reference_photo=reference_photo,
carrier_image=carrier_image,
passphrase=passphrase,
pin=pin,
)
# Save the stego image
output_path = Path(f"secret_{result.suggested_filename}")
output_path.write_bytes(result.stego_image)
print(f"Saved to: {output_path}")
print(f"Capacity used: {result.capacity_used_percent:.1f}%")
# === DECODE ===
print("\nDecoding message...")
stego_image = output_path.read_bytes()
decoded = stegasoo.decode(
stego_image=stego_image,
reference_photo=reference_photo,
passphrase=passphrase,
pin=pin,
)
print(f"Decoded message: {decoded.message}")
print(f"Message type: {decoded.payload_type}")
if __name__ == "__main__":
main()

72
examples/channel_keys.py Normal file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""
Channel Keys Example
Channel keys allow you to create private communication channels.
Only people with the same channel key can decode messages.
"""
from pathlib import Path
import stegasoo
from stegasoo.channel import generate_channel_key, get_channel_fingerprint
def main():
# Generate a channel key for your group
channel_key = generate_channel_key()
fingerprint = get_channel_fingerprint(channel_key)
print("=== Channel Key Generated ===")
print(f"Key: {channel_key}")
print(f"Fingerprint: {fingerprint}")
print("\nShare this key securely with your group members!")
print("-" * 40)
# Load images
reference_photo = Path("my_secret_photo.png").read_bytes()
carrier_image = Path("carrier.png").read_bytes()
# Encode with channel key
print("\nEncoding message with channel key...")
result = stegasoo.encode(
message="Secret group message!",
reference_photo=reference_photo,
carrier_image=carrier_image,
passphrase="correct horse battery staple",
pin="123456",
channel_key=channel_key, # Add the channel key
)
stego_data = result.stego_image
print(f"Encoded successfully!")
# Decode with correct channel key
print("\nDecoding with correct channel key...")
decoded = stegasoo.decode(
stego_image=stego_data,
reference_photo=reference_photo,
passphrase="correct horse battery staple",
pin="123456",
channel_key=channel_key, # Same channel key
)
print(f"Message: {decoded.message}")
# Try to decode with wrong channel key
print("\nTrying to decode with wrong channel key...")
wrong_key = generate_channel_key()
try:
stegasoo.decode(
stego_image=stego_data,
reference_photo=reference_photo,
passphrase="correct horse battery staple",
pin="123456",
channel_key=wrong_key, # Different channel key
)
print("ERROR: Should have failed!")
except (stegasoo.DecryptionError, stegasoo.ExtractionError):
print("Correctly rejected - wrong channel key!")
if __name__ == "__main__":
main()

78
examples/embed_file.py Normal file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""
File Embedding Example
This example demonstrates how to embed a file (like a document or image)
inside a carrier image using Stegasoo.
"""
from pathlib import Path
import stegasoo
from stegasoo.models import FilePayload
def main():
# Load images
reference_photo = Path("my_secret_photo.png").read_bytes()
carrier_image = Path("carrier.png").read_bytes()
# Load the file to embed
secret_file = Path("secret_document.pdf")
file_data = secret_file.read_bytes()
# Create a FilePayload
payload = FilePayload(
filename=secret_file.name,
data=file_data,
mime_type="application/pdf",
)
# Credentials
passphrase = "correct horse battery staple"
pin = "123456"
# Check capacity first
capacity = stegasoo.calculate_capacity(carrier_image)
print(f"Carrier capacity: {capacity['capacity_bytes']:,} bytes")
print(f"File size: {len(file_data):,} bytes")
if len(file_data) > capacity["capacity_bytes"]:
print("Error: File too large for this carrier!")
return
# Encode the file
print("\nEmbedding file...")
result = stegasoo.encode(
file_payload=payload,
reference_photo=reference_photo,
carrier_image=carrier_image,
passphrase=passphrase,
pin=pin,
)
output_path = Path(f"contains_file_{result.suggested_filename}")
output_path.write_bytes(result.stego_image)
print(f"Saved to: {output_path}")
# Decode and extract the file
print("\nExtracting file...")
decoded = stegasoo.decode(
stego_image=output_path.read_bytes(),
reference_photo=reference_photo,
passphrase=passphrase,
pin=pin,
)
if decoded.payload_type == "file":
extracted_path = Path(f"extracted_{decoded.filename}")
extracted_path.write_bytes(decoded.file_data)
print(f"Extracted: {extracted_path}")
print(f"Original filename: {decoded.filename}")
print(f"MIME type: {decoded.mime_type}")
else:
print(f"Unexpected payload type: {decoded.payload_type}")
if __name__ == "__main__":
main()

View File

@@ -1,952 +0,0 @@
# Stegasoo REST API Documentation
Complete REST API reference for Stegasoo steganography operations.
## Table of Contents
- [Overview](#overview)
- [Installation](#installation)
- [Authentication](#authentication)
- [Base URL](#base-url)
- [Endpoints](#endpoints)
- [GET /](#get--status)
- [POST /generate](#post-generate)
- [POST /encode](#post-encode-json)
- [POST /encode/multipart](#post-encodemultipart)
- [POST /decode](#post-decode-json)
- [POST /decode/multipart](#post-decodemultipart)
- [POST /image/info](#post-imageinfo)
- [Data Models](#data-models)
- [Error Handling](#error-handling)
- [Code Examples](#code-examples)
- [Rate Limiting](#rate-limiting)
- [Security Considerations](#security-considerations)
---
## Overview
The Stegasoo REST API provides programmatic access to all steganography operations:
- **Generate** credentials (phrases, PINs, RSA keys)
- **Encode** messages into images
- **Decode** messages from images
- **Analyze** image capacity
The API supports both JSON (base64-encoded images) and multipart form data (direct file uploads).
---
## Installation
### From PyPI
```bash
pip install stegasoo[api]
```
### From Source
```bash
git clone https://github.com/example/stegasoo.git
cd stegasoo
pip install -e ".[api]"
```
### Running the Server
**Development:**
```bash
cd frontends/api
python main.py
```
**Production:**
```bash
cd frontends/api
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
```
**Docker:**
```bash
docker-compose up api
```
---
## Authentication
The API currently operates without authentication. For production deployments, implement authentication at the reverse proxy level (nginx, Caddy) or add API key middleware.
---
## Base URL
| Environment | URL |
|-------------|-----|
| Local Development | `http://localhost:8000` |
| Docker | `http://localhost:8000` |
| Production | Configure as needed |
---
## Endpoints
### GET / (Status)
Check API status and configuration.
#### Request
```http
GET / HTTP/1.1
Host: localhost:8000
```
#### Response
```json
{
"version": "2.0.1",
"has_argon2": true,
"day_names": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
}
```
#### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| `version` | string | Stegasoo library version |
| `has_argon2` | boolean | Whether Argon2id is available |
| `day_names` | array | Day names for phrase mapping |
#### cURL Example
```bash
curl http://localhost:8000/
```
---
### POST /generate
Generate credentials for encoding/decoding.
#### Request
```http
POST /generate HTTP/1.1
Host: localhost:8000
Content-Type: application/json
```
#### Request Body
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `use_pin` | boolean | `true` | Generate a PIN |
| `use_rsa` | boolean | `false` | Generate an RSA key |
| `pin_length` | integer | `6` | PIN length (6-9) |
| `rsa_bits` | integer | `2048` | RSA key size (2048, 3072, 4096) |
| `words_per_phrase` | integer | `3` | Words per phrase (3-12) |
#### Response
```json
{
"phrases": {
"Monday": "abandon ability able",
"Tuesday": "actor actress actual",
"Wednesday": "advice aerobic affair",
"Thursday": "afraid again age",
"Friday": "agree ahead aim",
"Saturday": "airport aisle alarm",
"Sunday": "album alcohol alert"
},
"pin": "847293",
"rsa_key_pem": null,
"entropy": {
"phrase": 33,
"pin": 19,
"rsa": 0,
"total": 52
}
}
```
#### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| `phrases` | object | Day-to-phrase mapping |
| `pin` | string\|null | Generated PIN (if requested) |
| `rsa_key_pem` | string\|null | PEM-encoded RSA key (if requested) |
| `entropy.phrase` | integer | Entropy from phrases (bits) |
| `entropy.pin` | integer | Entropy from PIN (bits) |
| `entropy.rsa` | integer | Entropy from RSA key (bits) |
| `entropy.total` | integer | Combined entropy (bits) |
#### cURL Examples
**PIN only:**
```bash
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{"use_pin": true, "use_rsa": false}'
```
**RSA only:**
```bash
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{"use_pin": false, "use_rsa": true, "rsa_bits": 4096}'
```
**Both with custom settings:**
```bash
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{
"use_pin": true,
"use_rsa": true,
"pin_length": 9,
"rsa_bits": 4096,
"words_per_phrase": 6
}'
```
---
### POST /encode (JSON)
Encode a message using base64-encoded images.
#### Request
```http
POST /encode HTTP/1.1
Host: localhost:8000
Content-Type: application/json
```
#### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `message` | string | ✓ | Message to encode |
| `reference_photo_base64` | string | ✓ | Base64-encoded reference photo |
| `carrier_image_base64` | string | ✓ | Base64-encoded carrier image |
| `day_phrase` | string | ✓ | Today's passphrase |
| `pin` | string | * | Static PIN (6-9 digits) |
| `rsa_key_base64` | string | * | Base64-encoded RSA key PEM |
| `rsa_password` | string | | Password for RSA key |
| `date_str` | string | | Date override (YYYY-MM-DD) |
\* At least one of `pin` or `rsa_key_base64` required.
#### Response
```json
{
"stego_image_base64": "iVBORw0KGgo...",
"filename": "a1b2c3d4_20251227.png",
"capacity_used_percent": 12.4,
"date_used": "2025-12-27",
"day_of_week": "Saturday"
}
```
#### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| `stego_image_base64` | string | Base64-encoded stego PNG |
| `filename` | string | Suggested filename |
| `capacity_used_percent` | float | Percentage of capacity used |
| `date_used` | string | Date embedded in image (YYYY-MM-DD) |
| `day_of_week` | string | Day name for passphrase rotation |
#### cURL Example
```bash
# Prepare base64-encoded images
REF_B64=$(base64 -w0 reference.jpg)
CARRIER_B64=$(base64 -w0 carrier.png)
curl -X POST http://localhost:8000/encode \
-H "Content-Type: application/json" \
-d "{
\"message\": \"Secret message\",
\"reference_photo_base64\": \"$REF_B64\",
\"carrier_image_base64\": \"$CARRIER_B64\",
\"day_phrase\": \"apple forest thunder\",
\"pin\": \"123456\"
}" | jq -r '.stego_image_base64' | base64 -d > stego.png
```
---
### POST /encode/multipart
Encode a message using direct file uploads. Returns the stego image directly.
#### Request
```http
POST /encode/multipart HTTP/1.1
Host: localhost:8000
Content-Type: multipart/form-data; boundary=----FormBoundary
------FormBoundary
Content-Disposition: form-data; name="message"
Secret message here
------FormBoundary
Content-Disposition: form-data; name="day_phrase"
apple forest thunder
------FormBoundary
Content-Disposition: form-data; name="pin"
123456
------FormBoundary
Content-Disposition: form-data; name="reference_photo"; filename="ref.jpg"
Content-Type: image/jpeg
<binary image data>
------FormBoundary
Content-Disposition: form-data; name="carrier"; filename="carrier.png"
Content-Type: image/png
<binary image data>
------FormBoundary--
```
#### Form Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `message` | string | ✓ | Message to encode |
| `reference_photo` | file | ✓ | Reference photo file |
| `carrier` | file | ✓ | Carrier image file |
| `day_phrase` | string | ✓ | Today's passphrase |
| `pin` | string | * | Static PIN |
| `rsa_key` | file | * | RSA key file (.pem) |
| `rsa_password` | string | | Password for RSA key |
| `date_str` | string | | Date override (YYYY-MM-DD) |
\* At least one of `pin` or `rsa_key` required.
#### Response
Returns the PNG image directly with headers:
- `Content-Type: image/png`
- `Content-Disposition: attachment; filename=<generated_filename>.png`
- `X-Stegasoo-Date: 2025-12-27` (date used for encoding)
- `X-Stegasoo-Day: Saturday` (day of week for passphrase rotation)
- `X-Stegasoo-Capacity-Percent: 12.4` (capacity used)
#### cURL Examples
**With PIN:**
```bash
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Secret message" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
```
**With RSA key:**
```bash
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Secret message" \
-F "day_phrase=apple forest thunder" \
-F "rsa_key=@mykey.pem" \
-F "rsa_password=keypassword" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
```
**With both PIN and RSA:**
```bash
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Maximum security message" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "rsa_key=@mykey.pem" \
-F "rsa_password=keypassword" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
```
**With custom date:**
```bash
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Backdated message" \
-F "day_phrase=monday phrase here" \
-F "pin=123456" \
-F "date_str=2025-12-29" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
```
---
### POST /decode (JSON)
Decode a message using base64-encoded images.
#### Request
```http
POST /decode HTTP/1.1
Host: localhost:8000
Content-Type: application/json
```
#### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `stego_image_base64` | string | ✓ | Base64-encoded stego image |
| `reference_photo_base64` | string | ✓ | Base64-encoded reference photo |
| `day_phrase` | string | ✓ | Passphrase for encoding day |
| `pin` | string | * | Static PIN |
| `rsa_key_base64` | string | * | Base64-encoded RSA key |
| `rsa_password` | string | | Password for RSA key |
\* Must match the security factors used during encoding.
#### Response
```json
{
"message": "Secret message here"
}
```
#### cURL Example
```bash
# Prepare base64-encoded images
STEGO_B64=$(base64 -w0 stego.png)
REF_B64=$(base64 -w0 reference.jpg)
curl -X POST http://localhost:8000/decode \
-H "Content-Type: application/json" \
-d "{
\"stego_image_base64\": \"$STEGO_B64\",
\"reference_photo_base64\": \"$REF_B64\",
\"day_phrase\": \"apple forest thunder\",
\"pin\": \"123456\"
}"
```
---
### POST /decode/multipart
Decode a message using direct file uploads.
#### Request
```http
POST /decode/multipart HTTP/1.1
Host: localhost:8000
Content-Type: multipart/form-data
```
#### Form Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `stego_image` | file | ✓ | Stego image file |
| `reference_photo` | file | ✓ | Reference photo file |
| `day_phrase` | string | ✓ | Passphrase for encoding day |
| `pin` | string | * | Static PIN |
| `rsa_key` | file | * | RSA key file |
| `rsa_password` | string | | Password for RSA key |
#### Response
```json
{
"message": "Secret message here"
}
```
#### cURL Examples
**With PIN:**
```bash
curl -X POST http://localhost:8000/decode/multipart \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@reference.jpg" \
-F "stego_image=@stego.png"
```
**With RSA key:**
```bash
curl -X POST http://localhost:8000/decode/multipart \
-F "day_phrase=apple forest thunder" \
-F "rsa_key=@mykey.pem" \
-F "rsa_password=keypassword" \
-F "reference_photo=@reference.jpg" \
-F "stego_image=@stego.png"
```
---
### POST /image/info
Get information about an image's capacity.
#### Request
```http
POST /image/info HTTP/1.1
Host: localhost:8000
Content-Type: multipart/form-data
```
#### Form Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `image` | file | ✓ | Image file to analyze |
#### Response
```json
{
"width": 1920,
"height": 1080,
"pixels": 2073600,
"capacity_bytes": 776970,
"capacity_kb": 758
}
```
#### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| `width` | integer | Image width in pixels |
| `height` | integer | Image height in pixels |
| `pixels` | integer | Total pixel count |
| `capacity_bytes` | integer | Maximum message capacity (bytes) |
| `capacity_kb` | integer | Maximum message capacity (KB) |
#### cURL Example
```bash
curl -X POST http://localhost:8000/image/info \
-F "image=@myimage.png"
```
---
## Data Models
### GenerateRequest
```json
{
"use_pin": true,
"use_rsa": false,
"pin_length": 6,
"rsa_bits": 2048,
"words_per_phrase": 3
}
```
### GenerateResponse
```json
{
"phrases": {"Monday": "...", "Tuesday": "...", ...},
"pin": "123456",
"rsa_key_pem": "-----BEGIN PRIVATE KEY-----...",
"entropy": {"phrase": 33, "pin": 19, "rsa": 0, "total": 52}
}
```
### EncodeRequest
```json
{
"message": "string",
"reference_photo_base64": "string",
"carrier_image_base64": "string",
"day_phrase": "string",
"pin": "string",
"rsa_key_base64": "string",
"rsa_password": "string",
"date_str": "YYYY-MM-DD"
}
```
### EncodeResponse
```json
{
"stego_image_base64": "string",
"filename": "string",
"capacity_used_percent": 12.4,
"date_used": "YYYY-MM-DD",
"day_of_week": "Saturday"
}
```
### DecodeRequest
```json
{
"stego_image_base64": "string",
"reference_photo_base64": "string",
"day_phrase": "string",
"pin": "string",
"rsa_key_base64": "string",
"rsa_password": "string"
}
```
### DecodeResponse
```json
{
"message": "string"
}
```
### ImageInfoResponse
```json
{
"width": 1920,
"height": 1080,
"pixels": 2073600,
"capacity_bytes": 776970,
"capacity_kb": 758
}
```
### ErrorResponse
```json
{
"error": "ErrorType",
"detail": "Error description"
}
```
---
## Error Handling
### HTTP Status Codes
| Code | Meaning | Use Case |
|------|---------|----------|
| 200 | OK | Successful operation |
| 400 | Bad Request | Invalid input, capacity error |
| 401 | Unauthorized | Decryption failed (wrong credentials) |
| 500 | Internal Error | Unexpected server error |
### Error Response Format
```json
{
"detail": "Error message describing the problem"
}
```
### Common Errors
| Status | Error | Solution |
|--------|-------|----------|
| 400 | "Must enable at least one of use_pin or use_rsa" | Set `use_pin` or `use_rsa` to true |
| 400 | "rsa_bits must be one of [2048, 3072, 4096]" | Use valid RSA key size |
| 400 | "Carrier image too small" | Use larger carrier image |
| 400 | "PIN must be 6-9 digits" | Fix PIN format |
| 401 | "Decryption failed. Check credentials." | Verify phrase, PIN, ref photo |
| 400 | "Message too long" | Reduce message size or use larger carrier |
---
## Code Examples
### Python with requests
```python
import base64
import requests
BASE_URL = "http://localhost:8000"
# Generate credentials
response = requests.post(f"{BASE_URL}/generate", json={
"use_pin": True,
"use_rsa": False,
"words_per_phrase": 3
})
creds = response.json()
print(f"PIN: {creds['pin']}")
print(f"Monday phrase: {creds['phrases']['Monday']}")
# Encode using multipart
with open("reference.jpg", "rb") as ref, open("carrier.png", "rb") as carrier:
response = requests.post(f"{BASE_URL}/encode/multipart", files={
"reference_photo": ref,
"carrier": carrier,
}, data={
"message": "Secret message",
"day_phrase": "apple forest thunder",
"pin": "123456"
})
with open("stego.png", "wb") as f:
f.write(response.content)
# Decode using multipart
with open("reference.jpg", "rb") as ref, open("stego.png", "rb") as stego:
response = requests.post(f"{BASE_URL}/decode/multipart", files={
"reference_photo": ref,
"stego_image": stego,
}, data={
"day_phrase": "apple forest thunder",
"pin": "123456"
})
print(f"Decoded: {response.json()['message']}")
```
### JavaScript/Node.js
```javascript
const FormData = require('form-data');
const fs = require('fs');
const axios = require('axios');
const BASE_URL = 'http://localhost:8000';
async function encode() {
const form = new FormData();
form.append('message', 'Secret message');
form.append('day_phrase', 'apple forest thunder');
form.append('pin', '123456');
form.append('reference_photo', fs.createReadStream('reference.jpg'));
form.append('carrier', fs.createReadStream('carrier.png'));
const response = await axios.post(`${BASE_URL}/encode/multipart`, form, {
headers: form.getHeaders(),
responseType: 'arraybuffer'
});
fs.writeFileSync('stego.png', response.data);
console.log('Encoded successfully');
}
async function decode() {
const form = new FormData();
form.append('day_phrase', 'apple forest thunder');
form.append('pin', '123456');
form.append('reference_photo', fs.createReadStream('reference.jpg'));
form.append('stego_image', fs.createReadStream('stego.png'));
const response = await axios.post(`${BASE_URL}/decode/multipart`, form, {
headers: form.getHeaders()
});
console.log('Decoded:', response.data.message);
}
encode().then(decode);
```
### Go
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
)
func main() {
// Encode
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("message", "Secret message")
writer.WriteField("day_phrase", "apple forest thunder")
writer.WriteField("pin", "123456")
ref, _ := os.Open("reference.jpg")
refPart, _ := writer.CreateFormFile("reference_photo", "reference.jpg")
io.Copy(refPart, ref)
ref.Close()
carrier, _ := os.Open("carrier.png")
carrierPart, _ := writer.CreateFormFile("carrier", "carrier.png")
io.Copy(carrierPart, carrier)
carrier.Close()
writer.Close()
resp, _ := http.Post(
"http://localhost:8000/encode/multipart",
writer.FormDataContentType(),
body,
)
stego, _ := os.Create("stego.png")
io.Copy(stego, resp.Body)
stego.Close()
resp.Body.Close()
fmt.Println("Encoded successfully")
}
```
### Shell Script (Bash)
```bash
#!/bin/bash
BASE_URL="http://localhost:8000"
REF_PHOTO="reference.jpg"
CARRIER="carrier.png"
PHRASE="apple forest thunder"
PIN="123456"
MESSAGE="Secret message"
# Encode
echo "Encoding..."
curl -s -X POST "$BASE_URL/encode/multipart" \
-F "message=$MESSAGE" \
-F "day_phrase=$PHRASE" \
-F "pin=$PIN" \
-F "reference_photo=@$REF_PHOTO" \
-F "carrier=@$CARRIER" \
--output stego.png
echo "Encoded to stego.png"
# Decode
echo "Decoding..."
DECODED=$(curl -s -X POST "$BASE_URL/decode/multipart" \
-F "day_phrase=$PHRASE" \
-F "pin=$PIN" \
-F "reference_photo=@$REF_PHOTO" \
-F "stego_image=@stego.png" | jq -r '.message')
echo "Decoded message: $DECODED"
```
---
## Rate Limiting
The API does not implement rate limiting by default. For production:
1. **Reverse Proxy**: Use nginx or Caddy rate limiting
2. **Application Level**: Add FastAPI middleware
Example nginx rate limiting:
```nginx
limit_req_zone $binary_remote_addr zone=stegasoo:10m rate=10r/s;
location /api/ {
limit_req zone=stegasoo burst=20 nodelay;
proxy_pass http://localhost:8000/;
}
```
---
## Security Considerations
### In Transit
- Use HTTPS in production
- Configure TLS at reverse proxy level
### Memory Usage
- Argon2id requires 256MB RAM per operation
- Concurrent requests can exhaust memory
- Limit workers based on available RAM
### Input Validation
The API validates:
- PIN format (6-9 digits, no leading zero)
- Message size (max 50KB)
- Image size (max 5MB file, ~4MP dimensions)
- RSA key validity
### Credential Handling
- Credentials are never logged
- No persistent storage of secrets
- Memory cleared after operations
---
## Interactive Documentation
When the API is running, visit:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
---
## See Also
- [CLI Documentation](CLI.md) - Command-line interface
- [Web UI Documentation](WEB_UI.md) - Browser interface
- [README](README.md) - Project overview
- Image size (max 5MB file, ~4MP dimensions)
- RSA key validity
### Credential Handling
- Credentials are never logged
- No persistent storage of secrets
- Memory cleared after operations
---
## Interactive Documentation
When the API is running, visit:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
---
## See Also
- [CLI Documentation](CLI.md) - Command-line interface
- [Web UI Documentation](WEB_UI.md) - Browser interface
- [README](README.md) - Project overview

View File

@@ -1,634 +0,0 @@
# Stegasoo CLI Documentation
Complete command-line interface reference for Stegasoo steganography operations.
## Table of Contents
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Commands](#commands)
- [generate](#generate-command)
- [encode](#encode-command)
- [decode](#decode-command)
- [info](#info-command)
- [Security Factors](#security-factors)
- [Workflow Examples](#workflow-examples)
- [Piping & Scripting](#piping--scripting)
- [Error Handling](#error-handling)
- [Exit Codes](#exit-codes)
---
## Installation
### From PyPI
```bash
# CLI only
pip install stegasoo[cli]
# With all extras
pip install stegasoo[all]
```
### From Source
```bash
git clone https://github.com/example/stegasoo.git
cd stegasoo
pip install -e ".[cli]"
```
### Verify Installation
```bash
stegasoo --version
stegasoo --help
```
---
## Quick Start
```bash
# 1. Generate credentials (do this once, memorize results)
stegasoo generate --pin --words 3
# 2. Encode a message
stegasoo encode \
--ref secret_photo.jpg \
--carrier meme.png \
--phrase "apple forest thunder" \
--pin 123456 \
--message "Meet at midnight"
# 3. Decode a message
stegasoo decode \
--ref secret_photo.jpg \
--stego stego_abc123_20251227.png \
--phrase "apple forest thunder" \
--pin 123456
```
---
## Commands
### Generate Command
Generate credentials for encoding/decoding operations. Creates daily passphrases and optionally a PIN and/or RSA key.
#### Synopsis
```bash
stegasoo generate [OPTIONS]
```
#### Options
| Option | Short | Type | Default | Description |
|--------|-------|------|---------|-------------|
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
| `--pin-length` | | 6-9 | 6 | PIN length in digits |
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072, 4096) |
| `--words` | | 3-12 | 3 | Words per daily phrase |
| `--output` | `-o` | path | | Save RSA key to file |
| `--password` | `-p` | string | | Password for RSA key file |
| `--json` | | flag | | Output as JSON |
#### Examples
**Basic generation with PIN (default):**
```bash
stegasoo generate
```
Output:
```
════════════════════════════════════════════════════════════
STEGASOO CREDENTIALS
════════════════════════════════════════════════════════════
⚠️ MEMORIZE THESE AND CLOSE THIS WINDOW
Do not screenshot or save to file!
─── STATIC PIN ───
847293
─── DAILY PHRASES ───
Monday │ abandon ability able
Tuesday │ actor actress actual
Wednesday │ advice aerobic affair
Thursday │ afraid again age
Friday │ agree ahead aim
Saturday │ airport aisle alarm
Sunday │ album alcohol alert
─── SECURITY ───
Phrase entropy: 33 bits
PIN entropy: 19 bits
Combined: 52 bits
+ photo entropy: 80-256 bits
```
**Generate with RSA key:**
```bash
stegasoo generate --rsa --rsa-bits 4096
```
**Save RSA key to encrypted file:**
```bash
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"
```
**Maximum security (longer phrases + both factors):**
```bash
stegasoo generate --pin --rsa --words 6 --pin-length 9
```
**JSON output for scripting:**
```bash
stegasoo generate --json | jq '.phrases.Monday'
```
**RSA only (no PIN):**
```bash
stegasoo generate --no-pin --rsa -o key.pem -p "password123"
```
---
### Encode Command
Encode a secret message into an image using steganography.
#### Synopsis
```bash
stegasoo encode [OPTIONS]
```
#### Options
| Option | Short | Type | Required | Description |
|--------|-------|------|----------|-------------|
| `--ref` | `-r` | path | ✓ | Reference photo (shared secret) |
| `--carrier` | `-c` | path | ✓ | Carrier image to hide message in |
| `--phrase` | `-p` | string | ✓ | Today's passphrase |
| `--message` | `-m` | string | | Message to encode |
| `--message-file` | `-f` | path | | Read message from file |
| `--pin` | | string | * | Static PIN (6-9 digits) |
| `--key` | `-k` | path | * | RSA key file |
| `--key-password` | | string | | Password for RSA key |
| `--output` | `-o` | path | | Output filename |
| `--date` | | YYYY-MM-DD | | Date override |
| `--quiet` | `-q` | flag | | Suppress output |
\* At least one of `--pin` or `--key` is required.
#### Message Input Methods
1. **Command line argument:**
```bash
stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 -m "Secret message"
```
2. **From file:**
```bash
stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 -f message.txt
```
3. **From stdin (pipe):**
```bash
echo "Secret message" | stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456
```
#### Examples
**Basic encoding with PIN:**
```bash
stegasoo encode \
--ref photos/vacation.jpg \
--carrier memes/funny_cat.png \
--phrase "correct horse battery" \
--pin 847293 \
--message "The package arrives Tuesday"
```
Output:
```
✓ Encoded successfully!
Output: a1b2c3d4_20251227.png
Size: 245,832 bytes
Capacity used: 12.4%
Date: 2025-12-27
```
**With RSA key:**
```bash
stegasoo encode \
-r reference.jpg \
-c carrier.png \
-p "apple forest thunder" \
-k mykey.pem \
--key-password "secretpassword" \
-m "Encrypted with RSA"
```
**Both PIN and RSA (maximum security):**
```bash
stegasoo encode \
-r ref.jpg \
-c carrier.png \
-p "word1 word2 word3" \
--pin 123456 \
-k mykey.pem \
--key-password "pass" \
-m "Double-locked message"
```
**Custom output filename:**
```bash
stegasoo encode \
-r ref.jpg \
-c carrier.png \
-p "phrase words here" \
--pin 123456 \
-m "Message" \
-o holiday_photo.png
```
**Encoding with specific date (for testing):**
```bash
stegasoo encode \
-r ref.jpg \
-c carrier.png \
-p "monday phrase here" \
--pin 123456 \
-m "Message" \
--date 2025-12-29
```
**Long message from file:**
```bash
stegasoo encode \
-r ref.jpg \
-c large_image.png \
-p "phrase" \
--pin 123456 \
-f secret_document.txt \
-o output.png
```
**Quiet mode for scripting:**
```bash
stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 -m "msg" -q -o out.png
# No output, just creates the file
```
---
### Decode Command
Decode a secret message from a stego image.
#### Synopsis
```bash
stegasoo decode [OPTIONS]
```
#### Options
| Option | Short | Type | Required | Description |
|--------|-------|------|----------|-------------|
| `--ref` | `-r` | path | ✓ | Reference photo (same as encoding) |
| `--stego` | `-s` | path | ✓ | Stego image to decode |
| `--phrase` | `-p` | string | ✓ | Passphrase for the encoding day |
| `--pin` | | string | * | Static PIN |
| `--key` | `-k` | path | * | RSA key file |
| `--key-password` | | string | | Password for RSA key |
| `--output` | `-o` | path | | Save message to file |
| `--quiet` | `-q` | flag | | Output only the message |
\* Must provide the same security factors used during encoding.
#### Examples
**Basic decoding with PIN:**
```bash
stegasoo decode \
--ref photos/vacation.jpg \
--stego received_image.png \
--phrase "correct horse battery" \
--pin 847293
```
Output:
```
✓ Decoded successfully!
The package arrives Tuesday
```
**With RSA key:**
```bash
stegasoo decode \
-r reference.jpg \
-s stego_image.png \
-p "apple forest thunder" \
-k mykey.pem \
--key-password "secretpassword"
```
**Save decoded message to file:**
```bash
stegasoo decode \
-r ref.jpg \
-s stego.png \
-p "phrase" \
--pin 123456 \
-o decoded_message.txt
```
Output:
```
✓ Decoded successfully!
Saved to: decoded_message.txt
```
**Quiet mode (message only):**
```bash
stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q
```
Output:
```
The package arrives Tuesday
```
**Pipe to another command:**
```bash
stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q | gpg --decrypt
```
---
### Info Command
Display information about an image's capacity and embedded date.
#### Synopsis
```bash
stegasoo info IMAGE
```
#### Arguments
| Argument | Type | Description |
|----------|------|-------------|
| `IMAGE` | path | Path to image file |
#### Examples
**Check carrier image capacity:**
```bash
stegasoo info vacation_photo.png
```
Output:
```
Image: vacation_photo.png
Dimensions: 1920 × 1080
Pixels: 2,073,600
Mode: RGB
Format: PNG
Capacity: ~776,970 bytes (758 KB)
```
**Check stego image (shows encoding date):**
```bash
stegasoo info stego_a1b2c3d4_20251227.png
```
Output:
```
Image: stego_a1b2c3d4_20251227.png
Dimensions: 1920 × 1080
Pixels: 2,073,600
Mode: RGB
Format: PNG
Capacity: ~776,970 bytes (758 KB)
Embed date: 2025-12-27 (Saturday)
```
---
## Security Factors
Stegasoo uses multiple authentication factors:
| Factor | Description | Entropy |
|--------|-------------|---------|
| Reference Photo | A photo both parties have | ~80-256 bits |
| Day Phrase | Changes daily (e.g., 3 BIP-39 words) | ~33 bits (3 words) |
| Static PIN | Same every day (6-9 digits) | ~20 bits (6 digits) |
| RSA Key | Shared key file | ~128 bits effective |
### Minimum Requirements
- At least one of PIN or RSA key must be provided
- Reference photo is always required
- Day phrase is always required
### Security Configurations
| Configuration | Entropy (excl. photo) | Use Case |
|--------------|----------------------|----------|
| 3-word phrase + 6-digit PIN | ~53 bits | Casual use |
| 6-word phrase + 9-digit PIN | ~96 bits | Standard security |
| 3-word phrase + RSA 2048 | ~161 bits | File-based auth |
| 6-word phrase + PIN + RSA | ~224 bits | Maximum security |
---
## Workflow Examples
### Daily Secure Communication
**Setup (once):**
```bash
# Both parties generate same credentials
stegasoo generate --pin --words 3
# Or share RSA key securely
stegasoo generate --rsa -o shared_key.pem -p "agreedpassword"
# Securely transfer shared_key.pem to recipient
```
**Sender (daily):**
```bash
# Get today's phrase from your memorized list
TODAY_PHRASE="monday phrase words"
# Encode message
stegasoo encode \
-r our_shared_photo.jpg \
-c random_meme.png \
-p "$TODAY_PHRASE" \
--pin 847293 \
-m "Meeting moved to 3pm"
# Share output image via normal channels (email, chat, etc.)
```
**Recipient (daily):**
```bash
# Use the phrase for the day the message was SENT
stegasoo decode \
-r our_shared_photo.jpg \
-s received_image.png \
-p "monday phrase words" \
--pin 847293
```
### Batch Processing
**Encode multiple messages:**
```bash
#!/bin/bash
PHRASE="apple forest thunder"
PIN="123456"
REF="reference.jpg"
for file in messages/*.txt; do
name=$(basename "$file" .txt)
stegasoo encode \
-r "$REF" \
-c "carriers/${name}.png" \
-p "$PHRASE" \
--pin "$PIN" \
-f "$file" \
-o "output/${name}_stego.png" \
-q
echo "Encoded: $name"
done
```
### Archive with Date Preservation
```bash
# Encode with specific date for archival
stegasoo encode \
-r ref.jpg \
-c carrier.png \
-p "archive phrase words" \
--pin 123456 \
-m "Historical record" \
--date 2025-01-15 \
-o archive_2025-01-15.png
```
---
## Piping & Scripting
### Stdin/Stdout Support
**Encode from pipe:**
```bash
cat secret.txt | stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 -o out.png
```
**Decode to pipe:**
```bash
stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q | less
```
**Chain with encryption:**
```bash
# Encode GPG-encrypted content
gpg -e -r recipient@email.com secret.txt
cat secret.txt.gpg | base64 | stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456
# Decode and decrypt
stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q | base64 -d | gpg -d
```
### JSON Output for Scripts
```bash
# Get credentials as JSON
creds=$(stegasoo generate --json)
# Extract specific fields
pin=$(echo "$creds" | jq -r '.pin')
monday=$(echo "$creds" | jq -r '.phrases.Monday')
entropy=$(echo "$creds" | jq -r '.entropy.total')
echo "PIN: $pin"
echo "Monday phrase: $monday"
echo "Total entropy: $entropy bits"
```
### Error Handling in Scripts
```bash
#!/bin/bash
set -e
if ! stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q 2>/dev/null; then
echo "Decryption failed - check credentials"
exit 1
fi
```
---
## Error Handling
### Common Errors
| Error | Cause | Solution |
|-------|-------|----------|
| "Must provide --pin or --key" | No security factor given | Add `--pin` or `--key` option |
| "PIN must be 6-9 digits" | Invalid PIN format | Use numeric PIN, 6-9 chars |
| "PIN cannot start with zero" | Leading zero in PIN | Use PIN starting with 1-9 |
| "Carrier image too small" | Message exceeds capacity | Use larger carrier image |
| "Decryption failed" | Wrong credentials | Verify phrase, PIN, ref photo |
| "RSA key is password-protected" | Missing key password | Add `--key-password` option |
### Troubleshooting Decryption Failures
1. **Check the encoding date:** The filename often contains the date (e.g., `_20251227`)
2. **Use correct phrase:** The phrase must match the day the message was encoded, not today
3. **Verify reference photo:** Must be the exact same file, not a resized copy
4. **Check stego image:** Ensure it wasn't resized, recompressed, or converted
---
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | General error |
| 2 | Invalid arguments/options |
---
## Environment Variables
| Variable | Description |
|----------|-------------|
| `PYTHONPATH` | Include `src/` for development |
---
## See Also
- [API Documentation](API.md) - REST API reference
- [Web UI Documentation](WEB_UI.md) - Browser interface guide
- [README](README.md) - Project overview and security model

View File

@@ -1,739 +0,0 @@
# Stegasoo Web UI Documentation
Complete guide for the Stegasoo web-based steganography interface.
## Table of Contents
- [Overview](#overview)
- [Installation & Setup](#installation--setup)
- [Pages & Features](#pages--features)
- [Home Page](#home-page)
- [Generate Credentials](#generate-credentials)
- [Encode Message](#encode-message)
- [Decode Message](#decode-message)
- [About Page](#about-page)
- [User Interface Guide](#user-interface-guide)
- [Workflow Examples](#workflow-examples)
- [Security Features](#security-features)
- [Configuration](#configuration)
- [Troubleshooting](#troubleshooting)
- [Mobile Support](#mobile-support)
---
## Overview
The Stegasoo Web UI provides a user-friendly browser-based interface for:
- **Generating** secure credentials (phrases, PINs, RSA keys)
- **Encoding** secret messages into images
- **Decoding** hidden messages from images
- **Learning** about the security model
Built with Flask, Bootstrap 5, and a modern dark theme.
### Features
- ✅ Drag-and-drop file uploads
- ✅ Image previews
- ✅ Client-side date detection
- ✅ Native sharing (Web Share API)
- ✅ Responsive design (mobile-friendly)
- ✅ Password-protected RSA key downloads
- ✅ Real-time entropy calculations
- ✅ Automatic file cleanup
---
## Installation & Setup
### From PyPI
```bash
pip install stegasoo[web]
```
### From Source
```bash
git clone https://github.com/example/stegasoo.git
cd stegasoo
pip install -e ".[web]"
```
### Running the Server
**Development:**
```bash
cd frontends/web
python app.py
```
Server starts at http://localhost:5000
**Production with Gunicorn:**
```bash
cd frontends/web
gunicorn --bind 0.0.0.0:5000 --workers 2 --threads 4 --timeout 60 app:app
```
**Docker:**
```bash
docker-compose up web
```
### First-Time Setup
1. Navigate to http://localhost:5000
2. Click "Generate" to create your credentials
3. **Memorize** your phrases and PIN
4. Share credentials securely with your communication partner
---
## Pages & Features
### Home Page
**URL:** `/`
The landing page introduces Stegasoo and provides quick access to all features.
#### Main Actions
| Card | Description | Link |
|------|-------------|------|
| **Encode Message** | Hide a secret in an image | `/encode` |
| **Decode Message** | Extract a hidden message | `/decode` |
| **Generate Keys** | Create new credentials | `/generate` |
#### "How It Works" Section
Explains the three key components:
1. **Reference Photo** - Shared secret image
2. **Day Phrase** - Changes daily
3. **Static PIN** - Same every day
---
### Generate Credentials
**URL:** `/generate`
Create a new set of credentials for steganography operations.
#### Configuration Options
| Option | Range | Default | Description |
|--------|-------|---------|-------------|
| Words per phrase | 3-12 | 3 | BIP-39 words per daily phrase |
| Use PIN | on/off | on | Generate a numeric PIN |
| PIN length | 6-9 | 6 | Digits in the PIN |
| Use RSA Key | on/off | off | Generate an RSA key pair |
| RSA key size | 2048/3072/4096 | 2048 | Key size in bits |
#### Entropy Calculator
The UI displays real-time entropy calculations:
```
Estimated entropy: ~53 bits
[==========> ] Good for most use cases
• Reference photo adds ~80-256 bits more
```
#### Generated Output
After clicking "Generate Credentials":
**Static PIN** (if enabled):
```
┌─────────────────────┐
│ 8 4 7 2 9 3 │
└─────────────────────┘
Use this 6-digit PIN every day
```
**Daily Phrases:**
```
Day │ Phrase
─────────────────────────────────────────
Monday │ abandon ability able
Tuesday │ actor actress actual
Wednesday │ advice aerobic affair
Thursday │ afraid again age
Friday │ agree ahead aim
Saturday │ airport aisle alarm
Sunday │ album alcohol alert
```
**RSA Key** (if enabled):
- Copy to clipboard button
- Download as password-protected .pem file
**Security Summary:**
```
Phrase entropy: 33 bits/phrase
PIN entropy: 19 bits/PIN
RSA entropy: 128 bits/RSA
─────────────────────────────
Total: 180 bits
+ reference photo (~80-256 bits) = 260+ bits combined
```
#### RSA Key Download
1. Click "Download as .pem"
2. Enter a password (minimum 8 characters)
3. Click "Download Protected Key"
4. Save the file securely
5. Share with your communication partner through a secure channel
---
### Encode Message
**URL:** `/encode`
Hide a secret message inside an image.
#### Input Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| Reference Photo | Image file | ✓ | Your shared secret photo |
| Carrier Image | Image file | ✓ | Image to hide message in |
| Secret Message | Text | ✓ | Message to hide (max 50KB) |
| Day Phrase | Text | ✓ | Today's passphrase |
| PIN | Number | * | Your static PIN |
| RSA Key | .pem file | * | Your shared RSA key |
| RSA Key Password | Password | | Password for encrypted key |
\* At least one security factor (PIN or RSA Key) required.
#### Drag-and-Drop Upload
Both image upload zones support:
- Click to browse
- Drag and drop files
- Instant image preview
- File name display
#### Character Counter
```
Message: [ ]
1,234 / 50,000 characters 2%
```
Shows warning at 80% capacity.
#### Day Detection
The page automatically detects your local day of week and updates the label:
```
Saturday's Phrase: [ ]
```
#### Encoding Process
1. Fill in all required fields
2. Click "Encode Message"
3. Wait for processing (shows spinner)
4. Redirected to result page
#### Result Page
**URL:** `/encode/result/<file_id>`
After successful encoding:
```
┌────────────────────────────────────────┐
│ ✓ Message Encoded Successfully! │
│ │
│ 📄 a1b2c3d4_20251227.png │
│ Your secret message is hidden │
│ in this image │
│ │
│ [ Download Image ] │
│ [ Share Image ] │
│ │
│ ⚠️ File expires in 5 minutes. │
│ Download or share now. │
│ │
│ [ Encode Another Message ] │
└────────────────────────────────────────┘
```
**Share Options:**
1. **Native Share** (mobile/supported browsers):
- Uses Web Share API
- Opens system share sheet
- Can share directly to apps
2. **Fallback Share** (desktop):
- Email link
- Telegram link
- WhatsApp link
- Copy link to clipboard
---
### Decode Message
**URL:** `/decode`
Extract a hidden message from a stego image.
#### Input Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| Reference Photo | Image file | ✓ | Same photo used for encoding |
| Stego Image | Image file | ✓ | Image containing hidden message |
| Day Phrase | Text | ✓ | Phrase for the **encoding** day |
| PIN | Number | * | Same PIN used for encoding |
| RSA Key | .pem file | * | Same RSA key used for encoding |
| RSA Key Password | Password | | Password for encrypted key |
\* Must match security factors used during encoding.
#### Date Detection from Filename
When you upload a stego image with a date in the filename (e.g., `stego_20251227.png`), the UI:
1. Extracts the date
2. Determines the day of week
3. Updates the phrase label: "Saturday's Phrase"
This helps you use the correct daily phrase.
#### Decoding Process
1. Fill in all required fields
2. Click "Decode Message"
3. Wait for processing
4. View decoded message on same page
#### Successful Decode
```
┌────────────────────────────────────────┐
│ ✓ Message Decrypted Successfully! │
│ │
│ Decoded Message: │
│ ┌──────────────────────────────────┐ │
│ │ Meet at midnight. The package │ │
│ │ will be under the bridge. │ │
│ └──────────────────────────────────┘ │
│ │
│ [ Decode Another Message ] │
└────────────────────────────────────────┘
```
#### Troubleshooting Tips
The page includes built-in troubleshooting guidance:
- ✓ Use the **exact same reference photo** file
- ✓ Use the phrase for the **encoding day**, not today
- ✓ Provide the **same security factors** used during encoding
- ✓ Ensure the stego image hasn't been **resized or recompressed**
- ✓ If using RSA key, verify the **password is correct**
---
### About Page
**URL:** `/about`
Learn about Stegasoo's security model and best practices.
#### Sections
**System Status:**
- Argon2id availability (vs PBKDF2 fallback)
- AES-256-GCM encryption status
**Security Model Table:**
| Component | Entropy | Purpose |
|-----------|---------|---------|
| Reference Photo | ~80-256 bits | Something you have |
| 3-Word Phrase | ~33 bits | Something you know (daily) |
| 6-Digit PIN | ~20 bits | Something you know (static) |
| Date | N/A | Automatic key rotation |
| **Combined** | **133+ bits** | **Beyond brute force** |
**Attack Resistance:**
What attackers can't do:
- Brute force (2^133 combinations)
- Use rainbow tables (random salt)
- Detect hidden data (random pixels)
- Use GPU farms (256MB RAM per attempt)
Real threats:
- Social engineering
- Physical device access
- Malware/keyloggers
- Shoulder surfing
**Best Practices:**
Do:
- Memorize phrases and PIN
- Use reference photo both parties have
- Use different carrier images each time
- Share stego images through normal channels
Don't:
- Transmit the reference photo
- Reuse carrier images
- Store credentials digitally
- Resize/recompress stego images
---
## User Interface Guide
### Navigation
The navbar provides quick access to all pages:
```
[Logo] Stegasoo Home | Encode | Decode | Generate | About
```
### Color Scheme
| Element | Color | Purpose |
|---------|-------|---------|
| Background | Dark gradient | Reduce eye strain |
| Cards | Semi-transparent | Visual hierarchy |
| Headers | Purple gradient | Brand identity |
| Success | Green | Positive actions |
| Warning | Yellow | Caution messages |
| Error | Red | Error states |
### Form Validation
- Real-time validation feedback
- Clear error messages in alerts
- Required field indicators
- Input constraints (max length, format)
### Loading States
During long operations:
- Button shows spinner
- Button text changes (e.g., "Encoding...")
- Button is disabled to prevent double-submit
### Flash Messages
```
┌──────────────────────────────────────────────┐
│ ✓ Credentials Generated! [×] │
└──────────────────────────────────────────────┘
```
Types:
- Success (green) - Operation completed
- Error (red) - Operation failed
- Warning (yellow) - Caution needed
---
## Workflow Examples
### First-Time Setup (Both Parties)
**Party A:**
1. Go to `/generate`
2. Configure: PIN ✓, 3 words, 6 digits
3. Click "Generate Credentials"
4. **Write down** phrases and PIN on paper
5. **Memorize** over the next few days
6. Destroy the paper
**Share with Party B (in person or secure channel):**
- The 7 daily phrases
- The PIN
- The reference photo file (if not already shared)
### Sending a Secret Message
1. Go to `/encode`
2. Upload your shared reference photo
3. Upload any carrier image (meme, vacation photo, etc.)
4. Type your secret message
5. Enter today's phrase (check your memory!)
6. Enter your PIN
7. Click "Encode Message"
8. Download or share the resulting image
9. Send via any channel (email, social media, chat)
### Receiving a Secret Message
1. Receive the stego image through any channel
2. Go to `/decode`
3. Upload the same reference photo
4. Upload the received stego image
5. Note the date in the filename (e.g., `_20251227`)
6. Enter the phrase for **that day** (not today!)
7. Enter the PIN
8. Click "Decode Message"
9. Read the secret message
### Changing Credentials
To rotate to new credentials:
1. Both parties generate new credentials together
2. Agree on a cutover date
3. Messages encoded before cutover use old credentials
4. Messages encoded after cutover use new credentials
---
## Security Features
### Client-Side Security
| Feature | Implementation |
|---------|----------------|
| Local date detection | JavaScript `Date()` object |
| No credential storage | Nothing saved in browser |
| Automatic cleanup | Files deleted after 5 minutes |
| HTTPS support | Configure at server level |
### Server-Side Security
| Feature | Implementation |
|---------|----------------|
| Memory-hard KDF | Argon2id (256MB RAM) |
| Authenticated encryption | AES-256-GCM |
| Random salt | Per-message salt |
| Temporary storage | In-memory, auto-expiring |
| Input validation | All inputs validated |
| File size limits | 5MB max upload |
### File Security
| Aspect | Protection |
|--------|------------|
| Upload location | `/tmp/stego_uploads` (Docker) |
| Storage duration | 5 minutes maximum |
| Access control | Random 16-byte file ID |
| Cleanup | Automatic + manual |
---
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `FLASK_ENV` | production | Flask environment |
| `PYTHONPATH` | - | Include `src/` for development |
### Application Limits
| Limit | Value | Config Location |
|-------|-------|-----------------|
| Max file upload | 5 MB | `app.config['MAX_CONTENT_LENGTH']` |
| File expiry | 5 minutes | `TEMP_FILE_EXPIRY` |
| Max image pixels | 4 MP | `stegasoo.constants` |
| Max message size | 50 KB | `stegasoo.constants` |
| PIN length | 6-9 digits | `stegasoo.constants` |
### Production Deployment
**With Gunicorn:**
```bash
gunicorn \
--bind 0.0.0.0:5000 \
--workers 2 \
--threads 4 \
--timeout 60 \
app:app
```
**Worker Calculation:**
- Each encode/decode uses ~256MB RAM (Argon2)
- Formula: `workers = (available_RAM - 512MB) / 256MB`
**With Nginx (reverse proxy):**
```nginx
server {
listen 80;
server_name stegasoo.example.com;
client_max_body_size 10M;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
}
}
```
**With Docker Compose:**
```yaml
services:
web:
build:
context: .
target: web
ports:
- "5000:5000"
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
```
---
## Troubleshooting
### Common Issues
#### "Decryption failed"
**Causes:**
- Wrong day phrase
- Wrong PIN
- Different reference photo
- Stego image was modified
**Solutions:**
1. Check the date in the stego filename
2. Use the phrase for that specific day
3. Verify you're using the original reference photo
4. Ensure the stego image wasn't resized/recompressed
#### "Carrier image too small"
**Cause:** Message too large for carrier capacity
**Solutions:**
1. Use a larger carrier image (more pixels)
2. Shorten the message
3. Check capacity with `/info` command (CLI)
#### "You must provide at least a PIN or RSA Key"
**Cause:** No security factor selected
**Solution:** Enter a PIN and/or upload an RSA key
#### Upload fails silently
**Causes:**
- File too large (>5MB)
- Invalid file type
- Browser issue
**Solutions:**
1. Reduce file size
2. Use PNG, JPG, or BMP formats
3. Try a different browser
#### RSA key password error
**Causes:**
- Wrong password
- Unencrypted key with password provided
- Corrupted key file
**Solutions:**
1. Verify the correct password
2. If key is unencrypted, leave password blank
3. Re-download or regenerate the key
### Browser Compatibility
| Browser | Status | Notes |
|---------|--------|-------|
| Chrome 80+ | ✓ Full | Web Share API supported |
| Firefox 80+ | ✓ Full | Limited Web Share |
| Safari 14+ | ✓ Full | Web Share on iOS |
| Edge 80+ | ✓ Full | Web Share API supported |
| IE 11 | ✗ None | Not supported |
### Performance Issues
**Slow encoding/decoding:**
- Normal: Argon2 is intentionally slow (security feature)
- Expected time: 2-5 seconds per operation
**High memory usage:**
- Normal: Argon2 requires 256MB RAM
- Configure worker count based on available RAM
---
## Mobile Support
### Responsive Design
The UI adapts to mobile screens:
- Single-column layout on small screens
- Touch-friendly buttons (48px minimum)
- Readable text without zooming
- Scrollable tables
### Mobile-Specific Features
**Native Sharing:**
On supported mobile browsers, the "Share Image" button opens the native share sheet, allowing you to share directly to:
- Messaging apps (iMessage, WhatsApp, Telegram)
- Social media (Instagram, Twitter)
- Email
- Other installed apps
**Camera Upload:**
File input accepts camera capture:
- Take a new photo as reference
- Capture carrier image directly
### PWA Support (Future)
The web app can be added to home screen on mobile devices for quick access.
---
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Tab` | Navigate between fields |
| `Enter` | Submit form (when focused) |
| `Esc` | Close modal/alert |
---
## Accessibility
| Feature | Implementation |
|---------|----------------|
| Screen readers | ARIA labels on interactive elements |
| Keyboard navigation | Full tab support |
| Color contrast | WCAG AA compliant |
| Focus indicators | Visible focus rings |
| Form labels | All inputs labeled |
---
## See Also
- [CLI Documentation](CLI.md) - Command-line interface
- [API Documentation](API.md) - REST API reference
- [README](README.md) - Project overview

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,62 @@
# Subprocess Isolation for Stegasoo WebUI
This update runs encode/decode/compare operations in isolated subprocesses
to prevent jpegio/scipy crashes from taking down the Flask server.
## Files
- **app.py** - Updated Flask app using subprocess isolation
- **subprocess_stego.py** - Flask-side wrapper with clean API
- **stego_worker.py** - Subprocess script that does actual stegasoo operations
## Setup
1. Place all three files in your `webui/` directory (same level as templates/)
2. Make sure stego_worker.py is executable (optional):
```bash
chmod +x stego_worker.py
```
3. Run the Flask app:
```bash
python app.py
```
## How It Works
Instead of calling stegasoo functions directly in the Flask process:
```python
# OLD (crashes could kill Flask)
result = encode(...)
```
We now run them in subprocesses:
```python
# NEW (crashes only kill the subprocess)
result = subprocess_stego.encode(...)
```
If jpegio or scipy crashes due to memory corruption, only the subprocess
dies. Flask logs the error and continues running. The next request spawns
a fresh subprocess.
## Configuration
In `app.py`, you can adjust the timeout:
```python
subprocess_stego = SubprocessStego(timeout=180) # 3 minutes
```
Larger images may need longer timeouts.
## Troubleshooting
If you see "Worker script not found" errors, make sure `stego_worker.py`
is in the same directory as `app.py`.
If subprocess operations fail, check the Flask logs for error details.
The subprocess wrapper captures both stdout and stderr from the worker.

File diff suppressed because it is too large Load Diff

979
frontends/web/auth.py Normal file
View File

@@ -0,0 +1,979 @@
"""
Stegasoo Authentication Module (v4.1.0)
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, flash, g, redirect, session, url_for
# Argon2 password hasher (lighter than stegasoo's 256MB for faster login)
ph = PasswordHasher(
time_cost=3,
memory_cost=65536, # 64MB
parallelism=4,
hash_len=32,
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."""
instance_path = Path(current_app.instance_path)
instance_path.mkdir(parents=True, exist_ok=True)
return instance_path / "stegasoo.db"
def get_db() -> sqlite3.Connection:
"""Get database connection, cached on Flask g object."""
if "db" not in g:
g.db = sqlite3.connect(get_db_path())
g.db.row_factory = sqlite3.Row
return g.db
def close_db(e=None):
"""Close database connection at end of request."""
db = g.pop("db", None)
if db is not None:
db.close()
def init_db():
"""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 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
);
""")
db.commit()
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()
row = db.execute(
"SELECT value FROM app_settings WHERE key = ?", (key,)
).fetchone()
return row["value"] if row else None
def set_app_setting(key: str, value: str) -> None:
"""Set an app-level setting value."""
db = get_db()
db.execute(
"""
INSERT INTO app_settings (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP
""",
(key, value, value),
)
db.commit()
def delete_app_setting(key: str) -> bool:
"""Delete an app-level setting. Returns True if deleted."""
db = get_db()
cursor = db.execute("DELETE FROM app_settings WHERE key = ?", (key,))
db.commit()
return cursor.rowcount > 0
# =============================================================================
# Recovery Key Management (v4.1.0)
# =============================================================================
# Setting key for recovery hash
RECOVERY_KEY_SETTING = "recovery_key_hash"
def has_recovery_key() -> bool:
"""Check if a recovery key has been configured."""
return get_app_setting(RECOVERY_KEY_SETTING) is not None
def get_recovery_key_hash() -> str | None:
"""Get the stored recovery key hash."""
return get_app_setting(RECOVERY_KEY_SETTING)
def set_recovery_key_hash(key_hash: str) -> None:
"""Store a recovery key hash."""
set_app_setting(RECOVERY_KEY_SETTING, key_hash)
def clear_recovery_key() -> bool:
"""Remove the recovery key. Returns True if removed."""
return delete_app_setting(RECOVERY_KEY_SETTING)
def verify_and_reset_admin_password(recovery_key: str, new_password: str) -> tuple[bool, str]:
"""
Verify recovery key and reset the first admin's password.
Args:
recovery_key: User-provided recovery key
new_password: New password to set
Returns:
(success, message) tuple
"""
from stegasoo.recovery import verify_recovery_key
stored_hash = get_recovery_key_hash()
if not stored_hash:
return False, "No recovery key configured for this instance"
if not verify_recovery_key(recovery_key, stored_hash):
return False, "Invalid recovery key"
# Find first admin user
db = get_db()
admin = db.execute(
"SELECT id, username FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
).fetchone()
if not admin:
return False, "No admin user found"
# Reset password
new_hash = ph.hash(new_password)
db.execute(
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_hash, admin["id"]),
)
db.commit()
# Invalidate all sessions for this user
invalidate_user_sessions(admin["id"])
return True, f"Password reset for '{admin['username']}'"
# =============================================================================
# User Queries
# =============================================================================
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 current user's username (backwards compatibility)."""
user = get_current_user()
return user.username if user else "unknown"
# =============================================================================
# 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 id, username, role, created_at, password_hash FROM users WHERE username = ?",
(username,),
).fetchone()
if not row:
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 users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_hash, row["id"]),
)
db.commit()
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
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("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):
"""Decorator to require login 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 (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)
with app.app_context():
init_db()

111
frontends/web/ssl_utils.py Normal file
View File

@@ -0,0 +1,111 @@
"""
SSL Certificate Utilities
Auto-generates self-signed certificates for HTTPS.
Uses cryptography library (already a dependency).
"""
import datetime
import ipaddress
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
def get_cert_paths(base_dir: Path) -> tuple[Path, Path]:
"""Get paths for cert and key files."""
cert_dir = base_dir / "certs"
cert_dir.mkdir(parents=True, exist_ok=True)
return cert_dir / "server.crt", cert_dir / "server.key"
def certs_exist(base_dir: Path) -> bool:
"""Check if both cert files exist."""
cert_path, key_path = get_cert_paths(base_dir)
return cert_path.exists() and key_path.exists()
def generate_self_signed_cert(
base_dir: Path,
hostname: str = "localhost",
days_valid: int = 365,
) -> tuple[Path, Path]:
"""
Generate self-signed SSL certificate.
Args:
base_dir: Base directory for certs folder
hostname: Server hostname for certificate
days_valid: Certificate validity in days
Returns:
Tuple of (cert_path, key_path)
"""
cert_path, key_path = get_cert_paths(base_dir)
# Generate RSA key
key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
# Create certificate
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
])
# Subject Alternative Names
san_list = [
x509.DNSName(hostname),
x509.DNSName("localhost"),
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
]
# Add the hostname as IP if it looks like one
try:
san_list.append(x509.IPAddress(ipaddress.IPv4Address(hostname)))
except ipaddress.AddressValueError:
pass
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=days_valid))
.add_extension(
x509.SubjectAlternativeName(san_list),
critical=False,
)
.sign(key, hashes.SHA256())
)
# Write key file (chmod 600)
key_path.write_bytes(
key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
)
key_path.chmod(0o600)
# Write cert file
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
return cert_path, key_path
def ensure_certs(base_dir: Path, hostname: str = "localhost") -> tuple[Path, Path]:
"""Ensure certificates exist, generating if needed."""
if certs_exist(base_dir):
return get_cert_paths(base_dir)
print(f"Generating self-signed SSL certificate for {hostname}...")
return generate_self_signed_cert(base_dir, hostname)

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

@@ -0,0 +1,995 @@
/**
* Stegasoo Frontend JavaScript
* Shared functionality across encode, decode, and generate pages
*/
const Stegasoo = {
// ========================================================================
// PASSWORD/PIN VISIBILITY TOGGLES
// ========================================================================
initPasswordToggles() {
document.querySelectorAll('[data-toggle-password]').forEach(btn => {
btn.addEventListener('click', function() {
const targetId = this.dataset.togglePassword;
const input = document.getElementById(targetId);
const icon = this.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');
}
});
});
},
// ========================================================================
// RSA INPUT METHOD TOGGLE (File vs QR)
// ========================================================================
initRsaMethodToggle() {
const fileRadio = document.getElementById('rsaMethodFile');
const qrRadio = document.getElementById('rsaMethodQr');
const fileSection = document.getElementById('rsaFileSection');
const qrSection = document.getElementById('rsaQrSection');
if (!fileRadio || !qrRadio || !fileSection || !qrSection) return;
const update = () => {
const isFile = fileRadio.checked;
fileSection.classList.toggle('d-none', !isFile);
qrSection.classList.toggle('d-none', isFile);
};
fileRadio.addEventListener('change', update);
qrRadio.addEventListener('change', update);
},
// ========================================================================
// DROP ZONES (Drag & Drop + Preview)
// ========================================================================
initDropZones(options = {}) {
document.querySelectorAll('.drop-zone').forEach(zone => {
const input = zone.querySelector('input[type="file"]');
const label = zone.querySelector('.drop-zone-label');
const preview = zone.querySelector('.drop-zone-preview');
if (!input) return;
// Check if this is a special zone type
const isPayloadZone = zone.id === 'payloadDropZone';
const isCarrierZone = zone.id === 'carrierDropZone';
const isQrZone = zone.id === 'qrDropZone';
// Drag events
['dragenter', 'dragover'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
zone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
zone.classList.remove('drag-over');
});
});
// Drop handler
zone.addEventListener('drop', e => {
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
input.dispatchEvent(new Event('change'));
}
});
// Change handler for preview (skip payload and QR zones - they have special handling)
if (!isPayloadZone && !isQrZone) {
input.addEventListener('change', function() {
if (this.files && this.files[0]) {
Stegasoo.showImagePreview(this.files[0], preview, label, zone);
}
});
}
});
},
showImagePreview(file, previewEl, labelEl, zone = null) {
if (!file.type.startsWith('image/')) return;
const isScanContainer = zone && zone.classList.contains('scan-container');
const isPixelContainer = zone && zone.classList.contains('pixel-container');
const reader = new FileReader();
reader.onload = e => {
if (previewEl) {
previewEl.src = e.target.result;
previewEl.classList.remove('d-none');
}
// For scan/pixel containers, hide the label entirely (filename will appear in data panel)
if (labelEl) {
if (isScanContainer || isPixelContainer) {
labelEl.classList.add('d-none');
} else {
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));
}
}
// Trigger appropriate animation
if (isScanContainer) {
Stegasoo.triggerScanAnimation(zone, file);
} else if (isPixelContainer) {
Stegasoo.triggerPixelReveal(zone, file);
}
};
reader.readAsDataURL(file);
},
// ========================================================================
// REFERENCE PHOTO SCAN ANIMATION
// ========================================================================
triggerScanAnimation(container, file, duration = 700) {
// Reset any previous state
container.classList.remove('scan-complete');
container.classList.add('scanning');
const preview = container.querySelector('.drop-zone-preview');
// Create hash blocks for the "hashing" visual effect
const createHashBlocks = () => {
// Remove old hash blocks
const oldBlocks = container.querySelector('.hash-blocks');
if (oldBlocks) oldBlocks.remove();
const hashContainer = document.createElement('div');
hashContainer.className = 'hash-blocks';
// Size and position to match preview image exactly
const imgWidth = preview.offsetWidth;
const imgHeight = preview.offsetHeight;
const imgTop = preview.offsetTop;
const imgLeft = preview.offsetLeft;
hashContainer.style.width = imgWidth + 'px';
hashContainer.style.height = imgHeight + 'px';
hashContainer.style.top = imgTop + 'px';
hashContainer.style.left = imgLeft + 'px';
// Create grid of hash blocks (10x8 for better coverage)
const cols = 10;
const rows = 8;
hashContainer.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
hashContainer.style.gridTemplateRows = `repeat(${rows}, 1fr)`;
// Create blocks with staggered delays for wave disappearance
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const block = document.createElement('div');
block.className = 'hash-block';
// Diagonal wave pattern for disappearance
const delay = (row + col) * 25 + Math.random() * 30;
block.style.animationDelay = delay + 'ms';
hashContainer.appendChild(block);
}
}
container.appendChild(hashContainer);
};
// Wait for image to be ready
if (preview.complete && preview.naturalWidth) {
createHashBlocks();
} else {
preview.onload = createHashBlocks;
}
// After animation duration, switch to complete state
setTimeout(() => {
container.classList.remove('scanning');
container.classList.add('scan-complete');
// Remove hash blocks container
const hashBlocks = container.querySelector('.hash-blocks');
if (hashBlocks) hashBlocks.remove();
// Populate data panel if file provided
if (file) {
const nameEl = container.querySelector('#refFileName') || container.querySelector('.scan-data-filename span');
const sizeEl = container.querySelector('#refFileSize') || container.querySelector('.scan-data-value');
const hashEl = container.querySelector('#refHashPreview') || container.querySelector('.scan-hash-preview');
if (nameEl) {
nameEl.textContent = file.name;
}
if (sizeEl) {
const sizeKB = (file.size / 1024).toFixed(1);
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
sizeEl.textContent = file.size > 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`;
}
if (hashEl) {
// Generate a deterministic fake hash preview from filename + size
const fakeHash = Stegasoo.generateFakeHash(file.name + file.size);
hashEl.textContent = `SHA256: ${fakeHash.substring(0, 8)}····${fakeHash.substring(56)}`;
}
}
}, duration);
},
generateFakeHash(input) {
// Simple deterministic hash-like string for display purposes
let hash = '';
const chars = '0123456789abcdef';
let seed = 0;
for (let i = 0; i < input.length; i++) {
seed = ((seed << 5) - seed) + input.charCodeAt(i);
seed = seed & seed;
}
for (let i = 0; i < 64; i++) {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
hash += chars[seed % 16];
}
return hash;
},
// ========================================================================
// CARRIER/STEGO PIXEL REVEAL ANIMATION
// ========================================================================
triggerPixelReveal(container, file, duration = 700) {
// Reset any previous state
container.classList.remove('load-complete');
container.classList.add('loading');
const preview = container.querySelector('.drop-zone-preview');
// Create embed traces container sized to image
const createTraces = () => {
// Remove old elements
let tracesContainer = container.querySelector('.embed-traces');
if (tracesContainer) tracesContainer.remove();
let oldGrid = container.querySelector('.embed-grid');
if (oldGrid) oldGrid.remove();
// Add grid overlay (covers whole panel like ref does)
const grid = document.createElement('div');
grid.className = 'embed-grid';
container.appendChild(grid);
// Create traces container
tracesContainer = document.createElement('div');
tracesContainer.className = 'embed-traces';
container.appendChild(tracesContainer);
// Size and position traces to match preview image exactly
const imgWidth = preview.offsetWidth;
const imgHeight = preview.offsetHeight;
const imgTop = preview.offsetTop;
const imgLeft = preview.offsetLeft;
tracesContainer.style.width = imgWidth + 'px';
tracesContainer.style.height = imgHeight + 'px';
tracesContainer.style.top = imgTop + 'px';
tracesContainer.style.left = imgLeft + 'px';
// Generate Tron-style circuit traces covering the image
Stegasoo.generateEmbedTraces(tracesContainer, imgWidth, imgHeight);
};
// Wait for image to be ready
if (preview.complete && preview.naturalWidth) {
createTraces();
} else {
preview.onload = createTraces;
}
setTimeout(() => {
container.classList.remove('loading');
container.classList.add('load-complete');
// Remove traces and grid
const traces = container.querySelector('.embed-traces');
if (traces) traces.remove();
const grid = container.querySelector('.embed-grid');
if (grid) grid.remove();
// Populate data panel
Stegasoo.populatePixelDataPanel(container, file, preview);
}, duration);
},
generateEmbedTraces(container, width, height) {
// Color classes for variety
const colors = ['color-yellow', 'color-cyan', 'color-purple', 'color-blue'];
// Generate 6-8 snake paths spread across the whole image
const numPaths = 6 + Math.floor(Math.random() * 3);
for (let p = 0; p < numPaths; p++) {
// Each path gets a random color
const pathColor = colors[Math.floor(Math.random() * colors.length)];
// Distribute starting points across the image
let x = (width * 0.1) + (Math.random() * width * 0.8);
let y = (height * 0.1) + (Math.random() * height * 0.8);
let delay = p * 40;
// Each path has 3-5 segments for more coverage
const numSegments = 3 + Math.floor(Math.random() * 3);
let horizontal = Math.random() > 0.5;
for (let s = 0; s < numSegments; s++) {
const trace = document.createElement('div');
trace.className = 'embed-trace ' + (horizontal ? 'h' : 'v') + ' ' + pathColor;
const length = 30 + Math.random() * 60;
trace.style.left = x + 'px';
trace.style.top = y + 'px';
trace.style.animationDelay = delay + 'ms';
if (horizontal) {
trace.style.width = length + 'px';
} else {
trace.style.height = length + 'px';
}
container.appendChild(trace);
// Move position for next segment
if (horizontal) {
x += length;
} else {
y += length;
}
// Wrap around if out of bounds to keep traces in view
if (x > width - 20) x = 10 + Math.random() * 40;
if (y > height - 20) y = 10 + Math.random() * 40;
if (x < 10) x = width - 60 + Math.random() * 40;
if (y < 10) y = height - 60 + Math.random() * 40;
// Alternate direction (90 degree turn)
horizontal = !horizontal;
delay += 30;
}
}
},
populatePixelDataPanel(container, file, preview) {
const nameEl = container.querySelector('.pixel-data-filename span');
const sizeEl = container.querySelector('.pixel-data-value');
const dimsEl = container.querySelector('.pixel-dimensions');
if (nameEl) {
nameEl.textContent = file.name;
}
if (sizeEl) {
const sizeKB = (file.size / 1024).toFixed(1);
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
sizeEl.textContent = file.size > 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`;
}
if (dimsEl && preview) {
dimsEl.textContent = `${preview.naturalWidth} × ${preview.naturalHeight} px`;
}
},
initReferenceScanAnimation() {
// Find all scan containers and wire up their inputs
document.querySelectorAll('.scan-container').forEach(container => {
const input = container.querySelector('input[type="file"]');
const preview = container.querySelector('.drop-zone-preview');
const label = container.querySelector('.drop-zone-label');
if (!input) return;
input.addEventListener('change', function() {
if (this.files && this.files[0]) {
Stegasoo.showImagePreview(this.files[0], preview, label, container);
}
});
});
},
// ========================================================================
// CLIPBOARD PASTE
// ========================================================================
initClipboardPaste(imageInputSelectors) {
document.addEventListener('paste', function(e) {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const blob = items[i].getAsFile();
// Find first empty input from the list
let targetInput = null;
for (const selector of imageInputSelectors) {
const input = document.querySelector(selector);
if (input && (!input.files || !input.files.length)) {
targetInput = input;
break;
}
}
// Fallback to first input if all have files
if (!targetInput) {
targetInput = document.querySelector(imageInputSelectors[0]);
}
if (targetInput) {
const container = new DataTransfer();
container.items.add(blob);
targetInput.files = container.files;
targetInput.dispatchEvent(new Event('change'));
}
break;
}
}
});
},
// ========================================================================
// QR CODE CROP ANIMATION WITH SECTION SCANNING
// ========================================================================
initQrCropAnimation(inputId = 'rsaKeyQrInput') {
const input = document.getElementById(inputId);
const container = document.getElementById('qrCropContainer');
const original = document.getElementById('qrOriginal');
const cropped = document.getElementById('qrCropped');
const dropZone = document.getElementById('qrDropZone');
if (!input || !container || !original || !cropped) return;
input.addEventListener('change', function() {
if (!this.files || !this.files[0]) return;
const file = this.files[0];
if (!file.type.startsWith('image/')) return;
const label = dropZone?.querySelector('.drop-zone-label');
// Reset animation state
container.classList.remove('scan-complete', 'scanning');
container.classList.add('d-none');
// Remove old overlay if exists
const oldOverlay = container.querySelector('.qr-section-overlay');
if (oldOverlay) oldOverlay.remove();
// Show loading state immediately
container.classList.remove('d-none');
container.classList.add('loading');
label?.classList.add('d-none');
// Add loading indicator if not present
let loader = container.querySelector('.qr-loader');
if (!loader) {
loader = document.createElement('div');
loader.className = 'qr-loader';
loader.innerHTML = `
<i class="bi bi-qr-code-scan"></i>
<span>Detecting QR code...</span>
`;
container.appendChild(loader);
}
// Fetch cropped version
const formData = new FormData();
formData.append('image', file);
fetch('/qr/crop', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) throw new Error('No QR detected');
return response.blob();
})
.then(blob => {
// Hide loader, show cropped image
container.classList.remove('loading');
cropped.src = URL.createObjectURL(blob);
return new Promise((resolve) => {
cropped.onload = () => {
// Start scanning animation
container.classList.add('scanning');
// Add scanner overlay - will be positioned via CSS to cover the image
let overlay = container.querySelector('.qr-scanner-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'qr-scanner-overlay';
['tl', 'tr', 'bl', 'br'].forEach(pos => {
const bracket = document.createElement('div');
bracket.className = `qr-finder-bracket ${pos}`;
overlay.appendChild(bracket);
});
// Add data panel inside overlay
const dataPanel = document.createElement('div');
dataPanel.className = 'qr-data-panel';
dataPanel.innerHTML = `
<div class="qr-data-row">
<span class="qr-status-badge">KEY LOADED</span>
<span class="qr-data-value">--</span>
</div>
`;
overlay.appendChild(dataPanel);
container.appendChild(overlay);
}
// Let CSS handle overlay positioning (inset with padding)
resolve();
};
});
})
.then(() => {
// Now verify key extraction
const keyFormData = new FormData();
keyFormData.append('qr_image', file);
return fetch('/extract-key-from-qr', {
method: 'POST',
body: keyFormData
});
})
.then(response => response.json())
.then(data => {
// Extraction complete - stop animation
container.classList.remove('scanning');
container.classList.add('scan-complete');
// Update data panel (inside overlay)
const overlay = container.querySelector('.qr-scanner-overlay');
const sizeEl = overlay?.querySelector('.qr-data-value');
if (data.success && sizeEl) {
const sizeKB = (file.size / 1024).toFixed(1);
sizeEl.textContent = sizeKB + ' KB';
}
})
.catch(err => {
console.log('QR crop/extract error:', err);
container.classList.remove('loading', 'scanning');
container.classList.add('error');
// Update loader to show error
const loader = container.querySelector('.qr-loader');
if (loader) {
loader.innerHTML = `
<i class="bi bi-exclamation-triangle-fill"></i>
<span>No QR code detected</span>
`;
}
});
});
},
// ========================================================================
// COLLAPSE CHEVRON ANIMATION
// ========================================================================
initCollapseChevrons() {
document.querySelectorAll('[data-chevron]').forEach(collapse => {
const chevronId = collapse.dataset.chevron;
const chevron = document.getElementById(chevronId);
if (!chevron) return;
collapse.addEventListener('show.bs.collapse', () => {
chevron.classList.add('bi-chevron-up');
chevron.classList.remove('bi-chevron-down');
});
collapse.addEventListener('hide.bs.collapse', () => {
chevron.classList.remove('bi-chevron-up');
chevron.classList.add('bi-chevron-down');
});
});
},
// ========================================================================
// FORM LOADING STATE
// ========================================================================
initFormLoading(formId, buttonId, loadingText = 'Processing...') {
const form = document.getElementById(formId);
const btn = document.getElementById(buttonId);
if (!form || !btn) return;
form.addEventListener('submit', () => {
btn.disabled = true;
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${loadingText}`;
});
},
// ========================================================================
// COPY TO CLIPBOARD
// ========================================================================
copyToClipboard(text, iconEl, textEl) {
navigator.clipboard.writeText(text).then(() => {
const origIcon = iconEl?.className;
const origText = textEl?.textContent;
if (iconEl) iconEl.className = 'bi bi-check';
if (textEl) textEl.textContent = 'Copied!';
setTimeout(() => {
if (iconEl) iconEl.className = origIcon;
if (textEl) textEl.textContent = origText;
}, 2000);
});
},
// ========================================================================
// MODE CARD HIGHLIGHTING
// ========================================================================
initModeCards(config) {
// config: { radioName: 'embed_mode', cards: { 'lsb': { id: 'lsbCard', borderClass: 'border-primary' }, ... } }
const radios = document.querySelectorAll(`input[name="${config.radioName}"]`);
const update = () => {
radios.forEach(radio => {
const cardConfig = config.cards[radio.value];
if (!cardConfig) return;
const card = document.getElementById(cardConfig.id);
if (!card) return;
card.classList.toggle(cardConfig.borderClass, radio.checked);
card.classList.toggle('border-2', radio.checked);
});
};
radios.forEach(radio => radio.addEventListener('change', update));
update(); // Initial state
},
// ========================================================================
// PASSPHRASE FONT SIZE AUTO-ADJUST
// ========================================================================
initPassphraseFontResize(inputId = 'passphraseInput') {
const input = document.getElementById(inputId);
if (!input) return;
const steps = [
{ maxChars: 30, size: 1.1 },
{ maxChars: 45, size: 1.0 },
{ maxChars: 60, size: 0.95 },
{ maxChars: Infinity, size: 0.9 }
];
const adjust = () => {
const len = input.value.length;
for (const step of steps) {
if (len <= step.maxChars) {
input.style.fontSize = step.size + 'rem';
break;
}
}
};
input.addEventListener('input', adjust);
adjust();
},
// ========================================================================
// CHANNEL KEY HANDLING (v4.0.0)
// ========================================================================
/**
* Generate a random channel key in format XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
* @returns {string} Generated key
*/
generateChannelKey() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let key = '';
for (let i = 0; i < 8; i++) {
if (i > 0) key += '-';
for (let j = 0; j < 4; j++) {
key += chars.charAt(Math.floor(Math.random() * chars.length));
}
}
return key;
},
/**
* Validate channel key format
* @param {string} key - Key to validate
* @returns {boolean} True if valid
*/
validateChannelKey(key) {
const pattern = /^[A-Z0-9]{4}(-[A-Z0-9]{4}){7}$/;
return pattern.test(key);
},
/**
* Format channel key input (auto-add dashes, uppercase)
* @param {HTMLInputElement} input - Input element
*/
formatChannelKeyInput(input) {
let value = input.value.toUpperCase();
const clean = value.replace(/-/g, '');
if (clean.length > 0 && clean.length <= 32) {
const formatted = clean.match(/.{1,4}/g)?.join('-') || clean;
if (formatted !== value && formatted.length <= 39) {
input.value = formatted;
} else {
input.value = value;
}
}
// Validate and show/hide error state
const isValid = this.validateChannelKey(input.value);
input.classList.toggle('is-invalid', input.value.length > 0 && !isValid);
},
/**
* Initialize channel key UI for encode/decode pages
* @param {Object} config - Configuration object
* @param {string} config.selectId - ID of channel select dropdown
* @param {string} config.customInputId - ID of custom key input container
* @param {string} config.keyInputId - ID of key input field
* @param {string} config.generateBtnId - ID of generate button (optional)
*/
initChannelKey(config = {}) {
const selectId = config.selectId || 'channelSelect';
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 and server info based on selection
const updateVisibility = () => {
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');
}
}
};
select?.addEventListener('change', updateVisibility);
// Initial state
updateVisibility();
// Format and validate key input
keyInput?.addEventListener('input', () => {
this.formatChannelKeyInput(keyInput);
});
// Generate button (if present)
generateBtn?.addEventListener('click', () => {
if (keyInput) {
keyInput.value = this.generateChannelKey();
keyInput.classList.remove('is-invalid');
}
});
},
/**
* Handle form submission with channel key validation
* @param {HTMLFormElement} form - Form element
* @param {string} selectId - ID of channel select dropdown
* @param {string} keyInputId - ID of key input field
* @returns {boolean} True if valid, false to prevent submission
*/
validateChannelKeyOnSubmit(form, selectId, keyInputId) {
const select = document.getElementById(selectId);
const keyInput = document.getElementById(keyInputId);
if (select?.value === 'custom' && keyInput) {
if (!this.validateChannelKey(keyInput.value)) {
keyInput.classList.add('is-invalid');
keyInput.focus();
return false;
}
// 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;
},
/**
* Initialize standalone channel key generator (for generate page)
* @param {string} inputId - ID of generated key input
* @param {string} generateBtnId - ID of generate button
* @param {string} copyBtnId - ID of copy button
*/
initChannelKeyGenerator(inputId, generateBtnId, copyBtnId) {
const input = document.getElementById(inputId);
const generateBtn = document.getElementById(generateBtnId);
const copyBtn = document.getElementById(copyBtnId);
generateBtn?.addEventListener('click', () => {
if (input) {
input.value = this.generateChannelKey();
}
if (copyBtn) {
copyBtn.disabled = false;
}
});
copyBtn?.addEventListener('click', () => {
if (input?.value) {
navigator.clipboard.writeText(input.value).then(() => {
const icon = copyBtn.querySelector('i');
if (icon) {
icon.className = 'bi bi-check';
setTimeout(() => { icon.className = 'bi bi-clipboard'; }, 2000);
}
});
}
});
},
// ========================================================================
// INITIALIZATION HELPERS
// ========================================================================
initEncodePage() {
this.initPasswordToggles();
this.initRsaMethodToggle();
this.initDropZones();
this.initClipboardPaste(['input[name="carrier"]', 'input[name="reference_photo"]']);
this.initQrCropAnimation('rsaQrInput');
this.initCollapseChevrons();
this.initPassphraseFontResize();
// Channel key (v4.0.0) - uses select dropdown
this.initChannelKey({
selectId: 'channelSelect',
customInputId: 'channelCustomInput',
keyInputId: 'channelKeyInput',
generateBtnId: 'channelKeyGenerate'
});
// Form submission with channel key validation
const form = document.getElementById('encodeForm');
const btn = document.getElementById('encodeBtn');
form?.addEventListener('submit', (e) => {
if (!this.validateChannelKeyOnSubmit(form, 'channelSelect', 'channelKeyInput')) {
e.preventDefault();
return false;
}
if (btn) {
btn.disabled = true;
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);
}
});
},
initDecodePage() {
this.initPasswordToggles();
this.initRsaMethodToggle();
this.initDropZones();
this.initClipboardPaste(['input[name="stego_image"]', 'input[name="reference_photo"]']);
this.initQrCropAnimation('rsaKeyQrInput');
this.initCollapseChevrons();
this.initPassphraseFontResize();
// Channel key (v4.0.0) - uses select dropdown
this.initChannelKey({
selectId: 'channelSelectDec',
customInputId: 'channelCustomInputDec',
keyInputId: 'channelKeyInputDec',
serverInfoId: 'channelServerInfoDec'
});
// Form submission with channel key validation and mode display
const form = document.getElementById('decodeForm');
const btn = document.getElementById('decodeBtn');
form?.addEventListener('submit', (e) => {
if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) {
e.preventDefault();
return false;
}
const selectedMode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'auto';
if (btn) {
btn.disabled = true;
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);
}
});
},
initGeneratePage() {
this.initPasswordToggles();
// Channel key generator (v4.0.0)
this.initChannelKeyGenerator('channelKeyGenerated', 'generateChannelKeyBtn', 'copyChannelKeyBtn');
}
};
// Auto-init based on page
document.addEventListener('DOMContentLoaded', () => {
// Detect page and initialize
if (document.getElementById('encodeForm')) {
Stegasoo.initEncodePage();
} else if (document.getElementById('decodeForm')) {
Stegasoo.initDecodePage();
} else if (document.querySelector('[data-page="generate"]')) {
Stegasoo.initGeneratePage();
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""
Stegasoo Subprocess Worker (v4.0.0)
This script runs in a subprocess and handles encode/decode operations.
If it crashes due to jpegio/scipy issues, the parent Flask process survives.
CHANGES in v4.0.0:
- Added channel_key support for encode/decode operations
- New channel_status operation
Communication is via JSON over stdin/stdout:
- Input: JSON object with operation parameters
- Output: JSON object with results or error
Usage:
echo '{"operation": "encode", ...}' | python stego_worker.py
"""
import base64
import json
import sys
import traceback
from pathlib import Path
# Ensure stegasoo is importable
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
sys.path.insert(0, str(Path(__file__).parent))
def _resolve_channel_key(channel_key_param):
"""
Resolve channel_key parameter to value for stegasoo.
Args:
channel_key_param: 'auto', 'none', explicit key, or None
Returns:
None (auto), "" (public), or explicit key string
"""
if channel_key_param is None or channel_key_param == "auto":
return None # Auto mode - use server config
elif channel_key_param == "none":
return "" # Public mode
else:
return channel_key_param # Explicit key
def _get_channel_info(resolved_key):
"""
Get channel mode and fingerprint for response.
Returns:
(mode, fingerprint) tuple
"""
from stegasoo import get_channel_status, has_channel_key
if resolved_key == "":
return "public", None
if resolved_key is not None:
# Explicit key
fingerprint = f"{resolved_key[:4]}-••••-••••-••••-••••-••••-••••-{resolved_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
def encode_operation(params: dict) -> dict:
"""Handle encode operation."""
from stegasoo import FilePayload, encode
# Decode base64 inputs
carrier_data = base64.b64decode(params["carrier_b64"])
reference_data = base64.b64decode(params["reference_b64"])
# Optional RSA key
rsa_key_data = None
if params.get("rsa_key_b64"):
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
# Determine payload type
if params.get("file_b64"):
file_data = base64.b64decode(params["file_b64"])
payload = FilePayload(
data=file_data,
filename=params.get("file_name", "file"),
mime_type=params.get("file_mime", "application/octet-stream"),
)
else:
payload = params.get("message", "")
# Resolve channel key (v4.0.0)
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
# Call encode with correct parameter names
result = encode(
message=payload,
reference_photo=reference_data,
carrier_image=carrier_data,
passphrase=params.get("passphrase", ""),
pin=params.get("pin"),
rsa_key_data=rsa_key_data,
rsa_password=params.get("rsa_password"),
embed_mode=params.get("embed_mode", "lsb"),
dct_output_format=params.get("dct_output_format", "png"),
dct_color_mode=params.get("dct_color_mode", "color"),
channel_key=resolved_channel_key, # v4.0.0
)
# Build stats dict if available
stats = None
if hasattr(result, "stats") and result.stats:
stats = {
"pixels_modified": getattr(result.stats, "pixels_modified", 0),
"capacity_used": getattr(result.stats, "capacity_used", 0),
"bytes_embedded": getattr(result.stats, "bytes_embedded", 0),
}
# Get channel info for response (v4.0.0)
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
return {
"success": True,
"stego_b64": base64.b64encode(result.stego_image).decode("ascii"),
"filename": getattr(result, "filename", None),
"stats": stats,
"channel_mode": channel_mode,
"channel_fingerprint": channel_fingerprint,
}
def decode_operation(params: dict) -> dict:
"""Handle decode operation."""
from stegasoo import decode
# Decode base64 inputs
stego_data = base64.b64decode(params["stego_b64"])
reference_data = base64.b64decode(params["reference_b64"])
# Optional RSA key
rsa_key_data = None
if params.get("rsa_key_b64"):
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
# Resolve channel key (v4.0.0)
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
# Call decode with correct parameter names
result = decode(
stego_image=stego_data,
reference_photo=reference_data,
passphrase=params.get("passphrase", ""),
pin=params.get("pin"),
rsa_key_data=rsa_key_data,
rsa_password=params.get("rsa_password"),
embed_mode=params.get("embed_mode", "auto"),
channel_key=resolved_channel_key, # v4.0.0
)
if result.is_file:
return {
"success": True,
"is_file": True,
"file_b64": base64.b64encode(result.file_data).decode("ascii"),
"filename": result.filename,
"mime_type": result.mime_type,
}
else:
return {
"success": True,
"is_file": False,
"message": result.message,
}
def compare_operation(params: dict) -> dict:
"""Handle compare_modes operation."""
from stegasoo import compare_modes
carrier_data = base64.b64decode(params["carrier_b64"])
result = compare_modes(carrier_data)
return {
"success": True,
"comparison": result,
}
def capacity_check_operation(params: dict) -> dict:
"""Handle will_fit_by_mode operation."""
from stegasoo import will_fit_by_mode
carrier_data = base64.b64decode(params["carrier_b64"])
result = will_fit_by_mode(
payload=params["payload_size"],
carrier_image=carrier_data,
embed_mode=params.get("embed_mode", "lsb"),
)
return {
"success": True,
"result": result,
}
def channel_status_operation(params: dict) -> dict:
"""Handle channel status check (v4.0.0)."""
from stegasoo import get_channel_status
status = get_channel_status()
reveal = params.get("reveal", False)
return {
"success": True,
"status": {
"mode": status["mode"],
"configured": status["configured"],
"fingerprint": status.get("fingerprint"),
"source": status.get("source"),
"key": status.get("key") if reveal and status["configured"] else None,
},
}
def main():
"""Main entry point - read JSON from stdin, write JSON to stdout."""
try:
# Read all input
input_text = sys.stdin.read()
if not input_text.strip():
output = {"success": False, "error": "No input provided"}
else:
params = json.loads(input_text)
operation = params.get("operation")
if operation == "encode":
output = encode_operation(params)
elif operation == "decode":
output = decode_operation(params)
elif operation == "compare":
output = compare_operation(params)
elif operation == "capacity":
output = capacity_check_operation(params)
elif operation == "channel_status":
output = channel_status_operation(params)
else:
output = {"success": False, "error": f"Unknown operation: {operation}"}
except json.JSONDecodeError as e:
output = {"success": False, "error": f"Invalid JSON: {e}"}
except Exception as e:
output = {
"success": False,
"error": str(e),
"error_type": type(e).__name__,
"traceback": traceback.format_exc(),
}
# Write output as JSON
print(json.dumps(output), flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,498 @@
"""
Subprocess Steganography Wrapper (v4.0.0)
Runs stegasoo operations in isolated subprocesses to prevent crashes
from taking down the Flask server.
CHANGES in v4.0.0:
- Added channel_key parameter to encode() and decode() methods
- Channel keys enable deployment/group isolation
Usage:
from subprocess_stego import SubprocessStego
stego = SubprocessStego()
# Encode with channel key
result = stego.encode(
carrier_data=carrier_bytes,
reference_data=ref_bytes,
message="secret message",
passphrase="my passphrase",
pin="123456",
embed_mode="dct",
channel_key="auto", # or "none", or explicit key
)
if result.success:
stego_bytes = result.stego_data
extension = result.extension
else:
error_message = result.error
# Decode
result = stego.decode(
stego_data=stego_bytes,
reference_data=ref_bytes,
passphrase="my passphrase",
pin="123456",
channel_key="auto",
)
# Compare modes (capacity)
result = stego.compare_modes(carrier_bytes)
"""
import base64
import json
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
# Default timeout for operations (seconds)
DEFAULT_TIMEOUT = 120
# Path to worker script - adjust if needed
WORKER_SCRIPT = Path(__file__).parent / "stego_worker.py"
@dataclass
class EncodeResult:
"""Result from encode operation."""
success: bool
stego_data: bytes | None = None
filename: str | None = None
stats: dict[str, Any] | None = None
# Channel info (v4.0.0)
channel_mode: str | None = None
channel_fingerprint: str | None = None
error: str | None = None
error_type: str | None = None
@dataclass
class DecodeResult:
"""Result from decode operation."""
success: bool
is_file: bool = False
message: str | None = None
file_data: bytes | None = None
filename: str | None = None
mime_type: str | None = None
error: str | None = None
error_type: str | None = None
@dataclass
class CompareResult:
"""Result from compare_modes operation."""
success: bool
width: int = 0
height: int = 0
lsb: dict[str, Any] | None = None
dct: dict[str, Any] | None = None
error: str | None = None
@dataclass
class CapacityResult:
"""Result from capacity check operation."""
success: bool
fits: bool = False
payload_size: int = 0
capacity: int = 0
usage_percent: float = 0.0
headroom: int = 0
mode: str = ""
error: str | None = None
@dataclass
class ChannelStatusResult:
"""Result from channel status check (v4.0.0)."""
success: bool
mode: str = "public"
configured: bool = False
fingerprint: str | None = None
source: str | None = None
key: str | None = None
error: str | None = None
class SubprocessStego:
"""
Subprocess-isolated steganography operations.
All operations run in a separate Python process. If jpegio or scipy
crashes, only the subprocess dies - Flask keeps running.
"""
def __init__(
self,
worker_path: Path | None = None,
python_executable: str | None = None,
timeout: int = DEFAULT_TIMEOUT,
):
"""
Initialize subprocess wrapper.
Args:
worker_path: Path to stego_worker.py (default: same directory)
python_executable: Python interpreter to use (default: same as current)
timeout: Default timeout in seconds
"""
self.worker_path = worker_path or WORKER_SCRIPT
self.python = python_executable or sys.executable
self.timeout = timeout
if not self.worker_path.exists():
raise FileNotFoundError(f"Worker script not found: {self.worker_path}")
def _run_worker(self, params: dict[str, Any], timeout: int | None = None) -> dict[str, Any]:
"""
Run the worker subprocess with given parameters.
Args:
params: Dictionary of parameters (will be JSON-encoded)
timeout: Operation timeout in seconds
Returns:
Dictionary with results from worker
"""
timeout = timeout or self.timeout
input_json = json.dumps(params)
try:
result = subprocess.run(
[self.python, str(self.worker_path)],
input=input_json,
capture_output=True,
text=True,
timeout=timeout,
cwd=str(self.worker_path.parent),
)
if result.returncode != 0:
# Worker crashed
return {
"success": False,
"error": f"Worker crashed (exit code {result.returncode})",
"stderr": result.stderr,
}
if not result.stdout.strip():
return {
"success": False,
"error": "Worker returned empty output",
"stderr": result.stderr,
}
return json.loads(result.stdout)
except subprocess.TimeoutExpired:
return {
"success": False,
"error": f"Operation timed out after {timeout} seconds",
"error_type": "TimeoutError",
}
except json.JSONDecodeError as e:
return {
"success": False,
"error": f"Invalid JSON from worker: {e}",
"raw_output": result.stdout if "result" in dir() else None,
}
except Exception as e:
return {
"success": False,
"error": str(e),
"error_type": type(e).__name__,
}
def encode(
self,
carrier_data: bytes,
reference_data: bytes,
message: str | None = None,
file_data: bytes | None = None,
file_name: str | None = None,
file_mime: str | None = None,
passphrase: str = "",
pin: str | None = None,
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
embed_mode: str = "lsb",
dct_output_format: str = "png",
dct_color_mode: str = "color",
# Channel key (v4.0.0)
channel_key: str | None = "auto",
timeout: int | None = None,
) -> EncodeResult:
"""
Encode a message or file into an image.
Args:
carrier_data: Carrier image bytes
reference_data: Reference photo bytes
message: Text message to encode (if not file)
file_data: File bytes to encode (if not message)
file_name: Original filename (for file payload)
file_mime: MIME type (for file payload)
passphrase: Encryption passphrase
pin: Optional PIN
rsa_key_data: Optional RSA key PEM bytes
rsa_password: RSA key password if encrypted
embed_mode: 'lsb' or 'dct'
dct_output_format: 'png' or 'jpeg' (for DCT mode)
dct_color_mode: 'grayscale' or 'color' (for DCT mode)
channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0)
timeout: Operation timeout in seconds
Returns:
EncodeResult with stego_data and extension on success
"""
params = {
"operation": "encode",
"carrier_b64": base64.b64encode(carrier_data).decode("ascii"),
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
"message": message,
"passphrase": passphrase,
"pin": pin,
"embed_mode": embed_mode,
"dct_output_format": dct_output_format,
"dct_color_mode": dct_color_mode,
"channel_key": channel_key, # v4.0.0
}
if file_data:
params["file_b64"] = base64.b64encode(file_data).decode("ascii")
params["file_name"] = file_name
params["file_mime"] = file_mime
if rsa_key_data:
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
params["rsa_password"] = rsa_password
result = self._run_worker(params, timeout)
if result.get("success"):
return EncodeResult(
success=True,
stego_data=base64.b64decode(result["stego_b64"]),
filename=result.get("filename"),
stats=result.get("stats"),
channel_mode=result.get("channel_mode"),
channel_fingerprint=result.get("channel_fingerprint"),
)
else:
return EncodeResult(
success=False,
error=result.get("error", "Unknown error"),
error_type=result.get("error_type"),
)
def decode(
self,
stego_data: bytes,
reference_data: bytes,
passphrase: str = "",
pin: str | None = None,
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
embed_mode: str = "auto",
# Channel key (v4.0.0)
channel_key: str | None = "auto",
timeout: int | None = None,
) -> DecodeResult:
"""
Decode a message or file from a stego image.
Args:
stego_data: Stego image bytes
reference_data: Reference photo bytes
passphrase: Decryption passphrase
pin: Optional PIN
rsa_key_data: Optional RSA key PEM bytes
rsa_password: RSA key password if encrypted
embed_mode: 'auto', 'lsb', or 'dct'
channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0)
timeout: Operation timeout in seconds
Returns:
DecodeResult with message or file_data on success
"""
params = {
"operation": "decode",
"stego_b64": base64.b64encode(stego_data).decode("ascii"),
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
"passphrase": passphrase,
"pin": pin,
"embed_mode": embed_mode,
"channel_key": channel_key, # v4.0.0
}
if rsa_key_data:
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
params["rsa_password"] = rsa_password
result = self._run_worker(params, timeout)
if result.get("success"):
if result.get("is_file"):
return DecodeResult(
success=True,
is_file=True,
file_data=base64.b64decode(result["file_b64"]),
filename=result.get("filename"),
mime_type=result.get("mime_type"),
)
else:
return DecodeResult(
success=True,
is_file=False,
message=result.get("message"),
)
else:
return DecodeResult(
success=False,
error=result.get("error", "Unknown error"),
error_type=result.get("error_type"),
)
def compare_modes(
self,
carrier_data: bytes,
timeout: int | None = None,
) -> CompareResult:
"""
Compare LSB and DCT capacity for a carrier image.
Args:
carrier_data: Carrier image bytes
timeout: Operation timeout in seconds
Returns:
CompareResult with capacity information
"""
params = {
"operation": "compare",
"carrier_b64": base64.b64encode(carrier_data).decode("ascii"),
}
result = self._run_worker(params, timeout)
if result.get("success"):
comparison = result.get("comparison", {})
return CompareResult(
success=True,
width=comparison.get("width", 0),
height=comparison.get("height", 0),
lsb=comparison.get("lsb"),
dct=comparison.get("dct"),
)
else:
return CompareResult(
success=False,
error=result.get("error", "Unknown error"),
)
def check_capacity(
self,
carrier_data: bytes,
payload_size: int,
embed_mode: str = "lsb",
timeout: int | None = None,
) -> CapacityResult:
"""
Check if a payload will fit in the carrier.
Args:
carrier_data: Carrier image bytes
payload_size: Size of payload in bytes
embed_mode: 'lsb' or 'dct'
timeout: Operation timeout in seconds
Returns:
CapacityResult with fit information
"""
params = {
"operation": "capacity",
"carrier_b64": base64.b64encode(carrier_data).decode("ascii"),
"payload_size": payload_size,
"embed_mode": embed_mode,
}
result = self._run_worker(params, timeout)
if result.get("success"):
r = result.get("result", {})
return CapacityResult(
success=True,
fits=r.get("fits", False),
payload_size=r.get("payload_size", 0),
capacity=r.get("capacity", 0),
usage_percent=r.get("usage_percent", 0.0),
headroom=r.get("headroom", 0),
mode=r.get("mode", embed_mode),
)
else:
return CapacityResult(
success=False,
error=result.get("error", "Unknown error"),
)
def get_channel_status(
self,
reveal: bool = False,
timeout: int | None = None,
) -> ChannelStatusResult:
"""
Get current channel key status (v4.0.0).
Args:
reveal: Include full key in response
timeout: Operation timeout in seconds
Returns:
ChannelStatusResult with channel info
"""
params = {
"operation": "channel_status",
"reveal": reveal,
}
result = self._run_worker(params, timeout)
if result.get("success"):
status = result.get("status", {})
return ChannelStatusResult(
success=True,
mode=status.get("mode", "public"),
configured=status.get("configured", False),
fingerprint=status.get("fingerprint"),
source=status.get("source"),
key=status.get("key") if reveal else None,
)
else:
return ChannelStatusResult(
success=False,
error=result.get("error", "Unknown error"),
)
# Convenience function for quick usage
_default_stego: SubprocessStego | None = None
def get_subprocess_stego() -> SubprocessStego:
"""Get or create default SubprocessStego instance."""
global _default_stego
if _default_stego is None:
_default_stego = SubprocessStego()
return _default_stego

View File

@@ -11,29 +11,32 @@
</div>
<div class="card-body">
<p class="lead">
Stegasoo is a secure steganography tool that hides encrypted messages and files
inside ordinary images using multi-factor authentication.
Stegasoo hides encrypted messages and files inside images using multi-factor authentication.
</p>
<h6 class="text-primary mt-4 mb-3"><i class="bi bi-stars me-2"></i>Key Features</h6>
<h6 class="text-primary mt-4 mb-3">Features</h6>
<div class="row">
<div class="col-md-6">
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Text &amp; File Embedding</strong> — Hide messages or any file type (PDF, ZIP, documents)
<strong>Text &amp; File Embedding</strong>
<br><small class="text-muted">Any file type: PDF, ZIP, documents</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Multi-Factor Security</strong> — Combines photo + phrase + PIN/RSA key
<strong>Multi-Factor Security</strong>
<br><small class="text-muted">Photo + passphrase + PIN/RSA key</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>AES-256-GCM Encryption</strong> — Military-grade authenticated encryption
<strong>AES-256-GCM Encryption</strong>
<br><small class="text-muted">Authenticated encryption with integrity check</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Daily Rotating Phrases</strong> — Different passphrase each day of the week
<strong>DCT &amp; LSB Modes</strong>
<br><small class="text-muted">JPEG resilience (DCT) or high capacity (LSB)</small>
</li>
</ul>
</div>
@@ -41,19 +44,29 @@
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Random Pixel Embedding</strong> — Defeats statistical steganalysis
<strong>Random Pixel Embedding</strong>
<br><small class="text-muted">Defeats statistical analysis</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Format Preservation</strong> — Maintains PNG/BMP lossless formats
<strong>Large Image Support</strong>
<br><small class="text-muted">Up to {{ max_payload_kb }} KB, tested with 14MB+ images</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Large Capacity</strong> — Up to {{ max_payload_kb }} KB payload, 16MP images
<strong>Zero Server Storage</strong>
<br><small class="text-muted">Nothing saved, files auto-expire</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Zero Server Storage</strong> — Nothing saved, files auto-expire
<strong>QR Code Keys</strong>
<br><small class="text-muted">Import/export RSA keys via QR</small>
</li>
<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.1</span>
<br><small class="text-muted">Group/deployment isolation</small>
</li>
</ul>
</div>
@@ -61,113 +74,357 @@
</div>
</div>
<!-- Embedding Modes -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-cpu me-2"></i>Embedding Modes</h5>
</div>
<div class="card-body">
<p>Two modes optimized for different use cases.</p>
<div class="row mt-4">
<!-- DCT Mode -->
<div class="col-md-6 mb-4">
<div class="card bg-dark h-100">
<div class="card-header">
<i class="bi bi-soundwave text-warning me-2"></i>
<strong>DCT Mode</strong>
<span class="badge bg-success ms-2">Default</span>
</div>
<div class="card-body">
<p class="small">
<strong>DCT (Discrete Cosine Transform)</strong> embeds data in frequency coefficients. Survives JPEG recompression.
</p>
<ul class="small mb-0">
<li><strong>Capacity:</strong> ~75 KB/MP</li>
<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">
<i class="bi bi-check-circle text-success me-1"></i> Instagram, Facebook<br>
<i class="bi bi-check-circle text-success me-1"></i> WhatsApp, Signal, Telegram<br>
<i class="bi bi-check-circle text-success me-1"></i> Twitter/X<br>
<i class="bi bi-check-circle text-success me-1"></i> Any recompressing platform
</div>
</div>
</div>
</div>
<!-- LSB Mode -->
<div class="col-md-6 mb-4">
<div class="card bg-dark h-100">
<div class="card-header">
<i class="bi bi-grid-3x3-gap text-primary me-2"></i>
<strong>LSB Mode</strong>
</div>
<div class="card-body">
<p class="small">
<strong>LSB (Least Significant Bit)</strong> embeds data in the lowest bit of each color channel. Imperceptible to the eye.
</p>
<ul class="small mb-0">
<li><strong>Capacity:</strong> ~375 KB/MP</li>
<li><strong>Output:</strong> PNG (lossless)</li>
<li><strong>Color:</strong> Full color</li>
<li><strong>Speed:</strong> ~0.5s</li>
</ul>
<hr>
<div class="small">
<i class="bi bi-check-circle text-success me-1"></i> Email attachments<br>
<i class="bi bi-check-circle text-success me-1"></i> Cloud storage<br>
<i class="bi bi-check-circle text-success me-1"></i> Direct file transfer<br>
<i class="bi bi-x-circle text-danger me-1"></i> Social media
</div>
</div>
</div>
</div>
</div>
<!-- Mode Comparison Table -->
<h6 class="mt-3"><i class="bi bi-table me-2"></i>Comparison</h6>
<div class="table-responsive">
<table class="table table-dark table-sm small">
<thead>
<tr>
<th>Aspect</th>
<th>DCT Mode <span class="badge bg-success ms-1">Default</span></th>
<th>LSB Mode</th>
</tr>
</thead>
<tbody>
<tr>
<td>Capacity (1080p)</td>
<td class="text-warning">~50 KB</td>
<td class="text-success">~770 KB</td>
</tr>
<tr>
<td>Survives JPEG</td>
<td class="text-success">✅ Yes</td>
<td class="text-danger">❌ No</td>
</tr>
<tr>
<td>Social Media</td>
<td class="text-success">✅ Works</td>
<td class="text-danger">❌ Broken</td>
</tr>
<tr>
<td>Detection Resistance</td>
<td>Better</td>
<td>Moderate</td>
</tr>
</tbody>
</table>
</div>
<div class="alert alert-info small mt-3 mb-0">
<i class="bi bi-lightbulb me-2"></i>
<strong>Auto-Detection:</strong> Mode is detected automatically when decoding.
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>How Security Works</h5>
</div>
<div class="card-body">
<p>Stegasoo uses <strong>hybrid multi-factor authentication</strong> to derive encryption keys:</p>
<p>Multi-factor authentication derives encryption keys:</p>
<div class="row text-center my-4">
<div class="col-md-3 mb-3">
<div class="p-3 bg-dark rounded">
<div class="col-6 col-lg-3 mb-3">
<div class="p-3 bg-dark rounded h-100 d-flex flex-column align-items-center">
<i class="bi bi-image text-info fs-2 d-block mb-2"></i>
<strong>Reference Photo</strong>
<div class="small text-muted mt-1">Something you have</div>
<div class="small text-success">~80-256 bits</div>
<div class="small text-success mt-auto pt-2">~80-256 bits</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="p-3 bg-dark rounded">
<div class="col-6 col-lg-3 mb-3">
<div class="p-3 bg-dark rounded h-100 d-flex flex-column align-items-center">
<i class="bi bi-chat-quote text-warning fs-2 d-block mb-2"></i>
<strong>Daily Phrase</strong>
<div class="small text-muted mt-1">Something you know (rotates)</div>
<div class="small text-success">~33 bits (3 words)</div>
<strong>Passphrase</strong>
<div class="small text-muted mt-1">Something you know</div>
<div class="small text-success mt-auto pt-2">~44 bits (4 words)</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="p-3 bg-dark rounded">
<div class="col-6 col-lg-3 mb-3">
<div class="p-3 bg-dark rounded h-100 d-flex flex-column align-items-center">
<i class="bi bi-123 text-danger fs-2 d-block mb-2"></i>
<strong>Static PIN</strong>
<div class="small text-muted mt-1">Something you know (fixed)</div>
<div class="small text-success">~20 bits (6 digits)</div>
<div class="small text-muted mt-1">Something you know</div>
<div class="small text-success mt-auto pt-2">~20 bits (6 digits)</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="p-3 bg-dark rounded">
<div class="col-6 col-lg-3 mb-3">
<div class="p-3 bg-dark rounded h-100 d-flex flex-column align-items-center">
<i class="bi bi-key text-primary fs-2 d-block mb-2"></i>
<strong>RSA Key</strong>
<div class="small text-muted mt-1">Something you have (optional)</div>
<div class="small text-success">~128 bits (2048-bit)</div>
<div class="small text-muted mt-1">Optional</div>
<div class="small text-success mt-auto pt-2">~128 bits</div>
</div>
</div>
</div>
<div class="alert alert-secondary">
<i class="bi bi-calculator me-2"></i>
<strong>Combined entropy:</strong> 130-400+ bits depending on configuration.
For reference, 128 bits is considered computationally infeasible to brute force.
<strong>Combined entropy:</strong> 144-424+ bits. 128 bits is infeasible to brute force.
</div>
<h6 class="mt-4">Key Derivation</h6>
<p>
{% if has_argon2 %}
<span class="badge bg-success me-1"><i class="bi bi-check"></i> Argon2id Available</span>
Using <strong>Argon2id</strong> with 256MB memory cost — the winner of the Password Hashing Competition
and current best practice for key derivation.
<span class="badge bg-success me-1"><i class="bi bi-check"></i> Argon2id</span>
256MB memory cost. Memory-hard KDF defeats GPU/ASIC attacks.
{% else %}
<span class="badge bg-warning text-dark me-1"><i class="bi bi-exclamation-triangle"></i> Argon2 Not Available</span>
Falling back to <strong>PBKDF2-SHA512</strong> with 600,000 iterations.
Using PBKDF2-SHA512 with 600k iterations.
Install <code>argon2-cffi</code> for stronger security.
{% endif %}
</p>
<h6 class="mt-4">Steganography Technique</h6>
<p>
Uses <strong>LSB (Least Significant Bit)</strong> embedding with pseudo-random pixel selection.
The pixel locations are determined by a key derived from your credentials, making the
hidden data's location unpredictable without the correct inputs.
</p>
</div>
</div>
<div class="card mb-4">
<!-- Channel Keys (v4.0.0) -->
<div class="card mb-4" id="channel-keys">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-file-earmark-binary me-2"></i>File Embedding</h5>
<h5 class="mb-0">
<i class="bi bi-broadcast me-2"></i>Channel Keys
<span class="badge bg-info ms-2">v4.1</span>
</h5>
</div>
<div class="card-body">
<p>
<span class="badge bg-info me-1">New in v2.1</span>
Stegasoo now supports embedding <strong>any file type</strong>, not just text messages.
Channel keys provide <strong>deployment/group isolation</strong>. Messages encoded with one channel key
cannot be decoded with a different key, even if all other credentials match.
</p>
<div class="row">
<div class="col-md-6">
<h6><i class="bi bi-check2-square text-success me-2"></i>Supported</h6>
<ul class="small">
<li>PDF documents</li>
<li>ZIP/RAR archives</li>
<li>Office documents (DOCX, XLSX, PPTX)</li>
<li>Source code files</li>
<li>Any binary file up to {{ max_payload_kb }} KB</li>
</ul>
<div class="row mt-4">
<!-- Auto Mode -->
<div class="col-md-4 mb-3">
<div class="card bg-dark h-100">
<div class="card-header text-center">
<i class="bi bi-gear-fill text-success fs-2 d-block mb-2"></i>
<strong>Auto</strong>
</div>
<div class="card-body">
<p class="small mb-2">Uses server-configured key if available, otherwise public mode.</p>
<ul class="small mb-0">
<li>Set via <code>STEGASOO_CHANNEL_KEY</code> env var</li>
<li>Or <code>channel_key</code> in config file</li>
<li>All users share the same channel</li>
</ul>
</div>
</div>
</div>
<div class="col-md-6">
<h6><i class="bi bi-info-circle text-info me-2"></i>How It Works</h6>
<ul class="small">
<li>Original filename is preserved</li>
<li>MIME type is stored for proper handling</li>
<li>File is encrypted identically to text</li>
<li>Decoding auto-detects text vs. file</li>
</ul>
<!-- Public Mode -->
<div class="col-md-4 mb-3">
<div class="card bg-dark h-100">
<div class="card-header text-center">
<i class="bi bi-globe text-info fs-2 d-block mb-2"></i>
<strong>Public</strong>
</div>
<div class="card-body">
<p class="small mb-2">No channel key. Compatible with other public installations.</p>
<ul class="small mb-0">
<li>Default if no server key configured</li>
<li>Anyone can decode (with credentials)</li>
<li>Interoperable between deployments</li>
</ul>
</div>
</div>
</div>
<!-- Custom Mode -->
<div class="col-md-4 mb-3">
<div class="card bg-dark h-100">
<div class="card-header text-center">
<i class="bi bi-key-fill text-warning fs-2 d-block mb-2"></i>
<strong>Custom</strong>
</div>
<div class="card-body">
<p class="small mb-2">Your own group key. Share with recipients.</p>
<ul class="small mb-0">
<li>Format: <code>XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX</code></li>
<li>32 chars (128 bits entropy)</li>
<li>Private group communication</li>
</ul>
</div>
</div>
</div>
</div>
<div class="alert alert-info small mt-3">
<i class="bi bi-lightbulb me-2"></i>
<strong>Tip:</strong> For larger files, compress them first (ZIP) to maximize capacity.
A 16MP carrier image can hold approximately 6MB of raw data, but we limit payloads
to {{ max_payload_kb }} KB for reasonable processing times.
{% if channel_configured %}
<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-3">
<i class="bi bi-info-circle me-2"></i>
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>
<!-- Version History -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Version History</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-dark table-sm small">
<thead>
<tr>
<th>Version</th>
<th>Changes</th>
</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,
4-word default, JPEG fix, large image support, subprocess isolation, Python 3.10-3.12
</td>
</tr>
<tr>
<td>3.2.0</td>
<td>Single passphrase, more default words</td>
</tr>
<tr>
<td>3.0.0</td>
<td>DCT mode, JPEG output, color preservation</td>
</tr>
<tr>
<td>2.2.0</td>
<td>QR code RSA key import/export</td>
</tr>
<tr>
<td>2.1.0</td>
<td>File embedding, compression</td>
</tr>
<tr>
<td>2.0.0</td>
<td>Web UI, REST API, RSA keys</td>
</tr>
<tr>
<td>1.0.0</td>
<td>Initial release, CLI only, LSB mode</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -188,11 +445,11 @@
<div id="setup" class="accordion-collapse collapse show" data-bs-parent="#usageAccordion">
<div class="accordion-body">
<ol>
<li>Both parties agree on a <strong>reference photo</strong> (shared secretly, never transmitted)</li>
<li>Go to <a href="/generate">Generate</a> and create credentials</li>
<li><strong>Memorize</strong> the 7 daily phrases and PIN</li>
<li>If using RSA, download and securely store the key file</li>
<li>Share credentials with your contact through a secure channel</li>
<li>Agree on a <strong>reference photo</strong> (never transmitted)</li>
<li>Go to <a href="/generate">Generate</a> to create credentials</li>
<li>Memorize passphrase and PIN</li>
<li>If using RSA, store the key file securely</li>
<li>Share credentials via secure channel</li>
</ol>
</div>
</div>
@@ -202,20 +459,23 @@
<h2 class="accordion-header">
<button class="accordion-button collapsed bg-dark text-light" type="button"
data-bs-toggle="collapse" data-bs-target="#encoding">
<i class="bi bi-2-circle me-2"></i>Encoding a Message or File
<i class="bi bi-2-circle me-2"></i>Encoding
</button>
</h2>
<div id="encoding" class="accordion-collapse collapse" data-bs-parent="#usageAccordion">
<div class="accordion-body">
<ol>
<li>Go to <a href="/encode">Encode</a></li>
<li>Upload your <strong>reference photo</strong></li>
<li>Upload a <strong>carrier image</strong> (the image to hide data in)</li>
<li>Choose <strong>Text</strong> or <strong>File</strong> mode</li>
<li>Enter your message or select a file to embed</li>
<li>Enter <strong>today's phrase</strong> and your PIN/key</li>
<li>Download the resulting stego image</li>
<li>Send the stego image through any channel (email, social media, etc.)</li>
<li>Upload <strong>reference photo</strong> and <strong>carrier image</strong></li>
<li>Choose mode:
<ul>
<li><strong>DCT</strong> (default): social media</li>
<li><strong>LSB</strong>: email, cloud, direct transfer</li>
</ul>
</li>
<li>Enter message or select file</li>
<li>Enter passphrase and PIN/key</li>
<li>Download stego image</li>
</ol>
</div>
</div>
@@ -225,23 +485,21 @@
<h2 class="accordion-header">
<button class="accordion-button collapsed bg-dark text-light" type="button"
data-bs-toggle="collapse" data-bs-target="#decoding">
<i class="bi bi-3-circle me-2"></i>Decoding a Message or File
<i class="bi bi-3-circle me-2"></i>Decoding
</button>
</h2>
<div id="decoding" class="accordion-collapse collapse" data-bs-parent="#usageAccordion">
<div class="accordion-body">
<ol>
<li>Go to <a href="/decode">Decode</a></li>
<li>Upload your <strong>reference photo</strong> (same one used for encoding)</li>
<li>Upload the <strong>stego image</strong> you received</li>
<li>Enter the phrase for <strong>the day it was encoded</strong> (check the filename for date)</li>
<li>Enter your PIN and/or RSA key</li>
<li>View the decoded message or download the extracted file</li>
<li>Upload <strong>reference photo</strong></li>
<li>Upload <strong>stego image</strong></li>
<li>Enter passphrase and PIN/key</li>
<li>View message or download file</li>
</ol>
<div class="alert alert-warning small mt-3 mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>
The stego image filename contains the encoding date (e.g., <code>abc123_20251228.png</code>).
Use this to determine which day's phrase to use!
<div class="alert alert-info small mt-3 mb-0">
<i class="bi bi-magic me-2"></i>
Mode is auto-detected.
</div>
</div>
</div>
@@ -252,100 +510,177 @@
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Limits &amp; Specifications</h5>
<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">
<tbody>
<tr>
<td><i class="bi bi-file-text me-2"></i>Max text message</td>
<td><strong>250,000 characters</strong> (~250 KB)</td>
</tr>
<tr>
<td><i class="bi bi-file-earmark me-2"></i>Max file payload</td>
<td><strong>{{ max_payload_kb }} KB</strong></td>
</tr>
<tr>
<td><i class="bi bi-image me-2"></i>Max carrier image</td>
<td><strong>16 megapixels</strong> (~4000×4000)</td>
</tr>
<tr>
<td><i class="bi bi-upload me-2"></i>Max upload size</td>
<td><strong>10 MB</strong></td>
</tr>
<tr>
<td><i class="bi bi-clock me-2"></i>Temp file expiry</td>
<td><strong>5 minutes</strong></td>
</tr>
<tr>
<td><i class="bi bi-key me-2"></i>PIN length</td>
<td><strong>6-9 digits</strong></td>
</tr>
<tr>
<td><i class="bi bi-shield me-2"></i>RSA key sizes</td>
<td><strong>2048, 3072, 4096 bits</strong></td>
</tr>
<tr>
<td><i class="bi bi-chat-quote me-2"></i>Phrase length</td>
<td><strong>3-12 words</strong> (BIP-39 wordlist)</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 class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-terminal me-2"></i>CLI &amp; API</h5>
</div>
<div class="card-body">
<p>Stegasoo is also available as a command-line tool and REST API:</p>
<h6 class="mt-3">Command Line</h6>
<pre class="bg-dark p-3 rounded"><code># Generate credentials
stegasoo generate --pin --rsa
# Encode a text message
stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -m "secret"
# Encode a file
stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -e document.pdf
# Decode (auto-detects text vs file)
stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456</code></pre>
<h6 class="mt-4">REST API</h6>
<pre class="bg-dark p-3 rounded"><code># Encode with multipart upload
curl -X POST http://localhost:8000/encode/multipart \
-F "reference_photo=@photo.jpg" \
-F "carrier=@meme.png" \
-F "message=secret" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
--output stego.png
# Encode a file
curl -X POST http://localhost:8000/encode/multipart \
-F "reference_photo=@photo.jpg" \
-F "carrier=@meme.png" \
-F "payload_file=@document.pdf" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
--output stego.png</code></pre>
<p class="small text-muted mt-3 mb-0">
API documentation available at <code>/docs</code> (Swagger) or <code>/redoc</code> when running the API server.
</p>
</div>
</div>
<div class="text-center mt-4 text-muted small">
<p>
Stegasoo v2.1.0 &bull;
<i class="bi bi-github me-1"></i>Open Source &bull;
Built with Python, Flask, and cryptography
</p>
</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

@@ -0,0 +1,234 @@
{% extends "base.html" %}
{% block title %}Account - 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">
<h5 class="mb-0"><i class="bi bi-person-gear me-2"></i>Account Settings</h5>
</div>
<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">
<div class="mb-3">
<label class="form-label">
<i class="bi bi-key me-1"></i> Current Password
</label>
<div class="input-group">
<input type="password" name="current_password" class="form-control"
id="currentPasswordInput" required>
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('currentPasswordInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">
<i class="bi bi-key-fill me-1"></i> New Password
</label>
<div class="input-group">
<input type="password" name="new_password" class="form-control"
id="newPasswordInput" required minlength="8">
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('newPasswordInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-text">Minimum 8 characters</div>
</div>
<div class="mb-4">
<label class="form-label">
<i class="bi bi-key-fill me-1"></i> Confirm New Password
</label>
<div class="input-group">
<input type="password" name="new_password_confirm" class="form-control"
id="newPasswordConfirmInput" required minlength="8">
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('newPasswordConfirmInput', 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>Update Password
</button>
</form>
</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>
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
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>
@@ -24,38 +27,67 @@
<li class="nav-item">
<a class="nav-link" href="/"><i class="bi bi-house me-1"></i> Home</a>
</li>
{% if not auth_enabled or is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="/encode"><i class="bi bi-lock me-1"></i> Encode</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/decode"><i class="bi bi-unlock me-1"></i> Decode</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/generate"><i class="bi bi-key me-1"></i> Generate</a>
</li>
{% endif %}
<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">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle me-1"></i> {{ username }}
</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>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="/login"><i class="bi bi-box-arrow-in-right me-1"></i> Login</a>
</li>
{% endif %}
{% endif %}
</ul>
</div>
</div>
</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 'success' }} alert-dismissible fade show" role="alert">
<i class="bi bi-{{ 'exclamation-triangle' if category == 'error' 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>
@@ -63,12 +95,16 @@
<div class="container text-center text-muted">
<small>
<img src="{{ url_for('static', filename='favicon.svg') }}" alt="" height="16" class="me-1" style="vertical-align: text-bottom;">
Stegasoo v2.1.0 — Hybrid Photo + Day-Phrase + PIN Steganography
Stegasoo v{{ version }} — Steganography with Reference Photo + Passphrase + PIN/Key
</small>
</div>
</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

@@ -3,6 +3,110 @@
{% block title %}Decode Message - Stegasoo{% endblock %}
{% block content %}
<style>
/* Glowing passphrase input */
.passphrase-input {
background: rgba(30, 40, 50, 0.8) !important;
border: 2px solid rgba(99, 179, 237, 0.3) !important;
color: #63b3ed !important;
font-family: 'Courier New', monospace;
font-size: 1.1rem;
letter-spacing: 0.5px;
padding: 12px 16px;
transition: border-color 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
}
.passphrase-input:focus {
border-color: rgba(99, 179, 237, 0.8) !important;
box-shadow: 0 0 20px rgba(99, 179, 237, 0.4), 0 0 40px rgba(99, 179, 237, 0.2) !important;
background: rgba(30, 40, 50, 0.95) !important;
}
.passphrase-input::placeholder {
color: rgba(99, 179, 237, 0.4);
}
/* Glowing PIN input */
.pin-input-container .form-control {
background: rgba(30, 40, 50, 0.8) !important;
border: 2px solid rgba(246, 173, 85, 0.3) !important;
color: #f6ad55 !important;
font-family: 'Courier New', monospace;
font-size: 1.2rem;
letter-spacing: 3px;
text-align: center;
transition: all 0.3s ease;
}
.pin-input-container .form-control:focus {
border-color: rgba(246, 173, 85, 0.8) !important;
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4), 0 0 40px rgba(246, 173, 85, 0.2) !important;
background: rgba(30, 40, 50, 0.95) !important;
}
.pin-input-container .form-control::placeholder {
color: rgba(246, 173, 85, 0.4);
letter-spacing: 1px;
}
/* QR Crop Animation */
.qr-crop-container {
position: relative;
overflow: hidden;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
}
.qr-crop-container img {
display: block;
max-height: 180px;
max-width: 180px;
width: auto;
margin: 0 auto;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.qr-crop-container .qr-original {
opacity: 1;
}
.qr-crop-container .qr-cropped {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.3);
opacity: 0;
max-height: 160px;
min-width: 140px;
min-height: 140px;
object-fit: contain;
}
.qr-crop-container.scan-complete .qr-original {
opacity: 0;
transform: scale(1.1);
filter: blur(4px);
}
.qr-crop-container.scan-complete .qr-cropped {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.qr-crop-container .crop-badge {
position: absolute;
bottom: 4px;
right: 4px;
font-size: 0.65rem;
opacity: 0;
transition: opacity 0.3s ease 0.4s;
}
.qr-crop-container.scan-complete .crop-badge {
opacity: 1;
}
</style>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
@@ -16,15 +120,13 @@
<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="position-relative">
<div class="alert-message p-3 rounded bg-dark border border-secondary" id="decodedContent" style="white-space: pre-wrap;">{{ decoded_message }}</div>
<button class="btn btn-sm btn-outline-light position-absolute top-0 end-0 m-2" onclick="navigator.clipboard.writeText(document.getElementById('decodedContent').innerText).then(() => this.innerHTML = '<i class=\'bi bi-check\'></i>').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 mt-3">
<a href="/decode" class="btn btn-outline-light w-100">
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
</a>
@@ -64,13 +166,37 @@
<label class="form-label">
<i class="bi bi-image me-1"></i> Reference Photo
</label>
<div class="drop-zone">
<div class="drop-zone scan-container" id="refDropZone">
<input type="file" name="reference_photo" accept="image/*" required>
<div class="drop-zone-label">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop image or click to browse</span>
</div>
<img class="drop-zone-preview d-none">
<img class="drop-zone-preview d-none" id="refPreview">
<!-- Scan overlay elements -->
<div class="scan-overlay">
<div class="scan-grid"></div>
<div class="scan-line"></div>
</div>
<!-- Corner brackets (shown after scan) -->
<div class="scan-corners">
<div class="scan-corner tl"></div>
<div class="scan-corner tr"></div>
<div class="scan-corner bl"></div>
<div class="scan-corner br"></div>
</div>
<!-- Data panel (shown after scan) -->
<div class="scan-data-panel">
<div class="scan-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span id="refFileName">image.jpg</span>
</div>
<div class="scan-data-row">
<span class="scan-status-badge">Hash Acquired</span>
<span class="scan-data-value" id="refFileSize">--</span>
</div>
<div class="scan-hash-preview" id="refHashPreview">SHA256: ················</div>
</div>
</div>
<div class="form-text">
The same reference photo used for encoding
@@ -81,13 +207,36 @@
<label class="form-label">
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
</label>
<div class="drop-zone" id="stegoDropZone">
<div class="drop-zone pixel-container" id="stegoDropZone">
<input type="file" name="stego_image" accept="image/*" required>
<div class="drop-zone-label">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop image or click to browse</span>
</div>
<img class="drop-zone-preview d-none">
<img class="drop-zone-preview d-none" id="stegoPreview">
<!-- Pixel blocks overlay - populated by JS -->
<div class="pixel-blocks"></div>
<!-- Pixel scan line -->
<div class="pixel-scan-line"></div>
<!-- Corner brackets -->
<div class="pixel-corners">
<div class="pixel-corner tl"></div>
<div class="pixel-corner tr"></div>
<div class="pixel-corner bl"></div>
<div class="pixel-corner br"></div>
</div>
<!-- Data panel -->
<div class="pixel-data-panel">
<div class="pixel-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span id="stegoFileName">image.png</span>
</div>
<div class="pixel-data-row">
<span class="pixel-status-badge">Stego Loaded</span>
<span class="pixel-data-value" id="stegoFileSize">--</span>
</div>
<div class="pixel-dimensions" id="stegoDims">-- × -- px</div>
</div>
</div>
<div class="form-text">
The image containing the hidden message/file
@@ -96,13 +245,13 @@
</div>
<div class="mb-3">
<label class="form-label" id="dayPhraseLabel">
<i class="bi bi-chat-quote me-1"></i> Day Phrase
<label class="form-label">
<i class="bi bi-chat-quote me-1"></i> Passphrase
</label>
<input type="text" name="day_phrase" class="form-control"
placeholder="e.g., correct horse battery" required>
<input type="text" name="passphrase" id="passphraseInput" class="form-control passphrase-input"
placeholder="e.g., correct horse battery staple" required>
<div class="form-text">
The phrase for the day the message was encoded
The passphrase used during encoding (typically 4 words)
</div>
</div>
@@ -113,62 +262,179 @@
<span class="text-warning small">(provide same factors used during encoding)</span>
</h6>
<div class="mb-3">
<div class="security-box">
<label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
</label>
<!-- RSA Input Method Toggle -->
<div class="btn-group w-100 mb-2" role="group">
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile">
<i class="bi bi-file-earmark me-1"></i>.pem File
</label>
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr">
<i class="bi bi-qr-code me-1"></i>QR Code
</label>
</div>
<!-- .pem File Input -->
<div id="rsaFileSection">
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
</div>
<!-- QR Code Input -->
<div id="rsaQrSection" class="d-none">
<div class="drop-zone p-3" id="qrDropZone">
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaKeyQrInput">
<div class="drop-zone-label text-center">
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
<span class="text-muted small">Drop QR image or click to browse</span>
</div>
<!-- Crop animation container -->
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
<img class="qr-original" id="qrOriginal" alt="Original">
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
<!-- Data panel -->
<div class="qr-data-panel">
<div class="qr-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span>RSA Key loaded</span>
</div>
<div class="qr-data-row">
<span class="qr-status-badge">RSA Key</span>
<span class="qr-data-value">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Key Password (always visible) -->
<div class="input-group input-group-sm mt-2">
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
</div>
<!-- PIN + Channel Row -->
<div class="row">
<div class="col-md-6 mb-3">
<div class="security-box h-100">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="6-9 digits" maxlength="9">
<button class="btn btn-outline-secondary" type="button" id="togglePin">
<div class="input-group pin-input-container">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-text">
If PIN was used during encoding
</div>
<div class="form-text">If PIN was used during encoding</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="security-box h-100">
<label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
<i class="bi bi-broadcast me-1"></i> Channel
<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>
<ul class="nav nav-tabs nav-tabs-sm mb-2" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaFileTabDec" type="button">
<i class="bi bi-file-earmark me-1"></i>.pem File
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaQrTabDec" type="button">
<i class="bi bi-qr-code me-1"></i>QR Code
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="rsaFileTabDec" role="tabpanel">
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
</div>
<div class="tab-pane fade" id="rsaQrTabDec" role="tabpanel">
<input type="file" name="rsa_key_qr" class="form-control form-control-sm" id="rsaKeyQrInput" accept="image/*">
<div class="form-text small">PNG, JPG, or other image of QR code</div>
</div>
<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>
{% 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) -->
<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>
<div class="form-text">
If RSA key was used during encoding (file or QR image)
</div>
</div>
</div>
<!-- Custom Channel Key Input (shown when Custom selected) -->
<div class="mb-4 d-none" id="channelCustomInputDec">
<div class="security-box">
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
<div class="input-group">
<input type="text" name="channel_key_custom" class="form-control font-monospace"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
id="channelKeyInputDec">
</div>
</div>
</div>
<!-- RSA Key Password (shown when key selected) -->
<div class="mb-3 d-none" id="rsaPasswordGroup">
<label class="form-label">
<i class="bi bi-key me-1"></i> RSA Key Password
</label>
<input type="password" name="rsa_password" class="form-control"
placeholder="Password for the .pem file (if encrypted)">
<div class="form-text">
Leave blank if your key file is not password-protected
<!-- ================================================================
ADVANCED OPTIONS (v3.0) - Extraction Mode
================================================================ -->
<div class="mb-4">
<a class="btn btn-sm btn-outline-secondary w-100" data-bs-toggle="collapse" href="#advancedOptionsDec" role="button" aria-expanded="false">
<i class="bi bi-gear me-1"></i> Advanced Options
<i class="bi bi-chevron-down ms-1" id="advancedChevronDec"></i>
</a>
<div class="collapse" id="advancedOptionsDec">
<div class="card card-body mt-2 bg-dark border-secondary">
<!-- Extraction Mode Selection -->
<div class="mb-0">
<label class="form-label">
<i class="bi bi-cpu me-1"></i> Extraction Mode
<span class="badge bg-info ms-1">v3.0</span>
</label>
<div class="d-flex gap-2">
<!-- Auto Mode -->
<label class="mode-btn flex-fill active" id="autoModeCard" for="modeAuto">
<input class="form-check-input" type="radio" name="embed_mode" id="modeAuto" value="auto" checked>
<i class="bi bi-magic text-success"></i>
<span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· Try both</span></span>
</label>
<!-- LSB Mode -->
<label class="mode-btn flex-fill" id="lsbModeCardDec" for="modeLsbDec">
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsbDec" value="lsb">
<i class="bi bi-grid-3x3-gap text-primary"></i>
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Spatial</span></span>
</label>
<!-- DCT Mode -->
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %}" id="dctModeCardDec" for="modeDctDec">
<input class="form-check-input" type="radio" name="embed_mode" id="modeDctDec" value="dct" {% if not has_dct %}disabled{% endif %}>
<i class="bi bi-soundwave text-warning"></i>
<span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Frequency</span></span>
</label>
</div>
<div class="form-text mt-2">
<i class="bi bi-lightbulb me-1"></i>
<strong>Auto</strong> tries LSB first, then DCT.
{% if not has_dct %}
<span class="text-warning ms-2"><i class="bi bi-exclamation-triangle me-1"></i>DCT requires scipy</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
@@ -187,24 +453,36 @@
<h6 class="text-muted mb-3"><i class="bi bi-question-circle me-2"></i>Troubleshooting</h6>
<ul class="list-unstyled text-muted small mb-0">
<li class="mb-2">
<i class="bi bi-dot"></i>
Make sure you're using the <strong>exact same reference photo</strong> file
<i class="bi bi-check-circle-fill text-success me-1"></i>
Use the <strong>exact same reference photo</strong> file (byte-for-byte identical)
</li>
<li class="mb-2">
<i class="bi bi-dot"></i>
Use the phrase for the <strong>day the message was encoded</strong>, not today
<i class="bi bi-check-circle-fill text-success me-1"></i>
Enter the <strong>exact passphrase</strong> used during encoding (case-sensitive, spacing matters)
</li>
<li class="mb-2">
<i class="bi bi-dot"></i>
<i class="bi bi-check-circle-fill text-success me-1"></i>
Provide the <strong>same security factors</strong> (PIN and/or RSA key) used during encoding
</li>
<li class="mb-2">
<i class="bi bi-dot"></i>
Ensure the stego image hasn't been <strong>resized or recompressed</strong>
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>
Ensure the stego image hasn't been <strong>resized, cropped, or recompressed</strong>
</li>
<li class="mb-2">
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>
<strong>Format compatibility:</strong> v4.0 cannot decode messages from v3.1 or earlier (different format)
</li>
<li class="mb-2">
<i class="bi bi-broadcast text-info me-1"></i>
<strong>Channel key:</strong> Use the same channel (Auto/Public/Custom) that was used during encoding
</li>
<li class="mb-2">
<i class="bi bi-info-circle-fill text-info me-1"></i>
If using an RSA key, verify the <strong>password is correct</strong> (if key is encrypted)
</li>
<li class="mb-0">
<i class="bi bi-dot"></i>
If using an RSA key, make sure the <strong>password is correct</strong>
<i class="bi bi-info-circle-fill text-info me-1"></i>
If auto-detection fails, try specifying <strong>LSB or DCT mode</strong> in Advanced Options
</li>
</ul>
</div>
@@ -215,158 +493,30 @@
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
<script>
// Form submit loading state
document.getElementById('decodeForm')?.addEventListener('submit', function() {
const btn = document.getElementById('decodeBtn');
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Decoding...';
btn.disabled = true;
// Extraction mode button active state toggle
const extractModeRadios = document.querySelectorAll('input[name="embed_mode"]');
const extractModeBtns = {
'auto': document.getElementById('autoModeCard'),
'lsb': document.getElementById('lsbModeCardDec'),
'dct': document.getElementById('dctModeCardDec')
};
extractModeRadios.forEach(radio => {
radio.addEventListener('change', () => {
Object.values(extractModeBtns).forEach(btn => btn?.classList.remove('active'));
extractModeBtns[radio.value]?.classList.add('active');
});
});
// Show RSA password field when key is selected (only for .pem files, not QR)
const rsaKeyInput = document.getElementById('rsaKeyInput');
const rsaKeyQrInput = document.getElementById('rsaKeyQrInput');
const rsaPasswordGroup = document.getElementById('rsaPasswordGroup');
if (rsaKeyInput) {
rsaKeyInput.addEventListener('change', function() {
// Show password field only for .pem files
rsaPasswordGroup.classList.toggle('d-none', !this.files.length);
// Clear QR input if file is selected
if (rsaKeyQrInput && this.files.length) {
rsaKeyQrInput.value = '';
}
});
}
if (rsaKeyQrInput) {
rsaKeyQrInput.addEventListener('change', function() {
// Hide password field for QR codes (they're unencrypted)
rsaPasswordGroup.classList.add('d-none');
// Clear file input if QR is selected
if (rsaKeyInput && this.files.length) {
rsaKeyInput.value = '';
}
});
}
// Day names for date detection
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
// Detect day from filename
function detectDayFromFilename(filename) {
const dateMatch = filename.match(/_(\d{4})[-]?(\d{2})[-]?(\d{2})/);
if (dateMatch) {
const [, year, month, day] = dateMatch;
const date = new Date(year, month - 1, day);
return dayNames[date.getDay()];
}
return null;
}
// Update day phrase label
function updateDayLabel(dayName) {
const label = document.getElementById('dayPhraseLabel');
if (label && dayName) {
label.innerHTML = `<i class="bi bi-chat-quote me-1"></i> ${dayName}'s Phrase`;
}
}
// PIN Toggle
document.getElementById('togglePin')?.addEventListener('click', function() {
const input = document.getElementById('pinInput');
const icon = this.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');
}
// Advanced options chevron
const advancedOptionsDec = document.getElementById('advancedOptionsDec');
advancedOptionsDec?.addEventListener('show.bs.collapse', () => {
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-down', 'bi-chevron-up');
});
// Paste from Clipboard
document.addEventListener('paste', function(e) {
if (!document.getElementById('decodeForm')) return;
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") !== -1) {
const blob = items[i].getAsFile();
const stegoInput = document.querySelector('input[name="stego_image"]');
const refInput = document.querySelector('input[name="reference_photo"]');
const targetInput = (!stegoInput.files.length) ? stegoInput : refInput;
const container = new DataTransfer();
container.items.add(blob);
targetInput.files = container.files;
targetInput.dispatchEvent(new Event('change'));
break;
}
}
});
// Drag & drop with preview
document.querySelectorAll('.drop-zone').forEach(zone => {
const input = zone.querySelector('input[type="file"]');
const label = zone.querySelector('.drop-zone-label');
const preview = zone.querySelector('.drop-zone-preview');
const isStegoZone = zone.id === 'stegoDropZone';
['dragenter', 'dragover'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
zone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
zone.classList.remove('drag-over');
});
});
zone.addEventListener('drop', e => {
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
const file = e.dataTransfer.files[0];
showPreview(file);
if (isStegoZone) {
const dayName = detectDayFromFilename(file.name);
updateDayLabel(dayName);
}
}
});
input.addEventListener('change', function() {
if (this.files && this.files[0]) {
const file = this.files[0];
showPreview(file);
if (isStegoZone) {
const dayName = detectDayFromFilename(file.name);
updateDayLabel(dayName);
}
}
});
function showPreview(file) {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
preview.src = e.target.result;
preview.classList.remove('d-none');
label.innerHTML = '<i class="bi bi-check-circle text-success me-1"></i>' + file.name;
};
reader.readAsDataURL(file);
}
advancedOptionsDec?.addEventListener('hide.bs.collapse', () => {
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-up', 'bi-chevron-down');
});
</script>
{% endblock %}

View File

@@ -3,6 +3,114 @@
{% block title %}Encode Message - Stegasoo{% endblock %}
{% block content %}
<style>
/* Glowing passphrase input */
.passphrase-input-container {
position: relative;
}
.passphrase-input {
background: rgba(30, 40, 50, 0.8) !important;
border: 2px solid rgba(99, 179, 237, 0.3) !important;
color: #63b3ed !important;
font-family: 'Courier New', monospace;
font-size: 1.1rem;
letter-spacing: 0.5px;
padding: 12px 16px;
transition: border-color 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
}
.passphrase-input:focus {
border-color: rgba(99, 179, 237, 0.8) !important;
box-shadow: 0 0 20px rgba(99, 179, 237, 0.4), 0 0 40px rgba(99, 179, 237, 0.2) !important;
background: rgba(30, 40, 50, 0.95) !important;
}
.passphrase-input::placeholder {
color: rgba(99, 179, 237, 0.4);
}
/* Glowing PIN input */
.pin-input-container .form-control {
background: rgba(30, 40, 50, 0.8) !important;
border: 2px solid rgba(246, 173, 85, 0.3) !important;
color: #f6ad55 !important;
font-family: 'Courier New', monospace;
font-size: 1.2rem;
letter-spacing: 3px;
text-align: center;
transition: all 0.3s ease;
}
.pin-input-container .form-control:focus {
border-color: rgba(246, 173, 85, 0.8) !important;
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4), 0 0 40px rgba(246, 173, 85, 0.2) !important;
background: rgba(30, 40, 50, 0.95) !important;
}
.pin-input-container .form-control::placeholder {
color: rgba(246, 173, 85, 0.4);
letter-spacing: 1px;
}
/* QR Crop Animation */
.qr-crop-container {
position: relative;
overflow: hidden;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
}
.qr-crop-container img {
display: block;
max-height: 180px;
max-width: 180px;
width: auto;
margin: 0 auto;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.qr-crop-container .qr-original {
opacity: 1;
}
.qr-crop-container .qr-cropped {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.3);
opacity: 0;
max-height: 160px;
min-width: 140px;
min-height: 140px;
object-fit: contain;
}
.qr-crop-container.scan-complete .qr-original {
opacity: 0;
transform: scale(1.1);
filter: blur(4px);
}
.qr-crop-container.scan-complete .qr-cropped {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.qr-crop-container .crop-badge {
position: absolute;
bottom: 4px;
right: 4px;
font-size: 0.65rem;
opacity: 0;
transition: opacity 0.3s ease 0.4s;
}
.qr-crop-container.scan-complete .crop-badge {
opacity: 1;
}
</style>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
@@ -11,20 +119,44 @@
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data" id="encodeForm">
<input type="hidden" name="client_date" id="clientDate" value="">
<!-- Removed client_date hidden field -->
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">
<i class="bi bi-image me-1"></i> Reference Photo
</label>
<div class="drop-zone" id="refDropZone">
<div class="drop-zone scan-container" id="refDropZone">
<input type="file" name="reference_photo" accept="image/*" required>
<div class="drop-zone-label">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop image or click to browse</span>
</div>
<img class="drop-zone-preview d-none" id="refPreview">
<!-- Scan overlay elements -->
<div class="scan-overlay">
<div class="scan-grid"></div>
<div class="scan-line"></div>
</div>
<!-- Corner brackets (shown after scan) -->
<div class="scan-corners">
<div class="scan-corner tl"></div>
<div class="scan-corner tr"></div>
<div class="scan-corner bl"></div>
<div class="scan-corner br"></div>
</div>
<!-- Data panel (shown after scan) -->
<div class="scan-data-panel">
<div class="scan-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span id="refFileName">image.jpg</span>
</div>
<div class="scan-data-row">
<span class="scan-status-badge">Hash Acquired</span>
<span class="scan-data-value" id="refFileSize">--</span>
</div>
<div class="scan-hash-preview" id="refHashPreview">SHA256: ················</div>
</div>
</div>
<div class="form-text">
The secret photo both parties have (NOT transmitted)
@@ -35,13 +167,36 @@
<label class="form-label">
<i class="bi bi-file-image me-1"></i> Carrier Image
</label>
<div class="drop-zone" id="carrierDropZone">
<input type="file" name="carrier" accept="image/*" required>
<div class="drop-zone pixel-container" id="carrierDropZone">
<input type="file" name="carrier" accept="image/*" required id="carrierInput">
<div class="drop-zone-label">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop image or click to browse</span>
</div>
<img class="drop-zone-preview d-none" id="carrierPreview">
<!-- Pixel blocks overlay - populated by JS -->
<div class="pixel-blocks"></div>
<!-- Pixel scan line -->
<div class="pixel-scan-line"></div>
<!-- Corner brackets -->
<div class="pixel-corners">
<div class="pixel-corner tl"></div>
<div class="pixel-corner tr"></div>
<div class="pixel-corner bl"></div>
<div class="pixel-corner br"></div>
</div>
<!-- Data panel -->
<div class="pixel-data-panel">
<div class="pixel-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span id="carrierFileName">image.jpg</span>
</div>
<div class="pixel-data-row">
<span class="pixel-status-badge">Carrier Loaded</span>
<span class="pixel-data-value" id="carrierFileSize">--</span>
</div>
<div class="pixel-dimensions" id="carrierDims">-- × -- px</div>
</div>
</div>
<div class="form-text">
The image to hide your message in (e.g., a meme)
@@ -49,6 +204,48 @@
</div>
</div>
<!-- Capacity Info Panel (shown when carrier loaded) -->
<div class="alert alert-info small d-none" id="capacityPanel">
<div class="row align-items-center">
<div class="col">
<i class="bi bi-rulers me-1"></i>
<strong>Carrier:</strong> <span id="carrierDimensions">-</span>
</div>
<div class="col-auto">
<span class="badge bg-warning text-dark me-1" id="dctCapacityBadge">DCT: -</span>
<span class="badge bg-primary" id="lsbCapacityBadge">LSB: -</span>
</div>
</div>
</div>
<!-- Embedding Mode Selection -->
<div class="mb-4">
<label class="form-label">
<i class="bi bi-cpu me-1"></i> Embedding Mode
</label>
<div class="d-flex gap-2">
<!-- DCT Mode -->
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %} {% if has_dct %}active{% endif %}" id="dctModeCard" for="modeDct">
<input class="form-check-input" type="radio" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
<i class="bi bi-soundwave text-warning ms-2"></i>
<span class="ms-2"><strong>DCT</strong> <span class="text-muted">· Social Media</span></span>
<i class="bi bi-info-circle text-muted mode-info-icon ms-2" data-bs-toggle="tooltip" data-bs-html="true" title="<b>DCT Mode</b><br>• JPEG output<br>• Survives recompression<br>• ~75 KB/MP capacity"></i>
{% if not has_dct %}
<span class="small text-warning ms-2"><i class="bi bi-exclamation-triangle me-1"></i>Requires scipy</span>
{% endif %}
</label>
<!-- LSB Mode -->
<label class="mode-btn flex-fill {% if not has_dct %}active{% endif %}" id="lsbModeCard" for="modeLsb">
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsb" value="lsb" {% if not has_dct %}checked{% endif %}>
<i class="bi bi-grid-3x3-gap text-primary ms-2"></i>
<span class="ms-2"><strong>LSB</strong> <span class="text-muted">· Email & Files</span></span>
<i class="bi bi-info-circle text-muted mode-info-icon ms-2" data-bs-toggle="tooltip" data-bs-html="true" title="<b>LSB Mode</b><br>• Full color PNG output<br>• Higher capacity (~375 KB/MP)"></i>
</label>
</div>
</div>
<!-- Payload Type Selector -->
<div class="mb-3">
<label class="form-label">
@@ -108,14 +305,22 @@
</div>
</div>
<!-- Passphrase input with glow styling -->
<div class="mb-3">
<label class="form-label" id="dayPhraseLabel">
<i class="bi bi-chat-quote me-1"></i> {{ day_of_week }}'s Phrase
<label class="form-label" id="passphraseLabel">
<i class="bi bi-chat-quote me-1"></i> Passphrase
</label>
<input type="text" name="day_phrase" class="form-control"
placeholder="e.g., correct horse battery" required>
<div class="passphrase-input-container">
<input type="text" name="passphrase" class="form-control passphrase-input"
placeholder="e.g., apple forest thunder mountain" required
id="passphraseInput">
</div>
<div class="form-text">
Your phrase for <strong>today</strong> (based on your local timezone)
Your passphrase for this message
</div>
<div class="form-text mt-1" id="passphraseWarning" style="display: none;">
<i class="bi bi-exclamation-triangle text-warning me-1"></i>
Passphrase should have at least {{ recommended_passphrase_words }} words for good security
</div>
</div>
@@ -126,56 +331,182 @@
<span class="text-warning small">(provide at least one: PIN or RSA Key)</span>
</h6>
<div class="mb-3">
<div class="security-box">
<label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
</label>
<!-- RSA Input Method Toggle -->
<div class="btn-group w-100 mb-2" role="group">
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile">
<i class="bi bi-file-earmark me-1"></i>.pem File
</label>
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr">
<i class="bi bi-qr-code me-1"></i>QR Code
</label>
</div>
<!-- .pem File Input -->
<div id="rsaFileSection">
<input type="file" name="rsa_key" class="form-control form-control-sm" accept=".pem">
</div>
<!-- QR Code Input -->
<div id="rsaQrSection" class="d-none">
<div class="drop-zone p-3" id="qrDropZone">
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaQrInput">
<div class="drop-zone-label text-center">
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
<span class="text-muted small">Drop QR image or click to browse</span>
</div>
<!-- Crop animation container -->
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
<img class="qr-original" id="qrOriginal" alt="Original">
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
<!-- Data panel -->
<div class="qr-data-panel">
<div class="qr-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span>RSA Key loaded</span>
</div>
<div class="qr-data-row">
<span class="qr-status-badge">RSA Key</span>
<span class="qr-data-value">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Key Password (always visible) -->
<div class="input-group input-group-sm mt-2">
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
</div>
<!-- PIN + Channel Row -->
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="6-9 digits" maxlength="9">
<button class="btn btn-outline-secondary" type="button" id="togglePin">
<i class="bi bi-eye"></i>
</button>
<div class="security-box h-100">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group pin-input-container">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-text">Static 6-9 digit PIN</div>
</div>
<div class="form-text">Your static 6-9 digit PIN (if configured)</div>
</div>
<div class="col-md-6 mb-3">
<div class="security-box h-100">
<label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
<i class="bi bi-broadcast me-1"></i> Channel
<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>
<ul class="nav nav-tabs nav-tabs-sm mb-2" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaFileTab" type="button">
<i class="bi bi-file-earmark me-1"></i>.pem File
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaQrTab" type="button">
<i class="bi bi-qr-code me-1"></i>QR Code
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="rsaFileTab" role="tabpanel">
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
<div class="form-text small">Shared .pem format key file.</div>
</div>
<div class="tab-pane fade" id="rsaQrTab" role="tabpanel">
<input type="file" name="rsa_key_qr" class="form-control form-control-sm" id="rsaKeyQrInput" accept="image/*">
<div class="form-text small">PNG, JPG, or other image of QR code</div>
</div>
<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>
{% 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) -->
<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>
</div>
</div>
</div>
<!-- Custom Channel Key Input (shown when Custom selected) -->
<div class="mb-4 d-none" id="channelCustomInput">
<div class="security-box">
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
<div class="input-group">
<input type="text" name="channel_key_custom" class="form-control font-monospace"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
id="channelKeyInput">
<button class="btn btn-outline-secondary" type="button" id="channelKeyGenerate" title="Generate random key">
<i class="bi bi-shuffle"></i>
</button>
</div>
<div class="invalid-feedback" id="channelKeyError">
Invalid format. Use: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
</div>
</div>
</div>
<!-- RSA Key Password (shown when key selected) -->
<div class="mb-3 d-none" id="rsaPasswordGroup">
<label class="form-label">
<i class="bi bi-key me-1"></i> RSA Key Password
</label>
<input type="password" name="rsa_password" class="form-control"
placeholder="Password for the .pem file (if encrypted)">
<div class="form-text">
Leave blank if your key file is not password-protected (not needed for QR codes)
<!-- Advanced Options (DCT sub-options only) -->
<div class="mb-4 {% if not has_dct %}d-none{% endif %}" id="advancedOptionsContainer">
<a class="btn btn-sm btn-outline-secondary w-100" data-bs-toggle="collapse" href="#advancedOptions" role="button" aria-expanded="false">
<i class="bi bi-gear me-1"></i> DCT Options
<i class="bi bi-chevron-down ms-1" id="advancedChevron"></i>
</a>
<div class="collapse" id="advancedOptions">
<div class="card card-body mt-2 bg-dark border-secondary py-3">
<!-- DCT Color Mode - Compact -->
<div class="mb-3">
<label class="form-label small mb-2">
<i class="bi bi-palette me-1"></i> Color
</label>
<div class="d-flex gap-2">
<label class="mode-btn equal-width active" id="dctColorCard" for="dctColorColor">
<input class="form-check-input" type="radio" name="dct_color_mode" id="dctColorColor" value="color" checked>
<i class="bi bi-palette-fill text-success"></i>
<span class="ms-2"><strong>Color</strong> <span class="badge bg-success ms-1">Default</span></span>
</label>
<label class="mode-btn equal-width" id="dctGrayscaleCard" for="dctColorGrayscale">
<input class="form-check-input" type="radio" name="dct_color_mode" id="dctColorGrayscale" value="grayscale">
<i class="bi bi-circle-half text-secondary"></i>
<span class="ms-2"><strong>Grayscale</strong></span>
</label>
</div>
</div>
<!-- DCT Output Format - Compact -->
<div class="mb-0">
<label class="form-label small mb-2">
<i class="bi bi-file-image me-1"></i> Format
</label>
<div class="d-flex gap-2">
<label class="mode-btn equal-width active" id="dctJpegCard" for="dctFormatJpeg">
<input class="form-check-input" type="radio" name="dct_output_format" id="dctFormatJpeg" value="jpeg" checked>
<i class="bi bi-file-earmark-richtext text-warning"></i>
<span class="ms-2"><strong>JPEG</strong> <span class="badge bg-warning text-dark ms-1">Default</span></span>
</label>
<label class="mode-btn equal-width" id="dctPngCard" for="dctFormatPng">
<input class="form-check-input" type="radio" name="dct_output_format" id="dctFormatPng" value="png">
<i class="bi bi-file-earmark-image text-primary"></i>
<span class="ms-2"><strong>PNG</strong> <span class="text-muted d-none d-sm-inline">· Lossless</span></span>
</label>
</div>
</div>
</div>
</div>
</div>
@@ -204,8 +535,8 @@
<div class="alert alert-secondary mt-4 small">
<i class="bi bi-info-circle me-1"></i>
<strong>Limits:</strong>
Carrier image max ~4 megapixels (2000×2000).
Files max 10MB upload.
Carrier image max ~24 megapixels (6000x4000).
Files max 30MB upload.
Payload max {{ max_payload_kb }} KB.
</div>
</div>
@@ -215,28 +546,12 @@
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
<script>
// Detect client's local date and day
const now = new Date();
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const localDay = dayNames[now.getDay()];
const localDate = now.getFullYear() + '-' +
String(now.getMonth() + 1).padStart(2, '0') + '-' +
String(now.getDate()).padStart(2, '0');
// ============================================================================
// ENCODE PAGE - Payload type switching
// ============================================================================
// Update day label to client's local day
const dayLabel = document.getElementById('dayPhraseLabel');
if (dayLabel) {
dayLabel.innerHTML = `<i class="bi bi-chat-quote me-1"></i> ${localDay}'s Phrase`;
}
// Set hidden field with client's local date for server
const dateInput = document.getElementById('clientDate');
if (dateInput) {
dateInput.value = localDate;
}
// Payload type switching
const payloadTextRadio = document.getElementById('payloadText');
const payloadFileRadio = document.getElementById('payloadFile');
const textSection = document.getElementById('textPayloadSection');
@@ -249,203 +564,197 @@ function updatePayloadSection() {
textSection.classList.toggle('d-none', !isText);
fileSection.classList.toggle('d-none', isText);
// Update required attribute
if (isText) {
messageInput.required = true;
payloadFileInput.required = false;
messageInput.setAttribute('required', '');
payloadFileInput.removeAttribute('required');
} else {
messageInput.required = false;
payloadFileInput.required = true;
messageInput.removeAttribute('required');
payloadFileInput.setAttribute('required', '');
}
}
payloadTextRadio.addEventListener('change', updatePayloadSection);
payloadFileRadio.addEventListener('change', updatePayloadSection);
payloadTextRadio?.addEventListener('change', updatePayloadSection);
payloadFileRadio?.addEventListener('change', updatePayloadSection);
// File payload info display
const fileInfo = document.getElementById('fileInfo');
const fileInfoName = document.getElementById('fileInfoName');
const fileInfoSize = document.getElementById('fileInfoSize');
const payloadDropLabel = document.getElementById('payloadDropLabel');
// ============================================================================
// ENCODE PAGE - Passphrase validation
// ============================================================================
payloadFileInput.addEventListener('change', function() {
const passphraseInput = document.getElementById('passphraseInput');
const passphraseWarning = document.getElementById('passphraseWarning');
passphraseInput?.addEventListener('input', function() {
const words = this.value.trim().split(/\s+/).filter(w => w.length > 0);
const recommendedWords = {{ recommended_passphrase_words }};
if (passphraseWarning) {
passphraseWarning.style.display = (words.length > 0 && words.length < recommendedWords) ? 'block' : 'none';
}
});
// ============================================================================
// ENCODE PAGE - Payload file info
// ============================================================================
payloadFileInput?.addEventListener('change', function() {
const fileInfo = document.getElementById('fileInfo');
const fileInfoName = document.getElementById('fileInfoName');
const fileInfoSize = document.getElementById('fileInfoSize');
if (this.files && this.files[0]) {
const file = this.files[0];
fileInfoName.textContent = file.name;
fileInfoSize.textContent = formatFileSize(file.size);
fileInfo.classList.remove('d-none');
payloadDropLabel.innerHTML = `<i class="bi bi-check-circle text-success fs-3 d-block mb-2"></i><span>${file.name}</span>`;
} else {
fileInfo.classList.add('d-none');
payloadDropLabel.innerHTML = `<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i><span class="text-muted">Drop any file or click to browse</span><div class="small text-muted mt-1">Max {{ max_payload_kb }} KB</div>`;
}
});
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// Show RSA password field when key is selected (only for .pem files, not QR)
const rsaKeyInput = document.getElementById('rsaKeyInput');
const rsaKeyQrInput = document.getElementById('rsaKeyQrInput');
const rsaPasswordGroup = document.getElementById('rsaPasswordGroup');
if (rsaKeyInput) {
rsaKeyInput.addEventListener('change', function() {
// Show password field only for .pem files
rsaPasswordGroup.classList.toggle('d-none', !this.files.length);
// Clear QR input if file is selected
if (rsaKeyQrInput && this.files.length) {
rsaKeyQrInput.value = '';
}
});
}
if (rsaKeyQrInput) {
rsaKeyQrInput.addEventListener('change', function() {
// Hide password field for QR codes (they're unencrypted)
rsaPasswordGroup.classList.add('d-none');
// Clear file input if QR is selected
if (rsaKeyInput && this.files.length) {
rsaKeyInput.value = '';
}
});
}
// Form submit loading state
document.getElementById('encodeForm').addEventListener('submit', function(e) {
const btn = document.getElementById('encodeBtn');
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Encoding...';
btn.disabled = true;
});
// Character counter for text
const charCount = document.getElementById('charCount');
const charWarning = document.getElementById('charWarning');
const charPercent = document.getElementById('charPercent');
const maxChars = 250000;
messageInput.addEventListener('input', function() {
const len = this.value.length;
charCount.textContent = len.toLocaleString();
const pct = Math.round((len / maxChars) * 100);
charPercent.textContent = pct + '%';
charWarning.classList.toggle('d-none', len < maxChars * 0.8);
charCount.classList.toggle('text-danger', len > maxChars * 0.95);
});
// Drag & drop with preview for images
document.querySelectorAll('.drop-zone').forEach(zone => {
const input = zone.querySelector('input[type="file"]');
const label = zone.querySelector('.drop-zone-label');
const preview = zone.querySelector('.drop-zone-preview');
const isPayloadZone = zone.id === 'payloadDropZone';
['dragenter', 'dragover'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
zone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
zone.classList.remove('drag-over');
});
});
zone.addEventListener('drop', e => {
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
input.dispatchEvent(new Event('change'));
if (!isPayloadZone) {
showPreview(e.dataTransfer.files[0]);
}
}
});
if (!isPayloadZone) {
input.addEventListener('change', function() {
if (this.files && this.files[0]) {
showPreview(this.files[0]);
}
});
}
function showPreview(file) {
if (!file.type.startsWith('image/')) return;
fileInfo?.classList.remove('d-none');
if (fileInfoName) fileInfoName.textContent = file.name;
if (fileInfoSize) fileInfoSize.textContent = (file.size / 1024).toFixed(1) + ' KB';
const reader = new FileReader();
reader.onload = e => {
if (preview) {
preview.src = e.target.result;
preview.classList.remove('d-none');
}
label.innerHTML = '<i class="bi bi-check-circle text-success me-1"></i>' + file.name;
};
reader.readAsDataURL(file);
}
});
// PIN Toggle Logic
document.getElementById('togglePin').addEventListener('click', function() {
const input = document.getElementById('pinInput');
const icon = this.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('bi-eye', 'bi-eye-slash');
const label = document.getElementById('payloadDropLabel');
if (label) label.innerHTML = `<i class="bi bi-check-circle text-success me-1"></i>${file.name}`;
} else {
input.type = 'password';
icon.classList.replace('bi-eye-slash', 'bi-eye');
fileInfo?.classList.add('d-none');
}
});
// Prevent Same File Selection
// ============================================================================
// ENCODE PAGE - Character counter
// ============================================================================
messageInput?.addEventListener('input', function() {
const count = this.value.length;
const max = 250000;
const percent = Math.round((count / max) * 100);
const charCount = document.getElementById('charCount');
const charPercent = document.getElementById('charPercent');
const charWarning = document.getElementById('charWarning');
if (charCount) charCount.textContent = count.toLocaleString();
if (charPercent) charPercent.textContent = percent + '%';
charWarning?.classList.toggle('d-none', percent < 80);
});
// ============================================================================
// ENCODE PAGE - Carrier capacity
// ============================================================================
const capacityPanel = document.getElementById('capacityPanel');
const carrierInput = document.getElementById('carrierInput');
carrierInput?.addEventListener('change', function() {
if (this.files && this.files[0]) {
fetchCapacityComparison(this.files[0]);
}
});
function fetchCapacityComparison(file) {
const formData = new FormData();
formData.append('carrier', file);
fetch('/api/compare-capacity', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.error) return;
const dims = document.getElementById('carrierDimensions');
const lsbBadge = document.getElementById('lsbCapacityBadge');
const dctBadge = document.getElementById('dctCapacityBadge');
if (dims) dims.textContent = `${data.width} × ${data.height} (${(data.width * data.height / 1000000).toFixed(1)} MP)`;
if (lsbBadge) lsbBadge.textContent = `LSB: ${data.lsb.capacity_kb} KB`;
if (dctBadge) dctBadge.textContent = `DCT: ${data.dct.capacity_kb} KB`;
capacityPanel?.classList.remove('d-none');
})
.catch(err => console.error('Capacity fetch failed:', err));
}
// ============================================================================
// ENCODE PAGE - Mode switching (LSB/DCT)
// ============================================================================
// Initialize tooltips for mode info icons
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
new bootstrap.Tooltip(el);
});
// Mode button active state toggle
const modeRadios = document.querySelectorAll('input[name="embed_mode"]');
const modeBtns = { 'dct': document.getElementById('dctModeCard'), 'lsb': document.getElementById('lsbModeCard') };
modeRadios.forEach(radio => {
radio.addEventListener('change', () => {
Object.values(modeBtns).forEach(btn => btn?.classList.remove('active'));
modeBtns[radio.value]?.classList.add('active');
});
});
// Show/hide DCT options
const modeDct = document.getElementById('modeDct');
const advancedOptionsContainer = document.getElementById('advancedOptionsContainer');
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
radio.addEventListener('change', () => {
advancedOptionsContainer?.classList.toggle('d-none', !modeDct?.checked);
});
});
// DCT color mode button active state toggle
const colorModeRadios = document.querySelectorAll('input[name="dct_color_mode"]');
const colorModeBtns = { 'color': document.getElementById('dctColorCard'), 'grayscale': document.getElementById('dctGrayscaleCard') };
colorModeRadios.forEach(radio => {
radio.addEventListener('change', () => {
Object.values(colorModeBtns).forEach(btn => btn?.classList.remove('active'));
colorModeBtns[radio.value]?.classList.add('active');
});
});
// DCT format button active state toggle
const formatRadios = document.querySelectorAll('input[name="dct_output_format"]');
const formatBtns = { 'png': document.getElementById('dctPngCard'), 'jpeg': document.getElementById('dctJpegCard') };
formatRadios.forEach(radio => {
radio.addEventListener('change', () => {
Object.values(formatBtns).forEach(btn => btn?.classList.remove('active'));
formatBtns[radio.value]?.classList.add('active');
});
});
// Advanced options chevron
const advancedOptionsEl = document.getElementById('advancedOptions');
advancedOptionsEl?.addEventListener('show.bs.collapse', () => {
document.getElementById('advancedChevron')?.classList.replace('bi-chevron-down', 'bi-chevron-up');
});
advancedOptionsEl?.addEventListener('hide.bs.collapse', () => {
document.getElementById('advancedChevron')?.classList.replace('bi-chevron-up', 'bi-chevron-down');
});
// ============================================================================
// ENCODE PAGE - Duplicate file check
// ============================================================================
function checkDuplicateFiles() {
const refInput = document.querySelector('input[name="reference_photo"]');
const carInput = document.querySelector('input[name="carrier"]');
if (refInput.files[0] && carInput.files[0]) {
if (refInput?.files[0] && carInput?.files[0]) {
if (refInput.files[0].name === carInput.files[0].name &&
refInput.files[0].size === carInput.files[0].size) {
alert("Security Warning: You cannot use the same image for both Reference and Carrier!");
carInput.value = '';
document.getElementById('carrierPreview').classList.add('d-none');
document.querySelector('#carrierDropZone .drop-zone-label').innerHTML =
'<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>' +
'<span class="text-muted">Drop image or click to browse</span>';
document.getElementById('carrierPreview')?.classList.add('d-none');
const label = document.querySelector('#carrierDropZone .drop-zone-label');
if (label) {
label.innerHTML = '<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i><span class="text-muted">Drop image or click to browse</span>';
}
capacityPanel?.classList.add('d-none');
}
}
}
document.querySelector('input[name="reference_photo"]').addEventListener('change', checkDuplicateFiles);
document.querySelector('input[name="carrier"]').addEventListener('change', checkDuplicateFiles);
// Paste from Clipboard
document.addEventListener('paste', function(e) {
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") !== -1) {
const blob = items[i].getAsFile();
const carrierInput = document.querySelector('input[name="carrier"]');
const refInput = document.querySelector('input[name="reference_photo"]');
const targetInput = (!carrierInput.files.length) ? carrierInput : refInput;
const container = new DataTransfer();
container.items.add(blob);
targetInput.files = container.files;
targetInput.dispatchEvent(new Event('change'));
break;
}
}
});
document.querySelector('input[name="reference_photo"]')?.addEventListener('change', checkDuplicateFiles);
document.querySelector('input[name="carrier"]')?.addEventListener('change', checkDuplicateFiles);
</script>
{% endblock %}

View File

@@ -7,7 +7,9 @@
<div class="col-lg-6">
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="bi bi-check-circle me-2"></i>Encoding Successful!</h5>
<h5 class="mb-0">
<i class="bi bi-check-circle me-2"></i>Encoding Successful!
</h5>
</div>
<div class="card-body text-center">
<div class="my-4">
@@ -34,6 +36,81 @@
<code class="fs-5">{{ filename }}</code>
</div>
<!-- Mode and format badges -->
<div class="mb-4">
{% if embed_mode == 'dct' %}
<span class="badge bg-info fs-6">
<i class="bi bi-soundwave me-1"></i>DCT Mode
</span>
<!-- Color mode badge (v3.0.1) -->
{% if color_mode == 'color' %}
<span class="badge bg-success fs-6 ms-1">
<i class="bi bi-palette-fill me-1"></i>Color
</span>
{% else %}
<span class="badge bg-secondary fs-6 ms-1">
<i class="bi bi-circle-half me-1"></i>Grayscale
</span>
{% endif %}
<!-- Output format badge -->
{% if output_format == 'jpeg' %}
<span class="badge bg-warning text-dark fs-6 ms-1">
<i class="bi bi-file-earmark-richtext me-1"></i>JPEG
</span>
<div class="small text-muted mt-2">
{% if color_mode == 'color' %}
Color JPEG, frequency domain embedding (Q=95)
{% else %}
Grayscale JPEG, frequency domain embedding (Q=95)
{% endif %}
</div>
{% else %}
<span class="badge bg-primary fs-6 ms-1">
<i class="bi bi-file-earmark-image me-1"></i>PNG
</span>
<div class="small text-muted mt-2">
{% if color_mode == 'color' %}
Color PNG, frequency domain embedding (lossless)
{% else %}
Grayscale PNG, frequency domain embedding (lossless)
{% endif %}
</div>
{% endif %}
{% else %}
<span class="badge bg-primary fs-6">
<i class="bi bi-grid-3x3-gap me-1"></i>LSB Mode
</span>
<span class="badge bg-success fs-6 ms-1">
<i class="bi bi-palette-fill me-1"></i>Full Color
</span>
<span class="badge bg-primary fs-6 ms-1">
<i class="bi bi-file-earmark-image me-1"></i>PNG
</span>
<div class="small text-muted mt-2">Full color PNG, spatial LSB embedding</div>
{% endif %}
<!-- Channel info (v4.0.0) -->
<div class="mt-3">
{% if channel_mode == 'private' %}
<span class="badge bg-warning text-dark fs-6">
<i class="bi bi-shield-lock me-1"></i>Private Channel
</span>
{% if channel_fingerprint %}
<div class="small text-muted mt-1">
<code>{{ channel_fingerprint }}</code>
</div>
{% endif %}
{% else %}
<span class="badge bg-info fs-6">
<i class="bi bi-globe me-1"></i>Public Channel
</span>
{% endif %}
</div>
</div>
<div class="d-grid gap-2">
<a href="{{ url_for('encode_download', file_id=file_id) }}"
class="btn btn-primary btn-lg" id="downloadBtn">
@@ -53,7 +130,20 @@
<ul class="mb-0 mt-2">
<li>This file expires in <strong>5 minutes</strong></li>
<li>Do <strong>not</strong> resize or recompress the image</li>
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
<li>JPEG format is lossy - avoid re-saving or editing</li>
{% else %}
<li>PNG format preserves your hidden data</li>
{% endif %}
{% if embed_mode == 'dct' %}
<li>Recipient needs <strong>DCT mode</strong> or <strong>Auto</strong> detection to decode</li>
{% if color_mode == 'color' %}
<li>Color preserved - extraction works on both color and grayscale</li>
{% endif %}
{% endif %}
{% if channel_mode == 'private' %}
<li><i class="bi bi-shield-lock text-warning me-1"></i>Recipient needs the <strong>same channel key</strong> to decode</li>
{% endif %}
</ul>
</div>
@@ -72,13 +162,14 @@
const shareBtn = document.getElementById('shareBtn');
const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}";
const fileName = "{{ filename }}";
const mimeType = "{{ 'image/jpeg' if embed_mode == 'dct' and output_format == 'jpeg' else 'image/png' }}";
if (navigator.share && navigator.canShare) {
// Check if we can share files
fetch(fileUrl)
.then(response => response.blob())
.then(blob => {
const file = new File([blob], fileName, { type: 'image/png' });
const file = new File([blob], fileName, { type: mimeType });
if (navigator.canShare({ files: [file] })) {
shareBtn.style.display = 'block';
shareBtn.addEventListener('click', async () => {
@@ -106,4 +197,4 @@ document.getElementById('downloadBtn').addEventListener('click', function() {
}, 2000);
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -3,7 +3,7 @@
{% block title %}Generate Credentials - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="row justify-content-center" data-page="generate">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
@@ -14,14 +14,18 @@
<!-- Generation Form -->
<form method="POST">
<div class="mb-4">
<label class="form-label">Words per Phrase</label>
<input type="range" class="form-range" name="words_per_phrase"
min="3" max="12" value="3" id="wordsRange">
<label class="form-label">Words per Passphrase</label>
<input type="range" class="form-range" name="words_per_passphrase"
min="{{ min_passphrase_words }}" max="12" value="{{ default_passphrase_words }}" id="wordsRange">
<div class="d-flex justify-content-between small text-muted">
<span>3 (33 bits)</span>
<span id="wordsValue" class="text-primary fw-bold">3 words (~33 bits)</span>
<span>{{ min_passphrase_words }} (~33 bits)</span>
<span id="wordsValue" class="text-primary fw-bold">{{ default_passphrase_words }} words (~44 bits)</span>
<span>12 (132 bits)</span>
</div>
<div class="form-text">
<i class="bi bi-shield-check me-1"></i>
Recommended: <strong>{{ recommended_passphrase_words }}+ words</strong> for good security
</div>
</div>
<hr>
@@ -58,19 +62,59 @@
</div>
<div class="mt-2 d-none" id="rsaOptions">
<label class="form-label small">Key Size</label>
<select name="rsa_bits" class="form-select form-select-sm">
<select name="rsa_bits" class="form-select form-select-sm" id="rsaBitsSelect">
<option value="2048" selected>2048 bits (~128 bits entropy)</option>
<option value="3072">3072 bits (~128 bits entropy)</option>
<option value="4096">4096 bits (~128 bits entropy)</option>
</select>
<div class="form-text text-warning d-none" id="rsaQrWarning">
<i class="bi bi-exclamation-triangle me-1"></i>QR code unavailable for keys &gt;3072 bits
</div>
</div>
</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 -->
@@ -106,25 +150,57 @@
{% endif %}
<div class="mb-4">
<h6 class="text-muted"><i class="bi bi-chat-quote me-2"></i>DAILY PHRASES</h6>
<div class="table-responsive">
<table class="table table-dark table-striped mb-0">
<tbody>
{% for day in days %}
<tr>
<td class="text-muted" style="width: 100px;">{{ day }}</td>
<td>
<span class="font-monospace phrase-display" id="phrase{{ loop.index }}">{{ phrases[day] }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h6 class="text-muted">
<i class="bi bi-chat-quote me-2"></i>PASSPHRASE
</h6>
<div class="passphrase-container">
<div class="passphrase-display" id="passphraseDisplay">
<code class="passphrase-text">{{ passphrase }}</code>
</div>
<div class="passphrase-buttons mt-3">
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="togglePassphraseVisibility()">
<i class="bi bi-eye-slash" id="passphraseToggleIcon"></i>
<span id="passphraseToggleText">Hide</span>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="copyPassphrase()">
<i class="bi bi-clipboard" id="passphraseCopyIcon"></i>
<span id="passphraseCopyText">Copy</span>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="toggleMemoryAid()">
<i class="bi bi-lightbulb" id="memoryAidIcon"></i>
<span id="memoryAidText">Memory Aid</span>
</button>
</div>
</div>
<div class="text-end mt-2">
<button class="btn btn-sm btn-outline-secondary" onclick="toggleAllPhrases()">
<i class="bi bi-eye-slash me-1"></i>Toggle Visibility
</button>
<!-- Memory Aid Story -->
<div class="memory-aid-container mt-3 d-none" id="memoryAidContainer">
<div class="card bg-dark border-primary">
<div class="card-header bg-primary text-white">
<i class="bi bi-book me-2"></i>Memory Story
</div>
<div class="card-body">
<p class="memory-story mb-3" id="memoryStory">
<!-- Story will be generated by JavaScript -->
</p>
<div class="form-text">
<i class="bi bi-info-circle me-1"></i>
This story is generated from your passphrase to help you remember it.
The words appear in order within the narrative.
</div>
<button type="button" class="btn btn-sm btn-outline-light mt-2" onclick="regenerateStory()">
<i class="bi bi-arrow-repeat me-1"></i>Generate Different Story
</button>
</div>
</div>
</div>
<div class="alert alert-info mt-3 mb-0">
<small class="text-muted">
({{ words_per_passphrase }} words = ~{{ passphrase_entropy }} bits entropy)
</small>
</div>
</div>
@@ -229,8 +305,9 @@
<div class="row text-center">
<div class="col">
<div class="p-2 bg-dark rounded">
<div class="small text-muted">Phrase</div>
<div class="fs-5 text-info">{{ phrase_entropy }} bits</div>
<div class="small text-muted">Passphrase</div>
<div class="fs-5 text-info">{{ passphrase_entropy }} bits</div>
<div class="small text-muted">{{ words_per_passphrase }} words</div>
</div>
</div>
{% if pin_entropy %}
@@ -279,7 +356,7 @@
<h6 class="text-muted mb-3"><i class="bi bi-info-circle me-2"></i>About Credentials</h6>
<ul class="small text-muted mb-0">
<li class="mb-2">
<strong>Daily phrases</strong> rotate each day of the week for forward secrecy
<strong>Passphrase</strong> is a single phrase you use each time
</li>
<li class="mb-2">
<strong>PIN</strong> is static and adds another factor both parties must know
@@ -343,9 +420,69 @@
min-width: 80px;
}
/* Passphrase Container */
.passphrase-container {
background: linear-gradient(145deg, #1e1e2e 0%, #2d2d44 100%);
border: 1px solid #0dcaf0;
border-radius: 16px;
padding: 1.5rem 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3), 0 0 40px rgba(13, 202, 240, 0.1);
}
.passphrase-display {
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(13, 202, 240, 0.3);
border-radius: 12px;
padding: 1.5rem;
text-align: center;
transition: filter 0.3s ease;
}
.passphrase-display.blurred {
filter: blur(8px);
user-select: none;
}
.passphrase-text {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 1.5rem;
font-weight: bold;
color: #0dcaf0;
text-shadow: 0 0 10px rgba(13, 202, 240, 0.5);
word-wrap: break-word;
display: block;
line-height: 1.6;
}
.passphrase-buttons {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.passphrase-buttons .btn {
min-width: 100px;
}
/* Memory Aid */
.memory-story {
font-size: 1.1rem;
line-height: 1.8;
color: #e9ecef;
}
.memory-story .passphrase-word {
font-weight: bold;
color: #0dcaf0;
text-decoration: underline;
text-decoration-style: wavy;
text-decoration-color: rgba(13, 202, 240, 0.5);
}
/* Responsive */
@media (max-width: 576px) {
.pin-container {
.pin-container, .passphrase-container {
padding: 1rem 1.25rem;
}
@@ -358,132 +495,49 @@
.pin-digits-row {
gap: 0.35rem;
}
.passphrase-text {
font-size: 1.2rem;
}
.memory-story {
font-size: 1rem;
}
}
</style>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
<script src="{{ url_for('static', filename='js/generate.js') }}"></script>
{% if generated %}
<script>
// Words range slider
const wordsRange = document.getElementById('wordsRange');
const wordsValue = document.getElementById('wordsValue');
// Page-specific data from Jinja
const passphraseWords = '{{ passphrase|default("", true) }}'.split(' ').filter(w => w.length > 0);
if (wordsRange) {
wordsRange.addEventListener('input', function() {
const bits = this.value * 11;
wordsValue.textContent = `${this.value} words (~${bits} bits)`;
});
}
// Toggle PIN/RSA options
const usePinCheck = document.getElementById('usePinCheck');
const useRsaCheck = document.getElementById('useRsaCheck');
const pinOptions = document.getElementById('pinOptions');
const rsaOptions = document.getElementById('rsaOptions');
if (usePinCheck) {
usePinCheck.addEventListener('change', function() {
pinOptions.classList.toggle('d-none', !this.checked);
});
}
if (useRsaCheck) {
useRsaCheck.addEventListener('change', function() {
rsaOptions.classList.toggle('d-none', !this.checked);
});
}
// 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;
if (pinHidden) {
pinDigits.classList.add('blurred');
icon.className = 'bi bi-eye';
text.textContent = 'Show';
} else {
pinDigits.classList.remove('blurred');
icon.className = 'bi bi-eye-slash';
text.textContent = 'Hide';
}
}
// Copy PIN
function copyPin() {
const pin = '{{ pin|default("", true) }}';
const icon = document.getElementById('pinCopyIcon');
const text = document.getElementById('pinCopyText');
navigator.clipboard.writeText(pin).then(() => {
icon.className = 'bi bi-check';
text.textContent = 'Copied!';
setTimeout(() => {
icon.className = 'bi bi-clipboard';
text.textContent = 'Copy';
}, 2000);
});
Stegasoo.copyToClipboard(
'{{ pin|default("", true) }}',
document.getElementById('pinCopyIcon'),
document.getElementById('pinCopyText')
);
}
// Toggle all phrases visibility
let phrasesHidden = false;
function toggleAllPhrases() {
phrasesHidden = !phrasesHidden;
document.querySelectorAll('.phrase-display').forEach(el => {
el.style.filter = phrasesHidden ? 'blur(8px)' : 'none';
});
function copyPassphrase() {
Stegasoo.copyToClipboard(
'{{ passphrase|default("", true) }}',
document.getElementById('passphraseCopyIcon'),
document.getElementById('passphraseCopyText')
);
}
// 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();
function toggleMemoryAid() {
StegasooGenerate.toggleMemoryAid(passphraseWords);
}
function regenerateStory() {
StegasooGenerate.regenerateStory(passphraseWords);
}
</script>
{% endif %}
{% endblock %}

View File

@@ -3,12 +3,38 @@
{% block title %}Stegasoo - Secure Steganography{% endblock %}
{% block content %}
<div class="text-center mb-5">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="120" class="mb-3">
<h1 class="display-4 fw-bold">Stegasoo</h1>
<p class="lead text-muted">Create hidden encrypted messages in images and photos using advanced steganography.</p>
<div class="row mb-4">
<div class="col-12">
<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 title-gold">
Stegasoo
<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>
</div>
</div>
</div>
<!-- Channel Status Banner (v4.0.0) -->
{% if channel_configured %}
<div class="alert alert-success mb-4">
<div class="d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-shield-lock me-2"></i>
<strong>Private Channel Mode</strong>
</div>
<div class="key-capsule">
<span class="badge led-badge-yellow"><span class="led-indicator led-yellow me-1"></span>Key Loaded</span>
<code class="small ms-2">{{ channel_fingerprint }}</code>
</div>
</div>
</div>
{% endif %}
<div class="row g-4 mb-5">
<!-- Encode Card -->
<div class="col-md-4">
@@ -18,9 +44,9 @@
<i class="bi bi-lock-fill fs-1 embossed-icon"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Encode Message</h5>
<h5 class="card-title">Encode</h5>
<p class="card-text text-muted">
Hide your secret message inside an innocent-looking image using your daily phrase + PIN.
Hide encrypted messages or files inside images
</p>
</div>
</div>
@@ -35,9 +61,9 @@
<i class="bi bi-unlock-fill fs-1 embossed-icon"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Decode Message</h5>
<h5 class="card-title">Decode</h5>
<p class="card-text text-muted">
Extract and decrypt hidden messages from Stegasoo-encoded images using your credentials.
Extract and decrypt hidden data from stego images
</p>
</div>
</div>
@@ -52,9 +78,9 @@
<i class="bi bi-key-fill fs-1 embossed-icon"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Generate Keys</h5>
<h5 class="card-title">Generate</h5>
<p class="card-text text-muted">
Create your weekly phrase card and PIN. Memorize 21 words + 6 digits for maximum security.
Create passphrases, PINs, and RSA keys
</p>
</div>
</div>
@@ -62,47 +88,85 @@
</div>
</div>
<div class="card">
<!-- Embedding Modes -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-cpu me-2"></i>Embedding Modes</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-md-6 mb-3 mb-md-0">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-soundwave text-warning fs-2 d-block mb-2"></i>
<strong>DCT Mode</strong>
<span class="badge bg-success ms-1">Default</span>
<div class="small text-muted mt-2">
Survives JPEG recompression<br>
Best for social media
</div>
</div>
</div>
<div class="col-md-6">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-grid-3x3-gap text-primary fs-2 d-block mb-2"></i>
<strong>LSB Mode</strong>
<div class="small text-muted mt-2">
Higher capacity (~375 KB/MP)<br>
Best for email &amp; file transfer
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-diagram-3 me-2"></i>How It Works</h5>
<a href="/about" class="btn btn-sm btn-outline-light">Learn More</a>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary"><i class="bi bi-1-circle me-2"></i>Key Components</h6>
<ul class="list-unstyled">
<li class="mb-2">
<h6 class="text-primary"><i class="bi bi-key me-2"></i>You Provide</h6>
<ul class="list-unstyled small">
<li class="mb-1">
<i class="bi bi-image text-info me-2"></i>
<strong>Reference Photo</strong> — Any photo you and recipient both have
<strong>Reference Photo</strong>: shared secret
</li>
<li class="mb-2">
<li class="mb-1">
<i class="bi bi-chat-quote text-info me-2"></i>
<strong>Day Phrase</strong> — 3 words, different each day of the week
<strong>Passphrase</strong>: 4+ words
</li>
<li class="mb-2">
<li class="mb-1">
<i class="bi bi-123 text-info me-2"></i>
<strong>Static PIN</strong> — 6 digits, same every day
<strong>PIN</strong>: 6-9 digits (or RSA key)
</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-primary"><i class="bi bi-2-circle me-2"></i>Security Features</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-shield-check text-success me-2"></i>
Argon2id memory-hard key derivation (256MB)
</li>
<li class="mb-2">
<i class="bi bi-shuffle text-success me-2"></i>
Pseudo-random pixel selection (defeats steganalysis)
</li>
<li class="mb-2">
<h6 class="text-primary"><i class="bi bi-shield-check me-2"></i>Security</h6>
<ul class="list-unstyled small">
<li class="mb-1">
<i class="bi bi-lock text-success me-2"></i>
AES-256-GCM authenticated encryption
AES-256-GCM encryption
</li>
<li class="mb-1">
<i class="bi bi-memory text-success me-2"></i>
Argon2id key derivation (256MB)
</li>
<li class="mb-1">
<i class="bi bi-shuffle text-success me-2"></i>
Pseudo-random embedding
</li>
<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.1</span>
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}Login - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5 col-lg-4">
<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">Login</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('login') }}">
<div class="mb-3">
<label class="form-label">
<i class="bi bi-person me-1"></i> Username
</label>
<input type="text" name="username" class="form-control"
placeholder="Enter your username" required autofocus>
</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="password" name="password" class="form-control"
id="passwordInput" required>
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('passwordInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">
<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>
</div>
{% endblock %}
{% block scripts %}
<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

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}Setup - 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-gear-fill fs-1 d-block mb-2"></i>
<h5 class="mb-0">Initial Setup</h5>
</div>
<div class="card-body">
<p class="text-muted text-center mb-4">
Welcome to Stegasoo! Create your admin account to get started.
</p>
<form method="POST" action="{{ url_for('setup') }}" id="setupForm">
<div class="mb-3">
<label class="form-label">
<i class="bi bi-person me-1"></i> Username
</label>
<input type="text" name="username" class="form-control"
value="admin" required minlength="3">
</div>
<div class="mb-3">
<label class="form-label">
<i class="bi bi-key me-1"></i> Password
</label>
<div class="input-group">
<input type="password" name="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>
<div class="mb-4">
<label class="form-label">
<i class="bi bi-key-fill me-1"></i> Confirm Password
</label>
<div class="input-group">
<input type="password" name="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>Create Admin Account
</button>
</form>
</div>
</div>
<div class="alert alert-info mt-4 small">
<i class="bi bi-info-circle me-2"></i>
This is a single-user setup. The admin account has full access to all features.
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script>
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

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "stegasoo"
version = "2.0.1"
version = "4.1.1"
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
readme = "README.md"
license = "MIT"
@@ -43,24 +43,47 @@ dependencies = [
]
[project.optional-dependencies]
# DCT steganography support (v3.0+)
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",
]
web = [
"flask>=3.0.0",
"gunicorn>=21.0.0",
"qrcode>=7.3.0",
"pyzbar",
"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",
"uvicorn[standard]>=0.20.0",
"python-multipart>=0.0.6",
"qrcode>=7.30",
"pyzbar>=0.1.9",
# Include DCT support for API
"numpy>=2.0.0",
"scipy>=1.10.0",
"jpegio>=0.2.0",
"reedsolo>=1.7.0",
]
all = [
"stegasoo[cli,web,api]",
"stegasoo[cli,web,api,dct,compression]",
]
dev = [
"stegasoo[all]",
@@ -99,9 +122,18 @@ target-version = ["py310", "py311", "py312"]
[tool.ruff]
line-length = 100
exclude = ["frontends/web/test_routes.py"] # Debug snippet, not a real module
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
# YCbCr colorspace variables (R, G, B, Y, Cb, Cr) are standard names
"src/stegasoo/dct_steganography.py" = ["N803", "N806"]
# Package __init__.py has imports after try/except and aliases - intentional structure
"src/stegasoo/__init__.py" = ["E402"]
[tool.mypy]
python_version = "3.10"
warn_return_any = true

View File

@@ -27,6 +27,11 @@ click>=8.1.0
pytest>=7.4.0
pytest-cov>=4.1.0
scipy>=1.16.3
jpegio>=0.2.8
numpy>=2.4.0
# Optional: Better performance for Pillow
# pillow-simd>=9.0.0 # Uncomment if available for your platform

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,10 +1,53 @@
#!/usr/bin/env python3
"""Main entry point."""
"""
Stegasoo - Main Entry Point
This module provides the main entry point for the stegasoo package.
It can be run directly or via the installed console script.
Usage:
python -m stegasoo --help
python src/main.py --help
stegasoo --help (if installed via pip)
"""
import sys
def main():
"""Main function."""
print("Hello, World!")
"""
Main entry point for Stegasoo CLI.
Delegates to the CLI module for command parsing and execution.
"""
try:
from stegasoo.cli import main as cli_main
cli_main()
except ImportError as e:
# Provide helpful error if dependencies are missing
print(f"Error: Could not import stegasoo package: {e}", file=sys.stderr)
print("\nMake sure stegasoo is installed:", file=sys.stderr)
print(" pip install -e .", file=sys.stderr)
print("\nOr run from the src directory:", file=sys.stderr)
print(" PYTHONPATH=src python -m stegasoo", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def version():
"""Print version and exit."""
try:
from stegasoo import __version__
print(f"stegasoo {__version__}")
except ImportError:
print("stegasoo (version unknown)")
if __name__ == "__main__":

View File

@@ -1,17 +1,24 @@
# Stegasoo Docker Image
# Multi-stage build for smaller image size
FROM python:3.11-slim as base
# Pin the base image digest for reproducibility
# To update: docker manifest inspect python:3.11-slim -v | jq -r '.[0].Descriptor.digest'
FROM python:3.11-slim@sha256:5501a4fe605abe24de87c2f3d6cf9fd760354416a0cad0296cf284fddcdca9e2 as base
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Suppress pip "running as root" warnings during build
ENV PIP_ROOT_USER_ACTION=ignore
# Install system dependencies
# NOTE: libjpeg-dev is required for jpegio compilation
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libc-dev \
libffi-dev \
libzbar0 \
libjpeg-dev \
&& rm -rf /var/lib/apt/lists/*
# ============================================================================
@@ -26,8 +33,10 @@ COPY pyproject.toml README.md ./
COPY src/ src/
COPY data/ data/
# Install the package with web extras
RUN pip install --no-cache-dir ".[web]"
# Install build dependencies for jpegio, then install the package
# jpegio requires Cython and numpy to compile
RUN pip install --no-cache-dir cython numpy && \
pip install --no-cache-dir ".[web]"
# ============================================================================
# Production stage - Web UI
@@ -73,11 +82,14 @@ FROM base as api
WORKDIR /app
# Install API extras
# Install API extras (includes DCT dependencies)
COPY pyproject.toml README.md ./
COPY src/ src/
COPY data/ data/
RUN pip install --no-cache-dir ".[api]"
# Install build dependencies for jpegio, then install the package
RUN pip install --no-cache-dir cython numpy && \
pip install --no-cache-dir ".[api]"
# Copy API files
COPY frontends/api/ frontends/api/
@@ -111,7 +123,10 @@ WORKDIR /app
COPY pyproject.toml README.md ./
COPY src/ src/
COPY data/ data/
RUN pip install --no-cache-dir ".[cli]"
# Install build dependencies for jpegio (if dct extras needed), then install
RUN pip install --no-cache-dir cython numpy && \
pip install --no-cache-dir ".[cli,dct]"
# Copy CLI files
COPY frontends/cli/ frontends/cli/

View File

@@ -1,617 +1,256 @@
"""
Stegasoo - Secure Steganography Library
Stegasoo - Secure Steganography with Multi-Factor Authentication (v4.0.1)
A Python library for hiding encrypted messages and files in images using
hybrid photo + passphrase + PIN authentication.
Basic Usage - Text Message:
from stegasoo import encode, decode, generate_credentials
# Generate credentials
creds = generate_credentials(use_pin=True, use_rsa=False)
print(creds.phrases['Monday'])
print(creds.pin)
# Encode a message
with open('secret.jpg', 'rb') as f:
ref_photo = f.read()
with open('meme.png', 'rb') as f:
carrier = f.read()
result = encode(
message="Meet at midnight",
reference_photo=ref_photo,
carrier_image=carrier,
day_phrase="apple forest thunder",
pin="123456"
)
with open('stego.png', 'wb') as f:
f.write(result.stego_image)
# Decode a message
decoded = decode(
stego_image=result.stego_image,
reference_photo=ref_photo,
day_phrase="apple forest thunder",
pin="123456"
)
print(decoded.message) # "Meet at midnight"
File Embedding:
from stegasoo import encode_file, decode, FilePayload
# Encode a file
result = encode_file(
filepath="secret_document.pdf",
reference_photo=ref_photo,
carrier_image=carrier,
day_phrase="apple forest thunder",
pin="123456"
)
# Decode - automatically detects file vs text
decoded = decode(...)
if decoded.is_file:
with open(decoded.filename, 'wb') as f:
f.write(decoded.file_data)
else:
print(decoded.message)
Debugging:
from stegasoo.debug import debug
debug.enable(True) # Enable debug output
debug.enable_performance(True) # Enable timing
Changes in v4.0.0:
- Added channel key support for deployment/group isolation
- New functions: get_channel_key, get_channel_fingerprint, generate_channel_key, etc.
- encode() and decode() now accept channel_key parameter
"""
from .constants import __version__, DAY_NAMES, MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE
from .models import (
Credentials,
EncodeInput,
EncodeResult,
DecodeInput,
DecodeResult,
EmbedStats,
KeyInfo,
ValidationResult,
FilePayload,
__version__ = "4.0.1"
# Core functionality
# Channel key management (v4.0.0)
from .channel import (
clear_channel_key,
format_channel_key,
generate_channel_key,
get_channel_key,
get_channel_status,
has_channel_key,
set_channel_key,
validate_channel_key,
)
from .exceptions import (
StegasooError,
ValidationError,
PinValidationError,
MessageValidationError,
ImageValidationError,
KeyValidationError,
SecurityFactorError,
CryptoError,
EncryptionError,
DecryptionError,
KeyDerivationError,
KeyGenerationError,
KeyPasswordError,
SteganographyError,
CapacityError,
ExtractionError,
EmbeddingError,
InvalidHeaderError,
)
from .keygen import (
generate_credentials,
generate_pin,
generate_phrase,
generate_day_phrases,
generate_rsa_key,
# Crypto functions
from .crypto import get_active_channel_key, get_channel_fingerprint, has_argon2
from .decode import decode, decode_file, decode_text
from .encode import encode
# Credential generation
from .generate import (
export_rsa_key_pem,
generate_credentials,
generate_passphrase,
generate_pin,
generate_rsa_key,
load_rsa_key,
get_key_info,
)
from .validation import (
validate_pin,
validate_message,
validate_payload,
validate_file_payload,
validate_image,
validate_rsa_key,
validate_security_factors,
validate_phrase,
validate_date_string,
require_valid_pin,
require_valid_message,
require_valid_payload,
require_valid_image,
require_valid_rsa_key,
require_security_factors,
)
from .crypto import (
encrypt_message,
decrypt_message,
decrypt_message_text,
derive_hybrid_key,
derive_pixel_key,
hash_photo,
parse_header,
get_date_from_encrypted,
has_argon2,
)
from .steganography import (
embed_in_image,
extract_from_image,
calculate_capacity,
get_image_dimensions,
get_image_format,
is_lossless_format,
LOSSLESS_FORMATS,
)
from .utils import (
generate_filename,
parse_date_from_filename,
get_day_from_date,
get_today_date,
get_today_day,
secure_delete,
SecureDeleter,
format_file_size,
)
from .debug import debug # Import debug utilities
# QR Code utilities (optional, depends on qrcode and pyzbar)
# Image utilities
from .image_utils import (
compare_capacity,
get_image_info,
)
# Steganography functions
from .steganography import (
compare_modes,
has_dct_support,
will_fit_by_mode,
)
# Utilities
from .utils import generate_filename
# QR Code utilities - optional, may not be available
try:
from .qr_utils import (
generate_qr_code,
read_qr_code,
read_qr_code_from_file,
detect_and_crop_qr,
extract_key_from_qr,
extract_key_from_qr_file,
compress_data,
decompress_data,
auto_decompress,
normalize_pem,
is_compressed,
can_fit_in_qr,
needs_compression,
has_qr_read,
has_qr_write,
has_qr_support,
generate_qr_code,
)
HAS_QR_UTILS = True
except ImportError:
HAS_QR_UTILS = False
generate_qr_code = None
extract_key_from_qr = None
detect_and_crop_qr = None
from datetime import date
from pathlib import Path
from typing import Optional, Union, Dict, Any
# Validation
from .validation import (
validate_file_payload,
validate_image,
validate_message,
validate_passphrase,
validate_pin,
validate_rsa_key,
validate_security_factors,
)
# Validation aliases for public API
validate_reference_photo = validate_image
validate_carrier = validate_image
def encode(
message: Union[str, bytes, FilePayload],
reference_photo: bytes,
carrier_image: bytes,
day_phrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
date_str: Optional[str] = None,
output_format: Optional[str] = None,
) -> EncodeResult:
"""
Encode a secret message or file into an image.
High-level convenience function that handles validation,
encryption, and embedding in one call.
Args:
message: Secret message (str), raw bytes, or FilePayload to hide
reference_photo: Shared reference photo bytes
carrier_image: Image to hide message in
day_phrase: Today's passphrase
pin: Static PIN (optional if using RSA key)
rsa_key_data: RSA private key PEM bytes (optional if using PIN)
rsa_password: Password for RSA key if encrypted
date_str: Date string YYYY-MM-DD (defaults to today)
output_format: Force output format ('PNG', 'BMP'). If None, preserves
carrier format for lossless types, defaults to PNG for lossy.
Returns:
EncodeResult with stego image and metadata
Raises:
ValidationError: If inputs are invalid
SecurityFactorError: If no PIN or RSA key provided
CapacityError: If carrier is too small
EncryptionError: If encryption fails
Note:
Output format is always lossless (PNG or BMP) to preserve hidden data.
If carrier is JPEG/GIF, output will be PNG to maintain data integrity.
"""
# Debug logging
debug.print(f"encode called: message type={type(message).__name__}, "
f"day_phrase='{day_phrase[:20]}...', pin_length={len(pin)}")
# Validate inputs
require_valid_payload(message)
require_valid_image(carrier_image, "Carrier image")
require_security_factors(pin, rsa_key_data)
if pin:
require_valid_pin(pin)
if rsa_key_data:
require_valid_rsa_key(rsa_key_data, rsa_password)
# Default date to today
if date_str is None:
date_str = date.today().isoformat()
debug.print(f"Encoding for date: {date_str}")
# Encrypt message/file
encrypted = encrypt_message(
message, reference_photo, day_phrase, date_str, pin, rsa_key_data
)
# Debug: show encrypted data size
debug.print(f"Encrypted payload: {len(encrypted)} bytes")
# Get pixel key
pixel_key = derive_pixel_key(
reference_photo, day_phrase, date_str, pin, rsa_key_data
)
debug.data(pixel_key, "Pixel key")
# Embed in image (returns extension too)
stego_data, stats, extension = embed_in_image(
carrier_image, encrypted, pixel_key, output_format=output_format
)
# Generate filename with correct extension
filename = generate_filename(date_str, extension=extension)
debug.print(f"Encoding complete: {filename}, "
f"modified {stats.pixels_modified}/{stats.total_pixels} pixels "
f"({stats.modification_percent:.2f}%)")
return EncodeResult(
stego_image=stego_data,
filename=filename,
pixels_modified=stats.pixels_modified,
total_pixels=stats.total_pixels,
capacity_used=stats.capacity_used,
date_used=date_str
)
# Additional validators
# Constants
from .constants import (
DEFAULT_PASSPHRASE_WORDS,
EMBED_MODE_AUTO,
EMBED_MODE_DCT,
EMBED_MODE_LSB,
FORMAT_VERSION,
LOSSLESS_FORMATS,
MAX_IMAGE_PIXELS,
MAX_MESSAGE_SIZE,
MAX_PASSPHRASE_WORDS,
MAX_PIN_LENGTH,
MIN_IMAGE_PIXELS,
MIN_PASSPHRASE_WORDS,
MIN_PIN_LENGTH,
RECOMMENDED_PASSPHRASE_WORDS,
)
# Exceptions
from .exceptions import (
CapacityError,
CryptoError,
DecryptionError,
EmbeddingError,
EncryptionError,
ExtractionError,
ImageValidationError,
InvalidHeaderError,
KeyDerivationError,
KeyGenerationError,
KeyPasswordError,
KeyValidationError,
MessageValidationError,
PinValidationError,
SecurityFactorError,
SteganographyError,
StegasooError,
ValidationError,
)
def encode_file(
filepath: Union[str, Path],
reference_photo: bytes,
carrier_image: bytes,
day_phrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
date_str: Optional[str] = None,
output_format: Optional[str] = None,
filename_override: Optional[str] = None,
) -> EncodeResult:
"""
Encode a file into an image.
Convenience function for embedding files. Preserves original filename.
Args:
filepath: Path to file to embed
reference_photo: Shared reference photo bytes
carrier_image: Image to hide file in
day_phrase: Today's passphrase
pin: Static PIN (optional if using RSA key)
rsa_key_data: RSA private key PEM bytes (optional if using PIN)
rsa_password: Password for RSA key if encrypted
date_str: Date string YYYY-MM-DD (defaults to today)
output_format: Force output format ('PNG', 'BMP')
filename_override: Override the stored filename
Returns:
EncodeResult with stego image and metadata
"""
debug.print(f"encode_file called: filepath={filepath}")
payload = FilePayload.from_file(str(filepath), filename_override)
return encode(
message=payload,
reference_photo=reference_photo,
carrier_image=carrier_image,
day_phrase=day_phrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password,
date_str=date_str,
output_format=output_format,
)
def encode_bytes(
data: bytes,
filename: str,
reference_photo: bytes,
carrier_image: bytes,
day_phrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
date_str: Optional[str] = None,
output_format: Optional[str] = None,
mime_type: Optional[str] = None,
) -> EncodeResult:
"""
Encode raw bytes with a filename into an image.
Convenience function for embedding binary data with metadata.
Args:
data: Raw bytes to embed
filename: Filename to associate with the data
reference_photo: Shared reference photo bytes
carrier_image: Image to hide data in
day_phrase: Today's passphrase
pin: Static PIN (optional if using RSA key)
rsa_key_data: RSA private key PEM bytes (optional if using PIN)
rsa_password: Password for RSA key if encrypted
date_str: Date string YYYY-MM-DD (defaults to today)
output_format: Force output format ('PNG', 'BMP')
mime_type: MIME type of the data
Returns:
EncodeResult with stego image and metadata
"""
debug.print(f"encode_bytes called: filename={filename}, data_size={len(data)}")
payload = FilePayload(data=data, filename=filename, mime_type=mime_type)
return encode(
message=payload,
reference_photo=reference_photo,
carrier_image=carrier_image,
day_phrase=day_phrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password,
date_str=date_str,
output_format=output_format,
)
@debug.time
def decode(
stego_image: bytes,
reference_photo: bytes,
day_phrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
) -> DecodeResult:
"""
Decode a secret message or file from a stego image.
High-level convenience function that handles extraction
and decryption in one call.
Args:
stego_image: Image containing hidden message/file
reference_photo: Shared reference photo bytes
day_phrase: Passphrase for the day message was encoded
pin: Static PIN (if used during encoding)
rsa_key_data: RSA private key PEM bytes (if used during encoding)
rsa_password: Password for RSA key if encrypted
Returns:
DecodeResult with:
- .payload_type: 'text' or 'file'
- .message: Decoded text (if text)
- .file_data: Decoded bytes (if file)
- .filename: Original filename (if file)
- .is_text / .is_file: Convenience properties
Raises:
ValidationError: If inputs are invalid
SecurityFactorError: If no PIN or RSA key provided
ExtractionError: If data cannot be extracted
DecryptionError: If decryption fails
"""
debug.print(f"decode called: stego_image_size={len(stego_image)}, "
f"day_phrase='{day_phrase[:20]}...'")
# Validate inputs
require_security_factors(pin, rsa_key_data)
if pin:
require_valid_pin(pin)
if rsa_key_data:
require_valid_rsa_key(rsa_key_data, rsa_password)
# Try to extract with today's date first
date_str = date.today().isoformat()
pixel_key = derive_pixel_key(
reference_photo, day_phrase, date_str, pin, rsa_key_data
)
debug.data(pixel_key, "Pixel key for extraction")
encrypted = extract_from_image(stego_image, pixel_key)
# If we got data, check if it's from a different date
if encrypted:
header = parse_header(encrypted)
if header and header['date'] != date_str:
debug.print(f"Found different date in header: {header['date']} (expected {date_str})")
# Re-extract with correct date
pixel_key = derive_pixel_key(
reference_photo, day_phrase, header['date'], pin, rsa_key_data
)
encrypted = extract_from_image(stego_image, pixel_key)
if not encrypted:
debug.print("No data extracted from image")
raise ExtractionError("Could not extract data. Check your inputs.")
debug.print(f"Extracted {len(encrypted)} bytes from image")
debug.data(encrypted[:64], "First 64 bytes of extracted data")
# Decrypt and return full result
return decrypt_message(encrypted, reference_photo, day_phrase, pin, rsa_key_data)
def decode_text(
stego_image: bytes,
reference_photo: bytes,
day_phrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
) -> str:
"""
Decode a text message from a stego image.
Convenience function that returns just the text string.
Raises an error if the content is a binary file.
Args:
stego_image: Image containing hidden message
reference_photo: Shared reference photo bytes
day_phrase: Passphrase for the day message was encoded
pin: Static PIN (if used during encoding)
rsa_key_data: RSA private key PEM bytes (if used during encoding)
rsa_password: Password for RSA key if encrypted
Returns:
Decrypted message string
Raises:
DecryptionError: If content is a binary file, not text
"""
debug.print("decode_text called")
result = decode(stego_image, reference_photo, day_phrase, pin, rsa_key_data, rsa_password)
if result.is_file:
# Try to decode file as text
if result.file_data:
try:
return result.file_data.decode('utf-8')
except UnicodeDecodeError:
debug.print(f"File is binary: {result.filename or 'unnamed'}")
raise DecryptionError(
f"Content is a binary file ({result.filename or 'unnamed'}), not text. "
"Use decode() instead and check result.is_file."
)
return ""
debug.print(f"Decoded text: {result.message[:100] if result.message else 'empty'}...")
return result.message or ""
# Models
from .models import (
CapacityComparison,
Credentials,
DecodeResult,
EncodeResult,
FilePayload,
GenerateResult,
ImageInfo,
ValidationResult,
)
from .validation import (
validate_dct_color_mode,
validate_dct_output_format,
validate_embed_mode,
)
# Aliases for backward compatibility
MIN_MESSAGE_LENGTH = 1
MAX_MESSAGE_LENGTH = MAX_MESSAGE_SIZE
MAX_PAYLOAD_SIZE = MAX_MESSAGE_SIZE
SUPPORTED_IMAGE_FORMATS = LOSSLESS_FORMATS
LSB_BYTES_PER_PIXEL = 3 / 8
DCT_BYTES_PER_PIXEL = 0.125
__all__ = [
# Version
'__version__',
# High-level API
'encode',
'encode_file',
'encode_bytes',
'decode',
'decode_text',
'generate_credentials',
# Constants
'DAY_NAMES',
'LOSSLESS_FORMATS',
'MAX_MESSAGE_SIZE',
'MAX_FILE_PAYLOAD_SIZE',
# Models
'Credentials',
'EncodeInput',
'EncodeResult',
'DecodeInput',
'DecodeResult',
'EmbedStats',
'KeyInfo',
'ValidationResult',
'FilePayload',
# Exceptions
'StegasooError',
'ValidationError',
'PinValidationError',
'MessageValidationError',
'ImageValidationError',
'KeyValidationError',
'SecurityFactorError',
'CryptoError',
'EncryptionError',
'DecryptionError',
'KeyDerivationError',
'KeyGenerationError',
'KeyPasswordError',
'SteganographyError',
'CapacityError',
'ExtractionError',
'EmbeddingError',
'InvalidHeaderError',
# Key generation
'generate_pin',
'generate_phrase',
'generate_day_phrases',
'generate_rsa_key',
'export_rsa_key_pem',
'load_rsa_key',
'get_key_info',
# Validation
'validate_pin',
'validate_message',
'validate_payload',
'validate_file_payload',
'validate_image',
'validate_rsa_key',
'validate_security_factors',
'validate_phrase',
'validate_date_string',
'require_valid_pin',
'require_valid_message',
'require_valid_payload',
'require_valid_image',
'require_valid_rsa_key',
'require_security_factors',
# Crypto
'encrypt_message',
'decrypt_message',
'decrypt_message_text',
'derive_hybrid_key',
'derive_pixel_key',
'hash_photo',
'parse_header',
'get_date_from_encrypted',
'has_argon2',
# Steganography
'embed_in_image',
'extract_from_image',
'calculate_capacity',
'get_image_dimensions',
'get_image_format',
'is_lossless_format',
"__version__",
# Core
"encode",
"decode",
"decode_file",
"decode_text",
# Generation
"generate_pin",
"generate_passphrase",
"generate_rsa_key",
"generate_credentials",
"export_rsa_key_pem",
"load_rsa_key",
# Channel key management (v4.0.0)
"generate_channel_key",
"get_channel_key",
"set_channel_key",
"clear_channel_key",
"has_channel_key",
"get_channel_status",
"validate_channel_key",
"format_channel_key",
"get_active_channel_key",
"get_channel_fingerprint",
# Image utilities
"get_image_info",
"compare_capacity",
# Utilities
'generate_filename',
'parse_date_from_filename',
'get_day_from_date',
'get_today_date',
'get_today_day',
'secure_delete',
'SecureDeleter',
'format_file_size',
# Debugging
'debug',
]
"generate_filename",
# Crypto
"has_argon2",
# Steganography
"has_dct_support",
"compare_modes",
"will_fit_by_mode",
# QR utilities
"generate_qr_code",
"extract_key_from_qr",
"detect_and_crop_qr",
"HAS_QR_UTILS",
# Validation
"validate_reference_photo",
"validate_carrier",
"validate_message",
"validate_file_payload",
"validate_passphrase",
"validate_pin",
"validate_rsa_key",
"validate_security_factors",
"validate_embed_mode",
"validate_dct_output_format",
"validate_dct_color_mode",
"validate_channel_key",
# Models
"ImageInfo",
"CapacityComparison",
"GenerateResult",
"EncodeResult",
"DecodeResult",
"FilePayload",
"Credentials",
"ValidationResult",
# Exceptions
"StegasooError",
"ValidationError",
"PinValidationError",
"MessageValidationError",
"ImageValidationError",
"KeyValidationError",
"SecurityFactorError",
"CryptoError",
"EncryptionError",
"DecryptionError",
"KeyDerivationError",
"KeyGenerationError",
"KeyPasswordError",
"SteganographyError",
"CapacityError",
"ExtractionError",
"EmbeddingError",
"InvalidHeaderError",
# Constants
"FORMAT_VERSION",
"MIN_PASSPHRASE_WORDS",
"RECOMMENDED_PASSPHRASE_WORDS",
"DEFAULT_PASSPHRASE_WORDS",
"MAX_PASSPHRASE_WORDS",
"MIN_PIN_LENGTH",
"MAX_PIN_LENGTH",
"MIN_MESSAGE_LENGTH",
"MAX_MESSAGE_LENGTH",
"MAX_MESSAGE_SIZE",
"MAX_PAYLOAD_SIZE",
"MIN_IMAGE_PIXELS",
"MAX_IMAGE_PIXELS",
"SUPPORTED_IMAGE_FORMATS",
"LOSSLESS_FORMATS",
"LSB_BYTES_PER_PIXEL",
"DCT_BYTES_PER_PIXEL",
"EMBED_MODE_LSB",
"EMBED_MODE_DCT",
"EMBED_MODE_AUTO",
]

684
src/stegasoo/batch.py Normal file
View File

@@ -0,0 +1,684 @@
"""
Stegasoo Batch Processing Module (v3.2.0)
Enables encoding/decoding multiple files in a single operation.
Supports parallel processing, progress tracking, and detailed reporting.
Changes in v3.2.0:
- BatchCredentials: renamed day_phrase → passphrase, removed date_str
- Updated all credential handling to use v3.2.0 API
"""
import json
import threading
import time
from collections.abc import Callable, Iterator
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from .constants import ALLOWED_IMAGE_EXTENSIONS, LOSSLESS_FORMATS
class BatchStatus(Enum):
"""Status of individual batch items."""
PENDING = "pending"
PROCESSING = "processing"
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
@dataclass
class BatchItem:
"""Represents a single item in a batch operation."""
input_path: Path
output_path: Path | None = None
status: BatchStatus = BatchStatus.PENDING
error: str | None = None
start_time: float | None = None
end_time: float | None = None
input_size: int = 0
output_size: int = 0
message: str = ""
@property
def duration(self) -> float | None:
"""Processing duration in seconds."""
if self.start_time and self.end_time:
return self.end_time - self.start_time
return None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"input_path": str(self.input_path),
"output_path": str(self.output_path) if self.output_path else None,
"status": self.status.value,
"error": self.error,
"duration_seconds": self.duration,
"input_size": self.input_size,
"output_size": self.output_size,
"message": self.message,
}
@dataclass
class BatchCredentials:
"""
Credentials for batch encode/decode operations (v3.2.0).
Provides a structured way to pass authentication factors
for batch processing instead of using plain dicts.
Changes in v3.2.0:
- Renamed day_phrase → passphrase
- Removed date_str (no longer used in cryptographic operations)
Example:
creds = BatchCredentials(
reference_photo=ref_bytes,
passphrase="apple forest thunder mountain",
pin="123456"
)
result = processor.batch_encode(images, creds, message="secret")
"""
reference_photo: bytes
passphrase: str # v3.2.0: renamed from day_phrase
pin: str = ""
rsa_key_data: bytes | None = None
rsa_password: str | None = None
def to_dict(self) -> dict:
"""Convert to dictionary for API compatibility."""
return {
"reference_photo": self.reference_photo,
"passphrase": self.passphrase,
"pin": self.pin,
"rsa_key_data": self.rsa_key_data,
"rsa_password": self.rsa_password,
}
@classmethod
def from_dict(cls, data: dict) -> "BatchCredentials":
"""
Create BatchCredentials from a dictionary.
Handles both v3.2.0 format (passphrase) and legacy formats (day_phrase, phrase).
"""
# Handle legacy 'day_phrase' and 'phrase' keys
passphrase = data.get("passphrase") or data.get("day_phrase") or data.get("phrase", "")
return cls(
reference_photo=data["reference_photo"],
passphrase=passphrase,
pin=data.get("pin", ""),
rsa_key_data=data.get("rsa_key_data"),
rsa_password=data.get("rsa_password"),
)
@dataclass
class BatchResult:
"""Summary of a batch operation."""
operation: str
total: int = 0
succeeded: int = 0
failed: int = 0
skipped: int = 0
start_time: float = field(default_factory=time.time)
end_time: float | None = None
items: list[BatchItem] = field(default_factory=list)
@property
def duration(self) -> float | None:
"""Total batch duration in seconds."""
if self.end_time:
return self.end_time - self.start_time
return None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"operation": self.operation,
"summary": {
"total": self.total,
"succeeded": self.succeeded,
"failed": self.failed,
"skipped": self.skipped,
"duration_seconds": self.duration,
},
"items": [item.to_dict() for item in self.items],
}
def to_json(self, indent: int = 2) -> str:
"""Serialize to JSON string."""
return json.dumps(self.to_dict(), indent=indent)
# Type alias for progress callback
ProgressCallback = Callable[[int, int, BatchItem], None]
class BatchProcessor:
"""
Handles batch encoding/decoding operations (v3.2.0).
Usage:
processor = BatchProcessor(max_workers=4)
# Batch encode with BatchCredentials
creds = BatchCredentials(
reference_photo=ref_bytes,
passphrase="apple forest thunder mountain",
pin="123456"
)
result = processor.batch_encode(
images=['img1.png', 'img2.png'],
message="Secret message",
output_dir="./encoded/",
credentials=creds,
)
# Batch encode with dict credentials
result = processor.batch_encode(
images=['img1.png', 'img2.png'],
message="Secret message",
credentials={
"reference_photo": ref_bytes,
"passphrase": "apple forest thunder mountain",
"pin": "123456"
},
)
# Batch decode
result = processor.batch_decode(
images=['encoded1.png', 'encoded2.png'],
credentials=creds,
)
"""
def __init__(self, max_workers: int = 4):
"""
Initialize batch processor.
Args:
max_workers: Maximum parallel workers (default 4)
"""
self.max_workers = max_workers
self._lock = threading.Lock()
def find_images(
self,
paths: list[str | Path],
recursive: bool = False,
) -> Iterator[Path]:
"""
Find all valid image files from paths.
Args:
paths: List of files or directories
recursive: Search directories recursively
Yields:
Path objects for each valid image
"""
for path in paths:
path = Path(path)
if path.is_file():
if self._is_valid_image(path):
yield path
elif path.is_dir():
pattern = "**/*" if recursive else "*"
for file_path in path.glob(pattern):
if file_path.is_file() and self._is_valid_image(file_path):
yield file_path
def _is_valid_image(self, path: Path) -> bool:
"""Check if path is a valid image file."""
return path.suffix.lower().lstrip(".") in ALLOWED_IMAGE_EXTENSIONS
def _normalize_credentials(
self, credentials: dict | BatchCredentials | None
) -> BatchCredentials:
"""
Normalize credentials to BatchCredentials object.
Handles both dict and BatchCredentials input, and legacy 'day_phrase' key.
"""
if credentials is None:
raise ValueError("Credentials are required")
if isinstance(credentials, BatchCredentials):
return credentials
if isinstance(credentials, dict):
return BatchCredentials.from_dict(credentials)
raise ValueError(f"Invalid credentials type: {type(credentials)}")
def batch_encode(
self,
images: list[str | Path],
message: str | None = None,
file_payload: Path | None = None,
output_dir: Path | None = None,
output_suffix: str = "_encoded",
credentials: dict | BatchCredentials | None = None,
compress: bool = True,
recursive: bool = False,
progress_callback: ProgressCallback | None = None,
encode_func: Callable = None,
) -> BatchResult:
"""
Encode message into multiple images.
Args:
images: List of image paths or directories
message: Text message to encode (mutually exclusive with file_payload)
file_payload: File to embed (mutually exclusive with message)
output_dir: Output directory (default: same as input)
output_suffix: Suffix for output files
credentials: BatchCredentials or dict with 'passphrase', 'pin', etc.
compress: Enable compression
recursive: Search directories recursively
progress_callback: Called for each item: callback(current, total, item)
encode_func: Custom encode function (for integration)
Returns:
BatchResult with operation summary
"""
if message is None and file_payload is None:
raise ValueError("Either message or file_payload must be provided")
# Normalize credentials to BatchCredentials
creds = self._normalize_credentials(credentials)
result = BatchResult(operation="encode")
image_paths = list(self.find_images(images, recursive))
result.total = len(image_paths)
if output_dir:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Prepare batch items
for img_path in image_paths:
if output_dir:
out_path = output_dir / f"{img_path.stem}{output_suffix}.png"
else:
out_path = img_path.parent / f"{img_path.stem}{output_suffix}.png"
item = BatchItem(
input_path=img_path,
output_path=out_path,
input_size=img_path.stat().st_size if img_path.exists() else 0,
)
result.items.append(item)
# Process items
def process_encode(item: BatchItem) -> BatchItem:
item.status = BatchStatus.PROCESSING
item.start_time = time.time()
try:
if encode_func:
# Use provided encode function
encode_func(
image_path=item.input_path,
output_path=item.output_path,
message=message,
file_payload=file_payload,
credentials=creds.to_dict(),
compress=compress,
)
else:
# Use stegasoo encode
self._do_encode(item, message, file_payload, creds, compress)
item.status = BatchStatus.SUCCESS
item.output_size = (
item.output_path.stat().st_size
if item.output_path and item.output_path.exists()
else 0
)
item.message = f"Encoded to {item.output_path.name}"
except Exception as e:
item.status = BatchStatus.FAILED
item.error = str(e)
item.end_time = time.time()
return item
# Execute with thread pool
self._execute_batch(result, process_encode, progress_callback)
return result
def batch_decode(
self,
images: list[str | Path],
output_dir: Path | None = None,
credentials: dict | BatchCredentials | None = None,
recursive: bool = False,
progress_callback: ProgressCallback | None = None,
decode_func: Callable = None,
) -> BatchResult:
"""
Decode messages from multiple images.
Args:
images: List of image paths or directories
output_dir: Output directory for file payloads (default: same as input)
credentials: BatchCredentials or dict with 'passphrase', 'pin', etc.
recursive: Search directories recursively
progress_callback: Called for each item: callback(current, total, item)
decode_func: Custom decode function (for integration)
Returns:
BatchResult with decoded messages in item.message fields
"""
# Normalize credentials to BatchCredentials
creds = self._normalize_credentials(credentials)
result = BatchResult(operation="decode")
image_paths = list(self.find_images(images, recursive))
result.total = len(image_paths)
if output_dir:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Prepare batch items
for img_path in image_paths:
item = BatchItem(
input_path=img_path,
output_path=output_dir,
input_size=img_path.stat().st_size if img_path.exists() else 0,
)
result.items.append(item)
# Process items
def process_decode(item: BatchItem) -> BatchItem:
item.status = BatchStatus.PROCESSING
item.start_time = time.time()
try:
if decode_func:
# Use provided decode function
decoded = decode_func(
image_path=item.input_path,
output_dir=item.output_path,
credentials=creds.to_dict(),
)
item.message = (
decoded.get("message", "") if isinstance(decoded, dict) else str(decoded)
)
else:
# Use stegasoo decode
item.message = self._do_decode(item, creds)
item.status = BatchStatus.SUCCESS
except Exception as e:
item.status = BatchStatus.FAILED
item.error = str(e)
item.end_time = time.time()
return item
# Execute with thread pool
self._execute_batch(result, process_decode, progress_callback)
return result
def _execute_batch(
self,
result: BatchResult,
process_func: Callable[[BatchItem], BatchItem],
progress_callback: ProgressCallback | None = None,
) -> None:
"""Execute batch processing with thread pool."""
completed = 0
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures = {executor.submit(process_func, item): item for item in result.items}
for future in as_completed(futures):
item = future.result()
completed += 1
with self._lock:
if item.status == BatchStatus.SUCCESS:
result.succeeded += 1
elif item.status == BatchStatus.FAILED:
result.failed += 1
elif item.status == BatchStatus.SKIPPED:
result.skipped += 1
if progress_callback:
progress_callback(completed, result.total, item)
result.end_time = time.time()
def _do_encode(
self,
item: BatchItem,
message: str | None,
file_payload: Path | None,
creds: BatchCredentials,
compress: bool,
) -> None:
"""
Perform actual encoding using stegasoo.encode.
Override this method to customize encoding behavior.
"""
try:
from .encode import encode
from .models import FilePayload
# Read carrier image
carrier_image = item.input_path.read_bytes()
if file_payload:
# Encode file
payload = FilePayload.from_file(str(file_payload))
result = encode(
message=payload,
reference_photo=creds.reference_photo,
carrier_image=carrier_image,
passphrase=creds.passphrase,
pin=creds.pin,
rsa_key_data=creds.rsa_key_data,
rsa_password=creds.rsa_password,
)
else:
# Encode text message
result = encode(
message=message,
reference_photo=creds.reference_photo,
carrier_image=carrier_image,
passphrase=creds.passphrase,
pin=creds.pin,
rsa_key_data=creds.rsa_key_data,
rsa_password=creds.rsa_password,
)
# Write output
if item.output_path:
item.output_path.write_bytes(result.stego_image)
except ImportError:
# Fallback to mock if stegasoo.encode not available
self._mock_encode(item, message, creds, compress)
def _do_decode(
self,
item: BatchItem,
creds: BatchCredentials,
) -> str:
"""
Perform actual decoding using stegasoo.decode.
Override this method to customize decoding behavior.
"""
try:
from .decode import decode
# Read stego image
stego_image = item.input_path.read_bytes()
result = decode(
stego_image=stego_image,
reference_photo=creds.reference_photo,
passphrase=creds.passphrase,
pin=creds.pin,
rsa_key_data=creds.rsa_key_data,
rsa_password=creds.rsa_password,
)
if result.is_text:
return result.message or ""
else:
# File payload - save it
if item.output_path and result.file_data:
output_file = item.output_path / (result.filename or "extracted_file")
output_file.write_bytes(result.file_data)
return f"File extracted: {result.filename or 'extracted_file'}"
return f"[File: {result.filename or 'binary data'}]"
except ImportError:
# Fallback to mock if stegasoo.decode not available
return self._mock_decode(item, creds)
def _mock_encode(
self, item: BatchItem, message: str, creds: BatchCredentials, compress: bool
) -> None:
"""Mock encode for testing - replace with actual stego.encode()"""
# This is a placeholder - in real usage, you'd call your actual encode function
# For now, just copy the file to simulate encoding
import shutil
if item.output_path:
shutil.copy(item.input_path, item.output_path)
def _mock_decode(self, item: BatchItem, creds: BatchCredentials) -> str:
"""Mock decode for testing - replace with actual stego.decode()"""
# This is a placeholder - in real usage, you'd call your actual decode function
return "[Decoded message would appear here]"
def batch_capacity_check(
images: list[str | Path],
recursive: bool = False,
) -> list[dict]:
"""
Check capacity of multiple images without encoding.
Args:
images: List of image paths or directories
recursive: Search directories recursively
Returns:
List of dicts with path, dimensions, and estimated capacity
"""
from PIL import Image
from .constants import MAX_IMAGE_PIXELS
processor = BatchProcessor()
results = []
for img_path in processor.find_images(images, recursive):
try:
with Image.open(img_path) as img:
width, height = img.size
pixels = width * height
# Estimate: 3 bits per pixel (RGB LSB), minus header overhead
capacity_bits = pixels * 3
capacity_bytes = (capacity_bits // 8) - 100 # Header overhead
results.append(
{
"path": str(img_path),
"dimensions": f"{width}x{height}",
"pixels": pixels,
"format": img.format,
"mode": img.mode,
"capacity_bytes": max(0, capacity_bytes),
"capacity_kb": max(0, capacity_bytes // 1024),
"valid": pixels <= MAX_IMAGE_PIXELS and img.format in LOSSLESS_FORMATS,
"warnings": _get_image_warnings(img, img_path),
}
)
except Exception as e:
results.append(
{
"path": str(img_path),
"error": str(e),
"valid": False,
}
)
return results
def _get_image_warnings(img, path: Path) -> list[str]:
"""Generate warnings for an image."""
from .constants import LOSSLESS_FORMATS, MAX_IMAGE_PIXELS
warnings = []
if img.format not in LOSSLESS_FORMATS:
warnings.append(f"Lossy format ({img.format}) - quality will degrade on re-save")
if img.size[0] * img.size[1] > MAX_IMAGE_PIXELS:
warnings.append(f"Image exceeds {MAX_IMAGE_PIXELS:,} pixel limit")
if img.mode not in ("RGB", "RGBA"):
warnings.append(f"Non-RGB mode ({img.mode}) - will be converted")
return warnings
# CLI-friendly functions
def print_batch_result(result: BatchResult, verbose: bool = False) -> None:
"""Print batch result summary to console."""
print(f"\n{'='*60}")
print(f"Batch {result.operation.upper()} Complete")
print(f"{'='*60}")
print(f"Total: {result.total}")
print(f"Succeeded: {result.succeeded}")
print(f"Failed: {result.failed}")
print(f"Skipped: {result.skipped}")
if result.duration:
print(f"Duration: {result.duration:.2f}s")
if verbose or result.failed > 0:
print(f"\n{''*60}")
for item in result.items:
status_icon = {
BatchStatus.SUCCESS: "",
BatchStatus.FAILED: "",
BatchStatus.SKIPPED: "",
BatchStatus.PENDING: "",
BatchStatus.PROCESSING: "",
}.get(item.status, "?")
print(f"{status_icon} {item.input_path.name}")
if item.error:
print(f" Error: {item.error}")
elif item.message and verbose:
print(f" {item.message}")

570
src/stegasoo/channel.py Normal file
View File

@@ -0,0 +1,570 @@
"""
Channel Key Management for Stegasoo (v4.0.0)
A channel key ties encode/decode operations to a specific deployment or group.
Messages encoded with one channel key can only be decoded by systems with the
same channel key configured.
Use cases:
- Organization deployment: IT sets a company-wide channel key
- Friend groups: Share a channel key for private communication
- Air-gapped systems: Generate unique key per installation
- Public instances: No channel key = compatible with any instance without a channel key
Storage priority:
1. Environment variable: STEGASOO_CHANNEL_KEY
2. Config file: ~/.stegasoo/channel.key or ./config/channel.key
3. None (public mode - compatible with any instance without a channel key)
INTEGRATION STATUS (v4.0.0):
- ✅ get_channel_key_hash() integrated into derive_hybrid_key() in crypto.py
- ✅ get_channel_key_hash() integrated into derive_pixel_key() in crypto.py
- ✅ channel_key parameter added to encode() and decode() functions
- ✅ Header flags indicate whether message was encoded with channel key
- ✅ Helpful error messages for channel key mismatches
"""
import hashlib
import os
import re
import secrets
from pathlib import Path
from .debug import debug
# Channel key format: 8 groups of 4 alphanumeric chars (32 chars total)
# Example: ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
CHANNEL_KEY_PATTERN = re.compile(r"^[A-Z0-9]{4}(-[A-Z0-9]{4}){7}$")
CHANNEL_KEY_LENGTH = 32 # Characters (excluding dashes)
CHANNEL_KEY_FORMATTED_LENGTH = 39 # With dashes
# Environment variable name
CHANNEL_KEY_ENV_VAR = "STEGASOO_CHANNEL_KEY"
# Config locations (in priority order)
CONFIG_LOCATIONS = [
Path("./config/channel.key"), # Project config
Path.home() / ".stegasoo" / "channel.key", # User config
]
def generate_channel_key() -> str:
"""
Generate a new random channel key.
Returns:
Formatted channel key (e.g., "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456")
Example:
>>> key = generate_channel_key()
>>> len(key)
39
"""
# Generate 32 random alphanumeric characters
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
raw_key = "".join(secrets.choice(alphabet) for _ in range(CHANNEL_KEY_LENGTH))
formatted = format_channel_key(raw_key)
debug.print(f"Generated channel key: {get_channel_fingerprint(formatted)}")
return formatted
def format_channel_key(raw_key: str) -> str:
"""
Format a raw key string into the standard format.
Args:
raw_key: Raw key string (with or without dashes)
Returns:
Formatted key with dashes (XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX)
Raises:
ValueError: If key is invalid length or contains invalid characters
Example:
>>> format_channel_key("ABCD1234EFGH5678IJKL9012MNOP3456")
"ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
"""
# Remove any existing dashes, spaces, and convert to uppercase
clean = raw_key.replace("-", "").replace(" ", "").upper()
if len(clean) != CHANNEL_KEY_LENGTH:
raise ValueError(f"Channel key must be {CHANNEL_KEY_LENGTH} characters (got {len(clean)})")
# Validate characters
if not all(c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" for c in clean):
raise ValueError("Channel key must contain only letters A-Z and digits 0-9")
# Format with dashes every 4 characters
return "-".join(clean[i : i + 4] for i in range(0, CHANNEL_KEY_LENGTH, 4))
def validate_channel_key(key: str) -> bool:
"""
Validate a channel key format.
Args:
key: Channel key to validate
Returns:
True if valid format, False otherwise
Example:
>>> validate_channel_key("ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456")
True
>>> validate_channel_key("invalid")
False
"""
if not key:
return False
try:
formatted = format_channel_key(key)
return bool(CHANNEL_KEY_PATTERN.match(formatted))
except ValueError:
return False
def get_channel_key() -> str | None:
"""
Get the current channel key from environment or config.
Checks in order:
1. STEGASOO_CHANNEL_KEY environment variable
2. ./config/channel.key file
3. ~/.stegasoo/channel.key file
Returns:
Channel key if configured, None if in public mode
Example:
>>> key = get_channel_key()
>>> if key:
... print("Private channel")
... else:
... print("Public mode")
"""
# 1. Check environment variable
env_key = os.environ.get(CHANNEL_KEY_ENV_VAR, "").strip()
if env_key:
if validate_channel_key(env_key):
debug.print(f"Channel key from environment: {get_channel_fingerprint(env_key)}")
return format_channel_key(env_key)
else:
debug.print(f"Warning: Invalid {CHANNEL_KEY_ENV_VAR} format, ignoring")
# 2. Check config files
for config_path in CONFIG_LOCATIONS:
if config_path.exists():
try:
key = config_path.read_text().strip()
if key and validate_channel_key(key):
debug.print(f"Channel key from {config_path}: {get_channel_fingerprint(key)}")
return format_channel_key(key)
except (OSError, PermissionError) as e:
debug.print(f"Could not read {config_path}: {e}")
continue
# 3. No channel key configured (public mode)
debug.print("No channel key configured (public mode)")
return None
def set_channel_key(key: str, location: str = "project") -> Path:
"""
Save a channel key to config file.
Args:
key: Channel key to save (will be formatted)
location: 'project' for ./config/ or 'user' for ~/.stegasoo/
Returns:
Path where key was saved
Raises:
ValueError: If key format is invalid
Example:
>>> path = set_channel_key("ABCD1234EFGH5678IJKL9012MNOP3456")
>>> print(path)
./config/channel.key
"""
formatted = format_channel_key(key)
if location == "user":
config_path = Path.home() / ".stegasoo" / "channel.key"
else:
config_path = Path("./config/channel.key")
# Create directory if needed
config_path.parent.mkdir(parents=True, exist_ok=True)
# Write key with newline
config_path.write_text(formatted + "\n")
# Set restrictive permissions (owner read/write only)
try:
config_path.chmod(0o600)
except (OSError, AttributeError):
pass # Windows doesn't support chmod the same way
debug.print(f"Channel key saved to {config_path}")
return config_path
def clear_channel_key(location: str = "all") -> list[Path]:
"""
Remove channel key configuration.
Args:
location: 'project', 'user', or 'all'
Returns:
List of paths that were deleted
Example:
>>> deleted = clear_channel_key('all')
>>> print(f"Removed {len(deleted)} files")
"""
deleted = []
paths_to_check = []
if location in ("project", "all"):
paths_to_check.append(Path("./config/channel.key"))
if location in ("user", "all"):
paths_to_check.append(Path.home() / ".stegasoo" / "channel.key")
for path in paths_to_check:
if path.exists():
try:
path.unlink()
deleted.append(path)
debug.print(f"Removed channel key: {path}")
except (OSError, PermissionError) as e:
debug.print(f"Could not remove {path}: {e}")
return deleted
def get_channel_key_hash(key: str | None = None) -> bytes | None:
"""
Get the channel key as a 32-byte hash suitable for key derivation.
This hash is mixed into the Argon2 key derivation to bind
encryption to a specific channel.
Args:
key: Channel key (if None, reads from config)
Returns:
32-byte SHA-256 hash of channel key, or None if no channel key
Example:
>>> hash_bytes = get_channel_key_hash()
>>> if hash_bytes:
... print(f"Hash: {len(hash_bytes)} bytes")
"""
if key is None:
key = get_channel_key()
if not key:
return None
# Hash the formatted key to get consistent 32 bytes
formatted = format_channel_key(key)
return hashlib.sha256(formatted.encode("utf-8")).digest()
def get_channel_fingerprint(key: str | None = None) -> str | None:
"""
Get a short fingerprint for display purposes.
Shows first and last 4 chars with masked middle.
Args:
key: Channel key (if None, reads from config)
Returns:
Fingerprint like "ABCD-••••-••••-••••-••••-••••-••••-3456" or None
Example:
>>> print(get_channel_fingerprint())
ABCD-••••-••••-••••-••••-••••-••••-3456
"""
if key is None:
key = get_channel_key()
if not key:
return None
formatted = format_channel_key(key)
parts = formatted.split("-")
# Show first and last group, mask the rest
masked = [parts[0]] + ["••••"] * 6 + [parts[-1]]
return "-".join(masked)
def get_channel_status() -> dict:
"""
Get comprehensive channel key status.
Returns:
Dictionary with:
- mode: 'private' or 'public'
- configured: bool
- fingerprint: masked key or None
- source: where key came from or None
- key: full key (for export) or None
Example:
>>> status = get_channel_status()
>>> print(f"Mode: {status['mode']}")
Mode: private
"""
key = get_channel_key()
if key:
# Find which source provided the key
source = "unknown"
env_key = os.environ.get(CHANNEL_KEY_ENV_VAR, "").strip()
if env_key and validate_channel_key(env_key):
source = "environment"
else:
for config_path in CONFIG_LOCATIONS:
if config_path.exists():
try:
file_key = config_path.read_text().strip()
if file_key and format_channel_key(file_key) == key:
source = str(config_path)
break
except (OSError, PermissionError):
continue
return {
"mode": "private",
"configured": True,
"fingerprint": get_channel_fingerprint(key),
"source": source,
"key": key,
}
else:
return {
"mode": "public",
"configured": False,
"fingerprint": None,
"source": None,
"key": None,
}
def has_channel_key() -> bool:
"""
Quick check if a channel key is configured.
Returns:
True if channel key is set, False for public mode
Example:
>>> if has_channel_key():
... print("Private channel active")
"""
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
# =============================================================================
if __name__ == "__main__":
import sys
def print_status():
"""Print current channel status."""
status = get_channel_status()
print(f"Mode: {status['mode'].upper()}")
if status["configured"]:
print(f"Fingerprint: {status['fingerprint']}")
print(f"Source: {status['source']}")
else:
print("No channel key configured (public mode)")
if len(sys.argv) < 2:
print("Channel Key Manager")
print("=" * 40)
print_status()
print()
print("Commands:")
print(" python -m stegasoo.channel generate - Generate new key")
print(" python -m stegasoo.channel set <KEY> - Set channel key")
print(" python -m stegasoo.channel show - Show full key")
print(" python -m stegasoo.channel clear - Remove channel key")
print(" python -m stegasoo.channel status - Show status")
sys.exit(0)
cmd = sys.argv[1].lower()
if cmd == "generate":
key = generate_channel_key()
print("Generated channel key:")
print(f" {key}")
print()
save = input("Save to config? [y/N]: ").strip().lower()
if save == "y":
path = set_channel_key(key)
print(f"Saved to: {path}")
elif cmd == "set":
if len(sys.argv) < 3:
print("Usage: python -m stegasoo.channel set <KEY>")
sys.exit(1)
try:
key = sys.argv[2]
formatted = format_channel_key(key)
path = set_channel_key(formatted)
print(f"Channel key set: {get_channel_fingerprint(formatted)}")
print(f"Saved to: {path}")
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
elif cmd == "show":
status = get_channel_status()
if status["configured"]:
print(f"Channel key: {status['key']}")
print(f"Source: {status['source']}")
else:
print("No channel key configured")
elif cmd == "clear":
deleted = clear_channel_key("all")
if deleted:
print(f"Removed channel key from: {', '.join(str(p) for p in deleted)}")
else:
print("No channel key files found")
elif cmd == "status":
print_status()
else:
print(f"Unknown command: {cmd}")
sys.exit(1)

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More