196 Commits

Author SHA1 Message Date
Aaron D. Lee
597a9c6411 Prepare 4.1.2 release documentation
Some checks failed
Release / test (push) Failing after 38s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped
- Add 4.1.2 changelog: Docker, Pi wizard, unit tests, validation script
- Add Raspberry Pi section to README with first-boot wizard info
- Document new features: TUI setup, overclock presets, sanitize scripts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:10:02 -05:00
Aaron D. Lee
67b25a43a6 Update RPi banner styling: purple→blue gradient + gold logo
- Horizontal borders: deep purple (93) → light blue (117) gradient
- STEGASOO ASCII logo: gold (220) to match web UI
- Applied to all RPi scripts: first-boot-wizard, setup, sanitize

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:06:35 -05:00
Aaron D. Lee
65a663fe3b Add Docker deployment documentation
- New DOCKER.md with comprehensive Docker setup guide
- Added Docker quick start section to README.md
- Documents environment variables, volumes, build process
- Includes production deployment and troubleshooting tips

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:00:01 -05:00
Aaron D. Lee
fc6e4eb805 Add comprehensive pytest unit tests for stegasoo library
Tests cover:
- Version info
- Credential generation (passphrase, PIN, channel key)
- Validation functions (passphrase, PIN, message, image)
- LSB encode/decode roundtrip and failure cases
- DCT encode/decode roundtrip and JPEG output
- Channel key encode/decode and wrong key rejection
- Compression of long messages
- Edge cases: Unicode, special chars, minimum passphrase

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:53:42 -05:00
Aaron D. Lee
50f07a0ce9 Adjust banner alignment - logo +1, tagline +5 spaces
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:32:41 -05:00
Aaron D. Lee
7accd26821 Standardized the ASCII banners in pi scripts
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:30:35 -05:00
Aaron D. Lee
075e10792c Simplify wizard banners - no side borders, pale pink lines
- Removed side borders from logo/sparkle sections
- Use horizontal lines only (no corner chars)
- Changed to pale pink (256-color 218) for softer look
- Centered "First Boot Wizard" and "Setup Complete!" text
- Both banners now identical except bottom text

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:27:21 -05:00
Aaron D. Lee
9a790de5c3 Fix wizard banner alignment - indent sparkles and logo
- Added 4 extra spaces to sparkle lines
- Added 2 extra spaces to logo lines
- Both banners now properly aligned within the border

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:23:28 -05:00
Aaron D. Lee
3c91c92a4d Fix wizard banner alignment and use light pink border
- Fixed logo alignment to be consistent across all lines
- Changed border from 0;35 (magenta) to 1;35 (light pink)
- Updated sparkle pattern for better visual consistency
- Both welcome and Setup Complete banners now match

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:21:31 -05:00
Aaron D. Lee
9d1bc7f829 Tighten wizard banners - remove extra blank line
Both welcome and Setup Complete banners now have consistent
design with sparkles directly above the bottom text line.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:19:46 -05:00
Aaron D. Lee
d8118d688b Fix wizard welcome banner to match Setup Complete style
Same cyan (0;36), pink border, and indentation as the completion banner

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:17:02 -05:00
Aaron D. Lee
b6acee1acb Add bright cyan STEGASOO logo to wizard welcome banner
Pink border, bright cyan logo, gray sparkles, white 'First Boot Wizard'

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:15:22 -05:00
Aaron D. Lee
b9baf35dfa Show CPU speed and temp in MOTD when overclocked
Displays MHz and temperature when arm_freq or over_voltage is set in config.txt

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:13:16 -05:00
Aaron D. Lee
561f03ffde Full bordered Setup Complete banner with colored text
Pink border, cyan logo, gray sparkles, green 'Setup Complete!'

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:12:12 -05:00
Aaron D. Lee
038347a505 Add pink border lines to Setup Complete banner
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:11:37 -05:00
Aaron D. Lee
e026d1a4db Update about.html version history, fix API exports
About page:
- Version history now shows v4.1.2 prominently with accordion for older versions
- Shortened 'Error Correction Reed-Solomon' to 'DCT ECC / RS Code'
- Removed v4.1 badges from established features

API fixes:
- Export MAX_FILE_PAYLOAD_SIZE from constants
- Export calculate_capacity_by_mode from steganography

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:09:28 -05:00
Aaron D. Lee
3f93e7a752 Add sparkly banner to first-boot wizard completion
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:07:56 -05:00
Aaron D. Lee
cdc7ffd3bf Fix gum --inline flag not supported in first-boot wizard
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:02:12 -05:00
Aaron D. Lee
6c3bc995f1 Mobile polish, release validation script, bump to v4.1.2
Mobile-responsive CSS improvements:
- Larger touch targets for drop zones and buttons (56px min)
- Touch feedback with active states for touch devices
- Camera hint text on mobile ("Tap to take photo or choose file")
- Mode buttons stack vertically on small screens
- Full-width download buttons on mobile
- Navbar doesn't stick on mobile to save screen space

Release validation script (scripts/validate-release.sh):
- Automated pre-release checks: ruff, imports, encode/decode sanity
- Optional Docker build/test (--docker flag)
- Optional Pi smoke test via SSH (--pi flag)
- Pass/fail summary with exit codes

Other:
- Version bump to 4.1.2 (pyproject.toml, constants.py, __init__.py)
- Fixed ruff import sorting in cli.py
- Updated PLAN-4.1.2.md (all 9 features complete)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:34:23 -05:00
Aaron D. Lee
2d3ed8a79a Add progress bars, fix DCT decode, sparkly MOTD
Progress bar support (v4.1.2):
- Web frontend: Real-time progress during encode with phase display
- CLI: --progress flag with rich library for encode command
- Backend: progress_file parameter for async progress reporting

DCT decode bug fix:
- Fixed InvalidMagicBytesError not being caught in early-exit check
- RS-protected format (v4.1.0+) has length prefix first, not magic bytes
- Exception handler now catches both ValueError and InvalidMagicBytesError

