225 Commits

Author SHA1 Message Date
Aaron D. Lee
9c88f53cd0 Reset DCT options to Color+JPEG when switching from LSB
Some checks failed
Release / test (push) Failing after 34s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped
2026-01-07 19:29:29 -05:00
Aaron D. Lee
3f8c2a6957 Compact mode UI with smart output options
Encode page:
- Inline mode buttons: DCT | LSB | Color | Gray | JPEG | PNG
- LSB mode auto-selects Color+PNG and disables Gray/JPEG
- Dynamic hint text with icons below mode buttons

Decode page:
- Compact inline mode buttons: Auto | LSB | DCT
- Dynamic hints that change per mode selection

CSS:
- Disabled btn-check styling for dimmed unavailable options

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 19:28:38 -05:00
Aaron D. Lee
22cf27d7f6 Security: Password-protect channel key export, add audit plan
Channel Key Protection:
- Hide channel key by default in admin settings
- Require password re-authentication to view/export key
- Add /admin/settings/unlock API endpoint for verification
- Key re-locks on page navigation (per-page-load only)

QR Print Sheet Refinements:
- Key split above/below QR image
- 10pt bold font, 1.6in QR size
- Zero gap between tiles, minimal margins
- No page header/footer for clean printing

Security Audit Plan:
- Comprehensive checklist covering auth, crypto, input validation
- Steganography-specific security considerations
- Air-gap deployment focus with known limitations documented
- Penetration testing checklist and automated tool recommendations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 19:16:24 -05:00
Aaron D. Lee
4d8575ce33 Web UI v4.1.6: Admin settings, nav icons, air-gap ready
Admin System Settings page:
- New /admin/settings route with channel key config
- QR code export with tiled print sheet (4x5 on US Letter)
- Server config display (HTTPS, port, auth, DCT/QR status)
- Environment info (version, Python, platform, KDF)

Navigation improvements:
- Icon-only nav with floating labels on hover
- Gold labels slide down below icons
- Gradient pill background on hover

Air-gap ready:
- All vendor libs now local (Bootstrap CSS/JS, Icons, html5-qrcode)
- QRious library for QR generation
- No external CDN dependencies

Other changes:
- Moved About link from nav to footer
- Channel QR export moved from about.html to admin/settings.html
- Print sheet button for QR codes (tiled US Letter output)
- Dev runner script (dev_run.sh) with r/q hotkeys
- Fixed navbar dropdown z-index

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:36:33 -05:00
Aaron D. Lee
28b539bcd9 Remove instance/ from tracking, fix ruff lint errors
Some checks failed
Release / test (push) Failing after 30s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped
Security:
- Remove instance/.secret_key and instance/stegasoo.db from git
- Add instance/ to .gitignore (was only ignoring frontends/web/instance/)

Lint fixes:
- Remove unused imports in temp_storage.py (os, shutil)
- Sort imports and fix f-string placeholders in cli.py

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 14:28:07 -05:00
Aaron D. Lee
6b82069dc8 Rename Pi tarball to stegasoo-rpi-runtime-env-arm64.tar.zst
More descriptive name for the pre-built pyenv + venv bundle.
Updated all scripts and docs to use new filename.
Also bumped PREBUILT_URL to v4.1.5.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 01:21:43 -05:00
Aaron D. Lee
52e1a3dfbf Release v4.1.5: Clean up docs, update release notes
- Remove old PLAN-4.1.x.md and RELEASE-4.1.1.md files
- Update RELEASE_NOTES.md for v4.1.5
- Highlights: dev docs, pull-image.sh auto-resize, 16GB images

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 01:17:25 -05:00
Aaron D. Lee
4a27d0c182 Add host-requirements.txt for Pi scripts
Lists all host machine dependencies needed to run:
- pull-image.sh (parted, e2fsprogs, zstd, zip, bc, pv)
- flash-image.sh (unzip, zstd, pv, jq)
- kickoff-pi-test.sh (sshpass, avahi-utils)

Includes quick install command for Debian/Ubuntu.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 01:08:54 -05:00
Aaron D. Lee
36931518ce Docs: Update Pi image workflow, 16GB+ requirement
- rpi/README.md: 16GB+ SD card requirement, use pull-image.sh
- rpi/BUILD_IMAGE.md: Simplified steps using pull-image.sh
- pull-image.sh: Optional .zst.zip wrapper for GitHub releases

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 01:01:24 -05:00
Aaron D. Lee
f79c63428b pull-image: Auto-resize rootfs to 16GB before pull
- Unmounts and resizes partition to exactly 16GB
- Handles both shrinking (large cards) and expanding (small cards)
- Disables Pi OS auto-expand service
- Consistent image size regardless of source SD card
- 16GB = minimum disk requirement = image size

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 00:58:55 -05:00
Aaron D. Lee
cc29de4200 Add update instructions to Pi README
Documents easy 3-command update process for existing installations.
Most updates just need git pull + systemctl restart since we use
editable pip installs.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 00:16:30 -05:00
Aaron D. Lee
c14f3f75cb Bump version to 4.1.5
Developer documentation release:
- Educational comments throughout core modules
- Pi test automation script
- MOTD improvements with dynamic emojis
- v4.2 wishlist

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 00:08:59 -05:00
Aaron D. Lee
aa99a258f4 Document CLI and Web UI architecture for future devs
CLI module now explains:
- Click command group hierarchy (tree diagram)
- JSON output pattern for scriptability
- Secure input handling (hide_input, confirmation_prompt)
- Dry-run mode pattern
- Batch processing with variadic args and progress callbacks

Web UI now explains:
- Flask architecture overview with ASCII diagram
- Subprocess isolation pattern (why we run stegasoo in subprocesses)
- Async job management with polling flow diagram
- Context processors for template globals
- Secret key persistence for session survival
- Environment-based configuration (12-factor style)

