198 Commits

Author SHA1 Message Date
Aaron D. Lee
1acb5a3dcc Update release notes for v4.1.7
Some checks failed
Release / test (push) Failing after 30s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:38:14 -05:00
Aaron D. Lee
14a73c63ac Add reedsolo to Docker, update docs for docker/ paths
- Add reedsolo>=1.7.0 to Dockerfile and Dockerfile.base for DCT
  error correction (fixes DCT decode failures in container)
- Update all documentation to use docker/docker-compose.yml paths

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:20:52 -05:00
Aaron D. Lee
3d53282738 Move pishrink.sh to rpi/tools/
Update .gitignore and .dockerignore paths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 18:08:19 -05:00
Aaron D. Lee
e831ae4884 Move Docker files to docker/ directory
- Move Dockerfile, Dockerfile.base, docker-compose.yml to docker/
- Update docker-compose.yml with correct context paths
- Update scripts/build.sh to use new paths
- Update DOCKER_QUICKSTART.md with new commands
- Add scripts/build.sh to tracked files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 18:01:15 -05:00
Aaron D. Lee
4751d05e9f Add Pi runtime tarball build script
Run on Pi after from-source build to create:
stegasoo-rpi-runtime-env-arm64.tar.zst (~50-60MB)

Contains pyenv + Python 3.12 + venv with all deps.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 17:59:08 -05:00
Aaron D. Lee
d15bcb8df4 Change 'Undetectable' to 'Covertly Embedded'
Less definitive claim for the encode page footer.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 17:54:22 -05:00
Aaron D. Lee
6ec7de5604 Add Docker quickstart guide
Concise guide covering:
- Build commands
- Basic and production run examples
- Environment variables table
- Custom SSL certs (own, mkcert, Let's Encrypt)
- Volumes and troubleshooting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 17:48:10 -05:00
Aaron D. Lee
1cdb2aca91 Bump mobile PIN digit font-size to 1.15rem
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:27:03 -05:00
Aaron D. Lee
46de371c42 Shrink PIN digit boxes on mobile for 9-digit support
Reduce box width, height, font-size, gap, and container padding.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:24:44 -05:00
Aaron D. Lee
11c0d45548 Make decode mode selector buttons wider
Remove btn-sm for regular-sized buttons on decode page.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:21:51 -05:00
Aaron D. Lee
7bb1029c0f Add text-nowrap and icons to decode mode selector
Match encode page styling with icons on all buttons.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:19:27 -05:00
Aaron D. Lee
e3f7f36e5e Prevent DCT/LSB button text from wrapping
Add text-nowrap to keep icon and text together.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:17:47 -05:00
Aaron D. Lee
f200737088 Improve DCT/LSB selector with icons and divider
- Add grid icon to LSB button to match DCT soundwave icon
- Add divider between mode and output options (hidden on mobile)
- Wraps cleanly on small screens

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:15:56 -05:00
Aaron D. Lee
6def318ba7 Left-align collapsed navbar menu on mobile
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:09:47 -05:00
Aaron D. Lee
e203af6a73 Add redacted dots to channel key preview in header
Shows ABCD-••••-3456 instead of ABCD...3456 to indicate
the key is longer and has been redacted.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:07:38 -05:00
Aaron D. Lee
6ba135098b Use consistent button group style for mode selectors
Convert DCT/LSB (encode) and Auto/LSB/DCT (decode) to use
Bootstrap btn-group style matching Color/Gray and JPEG/PNG.
Better mobile layout - all options on one line.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:03:08 -05:00
Aaron D. Lee
903739c055 Remove divider between color/format options for mobile
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:01:18 -05:00
Aaron D. Lee
30fbb5016e Shorten channel fingerprint in navbar for mobile
Display ABCD...3456 instead of full masked fingerprint.
Full fingerprint still visible in tooltip on hover.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:00:07 -05:00
Aaron D. Lee
041148e8fe Bump version to 4.1.7
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:54:12 -05:00
Aaron D. Lee
90bedce379 Add channel key loading option to first-boot wizard
Step 3 now offers three choices:
- Skip (public mode)
- Generate new key
- Enter existing key (for joining team deployments)

Validates entered keys using Python channel module before accepting.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:48:10 -05:00
Aaron D. Lee
021265f3cf Add screenshot capture script for documentation
- Capture main UI pages (Encode, Decode, Generate, Tools, About)
- Capture auth pages (Login, Setup, Account, Recover)
- Auto-convert PNG to WebP for smaller file sizes
- Update .gitignore to track this script

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:02:34 -05:00
Aaron D. Lee
ff42398509 Simplify wipe - remove dd zero, just use wipefs 2026-01-08 13:45:20 -05:00
Aaron D. Lee
a30ec33b98 Fix SD card flashing progress display
- Remove pv (showed read progress, not write progress)
- Use dd status=progress for actual write progress
- Reduce block size to 1M (better for slow SD cards)
- Remove conv=fsync (sync at end instead, faster)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:40:31 -05:00
Aaron D. Lee
252efbec7e Add filesystem validation after flashing
Run fsck.vfat on boot partition and e2fsck on root partition
after flashing to catch and fix any corruption.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:00:37 -05:00
Aaron D. Lee
6e906d5981 Run growpart/resize2fs directly without gum spin 2026-01-08 12:40:46 -05:00
Aaron D. Lee
df6125d098 Use growpart before resize2fs to expand full disk
resize2fs only fills the partition. Need growpart first to
expand the partition to fill the disk, then resize2fs to
expand the filesystem.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:36:06 -05:00
Aaron D. Lee
3d4a340305 Add prompt for filesystem expansion in wizard
Show current size and ask user before expanding, matching
the style of other wizard prompts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:30:39 -05:00
Aaron D. Lee
0decb39b17 Move filesystem expansion to first-boot wizard
Instead of a hidden systemd service, expand the filesystem
visibly during the first-boot wizard so users can see it
happening.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:00:47 -05:00
Aaron D. Lee
4291dfad38 Remove rpi-imager, use dd directly
rpi-imager was doing something that prevented the auto-expand
service from working. Simplify to just dd with optional pv
for progress.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:33:03 -05:00
Aaron D. Lee
ddee3583e8 Defer wipe until after final confirmation
Move the partition wipe to after user types 'yes' so they can
still abort without having already wiped the device.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:21:41 -05:00
Aaron D. Lee
3e2307cbcf Fix auto-expand service creation (add sudo)
The script runs as non-root but needs sudo to write to the
mounted rootfs partition.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:15:21 -05:00
Aaron D. Lee
cc745fbdfa Add auto-expand service in pull-image.sh
Create a systemd oneshot service that expands the rootfs on first boot
after flashing. The service self-destructs after running.

This ensures release images fill the SD card while keeping the
download size small (16GB shrunk image).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:02:01 -05:00
Aaron D. Lee
3027706d49 Keep auto-expand enabled in release images
The shrinking is only for faster image downloads. After flashing,
the image should auto-expand to fill the SD card.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 10:57:31 -05:00
Aaron D. Lee
39fbd617e6 Remove unused compression options, add man page installation
- Remove --compress/--algorithm CLI options (not wired to encode flow)
- Add man page installation to rpi/setup.sh
- Document man page installation in README.md and CLI.md
- Update man page to remove compression options

Compression will be properly implemented in v4.1.8.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 00:28:15 -05:00
Aaron D. Lee
de4cb0b3be Add stegasoo(1) man page
Comprehensive documentation covering all commands, options,
and usage examples for the CLI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 00:13:02 -05:00
Aaron D. Lee
add3951003 Remove color from channel fingerprint display
The color codes weren't displaying properly in all terminal
environments. Keep it simple with plain text.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 00:09:52 -05:00
Aaron D. Lee
3858e234da Fix channel fingerprint color using Click's native style API
Use click.style() with bright_yellow and color=True to ensure
the channel fingerprint displays in color across different
terminal environments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 00:07:24 -05:00
Aaron D. Lee
03e8e3a840 Try bold yellow for channel fingerprint color
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 00:04:36 -05:00
Aaron D. Lee
55e78d0503 Change channel fingerprint color to orange
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 00:03:39 -05:00
Aaron D. Lee
b13a9fcd3f Add cyan color to channel fingerprint in CLI info
Private channel fingerprints now display in cyan to stand out.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 00:02:23 -05:00
Aaron D. Lee
96b49c68ec Fix get_channel_status() to decrypt stored keys
The function was trying to format encrypted keys directly,
causing ValueError when reading ENC: prefixed stored keys.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 00:00:30 -05:00
Aaron D. Lee
be8744179d Encrypt stored channel keys with machine identity
Channel keys saved to config files are now encrypted using the
machine's identity (/etc/machine-id), so:
- Not stored in plaintext
- Tied to specific machine (can't copy file to another device)
- Legacy plaintext keys still work (auto-detected)

Changes:
- Added _encrypt_for_storage() and _decrypt_from_storage()
- set_channel_key() now encrypts before writing
- get_channel_key() decrypts when reading (handles legacy plaintext)
- Pi setup saves encrypted key to ~/.stegasoo/channel.key
- CLI `stegasoo info` now shows channel status correctly

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:54:23 -05:00
Aaron D. Lee
f971b75d7e Add mkcert support for browser-trusted HTTPS certificates
No more browser warnings! mkcert creates locally-trusted certs.

Pi Setup:
- Auto-install mkcert during setup
- Generate trusted certs when HTTPS enabled
- Copy CA to /static/ca/rootCA.pem for easy device setup
- New devices can download CA via HTTP and install it

Docker:
- docker-entrypoint.sh checks for mkcert, falls back to openssl
- Shows instructions for CA distribution to other devices

Scripts:
- Added setup-trusted-certs.sh helper for local dev
- Installs mkcert, generates certs, shows device setup instructions

To trust on new devices:
1. Download: http://stegasoo.local/static/ca/rootCA.pem
2. Install as trusted CA in browser/OS

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:49:38 -05:00
Aaron D. Lee
455c6dfd01 Docker HTTPS by default, smoke test improvements
Docker:
- HTTPS enabled by default (generates self-signed cert)
- Added docker-entrypoint.sh for SSL cert generation
- Gunicorn now starts with --certfile/--keyfile when HTTPS enabled
- Install curl/openssl in web container for healthcheck and certs
- Updated docs to reflect HTTPS default

Smoke Test:
- Moved from rpi/ to scripts/ (works for Pi, Docker, and dev)
- Updated header and examples
- Added to .gitignore exceptions

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:45:44 -05:00
Aaron D. Lee
a00a154a1a Add Pi smoke test script
Comprehensive test suite for Pi deployments:
- Connectivity check
- Auto-setup if first boot (admin/stegasoo)
- Login and session handling
- All page accessibility
- LSB encode/decode round trip
- DCT encode/decode round trip
- Tools API (capacity, EXIF)

Usage: ./rpi/smoke-test.sh [host] [port] [user] [pass]

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:25:45 -05:00
Aaron D. Lee
8b3b331843 Fix: run update-ca-certificates after install
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:10:59 -05:00
Aaron D. Lee
10c874374f Fix QR key loader: remove conflicting CSS, proper centering
- Remove duplicate qr-crop-container styles from encode/decode templates
- Use only qr-scan-container from style.css (flex centering + object-fit)
- Fix rsaQrSection to use align-items: center for horizontal centering
- Darken channel key fingerprint in header (#f0c674 → #c9a860)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:05:07 -05:00
Aaron D. Lee
0c1e87c7c0 Replace kickoff-pi-test.sh with remote-build-pi.sh
Simplified Pi build script:
- No imaging step (assumes SD card already flashed)
- Waits for Pi to be reachable via SSH
- Installs deps (including ca-certificates for git SSL)
- Clones from main branch (has updated tarball name)
- Copies pre-built tarball if available
- Runs setup and tests

Usage: ./remote-build-pi.sh [host] [user] [pass]

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:36:35 -05:00
Aaron D. Lee
d517a4dc8b Accordion chevrons: less orange, more muted gold
Reduced saturation (10→2), hue-rotate (15→5), brightness (1.5→1.2)
for a subtler gold that matches the toned-down color scheme.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:15:02 -05:00
Aaron D. Lee
6d59f3edfc UX polish: toned gold, cleaner labels, dropdown chevrons
- Toned down gold colors for better cross-monitor consistency
  - Header gold: #fee862 → #e5d058
  - Form labels: #ffe699 → #d9c580
- Removed text-shadow/outline from form labels (was smudgy)
- Removed background from nav floating labels
- More subtle nav hover background (halved opacity)
- Gold chevron on all dropdown selects for clarity
- Removed (environment) tag from channel key display
- Simplified channel key config text in about page
- Generate page: icon-only button for channel key

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:13:32 -05:00
Aaron D. Lee
17d0406be2 Homepage icons: clean white with gold hover
Removed gold outline/stroke from default state - too harsh on
some monitors. Now simple white icons that turn gold on hover
like the header nav, with lift effect and drop shadow.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:55:25 -05:00
Aaron D. Lee
ef73280015 Homepage polish: tagline styling, icon spacing, tooltips
- Tagline: smaller font, drop shadow, 3px offset, 3px left padding
- Icons: reduced gap from gap-5 to gap-4
- Channel badge tooltips: descriptive hover text for private/public

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:50:12 -05:00
Aaron D. Lee
6338d6aab4 v4.1.6 UX polish: homepage, navbar, tools consistency
Homepage:
- Minimal floating icons with gold hover effect
- Larger Stegasoo title (display-5)
- v4.1 badge repositioned to bottom-left of logo
- Tighter 8px gap between logo and title

Navbar:
- Container-fluid for fixed left positioning
- Reduced left padding, proper logo/badge spacing
- Channel fingerprint in gold, shield icon brighter

Tools page:
- Consistent font styling (0.62rem, weight 500, 1px spacing)
- Wider buttons (64px) with more gap
- Bolder text on hover (weight 600)

Typography consistency across nav, homepage, and tools.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:35:42 -05:00
Aaron D. Lee
b9d0fac535 Homepage icons: stronger shadow and dark outline for pop
Added dark outline via text-shadow and increased drop-shadow
opacity to make white icons stand out against dark background.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:51:14 -05:00
Aaron D. Lee
5c0a5bbba7 Homepage icons: gold on hover like tools page
Icons and labels turn gold on hover, matching the tools page
button styling. Combined with lift effect for visual pop.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:49:59 -05:00
Aaron D. Lee
ba1a77f00b Homepage icons: white with lift/shadow hover effect
Removed colored icons per user preference. Now using clean white
icons with subtle drop shadow that lifts and deepens on hover for
a "pop" visual cue without glow.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:49:05 -05:00
Aaron D. Lee
5e587df545 Show 'Public Channel' badge when no channel key configured 2026-01-07 20:44:20 -05:00
Aaron D. Lee
23456ac1e4 Vibrant colored icons: blue/green/gold, glow on hover 2026-01-07 20:43:13 -05:00
Aaron D. Lee
8be512ad7b Reduce left padding on channel badge 2026-01-07 20:42:00 -05:00
Aaron D. Lee
f129500202 Channel badge: thinner key text, brighter shield, more spacing 2026-01-07 20:41:35 -05:00
Aaron D. Lee
c37d743b3e Compact navbar: small logo only, gold fingerprint, v4.1 badge on homepage 2026-01-07 20:40:29 -05:00
Aaron D. Lee
5bdb625059 Move channel indicator to navbar header (shield + fingerprint) 2026-01-07 20:38:14 -05:00
Aaron D. Lee
231ba97fde Pull channel banner tight under navbar with negative margins 2026-01-07 20:37:05 -05:00
Aaron D. Lee
a70e88625f Pin channel banner full-width under navbar 2026-01-07 20:35:17 -05:00
Aaron D. Lee
b6770c46e5 Restore compact alert-style channel banner (320px max) 2026-01-07 20:34:26 -05:00
Aaron D. Lee
9f4318cc0f Move channel status above hero, same width 2026-01-07 20:34:01 -05:00
Aaron D. Lee
91dc665a77 Ultra-minimal homepage: floating icons, hover labels, compact hero 2026-01-07 20:33:39 -05:00
Aaron D. Lee
6066df391b Clean minimal homepage - hero, 3 action cards, quick info footer 2026-01-07 20:26:58 -05:00
Aaron D. Lee
be5c95b59d Bright gold chevrons matching indicator line 2026-01-07 20:16:20 -05:00
Aaron D. Lee
09b1abddc7 Brighter gold chevrons on accordions 2026-01-07 20:15:16 -05:00
Aaron D. Lee
0c9ea0e3f2 Accordion headers: gold left border, warmer tint, visible chevrons 2026-01-07 20:14:02 -05:00
Aaron D. Lee
aebfb20dfc Gold hover effect on tools page buttons (Capacity, EXIF, etc.) 2026-01-07 20:07:55 -05:00
Aaron D. Lee
b935c474af Drop shadow at 25% opacity 2026-01-07 20:04:33 -05:00
Aaron D. Lee
73b34ba8b5 Subtle 10% drop shadow on gold labels and toggles 2026-01-07 20:04:12 -05:00
Aaron D. Lee
89d8fee5da Gold toggle text with outline and drop shadow like labels 2026-01-07 20:03:09 -05:00
Aaron D. Lee
0e270dadb3 Just gold text on selected toggle, keep purple/blue styling 2026-01-07 20:02:31 -05:00
Aaron D. Lee
e2002b6026 Force gold styling with !important for toggle buttons 2026-01-07 20:01:54 -05:00
Aaron D. Lee
66ed11fb97 Gold styling for Text Message / File toggle buttons 2026-01-07 20:00:16 -05:00
Aaron D. Lee
9cbb4600f8 Remove label animation, keep static gold styling 2026-01-07 19:58:52 -05:00
Aaron D. Lee
c1c850c593 Normal font weight (400) for labels 2026-01-07 19:58:15 -05:00
Aaron D. Lee
e029f00d66 Bold labels again (font-weight: 600) 2026-01-07 19:57:51 -05:00
Aaron D. Lee
34e417fb55 Dark gold outline (0.3px, dark goldenrod) 2026-01-07 19:57:33 -05:00
Aaron D. Lee
e7954c63e4 Lighter font weight for labels (300) 2026-01-07 19:56:46 -05:00
Aaron D. Lee
446789a16f Add thin gold outline to labels (pulses with shimmer) 2026-01-07 19:56:19 -05:00
Aaron D. Lee
2538126573 Darker drop shadow on gold labels 2026-01-07 19:54:39 -05:00
Aaron D. Lee
a91d127ed7 Add subtle pulsing drop shadow to gold labels 2026-01-07 19:53:48 -05:00
Aaron D. Lee
a0781b1cf7 Faster gold shimmer cycle (1.5s) 2026-01-07 19:53:04 -05:00
Aaron D. Lee
5e32ecb35a More subtle gold shimmer 2026-01-07 19:52:33 -05:00
Aaron D. Lee
3e5de98f60 Gold shimmer: just color pulse, no outer glow 2026-01-07 19:52:16 -05:00
Aaron D. Lee
c8956b9e43 More pronounced gold shimmer: color + glow pulse 2026-01-07 19:45:13 -05:00
Aaron D. Lee
a8f15f87c6 Use file-earmark-image icon for Carrier (matches Stego) 2026-01-07 19:43:49 -05:00
Aaron D. Lee
8a64db9fcc Fix icons inside form labels (reset background-clip) 2026-01-07 19:43:20 -05:00
Aaron D. Lee
ab450955fe Gold shimmer: static gradient with brightness pulse on first half 2026-01-07 19:43:04 -05:00
Aaron D. Lee
afd502dbf3 Simpler shimmering gold labels - gradient text, no pseudo-elements 2026-01-07 19:41:23 -05:00
Aaron D. Lee
3f02e55ffd Fix squished icons in form labels (use inline-flex) 2026-01-07 19:40:03 -05:00
Aaron D. Lee
2ee824b02b Label shine: use mix-blend-mode overlay to mask to text 2026-01-07 19:39:24 -05:00
Aaron D. Lee
189620e4fb Label shine: narrower, subtler, shorter path with pauses 2026-01-07 19:38:05 -05:00
Aaron D. Lee
ecad88e859 Clip label shine to text bounds (no more eyebrows) 2026-01-07 19:36:50 -05:00
Aaron D. Lee
62bd31d0aa Form labels: shine sweeps across top edge only 2026-01-07 19:36:08 -05:00
Aaron D. Lee
241cdadd25 Form labels: gold with moving shine sweep effect 2026-01-07 19:34:48 -05:00
Aaron D. Lee
85309a2044 Form labels: pale gold with subtle shimmer animation 2026-01-07 19:33:48 -05:00
Aaron D. Lee
a81a20f8ee Style form labels with Stegasoo gold color 2026-01-07 19:31:58 -05:00
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
91 changed files with 11855 additions and 5476 deletions

52
.dockerignore Normal file
View File

@@ -0,0 +1,52 @@
# 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
# 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

28
.gitignore vendored
View File

@@ -64,16 +64,36 @@ htmlcov/
# Output test files.
test_data/*.png
# Dev scripts (local convenience scripts)
scripts/
# Dev scripts (local convenience scripts - except these)
scripts/*
!scripts/validate-release.sh
!scripts/smoke-test.sh
!scripts/setup-trusted-certs.sh
!scripts/screenshots.sh
!scripts/build.sh
# Web UI auth database and SSL certs
instance/
frontends/web/instance/
frontends/web/certs/
rpi/inject-wifi.sh
# Tests (private)
tests/
# RPi image build artifacts
*.img
*.img.xz
*.img.zst
pishrink.sh
*.img.zst.zip
rpi/tools/pishrink.sh
# 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

4
API.md
View File

@@ -88,7 +88,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
**Docker with channel key:**
```bash
STEGASOO_CHANNEL_KEY=XXXX-XXXX-... docker-compose up api
STEGASOO_CHANNEL_KEY=XXXX-XXXX-... docker-compose -f docker/docker-compose.yml up api
```
---
@@ -843,7 +843,7 @@ curl -s -X POST "$BASE_URL/decode/multipart" \
## Docker Configuration
### docker-compose.yml
### docker/docker-compose.yml
```yaml
x-common-env: &common-env

View File

@@ -5,6 +5,65 @@ 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
@@ -142,6 +201,9 @@ and this project adheres to [Semantic Versioning](https://semver.org).
- 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

14
CLI.md
View File

@@ -64,6 +64,18 @@ python -c "from stegasoo import has_dct_support; print('DCT:', 'available' if ha
stegasoo channel show
```
### Man Page
```bash
# Install man page
sudo mkdir -p /usr/local/share/man/man1
sudo cp docs/stegasoo.1 /usr/local/share/man/man1/
sudo mandb
# View
man stegasoo
```
---
## What's New in v4.1.0
@@ -798,7 +810,7 @@ stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456
### Docker Deployment
**docker-compose.yml:**
**docker/docker-compose.yml:**
```yaml
x-common-env: &common-env
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}

View File

@@ -6,7 +6,7 @@ Thank you for your interest in contributing to Stegasoo! This document provides
### Prerequisites
- Python 3.10 or higher
- Python 3.10 - 3.12
- Git
- Docker (optional, for container testing)

156
DOCKER.md Normal file
View File

@@ -0,0 +1,156 @@
# Docker Deployment
Stegasoo provides Docker images for both the Web UI and REST API.
## Quick Start
```bash
# Build and start all services
docker-compose -f docker/docker-compose.yml up -d
# Check status
docker-compose -f docker/docker-compose.yml ps
```
Access:
- **Web UI**: https://localhost:5000 (HTTPS with self-signed cert)
- **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: enabled, generates self-signed cert)
STEGASOO_HTTPS_ENABLED=true
STEGASOO_HOSTNAME=localhost
# To disable HTTPS:
# STEGASOO_HTTPS_ENABLED=false
```
### 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 docker/Dockerfile.base -t stegasoo-base:latest .
# Build services (fast - only copies app code)
docker-compose -f docker/docker-compose.yml build
```
### Full Build (No Base Image)
If you don't have the base image, the Dockerfile will build all dependencies (slower):
```bash
docker-compose -f docker/docker-compose.yml build
```
## Commands
```bash
# Start services
docker-compose -f docker/docker-compose.yml up -d
# View logs
docker-compose -f docker/docker-compose.yml logs -f
# Stop services
docker-compose -f docker/docker-compose.yml down
# Rebuild after code changes
docker-compose -f docker/docker-compose.yml build && docker-compose -f docker/docker-compose.yml up -d
# Full rebuild (no cache)
docker-compose -f docker/docker-compose.yml 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 -f docker/docker-compose.yml 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 -f docker/docker-compose.yml 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 -f docker/docker-compose.yml logs web
docker-compose -f docker/docker-compose.yml logs api
```
### Out of memory
Increase Docker's memory allocation or reduce worker count in `docker/Dockerfile`.
### Permission errors
The containers run as non-root user `stego` (UID 1000). Ensure volume permissions match.

View File

@@ -154,10 +154,10 @@ Build and run individual containers.
#### Build Images
```bash
# Build all targets
docker build -t stegasoo-web --target web .
docker build -t stegasoo-api --target api .
docker build -t stegasoo-cli --target cli .
# From project root - build all targets
docker build -t stegasoo-web --target web -f docker/Dockerfile .
docker build -t stegasoo-api --target api -f docker/Dockerfile .
docker build -t stegasoo-cli --target cli -f docker/Dockerfile .
```
#### Run Web UI
@@ -214,17 +214,17 @@ The easiest way to run all services.
```bash
# Start in background
docker-compose up -d
docker-compose -f docker/docker-compose.yml up -d
# Start specific service
docker-compose up -d web
docker-compose up -d api
docker-compose -f docker/docker-compose.yml up -d web
docker-compose -f docker/docker-compose.yml up -d api
# View logs
docker-compose logs -f
docker-compose -f docker/docker-compose.yml logs -f
# Stop all
docker-compose down
docker-compose -f docker/docker-compose.yml down
```
#### Authentication Configuration (v4.0.2)
@@ -239,7 +239,7 @@ STEGASOO_HOSTNAME=localhost # Hostname for SSL cert
STEGASOO_CHANNEL_KEY= # Optional channel key
# Then run
docker-compose up -d web
docker-compose -f docker/docker-compose.yml 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.
@@ -255,16 +255,16 @@ On first access, you'll be prompted to create an admin account. The database and
```bash
# Build images and start
docker-compose up -d --build
docker-compose -f docker/docker-compose.yml up -d --build
# Force rebuild (no cache)
docker-compose build --no-cache
docker-compose up -d
docker-compose -f docker/docker-compose.yml build --no-cache
docker-compose -f docker/docker-compose.yml up -d
```
#### Resource Configuration
The `docker-compose.yml` includes resource limits:
The `docker/docker-compose.yml` includes resource limits:
```yaml
services:
@@ -553,6 +553,85 @@ print(f'jpegio: {has_jpegio_support()}')
---
## 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.
---
## Verification
### Check Installation
@@ -773,7 +852,7 @@ Argon2 needs 256MB per operation. Increase container memory:
# Docker run
docker run --memory=768m ...
# Docker Compose - edit docker-compose.yml
# Docker Compose - edit docker/docker-compose.yml
deploy:
resources:
limits:

View File

@@ -1,538 +0,0 @@
# Stegasoo 4.1.0 Plan
## Overview
Version 4.1.0 is a feature release focusing on small-group deployment improvements and new utilities.
## Goals
1. ~~**Multi-User Support** - Admin can create up to 16 users for shared deployments~~ ✅ DONE
2. **Channel Key QR** - Easy visual sharing of channel keys via QR codes
3. ~~**CLI Channel Commands** - Manage channel keys from command line~~ ✅ DONE
4. **Advanced Tools** - Image/stego utilities (TBD)
---
## Feature 1: Multi-User Support ✅ COMPLETED
> Implemented in commit 7b33501. All requirements met.
### Requirements
- 16 users + 1 admin maximum (17 total)
- First user created at setup is always admin
- Admin can add/delete users, reset passwords
- Regular users can only change their own password
- No self-registration (admin-invite only)
### Database Changes
**Update User model in `frontends/web/models.py`:**
```python
class User(db.Model):
id = Column(Integer, primary_key=True)
username = Column(String(80), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
role = Column(String(20), default='user') # 'admin' or 'user'
created_at = Column(DateTime, default=datetime.utcnow)
```
**Migration:** Add `role` and `created_at` columns. Existing users get `role='admin'`.
### New Routes
| Route | Method | Access | Description |
|-------|--------|--------|-------------|
| `/admin/users` | GET | admin | List all users |
| `/admin/users/new` | GET, POST | admin | Create user form |
| `/admin/users/<id>/delete` | POST | admin | Delete user |
| `/admin/users/<id>/reset-password` | POST | admin | Generate temp password |
### New Decorator
```python
# auth.py
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('login'))
if current_user.role != 'admin':
flash('Admin access required', 'error')
return redirect(url_for('index'))
return f(*args, **kwargs)
return decorated
```
### UI Changes
**Navigation (for admin users):**
- Add "Users" link in navbar (visible only to admin)
**Account page (`/account`):**
- Admin sees link to user management
- All users see their own password change form
**New template: `templates/admin/users.html`:**
- Table: Username | Role | Created | Actions
- Actions: Reset Password, Delete (disabled for self)
- "Add User" button (disabled if at 16 user limit)
- Show count: "3 of 16 users"
**New template: `templates/admin/user_new.html`:**
- Username field (email-style allowed)
- Password field (auto-populated with random 8-char, admin can override)
- Submit → confirmation page shows password once with copy button
### Validation
- Username: 3-80 chars, alphanumeric + underscore/hyphen + @/. for email-style
- Password: 8+ chars (same as current)
- Can't delete yourself
- Can't demote the last admin
- Deleting user immediately invalidates their sessions
---
## Feature 2: Channel Key QR
### Web UI
**About page additions:**
If `STEGASOO_CHANNEL_KEY` environment variable is set:
```
┌─────────────────────────────────────────┐
│ Channel Key │
│ │
│ ██████████████ Your server uses a │
│ ██ ██ private channel key. │
│ ██ ██████ ██ Share this QR with │
│ ██ ██████ ██ others to join. │
│ ██ ██ │
│ ██████████████ [Copy Key] [Download]│
│ │
│ Key: abc123...xyz │
└─────────────────────────────────────────┘
```
- QR generated server-side using `qrcode` library
- "Copy Key" copies text to clipboard
- "Download QR" saves as PNG
**Implementation:**
```python
# about route addition
@app.route('/about')
def about():
channel_key = os.environ.get('STEGASOO_CHANNEL_KEY', '')
channel_qr_b64 = None
if channel_key:
# Generate QR as base64 PNG
qr = qrcode.make(channel_key)
buffer = BytesIO()
qr.save(buffer, format='PNG')
channel_qr_b64 = base64.b64encode(buffer.getvalue()).decode()
return render_template('about.html',
channel_key=channel_key,
channel_qr=channel_qr_b64)
```
### CLI Commands
**New command group: `stegasoo channel`**
```bash
# Generate a new channel key
stegasoo channel generate
# Output:
# Channel Key: stg_abc123...xyz789
#
# ██████████████████
# ██ ██
# ██ ██████████ ██
# ...
#
# Set in environment: export STEGASOO_CHANNEL_KEY="stg_abc123..."
# Show current key (from env or argument)
stegasoo channel show
# Output:
# Channel Key: stg_abc123...xyz789
# Display QR in terminal (ASCII)
stegasoo channel qr
# Output: ASCII QR code
# Save QR as PNG
stegasoo channel qr -o channel-key.png
# Output: Saved to channel-key.png
# Explicit format selection
stegasoo channel qr --format ascii # Terminal (default)
stegasoo channel qr --format png -o - # PNG to stdout
```
**Implementation notes:**
- Use `qrcode[pil]` for PNG output
- Use `qrcode` with `print_ascii()` for terminal
- Read key from `--key` argument or `STEGASOO_CHANNEL_KEY` env var
- `generate` uses existing `generate_channel_key()` from `stegasoo.channel`
---
## File Changes Summary
### New Files
| File | Description |
|------|-------------|
| `frontends/web/templates/admin/users.html` | User management page |
| `frontends/web/templates/admin/user_new.html` | Add user form |
### Modified Files
| File | Changes |
|------|---------|
| `frontends/web/models.py` | Add `role`, `created_at` to User |
| `frontends/web/auth.py` | Add `@admin_required`, user management routes |
| `frontends/web/templates/base.html` | Add Users link for admins |
| `frontends/web/templates/account.html` | Add admin link |
| `frontends/web/templates/about.html` | Add channel key QR section |
| `src/stegasoo/cli.py` | Add `channel` command group |
---
## Testing Plan
### Multi-User
1. Fresh install → first user is admin
2. Admin can create users up to limit (16)
3. Admin can't create 17th user (shows error)
4. Regular user can log in, encode/decode
5. Regular user can't access `/admin/users`
6. Admin can reset user password
7. Admin can delete user
8. Admin can't delete self
9. Existing 4.0.2 databases upgrade correctly (single user becomes admin)
### Channel Key QR
1. About page shows nothing if no channel key
2. About page shows QR + key if channel key set
3. Copy button works
4. Download gives valid PNG
5. QR scans correctly to key value
### CLI
1. `channel generate` creates valid key + shows QR
2. `channel show` displays current key
3. `channel qr` outputs ASCII to terminal
4. `channel qr -o file.png` saves PNG
5. Commands work with `--key` override
6. Commands read from env var
---
## Feature 3: Advanced Tools
### Included Tools
| Tool | Web | CLI | Description |
|------|-----|-----|-------------|
| **Capacity Calculator** | ✓ | ✓ | Upload image → show DCT/LSB capacity |
| **Metadata Stripper** | ✓ | ✓ | Remove EXIF/metadata from image |
| **Stego Detector** | ✓ | ✓ | Analyze image for signs of hidden data |
| **Image Compare** | ✓ | - | Side-by-side before/after diff |
| **Header Peek** | ✓ | ✓ | Check for Stegasoo header without decrypting |
| **Batch Mode** | - | ✓ | Encode/decode multiple files |
### Web UI: `/tools` Page
New page with card-based layout:
```
┌─────────────────────────────────────────────────────────────┐
│ 🛠️ Advanced Tools │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 📏 Capacity │ │ 🧹 Metadata │ │
│ │ Calculator │ │ Stripper │ │
│ │ │ │ │ │
│ │ Check how much │ │ Remove EXIF │ │
│ │ data fits │ │ before encoding │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 🔍 Stego │ │ 🔎 Header │ │
│ │ Detector │ │ Peek │ │
│ │ │ │ │ │
│ │ Analyze image │ │ Check for │ │
│ │ for hidden data │ │ Stegasoo data │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ ⚖️ Image │ │
│ │ Compare │ │
│ │ │ │
│ │ Before/after │ │
│ │ diff view │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
Each card opens a modal or expands inline for the tool interface.
### CLI Structure
```bash
# Capacity calculator
stegasoo capacity image.jpg
stegasoo capacity image.jpg --format json
# Metadata stripper
stegasoo strip image.jpg # Output to image_stripped.jpg
stegasoo strip image.jpg -o clean.jpg # Custom output
stegasoo strip image.jpg --in-place # Overwrite original
# Stego detector
stegasoo detect image.jpg
stegasoo detect image.jpg --verbose # Detailed analysis
# Header peek
stegasoo peek image.jpg
# Output: "Stegasoo DCT header detected" or "No Stegasoo header found"
# Batch mode
stegasoo encode --batch manifest.json # JSON with files + credentials
stegasoo decode --batch input_dir/ --out output_dir/
```
### Tool Details
#### Capacity Calculator
- Input: Image file
- Output: Dimensions, megapixels, DCT capacity, LSB capacity
- Web: Upload zone + results panel
- CLI: Table or JSON output
#### Metadata Stripper
- Input: Image file
- Output: Clean image (EXIF/metadata removed)
- Show what was removed (camera model, GPS, etc.)
- Preserve image quality
#### Stego Detector
- Input: Image file
- Analysis:
- Chi-square analysis (LSB detection)
- DCT coefficient histogram analysis
- Visual inspection hints
- Output: Likelihood score + findings
- Note: Detection is probabilistic, not definitive
#### Image Compare
- Input: Two images (original + stego)
- Output:
- Side-by-side view
- Difference overlay (amplified)
- Pixel-level stats (PSNR, SSIM)
- Web only (visual tool)
#### Header Peek
- Input: Image file
- Output: Header found (yes/no), mode (DCT/LSB), embedded size estimate
- Does NOT decrypt - just checks for valid header structure
- Useful for "is this a stego image?" without credentials
#### Batch Mode
- CLI only
- Manifest file (JSON) or directory-based
- Progress bar for multiple files
- Error handling per-file (continue on failure)
---
## Migration Notes
### Database Migration
For existing 4.0.2 installations:
```python
# migrations/add_user_role.py
def upgrade():
# Add columns with defaults
op.add_column('user', sa.Column('role', sa.String(20), default='user'))
op.add_column('user', sa.Column('created_at', sa.DateTime))
# Set existing users as admin (they were the first user)
op.execute("UPDATE user SET role = 'admin' WHERE role IS NULL")
op.execute("UPDATE user SET created_at = datetime('now') WHERE created_at IS NULL")
```
Or simpler: detect on startup, update schema automatically (current pattern).
---
## Out of Scope
- Per-user channel keys
- User groups/teams
- API authentication tokens
- User activity logging
- Password complexity rules beyond length
---
## Estimated Effort
| Component | Complexity |
|-----------|------------|
| Database schema change | Low |
| Admin routes + templates | Medium |
| Access control decorator | Low |
| About page QR | Low |
| CLI channel commands | Medium |
| Advanced Tools (TBD) | Medium-High |
| Testing | Medium |
---
## Decisions
1. **Temp password flow:** Password field auto-populates with random 8-char password. Admin can override if desired. Show password once on confirmation page.
2. **Session handling:** Yes - deleting a user immediately invalidates their active sessions (ban hammer).
3. **Username rules:** Sane requirements, email-style allowed. Validation: 3-80 chars, alphanumeric, underscore, hyphen, @ and . for email-style.
---
## Approval
- [x] Plan reviewed
- [x] Questions resolved
- [x] Ready to implement
## Progress
- [x] Multi-User Support (commit 7b33501)
- [x] Channel Key QR (Web UI) - added QR generator on About page
- [x] CLI Channel Commands
- [x] Saved Channel Keys (Web UI) - users can save/manage channel keys
- [x] Advanced Tools - Image Security Toolkit
- [x] CLI: `stegasoo tools capacity/strip/peek/exif`
- [x] API: `/api/tools/capacity`, `/api/tools/peek`, `/api/tools/exif/*`
- [x] WebUI: Tools page with tabbed interface
- [x] EXIF Editor with inline editing, clear all, save/download
---
## Architectural Improvements (4.1.0)
### Consolidated Channel Key Resolution
Moved `resolve_channel_key()` from 3 duplicate implementations to single source of truth in `src/stegasoo/channel.py`:
```python
# Library: src/stegasoo/channel.py
def resolve_channel_key(value, *, file_path=None, no_channel=False) -> str | None:
"""Unified channel key resolution - returns None (auto), "" (public), or key."""
def get_channel_response_info(channel_key) -> dict:
"""Get channel info dict for API/WebUI responses."""
```
Frontends now use thin wrappers that translate exceptions to their context (Click/HTTP).
### DCT Payload Pre-Check
Added `will_fit_by_mode()` pre-check to WebUI encode to fail fast with helpful error message instead of cryptic exception deep in DCT processing.
### EXIF Tools (Library Layer)
Added to `src/stegasoo/utils.py`:
- `read_image_exif(image_data)` - Read EXIF metadata as dict
- `write_image_exif(image_data, updates)` - Update EXIF fields (JPEG only)
Dependencies added: `piexif>=1.1.0`
---
## Action Item: Architectural Review ✅ DONE
Reviewed modules for consistency with Library → CLI → API → WebUI pattern:
| Module | Library | CLI | API | WebUI | Status |
|--------|---------|-----|-----|-------|--------|
| encode | ✓ | ✓ | ✓ | ✓ | Consistent |
| decode | ✓ | ✓ | ✓ | ✓ | Consistent |
| channel | ✓ | ✓ | ✓ | ✓ | Consolidated resolve_channel_key |
| tools | ✓ | ✓ | ✓ | ✓ | Complete |
| generate | ✓ | ✓ | - | ✓ | CLI has `stegasoo generate` |
Priority order: Developer/CLI → API integrator → WebUI end-user
---
## Admin Recovery System (4.1.0) ✅ DONE
Password reset capability for locked-out admins with multiple backup options.
### Library Layer (`src/stegasoo/recovery.py`)
```python
# Key generation and validation
generate_recovery_key() -> str # XXXX-XXXX-XXXX-... (32 chars)
hash_recovery_key(key) -> str # SHA-256 for storage
verify_recovery_key(key, hash) -> bool
# QR code (obfuscated - scans as gibberish)
obfuscate_key(key) -> str # XOR with RECOVERY_OBFUSCATION_KEY
deobfuscate_key(data) -> str | None
generate_recovery_qr(key) -> bytes # PNG with obfuscated data
extract_key_from_qr(image) -> str | None
# Stego backup (hide key in an image)
create_stego_backup(key, carrier_image) -> bytes
extract_stego_backup(stego_image, reference) -> str | None
```
### Database (`app_settings` table)
- `recovery_key_hash` - SHA-256 of recovery key (or null if disabled)
### Web Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/setup/recovery` | GET, POST | Step 2 of initial setup |
| `/recover` | GET, POST | Password reset page |
| `/recover/stego` | POST | Extract key from stego backup |
| `/account/recovery/regenerate` | GET, POST | Generate new key |
| `/account/recovery/disable` | POST | Remove recovery option |
| `/account/recovery/stego-backup` | POST | Create stego backup |
### CLI Commands
```bash
stegasoo admin recover --db path/to/stegasoo.db # Reset password
stegasoo admin generate-key [--qr] # Generate key (reference)
```
### Security Model
1. Recovery key shown once during setup - only hash stored
2. QR codes XOR'd with `RECOVERY_OBFUSCATION_KEY` (fixed in constants.py)
3. Stego backups use fixed internal passphrase/PIN - security is obscurity
4. Instance-bound: recovery key hash must match in target database
5. Options: text file, QR image, stego image, or no recovery (most secure)

View File

@@ -1,221 +0,0 @@
# Stegasoo 4.1.2 Plan
## Release Theme
Polish and UX improvements after the 4.1.1 stability release.
---
## 1. Real Progress Bar for Encode/Decode
**Status:** Planned
**Problem:** Users see elapsed time but no indication of how far along the operation is. Long DCT encodes on Pi can take 2-3 minutes with no feedback.
**Solution:** Polling + progress file approach
### Backend Changes
1. **dct_steganography.py** - Write progress during block loop:
```python
if progress_file and block_num % 50 == 0:
with open(progress_file, 'w') as f:
json.dump({"current": block_num, "total": total_blocks, "phase": "embedding"}, f)
```
2. **app.py** - New endpoints:
- `POST /encode` returns `job_id`, starts subprocess
- `GET /encode/progress/<job_id>` returns progress JSON
- `GET /encode/result/<job_id>` returns final result when done
3. **Subprocess wrapper** - Pass progress file path to encode/decode functions
### Frontend Changes
1. **stegasoo.js** - After form submit:
- Show progress bar (Bootstrap progress component)
- Poll `/encode/progress/{job_id}` every 500ms
- Update bar width and percentage text
- Show phase (hashing, embedding, encoding, etc.)
2. **Templates** - Add progress bar markup to encode.html and decode.html
### Files to Modify
- `src/stegasoo/dct_steganography.py`
- `frontends/web/app.py`
- `frontends/web/static/js/stegasoo.js`
- `frontends/web/templates/encode.html`
- `frontends/web/templates/decode.html`
---
## 2. Granular Decode Error Messages
**Status:** Planned
**Problem:** Decode failures show generic "Decryption failed" - users don't know if it's wrong photo, wrong passphrase, wrong PIN, corrupted image, or format mismatch.
**Solution:** Bubble up specific error types from library to UI
### Library Level (`src/stegasoo/`)
1. **Custom exception classes:**
```python
class StegasooError(Exception): pass
class InvalidMagicBytesError(StegasooError): pass
class DecryptionError(StegasooError): pass
class ReedSolomonError(StegasooError): pass
class PayloadTooLargeError(StegasooError): pass
class InvalidHeaderError(StegasooError): pass
class NoDataFoundError(StegasooError): pass
```
2. **Raise specific exceptions** in decode paths:
- Magic bytes mismatch → "Not a Stegasoo image or wrong mode (LSB/DCT)"
- RS decode failure → "Image corrupted beyond repair"
- AES-GCM auth fail → "Wrong credentials (photo/passphrase/PIN)"
- Header parse fail → "Invalid or corrupted header"
- No stego data → "No hidden data found in image"
3. **Error codes** for programmatic handling:
```python
class ErrorCode(Enum):
INVALID_MAGIC = "invalid_magic"
DECRYPTION_FAILED = "decryption_failed"
RS_FAILED = "rs_failed"
# etc.
```
### Web UI Level (`frontends/web/`)
1. **app.py** - Catch specific exceptions, return error type:
```python
except InvalidMagicBytesError:
flash("This doesn't appear to be a Stegasoo image, or mode mismatch", "danger")
except DecryptionError:
flash("Wrong credentials - check reference photo, passphrase, and PIN", "warning")
```
2. **decode.html** - Error-specific help text:
- Wrong credentials → "Double-check your reference photo matches exactly"
- Corrupted → "Image may have been re-saved or compressed"
- Mode mismatch → "Try switching between Auto/DCT/LSB"
### Files to Modify
- `src/stegasoo/__init__.py` (export exceptions)
- `src/stegasoo/exceptions.py` (new file)
- `src/stegasoo/dct_steganography.py`
- `src/stegasoo/steganography.py` (LSB)
- `frontends/web/app.py`
- `frontends/web/templates/decode.html`
---
## 3. Mobile-Responsive Polish
**Status:** Planned
**Problem:** UI works on mobile but has rough edges - cramped buttons, hard-to-tap targets, awkward layouts on small screens.
**Solution:** Targeted CSS/layout fixes for mobile breakpoints
### Areas to Improve
1. **Encode/Decode Forms:**
- Stack image drop zones vertically on mobile (currently side-by-side)
- Larger touch targets for file inputs
- Full-width buttons on small screens
- Passphrase input readable at smaller sizes
2. **Navigation:**
- Hamburger menu for mobile navbar (if not already)
- Sticky header doesn't eat too much screen
- Easy thumb reach for main actions
3. **Results/Output:**
- Download buttons full-width on mobile
- QR codes sized appropriately
- Click-to-copy message box works well with touch
4. **Drop Zones:**
- Larger tap targets
- Visual feedback for touch (not just hover)
- Camera integration hint on mobile ("Tap to take photo or choose file")
### Testing Targets
- iPhone SE (small)
- iPhone 14 (medium)
- iPad (tablet)
- Android Chrome
### Files to Modify
- `frontends/web/static/css/style.css` (or new mobile.css)
- `frontends/web/templates/encode.html`
- `frontends/web/templates/decode.html`
- `frontends/web/templates/base.html` (navbar)
---
## Testing Checklist
- [ ] Progress bar works on localhost
- [ ] Progress bar works on Pi (slower, more visible)
- [ ] Cancellation handling (what if user navigates away?)
- [ ] Error states display correctly
- [ ] Smoke test passes
---
## 4. Forced First-Login Setup
**Status:** Planned
**Problem:** Users can navigate the app without creating an admin account first. Should force password setup before anything else.
**Solution:** Middleware/decorator that redirects to setup page if no users exist.
### Files to Modify
- `frontends/web/app.py` (add before_request check)
- `frontends/web/templates/setup.html` (ensure it blocks other nav)
---
## 5. Dropzone UX Fixes
**Status:** Planned
**Problem:** Dropzone has some interaction bugs:
- Dropzone doesn't clear properly if first QR image fails
- Can't click on image preview to replace file (have to click surrounding border)
**Solution:** Fix JS event handling and state management
### Files to Modify
- `frontends/web/static/js/stegasoo.js`
- `frontends/web/static/css/style.css` (clickable preview)
---
## 6. Smoke Test Benchmarking
**Status:** Planned
**Problem:** No way to measure encode/decode performance or track regressions.
**Solution:** Add timing to smoke tests using `hyperfine` or `time`.
### Features
- Benchmark encode/decode on test images
- Output timing stats (min/max/avg)
- Optional `--benchmark` flag for smoke-test.sh
- Compare NVMe vs SD card, overclocked vs stock
### Files to Modify
- `rpi/smoke-test.sh`
---
## Notes
- Keep 4.1.2 focused - 6 small features
- Don't break DCT compatibility (4.1.1 RS format is stable)
- Test on Pi before release

View File

@@ -1,42 +0,0 @@
# Stegasoo 4.1.3 Plan
## Release Theme
Performance and admin features.
---
## 1. DCT Performance Optimizations
**Status:** Planned
**Problem:** DCT encode/decode can be slow on Pi, especially for large images.
**Ideas:**
- Vectorize block processing with NumPy
- Reduce Python loop overhead
- Parallel block processing (multiprocessing?)
- Profile and identify bottlenecks
- Consider Cython for hot paths
---
## 2. User Management UI
**Status:** Planned
**Problem:** No way for admin to manage users via UI. Currently need direct DB access.
**Features:**
- List all users
- Create new user (admin only)
- Delete user (admin only)
- Reset user password
- User activity/last login
---
## Notes
- These are heavier lifts than 4.1.2
- Profile before optimizing
- Consider security implications of user management

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)

View File

@@ -102,9 +102,43 @@ black src/ tests/ frontends/
ruff check src/ tests/ frontends/
```
## Docker
```bash
# Quick start (HTTPS enabled by default)
docker-compose -f docker/docker-compose.yml up -d
# Access
# Web UI: https://localhost:5000 (self-signed cert)
# REST API: http://localhost:8000
# Disable HTTPS if needed:
STEGASOO_HTTPS_ENABLED=false docker-compose -f docker/docker-compose.yml up -d
```
See [DOCKER.md](DOCKER.md) and [docs/DOCKER_QUICKSTART.md](docs/DOCKER_QUICKSTART.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
@@ -112,6 +146,7 @@ ruff check src/ tests/ frontends/
- [UNDER_THE_HOOD.md](UNDER_THE_HOOD.md) - Technical deep-dive
- [CHANGELOG.md](CHANGELOG.md) - Version history
- [CONTRIBUTING.md](CONTRIBUTING.md) - Contributor guide
- `man stegasoo` - Man page (install: `sudo cp docs/stegasoo.1 /usr/local/share/man/man1/ && sudo mandb`)
## License

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 docker/Dockerfile.base -t stegasoo-base:latest .`
- [ ] Web image builds: `docker-compose -f docker/docker-compose.yml build web`
- [ ] Container starts: `docker-compose -f docker/docker-compose.yml up -d web`
- [ ] Web UI accessible at http://localhost:5000
- [ ] Encode/decode works in container
- [ ] Container stops cleanly: `docker-compose -f docker/docker-compose.yml 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)

52
RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1,52 @@
## Stegasoo v4.1.7
### Mobile UI Polish
- **PIN Entry**: Shrunk digit boxes for 9-digit PIN support on mobile
- **Mode Selectors**: DCT/LSB buttons now use consistent button-group styling with icons
- **Navbar**: Left-aligned collapsed menu, shortened channel fingerprint display (`ABCD-••••-3456`)
- **Text Wrapping**: Fixed button text wrapping issues on narrow screens
### Docker Improvements
- **Reorganized**: Docker files moved to `docker/` directory
- `docker/Dockerfile`
- `docker/Dockerfile.base`
- `docker/docker-compose.yml`
- **DCT Fix**: Added Reed-Solomon (`reedsolo`) to Docker images - fixes DCT decode failures
- **Quick Start**: New `docs/DOCKER_QUICKSTART.md` guide
```bash
# Build and run
docker build -f docker/Dockerfile.base -t stegasoo-base:latest .
docker-compose -f docker/docker-compose.yml up -d
```
### Raspberry Pi
- **First-Boot Wizard**: Can now load existing channel key (for joining team deployments)
- **Project Cleanup**: Moved `pishrink.sh` to `rpi/tools/`
### UI Copy
- Changed "Undetectable" to "Covertly Embedded" on encode page (more accurate)
### Raspberry Pi Image
Download `stegasoo-rpi-4.1.7.img.zst.zip` from Releases.
```bash
# Flash (auto-detects SD card)
sudo ./rpi/flash-image.sh stegasoo-rpi-4.1.7.img.zst.zip
# Or manual
unzip -p stegasoo-rpi-4.1.7.img.zst.zip | zstdcat | 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 -f docker/docker-compose.yml up -d web # Web UI on :5000
docker-compose -f docker/docker-compose.yml 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

View File

@@ -177,7 +177,7 @@ python app.py
### Docker Configuration
```yaml
# docker-compose.yml
# docker/docker-compose.yml
services:
web:
environment:
@@ -360,7 +360,7 @@ gunicorn --bind 0.0.0.0:5000 --workers 2 --threads 4 --timeout 60 app:app
**Docker:**
```bash
docker-compose up web
docker-compose -f docker/docker-compose.yml up web
```
### First-Time Setup
@@ -1245,7 +1245,7 @@ volumes:
```bash
pip install scipy
# Or rebuild Docker image
docker-compose build --no-cache
docker-compose -f docker/docker-compose.yml build --no-cache
```
### Browser Compatibility

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

@@ -35,12 +35,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libzbar0 \
libjpeg-dev \
zlib1g-dev \
curl \
openssl \
&& rm -rf /var/lib/apt/lists/*
# Install ALL dependencies (slow path)
RUN pip install --no-cache-dir \
cython numpy scipy>=1.10.0 jpegio>=0.2.0 \
argon2-cffi>=23.0.0 pillow>=10.0.0 cryptography>=41.0.0 \
reedsolo>=1.7.0 \
flask>=3.0.0 gunicorn>=21.0.0 \
fastapi>=0.100.0 "uvicorn[standard]>=0.20.0" python-multipart>=0.0.6 \
qrcode>=7.3.0 pyzbar>=0.1.9 click>=8.0.0 lz4>=4.0.0
@@ -57,13 +60,24 @@ FROM base AS web
WORKDIR /app
# Install runtime dependencies (curl for healthcheck, openssl for cert generation)
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
curl openssl \
&& rm -rf /var/lib/apt/lists/*
# Copy application files (this is all that rebuilds normally!)
COPY src/ src/
COPY data/ data/
COPY frontends/web/ frontends/web/
# Create upload directory
RUN mkdir -p /tmp/stego_uploads
# Create upload directory and instance directories (for volumes)
# 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
# Copy and set up entrypoint (before switching to non-root user)
COPY frontends/web/docker-entrypoint.sh /app/frontends/web/
RUN chmod +x /app/frontends/web/docker-entrypoint.sh
# Create non-root user
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads
@@ -76,12 +90,12 @@ ENV PYTHONPATH=/app/src
EXPOSE 5000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/')" || exit 1
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -fsk https://localhost:5000/ || curl -fs http://localhost:5000/ || exit 1
# Run with gunicorn
# Run with entrypoint (handles HTTPS/HTTP mode)
WORKDIR /app/frontends/web
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]
ENTRYPOINT ["/app/frontends/web/docker-entrypoint.sh"]
# ============================================================================
# API stage - REST API

View File

@@ -32,7 +32,8 @@ RUN pip install --no-cache-dir \
jpegio>=0.2.0 \
argon2-cffi>=23.0.0 \
pillow>=10.0.0 \
cryptography>=41.0.0
cryptography>=41.0.0 \
reedsolo>=1.7.0
# Install web/api framework packages (also stable)
RUN pip install --no-cache-dir \

View File

@@ -1,5 +1,3 @@
version: '3.8'
# Shared environment variables
x-common-env: &common-env
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
@@ -10,7 +8,8 @@ services:
# ============================================================================
web:
build:
context: .
context: ..
dockerfile: docker/Dockerfile
target: web
container_name: stegasoo-web
ports:
@@ -20,7 +19,9 @@ services:
FLASK_ENV: production
# Authentication (v4.0.2)
STEGASOO_AUTH_ENABLED: ${STEGASOO_AUTH_ENABLED:-true}
STEGASOO_HTTPS_ENABLED: ${STEGASOO_HTTPS_ENABLED:-false}
# HTTPS enabled by default - generates self-signed cert if none provided
# To disable: STEGASOO_HTTPS_ENABLED=false docker-compose up
STEGASOO_HTTPS_ENABLED: ${STEGASOO_HTTPS_ENABLED:-true}
STEGASOO_HOSTNAME: ${STEGASOO_HOSTNAME:-localhost}
volumes:
# Persist auth database and SSL certs (v4.0.2)
@@ -30,16 +31,17 @@ services:
deploy:
resources:
limits:
memory: 768M
memory: 2048M
reservations:
memory: 384M
memory: 1024M
# ============================================================================
# REST API (FastAPI)
# ============================================================================
api:
build:
context: .
context: ..
dockerfile: docker/Dockerfile
target: api
container_name: stegasoo-api
ports:
@@ -50,9 +52,9 @@ services:
deploy:
resources:
limits:
memory: 768M
memory: 2048M
reservations:
memory: 384M
memory: 1024M
# Named volumes for persistent data
volumes:

162
docs/DOCKER_QUICKSTART.md Normal file
View File

@@ -0,0 +1,162 @@
# Docker Quickstart
Get Stegasoo running in Docker in under 5 minutes.
## Build
```bash
# From project root:
# Build web UI image
sudo docker build -t stegasoo-web --target web -f docker/Dockerfile .
# Or build all targets
sudo docker build -t stegasoo-api --target api -f docker/Dockerfile .
sudo docker build -t stegasoo-cli --target cli -f docker/Dockerfile .
# Or use docker-compose
sudo docker-compose -f docker/docker-compose.yml build
```
## Run (Basic)
```bash
# HTTP only, no auth
sudo docker run -d \
-p 5000:5000 \
-e STEGASOO_AUTH_ENABLED=false \
--name stegasoo \
stegasoo-web
```
Visit http://localhost:5000
## Run (Production)
```bash
# HTTPS + Auth + Channel Key
sudo docker run -d \
-p 5000:5000 \
-e STEGASOO_AUTH_ENABLED=true \
-e STEGASOO_HTTPS_ENABLED=true \
-e STEGASOO_HOSTNAME=stegasoo.local \
-e STEGASOO_CHANNEL_KEY=ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456 \
-v stegasoo-data:/opt/stegasoo/frontends/web/instance \
-v stegasoo-certs:/opt/stegasoo/frontends/web/certs \
--name stegasoo \
stegasoo-web
```
Visit https://localhost:5000 (accept self-signed cert warning)
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `STEGASOO_AUTH_ENABLED` | `true` | Require login |
| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS |
| `STEGASOO_HOSTNAME` | `localhost` | Hostname for SSL cert |
| `STEGASOO_CHANNEL_KEY` | *(none)* | Shared channel key (32 alphanumeric chars with dashes) |
## Docker Compose
Create `.env` file in project root:
```bash
STEGASOO_AUTH_ENABLED=true
STEGASOO_HTTPS_ENABLED=true
STEGASOO_HOSTNAME=stegasoo.local
STEGASOO_CHANNEL_KEY=
```
Run:
```bash
sudo docker-compose -f docker/docker-compose.yml up -d web
```
## Custom SSL Certificates
### Use Your Own Certs
```bash
# Stop container
sudo docker stop stegasoo
# Copy certs to volume
sudo docker run --rm -v stegasoo-certs:/certs -v $(pwd):/src alpine \
sh -c "cp /src/your-cert.crt /certs/server.crt && cp /src/your-key.key /certs/server.key && chmod 600 /certs/server.key"
# Start container
sudo docker start stegasoo
```
### Use mkcert (Local Development)
```bash
# Install mkcert
brew install mkcert # macOS
# or: sudo apt install mkcert # Debian/Ubuntu
# Create local CA and certs
mkcert -install
mkcert -cert-file server.crt -key-file server.key localhost 127.0.0.1 stegasoo.local
# Copy to Docker volume (see above)
```
### Use Let's Encrypt (Public Server)
```bash
# Get cert
sudo certbot certonly --standalone -d yourdomain.com
# Copy to Docker volume
sudo docker run --rm -v stegasoo-certs:/certs alpine \
sh -c "cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem /certs/server.crt && \
cp /etc/letsencrypt/live/yourdomain.com/privkey.pem /certs/server.key && \
chmod 600 /certs/server.key"
```
## Volumes
| Volume | Purpose |
|--------|---------|
| `stegasoo-data` | User database, settings |
| `stegasoo-certs` | SSL certificates |
## Smoke Test
```bash
# Check container logs
sudo docker logs stegasoo
# Test HTTP endpoint
curl -k https://localhost:5000/health
# Expected: {"status":"ok","version":"4.1.7",...}
```
## Troubleshooting
**Container won't start:**
```bash
sudo docker logs stegasoo
```
**Out of memory:**
```bash
# Argon2 needs 256MB+ per operation
sudo docker run --memory=768m ...
```
**Certificate errors:**
```bash
# Regenerate self-signed cert
sudo docker exec stegasoo rm -rf /opt/stegasoo/frontends/web/certs/*
sudo docker restart stegasoo
```
**Reset everything:**
```bash
sudo docker stop stegasoo && sudo docker rm stegasoo
sudo docker volume rm stegasoo-data stegasoo-certs
```

340
docs/stegasoo.1 Normal file
View File

@@ -0,0 +1,340 @@
.\" Stegasoo man page
.\" Generate with: groff -man -Tascii stegasoo.1
.TH STEGASOO 1 "January 2026" "Stegasoo 4.1.7" "User Commands"
.SH NAME
stegasoo \- steganography with hybrid authentication
.SH SYNOPSIS
.B stegasoo
[\fB\-v\fR|\fB\-\-version\fR]
[\fB\-\-json\fR]
[\fB\-h\fR|\fB\-\-help\fR]
.I command
[\fIargs\fR]
.SH DESCRIPTION
.B stegasoo
hides messages and files in images using PIN + passphrase security.
It uses LSB (Least Significant Bit) steganography with optional DCT
(Discrete Cosine Transform) encoding for JPEG resilience.
.PP
Messages are encrypted using a hybrid authentication scheme that combines
a reference photo (shared secret), passphrase, and PIN code.
.SH GLOBAL OPTIONS
.TP
.BR \-v ", " \-\-version
Show version and exit.
.TP
.B \-\-json
Output results as JSON (where supported).
.TP
.BR \-h ", " \-\-help
Show help message and exit.
.SH COMMANDS
.SS encode
Encode a message or file into an image.
.PP
.B stegasoo encode
.I carrier
.B \-r
.I reference
[\fB\-m\fR \fImessage\fR | \fB\-f\fR \fIfile\fR]
[\fIoptions\fR]
.TP
.BR \-r ", " \-\-reference " " \fIPATH\fR
Reference photo (shared secret). Required.
.TP
.BR \-m ", " \-\-message " " \fITEXT\fR
Message to encode.
.TP
.BR \-f ", " \-\-file " " \fIPATH\fR
File to embed instead of message.
.TP
.BR \-o ", " \-\-output " " \fIPATH\fR
Output image path.
.TP
.B \-\-passphrase " " \fITEXT\fR
Passphrase (recommend 4+ words). Prompts if not provided.
.TP
.B \-\-pin " " \fITEXT\fR
PIN code. Prompts if not provided.
.TP
.B \-\-dry\-run
Show capacity usage without encoding.
.PP
.B Examples:
.nf
stegasoo encode photo.png -r ref.jpg -m "Secret" --passphrase --pin
stegasoo encode photo.png -r ref.jpg -f doc.pdf -o encoded.png
.fi
.SS decode
Decode a message or file from an image.
.PP
.B stegasoo decode
.I image
.B \-r
.I reference
[\fIoptions\fR]
.TP
.BR \-r ", " \-\-reference " " \fIPATH\fR
Reference photo (shared secret). Required.
.TP
.B \-\-passphrase " " \fITEXT\fR
Passphrase. Prompts if not provided.
.TP
.B \-\-pin " " \fITEXT\fR
PIN code. Prompts if not provided.
.TP
.BR \-o ", " \-\-output " " \fIPATH\fR
Output path for file payloads.
.PP
.B Examples:
.nf
stegasoo decode encoded.png -r ref.jpg --passphrase --pin
stegasoo decode encoded.png -r ref.jpg -o ./extracted/
.fi
.SS generate
Generate random credentials (passphrase + PIN + optional channel key).
.PP
.B stegasoo generate
[\fIoptions\fR]
.TP
.B \-\-words " " \fIINTEGER\fR
Number of words in passphrase (default: 4).
.TP
.B \-\-pin\-length " " \fIINTEGER\fR
PIN length (default: 6).
.TP
.B \-\-channel\-key
Also generate a 256-bit channel key.
.PP
.B Examples:
.nf
stegasoo generate
stegasoo generate --words 6 --pin-length 8
stegasoo generate --channel-key
.fi
.SS info
Show version, features, and system information.
.PP
.B stegasoo info
[\fB\-\-full\fR]
.TP
.B \-\-full
Show full system information (CPU, temperature, disk on Pi).
.SS batch
Batch operations on multiple images.
.PP
.B stegasoo batch
.I subcommand
[\fIargs\fR]
.TP
.B batch encode
Encode message into multiple images.
.RS
.PP
.B stegasoo batch encode
.I images...
[\fB\-m\fR \fImessage\fR | \fB\-f\fR \fIfile\fR]
[\fIoptions\fR]
.PP
Options: \fB\-m\fR, \fB\-f\fR, \fB\-o\fR/\fB\-\-output\-dir\fR, \fB\-\-suffix\fR, \fB\-\-passphrase\fR, \fB\-\-pin\fR,
\fB\-r\fR/\fB\-\-recursive\fR, \fB\-j\fR/\fB\-\-jobs\fR, \fB\-v\fR/\fB\-\-verbose\fR.
.RE
.TP
.B batch decode
Decode messages from multiple images.
.RS
.PP
.B stegasoo batch decode
.I images...
[\fIoptions\fR]
.PP
Options: \fB\-o\fR/\fB\-\-output\-dir\fR, \fB\-\-passphrase\fR, \fB\-\-pin\fR, \fB\-r\fR/\fB\-\-recursive\fR,
\fB\-j\fR/\fB\-\-jobs\fR, \fB\-v\fR/\fB\-\-verbose\fR.
.RE
.TP
.B batch check
Check capacity of multiple images.
.RS
.PP
.B stegasoo batch check
.I images...
[\fB\-r\fR/\fB\-\-recursive\fR]
.RE
.SS channel
Manage channel keys for deployment isolation.
.PP
Channel keys bind encode/decode operations to a specific group or deployment.
Messages encoded with one channel key can only be decoded by systems with
the same channel key.
.PP
.B stegasoo channel
.I subcommand
[\fIargs\fR]
.TP
.B channel generate
Generate a new random channel key.
.RS
.PP
Options: \fB\-\-save\fR (project config), \fB\-\-save\-user\fR (user config).
.RE
.TP
.B channel show
Show the current channel key.
.RS
.PP
Options: \fB\-\-key\fR \fITEXT\fR (show specific key instead).
.RE
.TP
.B channel qr
Display channel key as QR code.
.RS
.PP
Options: \fB\-\-key\fR \fITEXT\fR, \fB\-\-format\fR [\fIascii\fR|\fIpng\fR], \fB\-o\fR/\fB\-\-output\fR \fIPATH\fR.
.RE
.TP
.B channel status
Show channel key status and configuration.
.TP
.B channel clear
Remove channel key configuration.
.RS
.PP
Options: \fB\-\-project\fR, \fB\-\-user\fR.
.RE
.SS admin
Web UI administration commands.
.PP
.B stegasoo admin
.I subcommand
[\fIargs\fR]
.TP
.B admin generate\-key
Generate a new recovery key (for reference only).
.RS
.PP
Options: \fB\-\-qr\fR (show QR code in terminal).
.RE
.TP
.B admin recover
Reset admin password using recovery key.
.RS
.PP
Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR.
.RE
.SS tools
Image security tools.
.PP
.B stegasoo tools
.I subcommand
[\fIargs\fR]
.TP
.B tools capacity
Show steganography capacity for an image.
.RS
.PP
.B stegasoo tools capacity
.I image
[\fB\-\-json\fR]
.RE
.TP
.B tools exif
View or edit EXIF metadata.
.RS
.PP
.B stegasoo tools exif
.I image
[\fB\-\-clear\fR] [\fB\-\-set\fR \fIFIELD=VALUE\fR] [\fB\-o\fR \fIPATH\fR] [\fB\-\-json\fR]
.RE
.TP
.B tools peek
Check if image contains Stegasoo hidden data.
.RS
.PP
.B stegasoo tools peek
.I image
[\fB\-\-json\fR]
.RE
.TP
.B tools strip
Strip EXIF/metadata from an image.
.RS
.PP
.B stegasoo tools strip
.I image
[\fB\-o\fR \fIPATH\fR] [\fB\-\-format\fR [\fIpng\fR|\fIbmp\fR]]
.RE
.SH ENVIRONMENT
.TP
.B STEGASOO_CHANNEL_KEY
Channel key for encode/decode operations. Overrides config file settings.
.TP
.B STEGASOO_HTTPS_ENABLED
Enable HTTPS for web UI (Docker/service mode).
.TP
.B STEGASOO_HOSTNAME
Hostname for SSL certificate generation.
.SH FILES
.TP
.I ~/.stegasoo/channel.key
User's channel key configuration (encrypted).
.TP
.I .stegasoo.toml
Project-level configuration file.
.TP
.I frontends/web/instance/stegasoo.db
Web UI SQLite database (accounts, settings).
.SH EXAMPLES
.SS Basic encode/decode workflow
.nf
# Generate credentials
stegasoo generate
# Encode a secret message
stegasoo encode vacation.png -r selfie.jpg -m "Meet at noon"
# Decode the message (on another system with same reference photo)
stegasoo decode vacation_steg.png -r selfie.jpg
.fi
.SS Using channel keys for team isolation
.nf
# Generate and save a channel key
stegasoo channel generate --save-user
# Share the key with your team
stegasoo channel qr -o team-key.png
# Now all encode/decode operations use this channel
stegasoo encode photo.png -r ref.jpg -m "Team secret"
.fi
.SS Batch processing
.nf
# Check capacity of all PNGs in a directory
stegasoo batch check ./photos/*.png
# Encode same message into multiple images
stegasoo batch encode ./photos/ -r ref.jpg -m "Secret" -o ./encoded/
.fi
.SH SECURITY
Stegasoo uses multiple layers of security:
.IP \(bu 2
Reference photo provides a visual shared secret
.IP \(bu 2
Passphrase (recommend 4+ words) for strong encryption
.IP \(bu 2
PIN code adds additional entropy
.IP \(bu 2
Channel keys isolate different deployments
.IP \(bu 2
AES-256 encryption for payload data
.PP
For maximum security, share the reference photo out-of-band (in person,
secure messenger) and use a strong passphrase.
.SH SEE ALSO
.BR openssl (1),
.BR qrencode (1)
.SH BUGS
Report bugs at: https://github.com/adlee-was-taken/stegasoo/issues
.SH AUTHOR
Written by the Stegasoo contributors.
.SH COPYRIGHT
Copyright \(co 2024-2026. MIT License.

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"))
@@ -598,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
# ============================================================================
@@ -642,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,
@@ -661,6 +749,7 @@ def encode_cmd(
dct_output_format,
dct_color_mode,
quiet,
progress,
):
"""
Encode a secret message or file into an image.
@@ -808,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

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

View File

@@ -0,0 +1,75 @@
#!/bin/bash
#
# Docker entrypoint for Stegasoo Web UI
# Handles SSL certificate generation and gunicorn startup
#
# Supports mkcert for browser-trusted certificates (no warning screen)
#
set -e
CERT_DIR="/app/frontends/web/certs"
CERT_FILE="$CERT_DIR/cert.pem"
KEY_FILE="$CERT_DIR/key.pem"
HOSTNAME="${STEGASOO_HOSTNAME:-localhost}"
# Generate SSL certificates
# Priority: 1) Existing certs, 2) mkcert (trusted), 3) openssl (self-signed)
generate_certs() {
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then
echo "Using existing SSL certificates."
return
fi
mkdir -p "$CERT_DIR"
# Try mkcert first (creates browser-trusted certs)
if command -v mkcert &> /dev/null; then
echo "Generating trusted certificate with mkcert for $HOSTNAME..."
cd "$CERT_DIR"
mkcert -key-file key.pem -cert-file cert.pem "$HOSTNAME" localhost 127.0.0.1 ::1
echo "Trusted certificate generated."
echo ""
echo " To trust on other devices, install the CA cert from:"
echo " $(mkcert -CAROOT)/rootCA.pem"
echo ""
return
fi
# Fallback to self-signed (shows browser warning)
echo "Generating self-signed SSL certificate for $HOSTNAME..."
echo "(Install mkcert for browser-trusted certs without warnings)"
openssl req -x509 -newkey rsa:2048 \
-keyout "$KEY_FILE" \
-out "$CERT_FILE" \
-sha256 -days 365 -nodes \
-subj "/CN=$HOSTNAME" \
-addext "subjectAltName=DNS:$HOSTNAME,DNS:localhost,IP:127.0.0.1" \
2>/dev/null
echo "Self-signed certificate generated."
}
# Start gunicorn with appropriate settings
if [ "${STEGASOO_HTTPS_ENABLED:-false}" = "true" ]; then
echo "HTTPS mode enabled"
generate_certs
exec gunicorn \
--bind 0.0.0.0:5000 \
--workers 2 \
--threads 4 \
--timeout 120 \
--certfile "$CERT_FILE" \
--keyfile "$KEY_FILE" \
app:app
else
echo "HTTP mode (HTTPS disabled)"
exec gunicorn \
--bind 0.0.0.0:5000 \
--workers 2 \
--threads 4 \
--timeout 120 \
app:app
fi

View File

@@ -7,6 +7,7 @@ Uses cryptography library (already a dependency).
import datetime
import ipaddress
import socket
from pathlib import Path
from cryptography import x509
@@ -15,6 +16,33 @@ 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"
@@ -64,12 +92,26 @@ def generate_self_signed_cert(
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()

View File

@@ -231,20 +231,14 @@ const StegasooGenerate = {
printWindow.document.write(`<!DOCTYPE html>
<html>
<head>
<title>Stegasoo RSA Key QR Code</title>
<title>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; }
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
img { max-width: 400px; }
.warning { margin-top: 20px; padding: 10px; border: 2px solid #ff9800; background: #fff3e0; max-width: 400px; text-align: center; font-size: 12px; }
</style>
</head>
<body>
<h2>Stegasoo RSA Private Key</h2>
<img src="${qrImg.src}" alt="RSA Key QR Code">
<div class="warning">
<strong>Warning:</strong> This QR code contains your unencrypted RSA private key.
Store securely and destroy after use.
</div>
<img src="${qrImg.src}" alt="QR Code">
<script>window.onload = function() { window.print(); }<\/script>
</body>
</html>`);

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();
}
});
});
},
@@ -316,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;
}
}
},
@@ -575,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) {
@@ -584,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);
});
});
},
@@ -888,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();
@@ -909,27 +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;
const startTime = Date.now();
const updateTimer = () => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
const timeStr = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Encoding... ${timeStr}`;
};
updateTimer();
setInterval(updateTimer, 1000);
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting...';
}
// Use async submission with progress tracking
this.submitEncodeAsync(form, btn);
});
},
@@ -938,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();
@@ -950,28 +1587,56 @@ const Stegasoo = {
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;
const startTime = Date.now();
const updateTimer = () => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
const timeStr = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Decoding (${selectedMode.toUpperCase()})... ${timeStr}`;
};
updateTimer();
setInterval(updateTimer, 1000);
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting...';
}
// Use async submission with progress tracking
this.submitDecodeAsync(form, btn);
});
},

View File

@@ -16,7 +16,7 @@
--overlay-dark: rgba(0, 0, 0, 0.3);
--overlay-light: rgba(255, 255, 255, 0.05);
--day-highlight: #E3FF54; /* Bright yellow/green for day of week */
--header-gold: #fee862; /* Halfway between light straw and 24k gold */
--header-gold: #e5d058; /* Muted gold - less harsh on varied monitors */
}
/* ----------------------------------------------------------------------------
@@ -91,6 +91,56 @@
min-width: 0;
}
/* Compact inline mode buttons */
.mode-btn.mode-btn-sm {
padding: 0.35rem 0.6rem;
padding-left: 1.75rem;
font-size: 0.8rem;
border-radius: 0.375rem;
border-width: 1px;
}
.mode-btn.mode-btn-sm .form-check-input {
left: 8px;
width: 14px;
height: 14px;
}
.mode-btn.mode-btn-sm i {
font-size: 0.85rem;
}
/* Disabled button labels for btn-check groups */
.btn-check:disabled + .btn {
opacity: 0.4;
pointer-events: none;
}
/* ----------------------------------------------------------------------------
Form Labels - Gold
---------------------------------------------------------------------------- */
.card .form-label {
color: #d9c580;
font-weight: 400;
}
/* Dropdown selects - ensure chevron is visible in dark mode */
.form-select,
select.form-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23d9c580' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e") !important;
background-repeat: no-repeat !important;
background-position: right 0.75rem center !important;
background-size: 16px 12px !important;
padding-right: 2.25rem !important;
}
/* Payload type toggle - gold text when selected */
.btn-check:checked + .btn-outline-primary {
color: #d9c580 !important;
font-weight: 500;
}
/* ----------------------------------------------------------------------------
Security Factor Boxes - Matches drop-zone dashed border style
---------------------------------------------------------------------------- */
@@ -125,6 +175,122 @@ body {
.navbar {
background: var(--overlay-dark) !important;
backdrop-filter: blur(10px);
z-index: 1030; /* Above page content for dropdowns */
}
.navbar > .container {
padding-left: 0;
}
/* Ensure navbar dropdown appears above all page content */
.navbar .dropdown-menu {
z-index: 1031;
}
/* Left-align collapsed navbar menu on mobile */
@media (max-width: 991.98px) {
.navbar-collapse .navbar-nav {
align-items: flex-start !important;
}
}
/* ----------------------------------------------------------------------------
Nav Icons - Floating Label on Hover (label floats below, no layout shift)
---------------------------------------------------------------------------- */
.nav-icons {
gap: 0.25rem;
}
.nav-icons .nav-item {
position: relative;
}
.nav-expand {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem 0.75rem !important;
border-radius: 0.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: transparent;
position: relative;
}
.nav-expand i {
font-size: 1.15rem;
transition: all 0.3s ease;
}
/* Floating label - absolutely positioned below */
.nav-expand span {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%) translateY(-4px);
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
color: var(--header-gold);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
transition: opacity 0.2s ease,
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1040;
}
.nav-expand:hover {
background: linear-gradient(135deg, rgba(74, 40, 96, 0.25) 0%, rgba(85, 112, 212, 0.2) 100%);
box-shadow: 0 0 8px rgba(102, 126, 234, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.nav-expand:hover i {
color: var(--header-gold);
filter: drop-shadow(0 0 4px rgba(254, 232, 98, 0.4));
}
.nav-expand:hover span {
opacity: 1;
transform: translateX(-50%) translateY(2px);
}
/* Active state for current page */
.nav-expand.active,
.nav-item.active .nav-expand {
background: linear-gradient(135deg, rgba(74, 40, 96, 0.6) 0%, rgba(85, 112, 212, 0.5) 100%);
}
.nav-expand.active i,
.nav-item.active .nav-expand i {
color: var(--header-gold);
}
/* Mobile: Always show labels inline in collapsed menu */
@media (max-width: 991.98px) {
.nav-expand span {
position: static;
transform: none;
opacity: 1;
background: none;
box-shadow: none;
padding: 0;
margin-left: 0.5rem;
font-size: 0.9rem;
text-transform: none;
letter-spacing: normal;
pointer-events: auto;
}
.nav-expand:hover span {
transform: none;
}
.nav-expand:hover {
background: rgba(255, 255, 255, 0.1);
}
}
/* ----------------------------------------------------------------------------
@@ -893,36 +1059,36 @@ footer {
opacity: 0;
}
/* Color variants - 60% opacity */
/* Color variants - 70% opacity with tighter glow for thin lines */
.embed-trace.color-yellow {
background: rgba(212, 225, 87, 0.6);
box-shadow: 0 0 6px rgba(212, 225, 87, 0.5), 0 0 12px rgba(212, 225, 87, 0.3);
background: rgba(212, 225, 87, 0.7);
box-shadow: 0 0 3px rgba(212, 225, 87, 0.6), 0 0 6px rgba(212, 225, 87, 0.3);
}
.embed-trace.color-cyan {
background: rgba(0, 255, 170, 0.6);
box-shadow: 0 0 6px rgba(0, 255, 170, 0.5), 0 0 12px rgba(0, 255, 170, 0.3);
background: rgba(0, 255, 170, 0.7);
box-shadow: 0 0 3px rgba(0, 255, 170, 0.6), 0 0 6px rgba(0, 255, 170, 0.3);
}
.embed-trace.color-purple {
background: rgba(167, 139, 250, 0.6);
box-shadow: 0 0 6px rgba(167, 139, 250, 0.5), 0 0 12px rgba(167, 139, 250, 0.3);
background: rgba(167, 139, 250, 0.7);
box-shadow: 0 0 3px rgba(167, 139, 250, 0.6), 0 0 6px rgba(167, 139, 250, 0.3);
}
.embed-trace.color-blue {
background: rgba(102, 126, 234, 0.6);
box-shadow: 0 0 6px rgba(102, 126, 234, 0.5), 0 0 12px rgba(102, 126, 234, 0.3);
background: rgba(102, 126, 234, 0.7);
box-shadow: 0 0 3px rgba(102, 126, 234, 0.6), 0 0 6px rgba(102, 126, 234, 0.3);
}
/* Vertical segments shrink from top */
.embed-trace.v {
width: 2px;
width: 1px;
transform-origin: top center;
}
/* Horizontal segments shrink from left */
.embed-trace.h {
height: 2px;
height: 1px;
transform-origin: left center;
}
@@ -1094,7 +1260,8 @@ footer {
---------------------------------------------------------------------------- */
#rsaQrSection {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
}
#rsaQrSection .drop-zone {
@@ -1442,3 +1609,716 @@ footer {
padding: 0.35rem 0.75rem;
background: rgba(0, 0, 0, 0.1);
}
/* ============================================================================
MOBILE RESPONSIVE IMPROVEMENTS
============================================================================ */
/* Mobile-specific drop zone improvements */
@media (max-width: 768px) {
/* Larger drop zones on mobile for easier touch targets */
.drop-zone {
padding: 2rem 1.5rem;
min-height: 140px;
}
/* Larger touch target for upload icons */
.drop-zone-label i {
font-size: 2.5rem !important;
}
/* Touch feedback - active state */
.drop-zone:active {
border-color: var(--gradient-start);
background: rgba(102, 126, 234, 0.15);
transform: scale(0.98);
}
/* Mode buttons - stack vertically on very small screens */
.d-flex.gap-2:has(.mode-btn) {
flex-direction: column;
}
.mode-btn {
padding: 1rem;
min-height: 56px; /* iOS touch target minimum */
}
/* Full-width primary buttons */
.btn-primary.btn-lg {
padding: 1rem 1.5rem;
font-size: 1.1rem;
min-height: 56px;
}
/* Security factor boxes - more padding for touch */
.security-box {
padding: 1.25rem;
}
/* Form controls - larger for touch */
.form-control,
.form-select {
padding: 0.75rem 1rem;
font-size: 1rem;
min-height: 48px;
}
/* Input groups - consistent sizing */
.input-group .form-control {
min-height: 48px;
}
.input-group .btn {
min-width: 48px;
padding: 0.75rem;
}
/* Password toggle button - easier to tap */
[data-toggle-password] {
min-width: 52px;
}
/* PIN input - larger on mobile */
.pin-input-container .form-control {
font-size: 1.4rem;
letter-spacing: 4px;
padding: 0.875rem 1rem;
}
/* Passphrase input - comfortable mobile size */
.passphrase-input {
font-size: 1rem !important;
padding: 0.875rem 1rem !important;
}
/* Card headers - compact on mobile */
.card-header h5 {
font-size: 1.1rem;
}
/* Alert info panel - readable text */
.alert.small {
font-size: 0.9rem;
}
/* Bottom info icons - larger tap targets */
.row.text-center .col-4 {
padding: 0.5rem;
}
.row.text-center .col-4 i {
font-size: 2rem !important;
}
/* Capacity panel badges - easier to read */
#capacityPanel .badge {
font-size: 0.8rem;
padding: 0.4rem 0.6rem;
}
/* Payload type toggle - full width buttons */
.btn-group[role="group"] {
flex-direction: row;
}
.btn-group .btn {
padding: 0.75rem 0.5rem;
font-size: 0.95rem;
}
/* Textarea - comfortable height */
textarea.form-control {
min-height: 120px;
}
/* Channel select - full width */
#channelSelect {
font-size: 1rem;
}
}
/* Very small screens (iPhone SE, etc.) */
@media (max-width: 375px) {
.drop-zone {
padding: 1.5rem 1rem;
}
.mode-btn {
padding: 0.875rem;
font-size: 0.9rem;
}
.mode-btn .text-muted {
display: none; /* Hide secondary text on tiny screens */
}
.card-header h5 {
font-size: 1rem;
}
/* Stack security factor row */
.row:has(.security-box) > .col-md-6 {
margin-bottom: 1rem;
}
}
/* Touch device optimizations */
@media (hover: none) and (pointer: coarse) {
/* Remove hover effects that don't work on touch */
.btn-primary:hover {
transform: none;
}
.feature-card:hover {
transform: none;
}
.card-link:hover .feature-card {
transform: none;
}
/* Add active states instead */
.btn-primary:active {
transform: scale(0.98);
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.3);
}
.feature-card:active {
transform: scale(0.98);
}
/* Drop zone active feedback */
.drop-zone:active {
border-color: var(--gradient-start);
background: rgba(102, 126, 234, 0.1);
}
/* Mode button active state */
.mode-btn:active {
background: rgba(255, 255, 255, 0.12);
border-color: var(--gradient-start);
}
}
/* Camera hint for mobile - shows on file inputs */
@media (max-width: 768px) {
.drop-zone-label span.text-muted {
display: block;
}
/* Add camera icon hint on mobile */
.drop-zone-label::after {
content: "Tap to take photo or choose file";
display: block;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
margin-top: 0.5rem;
}
/* Hide the default text and show mobile version */
.drop-zone-label > span.text-muted {
display: none;
}
}
/* Navbar mobile adjustments */
@media (max-width: 768px) {
.navbar {
padding: 0.5rem 1rem;
}
.navbar-brand img {
height: 32px;
}
/* Sticky header shouldn't eat too much space */
.navbar.sticky-top {
position: relative; /* Don't stick on mobile - saves screen space */
}
}
/* Results page mobile adjustments */
@media (max-width: 768px) {
/* Download button - full width on mobile */
.btn-success.btn-lg,
a.btn-success.btn-lg {
width: 100%;
padding: 1rem;
font-size: 1.1rem;
}
/* QR codes - appropriate sizing */
.qr-scan-container {
max-width: 280px;
margin: 0 auto;
}
/* Message display - readable on mobile */
.alert-message {
font-size: 0.9rem;
padding: 1rem;
word-break: break-word;
}
/* Result icon - slightly smaller on mobile */
.result-icon {
font-size: 3rem;
}
}
/* ============================================================================
TOOLS PAGE - Office-style Ribbon + Two-Panel Layout
============================================================================ */
/* Icon Toolbar Ribbon - Purple/Blue Gradient Theme */
.tools-ribbon {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
border-bottom: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 0.5rem 0.5rem 0 0;
flex-wrap: wrap;
}
.tools-ribbon-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tools-ribbon-divider {
width: 2px;
height: 32px;
background: linear-gradient(180deg, rgba(102, 126, 234, 0.4) 0%, rgba(139, 92, 246, 0.4) 100%);
margin: 0 0.75rem;
border-radius: 1px;
}
/* Tool Icon Buttons */
.tool-icon-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 64px;
height: 52px;
padding: 0.25rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: all 0.2s ease;
}
.tool-icon-btn i {
font-size: 1.25rem;
margin-bottom: 2px;
}
.tool-icon-btn span {
font-size: 0.62rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
}
.tool-icon-btn:hover {
background: rgba(255, 230, 150, 0.1);
border-color: rgba(255, 230, 150, 0.3);
color: var(--header-gold);
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.33);
}
.tool-icon-btn.active {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.25) 0%, rgba(139, 92, 246, 0.25) 100%);
border-color: rgba(139, 92, 246, 0.5);
color: #c4b5fd;
box-shadow: 0 0 12px rgba(139, 92, 246, 0.2);
}
/* Two-Panel Layout */
.tools-panels {
display: flex;
min-height: 400px;
border-radius: 0 0 0.5rem 0.5rem;
}
/* Left Panel - Input/Dropzone */
.tools-panel-input {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.15);
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
/* Tool Mode Banner - bottom of input panel */
.tool-mode-banner {
margin-top: auto; /* Push to bottom */
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1.25rem;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(139, 92, 246, 0.2) 100%);
border-top: 1px solid rgba(139, 92, 246, 0.2);
font-size: 0.75rem;
}
.tool-mode-type {
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 3px;
background: rgba(139, 92, 246, 0.3);
color: #c4b5fd;
}
.tool-mode-banner.mode-analyze .tool-mode-type {
background: rgba(72, 187, 120, 0.3);
color: #9ae6b4;
}
.tool-mode-banner.mode-transform .tool-mode-type {
background: rgba(237, 181, 71, 0.3);
color: #fbd38d;
}
.tool-mode-name {
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
}
/* Right Panel - Results */
.tools-panel-results {
width: 280px;
min-width: 280px;
display: flex;
flex-direction: column;
padding: 1.25rem;
background: rgba(0, 0, 0, 0.25);
}
/* Tool Options Row */
.tool-options {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-wrap: wrap;
}
.tool-options:empty {
display: none;
}
.tool-options label {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 0;
}
.tool-options .form-select,
.tool-options .form-control {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.15);
font-size: 0.85rem;
padding: 0.4rem 0.75rem;
}
/* Tool Drop Zone */
.tool-dropzone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
border: 2px dashed rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
background: rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.tool-dropzone input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
z-index: 10;
}
.tool-dropzone-label {
text-align: center;
color: rgba(255, 255, 255, 0.5);
}
.tool-dropzone-label i {
font-size: 2.5rem;
margin-bottom: 0.75rem;
display: block;
opacity: 0.5;
}
.tool-dropzone.drag-over {
border-color: #63b3ed;
background: rgba(99, 179, 237, 0.1);
}
.tool-dropzone.drag-over .tool-dropzone-label i {
color: #63b3ed;
opacity: 1;
}
/* Dropzone with preview */
.tool-dropzone.has-file .tool-dropzone-label {
display: none;
}
.tool-dropzone-preview {
display: none;
width: 100%;
height: 100%;
padding: 1rem;
}
.tool-dropzone.has-file .tool-dropzone-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.tool-dropzone-preview img {
max-width: 100%;
max-height: 180px;
object-fit: contain;
border-radius: 0.375rem;
border: 2px solid rgba(99, 179, 237, 0.3);
}
/* Rotate preview - smooth transitions for size and transform */
#rotateThumb {
transition: transform 0.1s ease-out, width 0.1s ease-out, height 0.1s ease-out;
}
/* Rotate image container - fixed height to contain rotated images */
.rotate-img-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
}
/* Rotate file info - separate row below dropzone */
.rotate-file-info {
text-align: center;
padding: 0.5rem 0;
margin-top: 0.25rem;
}
.rotate-file-info .file-name {
font-size: 0.85rem;
color: #63b3ed;
font-weight: 500;
}
.rotate-file-info .file-meta {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.tool-dropzone-preview .file-name {
margin-top: 0.75rem;
font-size: 0.85rem;
color: #63b3ed;
font-weight: 500;
}
.tool-dropzone-preview .file-meta {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.tool-dropzone-clear {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 20;
opacity: 0.6;
}
.tool-dropzone-clear:hover {
opacity: 1;
}
/* Results Panel Content */
.tool-results-header {
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.tool-results-header h6 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #fff;
}
.tool-results-header small {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.tool-results-body {
flex: 1;
overflow-y: auto;
}
.tool-results-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: rgba(255, 255, 255, 0.3);
text-align: center;
}
.tool-results-empty i {
font-size: 2rem;
margin-bottom: 0.5rem;
}
/* Result Items */
.tool-result-item {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.tool-result-item:last-child {
border-bottom: none;
}
.tool-result-label {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.5);
}
.tool-result-value {
font-size: 0.9rem;
font-weight: 500;
color: #fff;
font-family: 'SF Mono', 'Consolas', monospace;
}
.tool-result-value.text-primary { color: #63b3ed !important; }
.tool-result-value.text-success { color: #48bb78 !important; }
.tool-result-value.text-warning { color: #edb547 !important; }
/* Results Actions */
.tool-results-actions {
margin-top: auto;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
gap: 0.5rem;
}
.tool-results-actions .btn {
flex: 1;
font-size: 0.85rem;
}
/* Tool Section Visibility */
.tool-section {
display: none;
width: 100%;
flex: 1;
padding: 1.25rem;
}
.tool-section.active {
display: flex;
flex-direction: column;
}
/* EXIF Table in Results */
.tool-exif-table {
font-size: 0.8rem;
max-height: 250px;
overflow-y: auto;
}
.tool-exif-table table {
width: 100%;
}
.tool-exif-table th,
.tool-exif-table td {
padding: 0.35rem 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.tool-exif-table th {
font-weight: 500;
color: rgba(255, 255, 255, 0.5);
text-align: left;
width: 40%;
}
.tool-exif-table td {
font-family: 'SF Mono', 'Consolas', monospace;
word-break: break-all;
}
/* Loading State */
.tool-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
z-index: 30;
border-radius: 0.5rem;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.tools-panels {
flex-direction: column;
}
.tools-panel-results {
width: 100%;
min-width: 100%;
border-right: none;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.tools-ribbon {
justify-content: center;
}
.tool-icon-btn {
width: 48px;
height: 44px;
}
.tool-icon-btn span {
display: none;
}
}

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

@@ -100,7 +100,7 @@
<li><strong>Output:</strong> JPEG or PNG</li>
<li><strong>Color:</strong> Color or grayscale</li>
<li><strong>Speed:</strong> ~2s</li>
<li><strong>Error Correction:</strong> Reed-Solomon <span class="badge bg-info ms-1">v4.1</span></li>
<li><strong>Error Correction:</strong> Reed-Solomon</li>
</ul>
<hr>
<div class="small">
@@ -271,8 +271,7 @@
<div class="card-body">
<p class="small mb-2">Uses server-configured key if available, otherwise public mode.</p>
<ul class="small mb-0">
<li>Set via <code>STEGASOO_CHANNEL_KEY</code> env var</li>
<li>Or <code>channel_key</code> in config file</li>
<li>Server admin configures the shared key</li>
<li>All users share the same channel</li>
</ul>
</div>
@@ -317,55 +316,18 @@
</div>
{% if channel_configured %}
<div class="alert alert-success mt-3 mb-3">
<div class="alert alert-success mt-3 mb-0">
<i class="bi bi-shield-lock me-2"></i>
<strong>This server has a channel key configured:</strong>
<code class="ms-2">{{ channel_fingerprint }}</code>
<span class="text-muted ms-2">({{ channel_source }})</span>
</div>
{% else %}
<div class="alert alert-info mt-3 mb-3">
<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>.
Set <code>STEGASOO_CHANNEL_KEY</code> to enable server-wide channel isolation.
</div>
{% endif %}
<!-- Channel Key QR Generator -->
<div class="card bg-dark border-secondary">
<div class="card-header">
<i class="bi bi-qr-code me-2"></i>Share Channel Key via QR
</div>
<div class="card-body">
<p class="small text-muted mb-3">Generate a QR code to share a channel key with others.</p>
<div class="row g-2 align-items-end">
<div class="col-md-8">
<label class="form-label small">Channel Key</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="channelKeyQrInput"
placeholder="Enter or generate a key">
<button class="btn btn-outline-secondary" type="button" id="channelKeyQrGenerate"
title="Generate random key">
<i class="bi bi-shuffle"></i>
</button>
</div>
</div>
<div class="col-md-4">
<button class="btn btn-primary w-100" type="button" id="channelKeyQrShow">
<i class="bi bi-qr-code me-1"></i>Show QR
</button>
</div>
</div>
<div class="text-center mt-3 d-none" id="channelKeyQrContainer">
<canvas id="channelKeyQrCanvas" class="bg-white p-2 rounded"></canvas>
<div class="mt-2">
<button class="btn btn-sm btn-outline-secondary" type="button" id="channelKeyQrDownload">
<i class="bi bi-download me-1"></i>Download PNG
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -375,56 +337,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.1.0</strong></td>
<td>
<strong>Reed-Solomon error correction</strong> for DCT mode (corrects up to 16 byte errors per 223-byte chunk),
majority voting on length headers, improved robustness with problematic carrier images
</td>
</tr>
<tr>
<td><strong>4.0.0</strong></td>
<td>
<strong>Channel keys</strong> for group/deployment isolation,
DCT default, simplified auth, passphrase replaces day_phrase,
4-word default, JPEG fix, large image support, subprocess isolation, Python 3.10-3.12
</td>
</tr>
<tr>
<td>3.2.0</td>
<td>Single passphrase, more default words</td>
</tr>
<tr>
<td>3.0.0</td>
<td>DCT mode, JPEG output, color preservation</td>
</tr>
<tr>
<td>2.2.0</td>
<td>QR code RSA key import/export</td>
</tr>
<tr>
<td>2.1.0</td>
<td>File embedding, compression</td>
</tr>
<tr>
<td>2.0.0</td>
<td>Web UI, REST API, RSA keys</td>
</tr>
<tr>
<td>1.0.0</td>
<td>Initial release, CLI only, LSB mode</td>
</tr>
</tbody>
</table>
<!-- 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>
@@ -553,9 +523,8 @@
<div class="col-6 col-md-4 col-lg-2 mb-3">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-bandaid text-info fs-3 d-block mb-2"></i>
<div class="small text-muted">Error Correction</div>
<strong>Reed-Solomon</strong>
<span class="badge bg-info ms-1">v4.1</span>
<div class="small text-muted">DCT ECC</div>
<strong>RS Code</strong>
</div>
</div>
</div>
@@ -625,62 +594,3 @@
</div>
{% endblock %}
{% block scripts %}
<!-- QR Code library for channel key sharing -->
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const input = document.getElementById('channelKeyQrInput');
const generateBtn = document.getElementById('channelKeyQrGenerate');
const showBtn = document.getElementById('channelKeyQrShow');
const container = document.getElementById('channelKeyQrContainer');
const canvas = document.getElementById('channelKeyQrCanvas');
const downloadBtn = document.getElementById('channelKeyQrDownload');
// Generate random key
generateBtn?.addEventListener('click', function() {
if (input && typeof Stegasoo !== 'undefined') {
input.value = Stegasoo.generateChannelKey();
}
});
// Show QR code
showBtn?.addEventListener('click', function() {
const key = input?.value?.trim().replace(/-/g, '');
if (!key || key.length !== 32) {
alert('Please enter a valid 32-character channel key');
return;
}
// Format key with dashes for QR
const formatted = key.match(/.{4}/g)?.join('-') || key;
// Generate QR code
if (typeof QRCode !== 'undefined' && canvas) {
QRCode.toCanvas(canvas, formatted, {
width: 200,
margin: 2,
color: { dark: '#000', light: '#fff' }
}, function(error) {
if (error) {
console.error('QR generation error:', error);
return;
}
container?.classList.remove('d-none');
});
}
});
// Download QR as PNG
downloadBtn?.addEventListener('click', function() {
if (canvas) {
const link = document.createElement('a');
link.download = 'stegasoo-channel-key.png';
link.href = canvas.toDataURL('image/png');
link.click();
}
});
});
</script>
{% endblock %}

View File

@@ -140,6 +140,13 @@
{% 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">
@@ -170,9 +177,16 @@
placeholder="Key name" required maxlength="50">
</div>
<div class="col-7">
<input type="text" name="channel_key" class="form-control form-control-sm font-monospace"
placeholder="Channel key (32 hex chars)" required
pattern="[0-9a-fA-F\-]{32,39}" title="32 hex characters">
<div 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">
@@ -218,17 +232,209 @@
</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,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

@@ -5,44 +5,46 @@
<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>
<nav class="navbar navbar-expand-lg navbar-dark">
<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 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>
<div class="container-fluid">
<a class="navbar-brand" href="/" style="padding-left: 6px; margin-right: 8px;">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="28">
</a>
{% if channel_configured %}
<span class="badge bg-success bg-opacity-25 small" style="padding-left: 0.35rem;" title="Private Channel: {{ channel_fingerprint }}">
<i class="bi bi-shield-lock me-2" style="color: #6ee7b7;"></i><code style="font-size: 0.7rem; font-weight: 300; color: #c9a860;">{{ channel_fingerprint[:4] }}-••••-{{ channel_fingerprint[-4:] }}</code>
</span>
{% else %}
<span class="badge bg-secondary bg-opacity-25 small text-muted" style="padding-left: 0.35rem;" title="Public Channel: No shared channel key configured. Messages use only passphrase and PIN for encryption.">
<i class="bi bi-globe me-1"></i>Public Channel
</span>
{% endif %}
<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" href="/encode"><i class="bi bi-lock me-1"></i> Encode</a>
<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="/decode"><i class="bi bi-unlock me-1"></i> Decode</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="/generate"><i class="bi bi-key me-1"></i> Generate</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="/about"><i class="bi bi-info-circle me-1"></i> About</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/tools"><i class="bi bi-tools me-1"></i> Tools</a>
<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 %}
@@ -54,6 +56,7 @@
<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>
@@ -96,11 +99,15 @@
<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));

View File

@@ -4,6 +4,79 @@
{% block content %}
<style>
/* Accordion styling */
.step-accordion .accordion-button {
background: rgba(35, 45, 55, 0.8);
color: #fff;
padding: 0.75rem 1rem;
border-left: 3px solid rgba(255, 230, 153, 0.3);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
transition: all 0.3s ease;
}
.step-accordion .accordion-button:hover {
background: rgba(45, 55, 65, 0.9);
border-left-color: rgba(255, 230, 153, 0.5);
}
.step-accordion .accordion-button:not(.collapsed) {
background: linear-gradient(90deg, rgba(255, 230, 153, 0.12) 0%, rgba(40, 50, 60, 0.85) 40%, rgba(40, 50, 60, 0.85) 100%);
color: #fff;
box-shadow: inset 0 1px 0 rgba(255, 230, 153, 0.1);
border-left: 3px solid #ffe699;
}
.step-accordion .accordion-button::after {
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
}
.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 +86,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,76 +105,13 @@
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;
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
}
.pin-input-container .form-control::placeholder {
color: rgba(246, 173, 85, 0.4);
letter-spacing: 1px;
}
/* QR Crop Animation */
.qr-crop-container {
position: relative;
overflow: hidden;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
}
.qr-crop-container img {
display: block;
max-height: 180px;
max-width: 180px;
width: auto;
margin: 0 auto;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.qr-crop-container .qr-original {
opacity: 1;
}
.qr-crop-container .qr-cropped {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.3);
opacity: 0;
max-height: 160px;
min-width: 140px;
min-height: 140px;
object-fit: contain;
}
.qr-crop-container.scan-complete .qr-original {
opacity: 0;
transform: scale(1.1);
filter: blur(4px);
}
.qr-crop-container.scan-complete .qr-cropped {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.qr-crop-container .crop-badge {
position: absolute;
bottom: 4px;
right: 4px;
font-size: 0.65rem;
opacity: 0;
transition: opacity 0.3s ease 0.4s;
}
.qr-crop-container.scan-complete .crop-badge {
opacity: 1;
}
/* QR Crop Animation - uses .qr-scan-container from style.css */
</style>
<div class="row justify-content-center">
@@ -113,13 +120,13 @@
<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: <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'))"
@@ -129,13 +136,13 @@
<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>
@@ -144,345 +151,262 @@
<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-6 mb-3">
<div class="security-box h-100">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group 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>
<!-- ================================================================
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-6 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.1</span>
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
</label>
<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>
{% if saved_channel_keys %}
<optgroup label="Saved Keys">
{% for key in saved_channel_keys %}
<option value="{{ key.channel_key }}" data-key-id="{{ key.id }}">{{ key.name }} ({{ key.channel_key[:4] }}...)</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="custom">Custom...</option>
</select>
<!-- Server channel indicator (compact) -->
<div class="small text-success mt-2 {% if not channel_configured %}d-none{% endif %}" id="channelServerInfoDec" data-fingerprint="{{ (channel_fingerprint[:4] if channel_fingerprint else '') }}-••••-···-••••-{{ channel_fingerprint[-4:] if channel_fingerprint else '' }}">
{% if channel_configured and channel_fingerprint %}
<i class="bi bi-shield-lock me-1"></i>
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
{% endif %}
</div>
</div>
</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">
<input class="form-check-input" type="radio" name="embed_mode" id="modeAuto" value="auto" checked>
<i class="bi bi-magic text-success"></i>
<span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· Try both</span></span>
</label>
<!-- LSB Mode -->
<label class="mode-btn flex-fill" id="lsbModeCardDec" for="modeLsbDec">
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsbDec" value="lsb">
<i class="bi bi-grid-3x3-gap text-primary"></i>
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Spatial</span></span>
</label>
<!-- DCT Mode -->
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %}" id="dctModeCardDec" for="modeDctDec">
<input class="form-check-input" type="radio" name="embed_mode" id="modeDctDec" value="dct" {% if not has_dct %}disabled{% endif %}>
<i class="bi bi-soundwave text-warning"></i>
<span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Frequency</span></span>
</label>
<div 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>
<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 %}
<!-- Extraction Mode -->
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
<label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label>
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb">
<label class="btn btn-outline-secondary text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
<label class="btn btn-outline-secondary text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
</div>
</div>
<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 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>
@@ -495,28 +419,106 @@
{% 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
// ============================================================================
// Apply disabled styling to DCT if not available
if (document.getElementById('modeDct')?.disabled) {
document.getElementById('dctModeLabel')?.classList.add('disabled', 'text-muted');
}
// ============================================================================
// LOADING STATE
// ============================================================================
Stegasoo.initFormLoading('decodeForm', 'decodeBtn', 'Decoding...');
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -100,8 +100,8 @@
<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 class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn" title="Generate Channel Key">
<i class="bi bi-shuffle"></i>
</button>
<button class="btn btn-outline-secondary" type="button" id="copyChannelKeyBtn" disabled title="Copy to clipboard">
<i class="bi bi-clipboard"></i>
@@ -483,17 +483,17 @@
/* Responsive */
@media (max-width: 576px) {
.pin-container, .passphrase-container {
padding: 1rem 1.25rem;
padding: 1rem 0.75rem;
}
.pin-digit-box {
width: 2.25rem;
height: 2.75rem;
font-size: 1.25rem;
width: 1.9rem;
height: 2.4rem;
font-size: 1.15rem;
}
.pin-digits-row {
gap: 0.35rem;
gap: 0.25rem;
}
.passphrase-text {

View File

@@ -3,170 +3,64 @@
{% block title %}Stegasoo - Secure Steganography{% endblock %}
{% block content %}
<style>
.home-icon {
display: inline-flex;
flex-direction: column;
align-items: center;
padding: 1rem 1.5rem;
text-decoration: none;
transition: all 0.15s ease;
}
.home-icon i {
font-size: 2.5rem;
color: #fff;
margin-bottom: 0.5rem;
filter: drop-shadow(0 3px 2px rgba(0, 0, 0, 0.9));
transition: all 0.15s ease;
}
.home-icon span {
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.5);
opacity: 0;
transform: translateY(-8px);
transition: all 0.15s ease;
}
.home-icon:hover i {
color: #e5d058;
transform: translateY(-3px);
filter: drop-shadow(0 5px 4px rgba(0, 0, 0, 0.8));
}
.home-icon:hover span {
opacity: 1;
transform: translateY(0);
color: #e5d058;
}
</style>
<div class="row mb-4">
<div class="col-12">
<div class="d-flex align-items-end justify-content-center gap-4">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="155">
<div style="margin-bottom: 40px;">
<h1 class="display-4 fw-bold mb-2 title-gold">
Stegasoo
<span class="badge bg-success fs-6 ms-2">v4.1</span>
</h1>
<p class="lead text-muted mb-0">Hide encrypted data in plain sight.</p>
</div>
<div class="d-flex flex-column align-items-center justify-content-center" style="min-height: 70vh;">
<!-- Hero -->
<div class="d-flex align-items-center mb-4" style="gap: 8px;">
<div class="position-relative">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="80">
<span class="badge bg-success position-absolute" style="bottom: 1px; left: -6px; font-size: 0.6rem;">v4.1</span>
</div>
</div>
</div>
<!-- Channel Status Banner (v4.0.0) -->
{% if channel_configured %}
<div class="alert alert-success mb-4">
<div class="d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-shield-lock me-2"></i>
<strong>Private Channel Mode</strong>
</div>
<div class="key-capsule">
<span class="badge led-badge-yellow"><span class="led-indicator led-yellow me-1"></span>Key Loaded</span>
<code class="small ms-2">{{ channel_fingerprint }}</code>
<h1 class="display-5 fw-bold title-gold mb-0">Stegasoo</h1>
<p class="text-muted mb-0 small" style="margin-top: 3px; padding-left: 3px; font-size: 0.85rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);">Hide encrypted data in plain sight.</p>
</div>
</div>
</div>
{% endif %}
<div class="row g-4 mb-5">
<!-- Encode Card -->
<div class="col-md-4">
<a href="/encode" class="text-decoration-none card-link">
<div class="card h-100 feature-card">
<div class="card-header text-center py-3">
<i class="bi bi-lock-fill fs-1 embossed-icon"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Encode</h5>
<p class="card-text text-muted">
Hide encrypted messages or files inside images
</p>
</div>
</div>
</a>
<!-- Action Icons -->
<div class="d-flex gap-4">
<a href="/encode" class="home-icon"><i class="bi bi-lock-fill"></i><span>Encode</span></a>
<a href="/decode" class="home-icon"><i class="bi bi-unlock-fill"></i><span>Decode</span></a>
<a href="/generate" class="home-icon"><i class="bi bi-key-fill"></i><span>Generate</span></a>
</div>
<!-- Decode Card -->
<div class="col-md-4">
<a href="/decode" class="text-decoration-none card-link">
<div class="card h-100 feature-card">
<div class="card-header text-center py-3">
<i class="bi bi-unlock-fill fs-1 embossed-icon"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Decode</h5>
<p class="card-text text-muted">
Extract and decrypt hidden data from stego images
</p>
</div>
</div>
</a>
</div>
<!-- Generate Card -->
<div class="col-md-4">
<a href="/generate" class="text-decoration-none card-link">
<div class="card h-100 feature-card">
<div class="card-header text-center py-3">
<i class="bi bi-key-fill fs-1 embossed-icon"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Generate</h5>
<p class="card-text text-muted">
Create passphrases, PINs, and RSA keys
</p>
</div>
</div>
</a>
</div>
</div>
<!-- Embedding Modes -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-cpu me-2"></i>Embedding Modes</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-md-6 mb-3 mb-md-0">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-soundwave text-warning fs-2 d-block mb-2"></i>
<strong>DCT Mode</strong>
<span class="badge bg-success ms-1">Default</span>
<div class="small text-muted mt-2">
Survives JPEG recompression<br>
Best for social media
</div>
</div>
</div>
<div class="col-md-6">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-grid-3x3-gap text-primary fs-2 d-block mb-2"></i>
<strong>LSB Mode</strong>
<div class="small text-muted mt-2">
Higher capacity (~375 KB/MP)<br>
Best for email &amp; file transfer
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-diagram-3 me-2"></i>How It Works</h5>
<a href="/about" class="btn btn-sm btn-outline-light">Learn More</a>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary"><i class="bi bi-key me-2"></i>You Provide</h6>
<ul class="list-unstyled small">
<li class="mb-1">
<i class="bi bi-image text-info me-2"></i>
<strong>Reference Photo</strong>: shared secret
</li>
<li class="mb-1">
<i class="bi bi-chat-quote text-info me-2"></i>
<strong>Passphrase</strong>: 4+ words
</li>
<li class="mb-1">
<i class="bi bi-123 text-info me-2"></i>
<strong>PIN</strong>: 6-9 digits (or RSA key)
</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-primary"><i class="bi bi-shield-check me-2"></i>Security</h6>
<ul class="list-unstyled small">
<li class="mb-1">
<i class="bi bi-lock text-success me-2"></i>
AES-256-GCM encryption
</li>
<li class="mb-1">
<i class="bi bi-memory text-success me-2"></i>
Argon2id key derivation (256MB)
</li>
<li class="mb-1">
<i class="bi bi-shuffle text-success me-2"></i>
Pseudo-random embedding
</li>
<li class="mb-1">
<i class="bi bi-broadcast text-success me-2"></i>
<strong>Channel keys</strong> for group isolation
<span class="badge bg-info ms-1">v4.1</span>
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "stegasoo"
version = "4.1.1"
version = "4.1.5"
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
readme = "README.md"
license = "MIT"
@@ -54,6 +54,7 @@ cli = [
"click>=8.0.0",
"qrcode>=7.30",
"piexif>=1.1.0",
"rich>=13.0.0",
]
compression = [
"lz4>=4.0.0",

View File

@@ -1,5 +1,8 @@
# 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
@@ -26,25 +29,48 @@ ssh admin@stegasoo.local
# Take ownership of /opt (for pyenv, jpegio builds)
sudo chown admin:admin /opt
# Install git (not included in Lite image)
sudo apt-get update && sudo apt-get install -y git
# Install git and zstd (not included in Lite image)
sudo apt-get update && sudo apt-get install -y git zstd jq
```
## Step 4: Clone & Run Setup
## Step 4: Clone Repo
```bash
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git
cd stegasoo
./rpi/setup.sh
cd /opt
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
```
This takes ~15-20 minutes and installs:
- Python 3.12 via pyenv
- jpegio (patched for ARM)
- Stegasoo with web UI
- Systemd service
## Step 5: Copy Pre-built Tarball (from host)
## Step 5: Test It Works
> **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
@@ -52,7 +78,7 @@ curl -k https://localhost:5000
# Should return HTML
```
## Step 6: Sanitize for Distribution
## Step 8: Sanitize for Distribution
```bash
# Full sanitize (for final image - removes WiFi, shuts down)
@@ -72,7 +98,7 @@ This removes:
The script validates all cleanup steps before finishing.
## Step 7: Copy the Image
## Step 9: Pull the Image
Remove SD card, insert into your Linux machine:
@@ -80,32 +106,60 @@ Remove SD card, insert into your Linux machine:
# Find the SD card device (CAREFUL!)
lsblk
# Copy (replace sdX with actual device, e.g., sda)
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
# Pull image (auto-resizes to 16GB, compresses with zstd)
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
```
## Step 8: Shrink & Compress
The script automatically resizes rootfs to 16GB (for smaller download), preserves auto-expand, and compresses.
```bash
# Optional: Shrink image (saves space)
wget https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh
chmod +x pishrink.sh
sudo ./pishrink.sh stegasoo-rpi-*.img
# Compress (zstd is faster than xz with similar ratio)
zstd -19 -T0 stegasoo-rpi-*.img
```
## Step 9: Distribute
## Step 10: Distribute
Upload `.img.zst` to GitHub Releases.
Users can flash with:
```bash
# Linux
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
# Option 1: rpi-imager CLI (supports .zst.zip directly)
sudo rpi-imager --cli --disable-verify stegasoo-rpi-*.img.zst.zip /dev/sdX
# Or use rpi-imager "Use custom" option
# 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
```
---
@@ -115,14 +169,18 @@ zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
```bash
# On Pi (after SSH):
sudo chown admin:admin /opt
sudo apt-get update && sudo apt-get install -y git
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git
cd stegasoo && ./rpi/setup.sh
sudo 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 your machine:
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
zstd -19 -T0 stegasoo-rpi-*.img
# On host (pull image - auto-resizes to 16GB):
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
```

View File

@@ -12,8 +12,9 @@ sudo chown $USER:$USER /opt
sudo apt-get update && sudo apt-get install -y git
# Clone and run setup
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git /opt/stegasoo
cd /opt/stegasoo
cd /opt
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
cd stegasoo
./rpi/setup.sh
```
@@ -31,7 +32,7 @@ cd /opt/stegasoo
- Raspberry Pi 4 or 5
- Raspberry Pi OS Lite (64-bit) - Bookworm or later
- 4GB+ RAM recommended (2GB minimum)
- ~2GB free disk space
- 16GB+ SD card (pre-built images are 16GB)
- Internet connection
### Performance
@@ -48,6 +49,25 @@ If using a pre-built image from GitHub Releases:
> **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
@@ -138,8 +158,9 @@ sudo chown admin:admin /opt
sudo apt-get update && sudo apt-get install -y git
# Clone and run setup
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git /opt/stegasoo
cd /opt/stegasoo
cd /opt
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
cd stegasoo
./rpi/setup.sh
```
@@ -178,25 +199,27 @@ After Pi shuts down, remove SD card and on another Linux machine:
# Find SD card device (BE CAREFUL - wrong device = data loss!)
lsblk
# Copy (replace sdX with your SD card)
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
# Shrink the image (optional but recommended)
wget https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh
chmod +x pishrink.sh
sudo ./pishrink.sh stegasoo-rpi-*.img
# Compress (zstd is faster than xz with similar compression)
zstd -19 -T0 stegasoo-rpi-*.img
# 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 (for smaller download)
- Preserves auto-expand (image fills SD card on first boot)
- 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
```
Or use rpi-imager's "Use custom" option.

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
}

89
rpi/build-runtime-tarball.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/bin/bash
#
# Build Stegasoo Pi Runtime Environment Tarball
# Run this ON THE PI after a successful from-source build
#
# Creates: stegasoo-rpi-runtime-env-arm64.tar.zst (~50-60MB)
# Contains: pyenv + Python 3.12 + venv with all dependencies
#
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
OUTPUT_DIR="${OUTPUT_DIR:-/tmp}"
OUTPUT_FILE="$OUTPUT_DIR/stegasoo-rpi-runtime-env-arm64.tar.zst"
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Stegasoo Pi Runtime Tarball Builder ║${NC}"
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
# Verify we're on ARM64
ARCH=$(uname -m)
if [[ "$ARCH" != "aarch64" ]]; then
echo -e "${RED}Error: This script must be run on ARM64 (aarch64)${NC}"
echo "Current architecture: $ARCH"
exit 1
fi
# Verify pyenv exists
if [[ ! -d "$HOME/.pyenv" ]]; then
echo -e "${RED}Error: pyenv not found at ~/.pyenv${NC}"
echo "Run a from-source build first: ./rpi/setup.sh --no-prebuilt"
exit 1
fi
# Verify venv exists
if [[ ! -d "$INSTALL_DIR/venv" ]]; then
echo -e "${RED}Error: venv not found at $INSTALL_DIR/venv${NC}"
echo "Run a from-source build first: ./rpi/setup.sh --no-prebuilt"
exit 1
fi
# Step 1: Clean caches from venv
echo -e "${GREEN}[1/4]${NC} Cleaning caches from venv..."
VENV_SIZE_BEFORE=$(du -sh "$INSTALL_DIR/venv" | cut -f1)
find "$INSTALL_DIR/venv/" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "$INSTALL_DIR/venv/" -type d -name 'tests' -exec rm -rf {} + 2>/dev/null || true
find "$INSTALL_DIR/venv/" -type d -name 'test' -exec rm -rf {} + 2>/dev/null || true
find "$INSTALL_DIR/venv/" -type f -name '*.pyc' -delete 2>/dev/null || true
VENV_SIZE_AFTER=$(du -sh "$INSTALL_DIR/venv" | cut -f1)
echo " venv: $VENV_SIZE_BEFORE$VENV_SIZE_AFTER"
# Step 2: Create venv tarball
echo -e "${GREEN}[2/4]${NC} Creating venv tarball..."
cd "$INSTALL_DIR"
tar -cf - venv/ | zstd -19 -T0 > "$HOME/stegasoo-venv.tar.zst"
VENV_TAR_SIZE=$(ls -lh "$HOME/stegasoo-venv.tar.zst" | awk '{print $5}')
echo " Created: ~/stegasoo-venv.tar.zst ($VENV_TAR_SIZE)"
# Step 3: Create combined tarball
echo -e "${GREEN}[3/4]${NC} Creating combined runtime tarball..."
cd "$HOME"
tar -cf - .pyenv stegasoo-venv.tar.zst | zstd -19 -T0 > "$OUTPUT_FILE"
# Cleanup intermediate file
rm "$HOME/stegasoo-venv.tar.zst"
# Step 4: Summary
FINAL_SIZE=$(ls -lh "$OUTPUT_FILE" | awk '{print $5}')
echo -e "${GREEN}[4/4]${NC} Done!"
echo ""
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
echo -e " Output: ${YELLOW}$OUTPUT_FILE${NC}"
echo -e " Size: ${YELLOW}$FINAL_SIZE${NC}"
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
echo ""
echo "To pull to your host machine:"
echo " scp $(whoami)@$(hostname).local:$OUTPUT_FILE ./"
echo ""
echo "To use in setup.sh, copy to:"
echo " rpi/stegasoo-rpi-runtime-env-arm64.tar.zst"
echo ""
echo "Or upload to GitHub releases for automatic download."

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
}

View File

@@ -14,6 +14,10 @@
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
@@ -39,29 +43,58 @@ clear
# Welcome
# =============================================================================
gum style \
--border double \
--border-foreground 212 \
--padding "1 2" \
--margin "1" \
--align center \
" . * . . * . * . * . * ." \
" ___ _____ ___ ___ _ ___ ___ ___ " \
" / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\" \
" \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |" \
" |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/" \
"" \
" * . * . * . * . * . *" \
"" \
"First Boot Wizard"
print_banner "First Boot Wizard"
echo ""
gum style --foreground 245 "This wizard will help you configure your Stegasoo server."
gum style --foreground 245 "You can reconfigure later by editing /etc/systemd/system/stegasoo.service"
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
# =============================================================================
# Step 0: Expand Filesystem
# =============================================================================
clear
gum style \
--foreground 212 --bold \
"Step 0: Expand Filesystem"
echo ""
# Get current and total size
ROOT_DEV=$(findmnt -n -o SOURCE /)
CURRENT_SIZE=$(df -h / | awk 'NR==2 {print $2}')
TOTAL_SIZE=$(lsblk -b -d -o SIZE $(echo "$ROOT_DEV" | sed 's/[0-9]*$//') 2>/dev/null | tail -1 | awk '{printf "%.0fG", $1/1024/1024/1024}')
gum style --foreground 245 "\
The filesystem is currently $CURRENT_SIZE but your SD card may be larger.
Expanding will use all available space on the SD card."
echo ""
gum style --foreground 245 "Current: $CURRENT_SIZE"
echo ""
if gum confirm "Expand filesystem to fill SD card?" --default=true; then
# Get the disk device (strip partition number) and partition number
DISK_DEV=$(echo "$ROOT_DEV" | sed 's/p\?[0-9]*$//')
PART_NUM=$(echo "$ROOT_DEV" | grep -o '[0-9]*$')
echo ""
gum style --foreground 245 "Expanding partition..."
sudo growpart "$DISK_DEV" "$PART_NUM" 2>&1 || true
gum style --foreground 245 "Expanding filesystem..."
sudo resize2fs "$ROOT_DEV" 2>&1
NEW_SIZE=$(df -h / | awk 'NR==2 {print $2}')
echo ""
gum style --foreground 82 "✓ Expanded to: $NEW_SIZE"
else
gum style --foreground 214 "→ Skipped (run 'sudo growpart /dev/sdX 2 && sudo resize2fs /dev/sdX2' later)"
fi
sleep 1
# =============================================================================
# Configuration Variables
# =============================================================================
@@ -146,52 +179,100 @@ 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_CHOICE=$(gum choose \
"Skip (public mode)" \
"Generate new key" \
"Enter existing key")
CHANNEL_KEY=$(cat "$KEY_FILE" 2>/dev/null | head -1)
KEY_ERROR=$(cat "$ERR_FILE" 2>/dev/null)
rm -f "$KEY_FILE" "$ERR_FILE"
case "$CHANNEL_CHOICE" in
"Generate new key")
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'"
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
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 245 "Error details:"
echo "$KEY_ERROR"
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
CHANNEL_KEY=""
;;
"Enter existing key")
echo ""
gum confirm "Continue" --default=true --affirmative="OK" --negative=""
fi
else
gum style --foreground 214 "→ Using public mode"
sleep 0.5
fi
gum style --foreground 245 "Enter the channel key from your team/deployment."
gum style --foreground 245 "Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
echo ""
while true; do
ENTERED_KEY=$(gum input --placeholder "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456" --width 50)
if [ -z "$ENTERED_KEY" ]; then
gum style --foreground 214 "→ Cancelled, using public mode"
CHANNEL_KEY=""
break
fi
# Validate the key using Python
VENV_PYTHON="$INSTALL_DIR/venv/bin/python"
if "$VENV_PYTHON" -c "from stegasoo.channel import validate_channel_key, format_channel_key; k='$ENTERED_KEY'; exit(0 if validate_channel_key(k) else 1)" 2>/dev/null; then
# Get formatted key
CHANNEL_KEY=$("$VENV_PYTHON" -c "from stegasoo.channel import format_channel_key; print(format_channel_key('$ENTERED_KEY'))" 2>/dev/null)
echo ""
gum style --foreground 82 "✓ Channel key accepted!"
gum style --foreground 245 "Key: $CHANNEL_KEY"
break
else
echo ""
gum style --foreground 196 "Invalid key format. Please check and try again."
gum style --foreground 245 "Expected: 32 alphanumeric characters (with or without dashes)"
echo ""
if ! gum confirm "Try again?" --default=true; then
gum style --foreground 214 "→ Using public mode"
CHANNEL_KEY=""
break
fi
fi
done
;;
*)
gum style --foreground 214 "→ Using public mode"
CHANNEL_KEY=""
sleep 0.5
;;
esac
# =============================================================================
# Step 4: Overclock Configuration
@@ -283,6 +364,32 @@ 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 "
@@ -384,72 +491,38 @@ else
ACCESS_URL_LOCAL="http://$HOSTNAME.local:5000/setup"
fi
gum style \
--border double \
--border-foreground 82 \
--padding "1 2" \
--margin "1" \
--align center \
" . * . . * . * . * . * ." \
" ___ _____ ___ ___ _ ___ ___ ___" \
" / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\" \
" \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |" \
" |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/" \
"" \
" * . * . * . * . * . *" \
"" \
"Setup Complete!"
echo ""
print_complete_banner "Setup Complete!"
echo ""
gum style --foreground 82 --bold "Create your admin account:"
gum style --foreground 226 " $ACCESS_URL"
gum style --foreground 245 " $ACCESS_URL_LOCAL (if mDNS works)"
echo ""
gum style --foreground 226 " $ACCESS_URL_LOCAL"
gum style --foreground 245 " $ACCESS_URL (fallback IP)"
if [ -n "$CHANNEL_KEY" ]; then
gum style --foreground 82 --bold "Channel Key:"
gum style --foreground 226 " $CHANNEL_KEY"
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 the URL above in your browser" \
" 2. Accept the security warning (self-signed cert)" \
" 3. Create your admin account" \
" 4. Start encoding secret messages!"
echo ""
gum style --foreground 255 " 1. Open URL → 2. Accept cert → 3. Create admin → 4. Encode!"
gum style --foreground 82 --bold "Useful Commands:"
gum style --foreground 245 \
" sudo systemctl status stegasoo # Check status" \
" sudo systemctl restart stegasoo # Restart" \
" journalctl -u stegasoo -f # View logs"
echo ""
gum style --foreground 212 --bold "Enjoy Stegasoo!"
echo ""
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 \
--border rounded \
--border-foreground 226 \
--padding "1 2" \
--foreground 226 \
"Restart Required" \
"" \
"Overclock settings require a restart to take effect."
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 "Remember to restart later for overclock to take effect:"
gum style --foreground 245 " sudo reboot"
echo ""
gum style --foreground 214 "Run 'sudo reboot' later to apply overclock."
fi
fi
echo ""
gum style --foreground 212 --bold "Enjoy Stegasoo!"
echo ""

View File

@@ -1,16 +1,21 @@
#!/bin/bash
#
# Flash Stegasoo image to SD card
# Auto-detects SD card, decompresses and writes with progress
# Uses rpi-imager if available, falls back to dd
#
# Usage: ./flash-image.sh <image.img.xz> [device]
# ./flash-image.sh <image.img> [device]
# Usage: ./flash-image.sh <image> [device]
#
# If device is specified, skips auto-detection (useful for large drives)
# 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'
@@ -18,14 +23,50 @@ 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 pv lsblk; do
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}"
@@ -34,11 +75,14 @@ fi
# Check for image argument
if [ -z "$1" ]; then
echo -e "${RED}Usage: $0 <image.img.xz|image.img> [device]${NC}"
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-20260103.img.xz # auto-detect SD card"
echo " $0 stegasoo-rpi-20260103.img.xz /dev/sdb # specify device"
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
@@ -50,6 +94,25 @@ if [ ! -f "$IMAGE" ]; then
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=""
@@ -186,6 +249,10 @@ if [ -n "$MOUNTED" ]; then
done
fi
# Ask about wiping (defer actual wipe until after final confirmation)
echo
read -p "Wipe partition table first? (recommended if having issues) [y/N] " wipe_confirm
# Final confirmation
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${RED}║ WARNING: ALL DATA ON THIS DEVICE WILL BE DESTROYED! ║${NC}"
@@ -198,24 +265,151 @@ if [[ ! $REPLY == "yes" ]]; then
exit 1
fi
# Now wipe if requested
if [[ "$wipe_confirm" =~ ^[Yy]$ ]]; then
echo "Wiping partition table..."
sudo wipefs -af "$SELECTED" 2>/dev/null || true
sync
echo " Wiped"
fi
echo ""
echo -e "${GREEN}Flashing image to $SELECTED...${NC}"
echo ""
# Flash with dd (status=progress shows actual write progress)
echo -e "${YELLOW}Flashing (this may take several minutes for SD cards)...${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 ;;
xz) xzcat "$IMAGE" | sudo dd of="$SELECTED" bs=1M status=progress ;;
zst) zstdcat "$IMAGE" | sudo dd of="$SELECTED" bs=1M status=progress ;;
gz) zcat "$IMAGE" | sudo dd of="$SELECTED" bs=1M status=progress ;;
esac
else
pv "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null
sudo dd if="$IMAGE" of="$SELECTED" bs=1M status=progress
fi
echo ""
echo -e "${GREEN}Syncing...${NC}"
sync
# Wait for partitions to appear
sleep 2
partprobe "$SELECTED" 2>/dev/null || true
sleep 1
# Determine partition names
if [[ "$SELECTED" == *"nvme"* ]] || [[ "$SELECTED" == *"mmcblk"* ]]; then
BOOT_PART="${SELECTED}p1"
ROOT_PART="${SELECTED}p2"
else
BOOT_PART="${SELECTED}1"
ROOT_PART="${SELECTED}2"
fi
# Validate and repair filesystems
echo ""
echo -e "${YELLOW}Validating filesystems...${NC}"
echo " Checking boot partition ($BOOT_PART)..."
sudo fsck.vfat -a "$BOOT_PART" 2>&1 | grep -v "^$" || true
echo " Checking root partition ($ROOT_PART)..."
sudo e2fsck -f -y "$ROOT_PART" 2>&1 | tail -5 || true
echo -e "${GREEN} ✓ Filesystems validated${NC}"
# Inject WiFi config if config.json was loaded
if [ "$HAS_CONFIG" = true ]; then
echo ""
echo -e "${GREEN}Configuring WiFi from config.json...${NC}"
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}"
@@ -223,5 +417,11 @@ echo -e "${GREEN}╚════════════════════
echo ""
echo -e "You can now remove the SD card and boot your Raspberry Pi."
echo ""
echo -e "${YELLOW}Tip:${NC} On first boot, SSH in and the setup wizard will run automatically."
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}"

View File

@@ -21,6 +21,12 @@ cd "$JPEGIO_DIR"
echo "Applying ARM64 patch to jpegio..."
# Fix CRLF line endings (jpegio uses Windows line endings)
if file setup.py | grep -q CRLF; then
echo " Converting CRLF to LF..."
sed -i 's/\r$//' setup.py
fi
# Strategy 1: Try the standard patch file
if [ -f "$PATCH_FILE" ]; then
echo " Trying patch file..."

View File

@@ -1,6 +1,6 @@
--- a/setup.py
+++ b/setup.py
@@ -69,12 +69,12 @@
@@ -64,7 +64,7 @@ elif sys.platform == 'darwin': # macOS
largs.append('-mmacosx-version-min=10.9')
if arch == 'x64':
@@ -9,6 +9,9 @@
elif sys.platform == 'linux':
cargs.extend(['-w', '-fPIC'])
@@ -68,7 +68,7 @@ elif sys.platform == 'linux':
cargs.extend(['-w', '-fPIC'])
if arch == 'x64':
- cargs.append('-m64')
+ pass # ARM64: removed x86-specific -m64 flag

View File

@@ -1,31 +1,26 @@
#!/bin/bash
# Pull Raspberry Pi image from SD card (after setup)
# Resizes rootfs to 16GB for consistent image size, then pulls
#
# Pull Stegasoo image from SD card
# Auto-detects SD card, copies with progress, shrinks, and compresses
#
# Usage: ./pull-image.sh [output-name] [device]
# Output will be: stegasoo-rpi-YYYYMMDD.img.zst (or custom name)
# Use .img extension to skip compression: ./pull-image.sh foo.img
#
# If device is specified, skips auto-detection (useful for large drives)
#
# Usage: ./pull-image.sh <device> <output.img.zst>
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.1.5.img.zst
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m'
# Check for required tools
for cmd in dd pv zstd lsblk; do
if ! command -v $cmd &> /dev/null; then
echo -e "${RED}Error: $cmd is required but not installed.${NC}"
exit 1
fi
done
if [ $# -ne 2 ]; then
echo "Usage: $0 <device> <output.img.zst>"
echo "Example: $0 /dev/sdb stegasoo-rpi-4.1.5.img.zst"
exit 1
fi
DEVICE="$1"
OUTPUT="$2"
# Check for root
if [ "$EUID" -ne 0 ]; then
@@ -33,175 +28,166 @@ if [ "$EUID" -ne 0 ]; then
exit 1
fi
# Output filename and optional device
if [ -n "$1" ]; then
OUTPUT="$1"
else
OUTPUT="stegasoo-rpi-$(date +%Y%m%d).img.zst"
fi
MANUAL_DEVICE="$2"
# Check if output ends in .img (skip compression) or .zst (compress)
SKIP_COMPRESS=false
if [[ "$OUTPUT" == *.img ]]; then
IMG_FILE="$OUTPUT"
SKIP_COMPRESS=true
elif [[ "$OUTPUT" == *.zst ]]; then
IMG_FILE="${OUTPUT%.zst}"
else
# No recognized extension, add .img.zst
IMG_FILE="${OUTPUT}.img"
OUTPUT="${OUTPUT}.img.zst"
if [ ! -b "$DEVICE" ]; then
echo -e "${RED}Error: Device not found: $DEVICE${NC}"
exit 1
fi
echo -e "${BLUE}"
echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ Stegasoo SD Card Image Puller ║"
echo "╚═══════════════════════════════════════════════════════════════╝"
echo -e "${NC}"
# Use manual device or auto-detect
if [ -n "$MANUAL_DEVICE" ]; then
# Manual device specified
if [ ! -b "$MANUAL_DEVICE" ]; then
echo -e "${RED}Error: $MANUAL_DEVICE is not a block device${NC}"
exit 1
fi
SELECTED="$MANUAL_DEVICE"
echo -e "Using specified device: ${YELLOW}$SELECTED${NC}"
echo ""
lsblk "$SELECTED" -o NAME,SIZE,TYPE,MODEL
echo ""
else
# Auto-detect SD card candidates
# Looking for: USB/removable, 8-128GB, not mounted as root filesystem
echo -e "${BOLD}Scanning for SD cards...${NC}"
echo ""
declare -a CANDIDATES
declare -a CANDIDATE_INFO
while IFS= read -r line; do
DEV=$(echo "$line" | awk '{print $1}')
SIZE=$(echo "$line" | awk '{print $2}')
TYPE=$(echo "$line" | awk '{print $3}')
TRAN=$(echo "$line" | awk '{print $4}')
MODEL=$(echo "$line" | awk '{print $5" "$6" "$7}' | xargs)
# Skip if it's the root filesystem
if mount | grep -q "^/dev/${DEV}[0-9]* on / "; then
continue
fi
# Skip if any partition is mounted as root
ROOT_DEV=$(mount | grep " on / " | awk '{print $1}' | sed 's/[0-9]*$//')
if [[ "/dev/$DEV" == "$ROOT_DEV" ]]; then
continue
fi
# Get size in bytes for reliable comparison
SIZE_BYTES=$(lsblk -b -d -o SIZE -n "/dev/$DEV" 2>/dev/null | tr -d ' ')
SIZE_GB_INT=$((SIZE_BYTES / 1073741824)) # 1024^3
# Check if size is in SD card range (8GB - 128GB)
if [ "$SIZE_GB_INT" -ge 8 ] && [ "$SIZE_GB_INT" -le 128 ]; then
CANDIDATES+=("/dev/$DEV")
CANDIDATE_INFO+=("$SIZE $TYPE ${TRAN:-???} $MODEL")
fi
done < <(lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL -n | grep "disk")
if [ ${#CANDIDATES[@]} -eq 0 ]; then
echo -e "${RED}No SD card candidates found.${NC}"
echo "Looking for USB/removable disks between 8GB and 128GB."
echo ""
echo "Available disks:"
lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL
echo ""
echo -e "${YELLOW}Tip: Specify device manually: $0 output.img.zst /dev/sdX${NC}"
exit 1
fi
echo -e "${GREEN}Found ${#CANDIDATES[@]} candidate(s):${NC}"
echo ""
for i in "${!CANDIDATES[@]}"; do
echo -e " ${BOLD}[$((i+1))]${NC} ${CANDIDATES[$i]} - ${CANDIDATE_INFO[$i]}"
done
echo ""
if [ ${#CANDIDATES[@]} -eq 1 ]; then
SELECTED="${CANDIDATES[0]}"
echo -e "Auto-selected: ${YELLOW}$SELECTED${NC}"
else
read -p "Select device [1-${#CANDIDATES[@]}]: " -r
if [[ ! $REPLY =~ ^[0-9]+$ ]] || [ "$REPLY" -lt 1 ] || [ "$REPLY" -gt ${#CANDIDATES[@]} ]; then
echo -e "${RED}Invalid selection.${NC}"
exit 1
fi
SELECTED="${CANDIDATES[$((REPLY-1))]}"
fi
fi
# Show partitions
echo ""
echo -e "${BOLD}Partitions on $SELECTED:${NC}"
lsblk "$SELECTED" -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT
echo ""
# Final confirmation
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${RED}║ WARNING: This will read the ENTIRE device: ║${NC}"
echo -e "${RED}$SELECTED${NC}"
echo -e "${RED}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
echo ""
read -p "Continue? [y/N] " -n 1 -r
echo -e "${BOLD}Device info:${NC}"
lsblk "$DEVICE"
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
# 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 -e "${RED}Error: Could not find partitions${NC}"
exit 1
fi
# Unmount any mounted partitions
echo -e "${YELLOW}Unmounting partitions...${NC}"
umount "$BOOT_PART" 2>/dev/null || true
umount "$ROOT_PART" 2>/dev/null || true
# ============================================================================
# Resize rootfs to 16GB
# ============================================================================
echo
echo -e "${BOLD}Checking partition size...${NC}"
# Get current partition size in bytes
CURRENT_SIZE=$(blockdev --getsize64 "$ROOT_PART")
TARGET_BYTES=$((16 * 1024 * 1024 * 1024)) # 16GB in bytes
CURRENT_GB=$(echo "scale=2; $CURRENT_SIZE / 1073741824" | bc)
echo " Current rootfs size: ${CURRENT_GB}GB"
if [ "$CURRENT_SIZE" -gt "$TARGET_BYTES" ]; then
echo -e "${YELLOW}Resizing rootfs to 16GB...${NC}"
# Get boot partition end in sectors
BOOT_END=$(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))
# SHRINKING: filesystem first, then partition
echo " Checking filesystem..."
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
# Shrink filesystem to 15.5GB (leave room for partition overhead)
echo " Shrinking filesystem to 15500M..."
resize2fs "$ROOT_PART" 15500M
# Delete and recreate partition 2 with 16GB size
echo " Shrinking partition to 16GB..."
parted -s "$DEVICE" rm 2
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
# Refresh partition table
partprobe "$DEVICE"
sleep 2
# Expand filesystem to fill the partition exactly
echo " Expanding filesystem to fill partition..."
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
resize2fs "$ROOT_PART"
echo -e "${GREEN} Rootfs resized to 16GB${NC}"
elif [ "$CURRENT_SIZE" -lt "$TARGET_BYTES" ]; then
echo -e "${YELLOW} Rootfs is smaller than 16GB - expanding...${NC}"
# Get boot partition end in sectors
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
ROOT_SIZE_SECTORS=33554432
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
# EXPANDING: partition first, then filesystem
parted -s "$DEVICE" rm 2
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
partprobe "$DEVICE"
sleep 2
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
resize2fs "$ROOT_PART"
echo -e "${GREEN} Rootfs expanded to 16GB${NC}"
else
echo -e "${GREEN} Rootfs already ~16GB${NC}"
fi
# ============================================================================
# Pull image
# ============================================================================
echo
echo -e "${BOLD}Partition table:${NC}"
parted -s "$DEVICE" unit s print
echo
# Get the end of the last partition (partition 2 = rootfs)
END_SECTOR=$(parted -s "$DEVICE" unit s print | grep "^ 2" | awk '{print $3}' | tr -d 's')
if [ -z "$END_SECTOR" ]; then
echo -e "${RED}Error: Could not determine partition 2 end sector${NC}"
exit 1
fi
# Add a small buffer (1MB = 2048 sectors) for safety
TOTAL_SECTORS=$((END_SECTOR + 2048))
TOTAL_BYTES=$((TOTAL_SECTORS * 512))
TOTAL_GB=$(echo "scale=2; $TOTAL_BYTES / 1073741824" | bc)
echo -e "Image size: ${YELLOW}~${TOTAL_GB}GB${NC} (${TOTAL_SECTORS} sectors)"
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
echo
read -p "Proceed with image pull? [Y/n] " confirm
if [[ "$confirm" =~ ^[Nn]$ ]]; then
echo "Aborted."
exit 1
fi
# Get device size for pv
DEV_SIZE=$(blockdev --getsize64 "$SELECTED")
echo
echo -e "${GREEN}Pulling image...${NC}"
echo
echo ""
echo -e "${GREEN}[1/3]${NC} Copying image from $SELECTED..."
dd if="$SELECTED" bs=4M status=none | pv -s "$DEV_SIZE" > "$IMG_FILE"
sync
echo ""
echo -e "${GREEN}[2/3]${NC} Shrinking image..."
if command -v pishrink.sh &> /dev/null; then
pishrink.sh "$IMG_FILE"
elif [ -f "./pishrink.sh" ]; then
bash ./pishrink.sh "$IMG_FILE"
elif [ -f "../pishrink.sh" ]; then
bash ../pishrink.sh "$IMG_FILE"
# Use pv if available for progress, otherwise fallback to dd status
if command -v pv &> /dev/null; then
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS 2>/dev/null | \
pv -s $TOTAL_BYTES | \
zstd -T0 -3 > "$OUTPUT"
else
echo -e "${YELLOW}pishrink.sh not found, skipping shrink step.${NC}"
echo "Download from: https://github.com/Drewsif/PiShrink"
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS status=progress | \
zstd -T0 -3 > "$OUTPUT"
fi
echo ""
if [ "$SKIP_COMPRESS" = true ]; then
echo -e "${GREEN}[3/3]${NC} Skipping compression (.img output)"
FINAL_SIZE=$(du -h "$IMG_FILE" | awk '{print $1}')
OUTPUT="$IMG_FILE"
else
echo -e "${GREEN}[3/3]${NC} Compressing with zstd..."
pv "$IMG_FILE" | zstd -19 -T0 -q > "$OUTPUT"
rm -f "$IMG_FILE"
FINAL_SIZE=$(du -h "$OUTPUT" | awk '{print $1}')
fi
echo
echo -e "${GREEN}Done!${NC} Image saved to: $OUTPUT"
ls -lh "$OUTPUT"
echo ""
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Image Complete! ║${NC}"
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
echo -e "Size: ${YELLOW}$FINAL_SIZE${NC}"
echo ""
# ============================================================================
# Optional: Zip-wrap for GitHub releases
# ============================================================================
echo
read -p "Create .zst.zip wrapper for GitHub? [y/N] " zip_confirm
if [[ "$zip_confirm" =~ ^[Yy]$ ]]; then
ZIP_OUTPUT="${OUTPUT}.zip"
echo -e "${YELLOW}Creating zip wrapper (store mode, no compression)...${NC}"
zip -0 "$ZIP_OUTPUT" "$OUTPUT"
echo -e "${GREEN}Done!${NC} Upload this to GitHub Releases:"
ls -lh "$ZIP_OUTPUT"
echo
echo "Users can flash with:"
echo " sudo ./rpi/flash-image.sh $ZIP_OUTPUT"
else
echo
echo "To verify:"
echo " zstdcat $OUTPUT | fdisk -l /dev/stdin"
fi

144
rpi/remote-build-pi.sh Executable file
View File

@@ -0,0 +1,144 @@
#!/bin/bash
#
# Stegasoo Remote Pi Build Script
# Waits for Pi to be reachable, then sets up Stegasoo
#
# Usage: ./remote-build-pi.sh [host] [user] [pass]
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Pi connection settings (defaults)
PI_HOST="${1:-stegasoo.local}"
PI_USER="${2:-admin}"
PI_PASS="${3:-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() {
local attempt=1
ssh-keygen -R "$PI_HOST" 2>/dev/null || true
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'
}
run_on_pi() {
sshpass -p "$PI_PASS" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
}
run_on_pi_interactive() {
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
}
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"
}
ssh_pi() {
ssh-keygen -R "$PI_HOST" 2>/dev/null || true
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
}
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ Stegasoo Remote Pi Build ║${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "Host: ${YELLOW}$PI_HOST${NC}"
echo -e "User: ${YELLOW}$PI_USER${NC}"
echo ""
# -----------------------------------------------------------------------------
# Step 1: Wait for Pi to be ready
# -----------------------------------------------------------------------------
echo -e "${GREEN}[1/6]${NC} Waiting for Pi..."
echo ""
wait_for_pi
# -----------------------------------------------------------------------------
# Step 2: Install dependencies
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[2/6]${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 ca-certificates && sudo update-ca-certificates"
# -----------------------------------------------------------------------------
# Step 3: Clone repo
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[3/6]${NC} Cloning Stegasoo repo..."
echo ""
run_on_pi "cd /opt && rm -rf stegasoo && git clone https://github.com/adlee-was-taken/stegasoo.git stegasoo"
# -----------------------------------------------------------------------------
# Step 4: Copy pre-built tarball
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[4/6]${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 5: Run setup
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[5/6]${NC} Running setup.sh on Pi..."
echo ""
run_on_pi_interactive "cd /opt/stegasoo && ./rpi/setup.sh"
# -----------------------------------------------------------------------------
# Step 6: Test it works
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[6/6]${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://$PI_HOST:5000${NC}"
echo ""
read -p "Press ENTER to SSH into Pi for manual testing..."
ssh_pi

View File

@@ -29,6 +29,10 @@ GRAY='\033[0;90m'
BOLD='\033[1m'
NC='\033[0m'
# Source banner functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/banner.sh"
# Show help
show_help() {
echo "Stegasoo Sanitize Script - Prepare Pi for SD Card Imaging"
@@ -70,19 +74,10 @@ if [ "$EUID" -ne 0 ]; then
fi
clear
echo ""
echo -e "${GRAY} . * . . * . * . * . * .${NC}"
echo -e "${CYAN} ___ _____ ___ ___ _ ___ ___ ___${NC}"
echo -e "${CYAN} / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\${NC}"
echo -e "${CYAN} \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |${NC}"
echo -e "${CYAN} |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/${NC}"
echo ""
echo -e "${GRAY} * . * . * . * . * . *${NC}"
echo ""
if [ "$SOFT_RESET" = true ]; then
echo -e "${CYAN} Soft Reset (Factory)${NC}"
print_banner "Soft Reset (Factory)"
else
echo -e "${CYAN} Sanitize for Imaging${NC}"
print_banner "Sanitize for Imaging"
fi
echo ""

View File

@@ -29,6 +29,33 @@ GRAY='\033[0;90m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# Source banner.sh if available (for local runs), otherwise define inline
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
if [ -f "$SCRIPT_DIR/banner.sh" ]; then
source "$SCRIPT_DIR/banner.sh"
else
# Inline banner functions for curl-pipe execution
print_gradient_line() {
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
}
print_banner() {
local subtitle="$1"
echo ""
print_gradient_line
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
print_gradient_line
if [ -n "$subtitle" ]; then
echo -e "\033[1;37m ${subtitle}\033[0m"
print_gradient_line
fi
}
fi
# Show help
show_help() {
echo "Stegasoo Raspberry Pi Setup Script"
@@ -36,7 +63,9 @@ show_help() {
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " -h, --help Show this help message"
echo " --no-prebuilt Build from source instead of using pre-built venv"
echo " --from-source Same as --no-prebuilt"
echo ""
echo "Configuration:"
echo " Config files are loaded in order (later overrides earlier):"
@@ -80,19 +109,10 @@ for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf";
done
clear
echo ""
echo -e "${GRAY} . * . . * . * . * . * .${NC}"
echo -e "${CYAN} ___ _____ ___ ___ _ ___ ___ ___${NC}"
echo -e "${CYAN} / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\${NC}"
echo -e "${CYAN} \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |${NC}"
echo -e "${CYAN} |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/${NC}"
echo ""
echo -e "${GRAY} * . * . * . * . * . *${NC}"
echo ""
echo -e "${CYAN} Raspberry Pi Setup${NC}"
print_banner "Raspberry Pi Setup"
echo ""
echo " This will install Stegasoo with full DCT support"
echo " Estimated time: 15-20 minutes on Pi 5"
echo " Estimated time: ~2 minutes (pre-built) or 15-20 min (from source)"
echo ""
# Check if running on ARM
@@ -134,6 +154,7 @@ sudo apt-get install -y \
build-essential \
git \
curl \
zstd \
libssl-dev \
zlib1g-dev \
libbz2-dev \
@@ -163,49 +184,23 @@ else
echo " gum already installed"
fi
echo -e "${GREEN}[4/12]${NC} Installing pyenv and Python $PYTHON_VERSION..."
# Install pyenv if not present
if [ ! -d "$HOME/.pyenv" ]; then
curl https://pyenv.run | bash
# Add pyenv to current shell
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
# Add to .bashrc if not already there
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
echo '' >> ~/.bashrc
echo '# pyenv' >> ~/.bashrc
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
fi
# Install mkcert for browser-trusted certificates (no warning screen!)
echo " Installing mkcert for trusted HTTPS certificates..."
if ! command -v mkcert &>/dev/null; then
sudo apt-get install -y libnss3-tools
# Download mkcert for ARM64
sudo curl -sL "https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-arm64" -o /usr/local/bin/mkcert
sudo chmod +x /usr/local/bin/mkcert
# Install local CA (makes certs trusted on this Pi)
mkcert -install 2>/dev/null || true
echo " mkcert installed"
else
echo "pyenv already installed, skipping..."
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
echo " mkcert already installed"
fi
# Install Python 3.12 if not present
if ! pyenv versions | grep -q "$PYTHON_VERSION"; then
echo "Building Python $PYTHON_VERSION (this takes ~10 minutes)..."
pyenv install $PYTHON_VERSION
fi
pyenv global $PYTHON_VERSION
echo -e "${GREEN}[4/12]${NC} Cloning Stegasoo..."
# Verify Python version
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
if [ "$INSTALLED_PY" != "$PYTHON_VERSION" ]; then
echo -e "${RED}Error: Python $PYTHON_VERSION not active. Got: $INSTALLED_PY${NC}"
exit 1
fi
echo -e "${GREEN}[5/12]${NC} Cloning Stegasoo..."
# Clone Stegasoo first (needed for jpegio patch script)
# Clone Stegasoo first (needed to check for pre-built tarball)
if [ -d "$INSTALL_DIR/.git" ]; then
echo " Stegasoo directory exists, updating..."
cd "$INSTALL_DIR"
@@ -217,49 +212,166 @@ else
cd "$INSTALL_DIR"
fi
echo -e "${GREEN}[6/12]${NC} Creating Python virtual environment..."
# Pre-built environment tarball (skips 20+ min compile time)
# Includes both pyenv Python 3.12 AND venv with all dependencies
PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-rpi-runtime-env-arm64.tar.zst"
PREBUILT_URL="${PREBUILT_URL:-https://github.com/adlee-was-taken/stegasoo/releases/download/v4.1.5/stegasoo-rpi-runtime-env-arm64.tar.zst}"
USE_PREBUILT=true
# Create venv with pyenv Python (not system Python)
# Use pyenv which to get actual path (handles 3.12 -> 3.12.12 mapping)
PYENV_PYTHON=$(pyenv which python)
echo " Using Python: $PYENV_PYTHON"
if [ ! -d "venv" ]; then
"$PYENV_PYTHON" -m venv venv
fi
source venv/bin/activate
# Verify we're using the right Python
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
echo " venv Python: $VENV_PY"
echo -e "${GREEN}[7/12]${NC} Building jpegio for ARM..."
# Clone jpegio
JPEGIO_DIR="/tmp/jpegio-build"
rm -rf "$JPEGIO_DIR"
git clone "$JPEGIO_REPO" "$JPEGIO_DIR"
# Apply ARM64 patch
if [ -f "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
bash "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
# Use local tarball if present, otherwise will download
if [ -f "$PREBUILT_TARBALL" ]; then
echo -e "${GREEN}Found local pre-built environment - fast install mode${NC}"
else
echo " Applying inline ARM64 patch..."
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
echo -e "${GREEN}Will download pre-built environment - fast install mode${NC}"
fi
cd "$JPEGIO_DIR"
# Allow --no-prebuilt flag to force from-source build
if [[ " $* " =~ " --no-prebuilt " ]] || [[ " $* " =~ " --from-source " ]]; then
USE_PREBUILT=false
echo -e "${YELLOW}Building from source (--no-prebuilt specified)${NC}"
fi
# Build jpegio into venv
pip install --upgrade pip setuptools wheel cython numpy
pip install .
# Fast path: use pre-built environment if available
if [ "$USE_PREBUILT" = true ]; then
echo -e "${GREEN}[5/8]${NC} Installing pre-built Python environment..."
cd "$INSTALL_DIR"
rm -rf "$JPEGIO_DIR"
# Download if local file doesn't exist
if [ ! -f "$PREBUILT_TARBALL" ]; then
echo " Downloading pre-built environment (~50MB)..."
curl -L -o "$PREBUILT_TARBALL" "$PREBUILT_URL"
fi
echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..."
# Extract pre-built environment (includes pyenv Python + venv)
echo " Extracting pre-built environment..."
zstd -d "$PREBUILT_TARBALL" --stdout | tar -xf - -C "$HOME"
# Install dependencies (jpegio already in venv, won't re-download)
pip install -e ".[web]"
# Setup pyenv in current shell
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
pyenv global $PYTHON_VERSION
# Add to .bashrc if not already there
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
echo '' >> ~/.bashrc
echo '# pyenv' >> ~/.bashrc
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
fi
# Verify Python
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
echo -e " ${GREEN}${NC} Python: $INSTALLED_PY"
# Extract venv to install dir
echo -e "${GREEN}[6/8]${NC} Setting up virtual environment..."
if [ -f "$HOME/stegasoo-venv.tar.zst" ]; then
zstd -d "$HOME/stegasoo-venv.tar.zst" --stdout | tar -xf - -C "$INSTALL_DIR"
rm "$HOME/stegasoo-venv.tar.zst"
fi
# Activate and verify
source "$INSTALL_DIR/venv/bin/activate"
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
echo -e " ${GREEN}${NC} venv Python: $VENV_PY"
# Install stegasoo package in editable mode (quick, no compile)
echo -e "${GREEN}[7/8]${NC} Installing Stegasoo package..."
pip install -e "." --quiet
# Adjust step numbers for rest of script
STEP_OFFSET=-4
else
echo -e "${GREEN}[5/12]${NC} Installing pyenv and Python $PYTHON_VERSION..."
# Install pyenv if not present
if [ ! -d "$HOME/.pyenv" ]; then
curl https://pyenv.run | bash
# Add pyenv to current shell
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
# Add to .bashrc if not already there
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
echo '' >> ~/.bashrc
echo '# pyenv' >> ~/.bashrc
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
fi
else
echo " pyenv already installed"
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
fi
# Install Python 3.12 if not present
if ! pyenv versions | grep -q "$PYTHON_VERSION"; then
echo " Building Python $PYTHON_VERSION (this takes ~10 minutes)..."
pyenv install $PYTHON_VERSION
else
echo " Python $PYTHON_VERSION already installed"
fi
pyenv global $PYTHON_VERSION
# Verify Python version
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
if [ "$INSTALLED_PY" != "$PYTHON_VERSION" ]; then
echo -e "${RED}Error: Python $PYTHON_VERSION not active. Got: $INSTALLED_PY${NC}"
exit 1
fi
echo -e "${GREEN}[6/12]${NC} Creating Python virtual environment..."
echo -e " ${YELLOW}Note: No pre-built venv found. Building from source (20+ min)${NC}"
echo -e " ${YELLOW}To speed up future installs, add stegasoo-venv-pi-arm64.tar.gz to rpi/${NC}"
# Create venv with pyenv Python (not system Python)
# Use pyenv which to get actual path (handles 3.12 -> 3.12.12 mapping)
PYENV_PYTHON=$(pyenv which python)
echo " Using Python: $PYENV_PYTHON"
if [ ! -d "venv" ]; then
"$PYENV_PYTHON" -m venv venv
fi
source venv/bin/activate
# Verify we're using the right Python
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
echo " venv Python: $VENV_PY"
echo -e "${GREEN}[7/12]${NC} Building jpegio for ARM..."
# Clone jpegio
JPEGIO_DIR="/tmp/jpegio-build"
rm -rf "$JPEGIO_DIR"
git clone "$JPEGIO_REPO" "$JPEGIO_DIR"
# Apply ARM64 patch
if [ -f "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
bash "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
else
echo " Applying inline ARM64 patch..."
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
fi
cd "$JPEGIO_DIR"
# Build jpegio into venv
pip install --upgrade pip setuptools wheel cython numpy
pip install .
cd "$INSTALL_DIR"
rm -rf "$JPEGIO_DIR"
echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..."
# Install dependencies (jpegio already in venv, won't re-download)
pip install -e ".[web]"
STEP_OFFSET=0
fi
echo -e "${GREEN}[9/12]${NC} Creating systemd service..."
@@ -290,7 +402,7 @@ echo -e "${GREEN}[10/12]${NC} Enabling service..."
sudo systemctl daemon-reload
sudo systemctl enable stegasoo.service
echo -e "${GREEN}[11/12]${NC} Adding stegasoo to PATH..."
echo -e "${GREEN}[11/12]${NC} Setting up user environment..."
# Add stegasoo venv and rpi scripts to PATH for all users
sudo tee /etc/profile.d/stegasoo-path.sh > /dev/null <<'PATHEOF'
@@ -303,7 +415,26 @@ if [ -d /opt/stegasoo/rpi ]; then
fi
PATHEOF
sudo chmod 644 /etc/profile.d/stegasoo-path.sh
echo " Added /opt/stegasoo/venv/bin and /opt/stegasoo/rpi to PATH"
echo " Added stegasoo to PATH"
# Install custom bashrc if not already customized
if [ -f "$INSTALL_DIR/rpi/skel/.bashrc" ]; then
if ! grep -q "Stegasoo Pi" ~/.bashrc 2>/dev/null; then
cp "$INSTALL_DIR/rpi/skel/.bashrc" ~/.bashrc
source ~/.bashrc 2>/dev/null || true
echo " Installed custom .bashrc"
else
echo " Custom .bashrc already installed"
fi
fi
# Install man page
if [ -f "$INSTALL_DIR/docs/stegasoo.1" ]; then
sudo mkdir -p /usr/local/share/man/man1
sudo cp "$INSTALL_DIR/docs/stegasoo.1" /usr/local/share/man/man1/
sudo mandb -q 2>/dev/null || true
echo " Installed man page (man stegasoo)"
fi
echo -e "${GREEN}[12/12]${NC} Setting up login banner..."
@@ -324,13 +455,43 @@ if systemctl is-active --quiet stegasoo 2>/dev/null; then
STEGASOO_URL="http://$PI_IP:5000"
fi
echo ""
echo -e "\033[0;36m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
echo -e "\033[0;36m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
echo -e "\033[0;36m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
echo -e "\033[0;36m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
echo ""
echo -e " \033[0;32m●\033[0m Stegasoo is running"
echo -e " \033[0;33m$STEGASOO_URL\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
# Show CPU stats if overclocked (read configured freq, not current idle freq)
CONFIG_FILE=""
if [ -f /boot/firmware/config.txt ]; then CONFIG_FILE="/boot/firmware/config.txt"
elif [ -f /boot/config.txt ]; then CONFIG_FILE="/boot/config.txt"; fi
CPU_MHZ=""
CPU_TEMP=""
if [ -n "$CONFIG_FILE" ] && grep -qE "^arm_freq=" "$CONFIG_FILE" 2>/dev/null; then
CPU_MHZ=$(grep "^arm_freq=" "$CONFIG_FILE" | cut -d= -f2)
CPU_TEMP=$(vcgencmd measure_temp 2>/dev/null | cut -d= -f2)
fi
# Compact two-column layout
echo -e " 🚀 Stegasoo running 🌐 \033[0;33m$STEGASOO_URL\033[0m"
if [ -n "$CPU_MHZ" ] && [ -n "$CPU_TEMP" ]; then
# Temp emoji: ice<50, cool 50-70, fire>70
TEMP_NUM=$(echo "$CPU_TEMP" | grep -oE "[0-9]+" | head -1)
if [ -n "$TEMP_NUM" ]; then
if [ "$TEMP_NUM" -ge 70 ]; then
TEMP_EMOJI="🔥"
elif [ "$TEMP_NUM" -ge 50 ]; then
TEMP_EMOJI="😎"
else
TEMP_EMOJI="🧊"
fi
else
TEMP_EMOJI="🌡"
fi
echo -e " \033[0;35m⚡\033[0m ${CPU_MHZ} MHz ${TEMP_EMOJI} ${CPU_TEMP}"
fi
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo ""
else
echo ""
@@ -342,6 +503,10 @@ MOTDEOF
sudo chmod 644 /etc/profile.d/stegasoo-motd.sh
echo " Created login banner"
# Shorten the default Debian MOTD boilerplate
echo "Debian GNU/Linux · License: /usr/share/doc/*/copyright" | sudo tee /etc/motd > /dev/null
echo " Shortened system MOTD"
echo ""
echo -e "${BOLD}Installation Complete!${NC}"
echo -e "${BLUE}-------------------------------------------------------${NC}"
@@ -400,9 +565,15 @@ echo ""
read -p "Generate a private channel key? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
# Generate channel key using the CLI
CHANNEL_KEY=$($INSTALL_DIR/venv/bin/python -c "from stegasoo.channel import generate_channel_key; print(generate_channel_key())")
# Generate channel key and save encrypted to config
CHANNEL_KEY=$($INSTALL_DIR/venv/bin/python -c "
from stegasoo.channel import generate_channel_key, set_channel_key
key = generate_channel_key()
set_channel_key(key, 'user') # Saves encrypted to ~/.stegasoo/channel.key
print(key)
")
echo -e " ${GREEN}${NC} Channel key generated: ${YELLOW}$CHANNEL_KEY${NC}"
echo -e " ${GREEN}${NC} Key saved (encrypted) to ~/.stegasoo/channel.key"
echo ""
echo -e " ${RED}IMPORTANT: Save this key!${NC} You'll need to share it with anyone"
echo " who should be able to decode your images."
@@ -440,6 +611,52 @@ RestartSec=5
WantedBy=multi-user.target
EOF
# Generate SSL certificates if HTTPS enabled
if [ "$ENABLE_HTTPS" = "true" ]; then
echo " Generating SSL certificates..."
CERT_DIR="$INSTALL_DIR/frontends/web/certs"
mkdir -p "$CERT_DIR"
# Get local IP for SAN
LOCAL_IP=$(hostname -I | awk '{print $1}')
PI_HOSTNAME=$(hostname)
# Try mkcert first (creates browser-trusted certs - no warning screen!)
if command -v mkcert &> /dev/null; then
echo " Using mkcert for browser-trusted certificates..."
cd "$CERT_DIR"
mkcert -key-file server.key -cert-file server.crt \
"$PI_HOSTNAME" "$PI_HOSTNAME.local" localhost "$LOCAL_IP" 127.0.0.1 ::1
# Copy CA to web-accessible location for easy device setup
CA_ROOT=$(mkcert -CAROOT)
CA_DIR="$INSTALL_DIR/frontends/web/static/ca"
mkdir -p "$CA_DIR"
cp "$CA_ROOT/rootCA.pem" "$CA_DIR/"
echo -e " ${GREEN}${NC} Trusted certificates generated with mkcert"
echo -e " ${CYAN}Tip:${NC} New devices can get the CA from: http://$PI_HOSTNAME.local/static/ca/rootCA.pem"
else
# Fallback to self-signed (shows browser warning)
echo " Using self-signed certificate (browser will show warning)"
echo " Tip: Install mkcert for trusted certs without warnings"
openssl req -x509 -newkey rsa:2048 \
-keyout "$CERT_DIR/server.key" \
-out "$CERT_DIR/server.crt" \
-days 365 -nodes \
-subj "/O=Stegasoo/CN=$PI_HOSTNAME" \
-addext "subjectAltName=DNS:$PI_HOSTNAME,DNS:$PI_HOSTNAME.local,DNS:localhost,IP:$LOCAL_IP,IP:127.0.0.1" \
2>/dev/null
echo -e " ${GREEN}${NC} Self-signed certificates generated"
fi
# Fix permissions
chmod 600 "$CERT_DIR/server.key"
chown -R "$USER:$USER" "$CERT_DIR"
fi
# Setup port 443 redirect if requested
if [ "$USE_PORT_443" = "true" ]; then
echo " Setting up port 443 redirect..."
@@ -482,15 +699,19 @@ echo -e "${BLUE}-------------------------------------------------------${NC}"
echo ""
PI_IP=$(hostname -I | awk '{print $1}')
PI_HOST=$(hostname)
echo -e "${GREEN}Create your admin account:${NC}"
if [ "$ENABLE_HTTPS" = "true" ]; then
if [ "$USE_PORT_443" = "true" ]; then
echo -e " ${YELLOW}https://$PI_HOST.local/setup${NC}"
echo -e " ${YELLOW}https://$PI_IP/setup${NC}"
else
echo -e " ${YELLOW}https://$PI_HOST.local:5000/setup${NC}"
echo -e " ${YELLOW}https://$PI_IP:5000/setup${NC}"
fi
else
echo -e " ${YELLOW}http://$PI_HOST.local:5000/setup${NC}"
echo -e " ${YELLOW}http://$PI_IP:5000/setup${NC}"
fi
@@ -518,12 +739,12 @@ if [[ ! $REPLY =~ ^[Nn]$ ]]; then
echo -e "${GREEN}✓ Stegasoo is running!${NC}"
if [ "$ENABLE_HTTPS" = "true" ]; then
if [ "$USE_PORT_443" = "true" ]; then
echo -e " Create admin: ${YELLOW}https://$PI_IP/setup${NC}"
echo -e " Create admin: ${YELLOW}https://$PI_HOST.local/setup${NC} or ${YELLOW}https://$PI_IP/setup${NC}"
else
echo -e " Create admin: ${YELLOW}https://$PI_IP:5000/setup${NC}"
echo -e " Create admin: ${YELLOW}https://$PI_HOST.local:5000/setup${NC} or ${YELLOW}https://$PI_IP:5000/setup${NC}"
fi
else
echo -e " Create admin: ${YELLOW}http://$PI_IP:5000/setup${NC}"
echo -e " Create admin: ${YELLOW}http://$PI_HOST.local:5000/setup${NC} or ${YELLOW}http://$PI_IP:5000/setup${NC}"
fi
else
echo -e "${RED}✗ Failed to start. Check logs:${NC} journalctl -u stegasoo -f"

214
rpi/skel/.bashrc Normal file
View File

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

View File

@@ -1,541 +0,0 @@
#!/bin/bash
#
# Stegasoo Pi Image Smoke Test
# Automated testing of a fresh Pi image
#
# Usage: ./smoke-test.sh [ip] [--https] [--443] [--port=PORT]
# Default IP: 192.168.0.4
# --https Use HTTPS (port 5000)
# --443 Use HTTPS on port 443
# --port=N Specify custom port
#
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# Configuration
PI_IP="192.168.0.4"
HTTPS=false
PORT=5000
# Parse arguments
for arg in "$@"; do
case $arg in
--https) HTTPS=true ;;
--443) HTTPS=true; PORT=443 ;;
--port=*) PORT="${arg#*=}" ;;
--*) ;; # Ignore other flags
*)
# If it looks like an IP, use it
if [[ "$arg" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
PI_IP="$arg"
fi
;;
esac
done
if [ "$HTTPS" = true ]; then
if [ "$PORT" = "443" ]; then
BASE_URL="https://$PI_IP"
else
BASE_URL="https://$PI_IP:$PORT"
fi
CURL_OPTS="-k" # Allow self-signed certs
else
BASE_URL="http://$PI_IP:$PORT"
CURL_OPTS=""
fi
# Test credentials
ADMIN_USER="admin"
ADMIN_PASS="stegasoo"
REGULAR_USER="smokeuser"
REGULAR_PASS="SmokeUser123!"
# Temp files
COOKIE_JAR=$(mktemp)
COOKIE_JAR_USER=$(mktemp)
TEST_IMAGE=$(mktemp --suffix=.png)
ENCODED_IMAGE=$(mktemp --suffix=.png)
RESPONSE=$(mktemp)
ENCODED_IMAGE_USER=$(mktemp --suffix=.png)
QR_IMAGE=$(mktemp --suffix=.png)
cleanup() {
rm -f "$COOKIE_JAR" "$COOKIE_JAR_USER" "$TEST_IMAGE" "$ENCODED_IMAGE" "$ENCODED_IMAGE_USER" "$QR_IMAGE" "$RESPONSE"
}
trap cleanup EXIT
# Create a simple test image (red square)
create_test_image() {
if command -v convert &>/dev/null; then
convert -size 100x100 xc:red "$TEST_IMAGE"
elif command -v python3 &>/dev/null; then
python3 -c "
from PIL import Image
img = Image.new('RGB', (100, 100), color='red')
img.save('$TEST_IMAGE')
"
else
echo -e "${YELLOW}Warning: No image tool available, skipping encode/decode tests${NC}"
return 1
fi
}
# Results tracking
TESTS_PASSED=0
TESTS_FAILED=0
pass() {
echo -e " ${GREEN}[PASS]${NC} $1"
TESTS_PASSED=$((TESTS_PASSED + 1))
}
fail() {
echo -e " ${RED}[FAIL]${NC} $1"
TESTS_FAILED=$((TESTS_FAILED + 1))
}
skip() {
echo -e " ${YELLOW}[SKIP]${NC} $1"
}
# =============================================================================
# Header
# =============================================================================
echo ""
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ Stegasoo Pi Image Smoke Test ║${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "Target: ${YELLOW}$BASE_URL${NC}"
echo ""
# =============================================================================
# Test 1: Web UI Reachable
# =============================================================================
echo -e "${BOLD}[1/9] Web UI Accessibility${NC}"
if curl $CURL_OPTS -s -o /dev/null -w "%{http_code}" "$BASE_URL" | grep -q "200\|302"; then
pass "Web UI is reachable"
else
fail "Web UI not reachable at $BASE_URL"
echo -e "${RED}Cannot continue without web access. Is the Pi running?${NC}"
exit 1
fi
# Check if redirected to setup (first run) or login
REDIRECT=$(curl $CURL_OPTS -s -o /dev/null -w "%{redirect_url}" "$BASE_URL")
if echo "$REDIRECT" | grep -q "setup"; then
pass "Redirected to setup (fresh install)"
NEEDS_SETUP=true
elif echo "$REDIRECT" | grep -q "login"; then
pass "Redirected to login (already configured)"
NEEDS_SETUP=false
else
# Check page content
if curl $CURL_OPTS -s "$BASE_URL" | grep -q "setup\|Setup\|Create.*Admin"; then
pass "Setup page detected"
NEEDS_SETUP=true
else
pass "Login page detected"
NEEDS_SETUP=false
fi
fi
# =============================================================================
# Test 2: Create Admin User (if needed)
# =============================================================================
echo ""
echo -e "${BOLD}[2/9] Admin Setup${NC}"
if [ "$NEEDS_SETUP" = true ]; then
# Get CSRF token from setup page
SETUP_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR" "$BASE_URL/setup")
CSRF_TOKEN=$(echo "$SETUP_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
if [ -z "$CSRF_TOKEN" ]; then
# Try alternate pattern
CSRF_TOKEN=$(echo "$SETUP_PAGE" | grep -oP 'csrf_token.*?value="\K[^"]+' || echo "")
fi
# Create admin user
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
-X POST "$BASE_URL/setup" \
-d "username=$ADMIN_USER" \
-d "password=$ADMIN_PASS" \
-d "password_confirm=$ADMIN_PASS" \
-d "csrf_token=$CSRF_TOKEN")
if [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "200" ]; then
if curl $CURL_OPTS -s "$BASE_URL" | grep -q "login\|Login"; then
pass "Admin user created successfully"
else
pass "Setup completed (assuming success)"
fi
else
fail "Failed to create admin user (HTTP $HTTP_CODE)"
fi
else
skip "Setup already complete"
fi
# =============================================================================
# Test 3: Admin Login
# =============================================================================
echo ""
echo -e "${BOLD}[3/9] Admin Authentication${NC}"
# Get login page and CSRF
LOGIN_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR" "$BASE_URL/login")
CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
# Try login as admin
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
-X POST "$BASE_URL/login" \
-d "username=$ADMIN_USER" \
-d "password=$ADMIN_PASS" \
-d "csrf_token=$CSRF_TOKEN" \
-L)
# Check if we're logged in by accessing a protected page
if curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/" | grep -qi "encode\|decode\|logout"; then
pass "Admin login successful"
ADMIN_LOGGED_IN=true
else
fail "Admin login failed"
ADMIN_LOGGED_IN=false
fi
# =============================================================================
# Test 4: Admin Encode/Decode
# =============================================================================
echo ""
echo -e "${BOLD}[4/9] Admin Encode/Decode${NC}"
if [ "$ADMIN_LOGGED_IN" = true ]; then
ENCODE_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/encode")
if echo "$ENCODE_PAGE" | grep -qi "encode\|message\|image\|upload"; then
pass "Encode page loads"
else
fail "Encode page not accessible"
fi
# Try actual encoding if we have image tools
if create_test_image 2>/dev/null; then
CSRF_TOKEN=$(echo "$ENCODE_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
# For encode: use same image as reference_photo and carrier (for simplicity)
# First POST (no redirect follow), get Location header, then GET result page
ENCODE_RESULT=$(curl $CURL_OPTS -s -D - -o /dev/null \
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
-X POST "$BASE_URL/encode" \
-F "reference_photo=@$TEST_IMAGE" \
-F "carrier=@$TEST_IMAGE" \
-F "message=Admin smoke test" \
-F "passphrase=smoke test phrase" \
-F "pin=123456" \
-F "csrf_token=$CSRF_TOKEN")
# Extract redirect location
RESULT_LOCATION=$(echo "$ENCODE_RESULT" | grep -i "^location:" | tr -d '\r' | awk '{print $2}')
if [ -n "$RESULT_LOCATION" ]; then
# GET the result page
RESULT_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL$RESULT_LOCATION")
# Look for download link in result page
DOWNLOAD_URL=$(echo "$RESULT_PAGE" | grep -oP 'href="(/encode/download/[^"]+)"' | head -1 | grep -oP '/encode/download/[^"]+')
fi
if [ -n "$DOWNLOAD_URL" ]; then
# Download the encoded image
HTTP_CODE=$(curl $CURL_OPTS -s -o "$ENCODED_IMAGE" -w "%{http_code}" \
-b "$COOKIE_JAR" "$BASE_URL$DOWNLOAD_URL")
if [ "$HTTP_CODE" = "200" ] && file "$ENCODED_IMAGE" | grep -qi "image\|PNG\|JPEG"; then
pass "Admin encoding works"
# Now decode it
DECODE_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/decode")
CSRF_TOKEN=$(echo "$DECODE_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
DECODED=$(curl $CURL_OPTS -s \
-b "$COOKIE_JAR" \
-X POST "$BASE_URL/decode" \
-F "reference_photo=@$TEST_IMAGE" \
-F "stego_image=@$ENCODED_IMAGE" \
-F "passphrase=smoke test phrase" \
-F "pin=123456" \
-F "csrf_token=$CSRF_TOKEN")
if echo "$DECODED" | grep -q "Admin smoke test"; then
pass "Admin decoding works"
else
fail "Admin decode failed"
fi
else
fail "Failed to download encoded image (HTTP $HTTP_CODE)"
fi
else
# Check for error messages in result page
ERROR_MSG=$(echo "$RESULT_PAGE" | grep -oP 'toast-body">[^<]*<[^>]*>[^<]*' | head -1)
if [ -n "$ERROR_MSG" ]; then
fail "Encoding failed: $ERROR_MSG"
else
fail "No download link found in encode result"
fi
fi
else
skip "Encode/Decode (no image tools)"
fi
else
skip "Admin encode/decode (not logged in)"
fi
# =============================================================================
# Test 5: Create Regular User
# =============================================================================
echo ""
echo -e "${BOLD}[5/9] Create Regular User${NC}"
if [ "$ADMIN_LOGGED_IN" = true ]; then
# Check if there's a user management page
USERS_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/users" 2>/dev/null || echo "")
if echo "$USERS_PAGE" | grep -qi "user\|create\|add"; then
CSRF_TOKEN=$(echo "$USERS_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
-b "$COOKIE_JAR" \
-X POST "$BASE_URL/users/create" \
-d "username=$REGULAR_USER" \
-d "password=$REGULAR_PASS" \
-d "password_confirm=$REGULAR_PASS" \
-d "csrf_token=$CSRF_TOKEN")
if [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "200" ]; then
pass "Regular user created"
USER_CREATED=true
else
# Try alternate endpoint
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
-b "$COOKIE_JAR" \
-X POST "$BASE_URL/register" \
-d "username=$REGULAR_USER" \
-d "password=$REGULAR_PASS" \
-d "password_confirm=$REGULAR_PASS" \
-d "csrf_token=$CSRF_TOKEN")
if [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "200" ]; then
pass "Regular user created (via register)"
USER_CREATED=true
else
fail "Failed to create regular user"
USER_CREATED=false
fi
fi
else
skip "User creation (no user management page)"
USER_CREATED=false
fi
else
skip "User creation (admin not logged in)"
USER_CREATED=false
fi
# =============================================================================
# Test 6: Regular User Login & Encode/Decode
# =============================================================================
echo ""
echo -e "${BOLD}[6/9] Regular User Workflow${NC}"
if [ "$USER_CREATED" = true ]; then
# Logout admin first (get fresh session)
curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/logout" >/dev/null
# Login as regular user
LOGIN_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR_USER" "$BASE_URL/login")
CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
-b "$COOKIE_JAR_USER" -c "$COOKIE_JAR_USER" \
-X POST "$BASE_URL/login" \
-d "username=$REGULAR_USER" \
-d "password=$REGULAR_PASS" \
-d "csrf_token=$CSRF_TOKEN" \
-L)
if curl $CURL_OPTS -s -b "$COOKIE_JAR_USER" "$BASE_URL/" | grep -qi "encode\|decode\|logout"; then
pass "Regular user login successful"
# Try encode/decode as regular user
if [ -f "$TEST_IMAGE" ]; then
ENCODE_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR_USER" "$BASE_URL/encode")
CSRF_TOKEN=$(echo "$ENCODE_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
HTTP_CODE=$(curl $CURL_OPTS -s -o "$ENCODED_IMAGE_USER" -w "%{http_code}" \
-b "$COOKIE_JAR_USER" \
-X POST "$BASE_URL/encode" \
-F "reference_photo=@$TEST_IMAGE" \
-F "carrier=@$TEST_IMAGE" \
-F "message=User smoke test" \
-F "passphrase=user test phrase" \
-F "pin=567890" \
-F "csrf_token=$CSRF_TOKEN")
if [ "$HTTP_CODE" = "200" ] && [ -s "$ENCODED_IMAGE_USER" ] && file "$ENCODED_IMAGE_USER" | grep -qi "image\|PNG"; then
pass "Regular user encoding works"
else
fail "Regular user encoding failed"
fi
fi
else
fail "Regular user login failed"
fi
else
skip "Regular user workflow (user not created)"
fi
# =============================================================================
# Test 7: Password Recovery QR
# =============================================================================
echo ""
echo -e "${BOLD}[7/9] Password Recovery QR${NC}"
# Re-login as admin
LOGIN_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR" "$BASE_URL/login")
CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
curl $CURL_OPTS -s -o /dev/null \
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
-X POST "$BASE_URL/login" \
-d "username=$ADMIN_USER" \
-d "password=$ADMIN_PASS" \
-d "csrf_token=$CSRF_TOKEN" \
-L
# Check for recovery QR endpoint
RECOVERY_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/recovery" 2>/dev/null ||
curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/settings" 2>/dev/null ||
curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/account" 2>/dev/null || echo "")
if echo "$RECOVERY_PAGE" | grep -qi "recovery\|qr\|backup"; then
pass "Recovery page accessible"
# Try to get QR image
QR_URL=$(echo "$RECOVERY_PAGE" | grep -oP 'src="[^"]*qr[^"]*"' | head -1 | sed 's/src="//;s/"$//' || echo "")
if [ -n "$QR_URL" ]; then
if [[ "$QR_URL" != http* ]]; then
QR_URL="$BASE_URL$QR_URL"
fi
HTTP_CODE=$(curl $CURL_OPTS -s -o "$QR_IMAGE" -w "%{http_code}" -b "$COOKIE_JAR" "$QR_URL")
if [ "$HTTP_CODE" = "200" ] && [ -s "$QR_IMAGE" ]; then
if file "$QR_IMAGE" | grep -qi "image\|PNG"; then
pass "Recovery QR code generated"
else
fail "QR endpoint returned non-image"
fi
else
fail "Failed to fetch QR code"
fi
else
skip "QR code URL not found in page"
fi
else
skip "Password recovery (no recovery page found)"
fi
# =============================================================================
# Test 8: System Health
# =============================================================================
echo ""
echo -e "${BOLD}[8/9] System Health${NC}"
# Check if stegasoo CLI works via SSH (optional)
if command -v sshpass &>/dev/null; then
CLI_VERSION=$(sshpass -p 'stegasoo' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
admin@$PI_IP "stegasoo --version" 2>/dev/null || echo "")
if [ -n "$CLI_VERSION" ]; then
pass "CLI accessible: $CLI_VERSION"
else
skip "CLI check (SSH failed or CLI not in PATH)"
fi
else
skip "CLI check (sshpass not installed)"
fi
# Check service status via SSH
if command -v sshpass &>/dev/null; then
SERVICE_STATUS=$(sshpass -p 'stegasoo' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
admin@$PI_IP "systemctl is-active stegasoo" 2>/dev/null || echo "unknown")
if [ "$SERVICE_STATUS" = "active" ]; then
pass "Stegasoo service is active"
else
fail "Stegasoo service status: $SERVICE_STATUS"
fi
else
skip "Service check (sshpass not installed)"
fi
# =============================================================================
# Test 9: Cleanup
# =============================================================================
echo ""
echo -e "${BOLD}[9/9] Cleanup${NC}"
# Just verify we can still access the site
if curl $CURL_OPTS -s -o /dev/null -w "%{http_code}" "$BASE_URL" | grep -q "200\|302"; then
pass "Site still accessible after tests"
else
fail "Site not accessible after tests"
fi
# =============================================================================
# Summary
# =============================================================================
echo ""
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
echo ""
TOTAL=$((TESTS_PASSED + TESTS_FAILED))
if [ $TESTS_FAILED -eq 0 ]; then
echo -e "${GREEN}${BOLD}All tests passed!${NC} ($TESTS_PASSED/$TOTAL)"
else
echo -e "${RED}${BOLD}Some tests failed${NC} ($TESTS_PASSED passed, $TESTS_FAILED failed)"
fi
echo ""
echo -e "Target: $BASE_URL"
echo -e "Admin user: $ADMIN_USER"
echo -e "Regular user: $REGULAR_USER"
echo ""
exit $TESTS_FAILED

87
scripts/build.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/bin/bash
# Stegasoo Build Script
# Usage: ./build.sh [base|fast|full|clean]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DOCKER_DIR="$PROJECT_DIR/docker"
cd "$PROJECT_DIR"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# Detect docker compose command
if docker compose version &>/dev/null; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose &>/dev/null; then
COMPOSE_CMD="docker-compose"
else
echo -e "${RED}Error: docker compose not found${NC}"
exit 1
fi
# Check if we need sudo
SUDO=""
if ! docker ps &>/dev/null; then
SUDO="sudo"
fi
COMPOSE_FILE="$DOCKER_DIR/docker-compose.yml"
case "${1:-fast}" in
base)
echo -e "${YELLOW}Building base image (this takes 5-10 minutes)...${NC}"
$SUDO docker build -f "$DOCKER_DIR/Dockerfile.base" -t stegasoo-base:latest .
echo -e "${GREEN}Base image built! Future builds will be fast.${NC}"
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)
if ! $SUDO docker image inspect stegasoo-base:latest >/dev/null 2>&1; then
echo -e "${YELLOW}Base image not found. Building it first (one-time)...${NC}"
$0 base
fi
echo -e "${CYAN}Fast build using base image...${NC}"
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" build
echo -e "${GREEN}Done! Start with: $COMPOSE_CMD -f docker/docker-compose.yml up -d${NC}"
;;
full)
echo -e "${YELLOW}Full build from scratch (slow)...${NC}"
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" build --no-cache
echo -e "${GREEN}Done! Start with: $COMPOSE_CMD -f docker/docker-compose.yml up -d${NC}"
;;
clean)
echo -e "${YELLOW}Cleaning up...${NC}"
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" down --rmi local -v 2>/dev/null || true
$SUDO docker rmi stegasoo-base:latest 2>/dev/null || true
echo -e "${GREEN}Cleaned!${NC}"
;;
*)
echo -e "${CYAN}Stegasoo Build Script${NC}"
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"
echo " 3. Deps change: $0 base"
;;
esac

93
scripts/screenshots.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/bin/bash
# Capture Web UI screenshots for documentation
# Requires: chromium, imagemagick
# Usage: ./scripts/screenshots.sh [base_url]
#
# Modes:
# Default (auth disabled): Captures main UI pages
# With auth: Also captures login/setup/account pages
#
# Start server with: STEGASOO_AUTH_ENABLED=false python frontends/web/app.py
set -e
BASE_URL="${1:-http://localhost:5000}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
OUTPUT_DIR="$PROJECT_DIR/data"
WINDOW_SIZE="1280,900"
echo "╔══════════════════════════════════════════╗"
echo "║ Stegasoo Screenshot Capture ║"
echo "╚══════════════════════════════════════════╝"
echo ""
echo "Base URL: $BASE_URL"
echo "Output: $OUTPUT_DIR"
echo ""
# Check dependencies
for cmd in chromium magick curl; do
if ! command -v "$cmd" &> /dev/null; then
echo "Error: $cmd not found"
exit 1
fi
done
# Check if server is running
if ! curl -s "$BASE_URL" > /dev/null 2>&1; then
echo "Error: Server not responding at $BASE_URL"
echo "Start with: STEGASOO_AUTH_ENABLED=false python frontends/web/app.py"
exit 1
fi
# Capture a single screenshot
capture() {
local name="$1"
local route="$2"
local url="$BASE_URL$route"
printf " %-20s <- %s\n" "$name" "$route"
chromium --headless --screenshot="$OUTPUT_DIR/$name.png" \
--window-size="$WINDOW_SIZE" --hide-scrollbars \
--disable-gpu --no-sandbox \
"$url" 2>/dev/null
}
echo "Capturing main pages..."
echo ""
# Core pages (always capture)
capture "WebUI" "/"
capture "WebUI_Encode" "/encode"
capture "WebUI_Decode" "/decode"
capture "WebUI_Generate" "/generate"
capture "WebUI_Tools" "/tools"
capture "WebUI_About" "/about"
echo ""
echo "Capturing auth pages..."
echo ""
# Auth pages (may redirect if auth disabled, that's OK)
capture "WebUI_Login" "/login"
capture "WebUI_Setup" "/setup"
capture "WebUI_Account" "/account"
capture "WebUI_Recover" "/recover"
echo ""
echo "Converting to webp..."
echo ""
for png in "$OUTPUT_DIR"/WebUI*.png; do
[ -f "$png" ] || continue
name=$(basename "$png" .png)
printf " %-20s -> %s.webp\n" "$name.png" "$name"
magick "$png" -quality 85 "$OUTPUT_DIR/$name.webp"
rm -f "$png"
done
echo ""
echo "Done! Screenshots:"
echo ""
ls -lh "$OUTPUT_DIR"/WebUI*.webp 2>/dev/null | awk '{print " " $NF " (" $5 ")"}'
echo ""

149
scripts/setup-trusted-certs.sh Executable file
View File

@@ -0,0 +1,149 @@
#!/bin/bash
#
# Setup trusted HTTPS certificates for Stegasoo
# Uses mkcert to create browser-trusted certs (no warning screens!)
#
# Usage: ./setup-trusted-certs.sh [hostname]
#
# This script:
# 1. Installs mkcert if needed
# 2. Creates a local CA (one-time)
# 3. Generates certs for your hostname
# 4. Shows how to trust the CA on other devices
#
set -e
HOSTNAME="${1:-stegasoo.local}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR/.."
CERT_DIR="$PROJECT_ROOT/frontends/web/certs"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
echo ""
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ Stegasoo Trusted Certificate Setup ║${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
# Check/install mkcert
install_mkcert() {
if command -v mkcert &> /dev/null; then
echo -e "${GREEN}${NC} mkcert already installed"
return
fi
echo -e "${YELLOW}Installing mkcert...${NC}"
# Detect OS and install
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
if command -v brew &> /dev/null; then
brew install mkcert
else
echo -e "${RED}Please install Homebrew first: https://brew.sh${NC}"
exit 1
fi
elif [[ -f /etc/debian_version ]]; then
# Debian/Ubuntu/Raspberry Pi OS
sudo apt-get update
sudo apt-get install -y libnss3-tools
# Download mkcert binary
ARCH=$(dpkg --print-architecture)
if [[ "$ARCH" == "arm64" ]] || [[ "$ARCH" == "aarch64" ]]; then
MKCERT_URL="https://github.com/FiloSottile/mkcert/releases/latest/download/mkcert-linux-arm64"
else
MKCERT_URL="https://github.com/FiloSottile/mkcert/releases/latest/download/mkcert-linux-amd64"
fi
sudo curl -L "$MKCERT_URL" -o /usr/local/bin/mkcert
sudo chmod +x /usr/local/bin/mkcert
elif [[ -f /etc/arch-release ]]; then
# Arch Linux
sudo pacman -S mkcert
else
echo -e "${RED}Unsupported OS. Please install mkcert manually:${NC}"
echo " https://github.com/FiloSottile/mkcert#installation"
exit 1
fi
echo -e "${GREEN}${NC} mkcert installed"
}
# Install local CA
setup_ca() {
echo ""
echo -e "${CYAN}Setting up local Certificate Authority...${NC}"
if mkcert -install 2>/dev/null; then
echo -e "${GREEN}${NC} Local CA installed in system trust store"
else
echo -e "${YELLOW}!${NC} Could not auto-install CA (may need manual browser import)"
fi
}
# Generate certificates
generate_certs() {
echo ""
echo -e "${CYAN}Generating trusted certificate for: ${YELLOW}$HOSTNAME${NC}"
mkdir -p "$CERT_DIR"
cd "$CERT_DIR"
# Generate cert for hostname + common local names
mkcert -key-file key.pem -cert-file cert.pem \
"$HOSTNAME" \
localhost \
127.0.0.1 \
::1
echo -e "${GREEN}${NC} Certificates generated in: $CERT_DIR"
}
# Show CA location for other devices
show_ca_info() {
CA_ROOT=$(mkcert -CAROOT)
CA_FILE="$CA_ROOT/rootCA.pem"
echo ""
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} Setup Complete!${NC}"
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
echo ""
echo "Your certificates are ready. Browsers on THIS machine will trust them."
echo ""
echo -e "${YELLOW}To trust on OTHER devices (phones, tablets, other computers):${NC}"
echo ""
echo " 1. Copy the CA certificate to that device:"
echo -e " ${CYAN}$CA_FILE${NC}"
echo ""
echo " 2. Import it as a trusted CA:"
echo " - iOS: AirDrop/email the file, Settings > Profile Downloaded > Install"
echo " - Android: Settings > Security > Install from storage"
echo " - Windows: Double-click > Install > Trusted Root CAs"
echo " - macOS: Double-click > Keychain Access > Trust Always"
echo " - Linux: Copy to /usr/local/share/ca-certificates/ && update-ca-certificates"
echo ""
echo -e "${YELLOW}Quick copy command:${NC}"
echo " scp $CA_FILE user@device:/path/"
echo ""
# Offer to serve CA file via HTTP for easy phone download
echo -e "${YELLOW}Or serve the CA for easy phone download:${NC}"
echo " python3 -m http.server 8080 -d $CA_ROOT"
echo " Then visit: http://$(hostname -I | awk '{print $1}'):8080/rootCA.pem"
echo ""
}
# Main
install_mkcert
setup_ca
generate_certs
show_ca_info

333
scripts/smoke-test.sh Executable file
View File

@@ -0,0 +1,333 @@
#!/bin/bash
#
# Stegasoo Smoke Test
# Tests all core functionality against a running instance (Pi, Docker, or dev)
#
# Usage: ./smoke-test.sh [host] [port] [user] [pass]
#
# Examples:
# ./smoke-test.sh # Pi default (stegasoo.local:443)
# ./smoke-test.sh localhost 5000 # Docker default
# ./smoke-test.sh 192.168.1.100 5000 # Custom host
#
set -e
# Configuration
HOST="${1:-stegasoo.local}"
PORT="${2:-443}"
USER="${3:-admin}"
PASS="${4:-stegasoo}"
# Build URL (don't include :443 since it's default for https)
if [ "$PORT" = "443" ]; then
BASE_URL="https://$HOST"
else
BASE_URL="https://$HOST:$PORT"
fi
COOKIE_JAR="/tmp/stegasoo_smoke_cookies.txt"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEST_DATA="$SCRIPT_DIR/../test_data"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
PASSED=0
FAILED=0
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
log_test() {
echo -e "${CYAN}[TEST]${NC} $1"
}
log_pass() {
echo -e "${GREEN}[PASS]${NC} $1"
PASSED=$((PASSED + 1))
}
log_fail() {
echo -e "${RED}[FAIL]${NC} $1"
FAILED=$((FAILED + 1))
}
curl_get() {
curl -sk "$BASE_URL$1" -b "$COOKIE_JAR" -c "$COOKIE_JAR" "${@:2}"
}
curl_post() {
curl -sk -X POST "$BASE_URL$1" -b "$COOKIE_JAR" -c "$COOKIE_JAR" "${@:2}"
}
wait_for_job() {
local endpoint="$1"
local job_id="$2"
local max_polls="${3:-30}"
for i in $(seq 1 $max_polls); do
sleep 1
result=$(curl_get "$endpoint/$job_id")
if echo "$result" | grep -q '"status":\s*"complete"'; then
echo "$result"
return 0
fi
if echo "$result" | grep -q '"status":\s*"error"'; then
echo "$result"
return 1
fi
done
echo '{"status":"timeout"}'
return 1
}
# -----------------------------------------------------------------------------
# Tests
# -----------------------------------------------------------------------------
test_connectivity() {
log_test "Connectivity to $BASE_URL"
if curl -sk --connect-timeout 5 "$BASE_URL" -o /dev/null; then
log_pass "Server reachable"
else
log_fail "Cannot reach server"
exit 1
fi
}
test_setup_or_login() {
log_test "Setup/Login"
# Check if setup needed
response=$(curl_get "/" -L -o /dev/null -w "%{url_effective}")
if echo "$response" | grep -q "/setup"; then
log_test "Completing first-time setup..."
curl_post "/setup" \
-d "username=$USER" \
-d "password=$PASS" \
-d "password_confirm=$PASS" \
-L -o /dev/null
fi
# Login
curl_get "/login" -o /dev/null # Get session
curl_post "/login" \
-d "username=$USER" \
-d "password=$PASS" \
-L -o /dev/null
# Verify logged in
code=$(curl_get "/encode" -o /dev/null -w "%{http_code}")
if [ "$code" = "200" ]; then
log_pass "Authenticated successfully"
else
log_fail "Authentication failed (got $code)"
fi
}
test_pages() {
log_test "Page accessibility"
local pages="encode decode generate tools about"
local all_pass=true
for page in $pages; do
code=$(curl_get "/$page" -o /dev/null -w "%{http_code}")
if [ "$code" = "200" ]; then
echo -e " ${GREEN}${NC} /$page"
else
echo -e " ${RED}${NC} /$page ($code)"
all_pass=false
fi
done
if $all_pass; then
log_pass "All pages accessible"
else
log_fail "Some pages inaccessible"
fi
}
test_encode_decode_dct() {
log_test "DCT Encode/Decode round trip"
local message="DCT smoke test $(date +%s)"
# Encode
response=$(curl_post "/encode" \
-F "reference_photo=@$TEST_DATA/ref.jpg" \
-F "carrier=@$TEST_DATA/carrier.jpg" \
-F "message=$message" \
-F "passphrase=tower booty sunny windy" \
-F "pin=727643678" \
-F "embed_mode=dct" \
-F "channel_key=auto" \
-F "async=true")
job_id=$(echo "$response" | grep -oP '"job_id":\s*"[^"]+"' | cut -d'"' -f4)
if [ -z "$job_id" ]; then
log_fail "DCT encode - no job ID returned"
return
fi
# Wait for encode
result=$(wait_for_job "/encode/status" "$job_id" 15)
if ! echo "$result" | grep -q '"status":\s*"complete"'; then
log_fail "DCT encode timeout or error"
return
fi
file_id=$(echo "$result" | grep -oP '"file_id":\s*"[^"]+"' | cut -d'"' -f4)
curl_get "/encode/download/$file_id" -o /tmp/stego_dct_test.jpg
echo -e " ${GREEN}${NC} Encoded $(ls -lh /tmp/stego_dct_test.jpg | awk '{print $5}')"
# Decode
response=$(curl_post "/decode" \
-F "reference_photo=@$TEST_DATA/ref.jpg" \
-F "stego_image=@/tmp/stego_dct_test.jpg" \
-F "passphrase=tower booty sunny windy" \
-F "pin=727643678" \
-F "embed_mode=auto" \
-F "channel_key=auto" \
-F "async=true")
job_id=$(echo "$response" | grep -oP '"job_id":\s*"[^"]+"' | cut -d'"' -f4)
# Wait for decode (DCT is slower on Pi)
result=$(wait_for_job "/decode/status" "$job_id" 60)
if echo "$result" | grep -q "$message"; then
log_pass "DCT round trip - message verified"
else
log_fail "DCT decode - message mismatch"
echo " Expected: $message"
echo " Got: $result"
fi
}
test_encode_decode_lsb() {
log_test "LSB Encode/Decode round trip"
local message="LSB smoke test $(date +%s)"
# Encode
response=$(curl_post "/encode" \
-F "reference_photo=@$TEST_DATA/ref.jpg" \
-F "carrier=@$TEST_DATA/carrier.jpg" \
-F "message=$message" \
-F "passphrase=tower booty sunny windy" \
-F "pin=727643678" \
-F "embed_mode=lsb" \
-F "channel_key=auto" \
-F "async=true")
job_id=$(echo "$response" | grep -oP '"job_id":\s*"[^"]+"' | cut -d'"' -f4)
if [ -z "$job_id" ]; then
log_fail "LSB encode - no job ID returned"
return
fi
result=$(wait_for_job "/encode/status" "$job_id" 10)
if ! echo "$result" | grep -q '"status":\s*"complete"'; then
log_fail "LSB encode timeout or error"
return
fi
file_id=$(echo "$result" | grep -oP '"file_id":\s*"[^"]+"' | cut -d'"' -f4)
curl_get "/encode/download/$file_id" -o /tmp/stego_lsb_test.png
echo -e " ${GREEN}${NC} Encoded $(ls -lh /tmp/stego_lsb_test.png | awk '{print $5}')"
# Decode
response=$(curl_post "/decode" \
-F "reference_photo=@$TEST_DATA/ref.jpg" \
-F "stego_image=@/tmp/stego_lsb_test.png" \
-F "passphrase=tower booty sunny windy" \
-F "pin=727643678" \
-F "embed_mode=lsb" \
-F "channel_key=auto" \
-F "async=true")
job_id=$(echo "$response" | grep -oP '"job_id":\s*"[^"]+"' | cut -d'"' -f4)
result=$(wait_for_job "/decode/status" "$job_id" 15)
if echo "$result" | grep -q "$message"; then
log_pass "LSB round trip - message verified"
else
log_fail "LSB decode - message mismatch"
fi
}
test_tools() {
log_test "Tools endpoints"
# Capacity check
response=$(curl_post "/api/tools/capacity" \
-F "image=@$TEST_DATA/carrier.jpg" \
-w "%{http_code}" -o /tmp/capacity_result.json)
if [ "$response" = "200" ]; then
echo -e " ${GREEN}${NC} Capacity check"
else
echo -e " ${RED}${NC} Capacity check ($response)"
fi
# EXIF read
response=$(curl_post "/api/tools/exif" \
-F "image=@$TEST_DATA/carrier.jpg" \
-w "%{http_code}" -o /tmp/exif_result.json)
if [ "$response" = "200" ]; then
echo -e " ${GREEN}${NC} EXIF read"
log_pass "Tools API works"
else
echo -e " ${RED}${NC} EXIF read ($response)"
log_fail "Tools API failed"
fi
}
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
echo ""
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ Stegasoo Smoke Test ║${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "Target: ${YELLOW}$BASE_URL${NC}"
echo -e "User: ${YELLOW}$USER${NC}"
echo ""
# Clean up
rm -f "$COOKIE_JAR" /tmp/stego_*_test.* /tmp/exif_stripped.jpg
# Run tests
test_connectivity
test_setup_or_login
test_pages
test_encode_decode_lsb
test_encode_decode_dct
test_tools
# Summary
echo ""
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
echo -e "Results: ${GREEN}$PASSED passed${NC}, ${RED}$FAILED failed${NC}"
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
# Clean up
rm -f "$COOKIE_JAR"
if [ $FAILED -gt 0 ]; then
exit 1
fi

334
scripts/validate-release.sh Executable file
View File

@@ -0,0 +1,334 @@
#!/bin/bash
# =============================================================================
# Stegasoo Release Validation Script
# =============================================================================
# Automated pre-release validation to catch issues before tagging a release.
#
# Usage:
# ./scripts/validate-release.sh # Local validation only
# ./scripts/validate-release.sh --pi # Include Pi smoke test
# PI_IP=192.168.0.4 ./scripts/validate-release.sh --pi
#
# Exit codes:
# 0 = All tests passed
# 1 = One or more tests failed
# =============================================================================
# Don't use set -e as we need to handle test failures gracefully
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# Default Pi IP (can be overridden via environment)
PI_IP="${PI_IP:-192.168.0.4}"
PI_USER="${PI_USER:-alee}"
INCLUDE_PI=false
INCLUDE_DOCKER=true
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--pi)
INCLUDE_PI=true
shift
;;
--no-docker)
INCLUDE_DOCKER=false
shift
;;
--help|-h)
echo "Usage: $0 [--pi] [--no-docker]"
echo ""
echo "Options:"
echo " --pi Include Pi smoke test (requires SSH access)"
echo " --no-docker Skip Docker build/test"
echo ""
echo "Environment:"
echo " PI_IP Pi IP address (default: 192.168.0.4)"
echo " PI_USER Pi SSH user (default: alee)"
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
# Track results
TESTS_RUN=0
TESTS_PASSED=0
TESTS_FAILED=0
FAILED_TESTS=()
# Helper functions
pass() {
echo -e "${GREEN}[PASS]${NC} $1"
((TESTS_PASSED++))
((TESTS_RUN++))
}
fail() {
echo -e "${RED}[FAIL]${NC} $1"
FAILED_TESTS+=("$1")
((TESTS_FAILED++))
((TESTS_RUN++))
}
skip() {
echo -e "${YELLOW}[SKIP]${NC} $1"
}
section() {
echo ""
echo -e "${CYAN}━━━ $1 ━━━${NC}"
}
# =============================================================================
# Header
# =============================================================================
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ Stegasoo Release Validation ║${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
# Get version from pyproject.toml
VERSION=$(grep '^version = ' pyproject.toml | head -1 | cut -d'"' -f2)
echo -e "Version: ${YELLOW}${VERSION}${NC}"
echo -e "Branch: ${YELLOW}$(git branch --show-current)${NC}"
echo ""
# =============================================================================
# 1. Code Quality Checks
# =============================================================================
section "Code Quality"
# Ruff linting
if command -v ./venv/bin/ruff &> /dev/null; then
echo -n "Running ruff check... "
if ./venv/bin/ruff check src/ frontends/ --quiet 2>/dev/null; then
pass "Ruff linting"
else
fail "Ruff linting (run: ./venv/bin/ruff check src/ frontends/)"
fi
else
skip "Ruff not installed"
fi
# =============================================================================
# 2. Security Audit
# =============================================================================
section "Security Audit"
# pip-audit for known vulnerabilities
if command -v ./venv/bin/pip-audit &> /dev/null; then
echo -n "Running pip-audit... "
if ./venv/bin/pip-audit --quiet 2>/dev/null; then
pass "No known vulnerabilities"
else
fail "pip-audit found vulnerabilities (run: ./venv/bin/pip-audit)"
fi
else
echo -n "Installing pip-audit... "
if ./venv/bin/pip install pip-audit --quiet 2>/dev/null; then
echo -n "Running pip-audit... "
if ./venv/bin/pip-audit --quiet 2>/dev/null; then
pass "No known vulnerabilities"
else
fail "pip-audit found vulnerabilities (run: ./venv/bin/pip-audit)"
fi
else
skip "Could not install pip-audit"
fi
fi
# =============================================================================
# 3. Unit Tests (if they exist)
# =============================================================================
section "Unit Tests"
if ls tests/test_*.py 1> /dev/null 2>&1; then
echo -n "Running pytest... "
if ./venv/bin/pytest tests/ -q --tb=no 2>/dev/null; then
pass "Pytest unit tests"
else
fail "Pytest unit tests"
fi
else
skip "No unit tests found (tests/test_*.py)"
fi
# =============================================================================
# 4. Import Tests
# =============================================================================
section "Import Tests"
# Test core library import
echo -n "Testing stegasoo import... "
if ./venv/bin/python -c "from stegasoo import encode, decode; print('OK')" 2>/dev/null | grep -q OK; then
pass "Core library import"
else
fail "Core library import"
fi
# Test DCT support
echo -n "Testing DCT support... "
if ./venv/bin/python -c "from stegasoo import has_dct_support; assert has_dct_support(), 'No DCT'; print('OK')" 2>/dev/null | grep -q OK; then
pass "DCT support available"
else
fail "DCT support (scipy/jpegio missing?)"
fi
# Test CLI import
echo -n "Testing CLI import... "
if ./venv/bin/python -c "from stegasoo.cli import main; print('OK')" 2>/dev/null | grep -q OK; then
pass "CLI module import"
else
fail "CLI module import"
fi
# =============================================================================
# 5. Encode/Decode Sanity Test
# =============================================================================
section "Encode/Decode Test"
echo -n "Running encode/decode sanity check... "
SANITY_RESULT=$(./venv/bin/python << 'EOF' 2>&1
import sys
sys.path.insert(0, 'src')
from stegasoo import encode, decode
with open('test_data/carrier.jpg', 'rb') as f:
carrier = f.read()
with open('test_data/ref.jpg', 'rb') as f:
ref = f.read()
# LSB test
result = encode(message="sanity test", reference_photo=ref, carrier_image=carrier,
passphrase="test", pin="123456", embed_mode="lsb")
decoded = decode(stego_image=result.stego_image, reference_photo=ref,
passphrase="test", pin="123456", embed_mode="lsb")
assert decoded.message == "sanity test", f"LSB mismatch: {decoded.message}"
# DCT test
result = encode(message="dct sanity", reference_photo=ref, carrier_image=carrier,
passphrase="dct", pin="654321", embed_mode="dct")
decoded = decode(stego_image=result.stego_image, reference_photo=ref,
passphrase="dct", pin="654321", embed_mode="dct")
assert decoded.message == "dct sanity", f"DCT mismatch: {decoded.message}"
print("OK")
EOF
)
if echo "$SANITY_RESULT" | grep -q "OK"; then
pass "Encode/decode sanity (LSB + DCT)"
else
fail "Encode/decode sanity: $SANITY_RESULT"
fi
# =============================================================================
# 6. Docker Build & Test (optional)
# =============================================================================
if $INCLUDE_DOCKER; then
section "Docker"
if command -v docker &> /dev/null || command -v sudo &> /dev/null; then
DOCKER_CMD="docker"
if ! docker info &>/dev/null 2>&1; then
DOCKER_CMD="sudo docker"
fi
echo -n "Building Docker image... "
if $DOCKER_CMD build -t stegasoo:validate -q . >/dev/null 2>&1; then
pass "Docker build"
# Test container starts
echo -n "Testing container startup... "
CONTAINER_ID=$($DOCKER_CMD run -d -p 15000:5000 stegasoo:validate 2>/dev/null)
sleep 3
if curl -s -o /dev/null -w "%{http_code}" http://localhost:15000/ 2>/dev/null | grep -qE "200|302"; then
pass "Container responds to HTTP"
else
fail "Container HTTP response"
fi
# Cleanup
$DOCKER_CMD stop "$CONTAINER_ID" >/dev/null 2>&1 || true
$DOCKER_CMD rm "$CONTAINER_ID" >/dev/null 2>&1 || true
else
fail "Docker build"
fi
# Cleanup test image
$DOCKER_CMD rmi stegasoo:validate >/dev/null 2>&1 || true
else
skip "Docker not available"
fi
else
skip "Docker tests (use --docker to enable)"
fi
# =============================================================================
# 7. Pi Smoke Test (optional)
# =============================================================================
if $INCLUDE_PI; then
section "Pi Smoke Test"
echo -n "Testing SSH connectivity to $PI_USER@$PI_IP... "
if ssh -o ConnectTimeout=5 -o BatchMode=yes "$PI_USER@$PI_IP" "echo OK" 2>/dev/null | grep -q OK; then
pass "SSH connectivity"
echo -n "Checking stegasoo service status... "
if ssh "$PI_USER@$PI_IP" "systemctl is-active stegasoo" 2>/dev/null | grep -q active; then
pass "Stegasoo service running"
echo -n "Running smoke test on Pi... "
SMOKE_RESULT=$(ssh "$PI_USER@$PI_IP" "cd /home/$PI_USER/stegasoo && bash tests/smoke-test.sh --quick 2>&1" || echo "FAILED")
if echo "$SMOKE_RESULT" | grep -qE "All tests passed|PASS"; then
pass "Pi smoke test"
else
fail "Pi smoke test"
fi
else
fail "Stegasoo service not running"
fi
else
fail "SSH connectivity to Pi"
fi
else
skip "Pi smoke test (use --pi to enable)"
fi
# =============================================================================
# Summary
# =============================================================================
echo ""
echo -e "${CYAN}━━━ Summary ━━━${NC}"
echo ""
echo -e "Tests run: ${TESTS_RUN}"
echo -e "Passed: ${GREEN}${TESTS_PASSED}${NC}"
echo -e "Failed: ${RED}${TESTS_FAILED}${NC}"
if [ ${#FAILED_TESTS[@]} -gt 0 ]; then
echo ""
echo -e "${RED}Failed tests:${NC}"
for test in "${FAILED_TESTS[@]}"; do
echo -e " - $test"
done
fi
echo ""
if [ $TESTS_FAILED -eq 0 ]; then
echo -e "${GREEN}✓ All validation checks passed!${NC}"
echo -e " Ready to tag release ${VERSION}"
exit 0
else
echo -e "${RED}✗ Validation failed - fix issues before release${NC}"
exit 1
fi

View File

@@ -7,7 +7,7 @@ Changes in v4.0.0:
- encode() and decode() now accept channel_key parameter
"""
__version__ = "4.0.1"
__version__ = "4.1.7"
# Core functionality
# Channel key management (v4.0.0)
@@ -45,6 +45,7 @@ from .image_utils import (
# Steganography functions
from .steganography import (
calculate_capacity_by_mode,
compare_modes,
has_dct_support,
will_fit_by_mode,
@@ -92,6 +93,7 @@ from .constants import (
EMBED_MODE_LSB,
FORMAT_VERSION,
LOSSLESS_FORMATS,
MAX_FILE_PAYLOAD_SIZE,
MAX_IMAGE_PIXELS,
MAX_MESSAGE_SIZE,
MAX_PASSPHRASE_WORDS,
@@ -112,12 +114,16 @@ from .exceptions import (
ExtractionError,
ImageValidationError,
InvalidHeaderError,
InvalidMagicBytesError,
KeyDerivationError,
KeyGenerationError,
KeyPasswordError,
KeyValidationError,
MessageValidationError,
ModeMismatchError,
NoDataFoundError,
PinValidationError,
ReedSolomonError,
SecurityFactorError,
SteganographyError,
StegasooError,
@@ -145,6 +151,7 @@ from .validation import (
MIN_MESSAGE_LENGTH = 1
MAX_MESSAGE_LENGTH = MAX_MESSAGE_SIZE
MAX_PAYLOAD_SIZE = MAX_MESSAGE_SIZE
# MAX_FILE_PAYLOAD_SIZE imported from constants above
SUPPORTED_IMAGE_FORMATS = LOSSLESS_FORMATS
LSB_BYTES_PER_PIXEL = 3 / 8
DCT_BYTES_PER_PIXEL = 0.125
@@ -184,6 +191,7 @@ __all__ = [
"has_argon2",
# Steganography
"has_dct_support",
"calculate_capacity_by_mode",
"compare_modes",
"will_fit_by_mode",
# QR utilities
@@ -232,6 +240,10 @@ __all__ = [
"ExtractionError",
"EmbeddingError",
"InvalidHeaderError",
"InvalidMagicBytesError",
"ReedSolomonError",
"NoDataFoundError",
"ModeMismatchError",
# Constants
"FORMAT_VERSION",
"MIN_PASSPHRASE_WORDS",
@@ -244,6 +256,7 @@ __all__ = [
"MAX_MESSAGE_LENGTH",
"MAX_MESSAGE_SIZE",
"MAX_PAYLOAD_SIZE",
"MAX_FILE_PAYLOAD_SIZE",
"MIN_IMAGE_PIXELS",
"MAX_IMAGE_PIXELS",
"SUPPORTED_IMAGE_FORMATS",

View File

@@ -47,6 +47,80 @@ CONFIG_LOCATIONS = [
Path.home() / ".stegasoo" / "channel.key", # User config
]
# Encrypted config marker
ENCRYPTED_PREFIX = "ENC:"
def _get_machine_key() -> bytes:
"""
Get a machine-specific key for encrypting stored channel keys.
Uses /etc/machine-id on Linux, falls back to hostname hash.
This ties the encrypted key to this specific machine.
"""
machine_id = None
# Try Linux machine-id
try:
machine_id = Path("/etc/machine-id").read_text().strip()
except (OSError, FileNotFoundError):
pass
# Fallback to hostname
if not machine_id:
import socket
machine_id = socket.gethostname()
# Hash to get consistent 32 bytes
return hashlib.sha256(machine_id.encode()).digest()
def _encrypt_for_storage(plaintext: str) -> str:
"""
Encrypt a channel key for storage using machine-specific key.
Returns ENC: prefixed base64 string.
"""
import base64
key = _get_machine_key()
plaintext_bytes = plaintext.encode()
# XOR with key (cycling if needed)
encrypted = bytes(
pb ^ key[i % len(key)]
for i, pb in enumerate(plaintext_bytes)
)
return ENCRYPTED_PREFIX + base64.b64encode(encrypted).decode()
def _decrypt_from_storage(stored: str) -> str | None:
"""
Decrypt a stored channel key.
Returns None if decryption fails or format is invalid.
"""
import base64
if not stored.startswith(ENCRYPTED_PREFIX):
# Not encrypted, return as-is (legacy plaintext)
return stored
try:
encrypted = base64.b64decode(stored[len(ENCRYPTED_PREFIX):])
key = _get_machine_key()
# XOR to decrypt
decrypted = bytes(
eb ^ key[i % len(key)]
for i, eb in enumerate(encrypted)
)
return decrypted.decode()
except Exception:
return None
def generate_channel_key() -> str:
"""
@@ -154,11 +228,13 @@ def get_channel_key() -> str | None:
else:
debug.print(f"Warning: Invalid {CHANNEL_KEY_ENV_VAR} format, ignoring")
# 2. Check config files
# 2. Check config files (may be encrypted)
for config_path in CONFIG_LOCATIONS:
if config_path.exists():
try:
key = config_path.read_text().strip()
stored = config_path.read_text().strip()
# Decrypt if encrypted, otherwise use as-is (legacy)
key = _decrypt_from_storage(stored)
if key and validate_channel_key(key):
debug.print(f"Channel key from {config_path}: {get_channel_fingerprint(key)}")
return format_channel_key(key)
@@ -200,8 +276,9 @@ def set_channel_key(key: str, location: str = "project") -> Path:
# Create directory if needed
config_path.parent.mkdir(parents=True, exist_ok=True)
# Write key with newline
config_path.write_text(formatted + "\n")
# Encrypt and write (tied to this machine's identity)
encrypted = _encrypt_for_storage(formatted)
config_path.write_text(encrypted + "\n")
# Set restrictive permissions (owner read/write only)
try:
@@ -334,11 +411,12 @@ def get_channel_status() -> dict:
for config_path in CONFIG_LOCATIONS:
if config_path.exists():
try:
file_key = config_path.read_text().strip()
if file_key and format_channel_key(file_key) == key:
stored = config_path.read_text().strip()
file_key = _decrypt_from_storage(stored)
if file_key and validate_channel_key(file_key) and format_channel_key(file_key) == key:
source = str(config_path)
break
except (OSError, PermissionError):
except (OSError, PermissionError, ValueError):
continue
return {

View File

@@ -1,7 +1,69 @@
"""
Stegasoo CLI Module (v3.2.0)
Command-line interface with batch processing and compression support.
A proper CLI architecture using Click. This module demonstrates several
important patterns for building production-quality command-line tools:
PATTERN: COMMAND GROUPS
=======================
Click's @group decorator creates a hierarchy of commands:
stegasoo <- Main entry point
├── encode <- Simple commands at root level
├── decode
├── generate
├── info
├── batch/ <- Group for related commands
│ ├── encode
│ ├── decode
│ └── check
├── channel/ <- Another group
│ ├── generate
│ ├── show
│ ├── status
│ ├── qr
│ └── clear
├── tools/ <- Utility group
│ ├── capacity
│ ├── strip
│ ├── peek
│ └── exif
└── admin/ <- Administration group
├── recover
└── generate-key
PATTERN: JSON OUTPUT MODE
=========================
Every command supports --json for machine-readable output. The pattern:
@click.pass_context
def my_command(ctx, ...):
if ctx.obj.get("json"):
click.echo(json.dumps(result, indent=2))
else:
# Human-readable output with colors/formatting
click.echo(f"✓ Success: {result}")
This makes the CLI scriptable - you can pipe to jq, use in shell scripts, etc.
PATTERN: SENSITIVE INPUT
========================
Passwords/secrets use Click's secure prompts:
@click.option("--passphrase", prompt=True, hide_input=True,
confirmation_prompt=True, help="Passphrase")
- prompt=True: Asks if not provided
- hide_input=True: No echo (like sudo)
- confirmation_prompt=True: "Repeat for confirmation"
PATTERN: DRY-RUN MODE
=====================
For destructive or slow operations, --dry-run shows what WOULD happen:
if dry_run:
click.echo(f"Would encode to {output}")
return
Changes in v3.2.0:
- Updated to use DEFAULT_PASSPHRASE_WORDS (consistency with v3.2.0 naming)
@@ -18,12 +80,6 @@ from .batch import (
batch_capacity_check,
print_batch_result,
)
from .compression import (
HAS_LZ4,
CompressionAlgorithm,
algorithm_name,
get_available_algorithms,
)
from .constants import (
DEFAULT_PASSPHRASE_WORDS, # v3.2.0: renamed from DEFAULT_PHRASE_WORDS
DEFAULT_PIN_LENGTH,
@@ -32,10 +88,23 @@ from .constants import (
__version__,
)
# Click context settings
# Click context settings - these apply to all commands
# help_option_names lets users use either -h or --help
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
# =============================================================================
# ROOT GROUP - The main entry point
# =============================================================================
#
# @click.group() creates a command group. The function becomes both:
# 1. A callable that sets up shared state (ctx.obj)
# 2. A container for subcommands via @cli.command() decorators
#
# The context object (ctx.obj) is passed down to all subcommands.
# We use it to share the --json flag across the entire CLI.
@click.group(context_settings=CONTEXT_SETTINGS)
@click.version_option(__version__, "-v", "--version")
@click.option("--json", "json_output", is_flag=True, help="Output results as JSON")
@@ -46,6 +115,8 @@ def cli(ctx, json_output):
Hide messages in images using PIN + passphrase security.
"""
# ensure_object(dict) creates ctx.obj if it doesn't exist
# This prevents "NoneType has no attribute" errors
ctx.ensure_object(dict)
ctx.obj["json"] = json_output
@@ -53,6 +124,31 @@ def cli(ctx, json_output):
# =============================================================================
# ENCODE COMMANDS
# =============================================================================
#
# The encode command demonstrates several Click patterns:
#
# 1. ARGUMENT vs OPTION
# - Arguments are positional: `stegasoo encode photo.png`
# - Options have flags: `stegasoo encode -m "message" --pin 1234`
# Rule of thumb: required inputs → arguments, optional/secret → options
#
# 2. MUTUAL EXCLUSIVITY
# We need either --message OR --file, not both. Click doesn't have built-in
# mutual exclusivity, so we check manually:
#
# if not message and not file_payload:
# raise click.UsageError("Either --message or --file is required")
#
# 3. TYPE VALIDATION
# Click validates types automatically:
# - type=click.Path(exists=True) → file must exist
# - type=click.Choice(["a", "b"]) → must be one of these values
# - type=int → must be an integer
#
# 4. DEFAULT VALUES
# Options can have smart defaults:
# - default="zlib" → use this if not specified
# - default=True with is_flag=True → boolean flag defaults to on
@cli.command()
@@ -81,19 +177,10 @@ def cli(ctx, json_output):
help="Passphrase (recommend 4+ words)",
)
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
@click.option(
"--compress/--no-compress", default=True, help="Enable/disable compression (default: enabled)"
)
@click.option(
"--algorithm",
type=click.Choice(["zlib", "lz4", "none"]),
default="zlib",
help="Compression algorithm",
)
@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
@click.pass_context
def encode(
ctx, carrier, reference, message, file_payload, output, passphrase, pin, compress, algorithm, dry_run
ctx, carrier, reference, message, file_payload, output, passphrase, pin, dry_run
):
"""
Encode a message or file into an image.
@@ -105,24 +192,13 @@ def encode(
stegasoo encode photo.png -r ref.jpg -f secret.pdf -o encoded.png
"""
from PIL import Image
from .encode import encode as stegasoo_encode
from .encode import encode_file as stegasoo_encode_file
if not message and not file_payload:
raise click.UsageError("Either --message or --file is required")
# Parse compression algorithm
algo_map = {
"zlib": CompressionAlgorithm.ZLIB,
"lz4": CompressionAlgorithm.LZ4,
"none": CompressionAlgorithm.NONE,
}
compression_algo = algo_map[algorithm] if compress else CompressionAlgorithm.NONE
if algorithm == "lz4" and not HAS_LZ4:
click.echo("Warning: LZ4 not available, falling back to zlib", err=True)
compression_algo = CompressionAlgorithm.ZLIB
# Calculate payload size
if file_payload:
payload_size = Path(file_payload).stat().st_size
@@ -144,7 +220,6 @@ def encode(
"capacity_bytes": capacity_bytes,
"payload_type": payload_type,
"payload_size": payload_size,
"compression": algorithm_name(compression_algo),
"usage_percent": round(payload_size / capacity_bytes * 100, 1),
"fits": payload_size < capacity_bytes,
}
@@ -156,7 +231,6 @@ def encode(
click.echo(f"Reference: {reference}")
click.echo(f"Capacity: {capacity_bytes:,} bytes ({capacity_bytes//1024} KB)")
click.echo(f"Payload: {payload_size:,} bytes ({payload_type})")
click.echo(f"Compression: {algorithm_name(compression_algo)}")
click.echo(f"Usage: {result['usage_percent']}%")
click.echo(f"Status: {'✓ Fits' if result['fits'] else '✗ Too large'}")
return
@@ -203,7 +277,6 @@ def encode(
"reference": reference,
"output": output,
"payload_type": payload_type,
"compression": algorithm_name(compression_algo),
},
indent=2,
)
@@ -211,7 +284,6 @@ def encode(
else:
click.echo(f"✓ Encoded {payload_type} to {output}")
click.echo(f" Reference: {reference}")
click.echo(f" Compression: {algorithm_name(compression_algo)}")
except Exception as e:
if ctx.obj.get("json"):
@@ -319,6 +391,32 @@ def decode(ctx, image, reference, passphrase, pin, output):
# =============================================================================
# BATCH COMMANDS
# =============================================================================
#
# Batch processing demonstrates:
#
# 1. SUBGROUPS
# @cli.group() creates a nested command group:
# stegasoo batch encode *.png
# stegasoo batch decode *.png
# stegasoo batch check *.png
#
# 2. VARIADIC ARGUMENTS
# nargs=-1 accepts multiple arguments:
# @click.argument("images", nargs=-1, required=True)
# This lets users do: `stegasoo batch encode img1.png img2.png img3.png`
# Or with shell expansion: `stegasoo batch encode *.png`
#
# 3. PROGRESS CALLBACKS
# We pass a callback to the BatchProcessor for real-time updates:
#
# def progress(current, total, item):
# click.echo(f"[{current}/{total}] {item.input_path.name}")
#
# processor.batch_encode(..., progress_callback=progress)
#
# 4. PARALLEL PROCESSING
# --jobs/-j controls worker count. Default is 4 for good balance between
# speed and memory usage. Each worker loads images into memory.
@cli.group()
@@ -345,13 +443,6 @@ def batch():
help="Passphrase (recommend 4+ words)",
)
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
@click.option("--compress/--no-compress", default=True, help="Enable/disable compression")
@click.option(
"--algorithm",
type=click.Choice(["zlib", "lz4", "none"]),
default="zlib",
help="Compression algorithm",
)
@click.option("-r", "--recursive", is_flag=True, help="Search directories recursively")
@click.option("-j", "--jobs", default=4, help="Parallel workers (default: 4)")
@click.option("-v", "--verbose", is_flag=True, help="Show detailed output")
@@ -365,8 +456,6 @@ def batch_encode(
suffix,
passphrase,
pin,
compress,
algorithm,
recursive,
jobs,
verbose,
@@ -401,7 +490,6 @@ def batch_encode(
output_dir=Path(output_dir) if output_dir else None,
output_suffix=suffix,
credentials=credentials,
compress=compress,
recursive=recursive,
progress_callback=progress if not ctx.obj.get("json") else None,
)
@@ -585,33 +673,165 @@ def generate(ctx, words, pin_length, channel_key):
@cli.command()
@click.option("--full", is_flag=True, help="Show full system information (Pi stats)")
@click.pass_context
def info(ctx):
"""Show version and feature information."""
def info(ctx, full):
"""Show version, features, and system information."""
import os
import subprocess
# Check for DCT support
try:
from .dct_steganography import HAS_JPEGIO, HAS_SCIPY
has_dct = HAS_SCIPY and HAS_JPEGIO
except ImportError:
has_dct = False
# Check service status
service_status = "unknown"
service_url = None
try:
result = subprocess.run(
["systemctl", "is-active", "stegasoo"],
capture_output=True,
text=True,
timeout=2,
)
service_status = result.stdout.strip()
if service_status == "active":
# Try to get URL from service environment
env_result = subprocess.run(
["systemctl", "show", "stegasoo", "--property=Environment"],
capture_output=True,
text=True,
timeout=2,
)
https_enabled = "HTTPS_ENABLED=true" in env_result.stdout
protocol = "https" if https_enabled else "http"
# Get IP
ip_result = subprocess.run(
["hostname", "-I"],
capture_output=True,
text=True,
timeout=2,
)
ip = ip_result.stdout.strip().split()[0] if ip_result.stdout.strip() else "localhost"
service_url = f"{protocol}://{ip}"
except (subprocess.TimeoutExpired, FileNotFoundError, IndexError):
pass
# Check channel key
channel_fingerprint = None
channel_source = None
try:
from .channel import get_channel_fingerprint, get_channel_key, get_channel_status
key = get_channel_key()
if key:
channel_fingerprint = get_channel_fingerprint(key)
status = get_channel_status()
channel_source = status.get("source")
except ImportError:
pass
# System info (Pi-specific)
cpu_freq = None
cpu_temp = None
disk_free = None
uptime = None
if full:
try:
# CPU frequency
with open("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq") as f:
cpu_freq = int(f.read().strip()) // 1000 # MHz
except (FileNotFoundError, ValueError):
pass
try:
# CPU temp
with open("/sys/class/thermal/thermal_zone0/temp") as f:
cpu_temp = int(f.read().strip()) / 1000 # Celsius
except (FileNotFoundError, ValueError):
pass
try:
# Disk free
st = os.statvfs("/")
disk_free = (st.f_bavail * st.f_frsize) / (1024 ** 3) # GB
except OSError:
pass
try:
# Uptime
with open("/proc/uptime") as f:
uptime_secs = float(f.read().split()[0])
days = int(uptime_secs // 86400)
hours = int((uptime_secs % 86400) // 3600)
uptime = f"{days}d {hours}h" if days else f"{hours}h"
except (FileNotFoundError, ValueError):
pass
info_data = {
"version": __version__,
"compression": {
"available": [algorithm_name(a) for a in get_available_algorithms()],
"lz4_installed": HAS_LZ4,
},
"service": service_status,
"url": service_url,
"dct_support": has_dct,
"channel": {
"fingerprint": channel_fingerprint,
"source": channel_source,
} if channel_fingerprint else None,
"limits": {
"max_message_bytes": MAX_MESSAGE_SIZE,
"max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE,
},
"system": {
"cpu_mhz": cpu_freq,
"temp_c": cpu_temp,
"disk_free_gb": round(disk_free, 1) if disk_free else None,
"uptime": uptime,
} if full else None,
}
if ctx.obj.get("json"):
click.echo(json.dumps(info_data, indent=2))
else:
click.echo(f"Stegasoo v{__version__}")
click.echo("\nCompression algorithms:")
for algo in get_available_algorithms():
click.echo(f"{algorithm_name(algo)}")
if not HAS_LZ4:
click.echo(" (install 'lz4' for LZ4 support)")
click.echo("\nLimits:")
click.echo(f" • Max message: {MAX_MESSAGE_SIZE:,} bytes")
click.echo(f" • Max file payload: {MAX_FILE_PAYLOAD_SIZE:,} bytes")
# Fastfetch-style output
click.echo(f"\033[1mSTEGASOO\033[0m v{__version__}")
click.echo("" * 36)
# Service status
if service_status == "active":
click.echo(" Service: \033[32m● running\033[0m")
if service_url:
click.echo(f" URL: {service_url}")
elif service_status == "inactive":
click.echo(" Service: \033[31m○ stopped\033[0m")
else:
click.echo(f" Service: \033[33m? {service_status}\033[0m")
# Channel
if channel_fingerprint:
masked = f"{channel_fingerprint[:4]}••••••••{channel_fingerprint[-4:]}"
click.echo(f" Channel: {masked}")
else:
click.echo(" Channel: public")
# DCT
dct_status = "\033[32m✓ enabled\033[0m" if has_dct else "\033[31m✗ disabled\033[0m"
click.echo(f" DCT: {dct_status}")
# System info (if --full)
if full and any([cpu_freq, cpu_temp, disk_free, uptime]):
click.echo("" * 36)
if cpu_freq:
click.echo(f" CPU: {cpu_freq} MHz")
if cpu_temp:
temp_color = "\033[32m" if cpu_temp < 60 else "\033[33m" if cpu_temp < 75 else "\033[31m"
click.echo(f" Temp: {temp_color}{cpu_temp:.1f}°C\033[0m")
if uptime:
click.echo(f" Uptime: {uptime}")
if disk_free:
click.echo(f" Disk: {disk_free:.1f} GB free")
# =============================================================================
@@ -1108,7 +1328,9 @@ def admin_recover(db_path, password):
stegasoo admin recover --db /path/to/stegasoo.db
"""
import sqlite3
from argon2 import PasswordHasher
from .recovery import verify_recovery_key
# Try default paths if not specified

View File

@@ -25,7 +25,7 @@ from pathlib import Path
# VERSION
# ============================================================================
__version__ = "4.1.0"
__version__ = "4.1.5"
# ============================================================================
# FILE FORMAT

View File

@@ -1,18 +1,26 @@
"""
Stegasoo Cryptographic Functions (v4.0.0 - Channel Key Support)
Key derivation, encryption, and decryption using AES-256-GCM.
Supports both text messages and binary file payloads.
This is the crypto layer - where we turn plaintext into indecipherable noise.
BREAKING CHANGES in v4.0.0:
- Added channel key support for deployment/group isolation
- Messages encoded with a channel key require the same key to decode
- Channel key can be configured via environment, config file, or explicit parameter
- FORMAT_VERSION bumped to 5
The security model is multi-factor:
┌────────────────────────────────────────────────────────────────────┐
│ SOMETHING YOU HAVE SOMETHING YOU KNOW │
│ ├─ Reference photo ├─ Passphrase (4+ BIP-39 words) │
│ └─ RSA private key (opt) └─ PIN (6-9 digits) │
│ │
│ DEPLOYMENT BINDING │
│ └─ Channel key (ties messages to a specific server/group) │
└────────────────────────────────────────────────────────────────────┘
BREAKING CHANGES in v3.2.0:
- Removed date dependency from key derivation
- Renamed day_phrase → passphrase (no daily rotation needed)
All factors get mixed together through Argon2id (memory-hard KDF) to derive
the actual encryption key. Miss any factor = wrong key = garbage output.
Encryption: AES-256-GCM (authenticated encryption - tamper = detection)
KDF: Argon2id (256MB RAM, 4 iterations) or PBKDF2 fallback (600K iterations)
v4.0.0: Added channel key for server/group isolation
v3.2.0: Removed date dependency (was cute but annoying in practice)
"""
import hashlib
@@ -98,25 +106,38 @@ def _resolve_channel_key(channel_key: str | bool | None) -> bytes | None:
# =============================================================================
# CORE CRYPTO FUNCTIONS
# =============================================================================
#
# The "reference photo as a key" concept is one of Stegasoo's unique features.
# Most steganography tools just use a password. We add the photo as a
# "something you have" factor - like a hardware token, but it's a cat picture.
def hash_photo(image_data: bytes) -> bytes:
"""
Compute deterministic hash of photo pixel content.
This normalizes the image to RGB and hashes the raw pixel data,
making it resistant to metadata changes.
This is the magic sauce that turns your cat photo into a cryptographic key.
Why pixels and not the file hash?
- File metadata changes (EXIF stripped, resaved) = different file hash
- But pixel content stays the same
- We hash the RGB values directly, so format conversions don't matter
The double-hash with prefix is belt-and-suspenders mixing. Probably
overkill, but hey, it's crypto - paranoia is a feature.
Args:
image_data: Raw image file bytes
image_data: Raw image file bytes (any format PIL can read)
Returns:
32-byte SHA-256 hash
32-byte SHA-256 hash of pixel content
"""
# Convert to RGB to normalize (RGBA, grayscale, etc. all become RGB)
img: Image.Image = Image.open(io.BytesIO(image_data)).convert("RGB")
pixels = img.tobytes()
# Double-hash with prefix for additional mixing
# Double-hash: SHA256(SHA256(pixels) + first 1KB of pixels)
# The prefix adds image-specific data to prevent length-extension shenanigans
h = hashlib.sha256(pixels).digest()
h = hashlib.sha256(h + pixels[:1024]).digest()
return h
@@ -133,20 +154,38 @@ def derive_hybrid_key(
"""
Derive encryption key from multiple factors.
Combines:
- Photo hash (something you have)
- Passphrase (something you know)
- PIN (something you know, static)
- RSA key (something you have)
- Channel key (deployment/group binding)
- Salt (random per message)
This is the heart of Stegasoo's security model. We take all the things
you need to prove you're authorized (photo, passphrase, PIN, etc.) and
blend them together into one 32-byte key.
Uses Argon2id if available, falls back to PBKDF2.
The flow:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Photo hash │ + │ passphrase │ + │ PIN + RSA │ + salt
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
└────────────────┴────────────────┘
┌─────────────────┐
│ Argon2id │ <- Memory-hard KDF
│ 256MB / 4 iter │ <- Makes brute force expensive
└─────────────────┘
32-byte AES key
Why Argon2id?
- Memory-hard: attackers can't just throw GPUs at it
- 256MB RAM per attempt = expensive at scale
- Winner of the Password Hashing Competition (2015)
- "id" variant resists both side-channel and GPU attacks
Fallback: PBKDF2-SHA512 with 600K iterations (for systems without argon2)
Args:
photo_data: Reference photo bytes
passphrase: Shared passphrase (recommend 4+ words)
salt: Random salt for this message
passphrase: Shared passphrase (recommend 4+ words from BIP-39)
salt: Random salt for this message (32 bytes)
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
channel_key: Channel key parameter:
@@ -155,7 +194,7 @@ def derive_hybrid_key(
- "" or False: No channel key (public mode)
Returns:
32-byte derived key
32-byte derived key (ready for AES-256)
Raises:
KeyDerivationError: If key derivation fails
@@ -163,31 +202,36 @@ def derive_hybrid_key(
try:
photo_hash = hash_photo(photo_data)
# Resolve channel key
# Resolve channel key (server-specific binding)
channel_hash = _resolve_channel_key(channel_key)
# Build key material
# Build key material by concatenating all factors
# Passphrase is lowercased to be forgiving of case differences
key_material = photo_hash + passphrase.lower().encode() + pin.encode() + salt
# Add RSA key hash if provided
# Add RSA key hash if provided (another "something you have")
if rsa_key_data:
key_material += hashlib.sha256(rsa_key_data).digest()
# Add channel key hash if configured (v4.0.0)
# Add channel key hash if configured (v4.0.0 - deployment binding)
if channel_hash:
key_material += channel_hash
# Run it all through the KDF
if HAS_ARGON2:
# Argon2id: the good stuff
key = hash_secret_raw(
secret=key_material,
salt=salt[:32],
time_cost=ARGON2_TIME_COST,
memory_cost=ARGON2_MEMORY_COST,
parallelism=ARGON2_PARALLELISM,
time_cost=ARGON2_TIME_COST, # 4 iterations
memory_cost=ARGON2_MEMORY_COST, # 256 MB RAM
parallelism=ARGON2_PARALLELISM, # 4 threads
hash_len=32,
type=Type.ID,
type=Type.ID, # Hybrid mode: resists side-channel AND GPU attacks
)
else:
# PBKDF2 fallback for systems without argon2-cffi
# 600K iterations is slow but not memory-hard
kdf = PBKDF2HMAC(
algorithm=hashes.SHA512(),
length=32,
@@ -347,9 +391,12 @@ def _unpack_payload(data: bytes) -> DecodeResult:
# =============================================================================
# HEADER FLAGS (v4.0.0)
# =============================================================================
#
# The flags byte tells us about the message without decrypting it.
# Currently just one flag, but the byte gives us room for 8.
# Header flag bits
FLAG_CHANNEL_KEY = 0x01 # Set if encoded with a channel key
FLAG_CHANNEL_KEY = 0x01 # Bit 0: Message was encoded with a channel key
# Future flags could include: compression, file attachment, etc.
def encrypt_message(
@@ -361,33 +408,40 @@ def encrypt_message(
channel_key: str | bool | None = None,
) -> bytes:
"""
Encrypt message or file using AES-256-GCM with hybrid key derivation.
Encrypt message or file using AES-256-GCM.
Message format (v4.0.0 - with channel key support):
- Magic header (4 bytes)
- Version (1 byte) = 5
- Flags (1 byte) - indicates if channel key was used
- Salt (32 bytes)
- IV (12 bytes)
- Auth tag (16 bytes)
- Ciphertext (variable, padded)
This is where plaintext becomes ciphertext. We use AES-256-GCM which is:
- AES: The standard, used by everyone from banks to governments
- 256-bit key: Enough entropy to survive until the heat death of the universe
- GCM mode: Authenticated encryption - if anyone tampers, decryption fails
The output format (v4.0.0):
┌──────────────────────────────────────────────────────────────────────┐
\x89ST3 │ 05 │ flags │ salt (32B) │ iv (12B) │ tag (16B) │ ··· │
│ magic │ver │ │ │ │ │cipher│
└──────────────────────────────────────────────────────────────────────┘
Why the random padding at the end?
- Message length can reveal information (traffic analysis)
- We add 64-319 random bytes and round to 256-byte boundary
- All messages look roughly the same size
Args:
message: Message string, raw bytes, or FilePayload to encrypt
photo_data: Reference photo bytes
passphrase: Shared passphrase (recommend 4+ words for good entropy)
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
photo_data: Reference photo bytes (your "key photo")
passphrase: Shared passphrase (recommend 4+ words from BIP-39)
pin: Optional static PIN for additional security
rsa_key_data: Optional RSA key bytes (another "something you have")
channel_key: Channel key parameter:
- None or "auto": Use configured key
- None or "auto": Use server's configured key
- str: Use this specific key
- "" or False: No channel key (public mode)
Returns:
Encrypted message bytes
Encrypted message bytes ready for embedding
Raises:
EncryptionError: If encryption fails
EncryptionError: If encryption fails (shouldn't happen with valid inputs)
"""
try:
salt = secrets.token_bytes(SALT_SIZE)

View File

@@ -1,22 +1,30 @@
"""
DCT Domain Steganography Module (v4.1.0)
Embeds data in DCT coefficients with two approaches:
1. PNG output: Scipy-based DCT transform (grayscale or color)
2. JPEG output: jpegio-based coefficient manipulation (if available)
The fancy pants mode. Instead of hiding bits in pixel values (LSB mode),
we hide them in the *frequency domain* - specifically in the Discrete Cosine
Transform coefficients that JPEG compression uses internally.
v4.1.0 Changes:
- Reed-Solomon error correction protects against bit errors in problematic blocks
- Majority voting on length headers (3 copies) for additional robustness
- RS can correct up to 16 byte errors per 223-byte chunk
Why is this cool?
- Survives some image processing that would destroy LSB data
- Works with JPEG without the usual "save destroys everything" problem
- Uses the same math that JPEG itself uses - we're hiding in plain sight
v3.2.0-patch2 Changes:
- Chunked processing for large images to avoid heap corruption
- Process image in vertical strips to limit memory per operation
- Isolated DCT operations with fresh array allocations
- Workaround for scipy.fftpack memory issues
Two approaches depending on what you want:
1. PNG output: We do our own DCT math via scipy (works on any image)
2. JPEG output: We use jpegio to directly tweak the coefficients (chef's kiss)
Requires: scipy (for PNG mode), optionally jpegio (for JPEG mode), reedsolo (for error correction)
v4.1.0 - The "please stop corrupting my data" release:
- Reed-Solomon error correction (can fix up to 16 byte errors per chunk)
- Majority voting on headers (store 3 copies, take the winner)
- Because some image regions are just... problematic
v3.2.0-patch2 - The "scipy why are you like this" release:
- Chunked processing because scipy's FFT was corrupting memory on big images
- Process blocks one at a time with fresh arrays
- Yes, it's slower. No, I don't care. Correctness > speed.
Requires: scipy (PNG mode), optionally jpegio (JPEG mode), reedsolo (error correction)
"""
import gc
@@ -54,16 +62,64 @@ except ImportError:
HAS_JPEGIO = False
jio = None
# Import custom exceptions
from .exceptions import InvalidMagicBytesError
from .exceptions import ReedSolomonError as StegasooRSError
# Progress reporting interval (write every N blocks)
PROGRESS_INTERVAL = 50
def _write_progress(progress_file: str | None, current: int, total: int, phase: str = "embedding"):
"""Write progress to file for frontend polling."""
if progress_file is None:
return
try:
import json
with open(progress_file, "w") as f:
json.dump(
{
"current": current,
"total": total,
"percent": round((current / total) * 100, 1) if total > 0 else 0,
"phase": phase,
},
f,
)
except Exception:
pass # Don't let progress writing break encoding
# ============================================================================
# CONSTANTS
# ============================================================================
# JPEG uses 8x8 blocks for DCT - this is baked into the standard
BLOCK_SIZE = 8
# The zig-zag order of DCT coefficients. JPEG stores them this way because
# the human eye is more sensitive to low frequencies (top-left corner)
# than high frequencies (bottom-right). After quantization, most high-freq
# coefficients become zero, so zig-zag gives great compression.
#
# Visual of an 8x8 DCT block with zig-zag numbering:
#
# DC 1 5 6 14 15 27 28 <- Low frequency (smooth gradients)
# 2 4 7 13 16 26 29 42
# 3 8 12 17 25 30 41 43
# 9 11 18 24 31 40 44 53
# 10 19 23 32 39 45 52 54
# 20 22 33 38 46 51 55 60
# 21 34 37 47 50 56 59 61
# 35 36 48 49 57 58 62 63 <- High frequency (fine detail/noise)
#
# Position (0,0) is the DC coefficient - the average brightness of the block.
# We NEVER touch DC because changing it causes visible brightness shifts.
EMBED_POSITIONS = [
(0, 1),
(1, 0),
(2, 0),
(0, 1), # 1st AC coefficient
(1, 0), # 2nd AC coefficient
(2, 0), # ... and so on in zig-zag order
(1, 1),
(0, 2),
(0, 3),
@@ -96,32 +152,59 @@ EMBED_POSITIONS = [
(6, 1),
(7, 0),
]
# We use positions 4-20 (mid-frequency range). Here's the reasoning:
# - Positions 0-3: Too low frequency, changes are visible as color shifts
# - Positions 4-20: Sweet spot - carries enough energy to survive, not visible
# - Positions 21+: High frequency, often quantized to zero, unreliable
DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20]
# Quantization step for QIM (Quantization Index Modulation).
# This is how we actually embed bits: we round the coefficient to a grid
# and then nudge it based on whether we want a 0 or 1.
# Bigger step = more robust to noise, but more visible. 25 is a good balance.
QUANT_STEP = 25
DCT_MAGIC = b"DCTS"
HEADER_SIZE = 10
# Magic bytes so we can identify our own images
DCT_MAGIC = b"DCTS" # scipy DCT mode marker
JPEGIO_MAGIC = b"JPGS" # jpegio native JPEG mode marker
HEADER_SIZE = 10 # Magic (4) + version (1) + flags (1) + length (4)
OUTPUT_FORMAT_PNG = "png"
OUTPUT_FORMAT_JPEG = "jpeg"
JPEG_OUTPUT_QUALITY = 95
JPEGIO_MAGIC = b"JPGS"
JPEG_OUTPUT_QUALITY = 95 # High quality but not 100 (100 causes issues, see below)
# For jpegio mode: we only embed in coefficients with magnitude >= 2
# Coefficients of 0 or 1 are usually quantized noise - unreliable
JPEGIO_MIN_COEF_MAGNITUDE = 2
# We embed in the Y (luminance) channel only - it has the most capacity
# Cb/Cr are often subsampled 4:2:0 anyway
JPEGIO_EMBED_CHANNEL = 0
FLAG_COLOR_MODE = 0x01
FLAG_RS_PROTECTED = 0x02 # Reed-Solomon error correction enabled
# Reed-Solomon settings - 32 symbols can correct up to 16 byte errors per 223-byte chunk
# Header flags
FLAG_COLOR_MODE = 0x01 # Set if we preserved color (YCbCr mode)
FLAG_RS_PROTECTED = 0x02 # Set if Reed-Solomon protected (v4.1.0+)
# Reed-Solomon settings - the "please don't lose my data" system
# 32 parity symbols per chunk means we can correct up to 16 byte errors
# Math: RS(255, 223) where 255-223=32 parity bytes, corrects floor(32/2)=16
RS_NSYM = 32
RS_LENGTH_HEADER_SIZE = 8 # 8 bytes: 4 for raw_payload_length + 4 for rs_payload_length
RS_LENGTH_COPIES = 3 # Store length header 3 times for majority voting
RS_LENGTH_PREFIX_SIZE = RS_LENGTH_HEADER_SIZE * RS_LENGTH_COPIES # Total: 24 bytes
# Chunking settings for large images
MAX_CHUNK_HEIGHT = 512 # Process in 512-pixel tall strips
# We store the payload length 3 times and take majority vote
# Because if the length is wrong, everything is wrong
RS_LENGTH_HEADER_SIZE = 8 # 4 bytes raw length + 4 bytes RS-encoded length
RS_LENGTH_COPIES = 3 # Store 3 copies, need 2 to agree
RS_LENGTH_PREFIX_SIZE = RS_LENGTH_HEADER_SIZE * RS_LENGTH_COPIES # 24 bytes total
# JPEG normalization settings
# JPEGs with quality=100 have all quantization values = 1, which crashes jpegio
JPEGIO_NORMALIZE_QUALITY = 95 # Re-save quality for problematic JPEGs
JPEGIO_MAX_QUANT_VALUE_THRESHOLD = 1 # If all quant values <= this, normalize
# Chunking for large images - scipy's FFT gets memory-corrupty on huge arrays
MAX_CHUNK_HEIGHT = 512 # Process in strips to keep memory sane
# Fun bug: JPEGs saved with quality=100 have quantization tables full of 1s
# This makes the DCT coefficients HUGE and jpegio crashes spectacularly
# Solution: detect and re-save at quality 95 first
JPEGIO_NORMALIZE_QUALITY = 95
JPEGIO_MAX_QUANT_VALUE_THRESHOLD = 1 # All 1s in quant table = bad news
# ============================================================================
@@ -181,13 +264,26 @@ def has_jpegio_support() -> bool:
# ============================================================================
# REED-SOLOMON ERROR CORRECTION
# Protects against bit errors in problematic image blocks
# ============================================================================
#
# Why do we need this? DCT embedding isn't perfect. Some image regions are
# problematic - flat areas, high compression, edge cases. Bits can flip.
#
# Reed-Solomon is the same error correction used in CDs, DVDs, QR codes, and
# deep space communications. If it's good enough for Voyager, it's good enough
# for hiding cat pictures in other cat pictures.
#
# How it works (simplified):
# 1. Take your data bytes
# 2. Add extra "parity" bytes calculated from the data
# 3. If some bytes get corrupted, the math lets you reconstruct them
# 4. RS(255, 223) means: 255 byte blocks, 223 data + 32 parity
# 5. Can correct up to 16 corrupted bytes per block (floor(32/2))
#
# The tradeoff: ~14% overhead (32/223). Worth it for reliability.
# Check for reedsolo availability
try:
from reedsolo import RSCodec, ReedSolomonError
from reedsolo import ReedSolomonError, RSCodec
HAS_REEDSOLO = True
except ImportError:
HAS_REEDSOLO = False
@@ -196,48 +292,78 @@ except ImportError:
def _rs_encode(data: bytes) -> bytes:
"""Add Reed-Solomon error correction symbols to data."""
"""
Wrap data in Reed-Solomon error correction.
Takes your precious payload and adds parity bytes so we can
recover from the inevitable bit-rot of DCT embedding.
"""
if not HAS_REEDSOLO:
return data # No protection if reedsolo not available
return data # YOLO mode - no protection, good luck
rs = RSCodec(RS_NSYM)
return bytes(rs.encode(data))
def _rs_decode(data: bytes) -> bytes:
"""Decode Reed-Solomon protected data, correcting errors if possible."""
"""
Decode Reed-Solomon protected data, fixing errors along the way.
This is where the magic happens. If bits got flipped during
extraction, RS will quietly fix them. If too many flipped...
well, we tried.
"""
if not HAS_REEDSOLO:
return data # No decoding if reedsolo not available
return data
rs = RSCodec(RS_NSYM)
try:
decoded, _, errata_pos = rs.decode(data)
if errata_pos:
pass # Errors were corrected
# Errors were found and corrected - RS earned its keep today
pass
return bytes(decoded)
except ReedSolomonError as e:
raise ValueError(f"Reed-Solomon decoding failed: {e}") from e
# Too many errors - the image got mangled beyond repair
raise StegasooRSError(f"Image corrupted beyond repair: {e}") from e
# ============================================================================
# SAFE DCT FUNCTIONS
# These create fresh arrays to avoid scipy memory corruption issues
# ============================================================================
#
# Story time: scipy's fftpack (the old DCT implementation) has memory issues
# when you process large images. We'd get random garbage in our output, or
# worse, segfaults. Turns out it was reusing internal buffers in unsafe ways.
#
# The fix? Be paranoid. Every single array operation creates a fresh copy.
# Is it slower? Yes. Does it work? Also yes. I'll take correct over fast.
#
# The newer scipy.fft module is better, but we still play it safe because
# not everyone has the latest scipy and I don't want debugging nightmares.
def _safe_dct2(block: np.ndarray) -> np.ndarray:
"""
Apply 2D DCT with memory isolation.
Creates a completely fresh array to avoid heap corruption.
Apply 2D DCT (Discrete Cosine Transform) to an 8x8 block.
The DCT converts spatial data (pixel values) into frequency data
(how much of each frequency component is present). It's the heart
of JPEG compression.
We do it row-by-row and column-by-column with fresh arrays each time
because scipy's built-in dct2 can corrupt memory on large batches.
Paranoid? Yes. Necessary? Also yes.
"""
# Create a brand new array (not a view)
# Create a brand new array (not a view) - paranoia level: maximum
safe_block = np.array(block, dtype=np.float64, copy=True, order="C")
# First DCT on columns (transpose -> DCT rows -> transpose back)
# 2D DCT = 1D DCT on rows, then 1D DCT on columns (separable transform)
# First pass: DCT each column
temp = np.zeros_like(safe_block, dtype=np.float64, order="C")
for i in range(BLOCK_SIZE):
col = np.array(safe_block[:, i], dtype=np.float64, copy=True)
temp[:, i] = dct(col, norm="ortho")
temp[:, i] = dct(col, norm="ortho") # ortho normalization for symmetry
# Second DCT on rows
# Second pass: DCT each row of the result
result = np.zeros_like(temp, dtype=np.float64, order="C")
for i in range(BLOCK_SIZE):
row = np.array(temp[i, :], dtype=np.float64, copy=True)
@@ -248,19 +374,22 @@ def _safe_dct2(block: np.ndarray) -> np.ndarray:
def _safe_idct2(block: np.ndarray) -> np.ndarray:
"""
Apply 2D inverse DCT with memory isolation.
Creates a completely fresh array to avoid heap corruption.
Apply 2D inverse DCT - convert frequency data back to pixels.
After we've embedded our secret bits in the DCT coefficients,
we need to convert back to pixel values. This is the reverse
of _safe_dct2.
Same paranoid memory handling because same paranoid developer.
"""
# Create a brand new array (not a view)
safe_block = np.array(block, dtype=np.float64, copy=True, order="C")
# First IDCT on rows
# Inverse is the same idea: IDCT rows, then IDCT columns
temp = np.zeros_like(safe_block, dtype=np.float64, order="C")
for i in range(BLOCK_SIZE):
row = np.array(safe_block[i, :], dtype=np.float64, copy=True)
temp[i, :] = idct(row, norm="ortho")
# Second IDCT on columns
result = np.zeros_like(temp, dtype=np.float64, order="C")
for i in range(BLOCK_SIZE):
col = np.array(temp[:, i], dtype=np.float64, copy=True)
@@ -320,8 +449,25 @@ def _unpad_image(image: np.ndarray, original_size: tuple[int, int]) -> np.ndarra
def _embed_bit_in_coeff(coef: float, bit: int, quant_step: int = QUANT_STEP) -> float:
"""
Embed a single bit into a DCT coefficient using QIM.
QIM (Quantization Index Modulation) is smarter than simple LSB flipping.
Instead of just changing the last bit, we round to a quantization grid
and use odd/even to encode 0/1.
Why is this better?
- More robust to noise (small changes don't flip the bit)
- Works naturally with JPEG's own quantization
- The change is spread across the coefficient's magnitude
Visual example (quant_step=25):
- Coef = 73, want bit=0 -> round to 75 (75/25=3, 3%2=1) -> nudge to 50 (50/25=2, 2%2=0)
- Coef = 73, want bit=1 -> round to 75 (75/25=3, 3%2=1) -> already odd, keep at 75
"""
quantized = round(coef / quant_step)
if (quantized % 2) != bit:
# Need to flip even<->odd. Nudge in the direction that's closest.
if quantized % 2 == 0 and bit == 1:
quantized += 1 if coef >= quantized * quant_step else -1
elif quantized % 2 == 1 and bit == 0:
@@ -330,13 +476,35 @@ def _embed_bit_in_coeff(coef: float, bit: int, quant_step: int = QUANT_STEP) ->
def _extract_bit_from_coeff(coef: float, quant_step: int = QUANT_STEP) -> int:
"""
Extract a bit from a DCT coefficient.
The inverse of _embed_bit_in_coeff. We round to the quantization grid
and check if it's odd (1) or even (0).
This is why QIM is robust: small noise in the coefficient usually
doesn't change which grid point we round to.
"""
quantized = round(coef / quant_step)
return int(quantized % 2)
def _generate_block_order(num_blocks: int, seed: bytes) -> list:
"""
Generate a pseudo-random order for processing blocks.
This is crucial for security - if we just went left-to-right, top-to-bottom,
anyone could find the message by checking blocks in order. Instead, we
use a keyed shuffle so only someone with the same seed can find the data.
The seed comes from the crypto layer (derived from passphrase + photo + pin),
so the block order is effectively part of the encryption.
"""
# Use SHA-256 to expand the seed into randomness
hash_bytes = hashlib.sha256(seed).digest()
# Seed numpy's RNG (we use RandomState for reproducibility across versions)
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], "big"))
# Fisher-Yates shuffle
order = list(range(num_blocks))
rng.shuffle(order)
return order
@@ -365,14 +533,28 @@ def _save_color_image(rgb_array: np.ndarray, output_format: str = OUTPUT_FORMAT_
def _rgb_to_ycbcr(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Convert RGB to YCbCr color space.
YCbCr separates brightness (Y) from color (Cb=blue-ish, Cr=red-ish).
This is what JPEG uses internally, and it's great for us because:
- Human eyes are WAY more sensitive to brightness than color
- We can hide data in Y without it being as visible
- Cb/Cr are often subsampled (4:2:0) so Y has more capacity anyway
The coefficients here are from ITU-R BT.601 - the standard for video.
"""
R = rgb[:, :, 0].astype(np.float64)
G = rgb[:, :, 1].astype(np.float64)
B = rgb[:, :, 2].astype(np.float64)
# Y = luminance (brightness). Green contributes most because eyes are most sensitive to it.
Y = np.array(0.299 * R + 0.587 * G + 0.114 * B, dtype=np.float64, copy=True, order="C")
# Cb = blue-difference chroma (centered at 128)
Cb = np.array(
128 - 0.168736 * R - 0.331264 * G + 0.5 * B, dtype=np.float64, copy=True, order="C"
)
# Cr = red-difference chroma (centered at 128)
Cr = np.array(
128 + 0.5 * R - 0.418688 * G - 0.081312 * B, dtype=np.float64, copy=True, order="C"
)
@@ -381,6 +563,12 @@ def _rgb_to_ycbcr(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
def _ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray) -> np.ndarray:
"""
Convert YCbCr back to RGB.
After embedding in the Y channel, we need to reconstruct RGB for display.
The Cb/Cr channels are unchanged - we only touched luminance.
"""
R = Y + 1.402 * (Cr - 128)
G = Y - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 128)
B = Y + 1.772 * (Cb - 128)
@@ -410,7 +598,7 @@ def _parse_header(header_bits: list) -> tuple[int, int, int]:
magic, version, flags, length = struct.unpack(">4sBBI", header_bytes)
if magic != DCT_MAGIC:
raise ValueError("Invalid DCT stego magic bytes")
raise InvalidMagicBytesError("Not a Stegasoo image or wrong mode (try LSB instead of DCT)")
return version, flags, length
@@ -461,7 +649,7 @@ def _jpegio_parse_header(header_bytes: bytes) -> tuple[int, int, int]:
raise ValueError("Insufficient header data")
magic, version, flags, length = struct.unpack(">4sBBI", header_bytes[:HEADER_SIZE])
if magic != JPEGIO_MAGIC:
raise ValueError(f"Invalid JPEG stego magic: {magic}")
raise InvalidMagicBytesError("Not a Stegasoo JPEG or wrong mode")
return version, flags, length
@@ -556,6 +744,7 @@ def embed_in_dct(
seed: bytes,
output_format: str = OUTPUT_FORMAT_PNG,
color_mode: str = "color",
progress_file: str | None = None,
) -> tuple[bytes, DCTEmbedStats]:
"""Embed data using DCT coefficient modification."""
if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG):
@@ -565,10 +754,12 @@ def embed_in_dct(
color_mode = "color"
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
return _embed_jpegio(data, carrier_image, seed, color_mode)
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
_check_scipy()
return _embed_scipy_dct_safe(data, carrier_image, seed, output_format, color_mode)
return _embed_scipy_dct_safe(
data, carrier_image, seed, output_format, color_mode, progress_file
)
def _embed_scipy_dct_safe(
@@ -577,6 +768,7 @@ def _embed_scipy_dct_safe(
seed: bytes,
output_format: str,
color_mode: str = "color",
progress_file: str | None = None,
) -> tuple[bytes, DCTEmbedStats]:
"""
Embed using scipy DCT with safe memory handling.
@@ -639,7 +831,7 @@ def _embed_scipy_dct_safe(
gc.collect()
# Embed in Y channel
Y_embedded = _embed_in_channel_safe(Y_padded, bits, block_order, blocks_x)
Y_embedded = _embed_in_channel_safe(Y_padded, bits, block_order, blocks_x, progress_file)
del Y_padded
gc.collect()
@@ -663,7 +855,7 @@ def _embed_scipy_dct_safe(
del image
gc.collect()
embedded = _embed_in_channel_safe(padded, bits, block_order, blocks_x)
embedded = _embed_in_channel_safe(padded, bits, block_order, blocks_x, progress_file)
del padded
gc.collect()
@@ -696,6 +888,7 @@ def _embed_in_channel_safe(
bits: list,
block_order: list,
blocks_x: int,
progress_file: str | None = None,
) -> np.ndarray:
"""
Embed bits in channel using safe DCT operations.
@@ -708,8 +901,9 @@ def _embed_in_channel_safe(
result = np.array(channel, dtype=np.float64, copy=True, order="C")
bit_idx = 0
total_blocks = len(block_order)
for block_num in block_order:
for block_idx, block_num in enumerate(block_order):
if bit_idx >= len(bits):
break
@@ -745,6 +939,14 @@ def _embed_in_channel_safe(
# Clean up this iteration
del block, dct_block, modified_block
# Report progress periodically
if progress_file and block_idx % PROGRESS_INTERVAL == 0:
_write_progress(progress_file, block_idx, total_blocks, "embedding")
# Final progress update
if progress_file:
_write_progress(progress_file, total_blocks, total_blocks, "finalizing")
# Force garbage collection
gc.collect()
@@ -801,6 +1003,7 @@ def _embed_jpegio(
carrier_image: bytes,
seed: bytes,
color_mode: str = "color",
progress_file: str | None = None,
) -> tuple[bytes, DCTEmbedStats]:
"""Embed using jpegio for proper JPEG coefficient modification."""
import os
@@ -858,6 +1061,9 @@ def _embed_jpegio(
)
coefs_used = 0
total_bits = len(bits)
progress_interval = max(total_bits // 20, 100) # Report ~20 times or every 100 bits
for bit_idx, pos_idx in enumerate(order):
if bit_idx >= len(bits):
break
@@ -873,6 +1079,14 @@ def _embed_jpegio(
coefs_used += 1
# Report progress periodically
if progress_file and bit_idx % progress_interval == 0:
_write_progress(progress_file, bit_idx, total_bits, "embedding")
# Final progress before save
if progress_file:
_write_progress(progress_file, total_bits, total_bits, "saving")
jio.write(jpeg, output_path)
with open(output_path, "rb") as f:
@@ -968,8 +1182,8 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
total_needed = (HEADER_SIZE + data_length) * 8
if len(all_bits) >= total_needed:
break
except ValueError:
pass
except (ValueError, InvalidMagicBytesError):
pass # RS-protected format has length prefix first, not magic bytes
del padded
gc.collect()
@@ -994,6 +1208,7 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
# Count occurrences of each unique copy
from collections import Counter
counter = Counter(copies)
best_header, count = counter.most_common(1)[0]
@@ -1006,9 +1221,13 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
# Sanity check: both lengths should be reasonable
max_reasonable = (len(all_bits) // 8) - RS_LENGTH_PREFIX_SIZE
if (raw_payload_length > 0 and raw_payload_length <= max_reasonable and
rs_encoded_length > 0 and rs_encoded_length <= max_reasonable and
rs_encoded_length >= raw_payload_length):
if (
raw_payload_length > 0
and raw_payload_length <= max_reasonable
and rs_encoded_length > 0
and rs_encoded_length <= max_reasonable
and rs_encoded_length >= raw_payload_length
):
# This looks like RS-protected format
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
@@ -1085,6 +1304,7 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
# Extract 3 copies and use majority voting
from collections import Counter
copies = []
for i in range(RS_LENGTH_COPIES):
start = i * RS_LENGTH_HEADER_SIZE
@@ -1101,9 +1321,13 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
# Sanity check
max_reasonable = (len(all_positions) // 8) - RS_LENGTH_PREFIX_SIZE
if (raw_payload_length > 0 and raw_payload_length <= max_reasonable and
rs_encoded_length > 0 and rs_encoded_length <= max_reasonable and
rs_encoded_length >= raw_payload_length):
if (
raw_payload_length > 0
and raw_payload_length <= max_reasonable
and rs_encoded_length > 0
and rs_encoded_length <= max_reasonable
and rs_encoded_length >= raw_payload_length
):
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
if len(all_positions) >= total_bits_needed:

View File

@@ -37,6 +37,7 @@ def encode(
dct_output_format: str = "png",
dct_color_mode: str = "color",
channel_key: str | bool | None = None,
progress_file: str | None = None,
) -> EncodeResult:
"""
Encode a message or file into an image.
@@ -118,6 +119,7 @@ def encode(
embed_mode=embed_mode,
dct_output_format=dct_output_format,
dct_color_mode=dct_color_mode,
progress_file=progress_file,
)
# Generate filename

View File

@@ -133,6 +133,30 @@ class InvalidHeaderError(SteganographyError):
pass
class InvalidMagicBytesError(SteganographyError):
"""Magic bytes don't match - not a Stegasoo image or wrong mode."""
pass
class ReedSolomonError(SteganographyError):
"""Reed-Solomon error correction failed - image too corrupted."""
pass
class NoDataFoundError(SteganographyError):
"""No hidden data found in image."""
pass
class ModeMismatchError(SteganographyError):
"""Wrong steganography mode (LSB vs DCT)."""
pass
# ============================================================================
# FILE ERRORS
# ============================================================================

View File

@@ -1,21 +1,27 @@
"""
Stegasoo Steganography Functions (v3.2.0)
LSB and DCT embedding modes with pseudo-random pixel/coefficient selection.
This is the core embedding/extraction module. Two modes available:
Changes in v3.0:
- DCT domain embedding mode (requires scipy)
- embed_mode parameter for encode/decode
- Auto-detection of embedding mode
- Comparison utilities
LSB (Least Significant Bit) Mode:
- Classic steganography technique - hide bits in the least significant bit of pixel values
- Works on any image, outputs lossless PNG/BMP
- Higher capacity than DCT, but destroyed by JPEG compression
- Great for: high-capacity needs, lossless workflows
Changes in v3.0.1:
- dct_output_format parameter for DCT mode ('png' or 'jpeg')
- dct_color_mode parameter for DCT mode ('grayscale' or 'color')
DCT Mode (see dct_steganography.py):
- Hides data in frequency-domain coefficients
- Survives some image processing, works with JPEG
- Lower capacity but more robust
- Great for: JPEG images, robustness needs
Changes in v3.2.0:
- Fixed HEADER_OVERHEAD constant (65 bytes, not 104 - date field removed)
- Updated ENCRYPTION_OVERHEAD calculation
Both modes use pseudo-random pixel/coefficient selection based on a key.
Without the key, you don't know where to look - security through obscurity
PLUS actual encryption of the payload.
v3.0: Added DCT mode with scipy
v3.0.1: DCT output format options (PNG/JPEG, grayscale/color)
v3.2.0: Fixed overhead calculations after removing date field
"""
import io
@@ -39,6 +45,31 @@ from .debug import debug
from .exceptions import CapacityError, EmbeddingError
from .models import EmbedStats, FilePayload
# Progress reporting interval
PROGRESS_INTERVAL = 1000 # Write every N pixels for LSB
def _write_progress(progress_file: str | None, current: int, total: int, phase: str = "embedding"):
"""Write progress to file for frontend polling."""
if progress_file is None:
return
try:
import json
with open(progress_file, "w") as f:
json.dump(
{
"current": current,
"total": total,
"percent": round((current / total) * 100, 1) if total > 0 else 0,
"phase": phase,
},
f,
)
except Exception:
pass # Don't let progress writing break encoding
# Lossless formats that preserve LSB data
LOSSLESS_FORMATS = {"PNG", "BMP", "TIFF"}
@@ -58,24 +89,31 @@ EXT_TO_FORMAT = {
}
# =============================================================================
# OVERHEAD CONSTANTS (v4.0.0 - Updated for channel key support)
# OVERHEAD CONSTANTS
# =============================================================================
# v4.0.0 Header format (with flags byte for channel key indicator):
# Magic: 4 bytes (\x89ST3)
# Version: 1 byte (5 for v4.0.0)
# Flags: 1 byte (bit 0 = has channel key)
# Salt: 32 bytes
# IV: 12 bytes
# Tag: 16 bytes
# -----------------
# Total: 66 bytes
#
# v3.2.0 had 65 bytes (no flags byte)
# v3.1.0 had date field (10 bytes + 1 byte length) = 76 bytes header
# Every stego image has some overhead before the actual payload:
#
# The encrypted message format (v4.0.0):
# ┌─────────────────────────────────────────────────────────────────┐
# │ \x89ST3 │ v5 │ flags │ salt (32) │ iv (12) │ tag (16) │ ... │
# │ magic │ ver│ │ │ │ │ data│
# └─────────────────────────────────────────────────────────────────┘
# 4 bytes 1 1 32 12 16 var
#
# Plus LSB embedding adds a 4-byte length prefix so we know where to stop.
#
# History of overhead sizes (in case you're debugging old images):
# - v3.1.0: 76 bytes (had date field - 10+1 bytes)
# - v3.2.0: 65 bytes (removed date, simpler)
# - v4.0.0: 66 bytes (added flags byte for channel key)
HEADER_OVERHEAD = 66 # v4.0.0: Magic + version + flags + salt + iv + tag
LENGTH_PREFIX = 4 # 4 bytes for payload length in LSB embedding
ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX # 70 bytes total
HEADER_OVERHEAD = 66 # What the crypto layer adds to any message
LENGTH_PREFIX = 4 # We prepend the payload length for LSB extraction
ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX # Total: 70 bytes
# That 70 bytes is your minimum image capacity requirement.
# A tiny 100x100 image gives you ~3750 bytes capacity, minus 70 = ~3680 usable.
# DCT output format options (v3.0.1)
DCT_OUTPUT_PNG = "png"
@@ -431,6 +469,20 @@ def compare_modes(image_data: bytes) -> dict:
# =============================================================================
# PIXEL INDEX GENERATION
# =============================================================================
#
# The key insight: we don't hide data in sequential pixels (that's easy to find).
# Instead, we scatter the data across pseudo-random pixel locations.
#
# The pixel selection key (derived from passphrase + photo + pin) determines
# WHICH pixels get modified. Without the key, an attacker would have to:
# 1. Know we're using LSB steganography
# 2. Try every possible subset of pixels
# 3. Decrypt the result (which they also can't do without the key)
#
# We use ChaCha20 as a CSPRNG (Cryptographically Secure PRNG). It's:
# - Fast (faster than AES-CTR on most CPUs)
# - Deterministic (same key = same sequence, needed for extraction)
# - Secure (can't predict the sequence without the key)
@debug.time
@@ -438,8 +490,13 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
"""
Generate pseudo-random pixel indices for embedding.
Uses ChaCha20 as a CSPRNG seeded by the key to deterministically
select which pixels will hold hidden data.
This is the "where do we hide the bits?" function. We use ChaCha20
to generate a deterministic sequence of pixel indices that only
someone with the same key can reproduce.
Two strategies based on how much of the image we're using:
- >= 50% capacity: Full Fisher-Yates shuffle (sample without replacement)
- < 50% capacity: Direct random sampling (faster, same result)
"""
debug.validate(len(key) == 32, f"Pixel key must be 32 bytes, got {len(key)}")
debug.validate(num_pixels > 0, f"Number of pixels must be positive, got {num_pixels}")
@@ -450,6 +507,8 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
debug.print(f"Generating {num_needed} pixel indices from {num_pixels} total pixels")
# Strategy 1: Full shuffle when we need a lot of pixels
# Fisher-Yates shuffle is O(n) and gives us perfect random sampling
if num_needed >= num_pixels // 2:
debug.print(f"Using full shuffle (needed {num_needed}/{num_pixels} pixels)")
nonce = b"\x00" * 16
@@ -457,8 +516,10 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
encryptor = cipher.encryptor()
indices = list(range(num_pixels))
# Get enough random bytes to do the shuffle
random_bytes = encryptor.update(b"\x00" * (num_pixels * 4))
# Fisher-Yates shuffle - swap each element with a random earlier element
for i in range(num_pixels - 1, 0, -1):
j_bytes = random_bytes[(num_pixels - 1 - i) * 4 : (num_pixels - i) * 4]
j = int.from_bytes(j_bytes, "big") % (i + 1)
@@ -468,14 +529,17 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
debug.print(f"Generated {len(selected)} indices via shuffle")
return selected
# Strategy 2: Direct sampling when we need fewer pixels
# Generate random indices until we have enough unique ones
debug.print(f"Using optimized selection (needed {num_needed}/{num_pixels} pixels)")
selected = []
used = set()
used = set() # Track which pixels we've already picked
nonce = b"\x00" * 16
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
encryptor = cipher.encryptor()
# Pre-generate 2x the bytes we think we'll need (for collision handling)
bytes_needed = (num_needed * 2) * 4
random_bytes = encryptor.update(b"\x00" * bytes_needed)
@@ -489,8 +553,9 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
used.add(idx)
selected.append(idx)
else:
collisions += 1
collisions += 1 # Birthday paradox in action
# Edge case: ran out of pre-generated bytes (very high collision rate)
if len(selected) < num_needed:
debug.print(f"Need {num_needed - len(selected)} more indices, generating...")
extra_needed = num_needed - len(selected)
@@ -514,6 +579,23 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
# =============================================================================
# EMBEDDING FUNCTIONS
# =============================================================================
#
# The actual bit-hiding magic happens here. LSB embedding is conceptually simple:
#
# Original pixel RGB: (142, 87, 201)
# In binary: (10001110, 01010111, 11001001)
# ^ ^ ^
# These are the LSBs (least significant bits)
#
# To hide the bits [1, 0, 1]:
# Modified pixel RGB: (10001111, 01010110, 11001001) = (143, 86, 201)
# ^ ^ ^
# Changed! Changed! Already 1, no change needed
#
# The human eye can't see the difference between 142 and 143.
# But we've hidden 3 bits of secret data in one pixel.
#
# With a 1000x1000 image: 1 million pixels * 3 channels = 3 million bits = 375 KB!
@debug.time
@@ -526,6 +608,7 @@ def embed_in_image(
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = DCT_OUTPUT_PNG,
dct_color_mode: str = "color",
progress_file: str | None = None,
) -> tuple[bytes, Union[EmbedStats, "DCTEmbedStats"], str]:
"""
Embed data into an image using specified mode.
@@ -579,6 +662,7 @@ def embed_in_image(
pixel_key,
output_format=dct_output_format,
color_mode=dct_color_mode,
progress_file=progress_file,
)
# Determine extension based on output format
@@ -594,7 +678,7 @@ def embed_in_image(
return stego_bytes, dct_stats, ext
# LSB MODE
return _embed_lsb(data, image_data, pixel_key, bits_per_channel, output_format)
return _embed_lsb(data, image_data, pixel_key, bits_per_channel, output_format, progress_file)
def _embed_lsb(
@@ -603,6 +687,7 @@ def _embed_lsb(
pixel_key: bytes,
bits_per_channel: int = 1,
output_format: str | None = None,
progress_file: str | None = None,
) -> tuple[bytes, EmbedStats, str]:
"""
Embed data using LSB steganography (internal implementation).
@@ -659,8 +744,9 @@ def _embed_lsb(
bit_idx = 0
modified_pixels = 0
total_pixels_to_process = len(selected_indices)
for pixel_idx in selected_indices:
for progress_idx, pixel_idx in enumerate(selected_indices):
if bit_idx >= len(binary_data):
break
@@ -690,6 +776,16 @@ def _embed_lsb(
new_pixels[pixel_idx] = (r, g, b)
modified_pixels += 1
# Report progress periodically
if progress_file and progress_idx % PROGRESS_INTERVAL == 0:
_write_progress(progress_file, progress_idx, total_pixels_to_process, "embedding")
# Final progress before save
if progress_file:
_write_progress(
progress_file, total_pixels_to_process, total_pixels_to_process, "saving"
)
debug.print(f"Modified {modified_pixels} pixels (out of {len(selected_indices)} selected)")
stego_img = Image.new("RGB", img.size)

View File

View File

@@ -1,434 +0,0 @@
"""
Tests for Stegasoo batch processing module (v4.0.0).
Updated for v4.0.0:
- Uses 'passphrase' instead of 'phrase' in credentials dict
- No date_str parameter
- BatchCredentials.passphrase is a single string
"""
import shutil
import tempfile
from pathlib import Path
from unittest.mock import Mock
import pytest
from stegasoo.batch import (
BatchCredentials,
BatchItem,
BatchProcessor,
BatchResult,
BatchStatus,
batch_capacity_check,
print_batch_result,
)
@pytest.fixture
def temp_dir():
"""Create a temporary directory for tests."""
path = Path(tempfile.mkdtemp())
yield path
shutil.rmtree(path)
@pytest.fixture
def sample_images(temp_dir):
"""Create sample PNG images for testing."""
from PIL import Image
images = []
for i in range(3):
img_path = temp_dir / f"test_image_{i}.png"
img = Image.new("RGB", (100, 100), color=(i * 50, i * 50, i * 50))
img.save(img_path, "PNG")
images.append(img_path)
return images
@pytest.fixture
def sample_reference_photo():
"""Create a sample reference photo as bytes."""
from io import BytesIO
from PIL import Image
img = Image.new("RGB", (100, 100), color=(128, 128, 128))
buf = BytesIO()
img.save(buf, "PNG")
return buf.getvalue()
@pytest.fixture
def sample_credentials(sample_reference_photo):
"""Create sample v3.2.0 credentials dict."""
return {
"reference_photo": sample_reference_photo,
"passphrase": "test phrase four words", # v3.2.0: single passphrase
"pin": "123456",
}
class TestBatchItem:
"""Tests for BatchItem dataclass."""
def test_duration_calculation(self):
"""Duration should be calculated from start/end times."""
item = BatchItem(input_path=Path("test.png"))
item.start_time = 100.0
item.end_time = 105.5
assert item.duration == 5.5
def test_duration_none_without_times(self):
"""Duration should be None if times not set."""
item = BatchItem(input_path=Path("test.png"))
assert item.duration is None
def test_to_dict(self):
"""to_dict should serialize all fields."""
item = BatchItem(
input_path=Path("input.png"),
output_path=Path("output.png"),
status=BatchStatus.SUCCESS,
message="Done",
)
result = item.to_dict()
assert result["input_path"] == "input.png"
assert result["output_path"] == "output.png"
assert result["status"] == "success"
class TestBatchResult:
"""Tests for BatchResult dataclass."""
def test_to_json(self):
"""Should serialize to valid JSON."""
import json
result = BatchResult(operation="encode", total=5, succeeded=4, failed=1)
json_str = result.to_json()
parsed = json.loads(json_str)
assert parsed["operation"] == "encode"
assert parsed["summary"]["total"] == 5
def test_duration_with_end_time(self):
"""Duration should work when end_time is set."""
result = BatchResult(operation="test")
result.start_time = 100.0
result.end_time = 110.0
assert result.duration == 10.0
class TestBatchCredentials:
"""Tests for BatchCredentials dataclass (v3.2.0)."""
def test_from_dict_new_format(self, sample_reference_photo):
"""Should parse v3.2.0 format with 'passphrase' key."""
data = {
"reference_photo": sample_reference_photo,
"passphrase": "test phrase four words",
"pin": "123456",
}
creds = BatchCredentials.from_dict(data)
assert creds.passphrase == "test phrase four words"
assert creds.pin == "123456"
def test_from_dict_legacy_format(self, sample_reference_photo):
"""Should parse legacy format with 'day_phrase' key for migration."""
data = {
"reference_photo": sample_reference_photo,
"day_phrase": "legacy phrase here", # Old key name
"pin": "123456",
}
creds = BatchCredentials.from_dict(data)
# Should accept old key and map to passphrase
assert creds.passphrase == "legacy phrase here"
assert creds.pin == "123456"
def test_to_dict(self, sample_reference_photo):
"""Should serialize to v3.2.0 format."""
creds = BatchCredentials(
reference_photo=sample_reference_photo,
passphrase="test phrase four words",
pin="123456",
)
result = creds.to_dict()
assert result["passphrase"] == "test phrase four words"
assert result["pin"] == "123456"
assert "day_phrase" not in result # Old key should not be present
def test_passphrase_is_string(self, sample_reference_photo):
"""Passphrase should be a string, not a dict."""
creds = BatchCredentials(
reference_photo=sample_reference_photo,
passphrase="test phrase four words",
pin="123456",
)
assert isinstance(creds.passphrase, str)
class TestBatchProcessor:
"""Tests for BatchProcessor class."""
def test_init_default_workers(self):
"""Should default to 4 workers."""
processor = BatchProcessor()
assert processor.max_workers == 4
def test_init_custom_workers(self):
"""Should accept custom worker count."""
processor = BatchProcessor(max_workers=8)
assert processor.max_workers == 8
def test_is_valid_image_png(self, temp_dir):
"""Should recognize PNG as valid."""
processor = BatchProcessor()
png_path = temp_dir / "test.png"
png_path.touch()
assert processor._is_valid_image(png_path)
def test_is_valid_image_txt(self, temp_dir):
"""Should reject non-image files."""
processor = BatchProcessor()
txt_path = temp_dir / "test.txt"
txt_path.touch()
assert not processor._is_valid_image(txt_path)
def test_find_images_file(self, sample_images):
"""Should find single image file."""
processor = BatchProcessor()
results = list(processor.find_images([sample_images[0]]))
assert len(results) == 1
assert results[0] == sample_images[0]
def test_find_images_directory(self, sample_images, temp_dir):
"""Should find images in directory."""
processor = BatchProcessor()
results = list(processor.find_images([temp_dir]))
assert len(results) == 3
def test_find_images_recursive(self, temp_dir):
"""Should find images recursively."""
from PIL import Image
# Create nested directory
nested = temp_dir / "nested"
nested.mkdir()
img_path = nested / "nested.png"
img = Image.new("RGB", (50, 50))
img.save(img_path)
processor = BatchProcessor()
results = list(processor.find_images([temp_dir], recursive=True))
assert any(p.name == "nested.png" for p in results)
def test_batch_encode_requires_message_or_file(self, sample_images, sample_credentials):
"""Should raise if neither message nor file provided."""
processor = BatchProcessor()
with pytest.raises(ValueError, match="message or file_payload"):
processor.batch_encode(
images=sample_images,
credentials=sample_credentials,
)
def test_batch_encode_requires_credentials(self, sample_images):
"""Should raise if credentials not provided."""
processor = BatchProcessor()
with pytest.raises(ValueError, match="Credentials"):
processor.batch_encode(
images=sample_images,
message="test",
)
def test_batch_encode_accepts_passphrase_credentials(
self, sample_images, temp_dir, sample_credentials
):
"""Should accept v3.2.0 format credentials with passphrase."""
processor = BatchProcessor()
result = processor.batch_encode(
images=sample_images,
message="Test message",
output_dir=temp_dir / "output",
credentials=sample_credentials, # Uses 'passphrase' key
)
assert isinstance(result, BatchResult)
assert result.operation == "encode"
assert result.total == 3
def test_batch_encode_creates_result(self, sample_images, temp_dir, sample_credentials):
"""Should return BatchResult with correct structure."""
processor = BatchProcessor()
result = processor.batch_encode(
images=sample_images,
message="Test message",
output_dir=temp_dir / "output",
credentials=sample_credentials,
)
assert isinstance(result, BatchResult)
assert result.operation == "encode"
assert result.total == 3
assert len(result.items) == 3
def test_batch_decode_requires_credentials(self, sample_images):
"""Should raise if credentials not provided."""
processor = BatchProcessor()
with pytest.raises(ValueError, match="Credentials"):
processor.batch_decode(images=sample_images)
def test_batch_decode_accepts_passphrase_credentials(self, sample_images, sample_credentials):
"""Should accept v3.2.0 format credentials with passphrase."""
processor = BatchProcessor()
result = processor.batch_decode(
images=sample_images,
credentials=sample_credentials, # Uses 'passphrase' key
)
assert isinstance(result, BatchResult)
assert result.operation == "decode"
assert result.total == 3
def test_batch_decode_creates_result(self, sample_images, sample_credentials):
"""Should return BatchResult with correct structure."""
processor = BatchProcessor()
result = processor.batch_decode(
images=sample_images,
credentials=sample_credentials,
)
assert isinstance(result, BatchResult)
assert result.operation == "decode"
assert result.total == 3
def test_progress_callback_called(self, sample_images, sample_credentials):
"""Progress callback should be called for each item."""
processor = BatchProcessor()
callback = Mock()
processor.batch_encode(
images=sample_images,
message="Test",
credentials=sample_credentials,
progress_callback=callback,
)
assert callback.call_count == 3
def test_custom_encode_func(self, sample_images, temp_dir, sample_credentials):
"""Should use custom encode function if provided."""
processor = BatchProcessor()
encode_mock = Mock()
processor.batch_encode(
images=sample_images,
message="Test",
output_dir=temp_dir / "output",
credentials=sample_credentials,
encode_func=encode_mock,
)
assert encode_mock.call_count == 3
class TestBatchCapacityCheck:
"""Tests for batch_capacity_check function."""
def test_returns_list(self, sample_images):
"""Should return list of results."""
results = batch_capacity_check(sample_images)
assert isinstance(results, list)
assert len(results) == 3
def test_includes_capacity(self, sample_images):
"""Results should include capacity info."""
results = batch_capacity_check(sample_images)
for item in results:
assert "capacity_bytes" in item
assert "dimensions" in item
assert "valid" in item
def test_handles_invalid_files(self, temp_dir):
"""Should handle non-image files gracefully."""
bad_file = temp_dir / "not_an_image.png"
bad_file.write_bytes(b"not a png")
results = batch_capacity_check([bad_file])
assert len(results) == 1
assert "error" in results[0]
class TestPrintBatchResult:
"""Tests for print_batch_result function."""
def test_prints_summary(self, capsys, sample_images):
"""Should print summary without errors."""
result = BatchResult(
operation="encode",
total=3,
succeeded=2,
failed=1,
)
result.end_time = result.start_time + 5.0
print_batch_result(result)
captured = capsys.readouterr()
assert "ENCODE" in captured.out
assert "3" in captured.out # total
assert "2" in captured.out # succeeded
def test_verbose_shows_items(self, capsys):
"""Verbose mode should show individual items."""
result = BatchResult(operation="decode", total=1, succeeded=1)
result.items = [
BatchItem(
input_path=Path("test.png"),
status=BatchStatus.SUCCESS,
message="Decoded successfully",
)
]
result.end_time = result.start_time + 1.0
print_batch_result(result, verbose=True)
captured = capsys.readouterr()
assert "test.png" in captured.out
class TestCredentialsMigration:
"""Tests for v3.1.x to v3.2.0 credentials migration."""
def test_old_phrase_key_accepted(self, sample_reference_photo):
"""Old 'phrase' key should be accepted for migration."""
old_format = {
"reference_photo": sample_reference_photo,
"phrase": "old style phrase",
"pin": "123456",
}
# Should not raise
creds = BatchCredentials.from_dict(old_format)
assert creds.passphrase == "old style phrase"
def test_old_day_phrase_key_accepted(self, sample_reference_photo):
"""Old 'day_phrase' key should be accepted for migration."""
old_format = {
"reference_photo": sample_reference_photo,
"day_phrase": "old day phrase",
"pin": "123456",
}
creds = BatchCredentials.from_dict(old_format)
assert creds.passphrase == "old day phrase"
def test_new_passphrase_key_preferred(self, sample_reference_photo):
"""New 'passphrase' key should take precedence if both present."""
mixed_format = {
"reference_photo": sample_reference_photo,
"passphrase": "new style passphrase",
"day_phrase": "old day phrase",
"pin": "123456",
}
creds = BatchCredentials.from_dict(mixed_format)
assert creds.passphrase == "new style passphrase"

View File

@@ -1,181 +0,0 @@
"""
Tests for Stegasoo compression module.
"""
import pytest
from stegasoo.compression import (
COMPRESSION_MAGIC,
HAS_LZ4,
MIN_COMPRESS_SIZE,
CompressionAlgorithm,
CompressionError,
algorithm_name,
compress,
decompress,
estimate_compressed_size,
get_available_algorithms,
get_compression_ratio,
)
class TestCompress:
"""Tests for compress function."""
def test_compress_small_data_not_compressed(self):
"""Small data should not be compressed (overhead not worth it)."""
small_data = b"hello"
result = compress(small_data)
# Should have magic header but NONE algorithm
assert result.startswith(COMPRESSION_MAGIC)
assert result[4] == CompressionAlgorithm.NONE
def test_compress_zlib_reduces_size(self):
"""Zlib should reduce size for compressible data."""
# Highly compressible data
data = b"A" * 1000
result = compress(data, CompressionAlgorithm.ZLIB)
assert len(result) < len(data)
assert result.startswith(COMPRESSION_MAGIC)
assert result[4] == CompressionAlgorithm.ZLIB
def test_compress_incompressible_data(self):
"""Incompressible data should be stored uncompressed."""
import os
# Random data doesn't compress well
data = os.urandom(500)
result = compress(data, CompressionAlgorithm.ZLIB)
# Should fall back to NONE if compression didn't help
assert result.startswith(COMPRESSION_MAGIC)
def test_compress_none_algorithm(self):
"""NONE algorithm should just wrap data."""
data = b"Test data" * 100
result = compress(data, CompressionAlgorithm.NONE)
assert result.startswith(COMPRESSION_MAGIC)
assert result[4] == CompressionAlgorithm.NONE
# Data should be after 9-byte header
assert result[9:] == data
@pytest.mark.skipif(not HAS_LZ4, reason="LZ4 not installed")
def test_compress_lz4(self):
"""LZ4 compression should work if available."""
data = b"B" * 1000
result = compress(data, CompressionAlgorithm.LZ4)
assert len(result) < len(data)
assert result.startswith(COMPRESSION_MAGIC)
assert result[4] == CompressionAlgorithm.LZ4
class TestDecompress:
"""Tests for decompress function."""
def test_decompress_zlib(self):
"""Decompression should restore original data."""
original = b"Hello, World! " * 100
compressed = compress(original, CompressionAlgorithm.ZLIB)
result = decompress(compressed)
assert result == original
def test_decompress_none(self):
"""Uncompressed wrapped data should decompress correctly."""
original = b"Small data"
wrapped = compress(original, CompressionAlgorithm.NONE)
result = decompress(wrapped)
assert result == original
def test_decompress_no_magic(self):
"""Data without magic header should be returned as-is."""
data = b"Not compressed at all"
result = decompress(data)
assert result == data
def test_decompress_truncated_header(self):
"""Truncated header should raise CompressionError."""
bad_data = COMPRESSION_MAGIC + b"\x01" # Too short
with pytest.raises(CompressionError, match="Truncated"):
decompress(bad_data)
@pytest.mark.skipif(not HAS_LZ4, reason="LZ4 not installed")
def test_decompress_lz4(self):
"""LZ4 decompression should work."""
original = b"LZ4 test data " * 100
compressed = compress(original, CompressionAlgorithm.LZ4)
result = decompress(compressed)
assert result == original
def test_roundtrip_large_data(self):
"""Large data should survive compress/decompress roundtrip."""
import os
original = os.urandom(50000)
compressed = compress(original)
result = decompress(compressed)
assert result == original
class TestUtilities:
"""Tests for utility functions."""
def test_compression_ratio_compressed(self):
"""Ratio should be < 1 for well-compressed data."""
original = b"X" * 1000
compressed = compress(original)
ratio = get_compression_ratio(original, compressed)
assert ratio < 1.0
def test_compression_ratio_empty(self):
"""Empty data should return ratio of 1.0."""
ratio = get_compression_ratio(b"", b"")
assert ratio == 1.0
def test_estimate_compressed_size_small(self):
"""Small data estimation should be accurate."""
data = b"Test " * 100
estimate = estimate_compressed_size(data)
actual = len(compress(data))
# Should be within 20% for small data
assert abs(estimate - actual) / actual < 0.2
def test_available_algorithms(self):
"""Should always include NONE and ZLIB."""
algos = get_available_algorithms()
assert CompressionAlgorithm.NONE in algos
assert CompressionAlgorithm.ZLIB in algos
def test_algorithm_name(self):
"""Algorithm names should be human-readable."""
assert "Zlib" in algorithm_name(CompressionAlgorithm.ZLIB)
assert "None" in algorithm_name(CompressionAlgorithm.NONE)
assert "LZ4" in algorithm_name(CompressionAlgorithm.LZ4)
class TestEdgeCases:
"""Edge case tests."""
def test_empty_data(self):
"""Empty data should be handled gracefully."""
result = compress(b"")
assert decompress(result) == b""
def test_exact_min_size(self):
"""Data at exactly MIN_COMPRESS_SIZE should be compressed."""
data = b"x" * MIN_COMPRESS_SIZE
result = compress(data, CompressionAlgorithm.ZLIB)
assert result.startswith(COMPRESSION_MAGIC)
assert decompress(result) == data
def test_binary_data(self):
"""Binary data with null bytes should work."""
data = b"\x00\x01\x02\x03" * 500
compressed = compress(data)
assert decompress(compressed) == data
def test_unicode_after_encoding(self):
"""UTF-8 encoded Unicode should compress correctly."""
text = "Hello, 世界! 🎉 " * 100
data = text.encode("utf-8")
compressed = compress(data)
result = decompress(compressed)
assert result.decode("utf-8") == text

File diff suppressed because it is too large Load Diff