MOTD update:
- Added sparkly header to setup.sh MOTD (matches other rpi scripts)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:25:33 -05:00
Aaron D. Lee
040c44fec6 Remove duplicate MOTD, source bashrc after install
- System MOTD already shows banner, bashrc one was redundant
- Source bashrc immediately after copying for instant effect

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:32:20 -05:00
Aaron D. Lee
832d8be025 Fix jpegio ARM64 patch for CRLF line endings
- Convert CRLF to LF before patching (jpegio uses Windows line endings)
- Update patch context to match current jpegio setup.py

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:30:32 -05:00
Aaron D. Lee
7088623d2c adlee themed cli becuase I can.
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:25:23 -05:00
Aaron D. Lee
44a3ca8a0f Compact first-boot-wizard output for smaller terminals
- Remove sparkle decoration lines from banner
- Reduce padding and margins on boxes
- Condense first steps to single line
- Condense commands to single line
- Simplify restart notice (no bordered box)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:20:49 -05:00
Aaron D. Lee
7a35ac3df7 Update plan: mark #6 Smoke Test Benchmarking as done
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:15:05 -05:00
Aaron D. Lee
f69475b406 Implement granular decode error messages (#2)
New exceptions for specific decode failures:
- InvalidMagicBytesError: wrong mode or not a Stegasoo image
- ReedSolomonError: image too corrupted to recover
- NoDataFoundError, ModeMismatchError: additional clarity

Web UI now shows specific, actionable error messages:
- "Try a different mode (LSB/DCT)"
- "Image too corrupted, may have been re-saved"
- "Wrong credentials - check reference photo..."

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:34:05 -05:00
Aaron D. Lee
559dcd3dcf Implement forced first-login setup and dropzone UX fixes
#4 Forced First-Login Setup:
- Add before_request hook to redirect to /setup if no users exist
- Skip redirect for static files and setup routes

#5 Dropzone UX Fixes:
- Make preview images clickable to replace file
- Make entire drop zone clickable
- QR zone resets after 2s on error, allowing retry
- Clear file input on error so same file can be re-selected

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:28:28 -05:00
Aaron D. Lee
b1ddfaa75b flash-image.sh: prefer rpi-imager, fallback to dd
- Try rpi-imager first (native .zst support, faster)
- Fall back to dd if rpi-imager unavailable or fails
- pv now optional (uses dd status=progress without it)
- Handles .zst.zip GitHub wrapper automatically

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:14:01 -05:00
Aaron D. Lee
4843ec8c22 Add rpi-imager CLI option to flash docs
- rpi-imager --cli supports .zst.zip directly
- Also document flash-image.sh option
- Keep manual dd as fallback

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:11:13 -05:00
Aaron D. Lee
ac08011236 Clean up repo structure
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:05:06 -05:00
Aaron D. Lee
12c4b091fb Move smoke-test.sh to tests/, make it local-only
- Move from rpi/ to tests/ directory
- Add to .gitignore (local tool, not part of distribution)
- Pytest unit tests remain tracked

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:03:28 -05:00
Aaron D. Lee
c2c2c924e1 Add Docker support to smoke test, add inject-wifi.sh
Smoke test improvements:
- Add --docker flag for testing Docker containers
- Skip SSH/systemd checks in Docker mode
- Docker health check verifies HTTP response
- Show "Docker Smoke Test" header in Docker mode

inject-wifi.sh:
- Add to repo (was gitignored)
- Add cleanup trap for robustness
- Supports NetworkManager (Bookworm) and wpa_supplicant (legacy)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:01:36 -05:00
Aaron D. Lee
df7ad06a08 Update flash-image.sh: add .zst.zip support
- Add support for .zst.zip wrapper (GitHub releases workaround)
- Update examples to use .zst format (current default)
- Update usage to show all supported formats

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 17:50:52 -05:00
Aaron D. Lee
166b936ee5 Fix smoke test NEEDS_SETUP detection and login checks
- Check /login redirect to /setup instead of homepage redirect
- Use logout link presence to verify login success (encode/decode are public)
- Add -c flag to save cookies during homepage check

The smoke test was passing login even when not logged in because
encode/decode links are visible to everyone.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:43:09 -05:00
Aaron D. Lee
7138455f8d Update docs: cd /opt before git clone 2026-01-05 16:08:16 -05:00
Aaron D. Lee
9ab3260298 Update 4.1.2 plan: Docker cleanup done, add smoke test Docker support 2026-01-05 16:00:07 -05:00
Aaron D. Lee
763f7bf603 Fix Docker build: add .dockerignore, fix permissions
- Add .dockerignore to exclude instance/, test_data/, rpi/, etc.
- Create instance/certs dirs in Dockerfile for volume mounts
- Ensures stego user can write to mounted volumes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:59:32 -05:00
Aaron D. Lee
1059e17f4e Add release validation script to 4.1.2 plan 2026-01-05 15:12:18 -05:00
Aaron D. Lee
7cb42e189a Add release checklist for Pi and Docker validation 2026-01-05 15:11:56 -05:00
Aaron D. Lee
8c283bc4e5 Add 4.1.1 release notes 2026-01-05 14:50:37 -05:00
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
133 changed files with 31436 additions and 9102 deletions

39
.dockerignore Normal file
View File

@@ -0,0 +1,39 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*.egg-info
.eggs
venv/
.venv/
# Instance data (user creates fresh)
frontends/web/instance/
frontends/web/certs/
instance/
# Test data
test_data/
tests/
# Pi-specific
rpi/
*.img
*.img.zst
*.img.zst.zip
# Docs
*.md
docs/
# IDE
.vscode/
.idea/
# Misc
*.log
*.tmp
.DS_Store

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

27
.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
@@ -58,6 +64,19 @@ htmlcov/
# Output test files.
test_data/*.png
#Project root scripts.
rbld_containers.sh
quick_web.sh
# Dev scripts (local convenience scripts - except validate-release.sh)
scripts/*
!scripts/validate-release.sh
# Web UI auth database and SSL certs
frontends/web/instance/
frontends/web/certs/
# Tests (private)
tests/
# RPi image build artifacts
*.img
*.img.xz
*.img.zst
pishrink.sh

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

189
CHANGELOG.md Normal file
View File

@@ -0,0 +1,189 @@
# 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.2] - 2026-01-05
### Added
- **Docker Deployment**: Production-ready containerization
- `docker-compose.yml` for Web UI (port 5000) and REST API (port 8000)
- Multi-stage builds with base image for faster rebuilds
- Health checks, resource limits (768MB), and volume persistence
- Comprehensive `DOCKER.md` documentation
- **Raspberry Pi First-Boot Wizard**: Interactive TUI setup experience
- `gum` TUI toolkit for styled prompts and spinners
- WiFi configuration, HTTPS setup, channel key generation
- Overclock presets (Pi 5: 2.8/3.0 GHz with cooling recommendations)
- Port 443 redirect option for clean HTTPS URLs
- Styled banners with purple→blue gradient and gold logo
- **Pi Image Distribution**: Scripts for SD card imaging
- `sanitize-for-image.sh` removes credentials, SSH keys, user data
- Soft reset mode for testing without clearing WiFi
- Auto-validates sanitization before imaging
- **Unit Tests**: Comprehensive pytest test suite
- Tests for encode/decode, LSB/DCT modes, channel keys
- Validation, generation, compression, edge cases
- 29 tests covering core library functionality
- **Release Validation**: `scripts/validate-release.sh` for pre-release checks
### Changed
- Pi MOTD shows CPU speed and temperature when overclocked
- Mobile UI polish and responsive improvements
- Standardized ASCII banners across all Pi scripts
- Setup script uses pyenv for Python 3.12 (Pi OS ships 3.13)
### Fixed
- DCT decode reliability improvements
- Fixed `gum --inline` flag compatibility (not supported in all versions)
- Wizard banner alignment and spacing issues
## [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.1.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.2
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0
[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!

153
DOCKER.md Normal file
View File

@@ -0,0 +1,153 @@
# Docker Deployment
Stegasoo provides Docker images for both the Web UI and REST API.
## Quick Start
```bash
# Build and start all services
docker-compose up -d
# Check status
docker-compose ps
```
Access:
- **Web UI**: http://localhost:5000
- **REST API**: http://localhost:8000
## Services
| Service | Port | Description |
|---------|------|-------------|
| `web` | 5000 | Flask Web UI with authentication |
| `api` | 8000 | FastAPI REST API |
## Configuration
### Environment Variables
Create a `.env` file or set these variables:
```bash
# Channel key for private group communication (optional)
STEGASOO_CHANNEL_KEY=XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
# Web UI authentication (default: enabled)
STEGASOO_AUTH_ENABLED=true
# HTTPS support (default: disabled)
STEGASOO_HTTPS_ENABLED=false
STEGASOO_HOSTNAME=localhost
```
### Volume Mounts
Persistent data is stored in Docker volumes:
| Volume | Purpose |
|--------|---------|
| `stegasoo-web-data` | User database, session data |
| `stegasoo-web-certs` | SSL certificates (if HTTPS enabled) |
## Building
### Standard Build (Recommended)
Uses a pre-built base image with all dependencies:
```bash
# First time only: build the base image
docker build -f Dockerfile.base -t stegasoo-base:latest .
# Build services (fast - only copies app code)
docker-compose build
```
### Full Build (No Base Image)
If you don't have the base image, the Dockerfile will build all dependencies (slower):
```bash
docker-compose build
```
## Commands
```bash
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
# Rebuild after code changes
docker-compose build && docker-compose up -d
# Full rebuild (no cache)
docker-compose build --no-cache
```
## Resource Limits
Each container is configured with:
- **Memory limit**: 768 MB
- **Memory reservation**: 384 MB
This accounts for Argon2id's 256 MB RAM requirement during key derivation.
## Health Checks
Both services include health checks:
- Interval: 30 seconds
- Timeout: 10 seconds
- Start period: 5 seconds
- Retries: 3
Check health status:
```bash
docker-compose ps
```
## Production Deployment
For production, consider:
1. **Enable HTTPS**:
```bash
STEGASOO_HTTPS_ENABLED=true
STEGASOO_HOSTNAME=your-domain.com
```
2. **Use secrets for channel key**:
```bash
# Don't commit .env files with secrets
export STEGASOO_CHANNEL_KEY=your-key
docker-compose up -d
```
3. **Reverse proxy**: Put behind nginx/traefik for TLS termination
4. **Backup volumes**:
```bash
docker run --rm -v stegasoo-web-data:/data -v $(pwd):/backup \
alpine tar czf /backup/stegasoo-backup.tar.gz /data
```
## Troubleshooting
### Container won't start
```bash
# Check logs
docker-compose logs web
docker-compose logs api
```
### Out of memory
Increase Docker's memory allocation or reduce worker count in Dockerfile.
### Permission errors
The containers run as non-root user `stego` (UID 1000). Ensure volume permissions match.

View File

@@ -1,57 +1,69 @@
# 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)
# 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
# ============================================================================
# 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
# Suppress pip "running as root" warnings during build
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/
# Create upload directory
RUN mkdir -p /tmp/stego_uploads
# Create upload directory and instance directories (for volumes)
RUN mkdir -p /tmp/stego_uploads /app/frontends/web/instance /app/frontends/web/certs
# Create non-root user
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads
@@ -69,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
@@ -108,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)

250
PLAN-4.1.2.md Normal file
View File

@@ -0,0 +1,250 @@
# 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:** Done
**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:** Done
**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
### Implementation
- Added new exceptions: InvalidMagicBytesError, ReedSolomonError, NoDataFoundError, ModeMismatchError
- DCT decode now raises InvalidMagicBytesError for wrong magic bytes
- DCT decode now raises ReedSolomonError (renamed from reedsolo's) for corruption
- app.py catches specific exceptions with user-friendly messages:
- Invalid magic → "Try a different mode (LSB/DCT)"
- RS error → "Image too corrupted, may have been re-saved"
- Invalid header → "Image may have been modified"
- Decryption error → "Wrong credentials"
### Files Modified
- `src/stegasoo/exceptions.py` (new exceptions)
- `src/stegasoo/__init__.py` (exports)
- `src/stegasoo/dct_steganography.py` (raise specific exceptions)
- `frontends/web/app.py` (catch and display)
---
## 3. Mobile-Responsive Polish
**Status:** Done
**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:** Done
**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.
### Implementation
- Added `@app.before_request` hook that redirects to /setup if no users exist
- Skips redirect for static files and setup-related routes
### Files Modified
- `frontends/web/app.py` (added require_setup before_request hook)
---
## 5. Dropzone UX Fixes
**Status:** Done
**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
### Implementation
- Added click handler on preview images to trigger file input
- Made entire drop zone clickable (not just label)
- QR zone now resets after 2 seconds on error, allowing retry
- Clear file input on QR error so same file can be re-selected
### Files Modified
- `frontends/web/static/js/stegasoo.js`
---
## 6. Smoke Test Benchmarking
**Status:** Done
**Problem:** No way to measure encode/decode performance or track regressions.
**Solution:** Add timing to smoke tests using `hyperfine` or `time`.
### Implementation
- Added `--benchmark` flag to run encode/decode benchmarks after tests
- Added `--runs=N` flag to customize number of benchmark runs (default: 5)
- Uses hyperfine if available for precise timing with warmup
- Falls back to manual timing with bc if hyperfine not installed
- Outputs min/max/avg stats for both encode and decode operations
### Files Modified
- `tests/smoke-test.sh`
---
## 7. Docker Cleanup
**Status:** Done (4.1.1)
**Problem:** Docker build context is larger than needed (includes test images, rpi scripts, etc.)
**Solution:** Added `.dockerignore` and fixed volume permissions in Dockerfile
### Files Modified
- `.dockerignore` (created)
- `Dockerfile` (instance dir permissions)
---
## 8. Release Validation Script
**Status:** Done
**Problem:** Manual release checklist is error-prone. Need automated validation.
**Solution:** Script that runs through testable checklist items
### Features
- Run pytest
- Build and test Docker image
- SSH to Pi and run smoke test (optional, if PI_IP provided)
- Report pass/fail summary
### Files to Create
- `scripts/validate-release.sh`
---
## 9. Smoke Test Docker Support
**Status:** Done
**Problem:** Smoke test expects systemd service, doesn't auto-create admin for Docker.
**Solution:** Make smoke test Docker-aware
### Features
- Skip systemd checks if not on Pi/Linux with systemd
- Auto-detect fresh Docker (no users) and create admin via /setup
- Add `--docker` flag to skip Pi-specific checks
### Implementation
- Added `--docker` flag that sets localhost and skips SSH/systemd checks
- Docker health check verifies container responds with HTTP 200/302
- Header shows "Docker Smoke Test" in Docker mode
### Files Modified
- `rpi/smoke-test.sh`
---
## Notes
- Keep 4.1.2 focused - 9 features (9 done)
- 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

281
README.md
View File

@@ -2,225 +2,152 @@
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
- 📁 **File embedding** - Hide any file type (PDF, ZIP, documents)
- 📱 **QR code support** - Encode/decode RSA keys via QR codes
- **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
## Embedding Modes
## WebUI Preview
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|------|------------------|----------------|----------|
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
| **LSB** | ~750 KB | No | Email, direct file transfer |
Front Page | Encode | Decode | Generate |
:-------------------------:|:-------------------------:|:------------------------:|:--------:|
![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI.webp) | ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI_Encode.webp) | ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI_Decode.webp) | ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI_Generate.webp)
## Web UI
| Home | Encode | Decode | Generate |
|:----:|:------:|:------:|:--------:|
| ![Home](data/WebUI.webp) | ![Encode](data/WebUI_Encode.webp) | ![Decode](data/WebUI_Decode.webp) | ![Generate](data/WebUI_Generate.webp) |
## Installation
### From Source
## Quick Start
```bash
# Clone the repository
git clone https://github.com/adlee-was-taken/stegasoo.git
cd stegasoo
# Install core library
pip install -e .
# Install with CLI
pip install -e ".[cli]"
# Install with Web UI
pip install -e ".[web]"
# Install with REST API
pip install -e ".[api]"
# Install everything
# Install (Python 3.10-3.12)
pip install -e ".[all]"
```
### CLI Usage
```bash
# Generate credentials
stegasoo generate --pin --words 3
stegasoo generate --pin --words 4
# With RSA key
stegasoo generate --rsa --rsa-bits 4096 -o mykey.pem -p "secretpassword"
# Encode
# Encode a message
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-12 words) | ~33-100+ bits | Something you know (rotates daily) |
| PIN (6-9 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/
```
## Docker
```bash
# Quick start
docker-compose up -d
# Access
# Web UI: http://localhost:5000
# REST API: http://localhost:8000
```
See [DOCKER.md](DOCKER.md) for full documentation.
## Raspberry Pi
Pre-built SD card images available for Pi 4/5:
```bash
# Flash image (download from GitHub Releases)
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
# First boot runs interactive setup wizard:
# - WiFi configuration
# - HTTPS with port 443
# - Channel key generation
# - Optional overclocking
```
See [rpi/README.md](rpi/README.md) for manual installation.
## Documentation
- [INSTALL.md](INSTALL.md) - Installation guide
- [DOCKER.md](DOCKER.md) - Docker deployment
- [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.*

93
RELEASE-4.1.1.md Normal file
View File

@@ -0,0 +1,93 @@
# Stegasoo 4.1.1 Release Notes
**Release Date:** January 5, 2026
## Highlights
- **Reed-Solomon Error Correction** - DCT steganography now includes RS error correction, making encoded images more resilient to minor corruption and compression artifacts
- **Completely Rewritten Pi Setup** - Fresh install tested and validated, works reliably from scratch
- **SSH Login Banner** - See your Stegasoo URL immediately on SSH login
## New Features
### Reed-Solomon Error Correction
DCT-encoded images now include Reed-Solomon error correction codes, allowing recovery from minor image corruption. This significantly improves reliability when images are shared through platforms that may slightly modify them.
### SSH Login Banner (MOTD)
When you SSH into your Stegasoo Pi, you'll now see:
```
___ _____ ___ ___ _ ___ ___ ___
/ __||_ _|| __| / __| /_\ / __| / _ \ / _ \
\__ \ | | | _| | (_ | / _ \ \__ \ | (_) || (_) |
|___/ |_| |___| \___//_/ \_\|___/ \___/ \___/
● Stegasoo is running
https://192.168.0.4
```
### Elapsed Time Counter
Encode/decode buttons now show elapsed time during operations.
### Click-to-Copy Decoded Message
Click the decoded message box to copy to clipboard (no button needed).
### Overclock Wizard Option
First-boot wizard now offers optional CPU overclocking for Pi 4/5 with active cooling.
## Improvements
### Setup Script (setup.sh)
- Fixed pyenv Python path resolution (handles 3.12 → 3.12.12 mapping)
- Changed default install location to `/opt/stegasoo`
- Fixed jpegio build order (clone stegasoo first, then build jpegio into venv)
- Added python3-dev to dependencies
- Added btop for system monitoring
- Shows `/setup` URL at completion for admin account creation
### Sanitize Script
- Now clears port 443 iptables redirect (clean slate for wizard)
- Removes overclock settings before imaging
### Documentation
- Updated all docs to reference `/opt/stegasoo` path
- Added pre-setup steps (chown /opt, install git)
- Added Pi 4 performance baseline (~60s for 10MB JPEG)
### About Page
- Redesigned "Limits & Specs" section with key stats cards and accordion
## Bug Fixes
- Fixed DCT steganography for non-8-aligned images
- Fixed MOTD port detection (was using iptables which requires root)
- Fixed smoke test `--443` flag parsing
## Performance
On a Raspberry Pi 4 at 2GHz with USB 3.0 NVMe:
- ~50 seconds to encode a 10MB JPEG
- ~60 seconds to decode a 10MB JPEG
- Full encryption: passphrase + PIN + reference photo
## Upgrade Notes
If upgrading from 4.1.0:
```bash
cd /opt/stegasoo # or ~/stegasoo
git pull origin 4.1
```
For fresh installs, see the [Pi README](rpi/README.md).
## Pre-built Images
- `stegasoo-rpi-4.1.1_20260105-2.img.zst` - Raspberry Pi 4/5 image
Flash with:
```bash
zstdcat stegasoo-rpi-4.1.1_20260105-2.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
```
---
Full changelog: [v4.1.0...v4.1.1](https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.1)

44
RELEASE_CHECKLIST.md Normal file
View File

@@ -0,0 +1,44 @@
# Stegasoo Release Checklist
Pre-release validation checklist. Complete all items before tagging a release.
## Code Quality
- [ ] All tests pass: `./venv/bin/pytest tests/ -v`
- [ ] No lint errors: `./venv/bin/ruff check src/`
- [ ] Version bumped in `pyproject.toml`
- [ ] CHANGELOG.md updated
## Pi Image Validation
- [ ] Fresh Pi OS install with setup.sh works
- [ ] First-boot wizard completes successfully
- [ ] MOTD shows correct URL on SSH login
- [ ] Smoke test passes: `./rpi/smoke-test.sh --443 <PI_IP>`
- [ ] Encode/decode works on large image (10MB+)
- [ ] Sanitize script runs cleanly
- [ ] Image created and compressed
## Docker Validation
- [ ] Base image builds: `docker build -f Dockerfile.base -t stegasoo-base:latest .`
- [ ] Web image builds: `docker-compose build web`
- [ ] Container starts: `docker-compose up -d web`
- [ ] Web UI accessible at http://localhost:5000
- [ ] Encode/decode works in container
- [ ] Container stops cleanly: `docker-compose down`
## Release Process
- [ ] Merge feature branch to main
- [ ] Create annotated tag: `git tag -a vX.Y.Z -m "message"`
- [ ] Push tag: `git push origin vX.Y.Z`
- [ ] Create GitHub Release with release notes
- [ ] Upload Pi image (.img.zst.zip)
- [ ] Verify download links work
## Post-Release
- [ ] Delete old/obsolete releases if needed
- [ ] Update any external documentation
- [ ] Announce release (if applicable)

View File

@@ -2,10 +2,12 @@
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 2.x.x | :white_check_mark: |
| 1.x.x | :x: |
| 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
@@ -34,7 +36,7 @@ Stegasoo is designed to hide the **existence** of a secret message within an ord
| Goal | How It's Achieved |
|------|-------------------|
| **Confidentiality** | AES-256-GCM encryption with Argon2id key derivation |
| **Steganography** | LSB embedding with pseudo-random pixel selection |
| **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 |
@@ -43,20 +45,43 @@ Stegasoo is designed to hide the **existence** of a secret message within an ord
Stegasoo combines multiple authentication factors:
```
┌─────────────────────────────────────────────────────────────┐
│ Key Derivation │
│ │
│ Reference Photo ─────
│ (something you have)
│ ├──► Argon2id ──► AES-256 Key │
Day Passphrase ──────┤ (256MB RAM) │
│ (something you know)
│ PIN or RSA Key ──────
│ (second factor) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────
│ 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
@@ -68,7 +93,9 @@ Stegasoo combines multiple authentication factors:
- RS analysis
- Machine learning classifiers
**Mitigation:** Stegasoo uses pseudo-random pixel selection (not sequential), which helps but doesn't eliminate detectability.
**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
@@ -113,24 +140,28 @@ Stegasoo combines multiple authentication factors:
**Recommendation:**
- Use 8+ digit PINs
- Use 4+ word passphrases
- Use 4+ word passphrases (v4.0 default)
- Consider RSA keys for high-security use cases
### 5. Image Modification
**Risk:** Lossy compression destroys hidden data.
**Data is destroyed by:**
**LSB mode - data is destroyed by:**
- JPEG compression
- Resizing
- Filters/effects
- Screenshots
- Social media upload (Instagram, Twitter, etc.)
- 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:**
- Always use lossless formats (PNG, BMP)
- Transfer files directly (email, Signal, USB)
- Never upload stego images to social media
- LSB: Always use lossless formats (PNG, BMP), direct transfer
- DCT: Use for social media, but test with your specific platform
### 6. Metadata Leakage
@@ -165,49 +196,52 @@ Stegasoo combines multiple authentication factors:
| Encryption | AES-256-GCM | 12-byte IV, 16-byte tag |
| Photo Hash | SHA-256 | Full image bytes |
### Pixel Selection
### Pixel/Coefficient Selection
Pixels are selected pseudo-randomly using a key derived from:
Selection key is derived from:
```
pixel_key = SHA256(photo_hash || passphrase || date || pin/rsa_signature)
selection_key = SHA256(photo_hash || passphrase || pin/rsa_signature)
```
This prevents:
- Sequential embedding patterns
- Statistical detection of modified regions
### Format
### Message Format (v4.0)
```
┌──────────────────────────────────────────────────────────────┐
│ Magic (4B) │ Version (1B) │ Date (10B) │ 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) │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────
│ 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)
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. **3-word passphrase** provides reasonable security
3. **PNG format** always for output
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
@@ -216,8 +250,8 @@ This prevents:
|------------|--------|--------|
| 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 |
| Date in header | Reveals when message was encoded | By design (enables day-specific passphrases) |
| 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
@@ -231,6 +265,9 @@ If you're a security researcher interested in auditing Stegasoo, please reach ou
| 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 |

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 37 KiB

BIN
data/WebUI_About.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
data/WebUI_Account.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 32 KiB

BIN
data/WebUI_Login.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
data/WebUI_Setup.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

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

View File

@@ -1,766 +0,0 @@
#!/usr/bin/env python3
"""
Stegasoo Web Frontend
Flask-based web UI for steganography operations.
Supports both text messages and file embedding.
"""
import io
import sys
import time
import secrets
import mimetypes
from pathlib import Path
from datetime import datetime
from PIL import Image
from flask import (
Flask, render_template, request, send_file,
jsonify, flash, redirect, url_for
)
# Add parent to path for development
sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src'))
import stegasoo
from stegasoo import (
encode, decode, generate_credentials,
export_rsa_key_pem, load_rsa_key,
validate_pin, validate_message, validate_image,
validate_rsa_key, validate_security_factors,
validate_file_payload,
get_today_day, generate_filename,
DAY_NAMES, __version__,
StegasooError, DecryptionError, CapacityError,
has_argon2,
FilePayload,
MAX_FILE_PAYLOAD_SIZE,
)
from stegasoo.constants import (
MAX_MESSAGE_SIZE, MIN_PIN_LENGTH, MAX_PIN_LENGTH,
VALID_RSA_SIZES, MAX_FILE_SIZE,
)
# QR Code support
try:
import qrcode
from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M
HAS_QRCODE = True
except ImportError:
HAS_QRCODE = False
# QR Code reading
try:
from pyzbar.pyzbar import decode as pyzbar_decode
HAS_QRCODE_READ = True
except ImportError:
HAS_QRCODE_READ = False
import zlib
import base64
# Import QR utilities
from stegasoo.qr_utils import (
compress_data, decompress_data, auto_decompress,
is_compressed, can_fit_in_qr, needs_compression,
generate_qr_code, read_qr_code, extract_key_from_qr,
has_qr_write, has_qr_read,
QR_MAX_BINARY, COMPRESSION_PREFIX
)
# ============================================================================
# FLASK APP CONFIGURATION
# ============================================================================
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE # 20MB max upload
# Temporary file storage for sharing (file_id -> {data, timestamp, filename})
TEMP_FILES: dict[str, dict] = {}
THUMBNAIL_FILES: dict[str, bytes] = {}
TEMP_FILE_EXPIRY = 300 # 5 minutes
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnail
# ============================================================================
# CONFIGURATION
# ============================================================================
# Override stegasoo limits for larger files
# Note: You might need to modify the stegasoo library itself
# to actually increase these limits in its internal calculations
# Flask upload limit (30MB)
MAX_UPLOAD_SIZE = 30 * 1024 * 1024
# Try to import and override stegasoo constants if possible
try:
# Check current limits
print(f"Current MAX_FILE_SIZE from constants: {MAX_FILE_SIZE}")
print(f"Current MAX_FILE_PAYLOAD_SIZE: {MAX_FILE_PAYLOAD_SIZE}")
DESIRED_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2MB
# Note: You might need to patch the stegasoo module
# if MAX_FILE_PAYLOAD_SIZE is used internally
import stegasoo
if hasattr(stegasoo, 'MAX_FILE_PAYLOAD_SIZE'):
print(f"Overriding MAX_FILE_PAYLOAD_SIZE to {DESIRED_PAYLOAD_SIZE}")
stegasoo.MAX_FILE_PAYLOAD_SIZE = DESIRED_PAYLOAD_SIZE
except Exception as e:
print(f"Could not override stegasoo limits: {e}")
def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes:
"""Generate thumbnail from image data."""
try:
with Image.open(io.BytesIO(image_data)) as img:
# Convert to RGB if necessary
if img.mode in ('RGBA', 'LA', 'P'):
# Create white background for transparent images
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
img = background
elif img.mode != 'RGB':
img = img.convert('RGB')
# Create thumbnail
img.thumbnail(size, Image.Resampling.LANCZOS)
# Save to bytes
buffer = io.BytesIO()
img.save(buffer, format='JPEG', quality=85, optimize=True)
return buffer.getvalue()
except Exception as e:
# Log error but don't crash
print(f"Thumbnail generation error: {e}")
return None
def cleanup_temp_files():
"""Remove expired temporary files."""
now = time.time()
expired = [fid for fid, info in TEMP_FILES.items() if now - info['timestamp'] > TEMP_FILE_EXPIRY]
for fid in expired:
TEMP_FILES.pop(fid, None)
# Also clean up corresponding thumbnail
thumb_id = f"{fid}_thumb"
THUMBNAIL_FILES.pop(thumb_id, None)
def allowed_image(filename: str) -> bool:
"""Check if file has allowed image extension."""
if not filename or '.' not in filename:
return False
ext = filename.rsplit('.', 1)[1].lower()
return ext in {'png', 'jpg', 'jpeg', 'bmp', 'gif'}
def format_size(size_bytes: int) -> str:
"""Format file size for display."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
else:
return f"{size_bytes / (1024 * 1024):.1f} MB"
# ============================================================================
# ROUTES
# ============================================================================
@app.route('/')
def index():
return render_template('index.html')
@app.route('/generate', methods=['GET', 'POST'])
def generate():
if request.method == 'POST':
words_per_phrase = int(request.form.get('words_per_phrase', 3))
use_pin = request.form.get('use_pin') == 'on'
use_rsa = request.form.get('use_rsa') == 'on'
if not use_pin and not use_rsa:
flash('You must select at least one security factor (PIN or RSA Key)', 'error')
return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE)
pin_length = int(request.form.get('pin_length', 6))
rsa_bits = int(request.form.get('rsa_bits', 2048))
# Clamp values
words_per_phrase = max(3, min(12, words_per_phrase))
pin_length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, pin_length))
if rsa_bits not in VALID_RSA_SIZES:
rsa_bits = 2048
try:
creds = generate_credentials(
use_pin=use_pin,
use_rsa=use_rsa,
pin_length=pin_length,
rsa_bits=rsa_bits,
words_per_phrase=words_per_phrase
)
# Store RSA key temporarily for QR generation
qr_token = None
qr_needs_compression = False
qr_too_large = False
if creds.rsa_key_pem and HAS_QRCODE:
# Check if key fits in QR code
if can_fit_in_qr(creds.rsa_key_pem, compress=True):
qr_needs_compression = True
else:
qr_too_large = True
if not qr_too_large:
qr_token = secrets.token_urlsafe(16)
cleanup_temp_files()
TEMP_FILES[qr_token] = {
'data': creds.rsa_key_pem.encode(),
'filename': 'rsa_key.pem',
'timestamp': time.time(),
'type': 'rsa_key',
'compress': qr_needs_compression
}
return render_template('generate.html',
phrases=creds.phrases,
pin=creds.pin,
days=DAY_NAMES,
generated=True,
words_per_phrase=words_per_phrase,
pin_length=pin_length if use_pin else None,
use_pin=use_pin,
use_rsa=use_rsa,
rsa_bits=rsa_bits,
rsa_key_pem=creds.rsa_key_pem,
phrase_entropy=creds.phrase_entropy,
pin_entropy=creds.pin_entropy,
rsa_entropy=creds.rsa_entropy,
total_entropy=creds.total_entropy,
has_qrcode=HAS_QRCODE,
qr_token=qr_token,
qr_needs_compression=qr_needs_compression,
qr_too_large=qr_too_large
)
except Exception as e:
flash(f'Error generating credentials: {e}', 'error')
return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE)
return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE)
@app.route('/generate/qr/<token>')
def generate_qr(token):
"""Generate QR code for RSA key."""
if not HAS_QRCODE:
return "QR code support not available", 501
if token not in TEMP_FILES:
return "Token expired or invalid", 404
file_info = TEMP_FILES[token]
if file_info.get('type') != 'rsa_key':
return "Invalid token type", 400
try:
key_pem = file_info['data'].decode('utf-8')
compress = file_info.get('compress', False)
qr_png = generate_qr_code(key_pem, compress=compress)
return send_file(
io.BytesIO(qr_png),
mimetype='image/png',
as_attachment=False
)
except Exception as e:
return f"Error generating QR code: {e}", 500
@app.route('/generate/qr-download/<token>')
def generate_qr_download(token):
"""Download QR code as PNG file."""
if not HAS_QRCODE:
return "QR code support not available", 501
if token not in TEMP_FILES:
return "Token expired or invalid", 404
file_info = TEMP_FILES[token]
if file_info.get('type') != 'rsa_key':
return "Invalid token type", 400
try:
key_pem = file_info['data'].decode('utf-8')
compress = file_info.get('compress', False)
qr_png = generate_qr_code(key_pem, compress=compress)
return send_file(
io.BytesIO(qr_png),
mimetype='image/png',
as_attachment=True,
download_name='stegasoo_rsa_key_qr.png'
)
except Exception as e:
return f"Error generating QR code: {e}", 500
@app.route('/generate/download-key', methods=['POST'])
def download_key():
"""Download RSA key as password-protected PEM file."""
key_pem = request.form.get('key_pem', '')
password = request.form.get('key_password', '')
if not key_pem:
flash('No key to download', 'error')
return redirect(url_for('generate'))
if not password or len(password) < 8:
flash('Password must be at least 8 characters', 'error')
return redirect(url_for('generate'))
try:
private_key = load_rsa_key(key_pem.encode('utf-8'))
encrypted_pem = export_rsa_key_pem(private_key, password=password)
key_id = secrets.token_hex(4)
filename = f'stegasoo_key_{private_key.key_size}_{key_id}.pem'
return send_file(
io.BytesIO(encrypted_pem),
mimetype='application/x-pem-file',
as_attachment=True,
download_name=filename
)
except Exception as e:
flash(f'Error creating key file: {e}', 'error')
return redirect(url_for('generate'))
@app.route('/extract-key-from-qr', methods=['POST'])
def extract_key_from_qr_route():
"""
Extract RSA key from uploaded QR code image.
Returns JSON with the extracted key or error.
"""
if not HAS_QRCODE_READ:
return jsonify({
'success': False,
'error': 'QR code reading not available. Install pyzbar and libzbar.'
}), 501
qr_image = request.files.get('qr_image')
if not qr_image:
return jsonify({
'success': False,
'error': 'No QR image provided'
}), 400
try:
image_data = qr_image.read()
key_pem = extract_key_from_qr(image_data)
if key_pem:
return jsonify({
'success': True,
'key_pem': key_pem
})
else:
return jsonify({
'success': False,
'error': 'No valid RSA key found in QR code'
}), 400
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/encode', methods=['GET', 'POST'])
def encode_page():
day_of_week = get_today_day()
max_payload_kb = MAX_FILE_PAYLOAD_SIZE // 1024
if request.method == 'POST':
try:
# Get files
ref_photo = request.files.get('reference_photo')
carrier = request.files.get('carrier')
rsa_key_file = request.files.get('rsa_key')
payload_file = request.files.get('payload_file')
if not ref_photo or not carrier:
flash('Both reference photo and carrier image are required', 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename):
flash('Invalid file type. Use PNG, JPG, or BMP', 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Get form data
message = request.form.get('message', '')
day_phrase = request.form.get('day_phrase', '')
pin = request.form.get('pin', '').strip()
rsa_password = request.form.get('rsa_password', '')
payload_type = request.form.get('payload_type', 'text')
# Determine payload
if payload_type == 'file' and payload_file and payload_file.filename:
# File payload
file_data = payload_file.read()
result = validate_file_payload(file_data, payload_file.filename)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
mime_type, _ = mimetypes.guess_type(payload_file.filename)
payload = FilePayload(
data=file_data,
filename=payload_file.filename,
mime_type=mime_type
)
else:
# Text message
result = validate_message(message)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
payload = message
if not day_phrase:
flash('Day phrase is required', 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Read files
ref_data = ref_photo.read()
carrier_data = carrier.read()
# Handle RSA key - can come from .pem file or QR code image
rsa_key_data = None
rsa_key_qr = request.files.get('rsa_key_qr')
rsa_key_from_qr = False # Track source for password handling
if rsa_key_file and rsa_key_file.filename:
# RSA key from .pem file
rsa_key_data = rsa_key_file.read()
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
# RSA key from QR code image
qr_image_data = rsa_key_qr.read()
key_pem = extract_key_from_qr(qr_image_data)
if key_pem:
rsa_key_data = key_pem.encode('utf-8')
rsa_key_from_qr = True # QR keys are never password-protected
else:
flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Validate security factors
result = validate_security_factors(pin, rsa_key_data)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Validate PIN if provided
if pin:
result = validate_pin(pin)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Determine key password - QR code keys are never password-protected
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
# Validate RSA key if provided
if rsa_key_data:
result = validate_rsa_key(rsa_key_data, key_password)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Validate carrier image
result = validate_image(carrier_data, "Carrier image")
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Get date
client_date = request.form.get('client_date', '').strip()
if client_date and len(client_date) == 10 and client_date[4] == '-' and client_date[7] == '-':
date_str = client_date
else:
date_str = datetime.now().strftime('%Y-%m-%d')
# Encode
encode_result = encode(
message=payload,
reference_photo=ref_data,
carrier_image=carrier_data,
day_phrase=day_phrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=key_password,
date_str=date_str
)
# Store temporarily
file_id = secrets.token_urlsafe(16)
cleanup_temp_files()
TEMP_FILES[file_id] = {
'data': encode_result.stego_image,
'filename': encode_result.filename,
'timestamp': time.time()
}
return redirect(url_for('encode_result', file_id=file_id))
except CapacityError as e:
flash(str(e), 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
except StegasooError as e:
flash(str(e), 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
except Exception as e:
flash(f'Error: {e}', 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
@app.route('/encode/result/<file_id>')
def encode_result(file_id):
if file_id not in TEMP_FILES:
flash('File expired or not found. Please encode again.', 'error')
return redirect(url_for('encode_page'))
file_info = TEMP_FILES[file_id]
# Generate thumbnail
thumbnail_data = generate_thumbnail(file_info['data'])
thumbnail_id = None
if thumbnail_data:
thumbnail_id = f"{file_id}_thumb"
THUMBNAIL_FILES[thumbnail_id] = thumbnail_data
return render_template('encode_result.html',
file_id=file_id,
filename=file_info['filename'],
thumbnail_url=url_for('encode_thumbnail', thumb_id=thumbnail_id) if thumbnail_id else None
)
@app.route('/encode/thumbnail/<thumb_id>')
def encode_thumbnail(thumb_id):
"""Serve thumbnail image."""
if thumb_id not in THUMBNAIL_FILES:
return "Thumbnail not found", 404
return send_file(
io.BytesIO(THUMBNAIL_FILES[thumb_id]),
mimetype='image/jpeg',
as_attachment=False
)
@app.route('/encode/download/<file_id>')
def encode_download(file_id):
if file_id not in TEMP_FILES:
flash('File expired or not found.', 'error')
return redirect(url_for('encode_page'))
file_info = TEMP_FILES[file_id]
return send_file(
io.BytesIO(file_info['data']),
mimetype='image/png',
as_attachment=True,
download_name=file_info['filename']
)
@app.route('/encode/file/<file_id>')
def encode_file_route(file_id):
"""Serve file for Web Share API."""
if file_id not in TEMP_FILES:
return "Not found", 404
file_info = TEMP_FILES[file_id]
return send_file(
io.BytesIO(file_info['data']),
mimetype='image/png',
as_attachment=False,
download_name=file_info['filename']
)
@app.route('/encode/cleanup/<file_id>', methods=['POST'])
def encode_cleanup(file_id):
"""Manually cleanup a file after sharing."""
TEMP_FILES.pop(file_id, None)
# Also cleanup thumbnail if exists
thumb_id = f"{file_id}_thumb"
THUMBNAIL_FILES.pop(thumb_id, None)
return jsonify({'status': 'ok'})
@app.route('/decode', methods=['GET', 'POST'])
def decode_page():
if request.method == 'POST':
try:
# Get files
ref_photo = request.files.get('reference_photo')
stego_image = request.files.get('stego_image')
rsa_key_file = request.files.get('rsa_key')
if not ref_photo or not stego_image:
flash('Both reference photo and stego image are required', 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Get form data
day_phrase = request.form.get('day_phrase', '')
pin = request.form.get('pin', '').strip()
rsa_password = request.form.get('rsa_password', '')
if not day_phrase:
flash('Day phrase is required', 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Read files
ref_data = ref_photo.read()
stego_data = stego_image.read()
# Handle RSA key - can come from .pem file or QR code image
rsa_key_data = None
rsa_key_qr = request.files.get('rsa_key_qr')
rsa_key_from_qr = False # Track source for password handling
if rsa_key_file and rsa_key_file.filename:
# RSA key from .pem file
rsa_key_data = rsa_key_file.read()
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
# RSA key from QR code image
qr_image_data = rsa_key_qr.read()
key_pem = extract_key_from_qr(qr_image_data)
if key_pem:
rsa_key_data = key_pem.encode('utf-8')
rsa_key_from_qr = True # QR keys are never password-protected
else:
flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Validate security factors
result = validate_security_factors(pin, rsa_key_data)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Validate PIN if provided
if pin:
result = validate_pin(pin)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Determine key password - QR code keys are never password-protected
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
# Validate RSA key if provided
if rsa_key_data:
result = validate_rsa_key(rsa_key_data, key_password)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Decode
decode_result = decode(
stego_image=stego_data,
reference_photo=ref_data,
day_phrase=day_phrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=key_password
)
if decode_result.is_file:
# File content - store temporarily for download
file_id = secrets.token_urlsafe(16)
cleanup_temp_files()
filename = decode_result.filename or 'decoded_file'
TEMP_FILES[file_id] = {
'data': decode_result.file_data,
'filename': filename,
'mime_type': decode_result.mime_type,
'timestamp': time.time()
}
return render_template('decode.html',
decoded_file=True,
file_id=file_id,
filename=filename,
file_size=format_size(len(decode_result.file_data)),
mime_type=decode_result.mime_type
)
else:
# Text content
return render_template('decode.html', decoded_message=decode_result.message)
except DecryptionError:
flash('Decryption failed. Check your phrase, PIN, RSA key, and reference photo.', 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
except StegasooError as e:
flash(str(e), 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
except Exception as e:
flash(f'Error: {e}', 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
@app.route('/decode/download/<file_id>')
def decode_download(file_id):
"""Download decoded file."""
if file_id not in TEMP_FILES:
flash('File expired or not found.', 'error')
return redirect(url_for('decode_page'))
file_info = TEMP_FILES[file_id]
mime_type = file_info.get('mime_type', 'application/octet-stream')
return send_file(
io.BytesIO(file_info['data']),
mimetype=mime_type,
as_attachment=True,
download_name=file_info['filename']
)
@app.route('/about')
def about():
return render_template('about.html',
has_argon2=has_argon2(),
has_qrcode_read=HAS_QRCODE_READ,
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024
)
# ============================================================================
# MAIN
# ============================================================================
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False)

View File

@@ -1,781 +0,0 @@
#!/usr/bin/env python3
"""
Stegasoo Web Frontend
Flask-based web UI for steganography operations.
Supports both text messages and file embedding.
"""
import io
import sys
import time
import secrets
import mimetypes
from pathlib import Path
from datetime import datetime
from PIL import Image
from flask import (
Flask, render_template, request, send_file,
jsonify, flash, redirect, url_for
)
# Add parent to path for development
sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src'))
import stegasoo
from stegasoo import (
encode, decode, generate_credentials,
export_rsa_key_pem, load_rsa_key,
validate_pin, validate_message, validate_image,
validate_rsa_key, validate_security_factors,
validate_file_payload,
get_today_day, generate_filename,
DAY_NAMES, __version__,
StegasooError, DecryptionError, CapacityError,
has_argon2,
FilePayload,
MAX_FILE_PAYLOAD_SIZE,
)
from stegasoo.constants import (
MAX_MESSAGE_SIZE, MIN_PIN_LENGTH, MAX_PIN_LENGTH,
VALID_RSA_SIZES, MAX_FILE_SIZE,
)
# QR Code support
try:
import qrcode
from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M
HAS_QRCODE = True
except ImportError:
HAS_QRCODE = False
# QR Code reading
try:
from pyzbar.pyzbar import decode as pyzbar_decode
HAS_QRCODE_READ = True
except ImportError:
HAS_QRCODE_READ = False
import zlib
import base64
# Import QR utilities
from stegasoo.qr_utils import (
compress_data, decompress_data, auto_decompress,
is_compressed, can_fit_in_qr, needs_compression,
generate_qr_code, read_qr_code, extract_key_from_qr,
has_qr_write, has_qr_read,
QR_MAX_BINARY, COMPRESSION_PREFIX
)
# ============================================================================
# FLASK APP CONFIGURATION
# ============================================================================
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE # 20MB max upload
# Temporary file storage for sharing (file_id -> {data, timestamp, filename})
TEMP_FILES: dict[str, dict] = {}
THUMBNAIL_FILES: dict[str, bytes] = {}
TEMP_FILE_EXPIRY = 300 # 5 minutes
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnail
# ============================================================================
# CONFIGURATION
# ============================================================================
# Override stegasoo limits for larger files
# Note: You might need to modify the stegasoo library itself
# to actually increase these limits in its internal calculations
# Flask upload limit (30MB)
MAX_UPLOAD_SIZE = 30 * 1024 * 1024
# Try to import and override stegasoo constants if possible
try:
# Check current limits
print(f"Current MAX_FILE_SIZE from constants: {MAX_FILE_SIZE}")
print(f"Current MAX_FILE_PAYLOAD_SIZE: {MAX_FILE_PAYLOAD_SIZE}")
DESIRED_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2MB
# Note: You might need to patch the stegasoo module
# if MAX_FILE_PAYLOAD_SIZE is used internally
import stegasoo
if hasattr(stegasoo, 'MAX_FILE_PAYLOAD_SIZE'):
print(f"Overriding MAX_FILE_PAYLOAD_SIZE to {DESIRED_PAYLOAD_SIZE}")
stegasoo.MAX_FILE_PAYLOAD_SIZE = DESIRED_PAYLOAD_SIZE
except Exception as e:
print(f"Could not override stegasoo limits: {e}")
def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes:
"""Generate thumbnail from image data."""
try:
with Image.open(io.BytesIO(image_data)) as img:
# Convert to RGB if necessary
if img.mode in ('RGBA', 'LA', 'P'):
# Create white background for transparent images
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
img = background
elif img.mode != 'RGB':
img = img.convert('RGB')
# Create thumbnail
img.thumbnail(size, Image.Resampling.LANCZOS)
# Save to bytes
buffer = io.BytesIO()
img.save(buffer, format='JPEG', quality=85, optimize=True)
return buffer.getvalue()
except Exception as e:
# Log error but don't crash
print(f"Thumbnail generation error: {e}")
return None
def cleanup_temp_files():
"""Remove expired temporary files."""
now = time.time()
expired = [fid for fid, info in TEMP_FILES.items() if now - info['timestamp'] > TEMP_FILE_EXPIRY]
for fid in expired:
TEMP_FILES.pop(fid, None)
# Also clean up corresponding thumbnail
thumb_id = f"{fid}_thumb"
THUMBNAIL_FILES.pop(thumb_id, None)
def allowed_image(filename: str) -> bool:
"""Check if file has allowed image extension."""
if not filename or '.' not in filename:
return False
ext = filename.rsplit('.', 1)[1].lower()
return ext in {'png', 'jpg', 'jpeg', 'bmp', 'gif'}
def format_size(size_bytes: int) -> str:
"""Format file size for display."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
else:
return f"{size_bytes / (1024 * 1024):.1f} MB"
# ============================================================================
# ROUTES
# ============================================================================
@app.route('/')
def index():
return render_template('index.html')
@app.route('/generate', methods=['GET', 'POST'])
def generate():
if request.method == 'POST':
words_per_phrase = int(request.form.get('words_per_phrase', 3))
use_pin = request.form.get('use_pin') == 'on'
use_rsa = request.form.get('use_rsa') == 'on'
if not use_pin and not use_rsa:
flash('You must select at least one security factor (PIN or RSA Key)', 'error')
return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE)
pin_length = int(request.form.get('pin_length', 6))
rsa_bits = int(request.form.get('rsa_bits', 2048))
# Clamp values
words_per_phrase = max(3, min(12, words_per_phrase))
pin_length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, pin_length))
if rsa_bits not in VALID_RSA_SIZES:
rsa_bits = 2048
try:
creds = generate_credentials(
use_pin=use_pin,
use_rsa=use_rsa,
pin_length=pin_length,
rsa_bits=rsa_bits,
words_per_phrase=words_per_phrase
)
# Store RSA key temporarily for QR generation
qr_token = None
qr_needs_compression = False
qr_too_large = False
if creds.rsa_key_pem and HAS_QRCODE:
# Check if key fits in QR code
if can_fit_in_qr(creds.rsa_key_pem, compress=True):
qr_needs_compression = True
else:
qr_too_large = True
if not qr_too_large:
qr_token = secrets.token_urlsafe(16)
cleanup_temp_files()
TEMP_FILES[qr_token] = {
'data': creds.rsa_key_pem.encode(),
'filename': 'rsa_key.pem',
'timestamp': time.time(),
'type': 'rsa_key',
'compress': qr_needs_compression
}
return render_template('generate.html',
phrases=creds.phrases,
pin=creds.pin,
days=DAY_NAMES,
generated=True,
words_per_phrase=words_per_phrase,
pin_length=pin_length if use_pin else None,
use_pin=use_pin,
use_rsa=use_rsa,
rsa_bits=rsa_bits,
rsa_key_pem=creds.rsa_key_pem,
phrase_entropy=creds.phrase_entropy,
pin_entropy=creds.pin_entropy,
rsa_entropy=creds.rsa_entropy,
total_entropy=creds.total_entropy,
has_qrcode=HAS_QRCODE,
qr_token=qr_token,
qr_needs_compression=qr_needs_compression,
qr_too_large=qr_too_large
)
except Exception as e:
flash(f'Error generating credentials: {e}', 'error')
return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE)
return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE)
@app.route('/generate/qr/<token>')
def generate_qr(token):
"""Generate QR code for RSA key."""
if not HAS_QRCODE:
return "QR code support not available", 501
if token not in TEMP_FILES:
return "Token expired or invalid", 404
file_info = TEMP_FILES[token]
if file_info.get('type') != 'rsa_key':
return "Invalid token type", 400
try:
key_pem = file_info['data'].decode('utf-8')
compress = file_info.get('compress', False)
qr_png = generate_qr_code(key_pem, compress=compress)
return send_file(
io.BytesIO(qr_png),
mimetype='image/png',
as_attachment=False
)
except Exception as e:
return f"Error generating QR code: {e}", 500
@app.route('/generate/qr-download/<token>')
def generate_qr_download(token):
"""Download QR code as PNG file."""
if not HAS_QRCODE:
return "QR code support not available", 501
if token not in TEMP_FILES:
return "Token expired or invalid", 404
file_info = TEMP_FILES[token]
if file_info.get('type') != 'rsa_key':
return "Invalid token type", 400
try:
key_pem = file_info['data'].decode('utf-8')
compress = file_info.get('compress', False)
qr_png = generate_qr_code(key_pem, compress=compress)
return send_file(
io.BytesIO(qr_png),
mimetype='image/png',
as_attachment=True,
download_name='stegasoo_rsa_key_qr.png'
)
except Exception as e:
return f"Error generating QR code: {e}", 500
@app.route('/generate/download-key', methods=['POST'])
def download_key():
"""Download RSA key as password-protected PEM file."""
key_pem = request.form.get('key_pem', '')
password = request.form.get('key_password', '')
if not key_pem:
flash('No key to download', 'error')
return redirect(url_for('generate'))
if not password or len(password) < 8:
flash('Password must be at least 8 characters', 'error')
return redirect(url_for('generate'))
try:
private_key = load_rsa_key(key_pem.encode('utf-8'))
encrypted_pem = export_rsa_key_pem(private_key, password=password)
key_id = secrets.token_hex(4)
filename = f'stegasoo_key_{private_key.key_size}_{key_id}.pem'
return send_file(
io.BytesIO(encrypted_pem),
mimetype='application/x-pem-file',
as_attachment=True,
download_name=filename
)
except Exception as e:
flash(f'Error creating key file: {e}', 'error')
return redirect(url_for('generate'))
@app.route('/extract-key-from-qr', methods=['POST'])
def extract_key_from_qr_route():
"""
Extract RSA key from uploaded QR code image.
Returns JSON with the extracted key or error.
"""
if not HAS_QRCODE_READ:
return jsonify({
'success': False,
'error': 'QR code reading not available. Install pyzbar and libzbar.'
}), 501
qr_image = request.files.get('qr_image')
if not qr_image:
return jsonify({
'success': False,
'error': 'No QR image provided'
}), 400
try:
image_data = qr_image.read()
key_pem = extract_key_from_qr(image_data)
if key_pem:
return jsonify({
'success': True,
'key_pem': key_pem
})
else:
return jsonify({
'success': False,
'error': 'No valid RSA key found in QR code'
}), 400
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/encode', methods=['GET', 'POST'])
def encode_page():
day_of_week = get_today_day()
max_payload_kb = MAX_FILE_PAYLOAD_SIZE // 1024
if request.method == 'POST':
try:
# Get files
ref_photo = request.files.get('reference_photo')
carrier = request.files.get('carrier')
rsa_key_file = request.files.get('rsa_key')
payload_file = request.files.get('payload_file')
if not ref_photo or not carrier:
flash('Both reference photo and carrier image are required', 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename):
flash('Invalid file type. Use PNG, JPG, or BMP', 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Get form data
message = request.form.get('message', '')
day_phrase = request.form.get('day_phrase', '')
pin = request.form.get('pin', '').strip()
rsa_password = request.form.get('rsa_password', '')
payload_type = request.form.get('payload_type', 'text')
# Determine payload
if payload_type == 'file' and payload_file and payload_file.filename:
# File payload
file_data = payload_file.read()
result = validate_file_payload(file_data, payload_file.filename)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
mime_type, _ = mimetypes.guess_type(payload_file.filename)
payload = FilePayload(
data=file_data,
filename=payload_file.filename,
mime_type=mime_type
)
else:
# Text message
result = validate_message(message)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
payload = message
if not day_phrase:
flash('Day phrase is required', 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Read files
ref_data = ref_photo.read()
carrier_data = carrier.read()
# Handle RSA key - can come from .pem file or QR code image
rsa_key_data = None
rsa_key_qr = request.files.get('rsa_key_qr')
rsa_key_from_qr = False # Track source for password handling
if rsa_key_file and rsa_key_file.filename:
# RSA key from .pem file
rsa_key_data = rsa_key_file.read()
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
# RSA key from QR code image
qr_image_data = rsa_key_qr.read()
key_pem = extract_key_from_qr(qr_image_data)
if key_pem:
rsa_key_data = key_pem.encode('utf-8')
rsa_key_from_qr = True # QR keys are never password-protected
else:
flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Validate security factors
result = validate_security_factors(pin, rsa_key_data)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Validate PIN if provided
if pin:
result = validate_pin(pin)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Determine key password - QR code keys are never password-protected
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
# Validate RSA key if provided
if rsa_key_data:
result = validate_rsa_key(rsa_key_data, key_password)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Validate carrier image
result = validate_image(carrier_data, "Carrier image")
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Get date
client_date = request.form.get('client_date', '').strip()
if client_date and len(client_date) == 10 and client_date[4] == '-' and client_date[7] == '-':
date_str = client_date
else:
date_str = datetime.now().strftime('%Y-%m-%d')
# Encode
encode_result = encode(
message=payload,
reference_photo=ref_data,
carrier_image=carrier_data,
day_phrase=day_phrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=key_password,
date_str=date_str
)
# Store temporarily
file_id = secrets.token_urlsafe(16)
cleanup_temp_files()
TEMP_FILES[file_id] = {
'data': encode_result.stego_image,
'filename': encode_result.filename,
'timestamp': time.time()
}
return redirect(url_for('encode_result', file_id=file_id))
except CapacityError as e:
flash(str(e), 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
except StegasooError as e:
flash(str(e), 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
except Exception as e:
flash(f'Error: {e}', 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
@app.route('/encode/result/<file_id>')
def encode_result(file_id):
if file_id not in TEMP_FILES:
flash('File expired or not found. Please encode again.', 'error')
return redirect(url_for('encode_page'))
file_info = TEMP_FILES[file_id]
# Generate thumbnail
thumbnail_data = generate_thumbnail(file_info['data'])
thumbnail_id = None
if thumbnail_data:
thumbnail_id = f"{file_id}_thumb"
THUMBNAIL_FILES[thumbnail_id] = thumbnail_data
return render_template('encode_result.html',
file_id=file_id,
filename=file_info['filename'],
thumbnail_url=url_for('encode_thumbnail', thumb_id=thumbnail_id) if thumbnail_id else None
)
@app.route('/encode/thumbnail/<thumb_id>')
def encode_thumbnail(thumb_id):
"""Serve thumbnail image."""
if thumb_id not in THUMBNAIL_FILES:
return "Thumbnail not found", 404
return send_file(
io.BytesIO(THUMBNAIL_FILES[thumb_id]),
mimetype='image/jpeg',
as_attachment=False
)
@app.route('/encode/download/<file_id>')
def encode_download(file_id):
if file_id not in TEMP_FILES:
flash('File expired or not found.', 'error')
return redirect(url_for('encode_page'))
file_info = TEMP_FILES[file_id]
return send_file(
io.BytesIO(file_info['data']),
mimetype='image/png',
as_attachment=True,
download_name=file_info['filename']
)
@app.route('/encode/file/<file_id>')
def encode_file_route(file_id):
"""Serve file for Web Share API."""
if file_id not in TEMP_FILES:
return "Not found", 404
file_info = TEMP_FILES[file_id]
return send_file(
io.BytesIO(file_info['data']),
mimetype='image/png',
as_attachment=False,
download_name=file_info['filename']
)
@app.route('/encode/cleanup/<file_id>', methods=['POST'])
def encode_cleanup(file_id):
"""Manually cleanup a file after sharing."""
TEMP_FILES.pop(file_id, None)
# Also cleanup thumbnail if exists
thumb_id = f"{file_id}_thumb"
THUMBNAIL_FILES.pop(thumb_id, None)
return jsonify({'status': 'ok'})
@app.route('/decode', methods=['GET', 'POST'])
def decode_page():
if request.method == 'POST':
try:
# Get files
ref_photo = request.files.get('reference_photo')
stego_image = request.files.get('stego_image')
rsa_key_file = request.files.get('rsa_key')
if not ref_photo or not stego_image:
flash('Both reference photo and stego image are required', 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Get form data
day_phrase = request.form.get('day_phrase', '')
pin = request.form.get('pin', '').strip()
rsa_password = request.form.get('rsa_password', '')
# Get encoding date from form (detected from filename in JS)
stego_date = request.form.get('stego_date', '').strip()
if not day_phrase:
flash('Day phrase is required', 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Read files
ref_data = ref_photo.read()
stego_data = stego_image.read()
# Handle RSA key - can come from .pem file or QR code image
rsa_key_data = None
rsa_key_qr = request.files.get('rsa_key_qr')
rsa_key_from_qr = False # Track source for password handling
if rsa_key_file and rsa_key_file.filename:
# RSA key from .pem file
rsa_key_data = rsa_key_file.read()
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
# RSA key from QR code image
qr_image_data = rsa_key_qr.read()
key_pem = extract_key_from_qr(qr_image_data)
if key_pem:
rsa_key_data = key_pem.encode('utf-8')
rsa_key_from_qr = True # QR keys are never password-protected
else:
flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Validate security factors
result = validate_security_factors(pin, rsa_key_data)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Validate PIN if provided
if pin:
result = validate_pin(pin)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Determine key password - QR code keys are never password-protected
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
# Validate RSA key if provided
if rsa_key_data:
result = validate_rsa_key(rsa_key_data, key_password)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
with open('/tmp/debug_stego.png', 'wb') as f:
f.write(stego_data)
with open('/tmp/debug_ref.png', 'wb') as f:
f.write(ref_data)
with open('/tmp/debug_params.txt', 'w') as f:
f.write(f"day_phrase: {day_phrase}\n")
f.write(f"pin: {pin}\n")
f.write(f"date_str: {stego_date}\n")
f.write(f"rsa_key: {len(rsa_key_data) if rsa_key_data else None}\n")
print(f"DEBUG: Saved inputs to /tmp/debug_*")
# Decode
decode_result = decode(
stego_image=stego_data,
reference_photo=ref_data,
day_phrase=day_phrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=key_password,
date_str=stego_date if stego_date else None
)
if decode_result.is_file:
# File content - store temporarily for download
file_id = secrets.token_urlsafe(16)
cleanup_temp_files()
filename = decode_result.filename or 'decoded_file'
TEMP_FILES[file_id] = {
'data': decode_result.file_data,
'filename': filename,
'mime_type': decode_result.mime_type,
'timestamp': time.time()
}
return render_template('decode.html',
decoded_file=True,
file_id=file_id,
filename=filename,
file_size=format_size(len(decode_result.file_data)),
mime_type=decode_result.mime_type
)
else:
# Text content
return render_template('decode.html', decoded_message=decode_result.message)
except DecryptionError:
flash('Decryption failed. Check your phrase, PIN, RSA key, and reference photo.', 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
except StegasooError as e:
flash(str(e), 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
except Exception as e:
flash(f'Error: {e}', 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
@app.route('/decode/download/<file_id>')
def decode_download(file_id):
"""Download decoded file."""
if file_id not in TEMP_FILES:
flash('File expired or not found.', 'error')
return redirect(url_for('decode_page'))
file_info = TEMP_FILES[file_id]
mime_type = file_info.get('mime_type', 'application/octet-stream')
return send_file(
io.BytesIO(file_info['data']),
mimetype=mime_type,
as_attachment=True,
download_name=file_info['filename']
)
@app.route('/about')
def about():
return render_template('about.html',
has_argon2=has_argon2(),
has_qrcode_read=HAS_QRCODE_READ,
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024
)
# ============================================================================
# MAIN
# ============================================================================
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False)

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

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,273 @@
#!/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
progress_file=params.get("progress_file"), # v4.1.2
)
# 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,542 @@
"""
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
import tempfile
import uuid
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,
# Progress file (v4.1.2)
progress_file: str | 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
"progress_file": progress_file, # v4.1.2
}
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
# =============================================================================
# Progress File Utilities (v4.1.2)
# =============================================================================
def generate_job_id() -> str:
"""Generate a unique job ID for tracking encode/decode operations."""
return str(uuid.uuid4())[:8]
def get_progress_file_path(job_id: str) -> str:
"""Get the progress file path for a job ID."""
return str(Path(tempfile.gettempdir()) / f"stegasoo_progress_{job_id}.json")
def read_progress(job_id: str) -> dict | None:
"""
Read progress from file for a job ID.
Returns:
Progress dict with current, total, percent, phase, or None if not found
"""
progress_file = get_progress_file_path(job_id)
try:
with open(progress_file) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return None
def cleanup_progress_file(job_id: str) -> None:
"""Remove progress file for a completed job."""
progress_file = get_progress_file_path(job_id)
try:
Path(progress_file).unlink(missing_ok=True)
except Exception:
pass

View File

@@ -11,33 +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>
<br/>Hide messages or any file type (PDF, ZIP, documents)
<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>
<br/>Combines photo + phrase + PIN/RSA key
<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>
<br/>Military-grade authenticated encryption
<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>
<br/>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>
@@ -46,22 +45,28 @@
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Random Pixel Embedding</strong>
<br/>Defeats statistical steganalysis
<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>
<br/>Maintains PNG/BMP lossless formats
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Large Capacity</strong>
<br/>Up to {{ max_payload_kb }} KB payload, 24MP images
<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>Zero Server Storage</strong>
<br/>Nothing saved, files auto-expire and are scrubbed from disk.
<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>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>
@@ -69,259 +74,366 @@
</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</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>
</div>
</div>
<!-- REST API Card - UPDATED BASED ON CURRENT IMPLEMENTATION -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-cpu me-2"></i>REST API</h5>
</div>
<div class="card-body">
<p>
<span class="badge bg-success me-1"><i class="bi bi-check-circle"></i> FastAPI</span>
Stegasoo includes a complete REST API built with FastAPI, featuring automatic documentation,
type validation, and comprehensive error handling.
</p>
<h6 class="mt-4"><i class="bi bi-layers me-2"></i>API Endpoints</h6>
<div class="row">
<div class="col-md-6">
<ul class="small">
<li><code>POST /generate</code> Generate credentials</li>
<li><code>POST /encode</code> Encode text message (JSON)</li>
<li><code>POST /encode/file</code> Encode binary file (JSON)</li>
<li><code>POST /encode/multipart</code> Encode with file uploads</li>
<li><code>POST /decode</code> Decode message (JSON)</li>
</ul>
</div>
<div class="col-md-6">
<ul class="small">
<li><code>POST /decode/multipart</code> Decode with file uploads</li>
<li><code>POST /extract-key-from-qr</code> Extract RSA key from QR</li>
<li><code>POST /image/info</code> Get image capacity</li>
<li><code>GET /</code> API status and capabilities</li>
</ul>
</div>
</div>
<div class="alert alert-info small mt-3">
{% else %}
<div class="alert alert-info mt-3 mb-3">
<i class="bi bi-info-circle me-2"></i>
<strong>Note:</strong> The <code>/encode/multipart</code> endpoint returns the PNG image directly
(with headers indicating metadata), while <code>/decode/multipart</code> returns JSON.
Use <code>--output</code> flag to save responses to files.
This server is running in <strong>public mode</strong>.
Set <code>STEGASOO_CHANNEL_KEY</code> to enable server-wide channel isolation.
</div>
<h6 class="mt-4"><i class="bi bi-file-earmark-code me-2"></i>JSON API Examples</h6>
<pre class="bg-dark p-3 rounded"><code>// Generate credentials
curl -X POST "http://localhost:8000/generate" \
-H "Content-Type: application/json" \
-d '{"use_pin": true, "use_rsa": false, "pin_length": 6, "words_per_phrase": 3}'
{% endif %}
// Encode text message (images must be base64 encoded first)
// First encode images: base64 -w0 photo.jpg > photo.b64
curl -X POST "http://localhost:8000/encode" \
-H "Content-Type: application/json" \
-d '{
"message": "secret message",
"reference_photo_base64": "'"$(cat photo.b64)"'",
"carrier_image_base64": "'"$(cat carrier.b64)"'",
"day_phrase": "apple forest thunder",
"pin": "123456"
}'
// Encode file (base64) - encode file first: base64 -w0 document.pdf > doc.b64
curl -X POST "http://localhost:8000/encode/file" \
-H "Content-Type: application/json" \
-d '{
"file_data_base64": "'"$(cat doc.b64)"'",
"filename": "document.pdf",
"reference_photo_base64": "'"$(cat photo.b64)"'",
"carrier_image_base64": "'"$(cat carrier.b64)"'",
"day_phrase": "apple forest thunder",
"pin": "123456"
}'</code></pre>
<h6 class="mt-4"><i class="bi bi-upload me-2"></i>Multipart API Examples</h6>
<pre class="bg-dark p-3 rounded"><code># Encode text with file uploads
curl -X POST "http://localhost:8000/encode/multipart" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@photo.jpg" \
-F "carrier=@carrier.png" \
-F "message=secret" \
--output stego.png
# Encode file (no message field when using payload_file)
curl -X POST "http://localhost:8000/encode/multipart" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@photo.jpg" \
-F "carrier=@carrier.png" \
-F "payload_file=@document.pdf" \
--output stego.png
# Encode with RSA key from QR code (optional)
curl -X POST "http://localhost:8000/encode/multipart" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@photo.jpg" \
-F "carrier=@carrier.png" \
-F "message=secret" \
-F "rsa_key_qr=@keyqr.png" \
--output stego.png
# Decode with file uploads (returns JSON)
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" \
--output result.json</code></pre>
<h6 class="mt-4"><i class="bi bi-qr-code me-2"></i>QR Code Support</h6>
<p class="small">
The API can extract RSA keys from QR code images. QR code reading requires
<code>pyzbar</code> and <code>libzbar</code> system library.
</p>
<pre class="bg-dark p-3 rounded"><code># Extract key from QR code (returns JSON)
curl -X POST "http://localhost:8000/extract-key-from-qr" \
-F "qr_image=@keyqr.png"</code></pre>
<div class="alert alert-info small mt-3">
<i class="bi bi-journal-text me-2"></i>
<strong>Interactive Documentation:</strong> When running the API server, visit
<code>/docs</code> for Swagger UI or <code>/redoc</code> for ReDoc documentation.
All endpoints include detailed schemas and example requests.
<!-- 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">
<!-- Current Version - Prominent -->
<div class="alert alert-success mb-4">
<div class="d-flex align-items-center">
<span class="badge bg-success fs-6 me-3">v4.1.2</span>
<div>
<strong>Progress bars</strong> for encode operations,
<strong>mobile-responsive polish</strong>,
DCT decode bug fix, release validation script
</div>
</div>
</div>
<!-- Previous Versions - Accordion -->
<div class="accordion" id="versionAccordion">
<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="#olderVersions">
<i class="bi bi-archive me-2"></i>Previous Versions
</button>
</h2>
<div id="olderVersions" class="accordion-collapse collapse" data-bs-parent="#versionAccordion">
<div class="accordion-body p-0">
<table class="table table-dark table-sm small mb-0">
<tbody>
<tr>
<td width="80"><strong>4.1.1</strong></td>
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
</tr>
<tr>
<td><strong>4.1.0</strong></td>
<td>Reed-Solomon error correction for DCT, majority voting headers</td>
</tr>
<tr>
<td><strong>4.0.0</strong></td>
<td>Channel keys, DCT default, subprocess isolation</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.x</td>
<td>Web UI, REST API, RSA keys, QR codes, file embedding</td>
</tr>
<tr>
<td>1.0.0</td>
<td>Initial release, CLI only, LSB mode</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<h6 class="mt-4"><i class="bi bi-terminal me-2"></i>Command Line Interface</h6>
<p class="small">
Stegasoo also includes a full-featured CLI. Install with <code>pip install stegasoo[cli]</code>
or see the <a href="/cli">CLI documentation</a> for complete usage.
</p>
<pre class="bg-dark p-3 rounded"><code># CLI Examples
stegasoo generate --pin --words 3
stegasoo encode -r photo.jpg -c meme.png -p "phrase" --pin 123456 -m "secret"
stegasoo decode -r photo.jpg -s stego.png -p "phrase" --pin 123456
stegasoo info image.png</code></pre>
<p class="small text-muted mt-3 mb-0">
<span class="badge bg-{% if has_argon2 %}success{% else %}warning{% endif %} me-1">
{% if has_argon2 %}Argon2 Available{% else %}PBKDF2 Fallback{% endif %}
</span>
<span class="badge bg-{% if has_qrcode_read %}success{% else %}secondary{% endif %}">
{% if has_qrcode_read %}QR Reading Available{% else %}QR Reading Not Available{% endif %}
</span>
</p>
</div>
</div>
@@ -341,11 +453,11 @@ stegasoo info image.png</code></pre>
<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>
@@ -355,20 +467,23 @@ stegasoo info image.png</code></pre>
<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>
@@ -378,23 +493,21 @@ stegasoo info image.png</code></pre>
<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>
@@ -403,65 +516,178 @@ stegasoo info image.png</code></pre>
</div>
</div>
<div class="card">
<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>2 million characters</strong> (~2 MB)</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>24 megapixels</strong> (~6000×4000)</td>
</tr>
<tr>
<td><i class="bi bi-upload me-2"></i>Max upload size</td>
<td><strong>30 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>
<tr>
<td><i class="bi bi-cpu me-2"></i>API documentation</td>
<td><strong>/docs (Swagger)</strong> and <strong>/redoc</strong></td>
</tr>
<tr>
<td><i class="bi bi-qr-code me-2"></i>QR code support</td>
<td><strong>RSA key encoding/extraction </strong>(up to 3072 bit keys)</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">DCT ECC</div>
<strong>RS Code</strong>
</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="text-center mt-4 text-muted small">
<p>
Stegasoo v{{ version }} &bull;
<i class="bi bi-github me-1"></i>Open Source &bull;
Built with Python, FastAPI, 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 v{{ version }} — Steganography using "Reference Photo Hashing + Day-Phrase + PIN/Key".
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
@@ -95,26 +244,17 @@
</div>
</div>
<!-- Hidden field for encoding date (auto-detected from filename) -->
<input type="hidden" name="stego_date" id="stegoDate" value="">
<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>
<!-- Date detection info -->
<div class="alert alert-info small d-none" id="dateDetectedAlert">
<i class="bi bi-calendar-check me-1"></i>
<span id="dateDetectedText">Date detected from filename</span>
</div>
<hr class="my-4">
<h6 class="text-muted mb-3">
@@ -122,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>
@@ -196,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>
@@ -224,192 +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 AND date from filename - FIXED VERSION
function detectDayFromFilename(filename) {
// Match patterns like _20251227 or _2025-12-27
const compactMatch = filename.match(/_(\d{4})(\d{2})(\d{2})/);
const dashedMatch = filename.match(/_(\d{4})-(\d{2})-(\d{2})/);
const dateMatch = compactMatch || dashedMatch;
if (dateMatch) {
const [, year, month, day] = dateMatch;
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
const dayName = dayNames[date.getDay()];
// Return ISO format date string for the server
const dateStr = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
console.log('Detected date from filename:', dateStr, 'Day:', dayName);
return {
dayName: dayName,
dateStr: dateStr
};
}
return null;
}
// Update day phrase label AND set hidden date field
function updateDayLabel(dayName, dateStr) {
const label = document.getElementById('dayPhraseLabel');
if (label && dayName) {
label.innerHTML = `<i class="bi bi-chat-quote me-1"></i>Provide <span class="day-of-week-highlight">${dayName}</span>'s Phrase`;
}
// CRITICAL FIX: Set the hidden date field for the server
const dateField = document.getElementById('stegoDate');
if (dateField && dateStr) {
dateField.value = dateStr;
console.log('Set stego_date hidden field to:', dateStr);
}
// Show info alert about detected date
const dateAlert = document.getElementById('dateDetectedAlert');
const dateText = document.getElementById('dateDetectedText');
if (dateAlert && dateText && dateStr) {
dateText.textContent = `Encoding date detected: ${dateStr} (${dayName})`;
dateAlert.classList.remove('d-none');
}
}
// 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);
// FIXED: Extract both day name AND date string
if (isStegoZone) {
const detected = detectDayFromFilename(file.name);
if (detected) {
updateDayLabel(detected.dayName, detected.dateStr);
}
}
}
});
input.addEventListener('change', function() {
if (this.files && this.files[0]) {
const file = this.files[0];
showPreview(file);
// FIXED: Extract both day name AND date string
if (isStegoZone) {
const detected = detectDayFromFilename(file.name);
if (detected) {
updateDayLabel(detected.dayName, detected.dateStr);
}
}
}
});
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'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,7 +535,7 @@
<div class="alert alert-secondary mt-4 small">
<i class="bi bi-info-circle me-1"></i>
<strong>Limits:</strong>
Carrier image max ~24 megapixels (6000×4000).
Carrier image max ~24 megapixels (6000x4000).
Files max 30MB upload.
Payload max {{ max_payload_kb }} KB.
</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>Secure with <span class="day-of-week-highlight">${localDay}</span>'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

@@ -9,13 +9,32 @@
<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">Stegasoo</h1>
<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">
@@ -25,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 and enrypt secret data in an image like a photo or meme.
Hide encrypted messages or files inside images
</p>
</div>
</div>
@@ -42,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 data from Stegasoo-encoded images
Extract and decrypt hidden data from stego images
</p>
</div>
</div>
@@ -59,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 weekly phrase card with PIN and/or RSA key.
Create passphrases, PINs, and RSA keys
</p>
</div>
</div>
@@ -69,51 +88,81 @@
</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 to 12 words, one for each day of the week
<strong>Passphrase</strong>: 4+ words
</li>
<li class="mb-2">
<i class="bi bi-key text-info me-2"></i>
<strong>RSA Key:</strong> 2048, 3072, or 4096 bit PEM or printable QR code
</li>
<li class="mb-2">
<li class="mb-1">
<i class="bi bi-123 text-info me-2"></i>
<strong>Static PIN:</strong> 6-9 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>
Perfect for async communication and use on air-gapped devices
</li>
<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>

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

@@ -1,61 +0,0 @@
--- a/src/stegasoo/__init__.py
+++ b/src/stegasoo/__init__.py
@@ -189,6 +189,7 @@ def decode(
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
+ date_str: Optional[str] = None,
) -> DecodeResult:
"""
Decode a secret message or file from a stego image.
@@ -201,6 +202,7 @@ def decode(
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
+ date_str: Date the message was encoded (YYYY-MM-DD). If not provided,
+ tries today's date. Get this from the stego filename.
Returns:
@@ -221,8 +223,12 @@ def decode(
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()
+ # Use provided date or fall back to today
+ if date_str is None:
+ date_str = date.today().isoformat()
+ debug.print(f"No date provided, using today: {date_str}")
+ else:
+ debug.print(f"Using provided date: {date_str}")
+
pixel_key = derive_pixel_key(
reference_photo, day_phrase, date_str, pin, rsa_key_data
)
@@ -270,6 +276,7 @@ def decode_text(
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
+ date_str: Optional[str] = None,
) -> str:
"""
Decode a text message from a stego image.
@@ -283,12 +290,13 @@ def decode_text(
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
+ date_str: Date the message was encoded (YYYY-MM-DD)
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)
+ result = decode(stego_image, reference_photo, day_phrase, pin, rsa_key_data, rsa_password, date_str)
if result.is_file:

View File

@@ -1,80 +0,0 @@
--- a/frontends/web/templates/decode.html
+++ b/frontends/web/templates/decode.html
@@ -35,6 +35,9 @@
{% else %}
<!-- Decode Form -->
<form method="POST" enctype="multipart/form-data" id="decodeForm">
+ <!-- Hidden field for encoding date (detected from filename) -->
+ <input type="hidden" name="stego_date" id="stegoDate" value="">
+
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">
@@ -171,10 +174,20 @@ document.getElementById('togglePin')?.addEventListener('click', function() {
// 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 {
+ dayName: dayNames[date.getDay()],
+ dateStr: `${year}-${month}-${day}`
+ };
+ }
+ return null;
+}
+
+// Legacy function for day name only
+function detectDayFromFilenameOld(filename) {
+ const result = detectDayFromFilename(filename);
+ if (result) {
- return dayNames[date.getDay()];
+ return result.dayName;
}
return null;
}
@@ -182,8 +195,14 @@ function detectDayFromFilename(filename) {
// Update day phrase label
-function updateDayLabel(dayName) {
+function updateDayLabel(dayName, dateStr) {
const label = document.getElementById('dayPhraseLabel');
if (label && dayName) {
label.innerHTML = `<i class="bi bi-chat-quote me-1"></i>Provide <span class="day-of-week-highlight">${dayName}</span>'s Phrase`;
}
+
+ // Set the hidden date field
+ const dateField = document.getElementById('stegoDate');
+ if (dateField && dateStr) {
+ dateField.value = dateStr;
+ console.log('Set stego date to:', dateStr);
+ }
}
@@ -232,8 +251,10 @@ document.querySelectorAll('.drop-zone').forEach(zone => {
showPreview(file);
if (isStegoZone) {
- const dayName = detectDayFromFilename(file.name);
- updateDayLabel(dayName);
+ const detected = detectDayFromFilename(file.name);
+ if (detected) {
+ updateDayLabel(detected.dayName, detected.dateStr);
+ }
}
}
});
@@ -244,8 +265,10 @@ document.querySelectorAll('.drop-zone').forEach(zone => {
showPreview(file);
if (isStegoZone) {
- const dayName = detectDayFromFilename(file.name);
- updateDayLabel(dayName);
+ const detected = detectDayFromFilename(file.name);
+ if (detected) {
+ updateDayLabel(detected.dayName, detected.dateStr);
+ }
}
}
});

View File

@@ -1,22 +0,0 @@
--- a/frontends/web/app.py
+++ b/frontends/web/app.py
@@ -324,6 +324,9 @@ def decode_page():
day_phrase = request.form.get('day_phrase', '')
pin = request.form.get('pin', '').strip()
rsa_password = request.form.get('rsa_password', '')
+
+ # Get encoding date from form (detected from filename in JS)
+ stego_date = request.form.get('stego_date', '').strip()
if not day_phrase:
flash('Day phrase is required', 'error')
@@ -373,7 +376,8 @@ def decode_page():
day_phrase=day_phrase,
pin=pin,
rsa_key_data=rsa_key_data,
- rsa_password=key_password
+ rsa_password=key_password,
+ date_str=stego_date if stego_date else None
)
if decode_result.is_file:

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "stegasoo"
version = "2.2.1"
version = "4.1.2"
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
readme = "README.md"
license = "MIT"
@@ -43,9 +43,18 @@ 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",
"rich>=13.0.0",
]
compression = [
"lz4>=4.0.0",
@@ -55,6 +64,12 @@ web = [
"gunicorn>=21.0.0",
"qrcode>=7.3.0",
"pyzbar>=0.1.9",
"piexif>=1.1.0",
# Include DCT support for web UI
"numpy>=2.0.0",
"scipy>=1.10.0",
"jpegio>=0.2.0",
"reedsolo>=1.7.0",
]
api = [
"fastapi>=0.100.0",
@@ -62,9 +77,14 @@ api = [
"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]",
@@ -103,9 +123,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

@@ -1,3 +0,0 @@
#!/bin/bash
cd ./frontends/web/
python app.py

View File

@@ -1,4 +0,0 @@
#!/bin/bash
sudo docker-compose down
sudo docker-compose build
sudo docker-compose up -d

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

133
rpi/BUILD_IMAGE.md Normal file
View File

@@ -0,0 +1,133 @@
# 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
cd /opt
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
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
# Option 1: rpi-imager CLI (supports .zst.zip directly)
sudo rpi-imager --cli --disable-verify stegasoo-rpi-*.img.zst.zip /dev/sdX
# Option 2: flash-image.sh (auto-detects SD card, shows progress)
sudo ./rpi/flash-image.sh stegasoo-rpi-*.img.zst.zip
# Option 3: Manual dd
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
```
---
## Quick Command Summary
```bash
# On Pi (after SSH):
sudo chown admin:admin /opt
sudo apt-get update && sudo apt-get install -y git
cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
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
```

209
rpi/README.md Normal file
View File

@@ -0,0 +1,209 @@
# 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
cd /opt
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
cd 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
cd /opt
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
cd 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
# Option 1: rpi-imager CLI (supports .zst.zip directly)
sudo rpi-imager --cli --disable-verify stegasoo-rpi-*.img.zst.zip /dev/sdX
# Option 2: flash-image.sh (auto-detects SD card, shows progress)
sudo ./rpi/flash-image.sh stegasoo-rpi-*.img.zst.zip
# Option 3: Manual dd
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
```

428
rpi/first-boot-wizard.sh Executable file
View File

@@ -0,0 +1,428 @@
#!/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
# =============================================================================
echo ""
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "\033[1;37m First Boot Wizard\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
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
echo ""
echo ""
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "\033[1;32m Setup Complete!\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
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)"
if [ -n "$CHANNEL_KEY" ]; then
echo ""
echo -e "\033[1;32mChannel Key:\033[0m \033[0;33m$CHANNEL_KEY\033[0m"
fi
echo ""
gum style --foreground 82 --bold "First Steps:"
gum style --foreground 255 " 1. Open URL → 2. Accept cert → 3. Create admin → 4. Encode!"
echo ""
gum style --foreground 245 "Commands: systemctl {status|restart} stegasoo, journalctl -u stegasoo -f"
# Prompt for restart if overclock was enabled
if [ "$NEEDS_RESTART" = "true" ]; then
echo ""
gum style --foreground 226 --bold "⚠ Restart required for overclock settings"
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 "Run 'sudo reboot' later to apply overclock."
fi
fi
echo ""
gum style --foreground 212 --bold "Enjoy Stegasoo!"
echo ""

295
rpi/flash-image.sh Executable file
View File

@@ -0,0 +1,295 @@
#!/bin/bash
#
# Flash Stegasoo image to SD card
# Uses rpi-imager if available, falls back to dd
#
# Usage: ./flash-image.sh <image> [device]
#
# Supports: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip (GitHub release format)
# If device is specified, skips auto-detection (useful for NVMe/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 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 optional tools
HAS_RPI_IMAGER=false
HAS_PV=false
if command -v rpi-imager &> /dev/null; then
HAS_RPI_IMAGER=true
fi
if command -v pv &> /dev/null; then
HAS_PV=true
fi
if [ "$HAS_RPI_IMAGER" = false ] && [ "$HAS_PV" = false ]; then
echo -e "${YELLOW}Warning: Neither rpi-imager nor pv found. Progress will not be shown.${NC}"
fi
# 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> [device]${NC}"
echo ""
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
echo ""
echo "Examples:"
echo " $0 stegasoo-rpi-4.1.1.img.zst # auto-detect SD card"
echo " $0 stegasoo-rpi-4.1.1.img.zst.zip # from GitHub release"
echo " $0 stegasoo-rpi-4.1.1.img.zst /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
# Handle .zst.zip wrapper (GitHub releases workaround)
if [[ "$IMAGE" == *.zst.zip ]]; then
echo -e "${YELLOW}Extracting .zst from zip wrapper...${NC}"
if ! command -v unzip &> /dev/null; then
echo -e "${RED}Error: unzip is required for .zst.zip files but not installed.${NC}"
exit 1
fi
TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
unzip -q "$IMAGE" -d "$TEMP_DIR"
IMAGE=$(find "$TEMP_DIR" -name "*.zst" | head -1)
if [ -z "$IMAGE" ]; then
echo -e "${RED}Error: No .zst file found in zip archive${NC}"
exit 1
fi
echo -e "${GREEN}Found: $(basename "$IMAGE")${NC}"
echo ""
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 ""
# Try rpi-imager first (faster, native support for compressed images)
if command -v rpi-imager &> /dev/null; then
echo -e "${YELLOW}Using rpi-imager...${NC}"
if rpi-imager --cli --disable-verify "$IMAGE" "$SELECTED"; then
# rpi-imager succeeded
:
else
echo -e "${YELLOW}rpi-imager failed, falling back to dd...${NC}"
# Fall through to dd
USE_DD=true
fi
else
USE_DD=true
fi
# Fallback to dd
if [ "$USE_DD" = true ]; then
if [ "$HAS_PV" = true ]; then
echo -e "${YELLOW}Using dd with progress...${NC}"
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
else
echo -e "${YELLOW}Using dd (no progress - install pv for progress bar)...${NC}"
if [ "$COMPRESSED" = true ]; then
case "$COMP_TYPE" in
xz) xzcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
zst) zstdcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
gz) zcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
esac
else
dd if="$IMAGE" of="$SELECTED" bs=4M conv=fsync status=progress
fi
fi
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 ""

200
rpi/inject-wifi.sh Executable file
View File

@@ -0,0 +1,200 @@
#!/bin/bash
#
# Inject WiFi credentials into SD card for Raspberry Pi
# Supports both Bookworm (NetworkManager) and older (wpa_supplicant)
#
# First-time setup:
# ./inject-wifi.sh --setup
#
# Then after flashing:
# sudo ./inject-wifi.sh # auto-detect partitions
# sudo ./inject-wifi.sh /dev/sdb # specify device (finds partitions)
#
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
CONFIG_DIR="$HOME/.config/stegasoo"
CONFIG_FILE="$CONFIG_DIR/wifi.conf"
# Setup mode - save credentials
if [ "$1" == "--setup" ]; then
echo -e "${BLUE}Stegasoo WiFi Config Setup${NC}"
echo ""
read -p "WiFi SSID: " WIFI_SSID
read -s -p "WiFi Password: " WIFI_PASS
echo ""
read -p "Country code [US]: " WIFI_COUNTRY
WIFI_COUNTRY=${WIFI_COUNTRY:-US}
# Generate hashed PSK for wpa_supplicant (legacy)
if command -v wpa_passphrase &> /dev/null; then
HASHED_PSK=$(wpa_passphrase "$WIFI_SSID" "$WIFI_PASS" | grep -E "^\s+psk=" | tr -d '\t' | cut -d= -f2)
else
HASHED_PSK=""
echo -e "${YELLOW}Note: wpa_passphrase not found, legacy mode disabled${NC}"
fi
# Save config (includes plaintext for NetworkManager)
mkdir -p "$CONFIG_DIR"
chmod 700 "$CONFIG_DIR"
cat > "$CONFIG_FILE" << EOF
# Stegasoo WiFi config
WIFI_SSID="$WIFI_SSID"
WIFI_PASS="$WIFI_PASS"
WIFI_PSK_HASH="$HASHED_PSK"
WIFI_COUNTRY="$WIFI_COUNTRY"
EOF
chmod 600 "$CONFIG_FILE"
echo ""
echo -e "${GREEN}Config saved to $CONFIG_FILE${NC}"
exit 0
fi
# Normal mode - inject credentials
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: Must run as root (sudo)${NC}"
echo "Usage: sudo $0 [/dev/sdX]"
echo ""
echo "First-time setup (no sudo): $0 --setup"
exit 1
fi
# Load config
if [ -n "$SUDO_USER" ]; then
USER_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6)
CONFIG_FILE="$USER_HOME/.config/stegasoo/wifi.conf"
fi
if [ ! -f "$CONFIG_FILE" ]; then
echo -e "${RED}Config not found: $CONFIG_FILE${NC}"
echo ""
echo "Run setup first (without sudo):"
echo " ./inject-wifi.sh --setup"
exit 1
fi
source "$CONFIG_FILE"
if [ -z "$WIFI_SSID" ] || [ -z "$WIFI_PASS" ]; then
echo -e "${RED}Invalid config. Run --setup again.${NC}"
exit 1
fi
# Find partitions
MANUAL_DEV="$1"
if [ -n "$MANUAL_DEV" ]; then
# Strip partition number if given (e.g., /dev/sdb1 -> /dev/sdb)
BASE_DEV=$(echo "$MANUAL_DEV" | sed 's/[0-9]*$//')
BOOT_DEV="${BASE_DEV}1"
ROOT_DEV="${BASE_DEV}2"
else
# Auto-detect by label
BOOT_PART=$(lsblk -o NAME,FSTYPE,LABEL -rn | grep -E "vfat.*(bootfs|boot)" | head -1 | awk '{print $1}')
ROOT_PART=$(lsblk -o NAME,FSTYPE,LABEL -rn | grep -E "ext4.*rootfs" | head -1 | awk '{print $1}')
if [ -z "$BOOT_PART" ] || [ -z "$ROOT_PART" ]; then
echo -e "${RED}Could not find boot/root partitions. Is the SD card inserted?${NC}"
echo ""
lsblk -o NAME,SIZE,FSTYPE,LABEL
echo ""
echo -e "${YELLOW}Tip: Specify device manually: sudo $0 /dev/sdX${NC}"
exit 1
fi
BOOT_DEV="/dev/$BOOT_PART"
ROOT_DEV="/dev/$ROOT_PART"
fi
echo -e "${GREEN}Found partitions:${NC}"
echo -e " Boot: ${YELLOW}$BOOT_DEV${NC}"
echo -e " Root: ${YELLOW}$ROOT_DEV${NC}"
# Mount points
BOOT_MNT="/tmp/stegasoo-boot-$$"
ROOT_MNT="/tmp/stegasoo-root-$$"
cleanup() {
umount "$BOOT_MNT" 2>/dev/null || true
umount "$ROOT_MNT" 2>/dev/null || true
rmdir "$BOOT_MNT" "$ROOT_MNT" 2>/dev/null || true
}
trap cleanup EXIT
mkdir -p "$BOOT_MNT" "$ROOT_MNT"
# Mount partitions
mount "$BOOT_DEV" "$BOOT_MNT"
mount "$ROOT_DEV" "$ROOT_MNT"
echo ""
# 1. Write NetworkManager config (Bookworm+)
NM_DIR="$ROOT_MNT/etc/NetworkManager/system-connections"
if [ -d "$ROOT_MNT/etc/NetworkManager" ]; then
mkdir -p "$NM_DIR"
# NetworkManager connection file
NM_FILE="$NM_DIR/stegasoo-wifi.nmconnection"
cat > "$NM_FILE" << EOF
[connection]
id=$WIFI_SSID
type=wifi
autoconnect=true
[wifi]
mode=infrastructure
ssid=$WIFI_SSID
[wifi-security]
auth-alg=open
key-mgmt=wpa-psk
psk=$WIFI_PASS
[ipv4]
method=auto
[ipv6]
method=auto
EOF
chmod 600 "$NM_FILE"
echo -e "${GREEN}Created NetworkManager config (Bookworm)${NC}"
fi
# 2. Write wpa_supplicant.conf (legacy, boot partition)
if [ -n "$WIFI_PSK_HASH" ]; then
cat > "$BOOT_MNT/wpa_supplicant.conf" << EOF
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1
country=$WIFI_COUNTRY
network={
ssid="$WIFI_SSID"
psk=$WIFI_PSK_HASH
}
EOF
echo -e "${GREEN}Created wpa_supplicant.conf (legacy)${NC}"
fi
# 3. Set WiFi country in boot config
if [ -f "$BOOT_MNT/config.txt" ]; then
if ! grep -q "^dtparam=cfg80211" "$BOOT_MNT/config.txt"; then
echo "" >> "$BOOT_MNT/config.txt"
echo "# WiFi country" >> "$BOOT_MNT/config.txt"
echo "dtparam=cfg80211" >> "$BOOT_MNT/config.txt"
fi
fi
echo -e " SSID: ${YELLOW}$WIFI_SSID${NC}"
echo ""
echo -e "${GREEN}Done! WiFi credentials injected for Bookworm + legacy.${NC}"

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

111
rpi/patches/jpegio/apply-patch.sh Executable file
View File

@@ -0,0 +1,111 @@
#!/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..."
# Fix CRLF line endings (jpegio uses Windows line endings)
if file setup.py | grep -q CRLF; then
echo " Converting CRLF to LF..."
sed -i 's/\r$//' setup.py
fi
# 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,20 @@
--- a/setup.py
+++ b/setup.py
@@ -64,7 +64,7 @@ elif sys.platform == 'darwin': # macOS
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'])
@@ -68,7 +68,7 @@ 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 ""

595
rpi/sanitize-for-image.sh Executable file
View File

@@ -0,0 +1,595 @@
#!/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 "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\\033[0m"
echo -e "\033[38;5;220m \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |\033[0m"
echo -e "\033[38;5;220m |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/\033[0m"
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
if [ "$SOFT_RESET" = true ]; then
echo -e "\033[1;37m Soft Reset (Factory)\033[0m"
else
echo -e "\033[1;37m Sanitize for Imaging\033[0m"
fi
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
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

556
rpi/setup.sh Executable file
View File

@@ -0,0 +1,556 @@
#!/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 "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\\033[0m"
echo -e "\033[38;5;220m \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |\033[0m"
echo -e "\033[38;5;220m |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/\033[0m"
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "\033[1;37m Raspberry Pi Setup\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
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} Setting up user environment..."
# 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 stegasoo to PATH"
# Install custom bashrc if not already customized
if [ -f "$INSTALL_DIR/rpi/skel/.bashrc" ]; then
if ! grep -q "Stegasoo Pi" ~/.bashrc 2>/dev/null; then
cp "$INSTALL_DIR/rpi/skel/.bashrc" ~/.bashrc
source ~/.bashrc 2>/dev/null || true
echo " Installed custom .bashrc"
else
echo " Custom .bashrc already installed"
fi
fi
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[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e " \033[0;32m●\033[0m Stegasoo is running"
echo -e " \033[0;33m$STEGASOO_URL\033[0m"
# Show CPU stats if overclocked
if grep -qE "^(arm_freq|over_voltage)" /boot/firmware/config.txt 2>/dev/null || \
grep -qE "^(arm_freq|over_voltage)" /boot/config.txt 2>/dev/null; then
CPU_FREQ=$(vcgencmd measure_clock arm 2>/dev/null | cut -d= -f2)
CPU_TEMP=$(vcgencmd measure_temp 2>/dev/null | cut -d= -f2)
if [ -n "$CPU_FREQ" ] && [ -n "$CPU_TEMP" ]; then
CPU_MHZ=$((CPU_FREQ / 1000000))
echo -e " \033[0;35m⚡\033[0m ${CPU_MHZ} MHz \033[0;35m🌡\033[0m ${CPU_TEMP}"
fi
fi
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

214
rpi/skel/.bashrc Normal file
View File

@@ -0,0 +1,214 @@
# ============================================================================
# Stegasoo Pi - Bash Configuration
# ============================================================================
# If not running interactively, don't do anything
case $- in
*i*) ;;
*) return;;
esac
# ============================================================================
# History
# ============================================================================
HISTCONTROL=ignoreboth
HISTSIZE=5000
HISTFILESIZE=10000
shopt -s histappend
# ============================================================================
# Shell Options
# ============================================================================
shopt -s checkwinsize
shopt -s globstar 2>/dev/null
shopt -s cdspell 2>/dev/null
# ============================================================================
# Colors
# ============================================================================
# Color definitions
C_RESET='\[\e[0m\]'
C_GREY='\[\e[38;5;241m\]'
C_GREEN='\[\e[38;5;118m\]'
C_YELLOW='\[\e[38;5;179m\]'
C_BLUE='\[\e[38;5;69m\]'
C_RED='\[\e[38;5;196m\]'
C_BOLD='\[\e[1m\]'
# Enable color support
if [ -x /usr/bin/dircolors ]; then
test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)"
alias ls='ls --color=auto'
alias grep='grep --color=auto'
alias fgrep='fgrep --color=auto'
alias egrep='egrep --color=auto'
fi
# ============================================================================
# Prompt
# ============================================================================
# Git branch in prompt (if git installed)
_git_branch() {
git branch 2>/dev/null | sed -e '/^[^*]/d' -e 's/* \(.*\)/ \xe2\x8e\x87 \1/'
}
# Two-line prompt similar to zsh theme
# ┌「user@host」 「path」 「git」
# └$
_build_prompt() {
local git_info="$(_git_branch)"
if [ -n "$git_info" ]; then
git_info="${C_GREEN}${git_info}${C_GREY}"
fi
PS1="${C_GREY}┌「${C_GREEN}\u@\h${C_GREY}」 「${C_YELLOW}\w${C_GREY}${git_info}」\n${C_GREY}${C_BOLD}${C_BLUE}\$ ${C_RESET}"
}
PROMPT_COMMAND='_build_prompt'
# ============================================================================
# Navigation
# ============================================================================
alias ..='cd ..'
alias ...='cd ../..'
alias ....='cd ../../..'
alias ~='cd ~'
# ============================================================================
# Listing
# ============================================================================
alias ll='ls -lah'
alias la='ls -A'
alias l='ls -CF'
alias lt='ls -lahtr'
# ============================================================================
# Safety
# ============================================================================
alias rm='rm -i'
alias cp='cp -i'
alias mv='mv -i'
# ============================================================================
# Shortcuts
# ============================================================================
alias h='history'
alias c='clear'
alias q='exit'
alias reload='source ~/.bashrc'
# ============================================================================
# System
# ============================================================================
alias myip='curl -s ifconfig.me'
alias ports='netstat -tulanp 2>/dev/null || ss -tulanp'
alias df='df -h'
alias du='du -h'
alias free='free -h'
alias temp='vcgencmd measure_temp 2>/dev/null || sensors 2>/dev/null | grep -i temp || echo "No temp sensor"'
# ============================================================================
# Stegasoo
# ============================================================================
alias steg='stegasoo'
alias steglog='journalctl -u stegasoo -f'
alias stegstatus='systemctl status stegasoo'
alias stegrestart='sudo systemctl restart stegasoo'
alias stegstop='sudo systemctl stop stegasoo'
alias stegstart='sudo systemctl start stegasoo'
# Quick access to stegasoo directories
alias cdsteg='cd /opt/stegasoo'
alias cdweb='cd /opt/stegasoo/frontends/web'
# ============================================================================
# Git (if available)
# ============================================================================
alias g='git'
alias gs='git status'
alias ga='git add'
alias gc='git commit'
alias gp='git push'
alias gl='git pull'
alias gd='git diff'
alias gco='git checkout'
alias glog='git log --oneline --graph --decorate -10'
# ============================================================================
# Functions
# ============================================================================
# Create directory and cd into it
mkcd() { mkdir -p "$1" && cd "$1"; }
# Find files by name
ff() { find . -type f -iname "*$1*" 2>/dev/null; }
# Find directories by name
fdir() { find . -type d -iname "*$1*" 2>/dev/null; }
# Quick backup
backup() { cp "$1" "$1.backup-$(date +%Y%m%d-%H%M%S)"; }
# Extract archives
extract() {
if [ ! -f "$1" ]; then
echo "'$1' is not a valid file"
return 1
fi
case "$1" in
*.tar.bz2) tar xjf "$1" ;;
*.tar.gz) tar xzf "$1" ;;
*.tar.xz) tar xJf "$1" ;;
*.bz2) bunzip2 "$1" ;;
*.gz) gunzip "$1" ;;
*.tar) tar xf "$1" ;;
*.tbz2) tar xjf "$1" ;;
*.tgz) tar xzf "$1" ;;
*.zip) unzip "$1" ;;
*.Z) uncompress "$1" ;;
*.7z) 7z x "$1" ;;
*.zst) zstd -d "$1" ;;
*) echo "'$1' cannot be extracted" ;;
esac
}
# Show system info
sysinfo() {
echo -e "\e[1;32mHostname:\e[0m $(hostname)"
echo -e "\e[1;32mUptime:\e[0m $(uptime -p)"
echo -e "\e[1;32mMemory:\e[0m $(free -h | awk '/^Mem:/ {print $3 "/" $2}')"
echo -e "\e[1;32mDisk:\e[0m $(df -h / | awk 'NR==2 {print $3 "/" $2 " (" $5 ")"}')"
echo -e "\e[1;32mTemp:\e[0m $(vcgencmd measure_temp 2>/dev/null | cut -d= -f2 || echo 'N/A')"
echo -e "\e[1;32mIP:\e[0m $(hostname -I | awk '{print $1}')"
}
# ============================================================================
# Completion
# ============================================================================
if ! shopt -oq posix; then
if [ -f /usr/share/bash-completion/bash_completion ]; then
. /usr/share/bash-completion/bash_completion
elif [ -f /etc/bash_completion ]; then
. /etc/bash_completion
fi
fi
# ============================================================================
# Path
# ============================================================================
export PATH="$HOME/.local/bin:$PATH"

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

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