If you're reading this code trying to learn Flask/Click patterns,
these comments should actually teach you something useful.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:58:59 -05:00
Aaron D. Lee
93420704e8 Add personality to the codebase (comments that don't suck)
The code now explains itself like a friend teaching you crypto:
- DCT module: Why mid-frequency? What's QIM? Why is scipy being weird?
- Steganography: How LSB actually works with visual examples
- Crypto: The multi-factor security model with ASCII art diagrams

Also adds kickoff-pi-test.sh - one command to flash, wait, setup, test.
No more manual steps between flashing and seeing if it works.

Comments should teach, not just describe. If you're reading the code
trying to understand how DCT steganography works, these comments
should actually help. Novel concept, I know.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:53:26 -05:00
Aaron D. Lee
6e4eb5464e Fix MOTD: Remove escaped vars, shorten Debian boilerplate
- Fix TEMP_NUM/TEMP_EMOJI variables (no escaping in quoted heredoc)
- Shorten /etc/motd to one-liner with license path reference

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:14:19 -05:00
Aaron D. Lee
d04670e352 MOTD: Use globe emoji for URL
🚀 Stegasoo running     🌐 https://...

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:55:47 -05:00
Aaron D. Lee
fda1cdad51 MOTD: Dynamic temp emoji based on temperature
- 🧊 ice cube: < 50°C (cool)
- 😎 cool face: 50-70°C (warm)
- 🔥 fire: > 70°C (hot)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:54:52 -05:00
Aaron D. Lee
b48ccc5d16 MOTD: Adjust thermometer spacing
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:51:27 -05:00
Aaron D. Lee
15ed63cafa MOTD: Use link emoji for URL
🚀 Stegasoo running    🔗 https://...

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:48:17 -05:00
Aaron D. Lee
869d7ee8e3 MOTD: Replace bullet with rocket emoji
🚀 Stegasoo running    🚀 https://...

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:47:55 -05:00
Aaron D. Lee
3ee8c1d22a Fix MOTD temperature line alignment
Adjust spacing between MHz and thermometer emoji for proper
column alignment in terminal.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:46:22 -05:00
Aaron D. Lee
b96564358a Add v4.2 wishlist with GPU decode idea
Blue sky document for capturing future feature ideas.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:33:41 -05:00
Aaron D. Lee
01afb3da66 Refactor: Extract banner template to shared banner.sh
- Create rpi/banner.sh with print_banner, print_gradient_line,
  print_logo, print_starfield, print_complete_banner functions
- Update setup.sh to source banner.sh (with inline fallback for curl)
- Update first-boot-wizard.sh to use banner functions
- Update sanitize-for-image.sh to use banner functions
- Fix MOTD thermometer spacing alignment

Single source of truth for ASCII banner styling.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:33:12 -05:00
Aaron D. Lee
a98df5f9a0 SSL cert: Use actual hostname instead of 'localhost' default
When STEGASOO_HOSTNAME env var is not set, use socket.gethostname()
to get the actual machine hostname for certificate generation.

This ensures the cert includes proper hostname.local SAN.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:16:23 -05:00
Aaron D. Lee
70da348bce SSL certs: Include .local hostname and local IPs in SANs
The auto-generated SSL certificate now includes:
- hostname.local for mDNS browser access
- All detected local network IPs

This fixes browser access via stegasoo.local when HTTPS is enabled.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:15:24 -05:00
Aaron D. Lee
90ba8543a7 Remove trailing period from wizard intro 2026-01-06 21:56:37 -05:00
Aaron D. Lee
da3aea992c Polish first-boot-wizard intro text formatting
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:55:17 -05:00
Aaron D. Lee
ae47ff4932 Show mDNS hostname alongside IP in RPi scripts
- flash-stock-img.sh: Show stegasoo.local URL and SSH command
- setup.sh: Display both .local and IP URLs
- first-boot-wizard.sh: Prioritize .local URL, IP as fallback
- Clean up service file path display

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:52:51 -05:00
Aaron D. Lee
eb16eb1db2 v4.1.5: Accordion UI, webcam QR scanning, Pi image fix
Encode/Decode UI:
- New accordion layout with 3 steps (encode) / 2 steps (decode)
- Gold step numbers with checkmarks on completion
- Dynamic right-aligned summaries as fields are filled
- Subtle gradient highlight on active accordion step

Webcam QR Scanning:
- Camera button for RSA key QR codes on encode/decode pages
- Camera button for channel key scanning
- 3-2-1 countdown capture for dense QR codes
- Proper scanner stop/restart on retry
- Backend decompression for STEGASOO-Z: compressed keys

RSA Key Print:
- Removed identifying text from QR print output
- Now prints plain QR code for discretion

Pi Image Script:
- Fixed 16GB resize to detect expand vs shrink
- Fresh images now properly EXPAND to 16GB
- Already-expanded images properly SHRINK to 16GB

UI Polish:
- Removed PIN helper text for compactness
- Fixed QR drop zone centering
- Fixed decode page element IDs for JS

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:31:11 -05:00
Aaron D. Lee
c65d9e6682 Finalize 4.1.4 release prep
Some checks failed
Release / test (push) Failing after 34s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped
- BUILD_IMAGE.md: Clarify docs are for devs, not end users
- Add 4.1.5 plan with decode progress bar feature
- Update .gitignore for release assets (.zip, .img)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 17:51:00 -05:00
Aaron D. Lee
eeb44eae94 Update BUILD_IMAGE.md with SCP tarball step
- Split clone and setup into separate steps
- Add Step 5: Copy pre-built tarball from host
- Renumber remaining steps (7-11)
- Update quick command summary with full workflow

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 17:12:01 -05:00
Aaron D. Lee
26d4b82c91 MOTD: Show configured overclock freq, not idle freq
Read arm_freq from config.txt instead of vcgencmd live reading.
Previously showed 600 MHz at idle, now shows actual configured max.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 16:58:36 -05:00
Aaron D. Lee
7efeaf02e8 Bundle pyenv Python with pre-built tarball for zero-compile installs
- Combined tarball includes pyenv Python 3.12 + venv with all deps
- Downloads from GitHub releases by default (~50MB)
- Reduces install time from 20+ min to ~2 min
- Add --no-prebuilt / --from-source flags to force compile
- Update BUILD_IMAGE.md with tarball creation instructions
- Rename tarball: stegasoo-pi-arm64.tar.zst (was venv-only)

Fresh Pi installs no longer need to compile Python or jpegio.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 15:37:44 -05:00
Aaron D. Lee
925fb05cbd Default to pre-built venv for Pi setup
- USE_PREBUILT=true by default, downloads from GitHub releases
- Add --no-prebuilt / --from-source flags for manual builds
- Update estimated time: ~2 min vs 15-20 min from source
- Update help text with new options

Fresh Pi installs now download pre-built venv automatically,
cutting install time from 20+ minutes to ~2 minutes.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 15:29:52 -05:00
Aaron D. Lee
29a02265a1 Restructure setup.sh for optimized tarball detection
- Move repo clone to step 4 (before pyenv) to check for tarball early
- Set USE_PREBUILT flag immediately after cloning
- Python compile only happens if not already installed (fallback)
- Better progress messages during install

This ensures pre-built venv detection happens before expensive
Python compile step, and skips compile entirely on repeat installs.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 15:24:54 -05:00
Aaron D. Lee
d58f3c6fb6 4.1.4: QR sharing, venv tarball, flash script improvements
QR Channel Key Sharing:
- Admin-only QR generator in about.html (was visible to all)
- QR button for saved keys on account page
- Fixed about() route missing channel status vars (bug)

Pi Build Optimization:
- Pre-built venv tarball support (39MB zstd, skips 20+ min compile)
- setup.sh auto-detects and extracts tarball if present
- Strip __pycache__/tests before tarball (295MB → 208MB)

Flash Script Improvements:
- flash-image.sh now uses config.json for headless WiFi setup
- Consistent wipe prompt on both flash scripts
- pull-image.sh re-enables auto-expand before shrinking

Build Docs:
- Added zstd and jq to pre-setup apt-get
- Documented fast build option with pre-built venv

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 15:03:46 -05:00
Aaron D. Lee
cc46993d80 Add stegasoo info command and update docs for v4.1
- Enhanced `stegasoo info` with fastfetch-style output
  - Service status, URL, channel key, DCT support
  - System stats with --full (CPU, temp, uptime, disk)
- Updated UNDER_THE_HOOD.md for v4.1
  - Added v4.1 changes table (channel keys, docker, Pi wizard)
  - Updated architecture diagram
  - Added channel module to responsibilities

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 13:05:17 -05:00
Aaron D. Lee
893a044eaa Build tooling improvements for 4.1.4
- Rename flash-pi.sh → flash-stock-img.sh for clarity
- Add 16GB partition sizing option (faster imaging)
- Disable Pi OS auto-expand to preserve partition size
- Add pip-audit security check to release validation
- Add config.json.example, gitignore actual config

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 12:59:59 -05:00
Aaron D. Lee
9f03b69408 Add 4.1.4 planning doc and release notes 2026-01-06 12:45:48 -05:00
adlee-was-taken
cce2007c6e Merge pull request #11 from adlee-was-taken/main
Simple doc fixes.
2026-01-06 12:19:33 -05:00
adlee-was-taken
52f43d3a86 Fix formatting in UNDER_THE_HOOD.md 2026-01-06 12:16:15 -05:00
adlee-was-taken
85a7092d55 Fix formatting inconsistencies in UNDER_THE_HOOD.md 2026-01-06 12:14:24 -05:00
adlee-was-taken
4b37a81087 Fix formatting in STEGASOO ARCHITECTURE diagram 2026-01-06 12:12:47 -05:00
adlee-was-taken
31941dc3f5 Update email for reporting security vulnerabilities
Updated the email address for reporting vulnerabilities.
2026-01-06 11:58:26 -05:00
adlee-was-taken
9a7e4ddce7 Change security vulnerability reporting email
Updated contact email for reporting vulnerabilities.
2026-01-06 11:58:16 -05:00
adlee-was-taken
0424dd34d5 Update security policy for version support 2026-01-06 11:57:38 -05:00
adlee-was-taken
2127b916f3 Revise notes for supported versions
Updated notes for supported versions in SECURITY.md.
2026-01-06 11:56:26 -05:00
adlee-was-taken
f8e65890e5 Update supported versions in SECURITY.md
Removed the note about EOL for version 4.1.x.
2026-01-06 11:55:42 -05:00
adlee-was-taken
5861ab0e1e Update supported versions and EOL notes in SECURITY.md 2026-01-06 11:55:28 -05:00
adlee-was-taken
5309a08aaf Update Python version requirement to 3.10 - 3.12 2026-01-06 11:46:40 -05:00
Aaron D. Lee
d8fb95b68e Add optional partition wipe to flash-pi.sh
Some checks failed
Release / test (push) Failing after 29s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped
Prompts user to wipe partition table before flashing,
helpful when SD card has corrupted partitions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 00:59:38 -05:00
Aaron D. Lee
c0b6865790 Add headless Pi flash script with NetworkManager WiFi
- Reads config from rpi/config.json
- Flashes image with dd (supports .xz and .zst)
- Configures SSH, user/password, hostname on boot partition
- Creates NetworkManager connection file on rootfs for WiFi
- Works with Trixie/Bookworm (no more wpa_supplicant)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 23:48:59 -05:00
Aaron D. Lee
6e7ae0d6f9 Docker improvements and decode loading state
- Fix .dockerignore to exclude *.img.xz files (was 2.3GB context)
- Remove deprecated 'version' attribute from docker-compose.yml
- Increase container memory limits to 2GB/1GB (prevent OOM on DCT)
- Add loading spinner to decode button during form submission

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:53:39 -05:00
Aaron D. Lee
6a5b12f98e Fix multi-worker temp file issue with file-based storage
- New temp_storage.py module stores files on disk instead of in-memory
- Multiple Gunicorn workers can now share temp files
- Startup cleanup removes leftover files from previous runs
- Dockerfile creates temp_files directory
- Added temp_files/ to .gitignore

Previously encode preview worked but download failed with "File expired"
because each worker had its own in-memory TEMP_FILES dict.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:40:42 -05:00
Aaron D. Lee
d8eb7b0160 Bump version to 4.1.3
- Version bump from 4.1.2 to 4.1.3
- Updated CHANGELOG with SSL cert fix as highlight
- Added *.img.zst.zip to .gitignore

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:20:26 -05:00
Aaron D. Lee
962c04084b Fix SSL certificate generation for HTTPS mode
- wizard/setup now generate certs when HTTPS enabled
- app.py has proper error handling for cert failures
- Add custom SSL certificate documentation to INSTALL.md
- Include SANs for hostname, localhost, and local IP

Previously HTTPS could be enabled but certs weren't generated,
causing SSL_ERROR_RX_RECORD_TOO_LONG browser errors.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:16:12 -05:00
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
132 changed files with 20226 additions and 8531 deletions

53
.dockerignore Normal file
View File

@@ -0,0 +1,53 @@
# 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.xz
*.img.zst
*.img.zst.zip
pishrink.sh
# Docs
*.md
docs/
# IDE
.vscode/
.idea/
# Misc
*.log
*.tmp
.DS_Store
# Dev scripts and old files
scripts/
old_files/
*_old
*_old.*
*.bak
*.orig
# Temp files
frontends/web/temp_files/
*.db

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

40
.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,32 @@ 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
instance/
frontends/web/instance/
frontends/web/certs/
# Tests (private)
tests/
# RPi image build artifacts
*.img
*.img.xz
*.img.zst
pishrink.sh
*.img.zst.zip
# Temp file storage
frontends/web/temp_files/
rpi/config.json
# Pre-built Pi tarballs and images (release assets, too large for git)
rpi/*.tar.zst
rpi/*.tar.zst.zip
rpi/*.img
rpi/*.img.zst
rpi/*.img.zst.zip

View File

@@ -1 +1 @@
3.12.0
3.12

6
API.md
View File

@@ -1,4 +1,4 @@
# Stegasoo REST API Documentation (v4.0.1)
# Stegasoo REST API Documentation (v4.0.2)
Complete REST API reference for Stegasoo steganography operations.
@@ -113,7 +113,7 @@ Check API status and configuration.
```json
{
"version": "4.0.1",
"version": "4.0.2",
"has_argon2": true,
"has_qrcode_read": true,
"has_dct": true,
@@ -462,7 +462,7 @@ 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.1
X-Stegasoo-Version: 4.0.2
<binary image data>
```

214
CHANGELOG.md Normal file
View File

@@ -0,0 +1,214 @@
# 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.5] - 2026-01-07
### Added
- **Developer Documentation**: Educational comments throughout core modules
- DCT module: zig-zag diagrams, QIM explanation, Reed-Solomon deep dive
- LSB module: visual bit embedding examples, ChaCha20 pixel selection
- Crypto module: multi-factor KDF flow diagrams, Argon2id reasoning
- CLI module: Click patterns (groups, JSON output, secure input)
- Web UI module: Flask architecture, subprocess isolation, async jobs
- **Pi Test Automation**: `rpi/kickoff-pi-test.sh` script
- One command to flash, wait for boot, setup, and smoke test
- Self-contained (no dotfile dependencies)
- **v4.2 Wishlist**: `WISHLIST-4.2.md` for blue-sky ideas (GPU acceleration)
### Changed
- **Pi MOTD Improvements**:
- Dynamic temperature emoji (ice/cool/fire based on temp)
- Rocket emoji for service status, globe emoji for URL
- Shortened Debian boilerplate message
- Fixed escaped variable syntax in heredoc
## [4.1.3] - 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
- **Custom SSL Documentation**: Guide for replacing certs, Let's Encrypt setup
### 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
- **SSL certificate generation**: Wizard and setup now generate certs when HTTPS enabled
- DCT decode reliability improvements
- Fixed `gum --inline` flag compatibility (not supported in all versions)
- Wizard banner alignment and spacing issues
- Better error handling in app.py for SSL failures
## [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.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5
[4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3
[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

180
CLI.md
View File

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

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

@@ -62,8 +62,9 @@ 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)
# temp_files is for multi-worker temp file sharing
RUN mkdir -p /tmp/stego_uploads /app/frontends/web/instance /app/frontends/web/certs /app/frontends/web/temp_files
# Create non-root user
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads

View File

@@ -227,6 +227,23 @@ docker-compose logs -f
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 |
@@ -418,20 +435,200 @@ pip install stegasoo[all]
### Raspberry Pi
Stegasoo works on Raspberry Pi 4 (2GB+ RAM recommended):
Stegasoo works on Raspberry Pi 4/5 (4GB+ RAM recommended for Web UI).
#### Step 1: Install System Dependencies
```bash
# System dependencies
sudo apt-get install python3-dev libzbar0 libjpeg-dev
# Install (may take a while to compile)
pip install stegasoo[cli]
# For web/api, ensure enough RAM
pip install stegasoo[web] # Needs ~768MB free
sudo apt-get update
sudo apt-get install -y \
build-essential \
git \
libssl-dev \
zlib1g-dev \
libbz2-dev \
libreadline-dev \
libsqlite3-dev \
libncursesw5-dev \
xz-utils \
tk-dev \
libxml2-dev \
libxmlsec1-dev \
libffi-dev \
liblzma-dev \
libzbar0 \
libjpeg-dev
```
**Note:** Argon2 operations will be slower on Pi due to memory-hardness.
#### 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)
---
## Custom SSL Certificates
By default, Stegasoo generates a self-signed certificate for HTTPS. To use your own certificate (e.g., from Let's Encrypt or your organization's CA):
### Replace Self-Signed Certificates
```bash
# Stop the service
sudo systemctl stop stegasoo
# Backup existing certs (optional)
mv /opt/stegasoo/frontends/web/certs /opt/stegasoo/frontends/web/certs.bak
# Create new certs directory
mkdir -p /opt/stegasoo/frontends/web/certs
# Copy your certificates (adjust paths as needed)
cp /path/to/your/certificate.crt /opt/stegasoo/frontends/web/certs/server.crt
cp /path/to/your/private.key /opt/stegasoo/frontends/web/certs/server.key
# Set permissions (key must be readable by service user)
chmod 600 /opt/stegasoo/frontends/web/certs/server.key
chown -R $(whoami):$(whoami) /opt/stegasoo/frontends/web/certs
# Start the service
sudo systemctl start stegasoo
```
### Generate New Self-Signed Certificate
If your certificate expires or you need to regenerate:
```bash
# Stop service
sudo systemctl stop stegasoo
# Generate new cert with SANs
CERT_DIR="/opt/stegasoo/frontends/web/certs"
LOCAL_IP=$(hostname -I | awk '{print $1}')
HOSTNAME=$(hostname)
openssl req -x509 -newkey rsa:2048 \
-keyout "$CERT_DIR/server.key" \
-out "$CERT_DIR/server.crt" \
-days 365 -nodes \
-subj "/O=Stegasoo/CN=$HOSTNAME" \
-addext "subjectAltName=DNS:$HOSTNAME,DNS:$HOSTNAME.local,DNS:localhost,IP:$LOCAL_IP,IP:127.0.0.1"
chmod 600 "$CERT_DIR/server.key"
# Start service
sudo systemctl start stegasoo
```
### Let's Encrypt with Certbot
For publicly accessible servers:
```bash
# Install certbot
sudo apt install certbot
# Get certificate (standalone mode)
sudo certbot certonly --standalone -d yourdomain.com
# Copy to Stegasoo
sudo cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem /opt/stegasoo/frontends/web/certs/server.crt
sudo cp /etc/letsencrypt/live/yourdomain.com/privkey.pem /opt/stegasoo/frontends/web/certs/server.key
sudo chown $(whoami):$(whoami) /opt/stegasoo/frontends/web/certs/*
sudo chmod 600 /opt/stegasoo/frontends/web/certs/server.key
# Restart
sudo systemctl restart stegasoo
```
**Note:** Set up a cron job or systemd timer to copy renewed certificates and restart Stegasoo.
---

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.

97
PLAN-4.1.6.md Normal file
View File

@@ -0,0 +1,97 @@
# Stegasoo v4.1.6 Planning
## UI Tweaks
### 1. Revamp Tron Lines Animation (Carrier/Stego Image)
**Current state:**
- 6-8 snake paths, each with 3-5 segments (~24-40 total lines)
- 2px thick lines
- 30-60px length per segment
- Starting points spread across 80% of image area
- Colors: yellow, cyan, purple, blue with glow
**Target improvements:**
- [x] Thinner lines (1px instead of 2px)
- [x] More numerous (20-40 paths via 5x4 grid, ~60-200 segments total)
- [x] Better distribution across entire image (grid-based seeding)
- [x] Shorter segments (12-30px) for denser "circuit board" look
**Files:**
- `frontends/web/static/style.css` (~881-979) - `.embed-trace` styling
- `frontends/web/static/js/stegasoo.js` (~333-390) - `generateEmbedTraces()`
---
## Tools Page Expansion
### Analysis Tools
- [x] **JPEG Compression Tester** - Preview image at different quality levels (10-100%), show file size delta. Useful for understanding stego survivability.
- [ ] **LSB Plane Viewer** - Visualize least significant bit plane(s) of RGB channels. Classic stego analysis tool.
- [ ] **Histogram Viewer** - Color distribution graph per channel. Anomalies can indicate hidden data.
- [ ] **Image Diff** - Compare two images side-by-side with pixel difference highlighting. Great for original vs stego comparison.
- [ ] **Noise Analysis** - Chi-square or similar statistical analysis for detecting LSB embedding.
### Transform Tools
- [x] **Rotate/Flip** - 90°/180°/270° rotation, horizontal/vertical flip
- [ ] **Resize** - Scale with aspect ratio lock, common presets (50%, 25%, etc.)
- [ ] **Crop** - Basic rectangular crop with preview
- [x] **Format Convert** - PNG ↔ JPEG ↔ WebP with quality slider
### Existing Tools (already done)
- [x] Capacity Calculator
- [x] EXIF Viewer
- [x] EXIF Strip
- [x] Image Peek (header analysis)
### Tools UI/UX Overhaul
**Final Layout: Office-style Ribbon + Two-Panel**
```
┌─────────────────────────────────────────────────────────────┐
│ 📏 📋 👁️ 📊 ┃ ✂️ 🔄 📐 🔀 Image Tools │ ← Icon toolbar
├────────────────────────────────────────┬────────────────────┤
│ [Format: PNG ▼] [Quality: 85] │ │
├────────────────────────────────────────┤ Capacity │
│ │ Calculator │
│ ┌────────────────────────────┐ │ ────────────── │
│ │ │ │ │
│ │ Drop image here │ │ Dimensions: │
│ │ or click │ │ 1920 × 1080 │
│ │ │ │ │
│ └────────────────────────────┘ │ LSB Capacity: │
│ │ 245 KB │
│ [image.jpg] │ │
│ │ ────────────── │
│ │ [Clear] [Export] │
└────────────────────────────────────────┴────────────────────┘
Options + dropzone/preview Results sidebar
```
- Top ribbon: Icon buttons grouped by category (Analyze | Transform)
- Left panel: Tool options + dropzone/preview (INPUT)
- Right panel: Tool name + results/metadata + actions (OUTPUT)
- Flow: Left → Right (input → output)
**Implementation Tasks:**
- [x] Move inline CSS to style.css
- [x] Build icon toolbar ribbon
- [x] Build two-panel layout structure
- [x] Migrate existing tools (Capacity, EXIF, Strip)
- [x] Add new tools (Rotate, Compress, Convert)
- [ ] Loading spinner on all async operations
- [ ] Toast notifications instead of alerts
- [ ] Consistent color coding (green=analysis, amber=transform)
- [ ] Mobile: stack panels vertically
---
## CLI Improvements
### (Add items here)
---
## Other UI Tweaks
### (Add items here)

453
README.md
View File

@@ -2,429 +2,152 @@
A secure steganography system for hiding encrypted messages in images using hybrid authentication.
[![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](https://img.shields.io/badge/License-MIT-green)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
![Security](https://img.shields.io/badge/Security-AES--256--GCM-red)
![Version](https://img.shields.io/badge/Version-4.0.1-purple)
## Features
- 🔐 **AES-256-GCM** authenticated encryption
- 🧠 **Argon2id** memory-hard key derivation (256MB RAM requirement)
- 🎲 **Pseudo-random pixel selection** defeats steganalysis
- 🔑 **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
- 🆕 **DCT steganography** - JPEG-resilient embedding for social media
- 🆕 **Large image support** - Process images up to 14MB+
- **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
## What's New in v4.0.0
| Feature | Description |
|---------|-------------|
| **Simplified Auth** | Removed date dependency - encode/decode anytime without tracking dates |
| **Passphrase** | Renamed from "day phrase" to "passphrase" (no more daily rotation) |
| **Python 3.12** | Requires Python 3.10-3.12 (jpegio incompatible with 3.13) |
| **Large Image Fix** | JPEG normalization prevents crashes with quality=100 images |
| **Subprocess Isolation** | WebUI runs encode/decode in subprocesses for stability |
| **4-Word Default** | Default passphrase increased from 3 to 4 words |
### Breaking Changes from v3.x
- `day_phrase` parameter renamed to `passphrase` in all APIs
- `date_str` parameter removed from encode/decode functions
- Python 3.13 not supported (jpegio C extension incompatibility)
### Embedding Mode Comparison
## Embedding Modes
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|------|------------------|----------------|----------|
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
| **LSB** | ~750 KB | No | Email, file transfer |
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
| **LSB** | ~750 KB | No | Email, direct file transfer |
## WebUI Preview
## Web UI
| 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) |
| Home | Encode | Decode | Generate |
|:----:|:------:|:------:|:--------:|
| ![Home](data/WebUI.webp) | ![Encode](data/WebUI_Encode.webp) | ![Decode](data/WebUI_Decode.webp) | ![Generate](data/WebUI_Generate.webp) |
## Quick Start
```bash
# Install with all features (requires Python 3.10-3.12)
# Install (Python 3.10-3.12)
pip install -e ".[all]"
# Generate credentials (memorize these!)
# Generate credentials
stegasoo generate --pin --words 4
# Encode a message (DCT mode - default, best for social media)
# Encode a message
stegasoo encode \
--ref photo.jpg \
--ref my_photo.jpg \
--carrier meme.jpg \
--passphrase "apple forest thunder mountain" \
--pin 123456 \
--message "Secret message"
# Encode with LSB mode (higher capacity, for email/file transfer)
stegasoo encode \
--ref photo.jpg \
--carrier meme.png \
--passphrase "apple forest thunder mountain" \
--pin 123456 \
--message "Secret message" \
--mode lsb
# Decode (auto-detects mode)
# Decode
stegasoo decode \
--ref photo.jpg \
--stego stego.png \
--ref my_photo.jpg \
--stego stego_image.png \
--passphrase "apple forest thunder mountain" \
--pin 123456
```
For detailed installation instructions, see **[INSTALL.md](INSTALL.md)**.
## Interfaces
---
| 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
Stegasoo uses multiple authentication factors combined with strong cryptography:
```
┌─────────────────────────────────────────────────────────────────┐
│ AUTHENTICATION LAYERS
├─────────────────────────────────────────────────────────────────┤
│ │
│ Reference Photo ──┐
│ (~80-256 bits) │
│ ├──► Argon2id KDF ──► AES-256-GCM Key │
│ Passphrase ───────┤ (256MB RAM)
│ (~43-132 bits) │
│ │ │
│ Static PIN ───────┤ │
│ (~20-30 bits) │ │
│ │ │
│ RSA Key ──────────┘ │
│ (~128 bits) (optional, adds another factor) │
│ │
└─────────────────────────────────────────────────────────────────┘
Reference Photo ──┐
(~80-256 bits)
├──► Argon2id KDF ──► AES-256-GCM Key
Passphrase ───────┤ (256MB RAM)
(~43-132 bits)
PIN ──────────────┤
(~20-30 bits)
RSA Key ──────────┘
(optional)
```
### Entropy Summary
| Component | Entropy | Purpose |
|-----------|---------|---------|
| Reference Photo | ~80-256 bits | Something you have |
| Passphrase (3-12 words) | ~33-132 bits | Something you know |
| PIN (6-9 digits) | ~20-30 bits | Something you know |
| RSA Key (2048-4096 bit) | ~112-128 bits | Something you have (optional) |
| **Combined** | **133-400+ bits** | **Beyond brute force** |
### Attack Resistance
| Attack | Protection |
|--------|------------|
| Brute force | 2^133+ combinations minimum |
| Rainbow tables | Random 16-byte salt per message |
| Steganalysis | Pseudo-random pixel/coefficient selection |
| GPU cracking | Argon2id requires 256MB RAM per attempt |
| Side-channel | Constant-time operations in cryptography library |
| JPEG recompression | DCT mode embeds in frequency domain |
### Security Configurations
| Configuration | Entropy | Use Case |
|--------------|---------|----------|
| 3-word passphrase + 6-digit PIN | ~133 bits | Casual private messaging |
| 4-word passphrase + 9-digit PIN | ~176 bits | Standard security (recommended) |
| 4-word passphrase + RSA 2048 | ~241 bits | File-based authentication |
| 6-word passphrase + PIN + RSA 4096 | ~304 bits | Maximum security |
---
## Interfaces
### Command-Line Interface (CLI)
Full-featured CLI with piping support:
```bash
# Generate with RSA key
stegasoo generate --rsa --rsa-bits 4096 -o mykey.pem -p "password"
# Encode (DCT mode is now default)
stegasoo encode -r ref.jpg -c carrier.jpg -p "passphrase words here" --pin 123456 -m "Message"
# Encode with LSB mode for higher capacity
stegasoo encode -r ref.jpg -c carrier.png -p "passphrase words here" --pin 123456 \
-m "Message" --mode lsb
# Encode a file
stegasoo encode -r ref.jpg -c carrier.png -p "passphrase words here" --pin 123456 -f secret.txt
# Decode to stdout (quiet mode)
stegasoo decode -r ref.jpg -s stego.png -p "passphrase words here" --pin 123456 -q
# Compare LSB vs DCT capacity for an image
stegasoo compare carrier.png
# Check available modes
stegasoo modes
```
📖 Full documentation: **[CLI.md](CLI.md)**
### Web UI
Browser-based interface with drag-and-drop uploads:
```bash
# Start the server
cd frontends/web
python app.py
# Visit http://localhost:5000
```
Features:
- Drag-and-drop image uploads with scan animations
- Real-time entropy calculator
- Native mobile sharing (Web Share API)
- DCT mode default with compact mode selector
- Subprocess isolation for stability
- Large image support (14MB+ tested)
- Streamlined form flow (v3.3.0)
📖 Full documentation: **[WEB_UI.md](WEB_UI.md)**
### REST API
FastAPI-powered REST API with OpenAPI documentation:
```bash
# Start the server
cd frontends/api
uvicorn main:app --host 0.0.0.0 --port 8000
# 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, "passphrase_words": 4}'
# Encode (DCT mode is default)
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Secret" \
-F "passphrase=apple forest thunder mountain" \
-F "pin=123456" \
-F "reference_photo=@photo.jpg" \
-F "carrier=@meme.jpg" \
--output stego.jpg
# Encode with LSB mode
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Secret" \
-F "passphrase=apple forest thunder mountain" \
-F "pin=123456" \
-F "embed_mode=lsb" \
-F "reference_photo=@photo.jpg" \
-F "carrier=@meme.png" \
--output stego.png
# Decode (auto-detects mode)
curl -X POST http://localhost:8000/decode/multipart \
-F "passphrase=apple forest thunder mountain" \
-F "pin=123456" \
-F "reference_photo=@photo.jpg" \
-F "stego_image=@stego.jpg"
```
📖 Full documentation: **[API.md](API.md)**
---
## Project Structure
```
stegasoo/
├── src/stegasoo/ # Core library
│ ├── __init__.py # Public API
│ ├── constants.py # Configuration
│ ├── crypto.py # Encryption/decryption
│ ├── steganography.py # LSB image embedding
│ ├── dct_steganography.py # DCT embedding
│ ├── keygen.py # Credential generation
│ ├── validation.py # Input validation
│ ├── models.py # Data classes
│ ├── exceptions.py # Custom exceptions
│ ├── qr_utils.py # QR code utilities
│ └── utils.py # Utilities
├── frontends/
│ ├── web/ # Flask web UI
│ │ ├── app.py
│ │ ├── subprocess_stego.py # Subprocess isolation
│ │ └── stego_worker.py # Worker script
│ ├── cli/ # Command-line interface
│ └── api/ # FastAPI REST API
├── data/
│ └── bip39-words.txt # BIP-39 wordlist
├── pyproject.toml # Package configuration
├── requirements.txt # Dependencies
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Container orchestration
├── README.md # This file
├── INSTALL.md # Installation guide
├── CLI.md # CLI documentation
├── API.md # API documentation
├── WEB_UI.md # Web UI documentation
├── SECURITY.md # Security documentation
└── UNDER_THE_HOOD.md # Technical deep-dive
```
---
| 4-word passphrase + 6-digit PIN | ~153 bits | Standard security |
| 4-word passphrase + PIN + RSA | ~280+ bits | Maximum security |
## Requirements
| Requirement | Version | Notes |
|-------------|---------|-------|
| Python | 3.10-3.12 | **3.13 not supported** (jpegio incompatibility) |
| RAM | 512 MB+ | 256MB for Argon2 operations |
| Disk | ~100 MB | |
### Key Dependencies
| Package | Purpose |
|---------|---------|
| `cryptography` | AES-256-GCM encryption |
| `Pillow` | Image processing |
| `argon2-cffi` | Memory-hard key derivation |
| `scipy` | DCT transforms |
| `jpegio` | JPEG coefficient manipulation |
| `numpy` | Array operations |
---
## Configuration
### Limits
| Limit | Value |
|-------|-------|
| Max image size | Tested up to 14MB |
| Max message size | 50 KB |
| Max file upload | 5 MB |
| PIN length | 6-9 digits |
| Passphrase length | 3-12 words |
| RSA key sizes | 2048, 3072, 4096 bits |
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `FLASK_ENV` | production | Flask environment |
| `PYTHONPATH` | - | Include `src/` for development |
---
| 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/
# Check DCT support
python -c "from stegasoo import has_dct_support; print(f'DCT: {has_dct_support()}')"
python -c "from stegasoo.dct_steganography import has_jpegio_support; print(f'jpegio: {has_jpegio_support()}')"
black src/ tests/ frontends/
ruff check src/ tests/ frontends/
```
---
## Version History
| Version | Changes |
|---------|---------|
| **4.0.1** | Lint cleanup, test fixes, Web UI improvements (channel key dropdown, LED indicators) |
| **4.0.0** | Channel key support for deployment isolation, removed date dependency, renamed day_phrase→passphrase, Python 3.12 requirement, JPEG normalization fix, subprocess isolation, large image support |
| **3.2.x** | DCT color mode, JPEG output fixes |
| **3.0.x** | Added DCT steganography mode |
| **2.2.x** | QR code support, file embedding |
| **2.0.x** | Web UI, REST API, RSA keys |
| **1.0.x** | Initial release, CLI only |
---
## Upgrading from v3.x
### Code Changes Required
```python
# Old (v3.x)
result = encode(
message="secret",
day_phrase="apple forest thunder",
date_str="2024-01-15",
...
)
# New (v4.0)
result = encode(
message="secret",
passphrase="apple forest thunder mountain",
# No date_str needed!
...
)
```
### CLI Changes
## Docker
```bash
# Old (v3.x)
stegasoo encode --phrase "words" --date 2024-01-15 ...
# Quick start
docker-compose up -d
# New (v4.0)
stegasoo encode --passphrase "words here more" ...
# or short form
stegasoo encode -p "words here more" ...
# 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.
---
## See Also
- **[INSTALL.md](INSTALL.md)** - Detailed installation instructions
- **[CLI.md](CLI.md)** - Command-line interface reference
- **[API.md](API.md)** - REST API documentation
- **[WEB_UI.md](WEB_UI.md)** - Web interface guide
- **[SECURITY.md](SECURITY.md)** - Security model and threat analysis
- **[UNDER_THE_HOOD.md](UNDER_THE_HOOD.md)** - Technical implementation details
*This tool is for educational and legitimate privacy purposes. Users are responsible for complying with applicable laws.*

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)

47
RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1,47 @@
## Stegasoo v4.1.5
### Developer Experience
- **Educational Code Comments**: Core modules now include detailed explanations
- DCT: zig-zag coefficient diagrams, QIM embedding math, Reed-Solomon "Voyager" reference
- LSB: visual bit manipulation examples, ChaCha20 pixel selection
- Crypto: multi-factor KDF flow diagrams, Argon2id memory-hardness reasoning
- CLI/Web: architectural patterns for future contributors
### Raspberry Pi Improvements
- **Streamlined Image Creation**: `pull-image.sh` now handles everything
- Auto-resizes rootfs to exactly 16GB (consistent images from any SD card)
- Disables Pi OS auto-expand
- Compresses with zstd
- Optional .zst.zip wrapper for GitHub releases
- **16GB Minimum**: Pre-built images are now 16GB (was variable)
- **Host Requirements**: `rpi/host-requirements.txt` documents all dependencies
- **Test Automation**: `kickoff-pi-test.sh` for one-command flash+test cycles
### MOTD Polish
- Dynamic temperature emoji (ice/cool/fire based on CPU temp)
- Rocket emoji for service status
- Cleaner formatting
### Raspberry Pi Image
Download `stegasoo-rpi-4.1.5.img.zst.zip` from Releases.
```bash
# Flash (auto-detects SD card)
sudo ./rpi/flash-image.sh stegasoo-rpi-4.1.5.img.zst.zip
# Or manual
zstdcat stegasoo-rpi-4.1.5.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
```
Default login: `admin` / `stegasoo`
First boot runs the setup wizard for WiFi, HTTPS, and channel key configuration.
### Docker
```bash
docker-compose up -d web # Web UI on :5000
docker-compose up -d api # REST API on :8000
```
### Full Changelog
See [CHANGELOG.md](CHANGELOG.md) for complete version history.

View File

@@ -4,16 +4,16 @@
| 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 | |
| 4.1.x | Current Version | What you SHOULD be using. |
| 4.x.x | ⚠️ Security fixes only | Upgrade (EOL soon) |
| <= 3.x.x | ❌ End of life | |
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please email: **security@example.com** (replace with your email)
Instead, please email: **adlee-was-taken@proton.me**
Include:
- Description of the vulnerability

284
SECURITY_AUDIT_PLAN.md Normal file
View File

@@ -0,0 +1,284 @@
# Stegasoo Security Audit Plan
> **Target Audience**: Developers, security reviewers, and deployment administrators
> **Scope**: Web UI, REST API, CLI, and cryptographic core
> **Deployment Model**: Air-gapped / private LAN (primary), Internet-facing (secondary)
---
## Overview
Stegasoo is a steganography tool designed for **air-gapped deployments** on private networks. While the primary threat model assumes a trusted local network, this audit plan covers security best practices for both isolated and potentially exposed deployments.
### Known Limitations (By Design)
- **Self-signed certificates**: HTTPS uses self-signed certs; users must add exceptions or deploy their own CA
- **No rate limiting**: Assumes trusted users on private network
- **Single-node**: No distributed session store; sessions are per-instance
- **Air-gap focus**: External security (firewalls, network isolation) is user's responsibility
---
## 1. Authentication & Authorization
### 1.1 Password Security
- [ ] Passwords hashed with Argon2id (preferred) or PBKDF2 fallback
- [ ] Minimum password length enforced (8+ characters)
- [ ] Password not logged or exposed in error messages
- [ ] Password change requires current password verification
- [ ] Admin re-authentication required for sensitive operations (channel key export)
### 1.2 Session Management
- [ ] Session tokens are cryptographically random
- [ ] Session cookies have `HttpOnly` flag
- [ ] Session cookies have `Secure` flag (when HTTPS enabled)
- [ ] Session cookies have `SameSite` attribute
- [ ] Sessions invalidated on logout
- [ ] Sessions invalidated on password change
- [ ] Session timeout configured appropriately
### 1.3 Authorization
- [ ] Admin-only routes protected by `@admin_required` decorator
- [ ] User-only routes protected by `@login_required` decorator
- [ ] Users cannot access other users' saved channel keys
- [ ] Users cannot modify other users' accounts
- [ ] Role escalation not possible through API manipulation
---
## 2. Cryptographic Implementation
### 2.1 Key Derivation
- [ ] KDF uses Argon2id with appropriate parameters (memory, iterations, parallelism)
- [ ] PBKDF2 fallback uses sufficient iterations (600,000+)
- [ ] Salt is cryptographically random and unique per operation
- [ ] PIN/passphrase combined securely before KDF
### 2.2 Encryption
- [ ] AES-256-GCM used for payload encryption
- [ ] Nonce/IV is unique per encryption operation
- [ ] Authentication tag verified before decryption
- [ ] No padding oracle vulnerabilities
### 2.3 Channel Keys
- [ ] Channel keys are 128-bit (32 hex chars)
- [ ] Channel key derivation uses HKDF or similar
- [ ] Channel isolation prevents cross-channel decryption
- [ ] Fingerprint reveals no information about full key
### 2.4 Random Number Generation
- [ ] All random values use `secrets` module or OS CSPRNG
- [ ] No use of `random` module for security-sensitive operations
---
## 3. Input Validation & Injection Prevention
### 3.1 Web UI
- [ ] All user input sanitized before rendering (XSS prevention)
- [ ] Jinja2 auto-escaping enabled
- [ ] No `| safe` filter on user-controlled content
- [ ] Content-Security-Policy header configured
- [ ] X-Content-Type-Options: nosniff
### 3.2 File Uploads
- [ ] File size limits enforced server-side
- [ ] File type validation (magic bytes, not just extension)
- [ ] Uploaded files not executed
- [ ] Filenames sanitized (path traversal prevention)
- [ ] Temporary files cleaned up after processing
### 3.3 API Inputs
- [ ] JSON schema validation on API endpoints
- [ ] Integer overflow checks on size parameters
- [ ] No SQL injection (parameterized queries only)
- [ ] No command injection (no shell=True with user input)
---
## 4. Steganography-Specific Security
### 4.1 Carrier Image Handling
- [ ] Malformed images don't crash the server (PIL/jpegio hardening)
- [ ] DCT mode subprocess isolation for crash protection
- [ ] Memory limits on image processing
- [ ] No arbitrary code execution from image metadata
### 4.2 Payload Security
- [ ] Payload size limits enforced
- [ ] Encrypted payload indistinguishable from random noise
- [ ] No metadata leakage in output images
- [ ] Reference photo required (prevents dictionary attacks)
### 4.3 Capacity Reporting
- [ ] Capacity calculation doesn't leak information about encoding method
- [ ] Failed decodes don't reveal why (wrong key vs no data vs corrupted)
---
## 5. Network & Transport Security
### 5.1 HTTPS Configuration
- [ ] TLS 1.2+ only (no SSLv3, TLS 1.0/1.1)
- [ ] Strong cipher suites configured
- [ ] Certificate generation uses 2048+ bit RSA or P-256 EC
- [ ] Private key file permissions restricted (600)
### 5.2 Headers
- [ ] X-Frame-Options: DENY (clickjacking prevention)
- [ ] X-Content-Type-Options: nosniff
- [ ] Referrer-Policy: same-origin
- [ ] Permissions-Policy configured
### 5.3 CORS (if applicable)
- [ ] CORS not enabled (or restricted to specific origins)
- [ ] Credentials not allowed cross-origin
---
## 6. Error Handling & Logging
### 6.1 Error Messages
- [ ] Stack traces not exposed to users in production
- [ ] Error messages don't reveal sensitive paths or config
- [ ] Failed login doesn't reveal if username exists
### 6.2 Logging
- [ ] Passwords never logged
- [ ] Channel keys never logged
- [ ] Passphrases never logged
- [ ] Log files have appropriate permissions
- [ ] Sensitive operations logged for audit trail (optional)
---
## 7. Dependency Security
### 7.1 Python Dependencies
- [ ] All dependencies pinned to specific versions
- [ ] No known vulnerabilities in dependencies (run `pip-audit` or `safety`)
- [ ] Dependencies from trusted sources only (PyPI)
### 7.2 Frontend Dependencies
- [ ] All JS/CSS served locally (air-gap ready)
- [ ] No CDN dependencies
- [ ] Bootstrap and libraries are official releases
- [ ] Subresource integrity considered for any external loads
---
## 8. Deployment Security
### 8.1 File Permissions
- [ ] Database file not world-readable (600 or 640)
- [ ] SSL certificates/keys not world-readable
- [ ] Config files with secrets protected
- [ ] Instance directory not in web root
### 8.2 Docker Deployment
- [ ] Container runs as non-root user
- [ ] No unnecessary capabilities
- [ ] Resource limits configured
- [ ] Health checks don't expose sensitive info
### 8.3 Raspberry Pi Deployment
- [ ] Default passwords changed
- [ ] SSH key-only authentication (recommended)
- [ ] Unnecessary services disabled
- [ ] Firewall configured (UFW/iptables)
---
## 9. Air-Gap Specific Considerations
### 9.1 Network Isolation
- [ ] Document expected network topology
- [ ] No phone-home or telemetry
- [ ] No external API calls
- [ ] Works fully offline after deployment
### 9.2 Key Distribution
- [ ] QR code export for channel keys (offline transfer)
- [ ] Print sheet for physical key backup
- [ ] No cloud sync or external key servers
### 9.3 Updates
- [ ] Document offline update procedure
- [ ] Signed releases (future consideration)
- [ ] Checksum verification for downloads
---
## 10. Penetration Testing Checklist
### 10.1 Authentication Attacks
- [ ] Brute force login (note: no rate limiting by design)
- [ ] Session fixation
- [ ] Session hijacking
- [ ] Password reset flow abuse
### 10.2 Injection Attacks
- [ ] SQL injection on all inputs
- [ ] XSS (stored, reflected, DOM-based)
- [ ] Command injection
- [ ] Path traversal
- [ ] SSTI (Server-Side Template Injection)
### 10.3 Business Logic
- [ ] Access control bypass
- [ ] IDOR (Insecure Direct Object Reference)
- [ ] Race conditions
- [ ] Integer overflow in capacity calculations
### 10.4 Cryptographic Attacks
- [ ] Known-plaintext attacks on stego output
- [ ] Timing attacks on password verification
- [ ] Padding oracle attacks
- [ ] Key reuse vulnerabilities
---
## Tools for Automated Testing
```bash
# Dependency vulnerability scan
pip-audit
safety check
# Static analysis
bandit -r stegasoo/ frontends/
# Web security scan (if exposed)
nikto -h https://localhost:5000
OWASP ZAP (manual)
# SSL/TLS configuration
testssl.sh https://localhost:5000
# Python code quality
ruff check .
mypy stegasoo/
```
---
## Audit Schedule
| Phase | Focus Area | Priority |
|-------|-----------|----------|
| Pre-release | Crypto implementation, auth flow | Critical |
| Post-release | Dependency scan, static analysis | High |
| Quarterly | Full penetration test | Medium |
| Ongoing | CVE monitoring for dependencies | High |
---
## Notes
- This plan assumes **trusted users on a private network** as the primary deployment model
- Internet-facing deployments should add rate limiting, fail2ban, and reverse proxy hardening
- For high-security deployments, consider external security audit by professionals
---
*Last updated: 2026-01-07*

View File

@@ -2,7 +2,7 @@
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)
**Version 4.1** - Updated for channel keys and deployment isolation
---
@@ -22,20 +22,20 @@ A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work unde
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEGASOO ARCHITECTURE (v4.0)
│ STEGASOO ARCHITECTURE (v4.1)
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ INPUTS PROCESSING OUTPUT │
│ ─────── ────────── ────── │
│ │
│ Reference Photo ─┐ │
│ Passphrase ──────┼──► Argon2id KDF ──► AES-256 Key │
│ PIN/RSA Key ───── │ │
▼ │
│ Message/File ────────────────────────► AES-256-GCM ──► Ciphertext │
│ Passphrase ──────┼──► Argon2id KDF ──► AES-256 Key
│ PIN/RSA Key ───── │ │
Channel Key ─────┘ (v4.1) ▼ │
│ Message/File ────────────────────────► AES-256-GCM ──► Ciphertext
│ Encryption │ │
│ ▼ │
│ Carrier Image ───────────────────────────────────────► Embedding ─► Stego│
│ Carrier Image ───────────────────────────────────────► Embedding ─► Stego
│ (LSB/DCT) Image │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
@@ -50,11 +50,24 @@ A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work unde
| Header size | 75 bytes | 65 bytes (no date field) |
| Python support | 3.10+ | 3.10-3.12 only |
### v4.1 Changes
| Change | v4.0 | v4.1 |
|--------|------|------|
| Channel keys | None | 32-byte deployment isolation |
| Key derivation | passphrase + ref + pin | passphrase + ref + pin + channel |
| Web auth | Session-based | Session + admin/user roles |
| Raspberry Pi | Manual setup | First-boot wizard with gum |
| Docker | Basic | Production-ready compose |
**Channel Keys** provide deployment isolation - messages encoded on one Stegasoo instance cannot be decoded by another instance with a different channel key, even with the same passphrase/PIN/reference photo.
### Module Responsibilities
| Module | File | Purpose |
|--------|------|---------|
| **Crypto** | `crypto.py` | Key derivation (Argon2id), AES-256-GCM encryption/decryption |
| **Channel** | `channel.py` | Channel key management, deployment isolation (v4.1) |
| **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 |
@@ -626,7 +639,7 @@ Factor 1: Reference Photo ─┐
• 80-256 bits entropy │
• "Something you have" │
├──► Combined entropy: 133-400+ bits
Factor 2: Passphrase │ (Beyond brute force)
Factor 2: Passphrase │ (Beyond brute force)
• 43-132 bits entropy │
• "Something you know" │
• 4 words default (v4.0) │
@@ -688,7 +701,7 @@ AUTHENTICATED ENCRYPTION (AES-256-GCM)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ENCODE FLOW (v4.0)
│ ENCODE FLOW (v4.0) │
└──────────────────────────────────────────────────────────────────────────────┘
User Inputs Processing Output
@@ -714,14 +727,14 @@ Carrier Image ──────────────────────
│ │
┌───────────┴─────┴────────────┐
│ │
LSB Mode DCT Mode
LSB Mode DCT Mode
│ │
▼ ▼
embed_lsb() embed_in_dct()
(pixel LSBs) (DCT coefficients)
embed_lsb() embed_in_dct()
(pixel LSBs) (DCT coefficients)
│ │
▼ ▼
PNG Output PNG or JPEG
PNG Output PNG or JPEG
│ │
└──────────┬───────────────────┘
@@ -793,8 +806,8 @@ Stego Image ──────────► detect_mode() ──────
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)
- **Private channel?** → LSB (maximum capacity)
### v4.0 Simplifications

377
WEB_UI.md
View File

@@ -1,17 +1,22 @@
# Stegasoo Web UI Documentation (v4.0.1)
# Stegasoo Web UI Documentation (v4.1.0)
Complete guide for the Stegasoo web-based steganography interface.
## Table of Contents
- [Overview](#overview)
- [What's New in v4.0.1](#whats-new-in-v401)
- [What's New in v4.1.0](#whats-new-in-v410)
- [Authentication & HTTPS](#authentication--https)
- [Admin Recovery](#admin-recovery)
- [Multi-User Support](#multi-user-support)
- [Installation & Setup](#installation--setup)
- [Pages & Features](#pages--features)
- [Home Page](#home-page)
- [Generate Credentials](#generate-credentials)
- [Encode Message](#encode-message)
- [Decode Message](#decode-message)
- [Tools Page](#tools-page)
- [Account Page](#account-page)
- [About Page](#about-page)
- [Embedding Modes](#embedding-modes)
- [DCT Mode (Default)](#dct-mode-default)
@@ -53,24 +58,270 @@ Built with Flask, Bootstrap 5, and a modern dark theme.
---
## What's New in v4.0.1
## What's New in v4.1.0
Version 4.0.1 adds channel key support and UI improvements:
Version 4.1.0 adds admin recovery, multi-user support, and new tools:
| Feature | Description |
|---------|-------------|
| Channel keys | 256-bit keys for deployment/group isolation |
| Channel dropdown | Select channel mode (Auto/Public/Custom) |
| LED indicators | Visual status indicators for form fields |
| Key capsule styling | Improved RSA key display |
| Streamlined layout | PIN + Channel key in same row |
| **Admin Recovery** | Password reset using secure recovery key |
| **Multi-User Support** | Up to 16 users with role-based access |
| **EXIF Editor** | View, edit, and strip image metadata |
| **Saved Channel Keys** | Users can save/manage channel keys in account |
| **Toast Improvements** | Auto-dismiss after 20 seconds with fade |
**Key benefits:**
-Channel key isolation - Different teams/deployments can't read each other's messages
-Dropdown selection for channel mode instead of radio buttons
-Visual LED indicators show field status
-Cleaner form layout with improved spacing
- ✅ Backward compatible - public mode works without channel key
-Never get locked out - recovery key backup options
-Share access with team members (admin/user roles)
-Full EXIF metadata control in Tools page
-Persistent channel key storage per user
---
## What's New in v4.0.2
Version 4.0.2 added authentication and HTTPS support:
| Feature | Description |
|---------|-------------|
| **Authentication** | Single-admin login with SQLite3 user storage |
| **First-run setup** | Wizard to create admin account on first access |
| **Account management** | Change password page |
| **Optional HTTPS** | Auto-generated self-signed certificates |
---
## Authentication & HTTPS
### Overview
v4.0.2 adds optional authentication and HTTPS for secure home network deployment.
### First-Run Setup
On first access, you'll be prompted to create an admin account:
1. Navigate to `http://localhost:5000`
2. You'll be redirected to `/setup`
3. Enter a username (e.g., "admin")
4. Enter a password (minimum 8 characters)
5. Confirm the password
6. Click "Create Admin Account"
The admin account is stored in `frontends/web/instance/stegasoo.db` (SQLite).
### Login
After setup, protected pages require login:
- **Protected routes:** `/encode`, `/decode`, `/generate`, `/account`, `/api/*`
- **Public routes:** `/`, `/about`, `/login`, `/setup`
### Account Management
Access `/account` to:
- View current username
- Change your password
- Logout
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `STEGASOO_AUTH_ENABLED` | `true` | Enable/disable authentication |
| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS with self-signed certs |
| `STEGASOO_HOSTNAME` | `localhost` | Hostname for certificate generation |
### Enabling HTTPS
```bash
# Enable HTTPS
export STEGASOO_HTTPS_ENABLED=true
export STEGASOO_HOSTNAME=stegasoo.local # Optional: your hostname
cd frontends/web
python app.py
```
On first run with HTTPS enabled:
- Generates RSA 2048-bit private key
- Creates self-signed X.509 certificate (365 days validity)
- Stores in `frontends/web/certs/`
- Server starts on https://localhost:5000
**Note:** Browsers will show a security warning for self-signed certificates. This is expected for home network use.
**Tip:** To avoid browser warnings, use [mkcert](https://github.com/FiloSottile/mkcert) to generate locally-trusted certificates:
```bash
# Install mkcert and create local CA (one-time)
mkcert -install
# Generate trusted certs for your Pi
mkcert -key-file key.pem -cert-file cert.pem stegasoo.local localhost 127.0.0.1 YOUR_PI_IP
# Copy to certs directory
mv key.pem cert.pem frontends/web/certs/
```
### Disabling Authentication
For development or trusted networks:
```bash
export STEGASOO_AUTH_ENABLED=false
cd frontends/web
python app.py
```
### Docker Configuration
```yaml
# docker-compose.yml
services:
web:
environment:
STEGASOO_AUTH_ENABLED: "true"
STEGASOO_HTTPS_ENABLED: "true"
STEGASOO_HOSTNAME: "stegasoo.local"
volumes:
- ./instance:/app/frontends/web/instance # Persist user database
- ./certs:/app/frontends/web/certs # Persist SSL certs
```
### Security Notes
- Passwords are hashed with Argon2id (time_cost=3, memory_cost=64MB)
- Single admin user only (no registration)
- Session-based authentication using Flask sessions
- Database stored in `instance/stegasoo.db` (add to `.gitignore`)
---
## Admin Recovery
### Overview
If you forget your admin password, the recovery key is the ONLY way to reset it. Generate and save your recovery key immediately after setup.
### Recovery Key Format
```
XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
└──────────────────────────────────────┘
32 alphanumeric characters (8 groups of 4)
```
### Backup Options
The recovery key can be saved in multiple ways:
| Method | Description | Security Level |
|--------|-------------|----------------|
| **Text file** | Plain text download | Low - store securely |
| **QR code** | Obfuscated PNG image | Medium - XOR'd with magic hash |
| **Stego image** | Hidden in carrier image | High - requires original image |
### Generating a Recovery Key
**During first-run setup:**
1. Complete the admin account wizard
2. You'll be prompted to save your recovery key
3. Choose backup method(s)
4. Confirm you've saved the key
**From Account page (admin only):**
1. Navigate to `/account`
2. Click "Generate Recovery Key" (or "Regenerate" if one exists)
3. Save using your preferred method
4. Check the confirmation box
5. Click "Save New Key"
### QR Code Obfuscation
QR codes are not plain text - they're XOR'd with a fixed obfuscation key derived from Stegasoo's magic headers. This prevents casual scanning from revealing the key.
### Stego Backup
Hide your recovery key inside an image using Stegasoo itself:
1. Upload a carrier image (JPG/PNG, 50KB-2MB)
2. Click the "Stego" button
3. Download the stego image
4. **Important:** Keep the original carrier image - you'll need it for extraction
### Recovering Your Password
**URL:** `/recover`
1. Navigate to the login page
2. Click "Forgot password?"
3. **Option A:** Enter recovery key directly
4. **Option B:** Extract from stego backup:
- Expand "Extract from stego backup"
- Upload your stego backup image
- Upload the original carrier/reference image
- Click "Extract Key"
5. Enter and confirm your new password
6. Click "Reset Password"
### CLI Recovery
For locked-out scenarios where you can't access the web UI:
```bash
stegasoo admin recover --db frontends/web/instance/stegasoo.db
```
You'll be prompted for your recovery key and new password.
### Important Notes
- Recovery keys are instance-bound (tied to the specific database)
- Regenerating a key invalidates the previous one
- Store backups in a secure, separate location
- Without a recovery key, the only option is to delete the database and reconfigure
---
## Multi-User Support
### Overview
Admins can create up to 16 additional users with role-based access control.
### Roles
| Role | Permissions |
|------|-------------|
| **Admin** | Full access: encode, decode, generate, tools, user management, recovery |
| **User** | Standard access: encode, decode, generate, account settings |
### User Management
**URL:** `/admin/users` (admin only)
#### Creating Users
1. Click "Add User"
2. Enter username
3. Select role (admin/user)
4. A temporary password is generated
5. Share the temporary password securely with the new user
6. User must change password on first login
#### Managing Users
- View all users and their roles
- Reset user passwords (generates new temp password)
- Change user roles
- Delete users (except yourself)
### User Limits
- Maximum 16 users total (including admin)
- At least one admin must exist
- Users can't delete or demote the last admin
---
@@ -441,6 +692,83 @@ If decryption fails:
---
### Tools Page
**URL:** `/tools`
The Tools page provides utilities for image analysis and manipulation.
#### EXIF Editor
View and edit image metadata (EXIF data).
**Features:**
- View all EXIF fields from uploaded image
- Inline editing of individual fields
- Clear all metadata with one click
- Download cleaned image
**Usage:**
1. Upload an image (JPG recommended - richest EXIF data)
2. View all metadata fields in a table
3. Click any field to edit its value
4. Click "Save" to apply changes
5. Use "Clear All" to strip all metadata
6. Download the modified image
**Common EXIF fields:**
| Field | Description |
|-------|-------------|
| Make/Model | Camera manufacturer and model |
| DateTime | When the photo was taken |
| GPSLatitude/GPSLongitude | Location coordinates |
| Software | Editing software used |
| Artist | Photographer name |
**Privacy tip:** Always strip EXIF data before sharing images publicly to remove location and device information.
#### Peek (Stego Detection)
Quickly check if an image contains hidden data.
#### Strip Metadata
Remove all metadata from an image in one click.
---
### Account Page
**URL:** `/account`
Manage your account settings and preferences.
#### Password Change
1. Enter current password
2. Enter new password (minimum 8 characters)
3. Confirm new password
4. Click "Change Password"
#### Saved Channel Keys (v4.1.0)
Users can save frequently-used channel keys for quick access:
1. Click "Add Channel Key"
2. Enter a name/label for the key
3. Paste the channel key
4. Click "Save"
Saved keys appear in a dropdown during encode/decode operations.
#### Recovery Key Management (Admin only)
- View recovery key status (configured/not configured)
- Generate or regenerate recovery key
- Download backup options (text, QR, stego)
---
### About Page
**URL:** `/about`
@@ -448,10 +776,10 @@ If decryption fails:
Information about the Stegasoo project, security model, and credits.
Includes:
- Version information (v3.3.0)
- Recent UI improvements
- Version information (v4.1.0)
- Feature highlights
- Security model overview
- Dependency status (Argon2, QR code support)
- Dependency status (Argon2, scipy/DCT, QR code support)
---
@@ -752,6 +1080,10 @@ Both modes use the same strong encryption (AES-256-GCM with Argon2id key derivat
|----------|---------|-------------|
| `FLASK_ENV` | production | Flask environment |
| `PYTHONPATH` | - | Include `src/` for development |
| `STEGASOO_AUTH_ENABLED` | `true` | Enable/disable authentication (v4.0.2) |
| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS with self-signed certs (v4.0.2) |
| `STEGASOO_HOSTNAME` | `localhost` | Hostname for certificate CN (v4.0.2) |
| `STEGASOO_CHANNEL_KEY` | - | Channel key for deployment isolation |
### Application Limits
@@ -808,12 +1140,23 @@ services:
target: web
ports:
- "5000:5000"
environment:
STEGASOO_AUTH_ENABLED: "true"
STEGASOO_HTTPS_ENABLED: "false"
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
volumes:
- stegasoo-web-data:/app/frontends/web/instance
- stegasoo-web-certs:/app/frontends/web/certs
deploy:
resources:
limits:
memory: 768M
reservations:
memory: 384M
volumes:
stegasoo-web-data:
stegasoo-web-certs:
```
---

42
WISHLIST-4.2.md Normal file
View File

@@ -0,0 +1,42 @@
# Stegasoo v4.2 Wishlist
Blue sky ideas for future development. No timeline - just capturing thoughts.
---
## Performance
### GPU-Accelerated DCT Encoding/Decoding
- **Idea**: Leverage GPU for JPEG DCT coefficient manipulation
- **Potential Approaches**:
- OpenCL/CUDA for parallel DCT operations
- Raspberry Pi VideoCore IV/VI GPU compute
- WebGPU for browser-based acceleration
- **Challenges**:
- jpegio library is CPU-bound (C extension)
- Would need custom DCT implementation
- Memory transfer overhead may negate gains for small images
- **Research**:
- libjpeg-turbo uses SIMD but not GPU
- nvJPEG (NVIDIA) does GPU-accelerated JPEG
- Could potentially use GPU for the embedding math, not JPEG decode
---
## Features
(Add ideas here)
---
## Infrastructure
(Add ideas here)
---
## Notes
- This is a living document - add ideas anytime
- Not all ideas will be implemented
- Feasibility research needed before committing to roadmap

View File

@@ -1,61 +0,0 @@
#!/bin/bash
# Stegasoo Build Script
# Usage: ./build.sh [base|fast|full|clean]
set -e
case "${1:-fast}" in
base)
# Build base image with all dependencies (run once, or when deps change)
echo "🔨 Building base image (this takes 5-10 minutes)..."
docker build -f Dockerfile.base -t stegasoo-base:latest .
echo "✅ Base image built! Future builds will be fast."
echo ""
echo "Optional: Push to registry for team use:"
echo " docker tag stegasoo-base:latest yourregistry/stegasoo-base:latest"
echo " docker push yourregistry/stegasoo-base:latest"
;;
fast)
# Fast build using pre-built base image
if ! docker image inspect stegasoo-base:latest >/dev/null 2>&1; then
echo "⚠️ Base image not found. Building it first (one-time)..."
$0 base
fi
echo "🚀 Fast build using base image..."
docker-compose build
echo "✅ Done! Start with: docker-compose up -d"
;;
full)
# Full rebuild from scratch (slow, but no base image needed)
echo "🐢 Full build from scratch (slow)..."
docker-compose build --no-cache
echo "✅ Done! Start with: docker-compose up -d"
;;
clean)
# Clean up everything
echo "🧹 Cleaning up..."
docker-compose down --rmi local -v 2>/dev/null || true
docker rmi stegasoo-base:latest 2>/dev/null || true
echo "✅ Cleaned!"
;;
*)
echo "Stegasoo Build Script"
echo ""
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " base Build the base image (one-time, 5-10 min)"
echo " fast Fast build using base image (default, ~10 sec)"
echo " full Full rebuild from scratch (slow, no base needed)"
echo " clean Remove all images and volumes"
echo ""
echo "Typical workflow:"
echo " 1. First time: $0 base"
echo " 2. Daily dev: $0 fast (or just 'docker-compose build')"
echo " 3. Deps change: $0 base (rebuild base image)"
;;
esac

View File

@@ -1,170 +0,0 @@
#!/usr/bin/env python3
"""
Diagnostic script to check for scipy/numpy issues.
Run this BEFORE starting the web app.
Usage:
python check_scipy.py
"""
import sys
print(f"Python version: {sys.version}")
print()
# Check numpy
try:
import numpy as np
print(f"NumPy version: {np.__version__}")
print(f"NumPy config:")
np.show_config()
except ImportError as e:
print(f"NumPy not installed: {e}")
except Exception as e:
print(f"NumPy error: {e}")
print()
print("-" * 50)
print()
# Check scipy
try:
import scipy
print(f"SciPy version: {scipy.__version__}")
except ImportError as e:
print(f"SciPy not installed: {e}")
print()
# Check PIL
try:
from PIL import Image
print(f"Pillow version: {Image.__version__}")
except ImportError as e:
print(f"Pillow not installed: {e}")
print()
print("-" * 50)
print()
# Test scipy DCT directly
print("Testing scipy DCT...")
try:
from scipy.fftpack import dct, idct
import numpy as np
# Create test array
test = np.random.rand(8, 8).astype(np.float64)
print(f"Input array shape: {test.shape}, dtype: {test.dtype}")
# Test 1D DCT
row = test[0, :]
result = dct(row, norm='ortho')
print(f"1D DCT result shape: {result.shape}, dtype: {result.dtype}")
# Test 2D DCT (the potentially problematic operation)
result2d = dct(dct(test.T, norm='ortho').T, norm='ortho')
print(f"2D DCT result shape: {result2d.shape}, dtype: {result2d.dtype}")
# Test inverse
recovered = idct(idct(result2d.T, norm='ortho').T, norm='ortho')
error = np.max(np.abs(test - recovered))
print(f"Round-trip error: {error}")
if error < 1e-10:
print("✓ scipy DCT working correctly")
else:
print("⚠ scipy DCT has precision issues")
except Exception as e:
print(f"✗ scipy DCT failed: {e}")
import traceback
traceback.print_exc()
print()
print("-" * 50)
print()
# Test with larger array (more like real image processing)
print("Testing with larger arrays (512x512)...")
try:
from scipy.fftpack import dct, idct
import numpy as np
import gc
# Simulate processing many 8x8 blocks
large_array = np.random.rand(512, 512).astype(np.float64)
print(f"Large array shape: {large_array.shape}, size: {large_array.nbytes} bytes")
count = 0
for y in range(0, 512, 8):
for x in range(0, 512, 8):
block = large_array[y:y+8, x:x+8].copy()
dct_block = dct(dct(block.T, norm='ortho').T, norm='ortho')
recovered = idct(idct(dct_block.T, norm='ortho').T, norm='ortho')
large_array[y:y+8, x:x+8] = recovered
count += 1
print(f"Processed {count} blocks successfully")
del large_array
gc.collect()
print("✓ Large array processing completed")
except Exception as e:
print(f"✗ Large array processing failed: {e}")
import traceback
traceback.print_exc()
print()
print("-" * 50)
print()
# Test PIL with large image
print("Testing PIL with large image...")
try:
from PIL import Image
import io
# Create a large test image
img = Image.new('RGB', (4000, 3000), color=(128, 128, 128))
# Save to bytes
buffer = io.BytesIO()
img.save(buffer, format='PNG')
img_bytes = buffer.getvalue()
print(f"Test image size: {len(img_bytes)} bytes")
# Re-open and process
buffer2 = io.BytesIO(img_bytes)
img2 = Image.open(buffer2)
print(f"Re-opened image: {img2.size}, mode: {img2.mode}")
# Convert to numpy array
import numpy as np
arr = np.array(img2)
print(f"NumPy array: {arr.shape}, dtype: {arr.dtype}")
# Clean up
img.close()
img2.close()
buffer.close()
buffer2.close()
del arr
gc.collect()
print("✓ PIL large image test completed")
except Exception as e:
print(f"✗ PIL test failed: {e}")
import traceback
traceback.print_exc()
print()
print("=" * 50)
print("Diagnostics complete")
print()
print("If no errors above but web app still crashes, try:")
print("1. pip install --upgrade scipy numpy pillow")
print("2. pip install scipy==1.11.4 numpy==1.26.4 # Known stable versions")
print("3. Check if using conda vs pip (mixing can cause issues)")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 37 KiB

BIN
data/WebUI_About.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
data/WebUI_Account.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 32 KiB

BIN
data/WebUI_Login.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
data/WebUI_Setup.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -1,215 +0,0 @@
#!/usr/bin/env python3
"""
Debug script for DCT/jpegio extraction issues.
Run from the stegasoo directory.
"""
import sys
import struct
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / 'src'))
import hashlib
import numpy as np
# Check for jpegio
try:
import jpegio as jio
print("✓ jpegio available")
except ImportError:
print("✗ jpegio NOT available")
sys.exit(1)
def get_usable_positions(coef_array, min_magnitude=2):
"""Get positions of usable coefficients."""
positions = []
h, w = coef_array.shape
for row in range(h):
for col in range(w):
# Skip DC coefficients (top-left of each 8x8 block)
if (row % 8 == 0) and (col % 8 == 0):
continue
if abs(coef_array[row, col]) >= min_magnitude:
positions.append((row, col))
return positions
def generate_order(num_positions, seed):
"""Generate pseudo-random order for coefficient selection."""
hash_bytes = hashlib.sha256(seed + b"jpeg_coef_order").digest()
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big'))
order = list(range(num_positions))
rng.shuffle(order)
return order
def extract_bits(coef_array, positions, order, num_bits):
"""Extract bits from coefficients."""
bits = []
for i, pos_idx in enumerate(order):
if i >= num_bits:
break
row, col = positions[pos_idx]
coef = coef_array[row, col]
bits.append(coef & 1)
return bits
def bits_to_bytes(bits):
"""Convert list of bits to bytes."""
result = []
for i in range(0, len(bits), 8):
byte_bits = bits[i:i+8]
if len(byte_bits) == 8:
byte_val = sum(byte_bits[j] << (7-j) for j in range(8))
result.append(byte_val)
return bytes(result)
def main():
if len(sys.argv) < 3:
print("Usage: python debug_jpegio.py <stego_image.jpg> <reference_photo>")
print("\nOptional: add passphrase, pin, key path")
print(" python debug_jpegio.py stego.jpg ref.jpg 'passphrase' '123456' key.pem")
sys.exit(1)
stego_path = sys.argv[1]
ref_path = sys.argv[2]
passphrase = sys.argv[3] if len(sys.argv) > 3 else "test"
pin = sys.argv[4] if len(sys.argv) > 4 else ""
key_path = sys.argv[5] if len(sys.argv) > 5 else None
print(f"\n{'='*60}")
print("JPEGIO DCT EXTRACTION DEBUG")
print(f"{'='*60}")
print(f"Stego image: {stego_path}")
print(f"Reference: {ref_path}")
print(f"Passphrase: '{passphrase}'")
print(f"PIN: '{pin}'")
print(f"Key: {key_path}")
# Load stego image with jpegio
print(f"\n[1] Loading stego image with jpegio...")
try:
jpeg = jio.read(stego_path)
print(f" ✓ jpegio.read() succeeded")
print(f" Number of components: {len(jpeg.coef_arrays)}")
for i, arr in enumerate(jpeg.coef_arrays):
print(f" Component {i}: shape={arr.shape}, dtype={arr.dtype}")
except Exception as e:
print(f" ✗ Failed: {e}")
sys.exit(1)
# Get coefficient array (channel 0)
coef_array = jpeg.coef_arrays[0]
print(f"\n[2] Coefficient array analysis...")
print(f" Shape: {coef_array.shape}")
print(f" Non-zero coefficients: {np.count_nonzero(coef_array)}")
print(f" Min value: {coef_array.min()}")
print(f" Max value: {coef_array.max()}")
# Get usable positions
print(f"\n[3] Finding usable positions (|coef| >= 2, non-DC)...")
positions = get_usable_positions(coef_array)
print(f" Usable positions: {len(positions)}")
print(f" Capacity: ~{len(positions) // 8} bytes")
# Generate seed (this needs to match the encode seed!)
print(f"\n[4] Generating seed...")
# Load reference photo
ref_data = Path(ref_path).read_bytes()
ref_hash = hashlib.sha256(ref_data).digest()
print(f" Reference hash: {ref_hash[:8].hex()}...")
# Load RSA key if provided
rsa_component = b""
if key_path:
try:
from stegasoo import load_rsa_key
key_data = Path(key_path).read_bytes()
# Try without password first
try:
rsa_key = load_rsa_key(key_data, password=None)
except:
rsa_key = load_rsa_key(key_data, password="testpass")
# Get public key bytes for seed
from cryptography.hazmat.primitives import serialization
pub_bytes = rsa_key.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
rsa_component = hashlib.sha256(pub_bytes).digest()
print(f" RSA key loaded, hash: {rsa_component[:8].hex()}...")
except Exception as e:
print(f" ✗ Could not load RSA key: {e}")
# Build seed like stegasoo does
# This is the critical part - must match encoding!
seed_parts = [
ref_hash,
passphrase.encode('utf-8'),
pin.encode('utf-8') if pin else b"",
rsa_component,
]
seed = hashlib.sha256(b"".join(seed_parts)).digest()
print(f" Combined seed: {seed[:8].hex()}...")
# Generate order
print(f"\n[5] Generating coefficient order...")
order = generate_order(len(positions), seed)
print(f" First 10 indices: {order[:10]}")
# Try to extract header
print(f"\n[6] Extracting header (first 80 bits = 10 bytes)...")
HEADER_SIZE = 10
header_bits = extract_bits(coef_array, positions, order, HEADER_SIZE * 8)
header_bytes = bits_to_bytes(header_bits)
print(f" Raw header bytes: {header_bytes.hex()}")
print(f" As ASCII (if printable): {repr(header_bytes)}")
# Check for JPGS magic
JPEGIO_MAGIC = b'JPGS'
if header_bytes[:4] == JPEGIO_MAGIC:
print(f" ✓ Found JPEGIO magic bytes!")
version = header_bytes[4]
flags = header_bytes[5]
data_length = struct.unpack('>I', header_bytes[6:10])[0]
print(f" Version: {version}")
print(f" Flags: {flags}")
print(f" Data length: {data_length} bytes")
if data_length > 0 and data_length < len(positions) // 8:
print(f"\n[7] Extracting payload ({data_length} bytes)...")
total_bits = (HEADER_SIZE + data_length) * 8
all_bits = extract_bits(coef_array, positions, order, total_bits)
data_bits = all_bits[HEADER_SIZE * 8:]
payload = bits_to_bytes(data_bits)
print(f" Payload (first 64 bytes): {payload[:64].hex()}")
print(f" This should be encrypted data starting with salt/IV")
else:
print(f" ✗ Invalid data length: {data_length}")
else:
print(f" ✗ No JPEGIO magic found")
print(f" Expected: {JPEGIO_MAGIC.hex()} ('JPGS')")
print(f" Got: {header_bytes[:4].hex()} ('{header_bytes[:4]}')")
# Try alternate interpretations
print(f"\n[7] Trying alternate header interpretations...")
# Maybe it's scipy DCT format?
DCT_MAGIC = b'DCTS'
if header_bytes[:4] == DCT_MAGIC:
print(f" Found SCIPY DCT magic - wrong extraction method!")
else:
# Show bit distribution
print(f" First 32 extracted bits: {header_bits[:32]}")
# Check if bits look random or patterned
ones = sum(header_bits[:80])
print(f" Bit distribution: {ones}/80 ones ({100*ones/80:.1f}%)")
print(f"\n{'='*60}")
print("DEBUG COMPLETE")
print(f"{'='*60}\n")
if __name__ == '__main__':
main()

View File

@@ -1,5 +1,3 @@
version: '3.8'
# Shared environment variables
x-common-env: &common-env
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
@@ -18,13 +16,21 @@ services:
environment:
<<: *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: 768M
memory: 2048M
reservations:
memory: 384M
memory: 1024M
# ============================================================================
# REST API (FastAPI)
@@ -42,6 +48,13 @@ services:
deploy:
resources:
limits:
memory: 768M
memory: 2048M
reservations:
memory: 384M
memory: 1024M
# 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,500 +0,0 @@
# API Update Summary for v3.2.0
## Overview
The FastAPI REST API has been updated to align with Stegasoo v3.2.0's breaking changes:
1. **Removed date dependency** - No `date_str` field in requests
2. **Renamed day_phrase → passphrase** - Updated all request/response models
3. **Updated generation** - Now generates single passphrase instead of daily phrases
## Breaking Changes
### Request Model Changes
#### 1. EncodeRequest & EncodeFileRequest
**Before (v3.1.0):**
```python
class EncodeRequest(BaseModel):
message: str
reference_photo_base64: str
carrier_image_base64: str
day_phrase: str # ← Changed to passphrase
pin: str = ""
rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None
date_str: Optional[str] = None # ← REMOVED
embed_mode: EmbedModeType = "lsb"
```
**After (v3.2.0):**
```python
class EncodeRequest(BaseModel):
message: str
reference_photo_base64: str
carrier_image_base64: str
passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
pin: str = ""
rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None
# date_str removed in v3.2.0
embed_mode: EmbedModeType = "lsb"
dct_output_format: DctOutputFormatType = "png"
dct_color_mode: DctColorModeType = "grayscale"
```
#### 2. DecodeRequest
**Before (v3.1.0):**
```python
class DecodeRequest(BaseModel):
stego_image_base64: str
reference_photo_base64: str
day_phrase: str # ← Changed to passphrase
pin: str = ""
rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None
embed_mode: ExtractModeType = "auto"
```
**After (v3.2.0):**
```python
class DecodeRequest(BaseModel):
stego_image_base64: str
reference_photo_base64: str
passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
pin: str = ""
rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None
embed_mode: ExtractModeType = "auto"
```
#### 3. GenerateRequest
**Before (v3.1.0):**
```python
class GenerateRequest(BaseModel):
use_pin: bool = True
use_rsa: bool = False
pin_length: int = Field(default=6, ge=MIN_PIN_LENGTH, le=MAX_PIN_LENGTH)
rsa_bits: int = Field(default=2048)
words_per_phrase: int = Field(default=3, ge=MIN_PHRASE_WORDS, le=MAX_PHRASE_WORDS)
```
**After (v3.2.0):**
```python
class GenerateRequest(BaseModel):
use_pin: bool = True
use_rsa: bool = False
pin_length: int = Field(default=6, ge=MIN_PIN_LENGTH, le=MAX_PIN_LENGTH)
rsa_bits: int = Field(default=2048)
words_per_passphrase: int = Field(
default=DEFAULT_PASSPHRASE_WORDS, # = 4, was 3
ge=MIN_PASSPHRASE_WORDS,
le=MAX_PASSPHRASE_WORDS,
description="Words per passphrase (v3.2.0: default increased to 4)"
)
```
### Response Model Changes
#### 1. GenerateResponse
**Before (v3.1.0):**
```python
class GenerateResponse(BaseModel):
phrases: dict[str, str] # Monday -> phrase, Tuesday -> phrase, etc.
pin: Optional[str] = None
rsa_key_pem: Optional[str] = None
entropy: dict[str, int]
```
**After (v3.2.0):**
```python
class GenerateResponse(BaseModel):
passphrase: str = Field(description="Single passphrase (v3.2.0: no daily rotation)")
pin: Optional[str] = None
rsa_key_pem: Optional[str] = None
entropy: dict[str, int]
# Legacy field for compatibility
phrases: Optional[dict[str, str]] = Field(
default=None,
description="Deprecated: Use 'passphrase' instead"
)
```
#### 2. EncodeResponse
**Before (v3.1.0):**
```python
class EncodeResponse(BaseModel):
stego_image_base64: str
filename: str
capacity_used_percent: float
date_used: str
day_of_week: str
embed_mode: str
output_format: str = "png"
color_mode: str = "color"
```
**After (v3.2.0):**
```python
class EncodeResponse(BaseModel):
stego_image_base64: str
filename: str
capacity_used_percent: float
embed_mode: str
output_format: str = "png"
color_mode: str = "color"
# Legacy fields (no longer used in crypto)
date_used: Optional[str] = Field(
default=None,
description="Deprecated: Date no longer used in v3.2.0"
)
day_of_week: Optional[str] = Field(
default=None,
description="Deprecated: Date no longer used in v3.2.0"
)
```
### Endpoint Changes
#### 1. POST /encode
**Before (v3.1.0):**
```json
{
"message": "Secret message",
"reference_photo_base64": "...",
"carrier_image_base64": "...",
"day_phrase": "apple forest thunder",
"date_str": "2025-01-15",
"pin": "123456",
"embed_mode": "lsb"
}
```
**After (v3.2.0):**
```json
{
"message": "Secret message",
"reference_photo_base64": "...",
"carrier_image_base64": "...",
"passphrase": "apple forest thunder mountain",
"pin": "123456",
"embed_mode": "lsb"
}
```
#### 2. POST /decode
**Before (v3.1.0):**
```json
{
"stego_image_base64": "...",
"reference_photo_base64": "...",
"day_phrase": "apple forest thunder",
"pin": "123456",
"embed_mode": "auto"
}
```
**After (v3.2.0):**
```json
{
"stego_image_base64": "...",
"reference_photo_base64": "...",
"passphrase": "apple forest thunder mountain",
"pin": "123456",
"embed_mode": "auto"
}
```
#### 3. POST /generate
**Response Before (v3.1.0):**
```json
{
"phrases": {
"Monday": "apple forest thunder",
"Tuesday": "banana river lightning",
...
},
"pin": "123456",
"rsa_key_pem": null,
"entropy": {
"phrase": 33,
"pin": 20,
"rsa": 0,
"total": 53
}
}
```
**Response After (v3.2.0):**
```json
{
"passphrase": "apple forest thunder mountain",
"pin": "123456",
"rsa_key_pem": null,
"entropy": {
"passphrase": 44,
"pin": 20,
"rsa": 0,
"total": 64
},
"phrases": null
}
```
#### 4. POST /encode/multipart
**Form Fields Before (v3.1.0):**
- `day_phrase` (required)
- `date_str` (optional)
- `reference_photo` (file)
- `carrier` (file)
- ...
**Form Fields After (v3.2.0):**
- `passphrase` (required) ← renamed from day_phrase
- `reference_photo` (file)
- `carrier` (file)
- ... (date_str removed)
**Response Headers Before (v3.1.0):**
```
X-Stegasoo-Date: 2025-01-15
X-Stegasoo-Day: Wednesday
X-Stegasoo-Capacity-Percent: 25.5
X-Stegasoo-Embed-Mode: lsb
```
**Response Headers After (v3.2.0):**
```
X-Stegasoo-Capacity-Percent: 25.5
X-Stegasoo-Embed-Mode: lsb
X-Stegasoo-Output-Format: png
X-Stegasoo-Color-Mode: color
X-Stegasoo-Version: 3.2.0
```
### New Status Endpoint Information
#### GET /
**Added to response:**
```json
{
"version": "3.2.0",
...
"breaking_changes": {
"date_removed": "No date_str parameter needed - encode/decode anytime",
"passphrase_renamed": "day_phrase → passphrase (single passphrase, no daily rotation)",
"format_version": 4,
"backward_compatible": false
}
}
```
## Migration Guide for API Clients
### 1. Update Request Bodies
**Find and replace in client code:**
```javascript
// Before
{
day_phrase: "apple forest thunder",
date_str: "2025-01-15"
}
// After
{
passphrase: "apple forest thunder mountain"
}
```
### 2. Update Response Handling
**Before:**
```javascript
const response = await fetch('/encode', {
method: 'POST',
body: JSON.stringify({
message: "secret",
day_phrase: "words",
date_str: "2025-01-15",
...
})
});
const data = await response.json();
console.log(data.date_used); // "2025-01-15"
console.log(data.day_of_week); // "Wednesday"
```
**After:**
```javascript
const response = await fetch('/encode', {
method: 'POST',
body: JSON.stringify({
message: "secret",
passphrase: "longer words here now",
// date_str removed
...
})
});
const data = await response.json();
// date_used and day_of_week are null in v3.2.0
```
### 3. Update Generate Endpoint Usage
**Before:**
```javascript
const creds = await fetch('/generate', {
method: 'POST',
body: JSON.stringify({ use_pin: true })
}).then(r => r.json());
// Use Monday's phrase
const mondayPhrase = creds.phrases['Monday'];
```
**After:**
```javascript
const creds = await fetch('/generate', {
method: 'POST',
body: JSON.stringify({ use_pin: true })
}).then(r => r.json());
// Use single passphrase
const passphrase = creds.passphrase;
```
### 4. Update Multipart Requests
**Before (JavaScript fetch):**
```javascript
const formData = new FormData();
formData.append('day_phrase', 'apple forest thunder');
formData.append('date_str', '2025-01-15');
formData.append('reference_photo', refPhotoFile);
formData.append('carrier', carrierFile);
formData.append('message', 'secret');
formData.append('pin', '123456');
const response = await fetch('/encode/multipart', {
method: 'POST',
body: formData
});
```
**After (JavaScript fetch):**
```javascript
const formData = new FormData();
formData.append('passphrase', 'apple forest thunder mountain');
// date_str removed
formData.append('reference_photo', refPhotoFile);
formData.append('carrier', carrierFile);
formData.append('message', 'secret');
formData.append('pin', '123456');
const response = await fetch('/encode/multipart', {
method: 'POST',
body: formData
});
```
## Testing Checklist
### Endpoints to Test
- [ ] GET / - Returns v3.2.0 with breaking_changes info
- [ ] GET /modes - Returns mode information
- [ ] POST /generate - Returns single passphrase
- [ ] POST /encode - Works without date_str
- [ ] POST /encode/file - Works without date_str
- [ ] POST /decode - Works without date_str
- [ ] POST /encode/multipart - Accepts passphrase instead of day_phrase
- [ ] POST /decode/multipart - Accepts passphrase instead of day_phrase
- [ ] POST /compare - Still works
- [ ] POST /will-fit - Still works
- [ ] POST /image/info - Still works
- [ ] POST /extract-key-from-qr - Still works
### Validation Tests
- [ ] Reject requests with `day_phrase` field (should get validation error)
- [ ] Reject requests with `date_str` field (should be ignored or error)
- [ ] Accept requests with `passphrase` field
- [ ] Generate response includes `passphrase` field
- [ ] Generate response has `phrases` as null
- [ ] Encode response has `date_used` and `day_of_week` as null
- [ ] Multipart encode works with new field names
- [ ] Response headers updated correctly
## OpenAPI/Swagger Documentation
The FastAPI auto-generated documentation (/docs and /redoc) will automatically reflect the changes:
1. **Models updated** - Request/response schemas show new field names
2. **Descriptions updated** - Field descriptions mention v3.2.0 changes
3. **Examples updated** - Interactive API explorer uses new field names
Users can browse to `/docs` to see the updated API specification.
## Backward Compatibility
**Breaking Change:** API v3.2.0 is NOT backward compatible with v3.1.0
Clients using the old API will encounter:
1. **Validation errors** - Missing required `passphrase` field
2. **Unexpected responses** - `phrases` field will be null
3. **Changed behavior** - Date fields no longer populated
### Migration Timeline Recommendation
1. **Deploy v3.2.0 API** to staging
2. **Update client applications** to use new field names
3. **Test thoroughly** with staging API
4. **Deploy v3.2.0 API** to production
5. **Notify users** of breaking changes
Alternatively, run v3.1.0 and v3.2.0 APIs side-by-side on different paths:
- `/api/v3.1/` - Old API
- `/api/v3.2/` - New API
## Constants Updates
Used in validation:
```python
from stegasoo.constants import (
MIN_PASSPHRASE_WORDS, # = 3
MAX_PASSPHRASE_WORDS, # = 12
DEFAULT_PASSPHRASE_WORDS, # = 4 (increased from 3)
)
```
## Error Messages
All error messages updated:
- "day_phrase is required" → "passphrase is required"
- References to "phrase" now mean "passphrase"
## Implementation Status
✅ All request models updated
✅ All response models updated
✅ All endpoints updated
✅ Multipart endpoints updated
✅ Status endpoint shows breaking changes
✅ Constants imported correctly
✅ Error handling updated
✅ No references to day_phrase in user-facing text
✅ No date_str parameters accepted
Ready for deployment!

View File

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

View File

@@ -24,11 +24,31 @@ Usage:
stegasoo channel [SUBCOMMAND]
"""
import json
import sys
import tempfile
import threading
import time
import uuid
from pathlib import Path
import click
# Rich progress bar (optional)
try:
from rich.progress import (
BarColumn,
Progress,
SpinnerColumn,
TaskProgressColumn,
TextColumn,
TimeElapsedColumn,
)
HAS_RICH = True
except ImportError:
HAS_RICH = False
# Add parent to path for development
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
@@ -168,37 +188,25 @@ def resolve_channel_key_option(
"""
Resolve channel key from CLI options.
Wrapper around library's resolve_channel_key with Click exception handling.
Returns:
None: Use server-configured key (auto mode)
"": Public mode (no channel key)
str: Explicit channel key
"""
if no_channel:
return "" # Public mode
from stegasoo.channel import resolve_channel_key
if channel_file:
# Load from file
path = Path(channel_file)
if not path.exists():
raise click.ClickException(f"Channel key file not found: {channel_file}")
key = path.read_text().strip()
if not validate_channel_key(key):
raise click.ClickException(f"Invalid channel key format in file: {channel_file}")
return key
if channel:
if channel.lower() == "auto":
return None # Use server config
# Explicit key provided
if not validate_channel_key(channel):
raise click.ClickException(
"Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX\n"
"Generate a new key with: stegasoo channel generate"
)
return channel
# Default: use server-configured key (auto mode)
return None
try:
return resolve_channel_key(
value=channel,
file_path=channel_file,
no_channel=no_channel,
)
except FileNotFoundError as e:
raise click.ClickException(str(e))
except ValueError as e:
raise click.ClickException(str(e))
def format_channel_status_line(quiet: bool = False) -> str | None:
@@ -610,6 +618,73 @@ def channel_clear(project, clear_all, force):
click.echo(" Mode is now: PUBLIC")
# ============================================================================
# PROGRESS BAR UTILITIES (v4.1.2)
# ============================================================================
def _generate_progress_job_id() -> str:
"""Generate a unique job ID for progress tracking."""
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."""
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
def _run_encode_with_progress(encode_func, encode_kwargs: dict, progress_file: str) -> tuple:
"""
Run encode in a thread and return result.
Returns:
(success, result_or_error)
"""
result_holder = {"result": None, "error": None}
def run():
try:
result_holder["result"] = encode_func(**encode_kwargs, progress_file=progress_file)
except Exception as e:
result_holder["error"] = e
thread = threading.Thread(target=run)
thread.start()
return thread, result_holder
def _format_phase(phase: str) -> str:
"""Format phase name for display."""
phases = {
"starting": "Starting",
"initializing": "Initializing",
"embedding": "Embedding",
"saving": "Saving",
"finalizing": "Finalizing",
"complete": "Complete",
}
return phases.get(phase, phase.capitalize())
# ============================================================================
# ENCODE COMMAND
# ============================================================================
@@ -654,6 +729,7 @@ def channel_clear(project, clear_all, force):
help="DCT color mode: grayscale (default) or color (preserves original colors)",
)
@click.option("--quiet", "-q", is_flag=True, help="Suppress output except errors")
@click.option("--progress", is_flag=True, help="Show progress bar (requires rich)")
def encode_cmd(
ref,
carrier,
@@ -673,6 +749,7 @@ def encode_cmd(
dct_output_format,
dct_color_mode,
quiet,
progress,
):
"""
Encode a secret message or file into an image.
@@ -820,19 +897,63 @@ def encode_cmd(
click.echo(channel_status)
# v4.0.0: Include channel_key parameter
result = encode(
message=payload,
reference_photo=ref_photo,
carrier_image=carrier_image,
passphrase=passphrase,
pin=pin or "",
rsa_key_data=rsa_key_data,
rsa_password=effective_key_password,
embed_mode=embed_mode,
dct_output_format=dct_output_format,
dct_color_mode=dct_color_mode,
channel_key=resolved_channel_key,
)
# v4.1.2: Progress bar support
encode_kwargs = {
"message": payload,
"reference_photo": ref_photo,
"carrier_image": carrier_image,
"passphrase": passphrase,
"pin": pin or "",
"rsa_key_data": rsa_key_data,
"rsa_password": effective_key_password,
"embed_mode": embed_mode,
"dct_output_format": dct_output_format,
"dct_color_mode": dct_color_mode,
"channel_key": resolved_channel_key,
}
if progress and HAS_RICH:
# Run with progress bar
job_id = _generate_progress_job_id()
progress_file = _get_progress_file_path(job_id)
thread, result_holder = _run_encode_with_progress(encode, encode_kwargs, progress_file)
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
TimeElapsedColumn(),
transient=True,
) as progress_bar:
task = progress_bar.add_task("Encoding...", total=100)
while thread.is_alive():
prog = _read_progress(job_id)
if prog:
percent = prog.get("percent", 0)
phase = _format_phase(prog.get("phase", "processing"))
progress_bar.update(task, completed=percent, description=f"{phase}...")
time.sleep(0.1)
# Final update
progress_bar.update(task, completed=100, description="Complete!")
_cleanup_progress_file(job_id)
if result_holder["error"]:
raise result_holder["error"]
result = result_holder["result"]
elif progress and not HAS_RICH:
click.secho(
"Warning: --progress requires 'rich' package. Install with: pip install rich",
fg="yellow",
)
result = encode(**encode_kwargs)
else:
result = encode(**encode_kwargs)
# Determine output path
if output:

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

@@ -1,426 +0,0 @@
# Web Frontend Update Summary for v3.2.0
## Overview
The Flask web frontend has been updated to align with Stegasoo v3.2.0's breaking changes:
1. **Removed date dependency** - No date selection or tracking in UI
2. **Renamed day_phrase → passphrase** - Updated all forms and templates
3. **Increased default words** - From 3 to 4 for better security
## Key Changes
### 1. Form Parameter Changes
#### Generate Page
**Before (v3.1.0):**
```python
words_per_phrase = int(request.form.get('words_per_phrase', 3))
# Generated daily phrases for all days of the week
```
**After (v3.2.0):**
```python
words_per_passphrase = int(request.form.get('words_per_passphrase', 4))
# Generates single passphrase
```
**Template variables changed:**
- `phrases``passphrase` (single string instead of dict)
- `words_per_phrase``words_per_passphrase`
- `phrase_entropy``passphrase_entropy`
- Removed `days` variable (no longer needed)
#### Encode Page
**Before (v3.1.0):**
```python
day_phrase = request.form.get('day_phrase', '')
client_date = request.form.get('client_date', '').strip()
day_of_week = get_today_day() # Used in template
encode_result = encode(
...,
day_phrase=day_phrase,
date_str=date_str,
...
)
```
**After (v3.2.0):**
```python
passphrase = request.form.get('passphrase', '')
# No client_date or day_of_week needed
encode_result = encode(
...,
passphrase=passphrase, # Renamed
# date_str removed
...
)
```
#### Decode Page
**Before (v3.1.0):**
```python
day_phrase = request.form.get('day_phrase', '')
stego_date = request.form.get('stego_date', '').strip()
decode_result = decode(
...,
day_phrase=day_phrase,
date_str=stego_date if stego_date else None,
...
)
```
**After (v3.2.0):**
```python
passphrase = request.form.get('passphrase', '')
# No stego_date needed
decode_result = decode(
...,
passphrase=passphrase, # Renamed
# date_str removed
...
)
```
### 2. Template Context Updates
**inject_globals() changes:**
**Added:**
```python
'min_passphrase_words': MIN_PASSPHRASE_WORDS,
'recommended_passphrase_words': RECOMMENDED_PASSPHRASE_WORDS,
'default_passphrase_words': DEFAULT_PASSPHRASE_WORDS,
```
**Used for:**
- Showing passphrase length requirements
- Default values in generate form
- Validation messages
### 3. Validation Updates
**Added passphrase validation:**
```python
from stegasoo import validate_passphrase
# In encode_page()
result = validate_passphrase(passphrase)
if not result.is_valid:
flash(result.error_message, 'error')
return ...
# Show warning if passphrase is short
if result.warning:
flash(result.warning, 'warning')
```
### 4. Error Message Updates
**Before:**
```python
flash('Day phrase is required', 'error')
flash('Decryption failed. Check your phrase, PIN...', 'error')
```
**After:**
```python
flash('Passphrase is required', 'error')
flash('Decryption failed. Check your passphrase, PIN...', 'error')
```
## Template Changes Needed
These Flask routes will need corresponding template updates:
### generate.html
**Changes needed:**
```html
<!-- Before -->
<label for="words_per_phrase">Words per phrase</label>
<input type="number" name="words_per_phrase" value="3">
{% if generated %}
<h3>Daily Phrases</h3>
{% for day in days %}
<tr>
<td>{{ day }}</td>
<td>{{ phrases[day] }}</td>
</tr>
{% endfor %}
{% endif %}
<!-- After -->
<label for="words_per_passphrase">Words per passphrase</label>
<input type="number" name="words_per_passphrase" value="{{ default_passphrase_words }}">
{% if generated %}
<h3>Passphrase</h3>
<div class="passphrase-display">
<code>{{ passphrase }}</code>
<p class="help-text">Use this passphrase to encode and decode messages (no date needed!)</p>
</div>
{% endif %}
```
**Entropy display:**
```html
<!-- Before -->
<li>Phrase entropy: {{ phrase_entropy }} bits</li>
<!-- After -->
<li>Passphrase entropy: {{ passphrase_entropy }} bits ({{ words_per_passphrase }} words)</li>
```
### encode.html
**Changes needed:**
```html
<!-- Before -->
<label for="day_phrase">Day Phrase</label>
<input type="text" name="day_phrase" required>
<label for="client_date">Encoding Date (Optional)</label>
<input type="date" name="client_date">
<p class="help-text">Defaults to today: {{ day_of_week }}</p>
<!-- After -->
<label for="passphrase">Passphrase</label>
<input type="text" name="passphrase" required
placeholder="Enter at least {{ recommended_passphrase_words }} words">
<p class="help-text">
v3.2.0: No date needed! Use your passphrase anytime.
</p>
```
### decode.html
**Changes needed:**
```html
<!-- Before -->
<label for="day_phrase">Day Phrase</label>
<input type="text" name="day_phrase" required>
<label for="stego_date">Encoding Date</label>
<input type="date" name="stego_date" id="stego_date">
<p class="help-text">Will be auto-detected from filename if possible</p>
<script>
// Auto-detect date from filename
stegoInput.addEventListener('change', function() {
const filename = this.files[0]?.name || '';
const dateMatch = filename.match(/_(\d{4})(\d{2})(\d{2})/);
if (dateMatch) {
document.getElementById('stego_date').value =
`${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`;
}
});
</script>
<!-- After -->
<label for="passphrase">Passphrase</label>
<input type="text" name="passphrase" required
placeholder="Enter your passphrase">
<p class="help-text">
v3.2.0: No date needed to decode!
</p>
<!-- Remove date detection script -->
```
### index.html
**Changes needed:**
```html
<!-- Before -->
<p>Generate daily passphrases and security credentials</p>
<p>Hide messages using day-specific phrases</p>
<!-- After -->
<p>Generate passphrases and security credentials</p>
<p>v3.2.0: Simplified - no more daily rotation!</p>
```
### about.html
**Add v3.2.0 section:**
```html
<h2>Version 3.2.0 Changes</h2>
<ul>
<li><strong>No date dependency</strong> - Encode and decode anytime without tracking dates</li>
<li><strong>Single passphrase</strong> - No more daily rotation, just remember one strong passphrase</li>
<li><strong>Better security</strong> - Default passphrase length increased to 4 words</li>
<li><strong>Asynchronous ready</strong> - Perfect for dead drops and delayed delivery</li>
</ul>
```
## JavaScript Changes Needed
### Remove date-related code:
```javascript
// REMOVE THIS (date detection from filename)
function detectDateFromFilename(filename) {
const match = filename.match(/_(\d{4})(\d{2})(\d{2})/);
if (match) {
return `${match[1]}-${match[2]}-${match[3]}`;
}
return null;
}
// REMOVE THIS (day-of-week display)
function updateDayOfWeek() {
const dateInput = document.getElementById('client_date');
const dayDisplay = document.getElementById('day_display');
// ...
}
```
### Update validation:
```javascript
// Before
const dayPhrase = document.getElementById('day_phrase').value;
if (!dayPhrase || dayPhrase.trim().length === 0) {
alert('Day phrase is required');
return false;
}
// After
const passphrase = document.getElementById('passphrase').value;
if (!passphrase || passphrase.trim().length === 0) {
alert('Passphrase is required');
return false;
}
// Add word count validation
const words = passphrase.trim().split(/\s+/);
if (words.length < {{ min_passphrase_words }}) {
alert(`Passphrase should have at least {{ recommended_passphrase_words }} words`);
return false;
}
```
## CSS Updates
Add styling for passphrase warnings:
```css
.passphrase-display {
background: #f5f5f5;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
}
.passphrase-display code {
font-size: 1.2em;
color: #2c3e50;
word-break: break-word;
}
.help-text.v3-2-0 {
color: #3498db;
font-weight: bold;
}
.flash.warning {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
color: #856404;
}
```
## Migration Notes for Users
Add to templates:
```html
<div class="alert alert-info">
<h4>⚠️ v3.2.0 Breaking Changes</h4>
<p>If you have messages encoded with v3.1.0:</p>
<ul>
<li>They cannot be decoded with v3.2.0</li>
<li>You need the original v3.1.0 installation to decode them</li>
<li>After decoding, you can re-encode with v3.2.0</li>
</ul>
</div>
```
## Form Field Summary
### Changed Field Names
| Old Name (v3.1.0) | New Name (v3.2.0) | Type |
|-------------------|-------------------|------|
| `day_phrase` | `passphrase` | text input |
| `words_per_phrase` | `words_per_passphrase` | number input |
| `client_date` | (removed) | date input |
| `stego_date` | (removed) | date input |
### New Validation Attributes
```html
<input type="text" name="passphrase"
required
minlength="{{ min_passphrase_words * 4 }}"
placeholder="Enter at least {{ recommended_passphrase_words }} words"
pattern="^\s*\S+(\s+\S+){3,}.*$"
title="Please enter at least 4 words">
```
## Testing Checklist
- [ ] Generate page creates single passphrase
- [ ] Generate page shows correct entropy (4 words = 44 bits)
- [ ] Generate page doesn't show day names
- [ ] Encode page accepts passphrase (not day_phrase)
- [ ] Encode page doesn't have date selection
- [ ] Encode page shows v3.2.0 help text
- [ ] Decode page accepts passphrase
- [ ] Decode page doesn't have date input
- [ ] Decode page doesn't auto-detect date from filename
- [ ] Error messages say "passphrase" not "day phrase"
- [ ] Validation shows warnings for short passphrases
- [ ] QR code functionality still works
- [ ] DCT mode options still work
- [ ] All flash messages updated
## Implementation Status
✅ Flask routes updated
✅ Form parameter names changed
✅ Function calls updated
✅ Validation added for passphrases
✅ Error messages updated
✅ Template context updated
⏳ Templates need updating (generate.html, encode.html, decode.html, index.html, about.html)
⏳ JavaScript needs updating
⏳ CSS styling for v3.2.0 features
## Quick Reference
**To test the Flask app:**
```bash
cd frontends/web
python app.py
# Visit http://localhost:5000
```
**Key user-facing changes:**
1. Generate: Shows one passphrase, not 7 daily phrases
2. Encode: No date selection, just passphrase
3. Decode: No date needed, just passphrase
**Benefits to highlight:**
- ✅ Simpler UI (fewer fields)
- ✅ No date tracking needed
- ✅ Encode today, decode anytime
- ✅ Perfect for asynchronous communications

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,979 @@
"""
Stegasoo Authentication Module (v4.1.0)
Multi-user authentication with role-based access control.
- Admin user created at first-run setup
- Admin can create up to 16 additional users
- Uses Argon2id password hashing
- Flask sessions for authentication state
- SQLite3 for user storage
"""
import functools
import secrets
import sqlite3
import string
from dataclasses import dataclass
from pathlib import Path
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from flask import current_app, flash, g, redirect, session, url_for
# Argon2 password hasher (lighter than stegasoo's 256MB for faster login)
ph = PasswordHasher(
time_cost=3,
memory_cost=65536, # 64MB
parallelism=4,
hash_len=32,
salt_len=16,
)
# Constants
MAX_USERS = 16 # Plus 1 admin = 17 total
MAX_CHANNEL_KEYS = 10 # Per user
ROLE_ADMIN = "admin"
ROLE_USER = "user"
@dataclass
class User:
"""User data class."""
id: int
username: str
role: str
created_at: str
@property
def is_admin(self) -> bool:
return self.role == ROLE_ADMIN
def get_db_path() -> Path:
"""Get database path in Flask instance folder."""
instance_path = Path(current_app.instance_path)
instance_path.mkdir(parents=True, exist_ok=True)
return instance_path / "stegasoo.db"
def get_db() -> sqlite3.Connection:
"""Get database connection, cached on Flask g object."""
if "db" not in g:
g.db = sqlite3.connect(get_db_path())
g.db.row_factory = sqlite3.Row
return g.db
def close_db(e=None):
"""Close database connection at end of request."""
db = g.pop("db", None)
if db is not None:
db.close()
def init_db():
"""Initialize database schema with migration support."""
db = get_db()
# Check if we need to migrate from old single-user schema
cursor = db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'"
)
has_old_table = cursor.fetchone() is not None
cursor = db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
)
has_new_table = cursor.fetchone() is not None
if has_old_table and not has_new_table:
# Migrate from old schema
_migrate_from_single_user(db)
elif not has_new_table:
# Fresh install - create new schema
_create_schema(db)
else:
# Existing install - check for new tables (migrations)
_ensure_channel_keys_table(db)
_ensure_app_settings_table(db)
def _create_schema(db: sqlite3.Connection):
"""Create the multi-user schema."""
db.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE TABLE IF NOT EXISTS user_channel_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
channel_key TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
last_used_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(user_id, channel_key)
);
CREATE INDEX IF NOT EXISTS idx_channel_keys_user ON user_channel_keys(user_id);
-- App-level settings (v4.1.0)
-- Stores recovery key hash and other instance-wide settings
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
""")
db.commit()
def _migrate_from_single_user(db: sqlite3.Connection):
"""Migrate from old single-user admin_user table to multi-user users table."""
# Create new table
_create_schema(db)
# Copy admin user from old table
old_user = db.execute(
"SELECT username, password_hash, created_at FROM admin_user WHERE id = 1"
).fetchone()
if old_user:
db.execute(
"""
INSERT INTO users (username, password_hash, role, created_at)
VALUES (?, ?, 'admin', ?)
""",
(old_user["username"], old_user["password_hash"], old_user["created_at"]),
)
db.commit()
# Drop old table
db.execute("DROP TABLE admin_user")
db.commit()
def _ensure_channel_keys_table(db: sqlite3.Connection):
"""Ensure user_channel_keys table exists (migration for existing installs)."""
cursor = db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_channel_keys'"
)
if cursor.fetchone() is None:
db.executescript("""
CREATE TABLE IF NOT EXISTS user_channel_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
channel_key TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
last_used_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(user_id, channel_key)
);
CREATE INDEX IF NOT EXISTS idx_channel_keys_user ON user_channel_keys(user_id);
""")
db.commit()
def _ensure_app_settings_table(db: sqlite3.Connection):
"""Ensure app_settings table exists (v4.1.0 migration)."""
cursor = db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
)
if cursor.fetchone() is None:
db.executescript("""
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
""")
db.commit()
# =============================================================================
# App Settings (v4.1.0)
# =============================================================================
def get_app_setting(key: str) -> str | None:
"""Get an app-level setting value."""
db = get_db()
row = db.execute(
"SELECT value FROM app_settings WHERE key = ?", (key,)
).fetchone()
return row["value"] if row else None
def set_app_setting(key: str, value: str) -> None:
"""Set an app-level setting value."""
db = get_db()
db.execute(
"""
INSERT INTO app_settings (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP
""",
(key, value, value),
)
db.commit()
def delete_app_setting(key: str) -> bool:
"""Delete an app-level setting. Returns True if deleted."""
db = get_db()
cursor = db.execute("DELETE FROM app_settings WHERE key = ?", (key,))
db.commit()
return cursor.rowcount > 0
# =============================================================================
# Recovery Key Management (v4.1.0)
# =============================================================================
# Setting key for recovery hash
RECOVERY_KEY_SETTING = "recovery_key_hash"
def has_recovery_key() -> bool:
"""Check if a recovery key has been configured."""
return get_app_setting(RECOVERY_KEY_SETTING) is not None
def get_recovery_key_hash() -> str | None:
"""Get the stored recovery key hash."""
return get_app_setting(RECOVERY_KEY_SETTING)
def set_recovery_key_hash(key_hash: str) -> None:
"""Store a recovery key hash."""
set_app_setting(RECOVERY_KEY_SETTING, key_hash)
def clear_recovery_key() -> bool:
"""Remove the recovery key. Returns True if removed."""
return delete_app_setting(RECOVERY_KEY_SETTING)
def verify_and_reset_admin_password(recovery_key: str, new_password: str) -> tuple[bool, str]:
"""
Verify recovery key and reset the first admin's password.
Args:
recovery_key: User-provided recovery key
new_password: New password to set
Returns:
(success, message) tuple
"""
from stegasoo.recovery import verify_recovery_key
stored_hash = get_recovery_key_hash()
if not stored_hash:
return False, "No recovery key configured for this instance"
if not verify_recovery_key(recovery_key, stored_hash):
return False, "Invalid recovery key"
# Find first admin user
db = get_db()
admin = db.execute(
"SELECT id, username FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
).fetchone()
if not admin:
return False, "No admin user found"
# Reset password
new_hash = ph.hash(new_password)
db.execute(
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_hash, admin["id"]),
)
db.commit()
# Invalidate all sessions for this user
invalidate_user_sessions(admin["id"])
return True, f"Password reset for '{admin['username']}'"
# =============================================================================
# User Queries
# =============================================================================
def any_users_exist() -> bool:
"""Check if any users have been created (for first-run detection)."""
db = get_db()
result = db.execute("SELECT 1 FROM users LIMIT 1").fetchone()
return result is not None
def user_exists() -> bool:
"""Alias for any_users_exist() for backwards compatibility."""
return any_users_exist()
def get_user_count() -> int:
"""Get total number of users."""
db = get_db()
result = db.execute("SELECT COUNT(*) FROM users").fetchone()
return result[0] if result else 0
def get_non_admin_count() -> int:
"""Get number of non-admin users."""
db = get_db()
result = db.execute("SELECT COUNT(*) FROM users WHERE role != 'admin'").fetchone()
return result[0] if result else 0
def can_create_user() -> bool:
"""Check if we can create more users (within limit)."""
return get_non_admin_count() < MAX_USERS
def get_user_by_id(user_id: int) -> User | None:
"""Get user by ID."""
db = get_db()
row = db.execute(
"SELECT id, username, role, created_at FROM users WHERE id = ?", (user_id,)
).fetchone()
if row:
return User(
id=row["id"],
username=row["username"],
role=row["role"],
created_at=row["created_at"],
)
return None
def get_user_by_username(username: str) -> User | None:
"""Get user by username."""
db = get_db()
row = db.execute(
"SELECT id, username, role, created_at FROM users WHERE username = ?",
(username,),
).fetchone()
if row:
return User(
id=row["id"],
username=row["username"],
role=row["role"],
created_at=row["created_at"],
)
return None
def get_all_users() -> list[User]:
"""Get all users, admins first, then by creation date."""
db = get_db()
rows = db.execute(
"""
SELECT id, username, role, created_at FROM users
ORDER BY role = 'admin' DESC, created_at ASC
"""
).fetchall()
return [
User(
id=row["id"],
username=row["username"],
role=row["role"],
created_at=row["created_at"],
)
for row in rows
]
def get_current_user() -> User | None:
"""Get the currently logged-in user from session."""
user_id = session.get("user_id")
if user_id:
return get_user_by_id(user_id)
return None
def get_username() -> str:
"""Get current user's username (backwards compatibility)."""
user = get_current_user()
return user.username if user else "unknown"
# =============================================================================
# Authentication
# =============================================================================
def verify_user_password(username: str, password: str) -> User | None:
"""
Verify password for a user.
Returns User if valid, None if invalid.
Also rehashes password if needed.
"""
db = get_db()
row = db.execute(
"SELECT id, username, role, created_at, password_hash FROM users WHERE username = ?",
(username,),
).fetchone()
if not row:
return None
try:
ph.verify(row["password_hash"], password)
# Rehash if parameters changed
if ph.check_needs_rehash(row["password_hash"]):
new_hash = ph.hash(password)
db.execute(
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_hash, row["id"]),
)
db.commit()
return User(
id=row["id"],
username=row["username"],
role=row["role"],
created_at=row["created_at"],
)
except VerifyMismatchError:
return None
def verify_password(password: str) -> bool:
"""Verify password for current user (backwards compatibility)."""
user = get_current_user()
if not user:
return False
result = verify_user_password(user.username, password)
return result is not None
def is_authenticated() -> bool:
"""Check if current session is authenticated."""
return session.get("user_id") is not None
def is_admin() -> bool:
"""Check if current user is an admin."""
user = get_current_user()
return user.is_admin if user else False
def login_user(user: User):
"""Set up session for logged-in user."""
session["user_id"] = user.id
session["username"] = user.username
session["role"] = user.role
# Legacy compatibility
session["authenticated"] = True
def logout_user():
"""Clear session for logout."""
session.clear()
# =============================================================================
# User Management
# =============================================================================
def generate_temp_password(length: int = 8) -> str:
"""Generate a random temporary password."""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def validate_username(username: str) -> tuple[bool, str]:
"""
Validate username format.
Rules: 3-80 chars, alphanumeric + underscore/hyphen + @/. for email-style
"""
if not username:
return False, "Username is required"
if len(username) < 3:
return False, "Username must be at least 3 characters"
if len(username) > 80:
return False, "Username must be at most 80 characters"
# Allow: alphanumeric, underscore, hyphen, @, . (for email-style)
allowed = set(string.ascii_letters + string.digits + "_-@.")
if not all(c in allowed for c in username):
return False, "Username can only contain letters, numbers, underscore, hyphen, @ and ."
# Must start with letter or number
if username[0] not in string.ascii_letters + string.digits:
return False, "Username must start with a letter or number"
return True, ""
def validate_password(password: str) -> tuple[bool, str]:
"""Validate password requirements."""
if not password:
return False, "Password is required"
if len(password) < 8:
return False, "Password must be at least 8 characters"
return True, ""
def create_user(
username: str, password: str, role: str = ROLE_USER
) -> tuple[bool, str, User | None]:
"""
Create a new user.
Returns (success, message, user).
"""
# Validate username
valid, msg = validate_username(username)
if not valid:
return False, msg, None
# Validate password
valid, msg = validate_password(password)
if not valid:
return False, msg, None
# Check if username already exists
if get_user_by_username(username):
return False, "Username already exists", None
# Check user limit (only for non-admin users)
if role != ROLE_ADMIN and not can_create_user():
return False, f"Maximum of {MAX_USERS} users reached", None
# Create user
password_hash = ph.hash(password)
db = get_db()
try:
cursor = db.execute(
"""
INSERT INTO users (username, password_hash, role)
VALUES (?, ?, ?)
""",
(username, password_hash, role),
)
db.commit()
user = get_user_by_id(cursor.lastrowid)
return True, "User created successfully", user
except sqlite3.IntegrityError:
return False, "Username already exists", None
def create_admin_user(username: str, password: str) -> tuple[bool, str]:
"""Create the initial admin user (first-run setup)."""
if any_users_exist():
return False, "Admin user already exists"
success, msg, _ = create_user(username, password, ROLE_ADMIN)
return success, msg
def change_password(
user_id: int, current_password: str, new_password: str
) -> tuple[bool, str]:
"""Change a user's password (requires current password)."""
user = get_user_by_id(user_id)
if not user:
return False, "User not found"
# Verify current password
if not verify_user_password(user.username, current_password):
return False, "Current password is incorrect"
# Validate new password
valid, msg = validate_password(new_password)
if not valid:
return False, msg
# Update password
new_hash = ph.hash(new_password)
db = get_db()
db.execute(
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_hash, user_id),
)
db.commit()
return True, "Password changed successfully"
def reset_user_password(user_id: int, new_password: str) -> tuple[bool, str]:
"""Reset a user's password (admin function, no current password required)."""
user = get_user_by_id(user_id)
if not user:
return False, "User not found"
# Validate new password
valid, msg = validate_password(new_password)
if not valid:
return False, msg
# Update password
new_hash = ph.hash(new_password)
db = get_db()
db.execute(
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_hash, user_id),
)
db.commit()
# Invalidate user's sessions
invalidate_user_sessions(user_id)
return True, "Password reset successfully"
def delete_user(user_id: int, current_user_id: int) -> tuple[bool, str]:
"""
Delete a user.
Cannot delete yourself or the last admin.
"""
if user_id == current_user_id:
return False, "Cannot delete yourself"
user = get_user_by_id(user_id)
if not user:
return False, "User not found"
# Check if this is the last admin
if user.role == ROLE_ADMIN:
db = get_db()
admin_count = db.execute(
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
).fetchone()[0]
if admin_count <= 1:
return False, "Cannot delete the last admin"
# Invalidate user's sessions before deletion
invalidate_user_sessions(user_id)
# Delete user
db = get_db()
db.execute("DELETE FROM users WHERE id = ?", (user_id,))
db.commit()
return True, f"User '{user.username}' deleted"
def invalidate_user_sessions(user_id: int):
"""
Invalidate all sessions for a user.
This is called when a user is deleted or their password is reset.
Since we use server-side sessions, we increment a "session version"
that's checked on each request.
"""
# For Flask's default session (client-side), we can't truly invalidate.
# But we can add a check - store a "valid_from" timestamp in the DB
# and compare against session creation time.
#
# For now, we'll use a simpler approach: store invalidated user IDs
# in app config (memory) which gets checked by login_required.
#
# This works for single-process deployments (like RPi).
# For multi-process, would need Redis or DB-backed sessions.
if "invalidated_users" not in current_app.config:
current_app.config["invalidated_users"] = set()
current_app.config["invalidated_users"].add(user_id)
def is_session_valid() -> bool:
"""Check if current session is still valid (user not deleted/invalidated)."""
user_id = session.get("user_id")
if not user_id:
return False
# Check if user was invalidated
invalidated = current_app.config.get("invalidated_users", set())
if user_id in invalidated:
return False
# Check if user still exists
if not get_user_by_id(user_id):
return False
return True
# =============================================================================
# Channel Keys
# =============================================================================
@dataclass
class ChannelKey:
"""Saved channel key data class."""
id: int
user_id: int
name: str
channel_key: str
created_at: str
last_used_at: str | None
def get_user_channel_keys(user_id: int) -> list[ChannelKey]:
"""Get all saved channel keys for a user, most recently used first."""
db = get_db()
rows = db.execute(
"""
SELECT id, user_id, name, channel_key, created_at, last_used_at
FROM user_channel_keys
WHERE user_id = ?
ORDER BY last_used_at DESC NULLS LAST, created_at DESC
""",
(user_id,),
).fetchall()
return [
ChannelKey(
id=row["id"],
user_id=row["user_id"],
name=row["name"],
channel_key=row["channel_key"],
created_at=row["created_at"],
last_used_at=row["last_used_at"],
)
for row in rows
]
def get_channel_key_by_id(key_id: int, user_id: int) -> ChannelKey | None:
"""Get a specific channel key (ensures user owns it)."""
db = get_db()
row = db.execute(
"""
SELECT id, user_id, name, channel_key, created_at, last_used_at
FROM user_channel_keys
WHERE id = ? AND user_id = ?
""",
(key_id, user_id),
).fetchone()
if row:
return ChannelKey(
id=row["id"],
user_id=row["user_id"],
name=row["name"],
channel_key=row["channel_key"],
created_at=row["created_at"],
last_used_at=row["last_used_at"],
)
return None
def get_channel_key_count(user_id: int) -> int:
"""Get count of saved channel keys for a user."""
db = get_db()
result = db.execute(
"SELECT COUNT(*) FROM user_channel_keys WHERE user_id = ?", (user_id,)
).fetchone()
return result[0] if result else 0
def can_save_channel_key(user_id: int) -> bool:
"""Check if user can save more channel keys (within limit)."""
return get_channel_key_count(user_id) < MAX_CHANNEL_KEYS
def save_channel_key(
user_id: int, name: str, channel_key: str
) -> tuple[bool, str, ChannelKey | None]:
"""
Save a channel key for a user.
Returns (success, message, key).
"""
# Validate name
name = name.strip()
if not name:
return False, "Key name is required", None
if len(name) > 50:
return False, "Key name must be at most 50 characters", None
# Validate channel key format (hex string)
channel_key = channel_key.strip().lower()
if not channel_key:
return False, "Channel key is required", None
if not all(c in "0123456789abcdef" for c in channel_key):
return False, "Invalid channel key format", None
# Check limit
if not can_save_channel_key(user_id):
return False, f"Maximum of {MAX_CHANNEL_KEYS} saved keys reached", None
db = get_db()
try:
cursor = db.execute(
"""
INSERT INTO user_channel_keys (user_id, name, channel_key)
VALUES (?, ?, ?)
""",
(user_id, name, channel_key),
)
db.commit()
key = get_channel_key_by_id(cursor.lastrowid, user_id)
return True, "Channel key saved", key
except sqlite3.IntegrityError:
return False, "This channel key is already saved", None
def update_channel_key_name(
key_id: int, user_id: int, new_name: str
) -> tuple[bool, str]:
"""Update the name of a saved channel key."""
new_name = new_name.strip()
if not new_name:
return False, "Key name is required"
if len(new_name) > 50:
return False, "Key name must be at most 50 characters"
key = get_channel_key_by_id(key_id, user_id)
if not key:
return False, "Channel key not found"
db = get_db()
db.execute(
"UPDATE user_channel_keys SET name = ? WHERE id = ? AND user_id = ?",
(new_name, key_id, user_id),
)
db.commit()
return True, "Key name updated"
def update_channel_key_last_used(key_id: int, user_id: int):
"""Update the last_used_at timestamp for a channel key."""
db = get_db()
db.execute(
"""
UPDATE user_channel_keys
SET last_used_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
""",
(key_id, user_id),
)
db.commit()
def delete_channel_key(key_id: int, user_id: int) -> tuple[bool, str]:
"""Delete a saved channel key."""
key = get_channel_key_by_id(key_id, user_id)
if not key:
return False, "Channel key not found"
db = get_db()
db.execute(
"DELETE FROM user_channel_keys WHERE id = ? AND user_id = ?",
(key_id, user_id),
)
db.commit()
return True, f"Key '{key.name}' deleted"
# =============================================================================
# Decorators
# =============================================================================
def login_required(f):
"""Decorator to require login for a route."""
@functools.wraps(f)
def decorated_function(*args, **kwargs):
# Check if auth is enabled
if not current_app.config.get("AUTH_ENABLED", True):
return f(*args, **kwargs)
# Check for first-run setup
if not any_users_exist():
return redirect(url_for("setup"))
# Check authentication
if not is_authenticated():
return redirect(url_for("login"))
# Check if session is still valid (user not deleted)
if not is_session_valid():
logout_user()
flash("Your session has expired. Please log in again.", "warning")
return redirect(url_for("login"))
return f(*args, **kwargs)
return decorated_function
def admin_required(f):
"""Decorator to require admin role for a route."""
@functools.wraps(f)
def decorated_function(*args, **kwargs):
# Check if auth is enabled
if not current_app.config.get("AUTH_ENABLED", True):
return f(*args, **kwargs)
# Check for first-run setup
if not any_users_exist():
return redirect(url_for("setup"))
# Check authentication
if not is_authenticated():
return redirect(url_for("login"))
# Check if session is still valid
if not is_session_valid():
logout_user()
flash("Your session has expired. Please log in again.", "warning")
return redirect(url_for("login"))
# Check admin role
if not is_admin():
flash("Admin access required", "error")
return redirect(url_for("index"))
return f(*args, **kwargs)
return decorated_function
# =============================================================================
# App Initialization
# =============================================================================
def init_app(app):
"""Initialize auth module with Flask app."""
app.teardown_appcontext(close_db)
with app.app_context():
init_db()

52
frontends/web/dev_run.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Stegasoo Web Frontend - Development Runner
# Press 'r' to restart, 'q' to quit (single keypress, no Enter needed)
cd "$(dirname "$0")"
PID=""
cleanup() {
echo -e "\n\033[33mShutting down...\033[0m"
[[ -n "$PID" ]] && kill "$PID" 2>/dev/null
stty sane 2>/dev/null
exit 0
}
trap cleanup SIGINT SIGTERM EXIT
start_server() {
clear
echo -e "\033[36m┌──────────────────────────────────────┐\033[0m"
echo -e "\033[36m│ Stegasoo Dev Server │\033[0m"
echo -e "\033[36m│ \033[0m[r] restart [q] quit\033[36m │\033[0m"
echo -e "\033[36m└──────────────────────────────────────┘\033[0m"
pkill -f "python app.py" 2>/dev/null
sleep 0.3
python app.py 2>&1 &
PID=$!
echo -e "\033[32m✓ Running on http://localhost:5000 (PID: $PID)\033[0m\n"
}
start_server
# Single keypress mode
stty -echo -icanon time 0 min 0
while true; do
key=$(dd bs=1 count=1 2>/dev/null)
case "$key" in
r|R) start_server ;;
q|Q) cleanup ;;
esac
# Check if crashed
if [[ -n "$PID" ]] && ! kill -0 "$PID" 2>/dev/null; then
echo -e "\033[31m✗ Crashed! Press 'r' to restart\033[0m"
PID=""
fi
sleep 0.1
done

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

@@ -0,0 +1,153 @@
"""
SSL Certificate Utilities
Auto-generates self-signed certificates for HTTPS.
Uses cryptography library (already a dependency).
"""
import datetime
import ipaddress
import socket
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_local_ips() -> list[str]:
"""Get local IP addresses for this machine."""
ips = []
try:
# Get hostname and resolve to IP
hostname = socket.gethostname()
for addr_info in socket.getaddrinfo(hostname, None, socket.AF_INET):
ip = addr_info[4][0]
if ip not in ips and not ip.startswith("127."):
ips.append(ip)
except Exception:
pass
# Also try connecting to external to get primary interface IP
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
if ip not in ips:
ips.append(ip)
s.close()
except Exception:
pass
return ips
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 hostname.local for mDNS access
if not hostname.endswith(".local"):
san_list.append(x509.DNSName(f"{hostname}.local"))
# Add the hostname as IP if it looks like one
try:
san_list.append(x509.IPAddress(ipaddress.IPv4Address(hostname)))
except ipaddress.AddressValueError:
pass
# Add local network IPs
for local_ip in _get_local_ips():
try:
ip_addr = ipaddress.IPv4Address(local_ip)
if x509.IPAddress(ip_addr) not in san_list:
san_list.append(x509.IPAddress(ip_addr))
except (ipaddress.AddressValueError, ValueError):
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,279 @@
/**
* 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>QR Code</title>
<style>
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
img { max-width: 400px; }
</style>
</head>
<body>
<img src="${qrImg.src}" alt="QR Code">
<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();
}
});

6
frontends/web/static/js/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -99,6 +99,23 @@ const Stegasoo = {
}
});
}
// Make preview clickable to replace file
if (preview) {
preview.style.cursor = 'pointer';
preview.addEventListener('click', (e) => {
e.stopPropagation();
input.click();
});
}
// Make entire zone clickable (in case label/preview don't cover it)
zone.addEventListener('click', (e) => {
// Only trigger if not clicking directly on the input
if (e.target !== input) {
input.click();
}
});
});
},
@@ -119,7 +136,11 @@ const Stegasoo = {
if (isScanContainer || isPixelContainer) {
labelEl.classList.add('d-none');
} else {
labelEl.innerHTML = '<i class="bi bi-check-circle text-success me-1"></i>' + file.name;
labelEl.textContent = '';
const icon = document.createElement('i');
icon.className = 'bi bi-check-circle text-success me-1';
labelEl.appendChild(icon);
labelEl.appendChild(document.createTextNode(file.name));
}
}
@@ -312,56 +333,68 @@ const Stegasoo = {
generateEmbedTraces(container, width, height) {
// Color classes for variety
const colors = ['color-yellow', 'color-cyan', 'color-purple', 'color-blue'];
// Generate 6-8 snake paths spread across the whole image
const numPaths = 6 + Math.floor(Math.random() * 3);
for (let p = 0; p < numPaths; p++) {
// Each path gets a random color
const pathColor = colors[Math.floor(Math.random() * colors.length)];
// Distribute starting points across the image
let x = (width * 0.1) + (Math.random() * width * 0.8);
let y = (height * 0.1) + (Math.random() * height * 0.8);
let delay = p * 40;
// Each path has 3-5 segments for more coverage
const numSegments = 3 + Math.floor(Math.random() * 3);
let horizontal = Math.random() > 0.5;
for (let s = 0; s < numSegments; s++) {
const trace = document.createElement('div');
trace.className = 'embed-trace ' + (horizontal ? 'h' : 'v') + ' ' + pathColor;
const length = 30 + Math.random() * 60;
trace.style.left = x + 'px';
trace.style.top = y + 'px';
trace.style.animationDelay = delay + 'ms';
if (horizontal) {
trace.style.width = length + 'px';
} else {
trace.style.height = length + 'px';
// Grid-based distribution: divide image into cells for even coverage
const gridCols = 5;
const gridRows = 4;
const cellWidth = width / gridCols;
const cellHeight = height / gridRows;
let pathIndex = 0;
// Spawn 1-2 paths from each grid cell for even distribution
for (let row = 0; row < gridRows; row++) {
for (let col = 0; col < gridCols; col++) {
// 1-2 paths per cell
const pathsInCell = 1 + Math.floor(Math.random() * 2);
for (let p = 0; p < pathsInCell; p++) {
const pathColor = colors[Math.floor(Math.random() * colors.length)];
// Start within this grid cell (with padding)
let x = (col * cellWidth) + (cellWidth * 0.15) + (Math.random() * cellWidth * 0.7);
let y = (row * cellHeight) + (cellHeight * 0.15) + (Math.random() * cellHeight * 0.7);
let delay = pathIndex * 15;
// Each path has 3-5 short segments
const numSegments = 3 + Math.floor(Math.random() * 3);
let horizontal = Math.random() > 0.5;
for (let s = 0; s < numSegments; s++) {
const trace = document.createElement('div');
trace.className = 'embed-trace ' + (horizontal ? 'h' : 'v') + ' ' + pathColor;
// Shorter segments: 12-30px for denser circuit look
const length = 12 + Math.random() * 18;
trace.style.left = Math.max(0, Math.min(x, width - length)) + 'px';
trace.style.top = Math.max(0, Math.min(y, height - length)) + 'px';
trace.style.animationDelay = delay + 'ms';
if (horizontal) {
trace.style.width = length + 'px';
} else {
trace.style.height = length + 'px';
}
container.appendChild(trace);
// Move position for next segment
if (horizontal) {
x += length * (Math.random() > 0.5 ? 1 : -1);
} else {
y += length * (Math.random() > 0.5 ? 1 : -1);
}
// Keep within bounds
x = Math.max(5, Math.min(x, width - 20));
y = Math.max(5, Math.min(y, height - 20));
// Alternate direction (90 degree turn)
horizontal = !horizontal;
delay += 20;
}
pathIndex++;
}
container.appendChild(trace);
// Move position for next segment
if (horizontal) {
x += length;
} else {
y += length;
}
// Wrap around if out of bounds to keep traces in view
if (x > width - 20) x = 10 + Math.random() * 40;
if (y > height - 20) y = 10 + Math.random() * 40;
if (x < 10) x = width - 60 + Math.random() * 40;
if (y < 10) y = height - 60 + Math.random() * 40;
// Alternate direction (90 degree turn)
horizontal = !horizontal;
delay += 30;
}
}
},
@@ -571,7 +604,7 @@ const Stegasoo = {
console.log('QR crop/extract error:', err);
container.classList.remove('loading', 'scanning');
container.classList.add('error');
// Update loader to show error
const loader = container.querySelector('.qr-loader');
if (loader) {
@@ -580,6 +613,17 @@ const Stegasoo = {
<span>No QR code detected</span>
`;
}
// Reset after delay so user can try again
setTimeout(() => {
container.classList.remove('error');
container.classList.add('d-none');
label?.classList.remove('d-none');
// Clear the file input so same file can be re-selected
input.value = '';
// Remove loader
if (loader) loader.remove();
}, 2000);
});
});
},
@@ -761,18 +805,43 @@ const Stegasoo = {
const customInputId = config.customInputId || 'channelCustomInput';
const keyInputId = config.keyInputId || 'channelKeyInput';
const generateBtnId = config.generateBtnId;
const serverInfoId = config.serverInfoId || 'channelServerInfo';
const select = document.getElementById(selectId);
const customInput = document.getElementById(customInputId);
const keyInput = document.getElementById(keyInputId);
const generateBtn = generateBtnId ? document.getElementById(generateBtnId) : null;
const serverInfo = document.getElementById(serverInfoId);
// Show/hide custom input based on selection
// Show/hide custom input and server info based on selection
const updateVisibility = () => {
const isCustom = select?.value === 'custom';
const value = select?.value;
const isCustom = value === 'custom';
const isPublic = value === 'none';
const isAuto = value === 'auto';
// Custom input visibility
customInput?.classList.toggle('d-none', !isCustom);
if (isCustom && keyInput) {
keyInput.focus();
// Pulse highlight effect
customInput?.classList.add('channel-highlight');
setTimeout(() => customInput?.classList.remove('channel-highlight'), 400);
}
// Server info: show for auto, hide for custom, show "no key" for public
if (serverInfo) {
if (isAuto) {
serverInfo.innerHTML = '<i class="bi bi-shield-lock me-1"></i>Server: <code>' + (serverInfo.dataset.fingerprint || '••••-••••-···-••••-••••') + '</code>';
serverInfo.className = 'small text-success mt-2';
serverInfo.classList.remove('d-none');
} else if (isPublic) {
serverInfo.innerHTML = '<i class="bi bi-globe me-1"></i>No channel key will be used';
serverInfo.className = 'small text-muted mt-2';
serverInfo.classList.remove('d-none');
} else {
serverInfo.classList.add('d-none');
}
}
};
@@ -815,6 +884,14 @@ const Stegasoo = {
// Set the select value to the actual key for form submission
select.value = keyInput.value;
}
// Track saved key usage (fire-and-forget)
const selectedOption = select?.selectedOptions?.[0];
const keyId = selectedOption?.dataset?.keyId;
if (keyId) {
fetch(`/api/channel/keys/${keyId}/use`, { method: 'POST' }).catch(() => {});
}
return true;
},
@@ -851,10 +928,578 @@ const Stegasoo = {
});
},
// ========================================================================
// ASYNC ENCODE WITH PROGRESS (v4.1.2)
// ========================================================================
/**
* Submit encode form asynchronously with progress tracking
* @param {HTMLFormElement} form - The encode form
* @param {HTMLElement} btn - The submit button
*/
async submitEncodeAsync(form, btn) {
const formData = new FormData(form);
formData.append('async', 'true');
// Show progress modal
this.showProgressModal('Encoding');
try {
// Start encode job
const response = await fetch('/encode', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Failed to start encode');
}
const result = await response.json();
if (result.error) {
throw new Error(result.error);
}
const jobId = result.job_id;
// Poll for progress
await this.pollEncodeProgress(jobId);
} catch (error) {
this.hideProgressModal();
alert('Encode failed: ' + error.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-lock-fill me-2"></i>Encode';
}
},
/**
* Poll encode progress until complete
* @param {string} jobId - The job ID
*/
async pollEncodeProgress(jobId) {
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const phaseText = document.getElementById('progressPhase');
const poll = async () => {
try {
// Check status first
const statusResponse = await fetch(`/encode/status/${jobId}`);
const statusData = await statusResponse.json();
if (statusData.status === 'complete') {
// Done - redirect to result
this.updateProgress(100, 'Complete!');
setTimeout(() => {
window.location.href = `/encode/result/${statusData.file_id}`;
}, 500);
return;
}
if (statusData.status === 'error') {
throw new Error(statusData.error || 'Encode failed');
}
// Get progress
const progressResponse = await fetch(`/encode/progress/${jobId}`);
const progressData = await progressResponse.json();
const percent = progressData.percent || 0;
const phase = progressData.phase || 'processing';
this.updateProgress(percent, this.formatPhase(phase));
// Continue polling
setTimeout(poll, 500);
} catch (error) {
this.hideProgressModal();
alert('Encode failed: ' + error.message);
}
};
await poll();
},
/**
* Format phase name for display
*/
formatPhase(phase) {
const phases = {
'starting': 'Starting...',
'initializing': 'Initializing...',
'embedding': 'Embedding data...',
'saving': 'Saving image...',
'finalizing': 'Finalizing...',
'complete': 'Complete!',
};
return phases[phase] || phase;
},
/**
* Show progress modal
*/
showProgressModal(operation = 'Processing') {
// Create modal if doesn't exist
let modal = document.getElementById('progressModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'progressModal';
modal.className = 'modal fade';
modal.setAttribute('data-bs-backdrop', 'static');
modal.setAttribute('data-bs-keyboard', 'false');
modal.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light">
<div class="modal-body p-4">
<h5 class="mb-3" id="progressTitle">${operation}...</h5>
<div class="progress mb-2" style="height: 24px;">
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated bg-success"
role="progressbar" style="width: 0%"></div>
</div>
<div class="d-flex justify-content-between text-muted small">
<span id="progressPhase">Initializing...</span>
<span id="progressText">0%</span>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
}
// Reset progress
this.updateProgress(0, 'Initializing...');
// Show modal
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
},
/**
* Hide progress modal
*/
hideProgressModal() {
const modal = document.getElementById('progressModal');
if (modal) {
const bsModal = bootstrap.Modal.getInstance(modal);
bsModal?.hide();
}
},
/**
* Update progress bar and text
*/
updateProgress(percent, phase) {
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const phaseText = document.getElementById('progressPhase');
if (progressBar) progressBar.style.width = percent + '%';
if (progressText) progressText.textContent = Math.round(percent) + '%';
if (phaseText) phaseText.textContent = phase;
},
// ========================================================================
// ASYNC DECODE WITH PROGRESS (v4.1.5)
// ========================================================================
/**
* Submit decode form asynchronously with progress tracking
* @param {HTMLFormElement} form - The decode form
* @param {HTMLElement} btn - The submit button
*/
async submitDecodeAsync(form, btn) {
const formData = new FormData(form);
formData.append('async', 'true');
// Show progress modal
this.showProgressModal('Decoding');
try {
// Start decode job
const response = await fetch('/decode', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Failed to start decode');
}
const result = await response.json();
if (result.error) {
throw new Error(result.error);
}
const jobId = result.job_id;
// Poll for progress
await this.pollDecodeProgress(jobId);
} catch (error) {
this.hideProgressModal();
alert('Decode failed: ' + error.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-unlock-fill me-2"></i>Decode';
}
},
/**
* Poll decode progress until complete
* @param {string} jobId - The job ID
*/
async pollDecodeProgress(jobId) {
const poll = async () => {
try {
// Check status first
const statusResponse = await fetch(`/decode/status/${jobId}`);
const statusData = await statusResponse.json();
if (statusData.status === 'complete') {
// Done - redirect to result page
this.updateProgress(100, 'Complete!');
setTimeout(() => {
window.location.href = `/decode/result/${jobId}`;
}, 500);
return;
}
if (statusData.status === 'error') {
// Handle specific error types
const errorType = statusData.error_type;
let errorMsg = statusData.error || 'Decode failed';
if (errorType === 'DecryptionError' || errorMsg.toLowerCase().includes('decrypt')) {
errorMsg = 'Wrong credentials. Double-check your reference photo, passphrase, PIN, and channel key.';
}
throw new Error(errorMsg);
}
// Get progress
const progressResponse = await fetch(`/decode/progress/${jobId}`);
const progressData = await progressResponse.json();
const percent = progressData.percent || 0;
const phase = progressData.phase || 'processing';
this.updateProgress(percent, this.formatDecodePhase(phase));
// Continue polling
setTimeout(poll, 500);
} catch (error) {
this.hideProgressModal();
alert(error.message);
}
};
await poll();
},
/**
* Format decode phase name for display
*/
formatDecodePhase(phase) {
const phases = {
'starting': 'Starting...',
'reading': 'Reading image...',
'extracting': 'Extracting data...',
'decrypting': 'Decrypting...',
'verifying': 'Verifying...',
'finalizing': 'Finalizing...',
'complete': 'Complete!',
};
return phases[phase] || phase;
},
// ========================================================================
// WEBCAM QR SCANNING (v4.1.5)
// ========================================================================
/**
* Active scanner instance
*/
_qrScanner: null,
_qrScannerModal: null,
_qrScannerCallback: null,
/**
* Show webcam QR scanner modal
* @param {Function} onSuccess - Callback with decoded QR text
* @param {string} title - Modal title
*/
showQrScanner(onSuccess, title = 'Scan QR Code') {
this._qrScannerCallback = onSuccess;
// Create modal if doesn't exist
let modal = document.getElementById('qrScannerModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'qrScannerModal';
modal.className = 'modal fade';
modal.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light">
<div class="modal-header border-secondary">
<h5 class="modal-title">
<i class="bi bi-camera-video me-2"></i>
<span id="qrScannerTitle">${title}</span>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="qrScannerReader" style="width: 100%;"></div>
<div id="qrScannerStatus" class="text-center py-3 text-muted">
<i class="bi bi-qr-code-scan me-2"></i>
Point camera at QR code
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-primary" id="qrCaptureBtn">
<i class="bi bi-camera me-1"></i>Capture
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Clean up scanner when modal hides
modal.addEventListener('hidden.bs.modal', () => {
this.stopQrScanner();
});
// Manual capture button
modal.querySelector('#qrCaptureBtn')?.addEventListener('click', () => {
this.captureQrFrame();
});
}
// Update title
const titleEl = modal.querySelector('#qrScannerTitle');
if (titleEl) titleEl.textContent = title;
// Reset status
const statusEl = modal.querySelector('#qrScannerStatus');
if (statusEl) {
statusEl.innerHTML = '<i class="bi bi-qr-code-scan me-2"></i>Point camera at QR code';
statusEl.className = 'text-center py-3 text-muted';
}
// Show modal
this._qrScannerModal = new bootstrap.Modal(modal);
this._qrScannerModal.show();
// Start scanner after modal is shown
modal.addEventListener('shown.bs.modal', () => {
this.startQrScanner();
}, { once: true });
},
/**
* Start the QR scanner
*/
startQrScanner() {
const readerEl = document.getElementById('qrScannerReader');
if (!readerEl) return;
// Check if Html5Qrcode is available
if (typeof Html5Qrcode === 'undefined') {
console.error('Html5Qrcode library not loaded');
const statusEl = document.getElementById('qrScannerStatus');
if (statusEl) {
statusEl.innerHTML = '<i class="bi bi-exclamation-triangle text-warning me-2"></i>QR scanner not available';
}
return;
}
this._qrScanner = new Html5Qrcode('qrScannerReader');
const config = {
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0,
};
this._qrScanner.start(
{ facingMode: 'environment' }, // Prefer back camera
config,
(decodedText, decodedResult) => {
// QR code detected
this.onQrCodeDetected(decodedText);
},
(errorMessage) => {
// Scan error (ignore, keep scanning)
}
).catch((err) => {
console.error('Failed to start scanner:', err);
const statusEl = document.getElementById('qrScannerStatus');
if (statusEl) {
if (err.toString().includes('Permission')) {
statusEl.innerHTML = '<i class="bi bi-camera-video-off text-danger me-2"></i>Camera permission denied';
} else {
statusEl.innerHTML = '<i class="bi bi-exclamation-triangle text-warning me-2"></i>Could not access camera';
}
statusEl.className = 'text-center py-3';
}
});
},
/**
* Capture a frame with countdown and try to decode
*/
captureQrFrame() {
const statusEl = document.getElementById('qrScannerStatus');
const captureBtn = document.getElementById('qrCaptureBtn');
if (!statusEl || !this._qrScanner) return;
// Disable button during countdown
if (captureBtn) captureBtn.disabled = true;
let count = 3;
const countdown = () => {
if (count > 0) {
statusEl.innerHTML = `<i class="bi bi-camera me-2"></i><span style="font-size: 1.5rem; font-weight: bold;">${count}</span>`;
statusEl.className = 'text-center py-3 text-warning';
count--;
setTimeout(countdown, 1000);
} else {
// Capture!
statusEl.innerHTML = '<i class="bi bi-hourglass-split me-2"></i>Analyzing...';
statusEl.className = 'text-center py-3 text-info';
// Get video element and capture frame
const video = document.querySelector('#qrScannerReader video');
if (video) {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
// Stop the scanner before file scan (prevents conflicts)
const scanner = this._qrScanner;
scanner.stop().then(() => {
canvas.toBlob((blob) => {
const file = new File([blob], 'capture.png', { type: 'image/png' });
scanner.scanFile(file, true)
.then((decodedText) => {
this.onQrCodeDetected(decodedText);
})
.catch((err) => {
statusEl.innerHTML = '<i class="bi bi-x-circle text-danger me-2"></i>No QR code found. Try again.';
statusEl.className = 'text-center py-3 text-danger';
if (captureBtn) captureBtn.disabled = false;
// Restart the scanner
this.startQrScanner();
});
}, 'image/png');
}).catch(() => {
statusEl.innerHTML = '<i class="bi bi-x-circle text-danger me-2"></i>Scanner error';
statusEl.className = 'text-center py-3 text-danger';
if (captureBtn) captureBtn.disabled = false;
});
} else {
statusEl.innerHTML = '<i class="bi bi-x-circle text-danger me-2"></i>Camera not ready';
statusEl.className = 'text-center py-3 text-danger';
if (captureBtn) captureBtn.disabled = false;
}
}
};
countdown();
},
/**
* Stop the QR scanner
*/
stopQrScanner() {
if (this._qrScanner) {
this._qrScanner.stop().then(() => {
this._qrScanner.clear();
this._qrScanner = null;
}).catch((err) => {
console.log('Scanner stop error:', err);
});
}
},
/**
* Handle detected QR code
* @param {string} text - Decoded QR text
*/
onQrCodeDetected(text) {
// Update status
const statusEl = document.getElementById('qrScannerStatus');
if (statusEl) {
statusEl.innerHTML = '<i class="bi bi-check-circle text-success me-2"></i>QR code detected!';
statusEl.className = 'text-center py-3 text-success';
}
// Close modal after brief delay
setTimeout(() => {
this._qrScannerModal?.hide();
// Call callback
if (this._qrScannerCallback) {
this._qrScannerCallback(text);
}
}, 500);
},
/**
* Add camera scan button to an input field
* @param {string} inputId - ID of the input field
* @param {string} title - Modal title
* @param {Function} validator - Optional validation function for scanned text
*/
addCameraScanButton(inputId, title = 'Scan QR Code', validator = null) {
const input = document.getElementById(inputId);
if (!input) return;
// Create button
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-outline-secondary';
btn.innerHTML = '<i class="bi bi-camera"></i>';
btn.title = 'Scan QR code with camera';
btn.addEventListener('click', () => {
this.showQrScanner((text) => {
// Validate if validator provided
if (validator && !validator(text)) {
alert('Invalid QR code format');
return;
}
// Set input value
input.value = text;
// Trigger input event for formatting
input.dispatchEvent(new Event('input', { bubbles: true }));
}, title);
});
// Wrap input in input-group if not already
const parent = input.parentElement;
if (!parent.classList.contains('input-group')) {
const wrapper = document.createElement('div');
wrapper.className = 'input-group';
parent.insertBefore(wrapper, input);
wrapper.appendChild(input);
wrapper.appendChild(btn);
} else {
parent.appendChild(btn);
}
},
// ========================================================================
// INITIALIZATION HELPERS
// ========================================================================
initEncodePage() {
this.initPasswordToggles();
this.initRsaMethodToggle();
@@ -872,18 +1517,56 @@ const Stegasoo = {
generateBtnId: 'channelKeyGenerate'
});
// Form submission with channel key validation
// Webcam QR scanning for channel key (v4.1.5)
document.getElementById('channelKeyScan')?.addEventListener('click', () => {
this.showQrScanner((text) => {
const input = document.getElementById('channelKeyInput');
if (input) {
const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
input.value = clean.length === 32 ? clean.match(/.{4}/g).join('-') : text.toUpperCase();
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}, 'Scan Channel Key');
});
// Webcam QR scanning for RSA key (v4.1.5)
document.getElementById('rsaQrWebcam')?.addEventListener('click', () => {
this.showQrScanner((text) => {
// Check for raw PEM or compressed format (STEGASOO-Z: prefix)
const isRawPem = text.includes('-----BEGIN') && text.includes('KEY-----');
const isCompressed = text.startsWith('STEGASOO-Z:');
if (isRawPem || isCompressed) {
// Valid RSA key data scanned
document.getElementById('rsaKeyPem').value = text;
// Show success in drop zone
const dropZone = document.getElementById('qrDropZone');
const label = dropZone?.querySelector('.drop-zone-label');
if (label) {
label.innerHTML = '<i class="bi bi-check-circle text-success fs-4 d-block mb-1"></i><span class="text-success small">RSA Key scanned successfully</span>';
}
} else {
alert('QR code does not contain a valid RSA key');
}
}, 'Scan RSA Key QR');
});
// Form submission with async progress tracking (v4.1.2)
const form = document.getElementById('encodeForm');
const btn = document.getElementById('encodeBtn');
form?.addEventListener('submit', (e) => {
e.preventDefault();
if (!this.validateChannelKeyOnSubmit(form, 'channelSelect', 'channelKeyInput')) {
e.preventDefault();
return false;
}
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Encoding...';
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting...';
}
// Use async submission with progress tracking
this.submitEncodeAsync(form, btn);
});
},
@@ -892,7 +1575,7 @@ const Stegasoo = {
this.initRsaMethodToggle();
this.initDropZones();
this.initClipboardPaste(['input[name="stego_image"]', 'input[name="reference_photo"]']);
this.initQrCropAnimation('rsaKeyQrInput');
this.initQrCropAnimation('rsaQrInput');
this.initCollapseChevrons();
this.initPassphraseFontResize();
@@ -900,22 +1583,60 @@ const Stegasoo = {
this.initChannelKey({
selectId: 'channelSelectDec',
customInputId: 'channelCustomInputDec',
keyInputId: 'channelKeyInputDec'
keyInputId: 'channelKeyInputDec',
serverInfoId: 'channelServerInfoDec'
});
// Form submission with channel key validation and mode display
// Webcam QR scanning for channel key (v4.1.5)
document.getElementById('channelKeyScanDec')?.addEventListener('click', () => {
this.showQrScanner((text) => {
const input = document.getElementById('channelKeyInputDec');
if (input) {
const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
input.value = clean.length === 32 ? clean.match(/.{4}/g).join('-') : text.toUpperCase();
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}, 'Scan Channel Key');
});
// Webcam QR scanning for RSA key (v4.1.5)
document.getElementById('rsaQrWebcam')?.addEventListener('click', () => {
this.showQrScanner((text) => {
// Check for raw PEM or compressed format (STEGASOO-Z: prefix)
const isRawPem = text.includes('-----BEGIN') && text.includes('KEY-----');
const isCompressed = text.startsWith('STEGASOO-Z:');
if (isRawPem || isCompressed) {
// Valid RSA key data scanned
document.getElementById('rsaKeyPem').value = text;
// Show success in drop zone
const dropZone = document.getElementById('qrDropZone');
const label = dropZone?.querySelector('.drop-zone-label');
if (label) {
label.innerHTML = '<i class="bi bi-check-circle text-success fs-4 d-block mb-1"></i><span class="text-success small">RSA Key scanned successfully</span>';
}
} else {
alert('QR code does not contain a valid RSA key');
}
}, 'Scan RSA Key QR');
});
// Form submission with async progress tracking (v4.1.5)
const form = document.getElementById('decodeForm');
const btn = document.getElementById('decodeBtn');
form?.addEventListener('submit', (e) => {
e.preventDefault();
if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) {
e.preventDefault();
return false;
}
const selectedMode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'auto';
if (btn) {
btn.disabled = true;
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Decoding (${selectedMode.toUpperCase()})...`;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting...';
}
// Use async submission with progress tracking
this.submitDecodeAsync(form, btn);
});
},

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -111,6 +111,7 @@ def encode_operation(params: dict) -> dict:
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
@@ -135,14 +136,33 @@ def encode_operation(params: dict) -> dict:
}
def _write_decode_progress(progress_file: str | None, percent: int, phase: str) -> None:
"""Write decode progress to file."""
if not progress_file:
return
try:
import json
with open(progress_file, "w") as f:
json.dump({"percent": percent, "phase": phase}, f)
except Exception:
pass # Best effort
def decode_operation(params: dict) -> dict:
"""Handle decode operation."""
from stegasoo import decode
progress_file = params.get("progress_file")
# Progress: starting
_write_decode_progress(progress_file, 5, "reading")
# Decode base64 inputs
stego_data = base64.b64decode(params["stego_b64"])
reference_data = base64.b64decode(params["reference_b64"])
_write_decode_progress(progress_file, 15, "reading")
# Optional RSA key
rsa_key_data = None
if params.get("rsa_key_b64"):
@@ -151,6 +171,8 @@ def decode_operation(params: dict) -> dict:
# Resolve channel key (v4.0.0)
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
_write_decode_progress(progress_file, 25, "extracting")
# Call decode with correct parameter names
result = decode(
stego_image=stego_data,
@@ -163,6 +185,8 @@ def decode_operation(params: dict) -> dict:
channel_key=resolved_channel_key, # v4.0.0
)
_write_decode_progress(progress_file, 90, "finalizing")
if result.is_file:
return {
"success": True,

View File

@@ -47,6 +47,8 @@ import base64
import json
import subprocess
import sys
import tempfile
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -233,6 +235,8 @@ class SubprocessStego:
# 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.
@@ -268,6 +272,7 @@ class SubprocessStego:
"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:
@@ -309,6 +314,8 @@ class SubprocessStego:
# Channel key (v4.0.0)
channel_key: str | None = "auto",
timeout: int | None = None,
# Progress tracking (v4.1.5)
progress_file: str | None = None,
) -> DecodeResult:
"""
Decode a message or file from a stego image.
@@ -323,6 +330,7 @@ class SubprocessStego:
embed_mode: 'auto', 'lsb', or 'dct'
channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0)
timeout: Operation timeout in seconds
progress_file: Path to write progress updates (v4.1.5)
Returns:
DecodeResult with message or file_data on success
@@ -335,6 +343,7 @@ class SubprocessStego:
"pin": pin,
"embed_mode": embed_mode,
"channel_key": channel_key, # v4.0.0
"progress_file": progress_file, # v4.1.5
}
if rsa_key_data:
@@ -496,3 +505,42 @@ def get_subprocess_stego() -> SubprocessStego:
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

@@ -0,0 +1,212 @@
"""
File-based Temporary Storage
Stores temp files on disk instead of in-memory dict.
This allows multiple Gunicorn workers to share temp files
and survives service restarts within the expiry window.
Files are stored in a temp directory with:
- {file_id}.data - The actual file data
- {file_id}.json - Metadata (filename, timestamp, mime_type, etc.)
IMPORTANT: This module ONLY manages files in the temp_files/ directory.
It does NOT touch instance/ (auth database) or any other directories.
"""
import json
import time
from pathlib import Path
from threading import Lock
# Default temp directory (can be overridden)
DEFAULT_TEMP_DIR = Path(__file__).parent / "temp_files"
# Lock for thread-safe operations
_lock = Lock()
# Module-level temp directory (set on init)
_temp_dir: Path = DEFAULT_TEMP_DIR
def init(temp_dir: Path | str | None = None):
"""Initialize temp storage with optional custom directory."""
global _temp_dir
_temp_dir = Path(temp_dir) if temp_dir else DEFAULT_TEMP_DIR
_temp_dir.mkdir(parents=True, exist_ok=True)
def _data_path(file_id: str) -> Path:
"""Get path for file data."""
return _temp_dir / f"{file_id}.data"
def _meta_path(file_id: str) -> Path:
"""Get path for file metadata."""
return _temp_dir / f"{file_id}.json"
def _thumb_path(thumb_id: str) -> Path:
"""Get path for thumbnail data."""
return _temp_dir / f"{thumb_id}.thumb"
def save_temp_file(file_id: str, data: bytes, metadata: dict) -> None:
"""
Save a temp file with its metadata.
Args:
file_id: Unique identifier for the file
data: File contents as bytes
metadata: Dict with filename, mime_type, timestamp, etc.
"""
init() # Ensure directory exists
with _lock:
# Add timestamp if not present
if "timestamp" not in metadata:
metadata["timestamp"] = time.time()
# Write data file
_data_path(file_id).write_bytes(data)
# Write metadata
_meta_path(file_id).write_text(json.dumps(metadata))
def get_temp_file(file_id: str) -> dict | None:
"""
Get a temp file and its metadata.
Returns:
Dict with 'data' (bytes) and all metadata fields, or None if not found.
"""
init()
data_file = _data_path(file_id)
meta_file = _meta_path(file_id)
if not data_file.exists() or not meta_file.exists():
return None
try:
data = data_file.read_bytes()
metadata = json.loads(meta_file.read_text())
return {"data": data, **metadata}
except (OSError, json.JSONDecodeError):
return None
def has_temp_file(file_id: str) -> bool:
"""Check if a temp file exists."""
init()
return _data_path(file_id).exists() and _meta_path(file_id).exists()
def delete_temp_file(file_id: str) -> None:
"""Delete a temp file and its metadata."""
init()
with _lock:
_data_path(file_id).unlink(missing_ok=True)
_meta_path(file_id).unlink(missing_ok=True)
def save_thumbnail(thumb_id: str, data: bytes) -> None:
"""Save a thumbnail."""
init()
with _lock:
_thumb_path(thumb_id).write_bytes(data)
def get_thumbnail(thumb_id: str) -> bytes | None:
"""Get thumbnail data."""
init()
thumb_file = _thumb_path(thumb_id)
if not thumb_file.exists():
return None
try:
return thumb_file.read_bytes()
except OSError:
return None
def delete_thumbnail(thumb_id: str) -> None:
"""Delete a thumbnail."""
init()
with _lock:
_thumb_path(thumb_id).unlink(missing_ok=True)
def cleanup_expired(max_age_seconds: float) -> int:
"""
Delete expired temp files.
Args:
max_age_seconds: Maximum age in seconds before expiry
Returns:
Number of files deleted
"""
init()
now = time.time()
deleted = 0
with _lock:
# Find all metadata files
for meta_file in _temp_dir.glob("*.json"):
try:
metadata = json.loads(meta_file.read_text())
timestamp = metadata.get("timestamp", 0)
if now - timestamp > max_age_seconds:
file_id = meta_file.stem
_data_path(file_id).unlink(missing_ok=True)
meta_file.unlink(missing_ok=True)
# Also delete thumbnail if exists
_thumb_path(f"{file_id}_thumb").unlink(missing_ok=True)
deleted += 1
except (OSError, json.JSONDecodeError):
# Remove corrupted files
meta_file.unlink(missing_ok=True)
deleted += 1
return deleted
def cleanup_all() -> int:
"""
Delete all temp files. Call on service start/stop.
Returns:
Number of files deleted
"""
init()
deleted = 0
with _lock:
for f in _temp_dir.iterdir():
if f.is_file():
f.unlink(missing_ok=True)
deleted += 1
return deleted
def get_stats() -> dict:
"""Get temp storage statistics."""
init()
files = list(_temp_dir.glob("*.data"))
total_size = sum(f.stat().st_size for f in files if f.exists())
return {
"file_count": len(files),
"total_size_bytes": total_size,
"temp_dir": str(_temp_dir),
}

View File

@@ -65,7 +65,7 @@
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Channel Keys</strong>
<span class="badge bg-info ms-1">v4.0</span>
<span class="badge bg-info ms-1">v4.1</span>
<br><small class="text-muted">Group/deployment isolation</small>
</li>
</ul>
@@ -100,6 +100,7 @@
<li><strong>Output:</strong> JPEG or PNG</li>
<li><strong>Color:</strong> Color or grayscale</li>
<li><strong>Speed:</strong> ~2s</li>
<li><strong>Error Correction:</strong> Reed-Solomon</li>
</ul>
<hr>
<div class="small">
@@ -250,7 +251,7 @@
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-broadcast me-2"></i>Channel Keys
<span class="badge bg-info ms-2">v4.0</span>
<span class="badge bg-info ms-2">v4.1</span>
</h5>
</div>
<div class="card-body">
@@ -325,7 +326,7 @@
{% else %}
<div class="alert alert-info mt-3 mb-0">
<i class="bi bi-info-circle me-2"></i>
This server is running in <strong>public mode</strong>.
This server is running in <strong>public mode</strong>.
Set <code>STEGASOO_CHANNEL_KEY</code> to enable server-wide channel isolation.
</div>
{% endif %}
@@ -338,49 +339,64 @@
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Version History</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-dark table-sm small">
<thead>
<tr>
<th>Version</th>
<th>Changes</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>4.0.0</strong></td>
<td>
<strong>Channel keys</strong> for group/deployment isolation,
DCT default, simplified auth, passphrase replaces day_phrase,
4-word default, JPEG fix, large image support, subprocess isolation, Python 3.10-3.12
</td>
</tr>
<tr>
<td>3.2.0</td>
<td>Single passphrase, more default words</td>
</tr>
<tr>
<td>3.0.0</td>
<td>DCT mode, JPEG output, color preservation</td>
</tr>
<tr>
<td>2.2.0</td>
<td>QR code RSA key import/export</td>
</tr>
<tr>
<td>2.1.0</td>
<td>File embedding, compression</td>
</tr>
<tr>
<td>2.0.0</td>
<td>Web UI, REST API, RSA keys</td>
</tr>
<tr>
<td>1.0.0</td>
<td>Initial release, CLI only, LSB mode</td>
</tr>
</tbody>
</table>
<!-- 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>
</div>
</div>
@@ -469,61 +485,114 @@
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Limits &amp; Specs</h5>
</div>
<div class="card-body">
<table class="table table-dark table-striped small">
<tbody>
<tr>
<td><i class="bi bi-file-text me-2"></i>Max text</td>
<td><strong>2M characters</strong></td>
</tr>
<tr>
<td><i class="bi bi-file-earmark me-2"></i>Max file</td>
<td><strong>{{ max_payload_kb }} KB</strong></td>
</tr>
<tr>
<td><i class="bi bi-image me-2"></i>Max carrier</td>
<td><strong>24 MP</strong> (~6000x4000)</td>
</tr>
<tr>
<td><i class="bi bi-soundwave me-2"></i>DCT capacity</td>
<td><strong>~75 KB/MP</strong></td>
</tr>
<tr>
<td><i class="bi bi-grid-3x3 me-2"></i>LSB capacity</td>
<td><strong>~375 KB/MP</strong></td>
</tr>
<tr>
<td><i class="bi bi-upload me-2"></i>Max upload</td>
<td><strong>30 MB</strong></td>
</tr>
<tr>
<td><i class="bi bi-clock me-2"></i>File expiry</td>
<td><strong>5 min</strong></td>
</tr>
<tr>
<td><i class="bi bi-key me-2"></i>PIN</td>
<td><strong>6-9 digits</strong></td>
</tr>
<tr>
<td><i class="bi bi-shield me-2"></i>RSA keys</td>
<td><strong>2048, 3072, 4096 bit</strong></td>
</tr>
<tr>
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>
<td><strong>3-12 words</strong> (BIP-39)</td>
</tr>
<tr>
<td><i class="bi bi-code me-2"></i>Python Version</td>
<td><strong>3.10-3.12</strong></td>
</tr>
<tr>
<td><i class="bi bi-box me-2"></i>Built with</td>
<td>Flask, Pillow, NumPy, SciPy, jpegio, cryptography, argon2-cffi</td>
</tr>
</tbody>
</table>
<!-- Key Specs - Always Visible -->
<div class="row text-center mb-4">
<div class="col-6 col-md-4 col-lg-2 mb-3">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-file-earmark text-primary fs-3 d-block mb-2"></i>
<div class="small text-muted">Max Payload</div>
<strong>{{ max_payload_kb }} KB</strong>
</div>
</div>
<div class="col-6 col-md-4 col-lg-2 mb-3">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-image text-info fs-3 d-block mb-2"></i>
<div class="small text-muted">Max Carrier</div>
<strong>24 MP</strong>
</div>
</div>
<div class="col-6 col-md-4 col-lg-2 mb-3">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-soundwave text-warning fs-3 d-block mb-2"></i>
<div class="small text-muted">DCT Capacity</div>
<strong>~75 KB/MP</strong>
</div>
</div>
<div class="col-6 col-md-4 col-lg-2 mb-3">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-grid-3x3 text-success fs-3 d-block mb-2"></i>
<div class="small text-muted">LSB Capacity</div>
<strong>~375 KB/MP</strong>
</div>
</div>
<div class="col-6 col-md-4 col-lg-2 mb-3">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-shield-check text-danger fs-3 d-block mb-2"></i>
<div class="small text-muted">Encryption</div>
<strong>AES-256</strong>
</div>
</div>
<div class="col-6 col-md-4 col-lg-2 mb-3">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-bandaid text-info fs-3 d-block mb-2"></i>
<div class="small text-muted">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>
</div>
{% endblock %}

View File

@@ -0,0 +1,440 @@
{% 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">
{% if is_admin %}
<button type="button" class="btn btn-outline-info"
onclick="showKeyQr('{{ key.channel_key }}', '{{ key.name }}')"
title="Show QR Code">
<i class="bi bi-qr-code"></i>
</button>
{% endif %}
<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">
<div class="input-group input-group-sm">
<input type="text" name="channel_key" id="channelKeyInput"
class="form-control font-monospace"
placeholder="XXXX-XXXX-..." required
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}">
<button type="button" class="btn btn-outline-secondary" id="scanChannelKeyBtn"
title="Scan QR code with camera">
<i class="bi bi-camera"></i>
</button>
</div>
</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>
{% if is_admin %}
<!-- QR Code Modal (Admin only) -->
<div class="modal fade" id="qrModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title"><i class="bi bi-qr-code me-2"></i><span id="qrKeyName">Channel Key</span></h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<canvas id="qrCanvas" class="bg-white p-2 rounded"></canvas>
<div class="mt-2">
<code class="small" id="qrKeyDisplay"></code>
</div>
</div>
<div class="modal-footer justify-content-center">
<button type="button" class="btn btn-sm btn-outline-secondary" id="qrDownload">
<i class="bi bi-download me-1"></i>Download
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="qrPrint">
<i class="bi bi-printer me-1"></i>Print Sheet
</button>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
{% if is_admin %}
<script src="{{ url_for('static', filename='js/qrcode.min.js') }}"></script>
{% endif %}
<script>
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
// Webcam QR scanning for channel key input (v4.1.5)
document.getElementById('scanChannelKeyBtn')?.addEventListener('click', function() {
Stegasoo.showQrScanner((text) => {
const input = document.getElementById('channelKeyInput');
if (input) {
// Clean and format the key
const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
if (clean.length === 32) {
input.value = clean.match(/.{4}/g).join('-');
} else {
input.value = text.toUpperCase();
}
}
}, 'Scan Channel Key');
});
// Format channel key input as user types
document.getElementById('channelKeyInput')?.addEventListener('input', function() {
Stegasoo.formatChannelKeyInput(this);
});
function renameKey(keyId, currentName) {
document.getElementById('renameInput').value = currentName;
document.getElementById('renameForm').action = '/account/keys/' + keyId + '/rename';
new bootstrap.Modal(document.getElementById('renameModal')).show();
}
{% if is_admin %}
function showKeyQr(channelKey, keyName) {
// Format key with dashes if not already
const clean = channelKey.replace(/-/g, '').toUpperCase();
const formatted = clean.match(/.{4}/g)?.join('-') || clean;
// Update modal content
document.getElementById('qrKeyName').textContent = keyName;
document.getElementById('qrKeyDisplay').textContent = formatted;
// Generate QR code using QRious
const canvas = document.getElementById('qrCanvas');
if (typeof QRious !== 'undefined' && canvas) {
try {
new QRious({
element: canvas,
value: formatted,
size: 200,
level: 'M'
});
new bootstrap.Modal(document.getElementById('qrModal')).show();
} catch (error) {
console.error('QR generation error:', error);
}
}
}
// Download QR as PNG
document.getElementById('qrDownload')?.addEventListener('click', function() {
const canvas = document.getElementById('qrCanvas');
const keyName = document.getElementById('qrKeyName').textContent;
if (canvas) {
const link = document.createElement('a');
link.download = 'stegasoo-channel-key-' + keyName.toLowerCase().replace(/\s+/g, '-') + '.png';
link.href = canvas.toDataURL('image/png');
link.click();
}
});
// Print tiled QR sheet (US Letter)
document.getElementById('qrPrint')?.addEventListener('click', function() {
const canvas = document.getElementById('qrCanvas');
const keyText = document.getElementById('qrKeyDisplay').textContent;
const keyName = document.getElementById('qrKeyName').textContent;
if (canvas && keyText) {
printQrSheet(canvas, keyText, keyName);
}
});
// Print QR codes tiled on US Letter paper (8.5" x 11")
function printQrSheet(canvas, keyText, title) {
const qrDataUrl = canvas.toDataURL('image/png');
const printWindow = window.open('', '_blank');
if (!printWindow) {
alert('Please allow popups to print');
return;
}
// US Letter: 8.5" x 11" - create 4x5 grid of QR codes
const cols = 4;
const rows = 5;
// Split key into two lines (4 groups each)
const keyParts = keyText.split('-');
const keyLine1 = keyParts.slice(0, 4).join('-');
const keyLine2 = keyParts.slice(4).join('-');
let qrGrid = '';
for (let i = 0; i < rows * cols; i++) {
qrGrid += `
<div class="qr-tile">
<div class="key-text">${keyLine1}</div>
<img src="${qrDataUrl}" alt="QR">
<div class="key-text">${keyLine2}</div>
</div>
`;
}
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title></title>
<style>
@page {
size: letter;
margin: 0.2in;
margin-top: 0.1in;
margin-bottom: 0.1in;
}
@media print {
@page { margin: 0.15in; }
html, body { margin: 0; padding: 0; }
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Courier New', monospace;
background: white;
}
.grid {
display: grid;
grid-template-columns: repeat(${cols}, 1fr);
gap: 0;
margin-top: 0.09in;
}
.qr-tile {
border: 1px dashed #ccc;
padding: 0.04in;
text-align: center;
page-break-inside: avoid;
}
.qr-tile img {
width: 1.6in;
height: 1.6in;
}
.key-text {
font-size: 10pt;
font-weight: bold;
color: #333;
line-height: 1.2;
}
.footer {
display: none;
}
</style>
</head>
<body>
<div class="grid">${qrGrid}</div>
<div class="footer">Cut along dashed lines</div>
<script>
window.onload = function() { window.print(); };
<\/script>
</body>
</html>
`);
printWindow.document.close();
}
{% endif %}
</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,506 @@
{% extends "base.html" %}
{% block title %}System Settings - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-10">
<!-- Channel Key Configuration -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-broadcast me-2"></i>Channel Key Configuration</h5>
</div>
<div class="card-body">
{% if channel_configured %}
<div class="alert alert-success mb-4">
<i class="bi bi-shield-lock me-2"></i>
<strong>Server channel key active:</strong>
<code class="ms-2">{{ channel_fingerprint }}</code>
<span class="text-muted ms-2">({{ channel_source }})</span>
</div>
{% else %}
<div class="alert alert-info mb-4">
<i class="bi bi-info-circle me-2"></i>
Server running in <strong>public mode</strong>.
Set <code>STEGASOO_CHANNEL_KEY</code> environment variable to enable server-wide channel isolation.
</div>
{% endif %}
<!-- QR Code 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>
<!-- Locked state - requires password -->
<div id="channelKeyLocked">
<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="password" class="form-control font-monospace"
value="********************************" disabled>
<span class="input-group-text"><i class="bi bi-lock"></i></span>
</div>
</div>
<div class="col-md-4">
<button class="btn btn-warning w-100" type="button" id="channelKeyUnlock">
<i class="bi bi-unlock me-1"></i>Unlock
</button>
</div>
</div>
<small class="text-muted mt-2 d-block">
<i class="bi bi-shield-lock me-1"></i>Re-enter your password to view or export the channel key.
</small>
</div>
<!-- Unlocked state - shows key and QR options -->
<div id="channelKeyUnlocked" style="display: none;">
<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>
<small class="text-success mt-2 d-block">
<i class="bi bi-unlock me-1"></i>Unlocked for this session.
</small>
</div>
</div>
</div>
</div>
</div>
<!-- Server Configuration -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Server Configuration</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<table class="table table-dark table-sm">
<tbody>
<tr>
<td><i class="bi bi-hdd-network me-2"></i>Hostname</td>
<td><code>{{ hostname }}</code></td>
</tr>
<tr>
<td><i class="bi bi-ethernet me-2"></i>Port</td>
<td><code>{{ port }}</code></td>
</tr>
<tr>
<td><i class="bi bi-shield-lock me-2"></i>HTTPS</td>
<td>
{% if https_enabled %}
<span class="badge bg-success"><i class="bi bi-lock me-1"></i>Enabled</span>
{% else %}
<span class="badge bg-warning text-dark"><i class="bi bi-unlock me-1"></i>Disabled</span>
{% endif %}
</td>
</tr>
<tr>
<td><i class="bi bi-person-lock me-2"></i>Authentication</td>
<td>
{% if auth_enabled %}
<span class="badge bg-success"><i class="bi bi-check me-1"></i>Enabled</span>
{% else %}
<span class="badge bg-danger"><i class="bi bi-x me-1"></i>Disabled</span>
{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-6">
<table class="table table-dark table-sm">
<tbody>
<tr>
<td><i class="bi bi-file-earmark me-2"></i>Max Payload</td>
<td><code>{{ max_payload_kb }} KB</code></td>
</tr>
<tr>
<td><i class="bi bi-upload me-2"></i>Max Upload</td>
<td><code>{{ max_upload_mb }} MB</code></td>
</tr>
<tr>
<td><i class="bi bi-soundwave me-2"></i>DCT Mode</td>
<td>
{% if dct_available %}
<span class="badge bg-success"><i class="bi bi-check me-1"></i>Available</span>
{% else %}
<span class="badge bg-secondary">Not Available</span>
{% endif %}
</td>
</tr>
<tr>
<td><i class="bi bi-qr-code me-2"></i>QR Support</td>
<td>
{% if qr_available %}
<span class="badge bg-success"><i class="bi bi-check me-1"></i>Available</span>
{% else %}
<span class="badge bg-secondary">Not Available</span>
{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="alert alert-secondary small mt-3 mb-0">
<i class="bi bi-info-circle me-2"></i>
To change server settings, edit environment variables or config file and restart the service.
<br>See <code>STEGASOO_HTTPS_ENABLED</code>, <code>STEGASOO_PORT</code>, <code>STEGASOO_CHANNEL_KEY</code>
</div>
</div>
</div>
<!-- Environment Info -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Environment</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 col-md-3 mb-3">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-box text-primary fs-3 d-block mb-2"></i>
<div class="small text-muted">Version</div>
<strong>{{ version }}</strong>
</div>
</div>
<div class="col-6 col-md-3 mb-3">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-terminal text-info fs-3 d-block mb-2"></i>
<div class="small text-muted">Python</div>
<strong>{{ python_version }}</strong>
</div>
</div>
<div class="col-6 col-md-3 mb-3">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-cpu text-warning fs-3 d-block mb-2"></i>
<div class="small text-muted">Platform</div>
<strong>{{ platform }}</strong>
</div>
</div>
<div class="col-6 col-md-3 mb-3">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-shield-check text-success fs-3 d-block mb-2"></i>
<div class="small text-muted">KDF</div>
<strong>{{ kdf_type }}</strong>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Password Verification Modal -->
<div class="modal fade" id="passwordModal" tabindex="-1">
<div class="modal-dialog modal-sm modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title"><i class="bi bi-shield-lock me-2"></i>Verify Password</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="small text-muted mb-3">Re-enter your password to access sensitive data.</p>
<div class="mb-3">
<label class="form-label small">Password</label>
<input type="password" class="form-control" id="verifyPassword" autocomplete="current-password">
<div class="invalid-feedback" id="passwordError">Incorrect password</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-warning btn-sm" id="verifyPasswordBtn">
<i class="bi bi-unlock me-1"></i>Unlock
</button>
</div>
</div>
</div>
</div>
<!-- QR Code Modal -->
<div class="modal fade" id="channelKeyQrModal" tabindex="-1">
<div class="modal-dialog modal-sm modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title"><i class="bi bi-qr-code me-2"></i>Channel Key</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<canvas id="channelKeyQrCanvas" class="bg-white p-2 rounded"></canvas>
<div class="mt-2">
<code class="small" id="channelKeyQrDisplay"></code>
</div>
</div>
<div class="modal-footer justify-content-center">
<button type="button" class="btn btn-sm btn-outline-secondary" id="channelKeyQrDownload">
<i class="bi bi-download me-1"></i>Download
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="channelKeyQrPrint">
<i class="bi bi-printer me-1"></i>Print Sheet
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/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 canvas = document.getElementById('channelKeyQrCanvas');
const displayEl = document.getElementById('channelKeyQrDisplay');
const downloadBtn = document.getElementById('channelKeyQrDownload');
const modalEl = document.getElementById('channelKeyQrModal');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
// Password verification elements
const lockedDiv = document.getElementById('channelKeyLocked');
const unlockedDiv = document.getElementById('channelKeyUnlocked');
const unlockBtn = document.getElementById('channelKeyUnlock');
const passwordModalEl = document.getElementById('passwordModal');
const passwordModal = passwordModalEl ? new bootstrap.Modal(passwordModalEl) : null;
const verifyPasswordInput = document.getElementById('verifyPassword');
const verifyPasswordBtn = document.getElementById('verifyPasswordBtn');
const passwordError = document.getElementById('passwordError');
// Unlock button shows password modal
unlockBtn?.addEventListener('click', function() {
verifyPasswordInput.value = '';
verifyPasswordInput.classList.remove('is-invalid');
passwordModal?.show();
setTimeout(() => verifyPasswordInput.focus(), 300);
});
// Handle Enter key in password field
verifyPasswordInput?.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
verifyPasswordBtn?.click();
}
});
// Verify password and unlock
verifyPasswordBtn?.addEventListener('click', async function() {
const password = verifyPasswordInput.value;
if (!password) {
verifyPasswordInput.classList.add('is-invalid');
return;
}
verifyPasswordBtn.disabled = true;
verifyPasswordBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Verifying...';
try {
const response = await fetch('/admin/settings/unlock', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ password })
});
const data = await response.json();
if (data.success) {
// Unlock successful
passwordModal?.hide();
lockedDiv.style.display = 'none';
unlockedDiv.style.display = 'block';
if (data.channel_key && input) {
input.value = data.channel_key;
}
} else {
// Password incorrect
verifyPasswordInput.classList.add('is-invalid');
passwordError.textContent = data.error || 'Incorrect password';
}
} catch (error) {
verifyPasswordInput.classList.add('is-invalid');
passwordError.textContent = 'Verification failed';
} finally {
verifyPasswordBtn.disabled = false;
verifyPasswordBtn.innerHTML = '<i class="bi bi-unlock me-1"></i>Unlock';
}
});
// Generate random key
generateBtn?.addEventListener('click', function() {
if (!input) return;
if (typeof Stegasoo !== 'undefined' && Stegasoo.generateChannelKey) {
input.value = Stegasoo.generateChannelKey();
} else {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let key = '';
for (let i = 0; i < 8; i++) {
if (i > 0) key += '-';
for (let j = 0; j < 4; j++) {
key += chars.charAt(Math.floor(Math.random() * chars.length));
}
}
input.value = key;
}
});
// Show QR code in modal
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;
}
const formatted = key.match(/.{4}/g)?.join('-') || key;
if (typeof QRious === 'undefined') {
alert('QR Code library failed to load.');
return;
}
try {
new QRious({
element: canvas,
value: formatted,
size: 200,
level: 'M'
});
if (displayEl) displayEl.textContent = formatted;
modal?.show();
} catch (error) {
alert('Failed to generate QR code: ' + error.message);
}
});
// 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();
}
});
// Print tiled QR sheet (US Letter)
document.getElementById('channelKeyQrPrint')?.addEventListener('click', function() {
if (canvas && displayEl) {
printQrSheet(canvas, displayEl.textContent, 'Channel Key');
}
});
});
// Print QR codes tiled on US Letter paper (8.5" x 11")
function printQrSheet(canvas, keyText, title) {
const qrDataUrl = canvas.toDataURL('image/png');
const printWindow = window.open('', '_blank');
if (!printWindow) {
alert('Please allow popups to print');
return;
}
// US Letter: 8.5" x 11" - create 4x5 grid of QR codes
const cols = 4;
const rows = 5;
// Split key into two lines (4 groups each)
const keyParts = keyText.split('-');
const keyLine1 = keyParts.slice(0, 4).join('-');
const keyLine2 = keyParts.slice(4).join('-');
let qrGrid = '';
for (let i = 0; i < rows * cols; i++) {
qrGrid += `
<div class="qr-tile">
<div class="key-text">${keyLine1}</div>
<img src="${qrDataUrl}" alt="QR">
<div class="key-text">${keyLine2}</div>
</div>
`;
}
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title></title>
<style>
@page {
size: letter;
margin: 0.2in;
margin-top: 0.1in;
margin-bottom: 0.1in;
}
@media print {
@page { margin: 0.15in; }
html, body { margin: 0; padding: 0; }
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Courier New', monospace;
background: white;
}
.grid {
display: grid;
grid-template-columns: repeat(${cols}, 1fr);
gap: 0;
margin-top: 0.09in;
}
.qr-tile {
border: 1px dashed #ccc;
padding: 0.04in;
text-align: center;
page-break-inside: avoid;
}
.qr-tile img {
width: 1.6in;
height: 1.6in;
}
.key-text {
font-size: 10pt;
font-weight: bold;
color: #333;
line-height: 1.2;
}
.footer {
display: none;
}
</style>
</head>
<body>
<div class="grid">${qrGrid}</div>
<div class="footer">Cut along dashed lines</div>
<script>
window.onload = function() { window.print(); };
<\/script>
</body>
</html>
`);
printWindow.document.close();
}
</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

@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Stegasoo{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
<link href="{{ url_for('static', filename='vendor/css/bootstrap.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='vendor/css/bootstrap-icons.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='style.css') }}" rel="stylesheet">
</head>
<body>
@@ -14,48 +14,78 @@
<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>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<ul class="navbar-nav ms-auto nav-icons">
<li class="nav-item">
<a class="nav-link" href="/"><i class="bi bi-house me-1"></i> Home</a>
<a class="nav-link nav-expand" href="/"><i class="bi bi-house"></i><span>Home</span></a>
</li>
{% if not auth_enabled or is_authenticated %}
<li class="nav-item">
<a class="nav-link nav-expand" href="/encode"><i class="bi bi-lock"></i><span>Encode</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="/encode"><i class="bi bi-lock me-1"></i> Encode</a>
<a class="nav-link nav-expand" href="/decode"><i class="bi bi-unlock"></i><span>Decode</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="/decode"><i class="bi bi-unlock me-1"></i> Decode</a>
<a class="nav-link nav-expand" href="/generate"><i class="bi bi-key"></i><span>Generate</span></a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="/generate"><i class="bi bi-key me-1"></i> Generate</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/about"><i class="bi bi-info-circle me-1"></i> About</a>
<a class="nav-link nav-expand" href="/tools"><i class="bi bi-tools"></i><span>Tools</span></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>
<li><a class="dropdown-item" href="/admin/settings"><i class="bi bi-sliders me-2"></i>System Settings</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 ('warning' if category == 'warning' else 'success') }} alert-dismissible fade show" role="alert">
<i class="bi bi-{{ 'exclamation-triangle' if category == 'error' else ('exclamation-circle' if category == 'warning' else 'check-circle') }} me-2"></i>
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="toast show align-items-center text-bg-{{ 'danger' if category == 'error' else ('warning' if category == 'warning' else 'success') }} border-0 fade" role="alert" data-bs-autohide="true" data-bs-delay="10000">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{ 'exclamation-triangle' if category == 'error' else ('exclamation-circle' if category == 'warning' else 'check-circle') }} me-2"></i>
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% endwith %}
</div>
{% block content %}{% endblock %}
</main>
@@ -64,11 +94,19 @@
<small>
<img src="{{ url_for('static', filename='favicon.svg') }}" alt="" height="16" class="me-1" style="vertical-align: text-bottom;">
Stegasoo v{{ version }} — Steganography with Reference Photo + Passphrase + PIN/Key
<span class="mx-2">|</span>
<a href="/about" class="text-muted text-decoration-none"><i class="bi bi-info-circle me-1"></i>About</a>
</small>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='vendor/js/bootstrap.bundle.min.js') }}"></script>
<!-- QR Code scanning library (local) -->
<script src="{{ url_for('static', filename='vendor/js/html5-qrcode.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

@@ -4,6 +4,74 @@
{% block content %}
<style>
/* Accordion styling */
.step-accordion .accordion-button {
background: rgba(30, 40, 50, 0.6);
color: #fff;
padding: 0.75rem 1rem;
border-left: 3px solid transparent;
transition: all 0.3s ease;
}
.step-accordion .accordion-button:not(.collapsed) {
background: linear-gradient(90deg, rgba(99, 179, 237, 0.15) 0%, rgba(40, 50, 60, 0.8) 40%, rgba(40, 50, 60, 0.8) 100%);
color: #fff;
box-shadow: inset 0 1px 0 rgba(99, 179, 237, 0.1);
border-left: 3px solid rgba(99, 179, 237, 0.6);
}
.step-accordion .accordion-button::after {
filter: invert(1);
}
.step-accordion .accordion-body {
background: rgba(30, 40, 50, 0.4);
padding: 1rem;
}
.step-accordion .accordion-item {
border-color: rgba(255,255,255,0.1);
background: transparent;
}
.step-accordion .accordion-item:first-child .accordion-button {
border-radius: 0;
}
.step-accordion .accordion-item:last-child .accordion-button.collapsed {
border-radius: 0;
}
.step-summary {
font-size: 0.8rem;
color: rgba(255,255,255,0.5);
margin-left: auto;
padding-right: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 50%;
}
.step-summary.has-content {
color: rgba(99, 179, 237, 0.8);
}
.step-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.step-number {
background: rgba(246, 173, 85, 0.2);
color: #f6ad55;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: bold;
border: 1px solid rgba(246, 173, 85, 0.3);
}
.step-number.complete {
background: rgba(72, 187, 120, 0.2);
color: #48bb78;
border-color: rgba(72, 187, 120, 0.3);
}
/* Glowing passphrase input */
.passphrase-input {
background: rgba(30, 40, 50, 0.8) !important;
@@ -13,20 +81,17 @@
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;
transition: border-color 0.3s ease, box-shadow 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;
box-shadow: 0 0 20px rgba(99, 179, 237, 0.4) !important;
}
.passphrase-input::placeholder {
color: rgba(99, 179, 237, 0.4);
}
/* Glowing PIN input */
/* 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;
@@ -35,18 +100,10 @@
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;
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
}
/* QR Crop Animation */
@@ -55,8 +112,12 @@
overflow: hidden;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
width: 100%;
display: flex;
justify-content: center;
align-items: center;
min-height: 120px;
}
.qr-crop-container img {
display: block;
max-height: 180px;
@@ -65,15 +126,10 @@
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-original { opacity: 1; }
.qr-crop-container .qr-cropped {
position: absolute;
top: 50%;
left: 50%;
top: 50%; left: 50%;
transform: translate(-50%, -50%) scale(0.3);
opacity: 0;
max-height: 160px;
@@ -81,30 +137,15 @@
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">
@@ -113,31 +154,29 @@
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-unlock-fill me-2"></i>Decode Secret Message or File</h5>
</div>
<div class="card-body">
<div class="card-body {% if not decoded_message and not decoded_file %}p-0{% endif %}">
{% if decoded_message %}
<!-- Text Message Result -->
<div class="alert alert-success">
<h6><i class="bi bi-check-circle me-2"></i>Message Decrypted Successfully!</h6>
</div>
<label class="form-label text-muted">Decoded Message:</label>
<div class="alert-message p-3 rounded bg-dark border border-secondary mb-2" id="decodedContent" style="white-space: pre-wrap;">{{ decoded_message }}</div>
<div class="d-flex justify-content-end mb-3">
<button class="btn btn-sm btn-outline-light" onclick="navigator.clipboard.writeText(document.getElementById('decodedContent').innerText).then(() => { this.innerHTML = '<i class=\'bi bi-check\'></i> Copied!'; setTimeout(() => this.innerHTML = '<i class=\'bi bi-clipboard\'></i> Copy', 2000); }).catch(() => alert('Failed to copy'))">
<i class="bi bi-clipboard"></i> Copy
</button>
</div>
<label class="form-label text-muted">Decoded Message: <small class="text-secondary">(click to copy)</small></label>
<div class="alert-message p-3 rounded bg-dark border border-secondary mb-3" id="decodedContent" style="white-space: pre-wrap; cursor: pointer; transition: border-color 0.2s;"
onclick="navigator.clipboard.writeText(this.innerText).then(() => { this.style.borderColor = '#198754'; this.dataset.origText = this.innerHTML; this.innerHTML = '<i class=\'bi bi-check-circle text-success\'></i> Copied to clipboard!'; setTimeout(() => { this.innerHTML = this.dataset.origText; this.style.borderColor = ''; }, 1500); }).catch(() => alert('Failed to copy'))"
onmouseover="this.style.borderColor = '#6c757d'"
onmouseout="this.style.borderColor = ''">{{ decoded_message }}</div>
<a href="/decode" class="btn btn-outline-light w-100">
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
</a>
{% elif decoded_file %}
<!-- File Result -->
<div class="alert alert-success">
<h6><i class="bi bi-check-circle me-2"></i>File Decrypted Successfully!</h6>
</div>
<div class="text-center mb-4">
<i class="bi bi-file-earmark-check text-success" style="font-size: 4rem;"></i>
<h5 class="mt-3">{{ filename }}</h5>
@@ -146,338 +185,266 @@
<small class="text-muted">Type: {{ mime_type }}</small>
{% endif %}
</div>
<a href="{{ url_for('decode_download', file_id=file_id) }}" class="btn btn-primary btn-lg w-100 mb-3">
<i class="bi bi-download me-2"></i>Download File
</a>
<div class="alert alert-warning small">
<i class="bi bi-clock me-1"></i>
<strong>File expires in 5 minutes.</strong> Download now.
</div>
<a href="/decode" class="btn btn-outline-light w-100">
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
</a>
{% else %}
<!-- Decode Form -->
<form method="POST" enctype="multipart/form-data" id="decodeForm">
<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 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 same reference photo used for encoding
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
</label>
<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" 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
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">
<i class="bi bi-chat-quote me-1"></i> Passphrase
</label>
<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 passphrase used during encoding (typically 4 words)
</div>
</div>
<hr class="my-4">
<h6 class="text-muted mb-3">
SECURITY FACTORS
<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>
<div class="accordion step-accordion" id="decodeAccordion">
<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-4 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 pin-input-container">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
<i class="bi bi-eye"></i>
<!-- ================================================================
STEP 1: IMAGES & MODE
================================================================ -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
<span class="step-title">
<span class="step-number" id="stepImagesNumber">1</span>
<i class="bi bi-images me-1"></i> Images & Mode
</span>
<span class="step-summary" id="stepImagesSummary">Select reference & stego</span>
</button>
</div>
<div class="form-text">If PIN was used during encoding</div>
</div>
</div>
</h2>
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
<div class="accordion-body">
<div class="col-md-8 mb-3">
<div class="security-box h-100">
<label class="form-label">
<i class="bi bi-broadcast me-1"></i> Channel
<span class="badge bg-info ms-1">v4.0</span>
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
</label>
<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 scan-container" id="refDropZone">
<input type="file" name="reference_photo" accept="image/*" required id="refPhotoInput">
<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</span>
</div>
<img class="drop-zone-preview d-none" id="refPreview">
<div class="scan-overlay"><div class="scan-grid"></div><div class="scan-line"></div></div>
<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>
<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>
</div>
<div class="form-text">Same reference photo used for encoding</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>
<option value="custom">Custom</option>
</select>
<div class="col-md-6 mb-3">
<label class="form-label">
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
</label>
<div class="drop-zone pixel-container" id="stegoDropZone">
<input type="file" name="stego_image" accept="image/*" required id="stegoInput">
<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</span>
</div>
<img class="drop-zone-preview d-none" id="stegoPreview">
<div class="pixel-blocks"></div>
<div class="pixel-scan-line"></div>
<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>
<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">-- x -- px</div>
</div>
</div>
<div class="form-text">Image containing the hidden message</div>
</div>
</div>
<!-- Server channel indicator (compact) -->
{% if channel_configured %}
<div class="small text-success mt-2">
<i class="bi bi-shield-lock me-1"></i>
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
</div>
{% endif %}
</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>
<!-- ================================================================
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">
<!-- Extraction Mode (compact inline) -->
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
<label class="mode-btn mode-btn-sm 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>
<i class="bi bi-magic text-success"></i> Auto
</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 class="mode-btn mode-btn-sm" id="lsbModeCard" for="modeLsb">
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsb" value="lsb">
<i class="bi bi-grid-3x3-gap text-primary"></i> LSB
</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 class="mode-btn mode-btn-sm {% if not has_dct %}opacity-50{% endif %}" id="dctModeCard" for="modeDct">
<input class="form-check-input" type="radio" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
<i class="bi bi-soundwave text-warning"></i> DCT
</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 class="form-text" id="modeHint">
<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT
</div>
</div>
</div>
</div>
<!-- ================================================================
STEP 2: SECURITY
================================================================ -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
<span class="step-title">
<span class="step-number" id="stepSecurityNumber">2</span>
<i class="bi bi-shield-lock me-1"></i> Security
</span>
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
</button>
</h2>
<div id="stepSecurity" class="accordion-collapse collapse" data-bs-parent="#decodeAccordion">
<div class="accordion-body">
<!-- Passphrase -->
<div class="mb-3">
<label class="form-label"><i class="bi bi-chat-quote me-1"></i> Passphrase</label>
<input type="text" name="passphrase" class="form-control passphrase-input"
placeholder="e.g., apple forest thunder mountain" required id="passphraseInput">
<div class="form-text">The passphrase used during encoding</div>
</div>
<hr class="my-3 opacity-25">
<div class="small text-muted mb-2">Provide same factors used during encoding</div>
<div class="row">
<!-- PIN -->
<div class="col-md-6 mb-2">
<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>
</div>
<!-- Channel -->
<div class="col-md-6 mb-2">
<div class="security-box h-100">
<label class="form-label"><i class="bi bi-broadcast me-1"></i> Channel</label>
<select class="form-select form-select-sm" name="channel_key" id="channelSelectDec">
<option value="auto" selected>Auto{% if channel_configured %} (Server){% 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 }}">{{ key.name }}</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="custom">Custom...</option>
</select>
</div>
</div>
</div>
<!-- Custom Channel Key -->
<div class="mb-3 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 form-control-sm font-monospace"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" id="channelKeyInputDec">
<button class="btn btn-outline-secondary btn-sm" type="button" id="channelKeyScanDec" title="Scan QR"><i class="bi bi-camera"></i></button>
</div>
</div>
</div>
<!-- RSA Key -->
<div class="mb-3">
<div class="security-box">
<label class="form-label"><i class="bi bi-file-earmark-lock me-1"></i> RSA Key <span class="text-muted">(if used)</span></label>
<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</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</label>
</div>
<div id="rsaFileSection">
<input type="file" name="rsa_key" class="form-control form-control-sm" accept=".pem">
</div>
<div id="rsaQrSection" class="d-none d-flex flex-column">
<input type="hidden" name="rsa_key_pem" id="rsaKeyPem">
<div class="drop-zone p-2 w-100" 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-5 d-block text-muted mb-1"></i>
<span class="text-muted small">Drop QR image</span>
</div>
<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">
</div>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2" id="rsaQrWebcam">
<i class="bi bi-camera me-1"></i>Scan with Camera
</button>
</div>
<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>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn">
<i class="bi bi-unlock me-2"></i>Decode
</button>
<!-- Submit Button -->
<div class="p-3">
<button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn">
<i class="bi bi-unlock me-2"></i>Decode
</button>
</div>
</form>
{% endif %}
</div>
</div>
{% if not decoded_message and not decoded_file %}
<!-- Troubleshooting Card -->
<div class="card mt-4">
<div class="card-body">
<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-check-circle-fill text-success me-1"></i>
Use the <strong>exact same reference photo</strong> file (byte-for-byte identical)
Use the <strong>exact same reference photo</strong> (byte-for-byte identical)
</li>
<li class="mb-2">
<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-check-circle-fill text-success me-1"></i>
Provide the <strong>same security factors</strong> (PIN and/or RSA key) used during encoding
Enter the <strong>exact passphrase</strong> used during encoding
</li>
<li class="mb-2">
<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)
Ensure the stego image hasn't been <strong>resized or recompressed</strong>
</li>
<li class="mb-0">
<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
If auto-detection fails, try specifying <strong>LSB or DCT mode</strong>
</li>
</ul>
</div>
@@ -490,28 +457,111 @@
{% block scripts %}
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
<script>
// 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')
// ============================================================================
// MODE HINT - Dynamic text based on selected extraction mode
// ============================================================================
const modeHints = {
auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' },
lsb: { icon: 'hdd', text: 'For email and direct transfers' },
dct: { icon: 'phone', text: 'For social media images' }
};
extractModeRadios.forEach(radio => {
radio.addEventListener('change', () => {
Object.values(extractModeBtns).forEach(btn => btn?.classList.remove('active'));
extractModeBtns[radio.value]?.classList.add('active');
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
radio.addEventListener('change', function() {
const hint = document.getElementById('modeHint');
const data = modeHints[this.value];
if (hint && data) {
hint.innerHTML = `<i class="bi bi-${data.icon} me-1"></i>${data.text}`;
}
});
});
// Advanced options chevron
const advancedOptionsDec = document.getElementById('advancedOptionsDec');
advancedOptionsDec?.addEventListener('show.bs.collapse', () => {
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-down', 'bi-chevron-up');
});
advancedOptionsDec?.addEventListener('hide.bs.collapse', () => {
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-up', 'bi-chevron-down');
// ============================================================================
// ACCORDION SUMMARY UPDATES
// ============================================================================
function updateImagesSummary() {
const ref = document.getElementById('refPhotoInput')?.files[0];
const stego = document.getElementById('stegoInput')?.files[0];
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'AUTO';
const summary = document.getElementById('stepImagesSummary');
const stepNum = document.getElementById('stepImagesNumber');
if (ref && stego) {
const refName = ref.name.length > 12 ? ref.name.slice(0, 10) + '..' : ref.name;
const stegoName = stego.name.length > 12 ? stego.name.slice(0, 10) + '..' : stego.name;
summary.textContent = `${refName} + ${stegoName}, ${mode}`;
summary.classList.add('has-content');
stepNum.classList.add('complete');
stepNum.innerHTML = '<i class="bi bi-check"></i>';
} else if (ref || stego) {
summary.textContent = ref ? ref.name.slice(0, 15) : stego.name.slice(0, 15);
summary.classList.remove('has-content');
stepNum.classList.remove('complete');
stepNum.textContent = '1';
} else {
summary.textContent = 'Select reference & stego';
summary.classList.remove('has-content');
stepNum.classList.remove('complete');
stepNum.textContent = '1';
}
}
function updateSecuritySummary() {
const passphrase = document.getElementById('passphraseInput')?.value || '';
const pin = document.getElementById('pinInput')?.value || '';
const rsaFile = document.querySelector('input[name="rsa_key"]')?.files[0];
const rsaPem = document.getElementById('rsaKeyPem')?.value || '';
const summary = document.getElementById('stepSecuritySummary');
const stepNum = document.getElementById('stepSecurityNumber');
const parts = [];
if (passphrase.trim()) parts.push('passphrase');
if (pin) parts.push('PIN');
if (rsaFile || rsaPem) parts.push('RSA');
if (parts.length > 0) {
summary.textContent = parts.join(' + ');
summary.classList.add('has-content');
if (passphrase.trim()) {
stepNum.classList.add('complete');
stepNum.innerHTML = '<i class="bi bi-check"></i>';
}
} else {
summary.textContent = 'Passphrase & keys';
summary.classList.remove('has-content');
stepNum.classList.remove('complete');
stepNum.textContent = '2';
}
}
// Attach listeners
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
document.getElementById('stegoInput')?.addEventListener('change', updateImagesSummary);
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
document.getElementById('passphraseInput')?.addEventListener('input', updateSecuritySummary);
document.getElementById('pinInput')?.addEventListener('input', updateSecuritySummary);
document.querySelector('input[name="rsa_key"]')?.addEventListener('change', updateSecuritySummary);
// ============================================================================
// MODE SWITCHING
// ============================================================================
const modeRadios = document.querySelectorAll('input[name="embed_mode"]');
const modeBtns = { 'auto': document.getElementById('autoModeCard'), 'lsb': document.getElementById('lsbModeCard'), 'dct': document.getElementById('dctModeCard') };
modeRadios.forEach(radio => {
radio.addEventListener('change', () => {
Object.values(modeBtns).forEach(btn => btn?.classList.remove('active'));
modeBtns[radio.value]?.classList.add('active');
});
});
// ============================================================================
// LOADING STATE
// ============================================================================
Stegasoo.initFormLoading('decodeForm', 'decodeBtn', 'Decoding...');
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -74,36 +74,47 @@
</div>
</div>
<hr class="my-4">
<!-- Channel Key Generation (v4.0.0) -->
<div class="mb-4">
<label class="form-label">
<i class="bi bi-broadcast me-1"></i> Channel Key
<span class="badge bg-info ms-1">v4.0</span>
<a href="{{ url_for('about') }}#channel-keys" class="text-muted ms-2" title="Learn about channel keys">
<i class="bi bi-question-circle"></i>
</a>
</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="text" class="form-control font-monospace" id="channelKeyGenerated"
placeholder="Click Generate" readonly>
<button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn">
<i class="bi bi-shuffle me-1"></i>Generate
</button>
<button class="btn btn-outline-secondary" type="button" id="copyChannelKeyBtn" disabled title="Copy to clipboard">
<i class="bi bi-clipboard"></i>
</button>
</div>
<div class="form-text">For private groups: generate, then use <strong>Custom</strong> mode when encoding/decoding.</div>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100 mt-3">
<button type="submit" class="btn btn-primary btn-lg w-100 mt-4">
<i class="bi bi-shuffle me-2"></i>Generate Credentials
</button>
</form>
<!-- Channel Key Accordion (Advanced) -->
<div class="accordion mt-4" id="advancedAccordion">
<div class="accordion-item bg-dark">
<h2 class="accordion-header">
<button class="accordion-button collapsed bg-dark text-light" type="button"
data-bs-toggle="collapse" data-bs-target="#channelKeyCollapse">
<i class="bi bi-broadcast me-2"></i>Channel Key
<span class="badge bg-info ms-2">Advanced</span>
</button>
</h2>
<div id="channelKeyCollapse" class="accordion-collapse collapse" data-bs-parent="#advancedAccordion">
<div class="accordion-body">
<p class="text-muted small mb-3">
Channel keys create private encoding channels. Only users with the same key can decode each other's images.
<a href="{{ url_for('about') }}#channel-keys" class="text-info">Learn more</a>
</p>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="text" class="form-control font-monospace" id="channelKeyGenerated"
placeholder="Click Generate to create a key" readonly>
<button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn">
<i class="bi bi-shuffle me-1"></i>Generate
</button>
<button class="btn btn-outline-secondary" type="button" id="copyChannelKeyBtn" disabled title="Copy to clipboard">
<i class="bi bi-clipboard"></i>
</button>
</div>
<div class="form-text mt-2">
<i class="bi bi-info-circle me-1"></i>
After generating, configure this key in your server's environment or use <strong>Custom</strong> channel mode when encoding/decoding.
</div>
</div>
</div>
</div>
</div>
{% else %}
<!-- Generated Credentials Display -->
@@ -498,61 +509,12 @@
{% block scripts %}
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
<script>
// ============================================================================
// GENERATE PAGE - Form Controls
// ============================================================================
// Words range slider
const wordsRange = document.getElementById('wordsRange');
const wordsValue = document.getElementById('wordsValue');
wordsRange?.addEventListener('input', function() {
const bits = this.value * 11;
wordsValue.textContent = `${this.value} words (~${bits} bits)`;
});
// Toggle PIN/RSA options visibility
const usePinCheck = document.getElementById('usePinCheck');
const useRsaCheck = document.getElementById('useRsaCheck');
const pinOptions = document.getElementById('pinOptions');
const rsaOptions = document.getElementById('rsaOptions');
const rsaQrWarning = document.getElementById('rsaQrWarning');
const rsaBitsSelect = document.getElementById('rsaBitsSelect');
usePinCheck?.addEventListener('change', function() {
pinOptions?.classList.toggle('d-none', !this.checked);
});
useRsaCheck?.addEventListener('change', function() {
rsaOptions?.classList.toggle('d-none', !this.checked);
});
// RSA key size QR warning (>3072 bits)
rsaBitsSelect?.addEventListener('change', function() {
rsaQrWarning?.classList.toggle('d-none', parseInt(this.value) <= 3072);
});
<script src="{{ url_for('static', filename='js/generate.js') }}"></script>
{% if generated %}
// ============================================================================
// GENERATE PAGE - Credential Display
// ============================================================================
<script>
// Page-specific data from Jinja
const passphraseWords = '{{ passphrase|default("", true) }}'.split(' ').filter(w => w.length > 0);
// PIN visibility toggle
let pinHidden = false;
function togglePinVisibility() {
const pinDigits = document.getElementById('pinDigits');
const icon = document.getElementById('pinToggleIcon');
const text = document.getElementById('pinToggleText');
pinHidden = !pinHidden;
pinDigits?.classList.toggle('blurred', pinHidden);
if (icon) icon.className = pinHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
if (text) text.textContent = pinHidden ? 'Show' : 'Hide';
}
// Copy PIN
function copyPin() {
Stegasoo.copyToClipboard(
'{{ pin|default("", true) }}',
@@ -561,21 +523,6 @@ function copyPin() {
);
}
// Passphrase visibility toggle
let passphraseHidden = false;
function togglePassphraseVisibility() {
const display = document.getElementById('passphraseDisplay');
const icon = document.getElementById('passphraseToggleIcon');
const text = document.getElementById('passphraseToggleText');
passphraseHidden = !passphraseHidden;
display?.classList.toggle('blurred', passphraseHidden);
if (icon) icon.className = passphraseHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
if (text) text.textContent = passphraseHidden ? 'Show' : 'Hide';
}
// Copy passphrase
function copyPassphrase() {
Stegasoo.copyToClipboard(
'{{ passphrase|default("", true) }}',
@@ -584,148 +531,13 @@ function copyPassphrase() {
);
}
// ============================================================================
// Memory Aid Story Generation - Templates by word count
// ============================================================================
const passphrase = '{{ passphrase|default("", true) }}';
const passphraseWords = passphrase.split(' ').filter(w => w.length > 0);
let currentStoryTemplate = 0;
// Templates organized by word count (3-12 words supported)
const storyTemplatesByLength = {
3: [
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])}.`,
w => `${hl(w[0])} loves ${hl(w[1])} and ${hl(w[2])}.`,
w => `A ${hl(w[0])} found a ${hl(w[1])} near the ${hl(w[2])}.`,
w => `${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])} — never forget.`,
w => `The ${hl(w[0])} hid the ${hl(w[1])} under the ${hl(w[2])}.`,
],
4: [
w => `${hl(w[0])} and ${hl(w[1])} discovered a ${hl(w[2])} made of ${hl(w[3])}.`,
w => `The ${hl(w[0])} ${hl(w[1])} ate ${hl(w[2])} for ${hl(w[3])}.`,
w => `In the ${hl(w[0])}, a ${hl(w[1])} met a ${hl(w[2])} carrying ${hl(w[3])}.`,
w => `${hl(w[0])} said "${hl(w[1])}" while holding a ${hl(w[2])} ${hl(w[3])}.`,
w => `The secret: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}.`,
],
5: [
w => `${hl(w[0])} traveled to ${hl(w[1])} seeking the ${hl(w[2])} of ${hl(w[3])} and ${hl(w[4])}.`,
w => `The ${hl(w[0])} ${hl(w[1])} lived in a ${hl(w[2])} house with ${hl(w[3])} ${hl(w[4])}.`,
w => `"${hl(w[0])}!" shouted ${hl(w[1])} as the ${hl(w[2])} ${hl(w[3])} flew toward ${hl(w[4])}.`,
w => `Captain ${hl(w[0])} sailed the ${hl(w[1])} ${hl(w[2])} searching for ${hl(w[3])} ${hl(w[4])}.`,
w => `In ${hl(w[0])} kingdom, ${hl(w[1])} guards protected the ${hl(w[2])} ${hl(w[3])} ${hl(w[4])}.`,
],
6: [
w => `${hl(w[0])} met ${hl(w[1])} at the ${hl(w[2])}. Together they found ${hl(w[3])}, ${hl(w[4])}, and ${hl(w[5])}.`,
w => `The ${hl(w[0])} ${hl(w[1])} wore a ${hl(w[2])} hat while eating ${hl(w[3])} ${hl(w[4])} ${hl(w[5])}.`,
w => `Detective ${hl(w[0])} found ${hl(w[1])} ${hl(w[2])} near the ${hl(w[3])} ${hl(w[4])} ${hl(w[5])}.`,
w => `In the ${hl(w[0])} ${hl(w[1])}, a ${hl(w[2])} ${hl(w[3])} sang about ${hl(w[4])} ${hl(w[5])}.`,
w => `Chef ${hl(w[0])} combined ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, and ${hl(w[5])}.`,
],
7: [
w => `${hl(w[0])} and ${hl(w[1])} walked through the ${hl(w[2])} ${hl(w[3])} to find the ${hl(w[4])} ${hl(w[5])} ${hl(w[6])}.`,
w => `The ${hl(w[0])} professor studied ${hl(w[1])} ${hl(w[2])} while drinking ${hl(w[3])} ${hl(w[4])} with ${hl(w[5])} ${hl(w[6])}.`,
w => `"${hl(w[0])} ${hl(w[1])}!" yelled ${hl(w[2])} as ${hl(w[3])} ${hl(w[4])} attacked the ${hl(w[5])} ${hl(w[6])}.`,
w => `In ${hl(w[0])}, King ${hl(w[1])} decreed that ${hl(w[2])} ${hl(w[3])} must honor ${hl(w[4])} ${hl(w[5])} ${hl(w[6])}.`,
],
8: [
w => `${hl(w[0])} ${hl(w[1])} and ${hl(w[2])} ${hl(w[3])} met at the ${hl(w[4])} ${hl(w[5])} to discuss ${hl(w[6])} ${hl(w[7])}.`,
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])} traveled from ${hl(w[3])} to ${hl(w[4])} carrying ${hl(w[5])} ${hl(w[6])} ${hl(w[7])}.`,
w => `${hl(w[0])} discovered that ${hl(w[1])} ${hl(w[2])} plus ${hl(w[3])} ${hl(w[4])} equals ${hl(w[5])} ${hl(w[6])} ${hl(w[7])}.`,
],
9: [
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} watched as ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} danced with ${hl(w[6])} ${hl(w[7])} ${hl(w[8])}.`,
w => `In the ${hl(w[0])} ${hl(w[1])} ${hl(w[2])}, three friends — ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])} — found ${hl(w[6])} ${hl(w[7])} ${hl(w[8])}.`,
w => `The recipe: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])}, ${hl(w[6])}, ${hl(w[7])}, ${hl(w[8])}.`,
],
10: [
w => `${hl(w[0])} ${hl(w[1])} told ${hl(w[2])} ${hl(w[3])} about the ${hl(w[4])} ${hl(w[5])} ${hl(w[6])} hidden in ${hl(w[7])} ${hl(w[8])} ${hl(w[9])}.`,
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])} ${hl(w[3])} ${hl(w[4])} lived beside ${hl(w[5])} ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])}.`,
],
11: [
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} and ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} discovered ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])}.`,
w => `In ${hl(w[0])} ${hl(w[1])}, the ${hl(w[2])} ${hl(w[3])} ${hl(w[4])} sang of ${hl(w[5])} ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])}.`,
],
12: [
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} met ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} at the ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])} ${hl(w[11])}.`,
w => `The twelve treasures: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])}, ${hl(w[6])}, ${hl(w[7])}, ${hl(w[8])}, ${hl(w[9])}, ${hl(w[10])}, ${hl(w[11])}.`,
],
};
function hl(word) {
return `<span class="passphrase-word">${word}</span>`;
}
function generateStory(idx = null) {
const count = passphraseWords.length;
if (count === 0) return '';
// Clamp to supported range (3-12)
const templateKey = Math.max(3, Math.min(12, count));
const templates = storyTemplatesByLength[templateKey];
if (!templates || templates.length === 0) {
// Fallback: just list the words
return passphraseWords.map(w => hl(w)).join(' &mdash; ');
}
const templateIdx = (idx ?? currentStoryTemplate) % templates.length;
return templates[templateIdx](passphraseWords);
}
function toggleMemoryAid() {
const container = document.getElementById('memoryAidContainer');
const icon = document.getElementById('memoryAidIcon');
const text = document.getElementById('memoryAidText');
const isHidden = container?.classList.contains('d-none');
container?.classList.toggle('d-none', !isHidden);
if (icon) icon.className = isHidden ? 'bi bi-lightbulb-fill' : 'bi bi-lightbulb';
if (text) text.textContent = isHidden ? 'Hide Aid' : 'Memory Aid';
if (isHidden) {
document.getElementById('memoryStory').innerHTML = generateStory();
}
StegasooGenerate.toggleMemoryAid(passphraseWords);
}
function regenerateStory() {
const count = passphraseWords.length;
const templateKey = Math.max(3, Math.min(12, count));
const templates = storyTemplatesByLength[templateKey] || [];
currentStoryTemplate = (currentStoryTemplate + 1) % Math.max(1, templates.length);
document.getElementById('memoryStory').innerHTML = generateStory(currentStoryTemplate);
StegasooGenerate.regenerateStory(passphraseWords);
}
// Print QR code
function printQrCode() {
const qrImg = document.getElementById('qrCodeImage');
if (!qrImg) return;
const printWindow = window.open('', '_blank');
printWindow.document.write(`<!DOCTYPE html>
<html>
<head>
<title>Stegasoo RSA Key QR Code</title>
<style>
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; font-family: sans-serif; }
img { max-width: 400px; }
.warning { margin-top: 20px; padding: 10px; border: 2px solid #ff9800; background: #fff3e0; max-width: 400px; text-align: center; font-size: 12px; }
</style>
</head>
<body>
<h2>Stegasoo RSA Private Key</h2>
<img src="${qrImg.src}" alt="RSA Key QR Code">
<div class="warning">
<strong>⚠️ SECURITY WARNING</strong><br>
This QR code contains your unencrypted RSA private key.<br>
Store securely and destroy after use.
</div>
<script>window.onload = function() { window.print(); }<\/script>
</body>
</html>`);
printWindow.document.close();
}
{% endif %}
</script>
{% endif %}
{% endblock %}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,289 +0,0 @@
#!/usr/bin/env python3
"""
Minimal Flask app to isolate the crash.
Run with: python minimal_flask_crash.py
Then test with:
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test1
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test2
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test3
"""
import io
import gc
import os
import sys
import tempfile
# Minimal imports first
from flask import Flask, request, jsonify
from PIL import Image
import numpy as np
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB
# Check for jpegio
try:
import jpegio as jio
HAS_JPEGIO = True
print("jpegio: available")
except ImportError:
HAS_JPEGIO = False
print("jpegio: NOT available")
@app.route('/test1', methods=['POST'])
def test1_pil_only():
"""Test 1: PIL only, no jpegio, no scipy"""
carrier = request.files.get('carrier')
if not carrier:
return jsonify({'error': 'No carrier'}), 400
data = carrier.read()
print(f"[test1] Read {len(data)} bytes")
img = Image.open(io.BytesIO(data))
width, height = img.size
fmt = img.format
img.close()
print(f"[test1] Image: {width}x{height} {fmt}")
gc.collect()
print("[test1] Returning response...")
return jsonify({
'test': 'pil_only',
'width': width,
'height': height,
'format': fmt,
})
@app.route('/test2', methods=['POST'])
def test2_multiple_opens():
"""Test 2: Open image multiple times like compare_modes does"""
carrier = request.files.get('carrier')
if not carrier:
return jsonify({'error': 'No carrier'}), 400
data = carrier.read()
print(f"[test2] Read {len(data)} bytes")
# First open
img1 = Image.open(io.BytesIO(data))
width, height = img1.size
img1.close()
print(f"[test2] Open 1: {width}x{height}")
# Second open
img2 = Image.open(io.BytesIO(data))
pixels = img2.size[0] * img2.size[1]
img2.close()
print(f"[test2] Open 2: {pixels} pixels")
# Third open
img3 = Image.open(io.BytesIO(data))
blocks = (img3.size[0] // 8) * (img3.size[1] // 8)
img3.close()
print(f"[test2] Open 3: {blocks} blocks")
gc.collect()
print("[test2] Returning response...")
return jsonify({
'test': 'multiple_opens',
'width': width,
'height': height,
'pixels': pixels,
'blocks': blocks,
})
@app.route('/test3', methods=['POST'])
def test3_with_jpegio():
"""Test 3: Include jpegio operations"""
if not HAS_JPEGIO:
return jsonify({'error': 'jpegio not available'}), 501
carrier = request.files.get('carrier')
if not carrier:
return jsonify({'error': 'No carrier'}), 400
data = carrier.read()
print(f"[test3] Read {len(data)} bytes")
# Check if JPEG
img = Image.open(io.BytesIO(data))
is_jpeg = img.format == 'JPEG'
width, height = img.size
img.close()
print(f"[test3] Image: {width}x{height}, JPEG: {is_jpeg}")
if not is_jpeg:
return jsonify({'error': 'Not a JPEG'}), 400
# Write to temp file
fd, temp_path = tempfile.mkstemp(suffix='.jpg')
os.write(fd, data)
os.close(fd)
print(f"[test3] Temp file: {temp_path}")
try:
# Read with jpegio
jpeg = jio.read(temp_path)
print(f"[test3] jpegio.read() OK")
coef = jpeg.coef_arrays[0]
coef_shape = coef.shape
print(f"[test3] Coef shape: {coef_shape}")
# Count positions like the real code does
positions = 0
h, w = coef.shape
for row in range(h):
for col in range(w):
if (row % 8 == 0) and (col % 8 == 0):
continue
if abs(coef[row, col]) >= 2:
positions += 1
print(f"[test3] Usable positions: {positions}")
# Cleanup
del coef
del jpeg
print(f"[test3] Deleted jpegio objects")
finally:
os.unlink(temp_path)
print(f"[test3] Removed temp file")
gc.collect()
print("[test3] Returning response...")
return jsonify({
'test': 'with_jpegio',
'width': width,
'height': height,
'coef_shape': list(coef_shape),
'positions': positions,
})
@app.route('/test4', methods=['POST'])
def test4_numpy_array_from_pil():
"""Test 4: Create numpy array from PIL image (like DCT does)"""
carrier = request.files.get('carrier')
if not carrier:
return jsonify({'error': 'No carrier'}), 400
data = carrier.read()
print(f"[test4] Read {len(data)} bytes")
img = Image.open(io.BytesIO(data))
width, height = img.size
print(f"[test4] Image: {width}x{height}")
# Convert to grayscale and numpy array
gray = img.convert('L')
arr = np.array(gray, dtype=np.float64, copy=True)
print(f"[test4] Array: {arr.shape} {arr.dtype}")
# Close PIL images
gray.close()
img.close()
print(f"[test4] PIL closed")
# Do some numpy operations
mean_val = float(np.mean(arr))
std_val = float(np.std(arr))
print(f"[test4] Stats: mean={mean_val:.2f}, std={std_val:.2f}")
# Clear array
del arr
gc.collect()
print("[test4] Returning response...")
return jsonify({
'test': 'numpy_from_pil',
'width': width,
'height': height,
'mean': mean_val,
'std': std_val,
})
@app.route('/test5', methods=['POST'])
def test5_file_read_keep_reference():
"""Test 5: Keep reference to file data in request scope"""
carrier = request.files.get('carrier')
if not carrier:
return jsonify({'error': 'No carrier'}), 400
# Don't read into local variable - read directly each time
# This mimics potential issues with Flask's file handling
print(f"[test5] File object: {carrier}")
# Read once
carrier.seek(0)
data1 = carrier.read()
print(f"[test5] First read: {len(data1)} bytes")
img = Image.open(io.BytesIO(data1))
width, height = img.size
img.close()
# Try to read again (should be empty or need seek)
data2 = carrier.read()
print(f"[test5] Second read (no seek): {len(data2)} bytes")
carrier.seek(0)
data3 = carrier.read()
print(f"[test5] Third read (after seek): {len(data3)} bytes")
gc.collect()
print("[test5] Returning response...")
return jsonify({
'test': 'file_handling',
'width': width,
'height': height,
'read1': len(data1),
'read2': len(data2),
'read3': len(data3),
})
@app.after_request
def after_request(response):
"""Log after each request"""
print(f"[after_request] Response status: {response.status}")
return response
@app.teardown_request
def teardown_request(exception):
"""Log during teardown"""
if exception:
print(f"[teardown] Exception: {exception}")
else:
print("[teardown] Clean teardown")
gc.collect()
if __name__ == '__main__':
print("\n" + "=" * 60)
print("MINIMAL FLASK CRASH TEST")
print("=" * 60)
print("\nTest endpoints:")
print(" /test1 - PIL only")
print(" /test2 - Multiple PIL opens")
print(" /test3 - With jpegio")
print(" /test4 - NumPy array from PIL")
print(" /test5 - File handling test")
print("\nUsage:")
print(' curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test1')
print("=" * 60 + "\n")
app.run(host='0.0.0.0', port=5001, debug=False, threaded=False)

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "stegasoo"
version = "4.0.1"
version = "4.1.5"
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
readme = "README.md"
license = "MIT"
@@ -48,10 +48,13 @@ 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",
@@ -61,10 +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",
@@ -76,6 +81,7 @@ api = [
"numpy>=2.0.0",
"scipy>=1.10.0",
"jpegio>=0.2.0",
"reedsolo>=1.7.0",
]
all = [
"stegasoo[cli,web,api,dct,compression]",

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

186
rpi/BUILD_IMAGE.md Normal file
View File

@@ -0,0 +1,186 @@
# Stegasoo Pi Image Build Workflow
> **Note:** This guide is for developers building custom Pi images.
> **End users:** Just download the pre-built `.img.zst` from [Releases](https://github.com/adlee-was-taken/stegasoo/releases), flash it, and boot. No build process needed.
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 and zstd (not included in Lite image)
sudo apt-get update && sudo apt-get install -y git zstd jq
```
## Step 4: Clone Repo
```bash
cd /opt
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
```
## Step 5: Copy Pre-built Tarball (from host)
> **Dev-only asset:** This tarball is for building Pi images, not for end users.
> It's available on [Releases](https://github.com/adlee-was-taken/stegasoo/releases) for image builders.
```bash
# On your host machine:
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
```
This tarball contains:
- pyenv with Python 3.12 (pre-compiled for ARM64)
- venv with all dependencies (jpegio, scipy, etc.)
Install time: **~2 minutes** (vs 20+ min from source)
## Step 6: Run Setup
```bash
cd /opt/stegasoo
./rpi/setup.sh # Detects local tarball, skips download
```
### From-Source Build (optional)
To build without the pre-built tarball:
```bash
./rpi/setup.sh --no-prebuilt # Takes 15-20 minutes
```
## Step 7: Test It Works
```bash
sudo systemctl start stegasoo
curl -k https://localhost:5000
# Should return HTML
```
## Step 8: 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 9: Pull the Image
Remove SD card, insert into your Linux machine:
```bash
# Find the SD card device (CAREFUL!)
lsblk
# Pull image (auto-resizes to 16GB, compresses with zstd)
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
```
The script automatically resizes rootfs to 16GB, disables auto-expand, and compresses.
## Step 10: 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
```
---
## Creating the Pre-built Tarball
After a successful from-source build, create the pre-built tarball for future installs:
```bash
# On the Pi after successful setup:
cd ~
# Strip caches and tests from venv (295MB → 208MB)
find /opt/stegasoo/venv/ -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
find /opt/stegasoo/venv/ -type d -name 'tests' -exec rm -rf {} + 2>/dev/null
find /opt/stegasoo/venv/ -type d -name 'test' -exec rm -rf {} + 2>/dev/null
# Create venv tarball
cd /opt/stegasoo
tar -cf - venv/ | zstd -19 -T0 > ~/stegasoo-venv.tar.zst
# Create combined tarball (pyenv + venv pointer)
cd ~
tar -cf - .pyenv stegasoo-venv.tar.zst | zstd -19 -T0 > /tmp/stegasoo-rpi-runtime-env-arm64.tar.zst
# Check size (should be ~50-60MB)
ls -lh /tmp/stegasoo-rpi-runtime-env-arm64.tar.zst
```
Pull to host and upload to GitHub releases:
```bash
# On host:
scp admin@stegasoo.local:/tmp/stegasoo-rpi-runtime-env-arm64.tar.zst ./
# Upload to GitHub releases as stegasoo-rpi-runtime-env-arm64.tar.zst
```
---
## Quick Command Summary
```bash
# On Pi (after SSH):
sudo chown admin:admin /opt
sudo apt-get update && sudo apt-get install -y git zstd jq
cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
# On host (copy tarball):
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
# On Pi (run setup):
cd /opt/stegasoo && ./rpi/setup.sh
sudo systemctl start stegasoo
curl -k https://localhost:5000
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
# On host (pull image - auto-resizes to 16GB):
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
```

225
rpi/README.md Normal file
View File

@@ -0,0 +1,225 @@
# 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)
- 16GB+ SD card (pre-built images are 16GB)
- 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`
## Updating an Existing Installation
To update to the latest version:
```bash
cd /opt/stegasoo
git pull origin main
sudo systemctl restart stegasoo
```
That's it - the editable install means Python uses the source directly.
**If dependencies changed** (check release notes), also run:
```bash
source venv/bin/activate
pip install -e ".[web]"
sudo systemctl restart stegasoo
```
## 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
# Pull image (auto-resizes to 16GB, compresses with zstd)
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
```
The `pull-image.sh` script automatically:
- Resizes rootfs to exactly 16GB (consistent image size)
- Disables Pi OS auto-expand
- Compresses with zstd for fast decompression
### 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
```

63
rpi/banner.sh Normal file
View File

@@ -0,0 +1,63 @@
#!/bin/bash
# Stegasoo Banner/Header Template
# Source this file to use the banner functions
#
# Usage:
# source "$(dirname "${BASH_SOURCE[0]}")/banner.sh"
# print_banner "Raspberry Pi Setup"
# print_gradient_line
# Colors
STEGASOO_GOLD='\033[38;5;220m'
STEGASOO_GRAY='\033[0;90m'
STEGASOO_WHITE='\033[1;37m'
STEGASOO_GREEN='\033[0;32m'
STEGASOO_NC='\033[0m'
# Gradient line (purple -> blue)
print_gradient_line() {
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
}
# Starfield decoration line
print_starfield() {
echo -e "${STEGASOO_GRAY} · . · . * · . * · . * · . * · . * · . ·${STEGASOO_NC}"
}
# ASCII logo (gold)
print_logo() {
echo -e "${STEGASOO_GOLD} ___ _____ ___ ___ _ ___ ___ ___${STEGASOO_NC}"
echo -e "${STEGASOO_GOLD} / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\${STEGASOO_NC}"
echo -e "${STEGASOO_GOLD} \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |${STEGASOO_NC}"
echo -e "${STEGASOO_GOLD} |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/${STEGASOO_NC}"
}
# Full banner with optional subtitle
# Usage: print_banner "Subtitle Text"
print_banner() {
local subtitle="$1"
echo ""
print_gradient_line
print_starfield
print_logo
print_starfield
print_gradient_line
if [ -n "$subtitle" ]; then
echo -e "${STEGASOO_WHITE} ${subtitle}${STEGASOO_NC}"
print_gradient_line
fi
}
# Completion banner (green title)
# Usage: print_complete_banner "Setup Complete!"
print_complete_banner() {
local title="$1"
echo ""
print_gradient_line
print_starfield
print_logo
print_starfield
print_gradient_line
echo -e "\033[1;32m ${title}\033[0m"
print_gradient_line
}

12
rpi/config.json.example Normal file
View File

@@ -0,0 +1,12 @@
{
"hostname": "stegasoo",
"username": "admin",
"password": "stegasoo",
"wifiSSID": "YourNetworkName",
"wifiPassword": "YourWiFiPassword",
"wifiCountry": "US",
"locale": "en_US.UTF-8",
"keyboardLayout": "us",
"timezone": "America/New_York",
"enableSSH": true
}

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

@@ -0,0 +1,438 @@
#!/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"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source banner functions
source "$SCRIPT_DIR/banner.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
# =============================================================================
print_banner "First Boot Wizard"
echo ""
gum style --foreground 245 "This wizard will help you configure your Stegasoo server"
echo ""
gum style --foreground 245 "You can reconfigure later by editing:"
gum style --foreground 214 " /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"
# Generate SSL certificates if HTTPS enabled
if [ "$ENABLE_HTTPS" = "true" ]; then
gum spin --spinner dot --title "Generating SSL certificates..." -- bash -c "
CERT_DIR='$INSTALL_DIR/frontends/web/certs'
mkdir -p \"\$CERT_DIR\"
# Get local IP for SAN
LOCAL_IP=\$(hostname -I | awk '{print \$1}')
HOSTNAME=\$(hostname)
# Generate cert with SANs for IP, hostname, and localhost
openssl req -x509 -newkey rsa:2048 \
-keyout \"\$CERT_DIR/server.key\" \
-out \"\$CERT_DIR/server.crt\" \
-days 365 -nodes \
-subj \"/O=Stegasoo/CN=\$HOSTNAME\" \
-addext \"subjectAltName=DNS:\$HOSTNAME,DNS:\$HOSTNAME.local,DNS:localhost,IP:\$LOCAL_IP,IP:127.0.0.1\" \
2>/dev/null
# Fix permissions
chmod 600 \"\$CERT_DIR/server.key\"
chown -R $STEGASOO_USER:\$(id -gn $STEGASOO_USER) \"\$CERT_DIR\"
"
gum style --foreground 82 "✓ SSL certificates generated"
fi
# 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 ""
print_complete_banner "Setup Complete!"
echo ""
gum style --foreground 82 --bold "Create your admin account:"
gum style --foreground 226 " $ACCESS_URL_LOCAL"
gum style --foreground 245 " $ACCESS_URL (fallback IP)"
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 ""

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

@@ -0,0 +1,442 @@
#!/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)
#
# Optional: Place config.json in same directory for headless WiFi setup
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/config.json"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m'
# Load config if present (optional - for headless WiFi setup)
HAS_CONFIG=false
if [ -f "$CONFIG_FILE" ] && command -v jq &> /dev/null; then
WIFI_SSID=$(jq -r '.wifiSSID // empty' "$CONFIG_FILE")
WIFI_PASS=$(jq -r '.wifiPassword // empty' "$CONFIG_FILE")
WIFI_COUNTRY=$(jq -r '.wifiCountry // "US"' "$CONFIG_FILE")
PI_HOSTNAME=$(jq -r '.hostname // empty' "$CONFIG_FILE")
if [ -n "$WIFI_SSID" ] && [ -n "$WIFI_PASS" ]; then
HAS_CONFIG=true
echo -e "${GREEN}Found config.json - will configure WiFi after flash${NC}"
echo -e " WiFi: ${YELLOW}$WIFI_SSID${NC}"
if [ -n "$PI_HOSTNAME" ]; then
echo -e " Hostname: ${YELLOW}$PI_HOSTNAME${NC}"
fi
echo ""
fi
elif [ -f "$CONFIG_FILE" ]; then
echo -e "${YELLOW}Note: config.json found but jq not installed (apt install jq)${NC}"
echo -e "${YELLOW} WiFi will need to be configured manually after boot${NC}"
echo ""
fi
# 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
# Ask about wiping
echo
read -p "Wipe partition table first? (recommended if having issues) [y/N] " wipe_confirm
if [[ "$wipe_confirm" =~ ^[Yy]$ ]]; then
echo "Wiping partition table..."
sudo wipefs -a "$SELECTED"
sudo dd if=/dev/zero of="$SELECTED" bs=1M count=10 status=none
sync
echo " Wiped clean"
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
# Inject WiFi config if config.json was loaded
if [ "$HAS_CONFIG" = true ]; then
echo ""
echo -e "${GREEN}Configuring WiFi from config.json...${NC}"
# Wait for partitions to appear
sleep 2
partprobe "$SELECTED" 2>/dev/null || true
sleep 1
# Determine boot partition
if [[ "$SELECTED" == *"nvme"* ]] || [[ "$SELECTED" == *"mmcblk"* ]]; then
BOOT_PART="${SELECTED}p1"
else
BOOT_PART="${SELECTED}1"
fi
if [ -b "$BOOT_PART" ]; then
MOUNT_DIR=$(mktemp -d)
if mount "$BOOT_PART" "$MOUNT_DIR" 2>/dev/null; then
# Create firstrun.sh for WiFi setup
cat > "$MOUNT_DIR/firstrun.sh" << 'EOFSCRIPT'
#!/bin/bash
set +e
# Set hostname if provided
if [ -n "PLACEHOLDER_HOSTNAME" ] && [ "PLACEHOLDER_HOSTNAME" != "" ]; then
CURRENT_HOSTNAME=$(cat /etc/hostname | tr -d " \t\n\r")
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
/usr/lib/raspberrypi-sys-mods/imager_custom set_hostname PLACEHOLDER_HOSTNAME
else
echo PLACEHOLDER_HOSTNAME >/etc/hostname
sed -i "s/127.0.1.1.*$CURRENT_HOSTNAME/127.0.1.1\tPLACEHOLDER_HOSTNAME/g" /etc/hosts
fi
fi
# Configure WiFi
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
/usr/lib/raspberrypi-sys-mods/imager_custom set_wlan 'PLACEHOLDER_SSID' 'PLACEHOLDER_WIFIPASS' 'PLACEHOLDER_COUNTRY'
else
# NetworkManager method (Trixie)
cat >/etc/NetworkManager/system-connections/preconfigured.nmconnection <<'NMEOF'
[connection]
id=preconfigured
type=wifi
autoconnect=true
[wifi]
mode=infrastructure
ssid=PLACEHOLDER_SSID
[wifi-security]
auth-alg=open
key-mgmt=wpa-psk
psk=PLACEHOLDER_WIFIPASS
[ipv4]
method=auto
[ipv6]
method=auto
NMEOF
chmod 600 /etc/NetworkManager/system-connections/preconfigured.nmconnection
rfkill unblock wifi
fi
# Cleanup
rm -f /boot/firstrun.sh
rm -f /boot/firmware/firstrun.sh
sed -i 's| systemd.run.*||g' /boot/cmdline.txt 2>/dev/null
sed -i 's| systemd.run.*||g' /boot/firmware/cmdline.txt 2>/dev/null
exit 0
EOFSCRIPT
# Replace placeholders
sed -i "s/PLACEHOLDER_SSID/$WIFI_SSID/g" "$MOUNT_DIR/firstrun.sh"
sed -i "s/PLACEHOLDER_WIFIPASS/$WIFI_PASS/g" "$MOUNT_DIR/firstrun.sh"
sed -i "s/PLACEHOLDER_COUNTRY/$WIFI_COUNTRY/g" "$MOUNT_DIR/firstrun.sh"
if [ -n "$PI_HOSTNAME" ]; then
sed -i "s/PLACEHOLDER_HOSTNAME/$PI_HOSTNAME/g" "$MOUNT_DIR/firstrun.sh"
else
sed -i "s/PLACEHOLDER_HOSTNAME//g" "$MOUNT_DIR/firstrun.sh"
fi
chmod +x "$MOUNT_DIR/firstrun.sh"
# Update cmdline.txt to run firstrun.sh
CMDLINE="$MOUNT_DIR/cmdline.txt"
if [ -f "$CMDLINE" ]; then
CURRENT=$(cat "$CMDLINE" | tr -d '\n' | sed 's| systemd.run.*||g')
echo "$CURRENT systemd.run=/boot/firmware/firstrun.sh systemd.run_success_action=reboot systemd.unit=kernel-command-line.target" > "$CMDLINE"
fi
umount "$MOUNT_DIR"
echo -e " ${GREEN}${NC} WiFi configured for: $WIFI_SSID"
else
echo -e " ${YELLOW}${NC} Could not mount boot partition"
fi
rmdir "$MOUNT_DIR" 2>/dev/null || true
else
echo -e " ${YELLOW}${NC} Boot partition not found"
fi
fi
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 ""
if [ "$HAS_CONFIG" = true ]; then
echo -e "${GREEN}WiFi pre-configured${NC} - Pi will connect to $WIFI_SSID on boot"
echo -e "SSH: ${YELLOW}ssh admin@${PI_HOSTNAME:-stegasoo}.local${NC} (password: stegasoo)"
else
echo -e "${YELLOW}Tip:${NC} On first boot, the setup wizard will help configure WiFi."
echo -e "${YELLOW}Tip:${NC} Or place config.json in rpi/ for headless setup next time."
fi
echo ""

332
rpi/flash-stock-img.sh Executable file
View File

@@ -0,0 +1,332 @@
#!/bin/bash
# Flash Raspberry Pi image with headless config (Trixie/Bookworm compatible)
# Usage: ./flash-stock-img.sh <image.img.xz> <device>
# Reads settings from config.json in same directory
#
# Uses the same firstrun.sh approach as rpi-imager for compatibility
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/config.json"
# ============================================================================
# Load config
# ============================================================================
if [ ! -f "$CONFIG_FILE" ]; then
echo "Error: config.json not found at $CONFIG_FILE"
exit 1
fi
PI_USER=$(jq -r '.username' "$CONFIG_FILE")
PI_PASS=$(jq -r '.password' "$CONFIG_FILE")
WIFI_SSID=$(jq -r '.wifiSSID' "$CONFIG_FILE")
WIFI_PASS=$(jq -r '.wifiPassword' "$CONFIG_FILE")
WIFI_COUNTRY=$(jq -r '.wifiCountry // "US"' "$CONFIG_FILE")
PI_HOSTNAME=$(jq -r '.hostname' "$CONFIG_FILE")
PI_TIMEZONE=$(jq -r '.timezone // "America/New_York"' "$CONFIG_FILE")
PI_KEYMAP=$(jq -r '.keyboardLayout // "us"' "$CONFIG_FILE")
echo "Loaded config from $CONFIG_FILE"
echo " Hostname: $PI_HOSTNAME"
echo " User: $PI_USER"
echo " WiFi: $WIFI_SSID"
echo " Timezone: $PI_TIMEZONE"
echo
# ============================================================================
# Validate args
# ============================================================================
if [ $# -ne 2 ]; then
echo "Usage: $0 <image.img.xz> <device>"
echo "Example: $0 2025-12-04-raspios-trixie-arm64-lite.img.xz /dev/sdb"
exit 1
fi
IMAGE="$1"
DEVICE="$2"
if [ ! -f "$IMAGE" ]; then
echo "Error: Image file not found: $IMAGE"
exit 1
fi
if [ ! -b "$DEVICE" ]; then
echo "Error: Device not found: $DEVICE"
exit 1
fi
# Safety check
echo "WARNING: This will ERASE all data on $DEVICE"
echo "Device info:"
lsblk "$DEVICE"
echo
read -p "Type 'yes' to continue: " confirm
if [ "$confirm" != "yes" ]; then
echo "Aborted."
exit 1
fi
# Ask about wiping
echo
read -p "Wipe partition table first? (recommended if having issues) [y/N] " wipe_confirm
if [[ "$wipe_confirm" =~ ^[Yy]$ ]]; then
echo "Wiping partition table..."
sudo wipefs -a "$DEVICE"
sudo dd if=/dev/zero of="$DEVICE" bs=1M count=10 status=none
sync
echo " Wiped clean"
fi
# ============================================================================
# Flash image
# ============================================================================
echo "Flashing $IMAGE to $DEVICE..."
if [[ "$IMAGE" == *.xz ]]; then
xzcat "$IMAGE" | sudo dd of="$DEVICE" bs=4M status=progress conv=fsync
elif [[ "$IMAGE" == *.zst ]]; then
zstdcat "$IMAGE" | sudo dd of="$DEVICE" bs=4M status=progress conv=fsync
else
sudo dd if="$IMAGE" of="$DEVICE" bs=4M status=progress conv=fsync
fi
echo "Syncing..."
sync
# Wait for partitions
sleep 2
sudo partprobe "$DEVICE" 2>/dev/null || true
sleep 1
# ============================================================================
# Find partitions
# ============================================================================
if [ -b "${DEVICE}1" ]; then
BOOT_PART="${DEVICE}1"
ROOT_PART="${DEVICE}2"
elif [ -b "${DEVICE}p1" ]; then
BOOT_PART="${DEVICE}p1"
ROOT_PART="${DEVICE}p2"
else
echo "Error: Could not find boot partition"
exit 1
fi
# ============================================================================
# Resize rootfs to 16GB (faster imaging)
# ============================================================================
echo
read -p "Resize rootfs to 16GB for faster imaging? [Y/n] " resize_confirm
if [[ ! "$resize_confirm" =~ ^[Nn]$ ]]; then
echo "Resizing rootfs partition to 16GB..."
# Get current partition size in bytes
CURRENT_SIZE=$(sudo blockdev --getsize64 "$ROOT_PART")
TARGET_BYTES=$((16 * 1024 * 1024 * 1024)) # 16GB in bytes
# Get boot partition end in sectors
BOOT_END=$(sudo parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
# Calculate 16GB in sectors (512 byte sectors)
ROOT_SIZE_SECTORS=33554432
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
if [ "$CURRENT_SIZE" -lt "$TARGET_BYTES" ]; then
# EXPANDING: partition first, then filesystem
echo "Current partition is smaller than 16GB - expanding..."
# Delete and recreate partition 2 with 16GB size
echo "Expanding partition to 16GB..."
sudo parted -s "$DEVICE" rm 2
sudo parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
# Refresh partition table
sudo partprobe "$DEVICE"
sleep 2
# Expand filesystem to fill the new partition
echo "Expanding filesystem to fill partition..."
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
sudo resize2fs "$ROOT_PART"
else
# SHRINKING: filesystem first, then partition
echo "Current partition is larger than 16GB - shrinking..."
# Check and shrink filesystem first
echo "Checking filesystem..."
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
# Shrink filesystem to 15.5GB (leave room for partition overhead)
echo "Shrinking filesystem to 15500M..."
sudo resize2fs "$ROOT_PART" 15500M
# Delete and recreate partition 2 with 16GB size
echo "Shrinking partition to 16GB..."
sudo parted -s "$DEVICE" rm 2
sudo parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
# Refresh partition table
sudo partprobe "$DEVICE"
sleep 2
# Expand filesystem to fill the partition exactly
echo "Expanding filesystem to fill partition..."
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
sudo resize2fs "$ROOT_PART"
fi
# Verify and show result
echo "Verifying partition size..."
sudo parted -s "$DEVICE" unit GB print | grep "^ 2"
# Disable Pi OS auto-expand on first boot
echo "Disabling auto-expand..."
TEMP_ROOT=$(mktemp -d)
sudo mount "$ROOT_PART" "$TEMP_ROOT"
# Remove resize2fs_once service if it exists
sudo rm -f "$TEMP_ROOT/etc/init.d/resize2fs_once"
sudo rm -f "$TEMP_ROOT/etc/rc3.d/S01resize2fs_once"
# Disable the systemd resize service
sudo rm -f "$TEMP_ROOT/etc/systemd/system/multi-user.target.wants/rpi-resizerootfs.service"
sudo umount "$TEMP_ROOT"
rmdir "$TEMP_ROOT"
echo " Rootfs set to 16GB (auto-expand disabled)"
fi
MOUNT_DIR=$(mktemp -d)
# ============================================================================
# Configure boot partition with firstrun.sh (rpi-imager method)
# ============================================================================
echo "Mounting boot partition..."
sudo mount "$BOOT_PART" "$MOUNT_DIR"
# Enable SSH
echo "Enabling SSH..."
sudo touch "$MOUNT_DIR/ssh"
# Generate password hash
PASS_HASH=$(echo "$PI_PASS" | openssl passwd -6 -stdin)
# Create firstrun.sh - this is exactly what rpi-imager generates
echo "Creating firstrun.sh..."
sudo tee "$MOUNT_DIR/firstrun.sh" > /dev/null << 'EOFSCRIPT'
#!/bin/bash
set +e
CURRENT_HOSTNAME=$(cat /etc/hostname | tr -d " \t\n\r")
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
/usr/lib/raspberrypi-sys-mods/imager_custom set_hostname PLACEHOLDER_HOSTNAME
else
echo PLACEHOLDER_HOSTNAME >/etc/hostname
sed -i "s/127.0.1.1.*$CURRENT_HOSTNAME/127.0.1.1\tPLACEHOLDER_HOSTNAME/g" /etc/hosts
fi
FIRSTUSER=$(getent passwd 1000 | cut -d: -f1)
FIRSTUSERHOME=$(getent passwd 1000 | cut -d: -f6)
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
/usr/lib/raspberrypi-sys-mods/imager_custom enable_ssh
else
systemctl enable ssh
fi
if [ -f /usr/lib/userconf-pi/userconf ]; then
/usr/lib/userconf-pi/userconf 'PLACEHOLDER_USER' 'PLACEHOLDER_HASH'
else
echo "$FIRSTUSER:"'PLACEHOLDER_HASH' | chpasswd -e
if [ "$FIRSTUSER" != "PLACEHOLDER_USER" ]; then
usermod -l "PLACEHOLDER_USER" "$FIRSTUSER"
usermod -m -d "/home/PLACEHOLDER_USER" "PLACEHOLDER_USER"
groupmod -n "PLACEHOLDER_USER" "$FIRSTUSER"
if grep -q "^autologin-user=" /etc/lightdm/lightdm.conf 2>/dev/null; then
sed -i "s/^autologin-user=.*/autologin-user=PLACEHOLDER_USER/" /etc/lightdm/lightdm.conf
fi
if [ -f /etc/systemd/system/getty@tty1.service.d/autologin.conf ]; then
sed -i "s/$FIRSTUSER/PLACEHOLDER_USER/" /etc/systemd/system/getty@tty1.service.d/autologin.conf
fi
fi
fi
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
/usr/lib/raspberrypi-sys-mods/imager_custom set_keymap 'PLACEHOLDER_KEYMAP'
/usr/lib/raspberrypi-sys-mods/imager_custom set_timezone 'PLACEHOLDER_TIMEZONE'
fi
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
/usr/lib/raspberrypi-sys-mods/imager_custom set_wlan 'PLACEHOLDER_SSID' 'PLACEHOLDER_WIFIPASS' 'PLACEHOLDER_COUNTRY'
else
cat >/etc/wpa_supplicant/wpa_supplicant.conf <<'WPAEOF'
country=PLACEHOLDER_COUNTRY
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
ap_scan=1
update_config=1
network={
ssid="PLACEHOLDER_SSID"
psk="PLACEHOLDER_WIFIPASS"
}
WPAEOF
chmod 600 /etc/wpa_supplicant/wpa_supplicant.conf
rfkill unblock wifi
for filename in /var/lib/systemd/rfkill/*:wlan ; do
echo 0 > "$filename"
done
fi
rm -f /boot/firstrun.sh
rm -f /boot/firmware/firstrun.sh
sed -i 's| systemd.run.*||g' /boot/cmdline.txt 2>/dev/null
sed -i 's| systemd.run.*||g' /boot/firmware/cmdline.txt 2>/dev/null
exit 0
EOFSCRIPT
# Replace placeholders with actual values
sudo sed -i "s/PLACEHOLDER_HOSTNAME/$PI_HOSTNAME/g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s/PLACEHOLDER_USER/$PI_USER/g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s|PLACEHOLDER_HASH|$PASS_HASH|g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s/PLACEHOLDER_KEYMAP/$PI_KEYMAP/g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s|PLACEHOLDER_TIMEZONE|$PI_TIMEZONE|g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s/PLACEHOLDER_SSID/$WIFI_SSID/g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s/PLACEHOLDER_WIFIPASS/$WIFI_PASS/g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s/PLACEHOLDER_COUNTRY/$WIFI_COUNTRY/g" "$MOUNT_DIR/firstrun.sh"
sudo chmod +x "$MOUNT_DIR/firstrun.sh"
# Update cmdline.txt to run firstrun.sh on boot
echo "Updating cmdline.txt..."
CMDLINE="$MOUNT_DIR/cmdline.txt"
if [ -f "$CMDLINE" ]; then
# Read current cmdline, strip existing systemd.run and init= (auto-expand)
CURRENT=$(cat "$CMDLINE" | tr -d '\n' | sed 's| systemd.run.*||g' | sed 's| init=[^ ]*||g')
echo "$CURRENT systemd.run=/boot/firmware/firstrun.sh systemd.run_success_action=reboot systemd.unit=kernel-command-line.target" | sudo tee "$CMDLINE" > /dev/null
echo " cmdline.txt updated"
fi
sudo umount "$MOUNT_DIR"
rmdir "$MOUNT_DIR"
echo
echo "Done! SD card is ready."
echo " Hostname: $PI_HOSTNAME"
echo " User: $PI_USER"
echo " SSH: enabled"
echo " WiFi: $WIFI_SSID"
echo
echo "Insert into Pi and boot. Access via:"
echo " mDNS: http://$PI_HOSTNAME.local"
echo " Find IP: ping $PI_HOSTNAME.local"
echo
echo "Once booted, SSH with: ssh $PI_USER@$PI_HOSTNAME.local"
# If we resized, remind about pull-image.sh
if [[ ! "$resize_confirm" =~ ^[Nn]$ ]]; then
echo
echo "=== After setup, use pull-image.sh to create distributable image ==="
echo " ./pull-image.sh $DEVICE stegasoo-rpi-VERSION.img.zst"
echo
echo "This will only pull the 16GB partition, not the entire SD card."
fi

29
rpi/host-requirements.txt Normal file
View File

@@ -0,0 +1,29 @@
# Host Machine Dependencies for Stegasoo Pi Scripts
# =================================================
#
# Quick install (Debian/Ubuntu):
# sudo apt install parted e2fsprogs zstd zip bc pv jq unzip sshpass
#
# Or install with this file:
# sudo apt install $(grep -v '^#' rpi/host-requirements.txt | grep -v '^$' | xargs)
# pull-image.sh - Create distributable images
parted # Partition table reading/writing
e2fsprogs # e2fsck, resize2fs for ext4
zstd # Compression (zstd -T0 -3)
zip # Optional .zst.zip wrapper for GitHub
bc # Floating point math for size display
pv # Progress bar (optional, falls back to dd status)
# flash-image.sh - Flash images to SD cards
unzip # Extract .zst.zip wrappers
zstd # Decompress .zst images
pv # Progress bar (optional)
jq # Parse config.json for headless WiFi (optional)
# kickoff-pi-test.sh - Automated flash+test
sshpass # Non-interactive SSH with password
avahi-utils # avahi-resolve for .local hostname lookup
# Optional tools
rpi-imager # Faster flashing (flash-image.sh falls back to dd)

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

193
rpi/kickoff-pi-test.sh Executable file
View File

@@ -0,0 +1,193 @@
#!/bin/bash
#
# Stegasoo Pi Test Kickoff Script
# Automates: flash -> wait for boot -> setup -> test
#
# Usage: ./kickoff-pi-test.sh <image.img.zst> </dev/sdX>
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Pi connection settings
PI_HOST="stegasoo.local"
PI_USER="admin"
PI_PASS="stegasoo"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Wait for Pi to be reachable
wait_for_pi() {
local attempt=1
ssh-keygen -R "$PI_HOST" 2>/dev/null
echo "Waiting for $PI_USER@$PI_HOST..."
while ! sshpass -p "$PI_PASS" ssh -o ConnectTimeout=2 -o StrictHostKeyChecking=no -o BatchMode=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "exit" 2>/dev/null; do
printf "\rAttempt %d..." "$attempt"
((attempt++))
sleep 2
done
printf "\r${GREEN}✓ Ready after %d attempts${NC}\n" "$attempt"
printf '\a' # Terminal bell
}
# Run command on Pi (non-interactive)
run_on_pi() {
sshpass -p "$PI_PASS" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
}
# Run command on Pi (interactive/PTY)
run_on_pi_interactive() {
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
}
# Copy file to Pi
scp_to_pi() {
local src="$1"
local dst="$2"
sshpass -p "$PI_PASS" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$src" "$PI_USER@$PI_HOST:$dst"
}
# Interactive SSH session
ssh_pi() {
ssh-keygen -R "$PI_HOST" 2>/dev/null
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
}
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <image.img.zst> </dev/sdX>"
echo ""
echo "Example: $0 stegasoo-v4.1.img.zst /dev/sda"
exit 1
fi
IMAGE="$1"
DEVICE="$2"
if [[ ! -f "$IMAGE" ]]; then
echo -e "${RED}Error: Image file not found: $IMAGE${NC}"
exit 1
fi
if [[ ! -b "$DEVICE" ]]; then
echo -e "${RED}Error: Device not found: $DEVICE${NC}"
exit 1
fi
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ Stegasoo Pi Test Kickoff ║${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "Image: ${YELLOW}$IMAGE${NC}"
echo -e "Device: ${YELLOW}$DEVICE${NC}"
echo ""
# -----------------------------------------------------------------------------
# Step 1: Flash the image
# -----------------------------------------------------------------------------
echo -e "${GREEN}[1/8]${NC} Flashing image..."
echo ""
# Auto-answer: "yes" for confirm, "y" for wipe, "y" for resize
printf 'yes\ny\ny\n' | "$SCRIPT_DIR/flash-stock-img.sh" "$IMAGE" "$DEVICE"
echo ""
echo -e "${GREEN}[2/8]${NC} Flash complete! Waiting for SD card insertion..."
echo ""
# -----------------------------------------------------------------------------
# Step 2: Wait for user to insert SD card
# -----------------------------------------------------------------------------
echo -e "${YELLOW}════════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Insert SD card into Pi and power on${NC}"
echo -e "${YELLOW}════════════════════════════════════════════════════════════════${NC}"
echo ""
read -p "Press ENTER when Pi is booting..."
echo ""
# -----------------------------------------------------------------------------
# Step 3: Wait for Pi to be ready
# -----------------------------------------------------------------------------
echo -e "${GREEN}[3/8]${NC} Waiting for Pi to boot..."
echo ""
wait_for_pi
# -----------------------------------------------------------------------------
# Step 4: Pre-setup (install dependencies)
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[4/8]${NC} Installing dependencies on Pi..."
echo ""
run_on_pi "sudo chown admin:admin /opt && sudo apt-get update && sudo apt-get install -y git zstd jq"
# -----------------------------------------------------------------------------
# Step 5: Clone repo
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[5/8]${NC} Cloning Stegasoo repo..."
echo ""
run_on_pi "cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo"
# -----------------------------------------------------------------------------
# Step 6: Copy pre-built tarball
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[6/8]${NC} Copying pre-built tarball to Pi..."
echo ""
TARBALL="$SCRIPT_DIR/stegasoo-rpi-runtime-env-arm64.tar.zst"
if [[ -f "$TARBALL" ]]; then
scp_to_pi "$TARBALL" "/opt/stegasoo/rpi/"
echo -e " ${GREEN}${NC} Tarball copied"
else
echo -e " ${YELLOW}${NC} Tarball not found at $TARBALL"
echo -e " ${YELLOW}${NC} Setup will build from source (takes longer)"
fi
# -----------------------------------------------------------------------------
# Step 7: Run setup
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[7/8]${NC} Running setup.sh on Pi..."
echo ""
run_on_pi_interactive "cd /opt/stegasoo && ./rpi/setup.sh"
# -----------------------------------------------------------------------------
# Step 8: Test it works
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[8/8]${NC} Testing Stegasoo..."
echo ""
run_on_pi "sudo systemctl start stegasoo && sleep 2 && curl -sk https://localhost:5000 | head -5"
echo ""
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} Build complete! Pi is ready for testing.${NC}"
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "Access: ${YELLOW}https://stegasoo.local:5000${NC}"
echo ""
read -p "Press ENTER to SSH into Pi for manual testing..."
ssh_pi

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

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