170 Commits

Author SHA1 Message Date
adlee-was-taken
1630d044aa Update WebUI screenshots for Encode/Decode pages
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 00:51:45 -05:00
adlee-was-taken
c2a0a731d7 Add READMEs for AUR CLI and API packages
- aur-cli: Lightweight CLI-only usage documentation
- aur-api: REST API with HTTPS/TLS configuration docs
- aur: Updated to reflect Python 3.11-3.14 support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:17:39 -05:00
adlee-was-taken
89de839fd8 Improve AUR post-install messages with service details
Add port numbers, HTTPS configuration instructions, and
systemctl enable commands to help users get started.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:15:15 -05:00
adlee-was-taken
49566292ba Fix channel badge centering when navbar collapses
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:13:17 -05:00
adlee-was-taken
9f0e0afeb6 Fix BIP-39 wordlist not found when installed via pip/AUR
Move bip39-words.txt into package data directory and use
importlib.resources for reliable path resolution. The wordlist
was previously only included in sdist but not in wheel builds.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:12:45 -05:00
Aaron D. Lee
398a359778 Add Windows Docker/WSL2 docs, update Python version to 3.11-3.14
- Windows: Add Docker Desktop and WSL2 options (recommended)
- Windows: Keep native install as advanced option
- Update Python badge and requirements tables

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:21:58 -05:00
Aaron D. Lee
86aa5cbddf Add pypi environment to release workflow
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:10:13 -05:00
Aaron D. Lee
2f54f80214 Update release notes for v4.2.1
Some checks failed
Release / test (push) Failing after 35s
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-12 23:00:20 -05:00
Aaron D. Lee
1cd2656e60 Update CI to Python 3.11-3.13 (drop 3.10 support)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:56:50 -05:00
Aaron D. Lee
ce728cec6e Update .SRCINFO files for v4.2.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:23:05 -05:00
Aaron D. Lee
555735a4fd Add Docker artifacts to gitignore
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:21:31 -05:00
Aaron D. Lee
08b70043e4 Update PKGBUILD versions and add AUR test scripts
- Update pkgver fallback to 4.2.1 in all PKGBUILDs
- Add test-aur-build.sh for Docker-based testing
- Add test-aur-nspawn.sh for systemd-nspawn testing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 20:31:00 -05:00
Aaron D. Lee
d395e5731e Fix CLI import paths for installed packages
The CLI api commands were using a hardcoded path to find frontends/
which didn't work when installed as a package. Now tries both:
- Development: .../stegasoo/frontends
- Installed: .../site-packages/frontends

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:29:06 -05:00
Aaron D. Lee
110b160e68 Bump version to 4.2.1
Release highlights:
- API key authentication (X-API-Key header)
- TLS with self-signed certificates
- CLI tools: compress, rotate, convert
- jpegtran lossless JPEG rotation
- AUR packages: stegasoo-cli-git, stegasoo-api-git
- Bug fixes: DCT rotation, jpegtran -trim, CLI output format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:18:13 -05:00
Aaron D. Lee
b09f607d34 Add stegasoo-api AUR package
New package in aur-api/ for API-only installation:
- Installs [api,cli,compression] extras
- Has fastapi/uvicorn for REST API
- No flask/gunicorn (web UI deps)
- 74MB package size
- Systemd service with TLS enabled by default

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:13:26 -05:00
Aaron D. Lee
34ede3815f Add API key authentication and TLS support
API Authentication (v4.2.1):
- API key auth via X-API-Key header
- Keys hashed (SHA-256) and stored in ~/.stegasoo/api_keys.json
- Auth disabled when no keys configured
- Protected endpoints: encode, decode, generate, channel/*, compare, etc.
- Public endpoints: /, /docs, /modes, /auth/status, /channel/status

TLS Support:
- Auto-generates self-signed certs on first run
- Certs include localhost, local IPs, hostname.local
- Stored in ~/.stegasoo/certs/

CLI Commands:
- stegasoo api keys list/create/delete
- stegasoo api tls generate/info
- stegasoo api serve (starts with TLS by default)

Updated systemd service to use TLS.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:03:51 -05:00
Aaron D. Lee
3b5ab41ce9 Add stegasoo-cli AUR package (CLI-only, no web deps)
New package in aur-cli/ for CLI-only installation:
- Installs [cli,dct,compression] extras only
- No flask/gunicorn/fastapi/uvicorn/pyzbar dependencies
- 68MB vs 79MB for full package
- Conflicts with stegasoo-git (can't install both)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 17:19:31 -05:00
Aaron D. Lee
525bcec3c9 Add compress, rotate, convert tools to CLI
Port Web UI image tools to CLI for parity:
- compress: JPEG compression with size reduction stats
- rotate: Rotation and flip with jpegtran for JPEGs (DCT-safe)
- convert: Format conversion between PNG, JPG, BMP, WebP

Rotate tool supports flip-only operations without rotation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 17:10:55 -05:00
Aaron D. Lee
afc8c93923 Fix CLI encode format detection and jpegtran -trim bug
CLI encode:
- Auto-detect output format from extension (.jpg → DCT mode, .png → LSB)
- Default to JPEG output for JPEG carriers (preserves DCT benefits)
- Pass embed_mode and dct_output_format to encode function

jpegtran fix (critical for rotation fallback):
- Remove -trim flag which was dropping edge blocks and destroying stego data
- Remove -perfect flag which fails on non-MCU-aligned images
- Plain jpegtran without flags works correctly for lossless rotation

This enables: encode → external rotation → decode to work correctly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 17:06:53 -05:00
Aaron D. Lee
38bef32750 Redesign EXIF viewer and compact tools UI
EXIF Viewer:
- Card-based grid layout with categories (Camera, Image, Date/Time, Exposure, GPS, Other)
- Icons for each category
- Truncation for long values with full value on hover

Tools UI:
- Reduced padding from 1.25rem to 0.5rem on all tool panels
- Smaller fonts for labels (0.55rem) and values (0.7rem)
- Compact headers and action buttons
- Tighter grid gaps and card padding

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 16:50:59 -05:00
Aaron D. Lee
4e3acfca20 Add jpegtran lossless rotation and EXIF orientation handling
DCT steganography improvements:
- Add _apply_exif_orientation() to fix portrait photos encoding rotated
- Add _jpegtran_rotate() for lossless JPEG rotation preserving DCT data
- Add rotation fallback in extract_from_dct() - tries 0°, 90°, 180°, 270°
- Quick header validation to skip invalid rotations efficiently
- Fix: wrap debug.print in try/except to prevent extraction failures

Web UI rotate tool:
- Use jpegtran for JPEGs (lossless, preserves DCT steganography)
- Fall back to PIL for non-JPEGs
- Dynamic UI shows "DCT Safe" for JPEGs, warning for other formats

This enables the workflow: encode → compress → rotate → decode
Rotated stego JPEGs can now be decoded by trying all orientations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 16:36:52 -05:00
Aaron D. Lee
2ebc42f2cd Fix EXIF viewer breaking on binary MakerNote fields
Some checks failed
Release / test (push) Failing after 43s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped
Pentax and other cameras have binary EXIF fields (MakerNote, etc.) that
contain raw bytes. The previous code used errors="replace" which still
produced strings with replacement characters that broke JSON parsing.

Now properly detect non-printable binary data and display as
"<N bytes binary>" instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:40:12 -05:00
Aaron D. Lee
1e07630b49 Update docs and comments for jpeglib migration (v4.2.0)
- Replace jpegio references with jpeglib in comments/docstrings
- Update sanitize-for-image.sh to use system Python 3.11+ (no pyenv)
- Update rpi/patches/README.md for jpeglib world
- Add AUR build artifacts to .gitignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 11:19:16 -05:00
Aaron D. Lee
67037ae196 RPi: Standardize tarball naming to stegasoo-rpi-venv-arm64.tar.zst
- Update remote-build-pi.sh to use new naming
- Rewrite build-runtime-tarball.sh for pyenv-free world (system Python)
- Removed pyenv references, now just tarballs the venv

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 10:59:07 -05:00
Aaron D. Lee
5a68840725 RPi: Fix jpeglib ARM64 build - skip turbo/mozjpeg correctly
Only remove dictionary entries for turbo/mozjpeg versions in setup.py
(they need cmake-generated headers). Keep the if blocks intact - they
safely evaluate to False for standard libjpeg versions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:15:34 -05:00
Aaron D. Lee
ebc999b2b3 RPi: Simpler approach - filter lines containing turbo/mozjpeg 2026-01-10 23:10:10 -05:00
Aaron D. Lee
f46ef01f5f RPi: Fix setup.py patching (use Python regex instead of sed) 2026-01-10 23:06:17 -05:00
Aaron D. Lee
0d76780deb RPi: Skip turbo/mozjpeg (need cmake-generated headers) 2026-01-10 23:03:28 -05:00
Aaron D. Lee
d34919e32f RPi: Download exact header version for each libjpeg 2026-01-10 22:42:59 -05:00
Aaron D. Lee
a4038589b0 RPi: Download matching libjpeg headers for each version
APIs changed between libjpeg versions, so each version directory
needs its matching headers:
- 6b gets 6b headers
- 7 gets 7 headers
- 8-8d get 8d headers
- 9-9f get 9f headers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:37:27 -05:00
Aaron D. Lee
db763f1464 Fix jpeglib tag (1.0.2 not v1.0.2) 2026-01-10 22:33:15 -05:00
Aaron D. Lee
27c5b08d41 RPi: Fix jpeglib include path (symlink src/jpeglib -> jpeglib)
The setup.py has broken include_dirs that reference 'jpeglib/cjpeglib'
but the source files are in 'src/jpeglib/cjpeglib'. Create a symlink
to fix the include path resolution.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:30:59 -05:00
Aaron D. Lee
28cb9bb9b3 RPi: Clone jpeglib from GitHub (PyPI tarball incomplete)
The PyPI source tarball is missing both jpeglib's own headers
(cjpeglib_common.h, etc.) and libjpeg headers. Clone from GitHub
which has the jpeglib headers, then download libjpeg headers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:30:21 -05:00
Aaron D. Lee
889df881ba RPi: Fix jpeglib ARM64 build (missing headers)
jpeglib has no pre-built ARM64 wheel and the source tarball is missing
libjpeg header files. This adds a workaround that downloads the official
libjpeg headers before building.

- Add rpi/patches/jpeglib/install-jpeglib-arm64.sh helper script
- Update setup.sh to download headers when building from source
- Downloads headers for libjpeg 6b, 7-9f, turbo, and mozjpeg versions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:20:40 -05:00
Aaron D. Lee
c058d116b8 RPi: Remove pyenv, use system Python 3.11+
- Rewrite setup.sh to use system Python instead of pyenv
- Add Python version check (3.11-3.14 supported)
- Remove jpegio build steps (jpeglib installs cleanly via pip)
- Simplify prebuilt tarball (just venv, no pyenv)
- Reduce install time: 5-10 min from source (was 15-20 min)
- Update README.md and BUILD_IMAGE.md accordingly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:35:50 -05:00
Aaron D. Lee
fae86887e2 Update RPi scripts and docs for v4.2.0
- Default branch: 4.1 → 4.2
- Update prebuilt URL to v4.2.0
- Update example filenames to 4.2.0
- Remove jpegio references (now using jpeglib)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:25:07 -05:00
Aaron D. Lee
5e45b2c5c1 AUR: Add zbar dependency, create temp_files directories
- Move zbar from optdepends to depends (required for Web UI QR reading)
- Create temp_files directories for web and api frontends

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:59:48 -05:00
Aaron D. Lee
71088989f3 Migrate from jpegio to jpeglib for Python 3.13+ support
- Replace jpegio with jpeglib (jpeglib.to_jpegio compatibility layer)
- Update Python requirement to >=3.11, add 3.13/3.14 classifiers
- AUR: Add install script for user creation and permissions
- AUR: Install frontends to site-packages, create Flask instance dir
- AUR: Use dynamic ${pyver} for systemd WorkingDirectory

Tested: CLI, Web UI (Gunicorn), API (Uvicorn), DCT jpeglib roundtrip

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:09:52 -05:00
Aaron D. Lee
530e5debef Include frontends in wheel, fix systemd WorkingDirectory 2026-01-10 16:58:16 -05:00
Aaron D. Lee
3b062458e3 Update Web UI screenshots and fix script for HTTPS 2026-01-10 16:15:20 -05:00
Aaron D. Lee
5e65035ca4 Fix AUR venv shebang paths 2026-01-10 16:06:08 -05:00
Aaron D. Lee
de9d1de881 Fix AUR package build for Python 3.12 2026-01-10 15:21:44 -05:00
Aaron D. Lee
8d90a888cf Add AUR package (stegasoo-git)
- Uses python312 from AUR for jpegio 3.13 compatibility
- Self-contained venv in /opt/stegasoo
- Includes systemd service files for web and API
- CLI symlinked to /usr/bin/stegasoo

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 12:55:20 -05:00
Aaron D. Lee
b0914778e3 Add zstandard to Docker base image
- Added zstandard>=0.22.0 to base image dependencies
- Updated verification to check zstd import
- Bumped base image version label to 4.2.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 00:07:18 -05:00
Aaron D. Lee
7e5462ea6e Add rebuild option to build.sh for complete no-cache builds
- rebuild: cleans everything, rebuilds base and services with --no-cache
- Updated help text to clarify full vs rebuild

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 00:04:00 -05:00
Aaron D. Lee
e085a8ffe9 Update release notes for v4.2.0
Added documentation for:
- Zstd default compression
- QR code generation (CLI and API)
- RSA 3072 cap, file expiry, progress bar UX
- Updated summary table with QR compression improvement

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:46:44 -05:00
Aaron D. Lee
2d7fbd1e0d Add QR code generation to CLI and API
CLI generate command:
- --qr <file.png|jpg> to save RSA key as QR image
- --qr-ascii to print ASCII QR code to terminal

API endpoints:
- POST /generate-key-qr - generate QR from key_pem
  - Supports png, jpg, and ascii output formats
  - Uses zstd compression by default
- Added has_qrcode_write to /capabilities

Core:
- generate_qr_code() now supports jpg/jpeg output format
- New generate_qr_ascii() for terminal display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:38:51 -05:00
Aaron D. Lee
32842f6b73 Move zstandard to core dependencies
zstd is now the default compression algorithm across all frontends,
so it should always be installed with the base package.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:32:49 -05:00
Aaron D. Lee
3fd3204552 Cap RSA at 3072 bits, add zstd compression for QR codes
- RSA key size capped at 3072 bits (4096 too large for QR codes)
- Added zstd compression for QR code RSA keys (better ratio than zlib)
- New prefix STEGASOO-ZS: for zstd, backward compatible with STEGASOO-Z: (zlib)
- Added zstandard dependency to web/api/compression extras
- Updated all docs, CLI options, and web UI to reflect 3072 max
- Version bump to 4.2.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:30:31 -05:00
Aaron D. Lee
175362ce4c Fix async encode returning HTML errors instead of JSON
When encode form was submitted in async mode, validation errors
returned HTML (render_template) instead of JSON, causing
"Unexpected token '<'" parse errors in the browser.

Added _error_response() helper that returns JSON in async mode
and HTML flash in sync mode.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:17:51 -05:00
Aaron D. Lee
2ed108f3a0 Fix decode progress - add Argon2 phase tracking
- decode.py now writes 20% "initializing" before Argon2
- decode.py writes 25% "extracting" after Argon2 completes
- DCT extraction scales from 25-70% (was 5-70%)
- Removed duplicate "loading" writes that caused backwards jumps

Progress flow: 15% reading -> 20% initializing (Argon2) -> 25-70% extracting -> 75-95% decoding -> 100% complete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:14:10 -05:00
Aaron D. Lee
167e1a6ff5 Add optional REST API systemd service for Pi
- Create stegasoo-api.service for FastAPI on port 8000
- Prompt user during setup with security warning (no auth)
- Default to disabled (recommended)
- Update help text and start commands for both services

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:09:48 -05:00
Aaron D. Lee
f2f3e2eefc Fix decode progress - pass progress_file to library
Worker was writing 25% then calling decode() without progress_file,
so library couldn't update progress. Now passes progress_file through
so library's extraction/RS-decode progress updates work.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:03:07 -05:00
Aaron D. Lee
5c685cba67 Fix decode progress getting stuck at 25%
- Reduce PROGRESS_INTERVAL from 2000 to 500 for responsive updates
- Scale extraction progress to 5-70% range
- Add progress updates before/after RS decode (75% and 95%)
- RS decode is the slow part, now visible in progress

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:48:53 -05:00
Aaron D. Lee
4e819b80cc Improve progress phase messages for key derivation
Show "Deriving keys (may take a moment)..." during Argon2 phase
to set user expectations on slower devices

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:45:16 -05:00
Aaron D. Lee
ea86216648 Bump pyproject.toml version to 4.2.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:42:14 -05:00
Aaron D. Lee
8de5659fa6 Increase temp file expiry from 5 to 10 minutes
- Update TEMP_FILE_EXPIRY constant (300 -> 600 seconds)
- Update all UI references to the new 10 minute expiry

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:41:09 -05:00
Aaron D. Lee
de0bf2410d Improve progress bar UX for encode/decode
- Add indeterminate (barber pole) animation during Argon2/initializing phase
- Prevent progress from jumping backwards (fixes flash-to-zero bug)
- Initial progress write at 5% when embedding actually starts
- Reset progress tracking on new operations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:38:42 -05:00
Aaron D. Lee
8b948d00a4 Update about page version history for v4.2.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:22:41 -05:00
Aaron D. Lee
6d88453b69 Update release notes for v4.2.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:16:34 -05:00
Aaron D. Lee
ea57bdf302 Make API encode/decode endpoints async with thread pool
- Added run_in_thread() helper using asyncio.to_thread()
- /encode, /encode/file, /decode use thread pool for CPU-bound ops
- /encode/multipart, /decode/multipart also updated
- Server can now handle concurrent requests without blocking
- Updated version header to v4.2.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:13:57 -05:00
Aaron D. Lee
55d54717f8 Use float32 instead of float64 for DCT operations
Reduces peak memory usage by ~50%:
- ENCODE: 211 MB -> 107 MB
- DECODE: 104 MB -> 52 MB

float32 provides sufficient precision for 8-bit images
(DCT roundtrip error ~1.8e-7, well under 0.5 threshold).

Significant improvement for Pi deployments with limited RAM.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:10:20 -05:00
Aaron D. Lee
c0fe85ac83 Add progress_file support to DCT extraction
- Added progress_file parameter to extract_from_dct, _extract_scipy_dct_safe, _extract_jpegio
- Progress writes at key phases: loading, extracting, decoding, complete
- Updated extract_from_image and _extract_dct to pass through progress_file
- Updated decode(), decode_file(), decode_text() with progress_file param
- Progress JSON format: {current, total, percent, phase}

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:01:20 -05:00
Aaron D. Lee
e9e4d1aab9 Vectorize DCT encode/decode for ~14x speedup
- Use scipy.fft.dctn/idctn with axes=(1,2) to process 500 blocks at once
- Extract bits in batch using numpy array indexing
- Vectorized QIM embedding with array operations
- Tests pass, roundtrip verified

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:51 -05:00
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
112 changed files with 10821 additions and 1953 deletions

View File

@@ -25,7 +25,6 @@ rpi/
*.img.xz
*.img.zst
*.img.zst.zip
pishrink.sh
# Docs
*.md

View File

@@ -37,7 +37,8 @@ jobs:
publish:
needs: test # Only run if tests pass
runs-on: ubuntu-latest
environment: pypi
# Required for PyPI trusted publishing (recommended)
permissions:
id-token: write

View File

@@ -14,7 +14,7 @@ jobs:
strategy:
fail-fast: false # Don't cancel other jobs if one fails
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.11", "3.12", "3.13"]
steps:
# 1. Get the code

16
.gitignore vendored
View File

@@ -64,9 +64,13 @@ htmlcov/
# Output test files.
test_data/*.png
# Dev scripts (local convenience scripts - except validate-release.sh)
# 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/
@@ -80,8 +84,8 @@ tests/
*.img
*.img.xz
*.img.zst
pishrink.sh
*.img.zst.zip
rpi/tools/pishrink.sh
# Temp file storage
frontends/web/temp_files/
@@ -93,3 +97,11 @@ rpi/*.tar.zst.zip
rpi/*.img
rpi/*.img.zst
rpi/*.img.zst.zip
# AUR build artifacts
aur-upload/
aur/.SRCINFO
aur/*.pkg.tar.zst
# Docker pre-built images and deps (release assets, too large for git)
docker/*.tar.zst

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

18
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
@@ -152,7 +164,7 @@ stegasoo generate [OPTIONS]
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
| `--pin-length` | | 6-9 | 6 | PIN length in digits |
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072, 4096) |
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072) |
| `--words` | | 3-12 | 4 | Words in passphrase |
| `--output` | `-o` | path | | Save RSA key to file |
| `--password` | `-p` | string | | Password for RSA key file |
@@ -168,7 +180,7 @@ stegasoo generate
stegasoo generate --words 6
# Generate with RSA key
stegasoo generate --rsa --rsa-bits 4096
stegasoo generate --rsa --rsa-bits 3072
# Save RSA key to encrypted file
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"
@@ -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,14 +6,14 @@ Stegasoo provides Docker images for both the Web UI and REST API.
```bash
# Build and start all services
docker-compose up -d
docker-compose -f docker/docker-compose.yml up -d
# Check status
docker-compose ps
docker-compose -f docker/docker-compose.yml ps
```
Access:
- **Web UI**: http://localhost:5000
- **Web UI**: https://localhost:5000 (HTTPS with self-signed cert)
- **REST API**: http://localhost:8000
## Services
@@ -36,9 +36,12 @@ STEGASOO_CHANNEL_KEY=XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
# Web UI authentication (default: enabled)
STEGASOO_AUTH_ENABLED=true
# HTTPS support (default: disabled)
STEGASOO_HTTPS_ENABLED=false
# HTTPS support (default: enabled, generates self-signed cert)
STEGASOO_HTTPS_ENABLED=true
STEGASOO_HOSTNAME=localhost
# To disable HTTPS:
# STEGASOO_HTTPS_ENABLED=false
```
### Volume Mounts
@@ -58,10 +61,10 @@ Uses a pre-built base image with all dependencies:
```bash
# First time only: build the base image
docker build -f Dockerfile.base -t stegasoo-base:latest .
docker build -f docker/Dockerfile.base -t stegasoo-base:latest .
# Build services (fast - only copies app code)
docker-compose build
docker-compose -f docker/docker-compose.yml build
```
### Full Build (No Base Image)
@@ -69,26 +72,26 @@ docker-compose build
If you don't have the base image, the Dockerfile will build all dependencies (slower):
```bash
docker-compose build
docker-compose -f docker/docker-compose.yml build
```
## Commands
```bash
# Start services
docker-compose up -d
docker-compose -f docker/docker-compose.yml up -d
# View logs
docker-compose logs -f
docker-compose -f docker/docker-compose.yml logs -f
# Stop services
docker-compose down
docker-compose -f docker/docker-compose.yml down
# Rebuild after code changes
docker-compose build && docker-compose up -d
docker-compose -f docker/docker-compose.yml build && docker-compose -f docker/docker-compose.yml up -d
# Full rebuild (no cache)
docker-compose build --no-cache
docker-compose -f docker/docker-compose.yml build --no-cache
```
## Resource Limits
@@ -109,7 +112,7 @@ Both services include health checks:
Check health status:
```bash
docker-compose ps
docker-compose -f docker/docker-compose.yml ps
```
## Production Deployment
@@ -126,7 +129,7 @@ For production, consider:
```bash
# Don't commit .env files with secrets
export STEGASOO_CHANNEL_KEY=your-key
docker-compose up -d
docker-compose -f docker/docker-compose.yml up -d
```
3. **Reverse proxy**: Put behind nginx/traefik for TLS termination
@@ -142,12 +145,12 @@ For production, consider:
### Container won't start
```bash
# Check logs
docker-compose logs web
docker-compose logs api
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 Dockerfile.
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

@@ -20,22 +20,23 @@ Complete installation instructions for all platforms and deployment methods.
## Requirements
### ⚠️ Python Version Requirements
### Python Version Requirements
| Python Version | Status | Notes |
|----------------|--------|-------|
| 3.10 | Supported | |
| 3.11 | ✅ Supported | Recommended |
| 3.10 | ❌ Not Supported | Dropped in v4.2.1 |
| 3.11 | ✅ Supported | Minimum version |
| 3.12 | ✅ Supported | Recommended |
| 3.13 | **Not Supported** | jpegio C extension incompatible |
| 3.13 | Supported | |
| 3.14 | ✅ Supported | Tested on Arch |
**Important:** Python 3.13 (released October 2024) is **not compatible** with jpegio due to C extension ABI changes. Use Python 3.12 or earlier.
**Note:** v4.2.1 switched from `jpegio` to `jpeglib` for DCT steganography, enabling Python 3.11-3.14 support.
### Minimum Requirements
| Requirement | Value |
|-------------|-------|
| Python | 3.10-3.12 |
| Python | 3.11-3.14 |
| RAM | 512 MB minimum (256MB for Argon2) |
| Disk | ~100 MB |
@@ -154,10 +155,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 +215,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 +240,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 +256,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:
@@ -423,16 +424,61 @@ pip install jpegio
### Windows
1. Install Python 3.12 from [python.org](https://python.org) (NOT 3.13!)
2. Install Visual Studio Build Tools
Windows users have three options, listed from easiest to most complex:
#### Option 1: Docker Desktop (Recommended)
The easiest way to run Stegasoo on Windows. No Python installation needed.
1. Install [Docker Desktop](https://www.docker.com/products/docker-desktop/)
2. Enable WSL2 backend when prompted
3. Clone and run:
```powershell
git clone https://github.com/adlee-was-taken/stegasoo.git
cd stegasoo
docker-compose -f docker/docker-compose.yml up -d web
```
Access at http://localhost:5000
#### Option 2: WSL2 (Windows Subsystem for Linux)
Run the Linux version natively on Windows.
```powershell
# Install WSL2 with Ubuntu
wsl --install -d Ubuntu
# Open Ubuntu terminal, then follow Linux instructions:
sudo apt-get update
sudo apt-get install -y python3.12 python3.12-venv libzbar0 libjpeg-dev
git clone https://github.com/adlee-was-taken/stegasoo.git
cd stegasoo
python3.12 -m venv venv
source venv/bin/activate
pip install -e ".[all]"
stegasoo --version
```
#### Option 3: Native Windows (Advanced)
Native Windows installation requires Visual Studio Build Tools for compiling C extensions.
1. Install Python 3.11 or 3.12 from [python.org](https://python.org)
2. Install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with "Desktop development with C++"
3. Install from pip:
```powershell
python -m venv venv
.\venv\Scripts\activate
pip install stegasoo[all]
pip install stegasoo[cli] # CLI only (easiest)
# or
pip install stegasoo[all] # Full install (may require additional setup)
```
**Note:** Native Windows installation may have issues with `jpegio` (DCT mode). Docker or WSL2 is recommended for full functionality.
### Raspberry Pi
Stegasoo works on Raspberry Pi 4/5 (4GB+ RAM recommended for Web UI).
@@ -852,7 +898,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:

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

@@ -4,7 +4,7 @@ A secure steganography system for hiding encrypted messages in images using hybr
[![Tests](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml/badge.svg)](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml)
[![Lint](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml/badge.svg)](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml)
![Python](https://img.shields.io/badge/Python-3.10--3.12-blue)
![Python](https://img.shields.io/badge/Python-3.11--3.14-blue)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
![Security](https://img.shields.io/badge/Security-AES--256--GCM-red)
@@ -105,15 +105,18 @@ ruff check src/ tests/ frontends/
## Docker
```bash
# Quick start
docker-compose up -d
# Quick start (HTTPS enabled by default)
docker-compose -f docker/docker-compose.yml up -d
# Access
# Web UI: http://localhost:5000
# 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) for full documentation.
See [DOCKER.md](DOCKER.md) and [docs/DOCKER_QUICKSTART.md](docs/DOCKER_QUICKSTART.md) for full documentation.
## Raspberry Pi
@@ -143,6 +146,7 @@ See [rpi/README.md](rpi/README.md) for manual installation.
- [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

View File

@@ -21,12 +21,12 @@ Pre-release validation checklist. Complete all items before tagging a release.
## Docker Validation
- [ ] Base image builds: `docker build -f Dockerfile.base -t stegasoo-base:latest .`
- [ ] Web image builds: `docker-compose build web`
- [ ] Container starts: `docker-compose up -d web`
- [ ] 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 down`
- [ ] Container stops cleanly: `docker-compose -f docker/docker-compose.yml down`
## Release Process

View File

@@ -1,47 +1,131 @@
## Stegasoo v4.1.5
## Stegasoo v4.2.1
### Developer Experience
- **Educational Code Comments**: Core modules now include detailed explanations
- DCT: zig-zag coefficient diagrams, QIM embedding math, Reed-Solomon "Voyager" reference
- LSB: visual bit manipulation examples, ChaCha20 pixel selection
- Crypto: multi-factor KDF flow diagrams, Argon2id memory-hardness reasoning
- CLI/Web: architectural patterns for future contributors
### API Security
### Raspberry Pi Improvements
- **Streamlined Image Creation**: `pull-image.sh` now handles everything
- Auto-resizes rootfs to exactly 16GB (consistent images from any SD card)
- Disables Pi OS auto-expand
- Compresses with zstd
- Optional .zst.zip wrapper for GitHub releases
- **16GB Minimum**: Pre-built images are now 16GB (was variable)
- **Host Requirements**: `rpi/host-requirements.txt` documents all dependencies
- **Test Automation**: `kickoff-pi-test.sh` for one-command flash+test cycles
**API Key Authentication**
- All protected endpoints require `X-API-Key` header
- Keys stored hashed (SHA-256) in `~/.stegasoo/api_keys.json`
- Auth disabled when no keys configured (easy onboarding)
### MOTD Polish
- Dynamic temperature emoji (ice/cool/fire based on CPU temp)
- Rocket emoji for service status
- Cleaner formatting
**TLS Support**
- Self-signed certificates auto-generated on first run
- Certs valid for localhost, all local IPs, hostname.local
- CLI: `stegasoo api tls generate` to pre-generate
### Raspberry Pi Image
Download `stegasoo-rpi-4.1.5.img.zst.zip` from Releases.
### CLI Improvements
**New API Management Commands**
```bash
# Flash (auto-detects SD card)
sudo ./rpi/flash-image.sh stegasoo-rpi-4.1.5.img.zst.zip
# Or manual
zstdcat stegasoo-rpi-4.1.5.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
stegasoo api keys create NAME # Create new key
stegasoo api keys list # List API keys
stegasoo api tls generate # Generate TLS cert
stegasoo api serve # Start server with TLS
```
**New Image Tools**
```bash
stegasoo tools compress IMG -q 75 # JPEG compression
stegasoo tools rotate IMG -r 90 # Lossless rotation
stegasoo tools convert IMG -f png # Format conversion
```
### Bug Fixes
- **DCT rotation**: Portrait photos no longer export rotated 90°
- **jpegtran**: Removed `-trim` flag that destroyed DCT stego data
- **CLI encode**: Now outputs JPEG when carrier is JPEG (was always PNG)
- **Import paths**: Fixed for installed packages (AUR/pip)
### Installation
**AUR (Arch Linux)**
```bash
yay -S stegasoo-git # Full (Web + API + CLI)
yay -S stegasoo-cli-git # CLI only
```
**Docker**
```bash
docker-compose -f docker/docker-compose.yml up -d
```
**Raspberry Pi**
Flash `stegasoo-rpi-4.2.1.img.zst.zip` to SD card.
Default login: `admin` / `stegasoo`
First boot runs the setup wizard for WiFi, HTTPS, and channel key configuration.
### Requirements
### Docker
```bash
docker-compose up -d web # Web UI on :5000
docker-compose up -d api # REST API on :8000
```
- Python 3.11 - 3.14 (dropped 3.10 support)
### Release Assets
| File | Description |
|------|-------------|
| `stegasoo-rpi-4.2.1.img.zst.zip` | Raspberry Pi SD card image |
| `stegasoo-docker-base-4.2.1.tar.zst` | Docker base image |
| Source code (zip/tar.gz) | Auto-generated |
---
## Stegasoo v4.2.0
### Performance Optimizations
Major performance improvements for Raspberry Pi and resource-constrained deployments.
#### DCT Vectorization (~14x faster)
- Batch DCT processing using `scipy.fft.dctn` with `axes=(1,2)`
- Processes 500 blocks at once instead of one-by-one
- Decode time reduced from ~2.6s to ~0.8s on 1MB images
#### Memory Optimization (50% reduction)
- Switched from `float64` to `float32` for all DCT operations
- Peak RAM: 211 MB → 107 MB for encode, 104 MB → 52 MB for decode
- Critical for Pi 3/4 avoiding swap thrashing
#### Progress Callbacks for Decode
- `progress_file` parameter added to `decode()` and extraction functions
- UI can now show decode progress (phases: loading, extracting, decoding, complete)
- JSON format: `{"current": 80, "total": 100, "percent": 80.0, "phase": "decoding"}`
#### Async API Endpoints
- Encode/decode operations now run in thread pool via `asyncio.to_thread()`
- API server can handle concurrent requests without blocking
- Essential for multi-user Pi deployments
### Compression
#### Zstd Default Compression
- `zstandard` is now a core dependency (always installed)
- Better compression ratio than zlib for QR code RSA keys
- New `STEGASOO-ZS:` prefix for zstd, backward compatible with `STEGASOO-Z:` (zlib)
### QR Code Generation
#### CLI Support
- `stegasoo generate --rsa --qr key.png` - save RSA key as QR image (PNG/JPG)
- `stegasoo generate --rsa --qr-ascii` - print ASCII QR to terminal
#### API Support
- `POST /generate-key-qr` - generate QR from RSA key
- Supports `png`, `jpg`, and `ascii` output formats
- Uses zstd compression by default
### Other Changes
- RSA key size capped at 3072 bits (4096 too large for QR codes)
- File auto-expire increased to 10 minutes
- Progress bar "candy cane" animation during Argon2 key derivation
- Optional API service in Pi setup (with security warning)
### Summary
| Metric | v4.1.7 | v4.2.0 | Improvement |
|--------|--------|--------|-------------|
| Decode (1MB) | ~2.6s | ~0.8s | **70% faster** |
| Peak RAM | 211 MB | 107 MB | **50% less** |
| Concurrent API | No | Yes | check |
| QR Compression | zlib | zstd | **~15% smaller** |
### Full Changelog
See [CHANGELOG.md](CHANGELOG.md) for complete version history.

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*

54
TODO-4.2.1.md Normal file
View File

@@ -0,0 +1,54 @@
# Stegasoo 4.2.1 Plan
## Bugs
- [x] Fix EXIF viewer panel not loading metadata in Web UI
- Redesigned with card-based grid layout and categories
- Compact styling for better space usage
- [x] DCT mode: portrait photos export rotated 90° (EXIF orientation not handled)
- Added `_apply_exif_orientation()` to apply EXIF rotation before embedding
- [x] DCT mode: add rotation fallback (try as-is, rotate 90°, retry on failure)
- Added rotation fallback in `extract_from_dct()` with quick header validation
- [x] Rotate tool: use jpegtran for lossless JPEG rotation (preserves DCT stego!)
- Web UI rotate tool now uses jpegtran for JPEGs
- DCT decode rotation fallback now uses jpegtran for JPEGs
- Dynamic UI shows "DCT Safe" for JPEGs, warning for other formats
## Tools Audit
- [x] Web UI tools - full shakedown and fixes
- Compress, Rotate, Strip, EXIF viewer all working
- Rotate uses jpegtran for lossless JPEG rotation
- Compact UI styling
- [x] CLI tools - full shakedown and fixes
- Fixed encode to output JPEG when carrier is JPEG (was always PNG)
- Fixed jpegtran -trim flag destroying DCT stego data
- Added compress, rotate, convert tools (matching Web UI)
- Rotate uses jpegtran for JPEGs, supports flip-only operations
## AUR Packages
- [x] `stegasoo-cli` - standalone CLI package (no web dependencies)
- Created aur-cli/PKGBUILD with [cli,dct,compression] extras only
- No flask/gunicorn/fastapi/uvicorn/pyzbar deps
- 68MB vs 79MB for full package
- [x] `stegasoo-api` - REST API package
- Created aur-api/PKGBUILD with [api,cli,compression] extras
- Has fastapi/uvicorn, no flask/gunicorn
- 74MB package size
- Includes systemd service with TLS
## API Auth Work
- [x] API key authentication (simpler than OAuth2 for personal use)
- `frontends/api/auth.py` - key generation, hashing, validation
- Keys stored in `~/.stegasoo/api_keys.json` (hashed)
- `X-API-Key` header for authentication
- Auth disabled when no keys configured
- [x] TLS with self-signed certificates
- Auto-generates certs on first run
- CLI: `stegasoo api tls generate`
- Certs stored in `~/.stegasoo/certs/`
- [x] CLI commands for API management
- `stegasoo api keys list/create/delete`
- `stegasoo api tls generate/info`
- `stegasoo api serve` (starts with TLS by default)
## API Documentation
- [ ] Postman collection (with environment templates)

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
@@ -411,7 +411,7 @@ Create a new set of credentials for steganography operations.
| Use PIN | on/off | on | Generate a numeric PIN |
| PIN length | 6-9 | 6 | Digits in the PIN |
| Use RSA Key | on/off | off | Generate an RSA key pair |
| RSA key size | 2048/3072/4096 | 2048 | Key size in bits |
| RSA key size | 2048/3072 | 2048 | Key size in bits |
#### Entropy Calculator
@@ -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

23
aur-api/.SRCINFO Normal file
View File

@@ -0,0 +1,23 @@
pkgbase = stegasoo-api-git
pkgdesc = Stegasoo REST API with TLS and API key authentication
pkgver = 4.2.1
pkgrel = 1
url = https://github.com/adlee-was-taken/stegasoo
install = stegasoo-api-git.install
arch = x86_64
license = MIT
makedepends = git
makedepends = python
makedepends = python-build
makedepends = python-hatchling
depends = python>=3.11
depends = zbar
optdepends = libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)
provides = stegasoo-api
conflicts = stegasoo-api
conflicts = stegasoo
conflicts = stegasoo-git
source = stegasoo-api-git::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main
sha256sums = SKIP
pkgname = stegasoo-api-git

109
aur-api/PKGBUILD Normal file
View File

@@ -0,0 +1,109 @@
# Maintainer: Aaron D. Lee <your-email@example.com>
pkgname=stegasoo-api-git
pkgver=4.2.1
pkgrel=1
pkgdesc="Stegasoo REST API with TLS and API key authentication"
arch=('x86_64')
url="https://github.com/adlee-was-taken/stegasoo"
license=('MIT')
# Python 3.11-3.14 supported
depends=(
'python>=3.11'
'zbar' # QR code reading for RSA key extraction
)
makedepends=(
'git'
'python'
'python-build'
'python-hatchling'
)
optdepends=(
'libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)'
)
provides=('stegasoo-api')
conflicts=('stegasoo-api' 'stegasoo' 'stegasoo-git')
install=stegasoo-api-git.install
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
sha256sums=('SKIP')
pkgver() {
cd "$pkgname"
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
build() {
cd "$pkgname"
python -m build --wheel --no-isolation
}
package() {
cd "$pkgname"
# Detect Python version for site-packages path
local pyver=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
# Install to /opt/stegasoo-api with dedicated venv
install -dm755 "$pkgdir/opt/stegasoo-api"
# Create fresh venv in package
python -m venv "$pkgdir/opt/stegasoo-api/venv"
# Install the wheel with API + CLI + compression extras
local wheel=$(ls dist/*.whl | head -1)
"$pkgdir/opt/stegasoo-api/venv/bin/pip" install --no-cache-dir "${wheel}[api,cli,compression]"
# Install API frontend (not included in wheel by default)
local site_packages="$pkgdir/opt/stegasoo-api/venv/lib/python${pyver}/site-packages"
install -dm755 "$site_packages/frontends/api"
cp -r frontends/api/*.py "$site_packages/frontends/api/"
cp -r frontends/__init__.py "$site_packages/frontends/" 2>/dev/null || true
# Create temp directory for API
install -dm755 "$site_packages/frontends/api/temp_files"
# Create config directories
install -dm755 "$pkgdir/opt/stegasoo-api/config"
install -dm700 "$pkgdir/opt/stegasoo-api/certs"
# Fix shebangs - replace build-time paths with installed paths
find "$pkgdir/opt/stegasoo-api/venv/bin" -type f -exec \
sed -i "s|$pkgdir/opt/stegasoo-api/venv|/opt/stegasoo-api/venv|g" {} \;
# Fix pyvenv.cfg
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo-api/venv/pyvenv.cfg"
# Create symlink to /usr/bin
install -dm755 "$pkgdir/usr/bin"
ln -s /opt/stegasoo-api/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
# Install license
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
# Install docs
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
# Install systemd service
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
[Unit]
Description=Stegasoo REST API (HTTPS)
After=network.target
[Service]
Type=simple
User=stegasoo
WorkingDirectory=/opt/stegasoo-api/venv/lib/python${pyver}/site-packages/frontends/api
Environment="PATH=/opt/stegasoo-api/venv/bin"
Environment="HOME=/opt/stegasoo-api"
# TLS enabled by default - certs auto-generated on first run
# Use: stegasoo api tls generate (to pre-generate certs)
# Use: stegasoo api keys create <name> (to create API keys)
ExecStart=/opt/stegasoo-api/venv/bin/stegasoo api serve --host 127.0.0.1 --port 8000
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
}

102
aur-api/README.md Normal file
View File

@@ -0,0 +1,102 @@
# Stegasoo API AUR Package
REST API server package for programmatic steganography operations. Includes HTTPS support and API key authentication.
## Installation
### From AUR (once published)
```bash
yay -S stegasoo-api-git
# or
paru -S stegasoo-api-git
```
### Manual build
```bash
git clone https://aur.archlinux.org/stegasoo-api-git.git
cd stegasoo-api-git
makepkg -si
```
## What Gets Installed
- `/opt/stegasoo-api/venv/` - Self-contained Python venv with API dependencies
- `/opt/stegasoo-api/config/` - API key storage
- `/opt/stegasoo-api/certs/` - TLS certificates
- `/usr/bin/stegasoo` - CLI executable
- `/usr/lib/systemd/system/stegasoo-api.service` - Systemd service
## Quick Start
```bash
# 1. Create an API key
sudo -u stegasoo stegasoo api keys create mykey
# 2. Start the service
sudo systemctl enable --now stegasoo-api
# 3. Test the API
curl -k -H "X-API-Key: YOUR_KEY" https://localhost:8000/
```
## Service Details
| Setting | Value |
|---------|-------|
| Port | 8000 |
| Protocol | HTTPS (self-signed cert auto-generated) |
| API Docs | https://localhost:8000/docs |
| OpenAPI | https://localhost:8000/openapi.json |
## API Key Management
```bash
# List all keys
stegasoo api keys list
# Create a new key
sudo -u stegasoo stegasoo api keys create <name>
# Revoke a key
sudo -u stegasoo stegasoo api keys revoke <name>
```
## TLS Configuration
```bash
# View current certificate info
stegasoo api tls info
# Generate new self-signed certificate
sudo -u stegasoo stegasoo api tls generate
# Use custom certificates (edit service)
sudo systemctl edit stegasoo-api
# Add:
# [Service]
# ExecStart=
# ExecStart=/opt/stegasoo-api/venv/bin/stegasoo api serve \
# --host 0.0.0.0 --port 8000 \
# --cert /path/to/cert.pem --key /path/to/key.pem
```
## Manual Run (without systemd)
```bash
# Development mode (auto-reload)
/opt/stegasoo-api/venv/bin/stegasoo api serve --reload
# Production mode
/opt/stegasoo-api/venv/bin/stegasoo api serve --host 0.0.0.0 --port 8000
```
## For Web UI
Install the full package instead:
```bash
yay -S stegasoo-git
```
## Maintainer
Aaron D. Lee

View File

@@ -0,0 +1,63 @@
post_install() {
# Create stegasoo system user if it doesn't exist
if ! getent passwd stegasoo >/dev/null; then
useradd -r -s /usr/bin/nologin -d /opt/stegasoo-api stegasoo
echo "Created system user 'stegasoo'"
fi
# Set ownership of directories
chown -R stegasoo:stegasoo /opt/stegasoo-api/config 2>/dev/null || true
chown -R stegasoo:stegasoo /opt/stegasoo-api/certs 2>/dev/null || true
echo ""
echo "==============================================="
echo " Stegasoo API installed successfully!"
echo "==============================================="
echo ""
echo "-----------------------------------------------"
echo " Quick Start"
echo "-----------------------------------------------"
echo " 1. Create an API key:"
echo " sudo -u stegasoo stegasoo api keys create mykey"
echo ""
echo " 2. Start the API server:"
echo " sudo systemctl start stegasoo-api"
echo " sudo systemctl enable stegasoo-api # auto-start"
echo ""
echo " 3. Access the API:"
echo " curl -k -H 'X-API-Key: YOUR_KEY' https://localhost:8000/"
echo ""
echo "-----------------------------------------------"
echo " Service Details"
echo "-----------------------------------------------"
echo " Port: 8000 (HTTPS by default)"
echo " Docs: https://localhost:8000/docs"
echo " Status: sudo systemctl status stegasoo-api"
echo ""
echo "-----------------------------------------------"
echo " Management Commands"
echo "-----------------------------------------------"
echo " stegasoo api keys list # List API keys"
echo " stegasoo api keys create X # Create new key"
echo " stegasoo api tls generate # Generate TLS certs"
echo " stegasoo api tls info # Show certificate info"
echo " stegasoo api serve --help # Server options"
echo ""
echo "==============================================="
echo ""
}
post_upgrade() {
post_install
}
pre_remove() {
# Stop service if running
systemctl stop stegasoo-api 2>/dev/null || true
}
post_remove() {
echo "Stegasoo API removed."
echo "User 'stegasoo' and config in /opt/stegasoo-api were not removed."
echo "To remove: userdel stegasoo && rm -rf /opt/stegasoo-api"
}

22
aur-api/test-build.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Test build the AUR API package locally
set -e
cd "$(dirname "$0")"
echo "=== Cleaning previous builds ==="
rm -rf stegasoo-api-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
echo "=== Generating .SRCINFO ==="
makepkg --printsrcinfo > .SRCINFO
echo "=== Building package ==="
makepkg -sf
echo "=== Package built ==="
ls -la *.pkg.tar.zst
echo ""
echo "To install: sudo pacman -U stegasoo-api-git-*.pkg.tar.zst"
echo "To test: makepkg -si"

22
aur-cli/.SRCINFO Normal file
View File

@@ -0,0 +1,22 @@
pkgbase = stegasoo-cli-git
pkgdesc = Secure steganography CLI with hybrid photo + passphrase + PIN authentication
pkgver = 4.2.1
pkgrel = 1
url = https://github.com/adlee-was-taken/stegasoo
install = stegasoo-cli-git.install
arch = x86_64
license = MIT
makedepends = git
makedepends = python
makedepends = python-build
makedepends = python-hatchling
depends = python>=3.11
optdepends = libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)
provides = stegasoo-cli
conflicts = stegasoo-cli
conflicts = stegasoo
conflicts = stegasoo-git
source = stegasoo-cli-git::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main
sha256sums = SKIP
pkgname = stegasoo-cli-git

69
aur-cli/PKGBUILD Normal file
View File

@@ -0,0 +1,69 @@
# Maintainer: Aaron D. Lee <your-email@example.com>
pkgname=stegasoo-cli-git
pkgver=4.2.1
pkgrel=1
pkgdesc="Secure steganography CLI with hybrid photo + passphrase + PIN authentication"
arch=('x86_64')
url="https://github.com/adlee-was-taken/stegasoo"
license=('MIT')
# Python 3.11-3.14 supported (uses jpeglib for modern Python compatibility)
depends=(
'python>=3.11'
)
makedepends=(
'git'
'python'
'python-build'
'python-hatchling'
)
optdepends=(
'libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)'
)
provides=('stegasoo-cli')
conflicts=('stegasoo-cli' 'stegasoo' 'stegasoo-git')
install=stegasoo-cli-git.install
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
sha256sums=('SKIP')
pkgver() {
cd "$pkgname"
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
build() {
cd "$pkgname"
python -m build --wheel --no-isolation
}
package() {
cd "$pkgname"
# Install to /opt/stegasoo-cli with dedicated venv
install -dm755 "$pkgdir/opt/stegasoo-cli"
# Create fresh venv in package
python -m venv "$pkgdir/opt/stegasoo-cli/venv"
# Install the wheel with CLI + DCT + compression extras (no web/api)
local wheel=$(ls dist/*.whl | head -1)
"$pkgdir/opt/stegasoo-cli/venv/bin/pip" install --no-cache-dir "${wheel}[cli,dct,compression]"
# Fix shebangs - replace build-time paths with installed paths
find "$pkgdir/opt/stegasoo-cli/venv/bin" -type f -exec \
sed -i "s|$pkgdir/opt/stegasoo-cli/venv|/opt/stegasoo-cli/venv|g" {} \;
# Fix pyvenv.cfg
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo-cli/venv/pyvenv.cfg"
# Create symlink to /usr/bin
install -dm755 "$pkgdir/usr/bin"
ln -s /opt/stegasoo-cli/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
# Install license
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
# Install docs
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
}

62
aur-cli/README.md Normal file
View File

@@ -0,0 +1,62 @@
# Stegasoo CLI AUR Package
Lightweight CLI-only package for steganography operations. No web UI or API server.
## Installation
### From AUR (once published)
```bash
yay -S stegasoo-cli-git
# or
paru -S stegasoo-cli-git
```
### Manual build
```bash
git clone https://aur.archlinux.org/stegasoo-cli-git.git
cd stegasoo-cli-git
makepkg -si
```
## What Gets Installed
- `/opt/stegasoo-cli/venv/` - Self-contained Python venv with CLI dependencies only
- `/usr/bin/stegasoo` - CLI executable
## Usage
```bash
# Show all commands
stegasoo --help
# Generate credentials (passphrase + PIN)
stegasoo generate
stegasoo generate --words 5 --pin-length 8
# Generate with RSA keys and QR codes
stegasoo generate --rsa --qr-ascii
# Encode a message
stegasoo encode -i carrier.jpg -r reference.jpg -m "secret message" \
-P "word1 word2 word3 word4" -p 123456
# Decode a message
stegasoo decode -i encoded.png -r reference.jpg \
-P "word1 word2 word3 word4" -p 123456
# Image tools
stegasoo tools --help
stegasoo tools compress image.png
stegasoo tools rotate image.jpg 90
```
## For Web UI or REST API
Install the full package instead:
```bash
yay -S stegasoo-git
```
## Maintainer
Aaron D. Lee

View File

@@ -0,0 +1,20 @@
post_install() {
echo ""
echo "==============================================="
echo " Stegasoo CLI installed successfully!"
echo "==============================================="
echo ""
echo "Usage:"
echo " stegasoo --help # Show all commands"
echo " stegasoo generate # Generate passphrase + PIN"
echo " stegasoo encode ... # Hide data in an image"
echo " stegasoo decode ... # Extract hidden data"
echo " stegasoo tools --help # Image tools (compress, etc.)"
echo ""
echo "For web UI or REST API, install stegasoo-git instead."
echo ""
}
post_upgrade() {
post_install
}

22
aur-cli/test-build.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Test build the AUR CLI package locally
set -e
cd "$(dirname "$0")"
echo "=== Cleaning previous builds ==="
rm -rf stegasoo-cli-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
echo "=== Generating .SRCINFO ==="
makepkg --printsrcinfo > .SRCINFO
echo "=== Building package ==="
makepkg -sf
echo "=== Package built ==="
ls -la *.pkg.tar.zst
echo ""
echo "To install: sudo pacman -U stegasoo-cli-git-*.pkg.tar.zst"
echo "To test: makepkg -si"

120
aur/PKGBUILD Normal file
View File

@@ -0,0 +1,120 @@
# Maintainer: Aaron D. Lee <your-email@example.com>
pkgname=stegasoo-git
pkgver=4.2.1
pkgrel=1
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
arch=('x86_64')
url="https://github.com/adlee-was-taken/stegasoo"
license=('MIT')
# Python 3.11-3.14 supported (uses jpeglib for modern Python compatibility)
depends=(
'python>=3.11'
'zbar' # QR code reading for Web UI
)
makedepends=(
'git'
'python'
'python-build'
'python-hatchling'
)
provides=('stegasoo')
conflicts=('stegasoo')
install=stegasoo-git.install
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
sha256sums=('SKIP')
pkgver() {
cd "$pkgname"
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
build() {
cd "$pkgname"
python -m build --wheel --no-isolation
}
package() {
cd "$pkgname"
# Detect Python version for site-packages path
local pyver=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
# Install to /opt/stegasoo with dedicated venv
install -dm755 "$pkgdir/opt/stegasoo"
# Create fresh venv in package
python -m venv "$pkgdir/opt/stegasoo/venv"
# Install the wheel with all extras
local wheel=$(ls dist/*.whl | head -1)
"$pkgdir/opt/stegasoo/venv/bin/pip" install --no-cache-dir "${wheel}[all]"
# Install frontends (not included in wheel)
local site_packages="$pkgdir/opt/stegasoo/venv/lib/python${pyver}/site-packages"
cp -r frontends "$site_packages/"
# Create writable directories for stegasoo user
install -dm755 "$pkgdir/opt/stegasoo/venv/var/app-instance"
install -dm755 "$site_packages/frontends/web/temp_files"
install -dm755 "$site_packages/frontends/api/temp_files"
# Fix shebangs - replace build-time paths with installed paths
find "$pkgdir/opt/stegasoo/venv/bin" -type f -exec \
sed -i "s|$pkgdir/opt/stegasoo/venv|/opt/stegasoo/venv|g" {} \;
# Fix pyvenv.cfg
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo/venv/pyvenv.cfg"
# Create symlinks to /usr/bin
install -dm755 "$pkgdir/usr/bin"
ln -s /opt/stegasoo/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
# Install license
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
# Install docs
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
# Install systemd service files
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-web.service" <<EOF
[Unit]
Description=Stegasoo Web UI
After=network.target
[Service]
Type=simple
User=stegasoo
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/web
Environment="PATH=/opt/stegasoo/venv/bin"
ExecStart=/opt/stegasoo/venv/bin/gunicorn -b 127.0.0.1:5000 app:app
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
[Unit]
Description=Stegasoo REST API (HTTPS)
After=network.target
[Service]
Type=simple
User=stegasoo
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/api
Environment="PATH=/opt/stegasoo/venv/bin"
Environment="HOME=/opt/stegasoo"
# TLS enabled by default - certs auto-generated on first run
# Use stegasoo api tls generate to pre-generate certs
# Use stegasoo api keys create <name> to create API keys
ExecStart=/opt/stegasoo/venv/bin/stegasoo api serve --host 127.0.0.1 --port 8000
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
}

90
aur/README.md Normal file
View File

@@ -0,0 +1,90 @@
# Stegasoo AUR Package
Full package with CLI, Web UI, and REST API. Supports Python 3.11-3.14.
## Installation
### From AUR (once published)
```bash
yay -S stegasoo-git
# or
paru -S stegasoo-git
```
### Manual build
```bash
git clone https://aur.archlinux.org/stegasoo-git.git
cd stegasoo-git
makepkg -si
```
## What Gets Installed
- `/opt/stegasoo/venv/` - Self-contained Python venv with all dependencies
- `/usr/bin/stegasoo` - CLI symlink
- `/usr/lib/systemd/system/stegasoo-web.service` - Web UI service (port 5000)
- `/usr/lib/systemd/system/stegasoo-api.service` - REST API service (port 8000, HTTPS)
## Optional Dependencies
```bash
# QR code reading from webcam/images (recommended)
sudo pacman -S zbar
```
All other dependencies are bundled in the venv.
## Usage
### CLI
```bash
stegasoo --help
stegasoo generate # Generate passphrase + PIN
stegasoo generate --rsa --qr-ascii # With RSA keys and QR codes
stegasoo encode -i carrier.jpg -r reference.jpg -m "secret" -P "word1 word2 word3 word4" -p 123456
stegasoo decode -i encoded.png -r reference.jpg -P "word1 word2 word3 word4" -p 123456
```
### Web UI
```bash
# Start service (user created automatically on install)
sudo systemctl enable --now stegasoo-web
# Access at http://localhost:5000
```
### REST API
```bash
# Create an API key first
sudo -u stegasoo stegasoo api keys create mykey
# Start service (HTTPS with auto-generated self-signed cert)
sudo systemctl enable --now stegasoo-api
# Access docs at https://localhost:8000/docs
curl -k -H "X-API-Key: YOUR_KEY" https://localhost:8000/
```
### HTTPS Configuration
The API uses HTTPS by default with auto-generated self-signed certificates.
```bash
# View certificate info
stegasoo api tls info
# Generate new self-signed cert
sudo -u stegasoo stegasoo api tls generate
# Use custom certs (edit service file)
sudo systemctl edit stegasoo-api
```
## Alternative Packages
- `stegasoo-cli-git` - CLI only, minimal dependencies
- `stegasoo-api-git` - CLI + REST API, no web UI
## Maintainer
Aaron D. Lee

75
aur/stegasoo-git.install Normal file
View File

@@ -0,0 +1,75 @@
post_install() {
# Create stegasoo system user if it doesn't exist
if ! getent passwd stegasoo >/dev/null; then
useradd -r -s /usr/bin/nologin -d /opt/stegasoo stegasoo
echo "Created system user 'stegasoo'"
fi
# Set ownership of instance directory for Flask
chown -R stegasoo:stegasoo /opt/stegasoo/venv/var/app-instance 2>/dev/null || true
echo ""
echo "==============================================="
echo " Stegasoo installed successfully!"
echo "==============================================="
echo ""
echo "CLI usage:"
echo " stegasoo --help"
echo " stegasoo generate # Generate credentials"
echo " stegasoo encode # Encode a message"
echo " stegasoo decode # Decode a message"
echo ""
echo "-----------------------------------------------"
echo " Web UI Service"
echo "-----------------------------------------------"
echo " Port: 5000 (HTTP)"
echo " Start: sudo systemctl start stegasoo-web"
echo " Enable: sudo systemctl enable stegasoo-web"
echo " Status: sudo systemctl status stegasoo-web"
echo " Access: http://localhost:5000"
echo ""
echo "-----------------------------------------------"
echo " REST API Service"
echo "-----------------------------------------------"
echo " Port: 8000 (HTTPS by default)"
echo " Start: sudo systemctl start stegasoo-api"
echo " Enable: sudo systemctl enable stegasoo-api"
echo " Status: sudo systemctl status stegasoo-api"
echo " Access: https://localhost:8000"
echo ""
echo "-----------------------------------------------"
echo " HTTPS Configuration"
echo "-----------------------------------------------"
echo " The API generates self-signed certs on first run."
echo " To pre-generate or use custom certificates:"
echo ""
echo " # Generate self-signed certs"
echo " sudo -u stegasoo stegasoo api tls generate"
echo ""
echo " # Use custom certs (edit the service file)"
echo " sudo systemctl edit stegasoo-api"
echo " # Add: ExecStart= with --cert and --key flags"
echo ""
echo " # Create API keys for authentication"
echo " sudo -u stegasoo stegasoo api keys create <name>"
echo ""
echo "==============================================="
echo ""
}
post_upgrade() {
post_install
}
pre_remove() {
# Stop services if running
systemctl stop stegasoo-web 2>/dev/null || true
systemctl stop stegasoo-api 2>/dev/null || true
}
post_remove() {
# Optionally remove the stegasoo user
# userdel stegasoo 2>/dev/null || true
echo "Stegasoo removed. User 'stegasoo' was not removed."
echo "To remove: userdel stegasoo"
}

22
aur/test-build.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Test build the AUR package locally
set -e
cd "$(dirname "$0")"
echo "=== Cleaning previous builds ==="
rm -rf stegasoo-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
echo "=== Generating .SRCINFO ==="
makepkg --printsrcinfo > .SRCINFO
echo "=== Building package ==="
makepkg -sf
echo "=== Package built ==="
ls -la *.pkg.tar.zst
echo ""
echo "To install: sudo pacman -U stegasoo-git-*.pkg.tar.zst"
echo "To test: makepkg -si"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
data/WebUI_Recover.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
data/WebUI_Tools.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

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,6 +60,12 @@ 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/
@@ -66,6 +75,10 @@ COPY frontends/web/ frontends/web/
# 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
USER stego
@@ -77,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,9 @@ 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 \
zstandard>=0.22.0
# Install web/api framework packages (also stable)
RUN pip install --no-cache-dir \
@@ -47,9 +49,9 @@ RUN pip install --no-cache-dir \
lz4>=4.0.0
# Verify key packages work
RUN python -c "import jpegio; import scipy; import numpy; print('jpegio + scipy + numpy OK')"
RUN python -c "import jpegio; import scipy; import numpy; import zstandard; print('jpegio + scipy + numpy + zstd OK')"
# Label for tracking
LABEL org.opencontainers.image.title="Stegasoo Base"
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
LABEL org.opencontainers.image.version="4.0.0"
LABEL org.opencontainers.image.version="4.2.1"

View File

@@ -8,7 +8,8 @@ services:
# ============================================================================
web:
build:
context: .
context: ..
dockerfile: docker/Dockerfile
target: web
container_name: stegasoo-web
ports:
@@ -18,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)
@@ -37,7 +40,8 @@ services:
# ============================================================================
api:
build:
context: .
context: ..
dockerfile: docker/Dockerfile
target: api
container_name: stegasoo-api
ports:

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

View File

@@ -126,7 +126,7 @@ Quick reference for all Jinja2 templates in `frontends/web/templates/`.
- `use_pin` - checkbox
- `pin_length` - PIN digits (6-9)
- `use_rsa` - checkbox
- `rsa_bits` - key size (2048/3072/4096)
- `rsa_bits` - key size (2048/3072)
**Output panels:**
- Passphrase display

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.

0
frontends/__init__.py Normal file
View File

View File

256
frontends/api/auth.py Normal file
View File

@@ -0,0 +1,256 @@
"""
API Key Authentication for Stegasoo REST API.
Provides simple API key authentication with hashed key storage.
Keys can be stored in user config (~/.stegasoo/) or project config (./config/).
Usage:
from .auth import require_api_key, get_api_key_status
@app.get("/protected")
async def protected_endpoint(api_key: str = Depends(require_api_key)):
return {"status": "authenticated"}
"""
import hashlib
import json
import os
import secrets
from pathlib import Path
from typing import Optional
from fastapi import Depends, HTTPException, Security
from fastapi.security import APIKeyHeader
# API key header name
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
# Config locations
USER_CONFIG_DIR = Path.home() / ".stegasoo"
PROJECT_CONFIG_DIR = Path("./config")
# Key file name
API_KEYS_FILE = "api_keys.json"
# Environment variable for API key (alternative to file)
API_KEY_ENV_VAR = "STEGASOO_API_KEY"
def _hash_key(key: str) -> str:
"""Hash an API key for storage."""
return hashlib.sha256(key.encode()).hexdigest()
def _get_keys_file(location: str = "user") -> Path:
"""Get path to API keys file."""
if location == "project":
return PROJECT_CONFIG_DIR / API_KEYS_FILE
return USER_CONFIG_DIR / API_KEYS_FILE
def _load_keys(location: str = "user") -> dict:
"""Load API keys from config file."""
keys_file = _get_keys_file(location)
if keys_file.exists():
try:
with open(keys_file) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {"keys": [], "enabled": True}
return {"keys": [], "enabled": True}
def _save_keys(data: dict, location: str = "user") -> None:
"""Save API keys to config file."""
keys_file = _get_keys_file(location)
keys_file.parent.mkdir(parents=True, exist_ok=True)
with open(keys_file, "w") as f:
json.dump(data, f, indent=2)
# Secure permissions (owner read/write only)
os.chmod(keys_file, 0o600)
def generate_api_key() -> str:
"""Generate a new API key."""
# Format: stegasoo_XXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXX
# 32 bytes = 256 bits of entropy
random_part = secrets.token_hex(16)
return f"stegasoo_{random_part[:4]}_{random_part[4:]}"
def add_api_key(name: str, location: str = "user") -> str:
"""
Generate and store a new API key.
Args:
name: Descriptive name for the key (e.g., "laptop", "automation")
location: "user" or "project"
Returns:
The generated API key (only shown once!)
"""
key = generate_api_key()
key_hash = _hash_key(key)
data = _load_keys(location)
# Check for duplicate name
for existing in data["keys"]:
if existing["name"] == name:
raise ValueError(f"Key with name '{name}' already exists")
data["keys"].append({
"name": name,
"hash": key_hash,
"created": __import__("datetime").datetime.now().isoformat(),
})
_save_keys(data, location)
return key
def remove_api_key(name: str, location: str = "user") -> bool:
"""
Remove an API key by name.
Returns:
True if key was found and removed, False otherwise
"""
data = _load_keys(location)
original_count = len(data["keys"])
data["keys"] = [k for k in data["keys"] if k["name"] != name]
if len(data["keys"]) < original_count:
_save_keys(data, location)
return True
return False
def list_api_keys(location: str = "user") -> list[dict]:
"""
List all API keys (names and creation dates, not actual keys).
"""
data = _load_keys(location)
return [{"name": k["name"], "created": k.get("created", "unknown")} for k in data["keys"]]
def set_auth_enabled(enabled: bool, location: str = "user") -> None:
"""Enable or disable API key authentication."""
data = _load_keys(location)
data["enabled"] = enabled
_save_keys(data, location)
def is_auth_enabled() -> bool:
"""Check if API key authentication is enabled."""
# Check project config first, then user config
for location in ["project", "user"]:
data = _load_keys(location)
if "enabled" in data:
return data["enabled"]
# Default: enabled if any keys exist
return bool(get_all_key_hashes())
def get_all_key_hashes() -> set[str]:
"""Get all valid API key hashes from all sources."""
hashes = set()
# Check environment variable first
env_key = os.environ.get(API_KEY_ENV_VAR)
if env_key:
hashes.add(_hash_key(env_key))
# Check project and user configs
for location in ["project", "user"]:
data = _load_keys(location)
for key_entry in data.get("keys", []):
if "hash" in key_entry:
hashes.add(key_entry["hash"])
return hashes
def validate_api_key(key: str) -> bool:
"""Validate an API key against stored hashes."""
if not key:
return False
key_hash = _hash_key(key)
valid_hashes = get_all_key_hashes()
return key_hash in valid_hashes
def get_api_key_status() -> dict:
"""Get current API key authentication status."""
user_keys = list_api_keys("user")
project_keys = list_api_keys("project")
env_configured = bool(os.environ.get(API_KEY_ENV_VAR))
total_keys = len(user_keys) + len(project_keys) + (1 if env_configured else 0)
return {
"enabled": is_auth_enabled(),
"total_keys": total_keys,
"user_keys": len(user_keys),
"project_keys": len(project_keys),
"env_configured": env_configured,
"keys": {
"user": user_keys,
"project": project_keys,
}
}
# FastAPI dependency for API key authentication
async def require_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> str:
"""
FastAPI dependency that requires a valid API key.
Usage:
@app.get("/protected")
async def endpoint(key: str = Depends(require_api_key)):
...
"""
# Check if auth is enabled
if not is_auth_enabled():
return "auth_disabled"
# No keys configured = auth disabled
if not get_all_key_hashes():
return "no_keys_configured"
# Validate the provided key
if not api_key:
raise HTTPException(
status_code=401,
detail="API key required. Provide X-API-Key header.",
headers={"WWW-Authenticate": "ApiKey"},
)
if not validate_api_key(api_key):
raise HTTPException(
status_code=403,
detail="Invalid API key.",
)
return api_key
async def optional_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> Optional[str]:
"""
FastAPI dependency that optionally validates API key.
Returns the key if valid, None if not provided or invalid.
Doesn't raise exceptions - useful for endpoints that work
with or without auth.
"""
if api_key and validate_api_key(api_key):
return api_key
return None

View File

@@ -1,10 +1,19 @@
#!/usr/bin/env python3
"""
Stegasoo REST API (v4.0.0)
Stegasoo REST API (v4.2.1)
FastAPI-based REST API for steganography operations.
Supports both text messages and file embedding.
CHANGES in v4.2.1:
- API key authentication (X-API-Key header)
- TLS support with self-signed certificates
- /auth/* endpoints for key management
CHANGES in v4.2.0:
- Async encode/decode operations (run in thread pool)
- Server can handle concurrent requests without blocking
CHANGES in v4.0.0:
- Added channel key support for deployment/group isolation
- New /channel endpoints for key management
@@ -21,15 +30,38 @@ NEW in v3.0: LSB and DCT embedding modes.
NEW in v3.0.1: DCT color mode and JPEG output format.
"""
import asyncio
import base64
import sys
from functools import partial
from pathlib import Path
from typing import Literal
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field
# API Key Authentication
try:
from .auth import (
require_api_key,
get_api_key_status,
add_api_key,
remove_api_key,
list_api_keys,
is_auth_enabled,
)
except ImportError:
# When running directly (not as package)
from auth import (
require_api_key,
get_api_key_status,
add_api_key,
remove_api_key,
list_api_keys,
is_auth_enabled,
)
# Add parent to path for development
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
@@ -68,13 +100,20 @@ from stegasoo.constants import (
try:
from stegasoo.qr_utils import (
extract_key_from_qr,
generate_qr_ascii,
generate_qr_code,
has_qr_read,
has_qr_write,
)
HAS_QR_READ = has_qr_read()
HAS_QR_WRITE = has_qr_write()
except ImportError:
HAS_QR_READ = False
HAS_QR_WRITE = False
extract_key_from_qr = None
generate_qr_code = None
generate_qr_ascii = None
# ============================================================================
@@ -344,6 +383,23 @@ class ChannelSetRequest(BaseModel):
location: str = Field(default="user", description="'user' or 'project'")
class AuthStatusResponse(BaseModel):
"""Response for API key authentication status."""
enabled: bool = Field(description="Whether API key auth is enabled")
total_keys: int = Field(description="Total number of configured API keys")
user_keys: int = Field(description="Keys in user config")
project_keys: int = Field(description="Keys in project config")
env_configured: bool = Field(description="Whether env var key is set")
class AuthKeyInfo(BaseModel):
"""Info about a single API key (not the actual key)."""
name: str
created: str
class ModesResponse(BaseModel):
"""Response showing available embedding modes."""
@@ -357,6 +413,7 @@ class StatusResponse(BaseModel):
version: str
has_argon2: bool
has_qrcode_read: bool
has_qrcode_write: bool # v4.2.0: QR generation capability
has_dct: bool
max_payload_kb: int
available_modes: list[str]
@@ -372,6 +429,32 @@ class QrExtractResponse(BaseModel):
error: str | None = None
class QrGenerateRequest(BaseModel):
"""Request to generate QR code from RSA key."""
key_pem: str = Field(..., description="RSA private key in PEM format")
output_format: str = Field(
default="png",
description="Output format: 'png', 'jpg', or 'ascii'",
)
compress: bool = Field(
default=True,
description="Compress key data with zstd (recommended for larger keys)",
)
class QrGenerateResponse(BaseModel):
"""Response containing generated QR code."""
success: bool
format: str | None = None
qr_data: str | None = Field(
default=None,
description="Base64-encoded image data (for png/jpg) or ASCII string",
)
error: str | None = None
class WillFitRequest(BaseModel):
"""Request to check if payload will fit."""
@@ -436,6 +519,27 @@ def _get_channel_info(channel_key: str | None) -> tuple[str, str | None]:
return info["mode"], info.get("fingerprint")
# ============================================================================
# HELPER: ASYNC EXECUTION
# ============================================================================
async def run_in_thread(func, *args, **kwargs):
"""
Run a CPU-bound function in a thread pool.
This allows the FastAPI server to handle other requests while
encode/decode operations are running. Essential for Pi deployments
where operations can take several seconds.
Usage:
result = await run_in_thread(encode, message=msg, carrier_image=carrier, ...)
"""
if kwargs:
func = partial(func, **kwargs)
return await asyncio.to_thread(func, *args)
# ============================================================================
# ROUTES - STATUS & INFO
# ============================================================================
@@ -469,6 +573,7 @@ async def root():
version=__version__,
has_argon2=has_argon2(),
has_qrcode_read=HAS_QR_READ,
has_qrcode_write=HAS_QR_WRITE,
has_dct=has_dct_support(),
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
available_modes=available_modes,
@@ -552,6 +657,7 @@ async def api_channel_status(
@app.post("/channel/generate", response_model=ChannelGenerateResponse)
async def api_channel_generate(
_: str = Depends(require_api_key),
save: bool = Query(False, description="Save to user config"),
save_project: bool = Query(False, description="Save to project config"),
):
@@ -590,7 +696,7 @@ async def api_channel_generate(
@app.post("/channel/set")
async def api_channel_set(request: ChannelSetRequest):
async def api_channel_set(request: ChannelSetRequest, _: str = Depends(require_api_key)):
"""
Set/save a channel key to config.
@@ -616,6 +722,7 @@ async def api_channel_set(request: ChannelSetRequest):
@app.delete("/channel")
async def api_channel_clear(
_: str = Depends(require_api_key),
location: str = Query("user", description="'user', 'project', or 'all'")
):
"""
@@ -642,8 +749,98 @@ async def api_channel_clear(
}
# ============================================================================
# ROUTES - AUTHENTICATION (v4.2.1)
# ============================================================================
@app.get("/auth/status", response_model=AuthStatusResponse)
async def api_auth_status():
"""
Get API key authentication status.
v4.2.1: New endpoint for auth status.
Returns whether auth is enabled and key counts.
"""
status = get_api_key_status()
return AuthStatusResponse(
enabled=status["enabled"],
total_keys=status["total_keys"],
user_keys=status["user_keys"],
project_keys=status["project_keys"],
env_configured=status["env_configured"],
)
@app.get("/auth/keys", response_model=list[AuthKeyInfo])
async def api_auth_list_keys(
location: str = Query("user", description="'user' or 'project'"),
_: str = Depends(require_api_key),
):
"""
List configured API keys (names only, not actual keys).
v4.2.1: New endpoint for auth management.
Requires authentication.
"""
if location not in ("user", "project"):
raise HTTPException(400, "location must be 'user' or 'project'")
keys = list_api_keys(location)
return [AuthKeyInfo(name=k["name"], created=k["created"]) for k in keys]
@app.post("/auth/keys")
async def api_auth_create_key(
name: str = Query(..., description="Name for the new API key"),
location: str = Query("user", description="'user' or 'project'"),
_: str = Depends(require_api_key),
):
"""
Create a new API key.
v4.2.1: New endpoint for auth management.
Returns the key ONCE - it cannot be retrieved again!
Requires authentication (or no keys configured yet).
"""
if location not in ("user", "project"):
raise HTTPException(400, "location must be 'user' or 'project'")
try:
key = add_api_key(name, location)
return {
"success": True,
"name": name,
"key": key,
"warning": "Save this key now! It cannot be retrieved again.",
}
except ValueError as e:
raise HTTPException(400, str(e))
@app.delete("/auth/keys")
async def api_auth_delete_key(
name: str = Query(..., description="Name of key to delete"),
location: str = Query("user", description="'user' or 'project'"),
_: str = Depends(require_api_key),
):
"""
Delete an API key by name.
v4.2.1: New endpoint for auth management.
Requires authentication.
"""
if location not in ("user", "project"):
raise HTTPException(400, "location must be 'user' or 'project'")
if remove_api_key(name, location):
return {"success": True, "deleted": name}
else:
raise HTTPException(404, f"Key '{name}' not found in {location} config")
@app.post("/compare", response_model=CompareModesResponse)
async def api_compare_modes(request: CompareModesRequest):
async def api_compare_modes(request: CompareModesRequest, _: str = Depends(require_api_key)):
"""
Compare LSB and DCT embedding modes for a carrier image.
@@ -701,7 +898,7 @@ async def api_compare_modes(request: CompareModesRequest):
@app.post("/will-fit", response_model=WillFitResponse)
async def api_will_fit(request: WillFitRequest):
async def api_will_fit(request: WillFitRequest, _: str = Depends(require_api_key)):
"""
Check if a payload of given size will fit in the carrier image.
@@ -737,6 +934,7 @@ async def api_will_fit(request: WillFitRequest):
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
async def api_extract_key_from_qr(
_: str = Depends(require_api_key),
qr_image: UploadFile = File(..., description="QR code image containing RSA key")
):
"""
@@ -760,13 +958,58 @@ async def api_extract_key_from_qr(
return QrExtractResponse(success=False, error=str(e))
@app.post("/generate-key-qr", response_model=QrGenerateResponse)
async def api_generate_key_qr(request: QrGenerateRequest, _: str = Depends(require_api_key)):
"""
Generate QR code from an RSA private key.
Supports PNG, JPG, and ASCII output formats.
Uses zstd compression by default for better QR code density.
"""
if not HAS_QR_WRITE:
raise HTTPException(501, "QR code generation not available. Install qrcode library.")
try:
fmt = request.output_format.lower()
if fmt == "ascii":
ascii_qr = generate_qr_ascii(
request.key_pem,
compress=request.compress,
invert=False,
)
return QrGenerateResponse(success=True, format="ascii", qr_data=ascii_qr)
elif fmt in ("png", "jpg", "jpeg"):
import base64
qr_bytes = generate_qr_code(
request.key_pem,
compress=request.compress,
output_format=fmt,
)
qr_b64 = base64.b64encode(qr_bytes).decode("ascii")
return QrGenerateResponse(success=True, format=fmt, qr_data=qr_b64)
else:
return QrGenerateResponse(
success=False,
error=f"Unsupported format: {fmt}. Use 'png', 'jpg', or 'ascii'",
)
except ValueError as e:
return QrGenerateResponse(success=False, error=str(e))
except Exception as e:
return QrGenerateResponse(success=False, error=f"QR generation failed: {e}")
# ============================================================================
# ROUTES - GENERATE
# ============================================================================
@app.post("/generate", response_model=GenerateResponse)
async def api_generate(request: GenerateRequest):
async def api_generate(request: GenerateRequest, _: str = Depends(require_api_key)):
"""
Generate credentials for encoding/decoding.
@@ -848,7 +1091,7 @@ def _get_output_info(embed_mode: str, dct_output_format: str, dct_color_mode: st
@app.post("/encode", response_model=EncodeResponse)
async def api_encode(request: EncodeRequest):
async def api_encode(request: EncodeRequest, _: str = Depends(require_api_key)):
"""
Encode a text message into an image.
@@ -874,8 +1117,9 @@ async def api_encode(request: EncodeRequest):
request.embed_mode, request.dct_output_format, request.dct_color_mode
)
# v4.0.0: Include channel_key
result = encode(
# v4.2.0: Run CPU-bound encode in thread pool
result = await run_in_thread(
encode,
message=request.message,
reference_photo=ref_photo,
carrier_image=carrier,
@@ -919,7 +1163,7 @@ async def api_encode(request: EncodeRequest):
@app.post("/encode/file", response_model=EncodeResponse)
async def api_encode_file(request: EncodeFileRequest):
async def api_encode_file(request: EncodeFileRequest, _: str = Depends(require_api_key)):
"""
Encode a file into an image (JSON with base64).
@@ -950,8 +1194,9 @@ async def api_encode_file(request: EncodeFileRequest):
request.embed_mode, request.dct_output_format, request.dct_color_mode
)
# v4.0.0: Include channel_key
result = encode(
# v4.2.0: Run CPU-bound encode in thread pool
result = await run_in_thread(
encode,
message=payload,
reference_photo=ref_photo,
carrier_image=carrier,
@@ -1000,7 +1245,7 @@ async def api_encode_file(request: EncodeFileRequest):
@app.post("/decode", response_model=DecodeResponse)
async def api_decode(request: DecodeRequest):
async def api_decode(request: DecodeRequest, _: str = Depends(require_api_key)):
"""
Decode a message or file from a stego image.
@@ -1021,8 +1266,9 @@ async def api_decode(request: DecodeRequest):
ref_photo = base64.b64decode(request.reference_photo_base64)
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
# v4.0.0: Include channel_key
result = decode(
# v4.2.0: Run CPU-bound decode in thread pool
result = await run_in_thread(
decode,
stego_image=stego,
reference_photo=ref_photo,
passphrase=request.passphrase,
@@ -1062,6 +1308,7 @@ async def api_decode(request: DecodeRequest):
@app.post("/encode/multipart")
async def api_encode_multipart(
_: str = Depends(require_api_key),
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
reference_photo: UploadFile = File(...),
carrier: UploadFile = File(...),
@@ -1150,8 +1397,9 @@ async def api_encode_multipart(
# Get DCT parameters
dct_params = _get_dct_params(embed_mode, dct_output_format, dct_color_mode)
# v4.0.0: Include channel_key
result = encode(
# v4.2.0: Run CPU-bound encode in thread pool
result = await run_in_thread(
encode,
message=payload,
reference_photo=ref_data,
carrier_image=carrier_data,
@@ -1202,6 +1450,7 @@ async def api_encode_multipart(
@app.post("/decode/multipart", response_model=DecodeResponse)
async def api_decode_multipart(
_: str = Depends(require_api_key),
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
reference_photo: UploadFile = File(...),
stego_image: UploadFile = File(...),
@@ -1264,8 +1513,9 @@ async def api_decode_multipart(
# QR code keys are never password-protected
effective_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
# v4.0.0: Include channel_key
result = decode(
# v4.2.0: Run CPU-bound decode in thread pool
result = await run_in_thread(
decode,
stego_image=stego_data,
reference_photo=ref_data,
passphrase=passphrase,
@@ -1306,6 +1556,7 @@ async def api_decode_multipart(
@app.post("/image/info", response_model=ImageInfoResponse)
async def api_image_info(
_: str = Depends(require_api_key),
image: UploadFile = File(...),
include_modes: bool = Query(True, description="Include capacity by mode (v3.0+)"),
):

View File

View File

@@ -120,6 +120,7 @@ try:
from stegasoo.qr_utils import ( # noqa: F401
can_fit_in_qr,
extract_key_from_qr_file,
generate_qr_ascii,
generate_qr_code,
has_qr_read,
has_qr_write,
@@ -136,6 +137,9 @@ except ImportError:
def has_qr_write() -> bool:
return False
def generate_qr_ascii(*args, **kwargs):
raise RuntimeError("QR code generation not available")
# ============================================================================
# CLI SETUP
@@ -236,7 +240,7 @@ def format_channel_status_line(quiet: bool = False) -> str | None:
help=f"PIN length (6-9, default: {DEFAULT_PIN_LENGTH})",
)
@click.option(
"--rsa-bits", type=click.Choice(["2048", "3072", "4096"]), default="2048", help="RSA key size"
"--rsa-bits", type=click.Choice(["2048", "3072"]), default="2048", help="RSA key size"
)
@click.option(
"--words",
@@ -247,7 +251,13 @@ def format_channel_status_line(quiet: bool = False) -> str | None:
@click.option("--output", "-o", type=click.Path(), help="Save RSA key to file (requires password)")
@click.option("--password", "-p", help="Password for RSA key file")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
@click.option(
"--qr",
type=click.Path(),
help="Save RSA key QR code to file (png/jpg, uses zstd compression)",
)
@click.option("--qr-ascii", is_flag=True, help="Print RSA key as ASCII QR code to terminal")
def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json, qr, qr_ascii):
"""
Generate credentials for encoding/decoding.
@@ -261,13 +271,18 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
Examples:
stegasoo generate
stegasoo generate --words 5
stegasoo generate --rsa --rsa-bits 4096
stegasoo generate --rsa --rsa-bits 3072
stegasoo generate --rsa -o mykey.pem -p "secretpassword"
stegasoo generate --rsa --qr key.png
stegasoo generate --rsa --qr-ascii
stegasoo generate --no-pin --rsa
"""
if not pin and not rsa:
raise click.UsageError("Must enable at least one of --pin or --rsa")
if (qr or qr_ascii) and not rsa:
raise click.UsageError("QR output requires --rsa to generate an RSA key")
if output and not password:
raise click.UsageError("--password is required when saving RSA key to file")
@@ -334,6 +349,33 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
click.echo(creds.rsa_key_pem)
click.echo()
# QR code output (v4.2.0)
if qr:
if not HAS_QR:
click.secho(" ⚠️ QR code library not available", fg="yellow")
else:
# Determine format from extension
qr_path = Path(qr)
ext = qr_path.suffix.lower()
fmt = "jpeg" if ext in (".jpg", ".jpeg") else "png"
qr_bytes = generate_qr_code(creds.rsa_key_pem, compress=True, output_format=fmt)
qr_path.write_bytes(qr_bytes)
click.secho(f"─── RSA KEY QR CODE ───", fg="green")
click.secho(f" Saved to: {qr}", fg="bright_white")
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
click.echo()
if qr_ascii:
if not HAS_QR:
click.secho(" ⚠️ QR code library not available", fg="yellow")
else:
click.secho("─── RSA KEY QR CODE (ASCII) ───", fg="green")
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
click.echo()
ascii_qr = generate_qr_ascii(creds.rsa_key_pem, compress=True, invert=True)
click.echo(ascii_qr)
click.secho("─── SECURITY ───", fg="green")
click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)")
if creds.pin:

View File

View File

@@ -31,7 +31,7 @@ KEY PATTERNS
============
1. SUBPROCESS ISOLATION
Stegasoo's DCT mode uses scipy/jpegio which can crash on malformed input.
Stegasoo's DCT mode uses scipy/jpeglib which can crash on malformed input.
We run encode/decode in subprocesses so crashes don't take down the server:
subprocess_stego = SubprocessStego(timeout=180)
@@ -213,7 +213,7 @@ except ImportError:
#
# This is a critical reliability pattern. Here's the problem:
#
# scipy's DCT and jpegio can crash (segfault) on:
# scipy's DCT and jpeglib can crash (segfault) on:
# - Malformed JPEG files
# - Very large images that exhaust memory
# - Certain edge cases in coefficient manipulation
@@ -253,6 +253,7 @@ from stegasoo.qr_utils import (
detect_and_crop_qr,
extract_key_from_qr,
generate_qr_code,
is_compressed,
)
# Initialize subprocess wrapper (worker script must be in same directory)
@@ -1116,6 +1117,13 @@ def encode_page():
# Check if async mode requested
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
def _error_response(msg):
"""Return error as JSON (async) or HTML flash (sync)."""
if is_async:
return jsonify({"error": msg}), 400
flash(msg, "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
try:
# Get files
ref_photo = request.files.get("reference_photo")
@@ -1124,12 +1132,10 @@ def encode_page():
payload_file = request.files.get("payload_file")
if not ref_photo or not carrier:
flash("Both reference photo and carrier image are required", "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response("Both reference photo and carrier image are required")
if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename):
flash("Invalid file type. Use PNG, JPG, or BMP", "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response("Invalid file type. Use PNG, JPG, or BMP")
# Get form data - v3.2.0: renamed from day_phrase to passphrase
message = request.form.get("message", "")
@@ -1158,8 +1164,7 @@ def encode_page():
# Check DCT availability
if embed_mode == "dct" and not has_dct_support():
flash("DCT mode requires scipy. Install with: pip install scipy", "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response("DCT mode requires scipy. Install with: pip install scipy")
# Determine payload
if payload_type == "file" and payload_file and payload_file.filename:
@@ -1168,8 +1173,7 @@ def encode_page():
result = validate_file_payload(file_data, payload_file.filename)
if not result.is_valid:
flash(result.error_message, "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response(result.error_message)
mime_type, _ = mimetypes.guess_type(payload_file.filename)
payload = FilePayload(
@@ -1179,20 +1183,17 @@ def encode_page():
# Text message
result = validate_message(message)
if not result.is_valid:
flash(result.error_message, "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response(result.error_message)
payload = message
# v3.2.0: Renamed from day_phrase
if not passphrase:
flash("Passphrase is required", "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response("Passphrase is required")
# v3.2.0: Validate passphrase
result = validate_passphrase(passphrase)
if not result.is_valid:
flash(result.error_message, "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response(result.error_message)
# Show warning if passphrase is short
if result.warning:
@@ -1209,8 +1210,8 @@ def encode_page():
rsa_key_from_qr = False
if rsa_key_pem:
# Webcam-scanned PEM key (v4.1.5) - may be compressed
if rsa_key_pem.startswith("STEGASOO-Z:"):
# Webcam-scanned PEM key (v4.1.5+) - may be compressed (zlib or zstd)
if is_compressed(rsa_key_pem):
rsa_key_pem = decompress_data(rsa_key_pem)
rsa_key_data = rsa_key_pem.encode("utf-8")
rsa_key_from_qr = True
@@ -1223,21 +1224,18 @@ def encode_page():
rsa_key_data = key_pem.encode("utf-8")
rsa_key_from_qr = True
else:
flash("Could not extract RSA key from QR code image.", "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response("Could not extract RSA key from QR code image.")
# Validate security factors
result = validate_security_factors(pin, rsa_key_data)
if not result.is_valid:
flash(result.error_message, "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response(result.error_message)
# Validate PIN if provided
if pin:
result = validate_pin(pin)
if not result.is_valid:
flash(result.error_message, "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response(result.error_message)
# Determine key password
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
@@ -1246,14 +1244,12 @@ def encode_page():
if rsa_key_data:
result = validate_rsa_key(rsa_key_data, key_password)
if not result.is_valid:
flash(result.error_message, "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response(result.error_message)
# Validate carrier image
result = validate_image(carrier_data, "Carrier image")
if not result.is_valid:
flash(result.error_message, "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response(result.error_message)
# Pre-check payload capacity BEFORE encode (fail fast)
from stegasoo.steganography import will_fit_by_mode
@@ -1273,8 +1269,7 @@ def encode_page():
alt_check = will_fit_by_mode(payload_size, carrier_data, embed_mode="lsb")
if alt_check.get("fits"):
error_msg += " - Try LSB mode instead."
flash(error_msg, "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response(error_msg)
# Build encode params for either sync or async
encode_params = {
@@ -1375,14 +1370,11 @@ def encode_page():
return redirect(url_for("encode_result", file_id=file_id))
except CapacityError as e:
flash(str(e), "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response(str(e))
except StegasooError as e:
flash(str(e), "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response(str(e))
except Exception as e:
flash(f"Error: {e}", "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return _error_response(f"Error: {e}")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
@@ -1657,8 +1649,8 @@ def decode_page():
rsa_key_from_qr = False
if rsa_key_pem:
# Webcam-scanned PEM key (v4.1.5) - may be compressed
if rsa_key_pem.startswith("STEGASOO-Z:"):
# Webcam-scanned PEM key (v4.1.5+) - may be compressed (zlib or zstd)
if is_compressed(rsa_key_pem):
rsa_key_pem = decompress_data(rsa_key_pem)
rsa_key_data = rsa_key_pem.encode("utf-8")
rsa_key_from_qr = True
@@ -2105,6 +2097,241 @@ def api_tools_exif_clear():
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/tools/rotate", methods=["POST"])
@login_required
def api_tools_rotate():
"""Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
from PIL import Image
import shutil
import subprocess
import tempfile
image_file = request.files.get("image")
if not image_file:
return jsonify({"success": False, "error": "No image provided"}), 400
rotation = int(request.form.get("rotation", 0))
flip_h = request.form.get("flip_h", "false").lower() == "true"
flip_v = request.form.get("flip_v", "false").lower() == "true"
try:
image_data = image_file.read()
img = Image.open(io.BytesIO(image_data))
original_format = img.format # JPEG, PNG, etc.
img.close()
# For JPEGs, use jpegtran for lossless rotation/flip (preserves DCT stego)
has_jpegtran = shutil.which("jpegtran") is not None
use_jpegtran = original_format == "JPEG" and has_jpegtran and (rotation or flip_h or flip_v)
if use_jpegtran:
# Chain jpegtran operations for lossless transformation
current_data = image_data
# Apply rotation first
if rotation in (90, 180, 270):
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-rotate", str(rotation), "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
# Apply horizontal flip
if flip_h:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-flip", "horizontal", "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
# Apply vertical flip
if flip_v:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-flip", "vertical", "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
buffer = io.BytesIO(current_data)
mimetype = "image/jpeg"
ext = "jpg"
else:
# Fallback to PIL for non-JPEGs or when jpegtran unavailable
img = Image.open(io.BytesIO(image_data))
# Apply rotation (PIL rotates counter-clockwise, so negate)
if rotation:
img = img.rotate(-rotation, expand=True)
# Apply flips
if flip_h:
img = img.transpose(Image.FLIP_LEFT_RIGHT)
if flip_v:
img = img.transpose(Image.FLIP_TOP_BOTTOM)
# Preserve original format
buffer = io.BytesIO()
if original_format == "JPEG":
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(buffer, format="JPEG", quality=95)
mimetype = "image/jpeg"
ext = "jpg"
else:
img.save(buffer, format="PNG")
mimetype = "image/png"
ext = "png"
buffer.seek(0)
stem = (
image_file.filename.rsplit(".", 1)[0]
if "." in image_file.filename
else image_file.filename
)
return send_file(
buffer,
mimetype=mimetype,
as_attachment=True,
download_name=f"{stem}_transformed.{ext}",
)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/tools/compress", methods=["POST"])
@login_required
def api_tools_compress():
"""Compress image to JPEG at specified quality."""
from PIL import Image
image_file = request.files.get("image")
if not image_file:
return jsonify({"success": False, "error": "No image provided"}), 400
quality = int(request.form.get("quality", 85))
quality = max(10, min(100, quality)) # Clamp to valid range
try:
img = Image.open(io.BytesIO(image_file.read()))
# Convert to RGB if necessary (JPEG doesn't support alpha)
if img.mode in ("RGBA", "LA", "P"):
img = img.convert("RGB")
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=quality)
buffer.seek(0)
stem = (
image_file.filename.rsplit(".", 1)[0]
if "." in image_file.filename
else image_file.filename
)
return send_file(
buffer,
mimetype="image/jpeg",
as_attachment=True,
download_name=f"{stem}_q{quality}.jpg",
)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/tools/convert", methods=["POST"])
@login_required
def api_tools_convert():
"""Convert image to different format."""
from PIL import Image
image_file = request.files.get("image")
if not image_file:
return jsonify({"success": False, "error": "No image provided"}), 400
output_format = request.form.get("format", "PNG").upper()
quality = int(request.form.get("quality", 90))
quality = max(10, min(100, quality))
# Validate format
format_map = {
"PNG": ("png", "image/png"),
"JPEG": ("jpg", "image/jpeg"),
"WEBP": ("webp", "image/webp"),
}
if output_format not in format_map:
return jsonify({"success": False, "error": f"Unsupported format: {output_format}"}), 400
try:
img = Image.open(io.BytesIO(image_file.read()))
# Convert to RGB for JPEG (no alpha)
if output_format == "JPEG" and img.mode in ("RGBA", "LA", "P"):
img = img.convert("RGB")
buffer = io.BytesIO()
save_kwargs = {"format": output_format}
if output_format in ("JPEG", "WEBP"):
save_kwargs["quality"] = quality
img.save(buffer, **save_kwargs)
buffer.seek(0)
ext, mimetype = format_map[output_format]
stem = (
image_file.filename.rsplit(".", 1)[0]
if "." in image_file.filename
else image_file.filename
)
return send_file(
buffer,
mimetype=mimetype,
as_attachment=True,
download_name=f"{stem}.{ext}",
)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
# Add these two test routes anywhere in app.py after the app = Flask(...) line:
@@ -2554,6 +2781,70 @@ def api_channel_key_use(key_id):
# ============================================================================
@app.route("/admin/settings")
@admin_required
def admin_settings():
"""System settings page (admin only)."""
import platform
import sys
from stegasoo import __version__
from stegasoo.channel import get_channel_status
channel_status = get_channel_status()
return render_template(
"admin/settings.html",
# Channel info (key hidden until password verified)
channel_configured=channel_status["configured"],
channel_fingerprint=channel_status.get("fingerprint"),
channel_source=channel_status.get("source"),
# Server config
hostname=os.environ.get("STEGASOO_HOSTNAME") or socket.gethostname(),
port=os.environ.get("STEGASOO_PORT", "5000"),
https_enabled=app.config.get("HTTPS_ENABLED", False),
auth_enabled=app.config.get("AUTH_ENABLED", True),
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
max_upload_mb=MAX_FILE_SIZE // (1024 * 1024),
dct_available=has_dct_support(),
qr_available=HAS_QRCODE_READ,
# Environment
version=__version__,
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
platform=platform.system(),
kdf_type="Argon2id" if has_argon2() else "PBKDF2",
)
@app.route("/admin/settings/unlock", methods=["POST"])
@admin_required
def admin_settings_unlock():
"""Verify password and return channel key (AJAX)."""
from stegasoo.channel import get_channel_status
data = request.get_json() or {}
password = data.get("password", "")
if not password:
return jsonify({"success": False, "error": "Password required"})
# Get current user and verify password
username = get_username()
user = verify_user_password(username, password)
if not user:
return jsonify({"success": False, "error": "Incorrect password"})
# Password verified - return channel key
channel_status = get_channel_status()
channel_key = channel_status.get("key") if channel_status["configured"] else ""
return jsonify({
"success": True,
"channel_key": channel_key
})
@app.route("/admin/users")
@admin_required
def admin_users():

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

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

File diff suppressed because one or more lines are too long

View File

@@ -333,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;
}
}
},
@@ -997,7 +1009,9 @@ const Stegasoo = {
const percent = progressData.percent || 0;
const phase = progressData.phase || 'processing';
this.updateProgress(percent, this.formatPhase(phase));
// Use indeterminate mode for initializing/starting phases
const isIndeterminate = (phase === 'initializing' || phase === 'starting');
this.updateProgress(percent, this.formatPhase(phase), isIndeterminate);
// Continue polling
setTimeout(poll, 500);
@@ -1017,7 +1031,7 @@ const Stegasoo = {
formatPhase(phase) {
const phases = {
'starting': 'Starting...',
'initializing': 'Initializing...',
'initializing': 'Deriving keys (may take a moment)...',
'embedding': 'Embedding data...',
'saving': 'Saving image...',
'finalizing': 'Finalizing...',
@@ -1058,8 +1072,9 @@ const Stegasoo = {
document.body.appendChild(modal);
}
// Reset progress
this.updateProgress(0, 'Initializing...');
// Reset progress tracking and start with indeterminate state
this.resetProgressTracking();
this.updateProgress(0, 'Initializing...', true);
// Show modal
const bsModal = new bootstrap.Modal(modal);
@@ -1078,16 +1093,47 @@ const Stegasoo = {
},
/**
* Update progress bar and text
* Track max progress to prevent backwards jumps
*/
updateProgress(percent, phase) {
_maxProgress: 0,
/**
* Reset progress tracking (call when starting new operation)
*/
resetProgressTracking() {
this._maxProgress = 0;
},
/**
* Update progress bar and text
* Supports indeterminate mode for initializing phase (barber pole at full width)
*/
updateProgress(percent, phase, indeterminate = false) {
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;
if (indeterminate) {
// Barber pole animation at full width, no percentage
if (progressBar) {
progressBar.style.width = '100%';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
if (progressText) progressText.textContent = '';
if (phaseText) phaseText.textContent = phase;
} else {
// Determinate progress - never go backwards
const safePercent = Math.max(percent, this._maxProgress);
this._maxProgress = safePercent;
if (progressBar) {
progressBar.style.width = safePercent + '%';
// Keep animation but show actual progress
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
if (progressText) progressText.textContent = Math.round(safePercent) + '%';
if (phaseText) phaseText.textContent = phase;
}
},
// ========================================================================
@@ -1175,7 +1221,9 @@ const Stegasoo = {
const percent = progressData.percent || 0;
const phase = progressData.phase || 'processing';
this.updateProgress(percent, this.formatDecodePhase(phase));
// Use indeterminate mode for initializing/starting/loading phases
const isIndeterminate = (phase === 'initializing' || phase === 'starting' || phase === 'loading');
this.updateProgress(percent, this.formatDecodePhase(phase), isIndeterminate);
// Continue polling
setTimeout(poll, 500);
@@ -1195,8 +1243,11 @@ const Stegasoo = {
formatDecodePhase(phase) {
const phases = {
'starting': 'Starting...',
'initializing': 'Deriving keys (may take a moment)...',
'loading': 'Deriving keys (may take a moment)...',
'reading': 'Reading image...',
'extracting': 'Extracting data...',
'decoding': 'Decoding data...',
'decrypting': 'Decrypting...',
'verifying': 'Verifying...',
'finalizing': 'Finalizing...',

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 {
@@ -1699,3 +1866,518 @@ footer {
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: 0.5rem;
}
.tool-section.active {
display: flex;
flex-direction: column;
}
/* EXIF Grid Layout */
.exif-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.3rem;
max-height: 280px;
overflow-y: auto;
padding: 0.15rem;
}
.exif-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 4px;
padding: 0.25rem 0.4rem;
}
.exif-card:hover {
background: rgba(255, 255, 255, 0.06);
}
.exif-card-label {
font-size: 0.55rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.02em;
margin-bottom: 0.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.exif-card-value {
font-size: 0.7rem;
font-family: 'SF Mono', 'Consolas', monospace;
color: rgba(255, 255, 255, 0.85);
word-break: break-word;
line-height: 1.2;
}
.exif-card-value.truncated {
max-height: 2.4em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* Category headers */
.exif-category {
grid-column: 1 / -1;
font-size: 0.6rem;
font-weight: 600;
color: var(--bs-primary);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.35rem 0 0.15rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
margin-top: 0.15rem;
}
.exif-category:first-child {
margin-top: 0;
padding-top: 0;
}
/* Compact tool headers and actions */
.tool-results-header {
padding-bottom: 0.35rem;
margin-bottom: 0.35rem;
}
.tool-results-header h6 {
font-size: 0.8rem;
margin-bottom: 0;
}
.tool-results-header small {
font-size: 0.65rem;
}
.tool-results-actions {
padding-top: 0.35rem;
margin-top: 0.35rem;
}
/* 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

@@ -3,7 +3,7 @@
Stegasoo Subprocess Worker (v4.0.0)
This script runs in a subprocess and handles encode/decode operations.
If it crashes due to jpegio/scipy issues, the parent Flask process survives.
If it crashes due to jpeglib/scipy issues, the parent Flask process survives.
CHANGES in v4.0.0:
- Added channel_key support for encode/decode operations
@@ -171,8 +171,7 @@ 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")
# Library handles progress internally via progress_file parameter
# Call decode with correct parameter names
result = decode(
stego_image=stego_data,
@@ -183,9 +182,9 @@ def decode_operation(params: dict) -> dict:
rsa_password=params.get("rsa_password"),
embed_mode=params.get("embed_mode", "auto"),
channel_key=resolved_channel_key, # v4.0.0
progress_file=progress_file, # v4.2.0: pass through for real-time progress
)
_write_decode_progress(progress_file, 90, "finalizing")
# Library writes 100% "complete" - no need for worker to write again
if result.is_file:
return {

View File

@@ -132,7 +132,7 @@ class SubprocessStego:
"""
Subprocess-isolated steganography operations.
All operations run in a separate Python process. If jpegio or scipy
All operations run in a separate Python process. If jpeglib or scipy
crashes, only the subprocess dies - Flask keeps running.
"""

View File

@@ -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,58 +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 (Admin only) -->
{% if is_admin %}
<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
<span class="badge bg-warning text-dark ms-2"><i class="bi bi-shield-check me-1"></i>Admin</span>
</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>
{% endif %}
</div>
</div>
@@ -381,11 +340,13 @@
<!-- 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>
<span class="badge bg-success fs-6 me-3">v4.2.1</span>
<div>
<strong>Progress bars</strong> for encode operations,
<strong>mobile-responsive polish</strong>,
DCT decode bug fix, release validation script
<strong>Security & API improvements:</strong>
API key authentication,
TLS with self-signed certs,
CLI tools (compress, rotate, convert),
jpegtran lossless JPEG rotation
</div>
</div>
</div>
@@ -403,6 +364,10 @@
<div class="accordion-body p-0">
<table class="table table-dark table-sm small mb-0">
<tbody>
<tr>
<td width="80"><strong>4.1.7</strong></td>
<td>Progress bars for encode, mobile polish, release validation</td>
</tr>
<tr>
<td width="80"><strong>4.1.1</strong></td>
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
@@ -600,7 +565,7 @@
</tr>
<tr>
<td><i class="bi bi-clock me-2"></i>File expiry</td>
<td><strong>5 min</strong></td>
<td><strong>10 min</strong></td>
</tr>
<tr>
<td><i class="bi bi-key me-2"></i>PIN</td>
@@ -608,7 +573,7 @@
</tr>
<tr>
<td><i class="bi bi-shield me-2"></i>RSA keys</td>
<td><strong>2048, 3072, 4096 bit</strong></td>
<td><strong>2048, 3072 bit</strong></td>
</tr>
<tr>
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>
@@ -635,62 +600,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

@@ -250,7 +250,10 @@
</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 PNG
<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>
@@ -263,7 +266,7 @@
<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="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
<script src="{{ url_for('static', filename='js/qrcode.min.js') }}"></script>
{% endif %}
<script>
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
@@ -305,20 +308,20 @@ function showKeyQr(channelKey, keyName) {
document.getElementById('qrKeyName').textContent = keyName;
document.getElementById('qrKeyDisplay').textContent = formatted;
// Generate QR code
// Generate QR code using QRious
const canvas = document.getElementById('qrCanvas');
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;
}
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);
}
}
}
@@ -333,6 +336,105 @@ document.getElementById('qrDownload')?.addEventListener('click', function() {
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 me-auto" 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 me-auto" 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,13 +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>
<!-- QR Code scanning library (v4.1.5) -->
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.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

@@ -6,20 +6,25 @@
<style>
/* Accordion styling */
.step-accordion .accordion-button {
background: rgba(30, 40, 50, 0.6);
background: rgba(35, 45, 55, 0.8);
color: #fff;
padding: 0.75rem 1rem;
border-left: 3px solid transparent;
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(99, 179, 237, 0.15) 0%, rgba(40, 50, 60, 0.8) 40%, rgba(40, 50, 60, 0.8) 100%);
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(99, 179, 237, 0.1);
border-left: 3px solid rgba(99, 179, 237, 0.6);
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);
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
}
.step-accordion .accordion-body {
background: rgba(30, 40, 50, 0.4);
@@ -106,46 +111,7 @@
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
}
/* QR Crop Animation */
.qr-crop-container {
position: relative;
overflow: hidden;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
width: 100%;
display: flex;
justify-content: center;
align-items: center;
min-height: 120px;
}
.qr-crop-container img {
display: block;
max-height: 180px;
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 Animation - uses .qr-scan-container from style.css */
</style>
<div class="row justify-content-center">
@@ -192,7 +158,7 @@
<div class="alert alert-warning small">
<i class="bi bi-clock me-1"></i>
<strong>File expires in 5 minutes.</strong> Download now.
<strong>File expires in 10 minutes.</strong> Download now.
</div>
<a href="/decode" class="btn btn-outline-light w-100">
@@ -274,26 +240,18 @@
</div>
<!-- Extraction Mode -->
<label class="form-label"><i class="bi bi-cpu me-1"></i> Extraction Mode</label>
<div class="d-flex gap-2 mb-2">
<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 ms-2"></i>
<span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· Try both</span></span>
</label>
<label class="mode-btn flex-fill" id="lsbModeCard" for="modeLsb">
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsb" value="lsb">
<i class="bi bi-grid-3x3-gap text-primary ms-2"></i>
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Email</span></span>
</label>
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %}" id="dctModeCard" for="modeDct">
<input class="form-check-input" type="radio" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
<i class="bi bi-soundwave text-warning ms-2"></i>
<span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Social</span></span>
</label>
<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">
<i class="bi bi-lightbulb me-1"></i><strong>Auto</strong> tries LSB first, then DCT.
<div class="form-text" id="modeHint">
<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT
</div>
</div>
@@ -394,7 +352,7 @@
<i class="bi bi-qr-code-scan fs-5 d-block text-muted mb-1"></i>
<span class="text-muted small">Drop QR image</span>
</div>
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
<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>
@@ -461,6 +419,25 @@
{% block scripts %}
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
<script>
// ============================================================================
// 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' }
};
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}`;
}
});
});
// ============================================================================
// ACCORDION SUMMARY UPDATES
// ============================================================================
@@ -533,15 +510,10 @@ document.querySelector('input[name="rsa_key"]')?.addEventListener('change', upda
// MODE SWITCHING
// ============================================================================
const modeRadios = document.querySelectorAll('input[name="embed_mode"]');
const modeBtns = { 'auto': document.getElementById('autoModeCard'), 'lsb': document.getElementById('lsbModeCard'), 'dct': document.getElementById('dctModeCard') };
modeRadios.forEach(radio => {
radio.addEventListener('change', () => {
Object.values(modeBtns).forEach(btn => btn?.classList.remove('active'));
modeBtns[radio.value]?.classList.add('active');
});
});
// Apply disabled styling to DCT if not available
if (document.getElementById('modeDct')?.disabled) {
document.getElementById('dctModeLabel')?.classList.add('disabled', 'text-muted');
}
// ============================================================================
// LOADING STATE

View File

@@ -6,20 +6,25 @@
<style>
/* Accordion styling */
.step-accordion .accordion-button {
background: rgba(30, 40, 50, 0.6);
background: rgba(35, 45, 55, 0.8);
color: #fff;
padding: 0.75rem 1rem;
border-left: 3px solid transparent;
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(99, 179, 237, 0.15) 0%, rgba(40, 50, 60, 0.8) 40%, rgba(40, 50, 60, 0.8) 100%);
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(99, 179, 237, 0.1);
border-left: 3px solid rgba(99, 179, 237, 0.6);
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);
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
}
.step-accordion .accordion-body {
background: rgba(30, 40, 50, 0.4);
@@ -106,46 +111,7 @@
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
}
/* QR Crop Animation */
.qr-crop-container {
position: relative;
overflow: hidden;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
width: 100%;
display: flex;
justify-content: center;
align-items: center;
min-height: 120px;
}
.qr-crop-container img {
display: block;
max-height: 180px;
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 Animation - uses .qr-scan-container from style.css */
</style>
<div class="row justify-content-center">
@@ -202,7 +168,7 @@
<div class="col-md-6 mb-3">
<label class="form-label">
<i class="bi bi-file-image me-1"></i> Carrier Image
<i class="bi bi-file-earmark-image me-1"></i> Carrier Image
</label>
<div class="drop-zone pixel-container" id="carrierDropZone">
<input type="file" name="carrier" accept="image/*" required id="carrierInput">
@@ -238,37 +204,32 @@
</div>
</div>
<!-- Embedding Mode -->
<label class="form-label"><i class="bi bi-cpu me-1"></i> Embedding Mode</label>
<div class="d-flex gap-2 mb-2">
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %} {% if has_dct %}active{% endif %}" id="dctModeCard" for="modeDct">
<input class="form-check-input" type="radio" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
<i class="bi bi-soundwave text-warning ms-2"></i>
<span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Social</span></span>
</label>
<label class="mode-btn flex-fill {% if not has_dct %}active{% endif %}" id="lsbModeCard" for="modeLsb">
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsb" value="lsb" {% if not has_dct %}checked{% endif %}>
<i class="bi bi-grid-3x3-gap text-primary ms-2"></i>
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Email</span></span>
</label>
</div>
<!-- DCT Options (inline, compact) -->
<div class="{% if not has_dct %}d-none{% endif %}" id="dctOptionsInline">
<div class="row g-2 mt-2" id="dctOptionsRow">
<div class="col-6">
<select class="form-select form-select-sm" name="dct_color_mode" id="dctColorSelect">
<option value="color" selected>Color</option>
<option value="grayscale">Grayscale</option>
</select>
</div>
<div class="col-6">
<select class="form-select form-select-sm" name="dct_output_format" id="dctFormatSelect">
<option value="jpeg" selected>JPEG</option>
<option value="png">PNG</option>
</select>
</div>
<!-- Embedding Mode (compact inline) -->
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
<div class="btn-group btn-group-sm" role="group">
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb" {% if not has_dct %}checked{% endif %}>
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
</div>
<span class="text-muted d-none d-sm-inline">|</span>
<span class="d-flex gap-2 align-items-center" id="outputOptions">
<div class="btn-group btn-group-sm" role="group">
<input type="radio" class="btn-check" name="dct_color_mode" id="colorMode" value="color" checked>
<label class="btn btn-outline-secondary btn-sm" for="colorMode">Color</label>
<input type="radio" class="btn-check" name="dct_color_mode" id="grayMode" value="grayscale">
<label class="btn btn-outline-secondary btn-sm" for="grayMode" id="grayModeLabel">Gray</label>
</div>
<div class="btn-group btn-group-sm" role="group">
<input type="radio" class="btn-check" name="dct_output_format" id="jpegFormat" value="jpeg" checked>
<label class="btn btn-outline-secondary btn-sm" for="jpegFormat" id="jpegFormatLabel">JPEG</label>
<input type="radio" class="btn-check" name="dct_output_format" id="pngFormat" value="png">
<label class="btn btn-outline-secondary btn-sm" for="pngFormat">PNG</label>
</div>
</span>
</div>
<div class="form-text" id="modeHint">
<i class="bi bi-{% if has_dct %}phone{% else %}hdd{% endif %} me-1"></i>{% if has_dct %}Survives social media compression{% else %}Higher capacity for direct transfers{% endif %}
</div>
</div>
@@ -428,7 +389,7 @@
<i class="bi bi-qr-code-scan fs-5 d-block text-muted mb-1"></i>
<span class="text-muted small">Drop QR image</span>
</div>
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
<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>
@@ -473,7 +434,7 @@
</div>
<div class="col-4">
<i class="bi bi-eye-slash fs-5 d-block mb-1 text-warning"></i>
Undetectable
Covertly Embedded
</div>
</div>
</div>
@@ -483,6 +444,24 @@
{% block scripts %}
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
<script>
// ============================================================================
// MODE HINT - Dynamic text based on selected embedding mode
// ============================================================================
const modeHints = {
dct: { icon: 'phone', text: 'Survives social media compression' },
lsb: { icon: 'hdd', text: 'Higher capacity, outputs Color PNG' }
};
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}`;
}
});
});
// ============================================================================
// ACCORDION SUMMARY UPDATES
// ============================================================================
@@ -665,21 +644,47 @@ carrierInput?.addEventListener('change', function() {
// ============================================================================
const modeRadios = document.querySelectorAll('input[name="embed_mode"]');
const modeBtns = { 'dct': document.getElementById('dctModeCard'), 'lsb': document.getElementById('lsbModeCard') };
const dctOptionsRow = document.getElementById('dctOptionsRow');
const dctModeLabel = document.getElementById('dctModeLabel');
const grayModeInput = document.getElementById('grayMode');
const grayModeLabel = document.getElementById('grayModeLabel');
const jpegFormatInput = document.getElementById('jpegFormat');
const jpegFormatLabel = document.getElementById('jpegFormatLabel');
const colorModeInput = document.getElementById('colorMode');
const pngFormatInput = document.getElementById('pngFormat');
// Apply disabled styling to DCT if not available
if (document.getElementById('modeDct')?.disabled) {
dctModeLabel?.classList.add('disabled', 'text-muted');
}
function updateOutputOptions(mode) {
const isLsb = mode === 'lsb';
if (isLsb) {
// LSB only supports Color + PNG
colorModeInput.checked = true;
pngFormatInput.checked = true;
grayModeInput.disabled = true;
jpegFormatInput.disabled = true;
grayModeLabel?.classList.add('disabled', 'text-muted');
jpegFormatLabel?.classList.add('disabled', 'text-muted');
} else {
// DCT: reset to defaults (Color + JPEG) and enable all
colorModeInput.checked = true;
jpegFormatInput.checked = true;
grayModeInput.disabled = false;
jpegFormatInput.disabled = false;
grayModeLabel?.classList.remove('disabled', 'text-muted');
jpegFormatLabel?.classList.remove('disabled', 'text-muted');
}
}
modeRadios.forEach(radio => {
radio.addEventListener('change', () => {
Object.values(modeBtns).forEach(btn => btn?.classList.remove('active'));
modeBtns[radio.value]?.classList.add('active');
dctOptionsRow?.classList.toggle('d-none', radio.value !== 'dct');
});
radio.addEventListener('change', () => updateOutputOptions(radio.value));
});
// Show DCT options if DCT selected initially
if (document.getElementById('modeDct')?.checked) {
dctOptionsRow?.classList.remove('d-none');
}
// Initialize output options based on initial mode
const initialMode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'lsb';
updateOutputOptions(initialMode);
// ============================================================================
// DUPLICATE FILE CHECK

View File

@@ -128,7 +128,7 @@
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Important:</strong>
<ul class="mb-0 mt-2">
<li>This file expires in <strong>5 minutes</strong></li>
<li>This file expires in <strong>10 minutes</strong></li>
<li>Do <strong>not</strong> resize or recompress the image</li>
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
<li>JPEG format is lossy - avoid re-saving or editing</li>

View File

@@ -65,11 +65,7 @@
<select name="rsa_bits" class="form-select form-select-sm" id="rsaBitsSelect">
<option value="2048" selected>2048 bits (~128 bits entropy)</option>
<option value="3072">3072 bits (~128 bits entropy)</option>
<option value="4096">4096 bits (~128 bits entropy)</option>
</select>
<div class="form-text text-warning d-none" id="rsaQrWarning">
<i class="bi bi-exclamation-triangle me-1"></i>QR code unavailable for keys &gt;3072 bits
</div>
</div>
</div>
</div>
@@ -100,8 +96,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>
@@ -286,12 +282,6 @@
<i class="bi bi-shield-exclamation me-1"></i>
<strong>Security note:</strong> The QR code contains your unencrypted private key.
Only scan in a secure environment. Consider using the password-protected download instead.
{% if rsa_bits >= 4096 %}
<br><br>
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>4096-bit keys</strong> produce very dense QR codes. If scanning fails,
use the PEM text or download options instead.
{% endif %}
</div>
</div>
</div>
@@ -483,17 +473,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

@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
[project]
name = "stegasoo"
version = "4.1.5"
version = "4.2.1"
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
requires-python = ">=3.11"
authors = [
{ name = "Aaron D. Lee" }
]
@@ -29,9 +29,10 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Security :: Cryptography",
"Topic :: Multimedia :: Graphics",
]
@@ -40,6 +41,7 @@ dependencies = [
"pillow>=10.0.0",
"cryptography>=41.0.0",
"argon2-cffi>=23.0.0",
"zstandard>=0.22.0", # v4.2.0: Default compression algorithm
]
[project.optional-dependencies]
@@ -47,7 +49,7 @@ dependencies = [
dct = [
"numpy>=2.0.0",
"scipy>=1.10.0",
"jpegio>=0.2.0",
"jpeglib>=1.0.0",
"reedsolo>=1.7.0",
]
cli = [
@@ -57,7 +59,7 @@ cli = [
"rich>=13.0.0",
]
compression = [
"lz4>=4.0.0",
"lz4>=4.0.0", # Optional: faster but slightly worse ratio than zstd
]
web = [
"flask>=3.0.0",
@@ -68,7 +70,7 @@ web = [
# Include DCT support for web UI
"numpy>=2.0.0",
"scipy>=1.10.0",
"jpegio>=0.2.0",
"jpeglib>=1.0.0",
"reedsolo>=1.7.0",
]
api = [
@@ -80,7 +82,7 @@ api = [
# Include DCT support for API
"numpy>=2.0.0",
"scipy>=1.10.0",
"jpegio>=0.2.0",
"jpeglib>=1.0.0",
"reedsolo>=1.7.0",
]
all = [
@@ -110,7 +112,14 @@ include = [
]
[tool.hatch.build.targets.wheel]
packages = ["src/stegasoo"]
packages = ["src/stegasoo", "frontends"]
[tool.hatch.build.targets.wheel.sources]
"src" = ""
# Include data files in the wheel
[tool.hatch.build.targets.wheel.force-include]
"src/stegasoo/data/bip39-words.txt" = "stegasoo/data/bip39-words.txt"
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -119,7 +128,7 @@ addopts = "-v --cov=stegasoo --cov-report=term-missing"
[tool.black]
line-length = 100
target-version = ["py310", "py311", "py312"]
target-version = ["py311", "py312", "py313"]
[tool.ruff]
line-length = 100
@@ -136,7 +145,7 @@ ignore = ["E501"]
"src/stegasoo/__init__.py" = ["E402"]
[tool.mypy]
python_version = "3.10"
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = true

View File

@@ -26,51 +26,50 @@ ssh admin@stegasoo.local
## Step 3: Pre-Setup
```bash
# Take ownership of /opt (for pyenv, jpegio builds)
# Take ownership of /opt
sudo chown admin:admin /opt
# Install git and zstd (not included in Lite image)
sudo apt-get update && sudo apt-get install -y git zstd jq
# Install git (not included in Lite image)
sudo apt-get update && sudo apt-get install -y git
```
## Step 4: Clone Repo
```bash
cd /opt
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
```
## Step 5: Copy Pre-built Tarball (from host)
> **Dev-only asset:** This tarball is for building Pi images, not for end users.
> It's available on [Releases](https://github.com/adlee-was-taken/stegasoo/releases) for image builders.
```bash
# On your host machine:
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
```
This tarball contains:
- pyenv with Python 3.12 (pre-compiled for ARM64)
- venv with all dependencies (jpegio, scipy, etc.)
Install time: **~2 minutes** (vs 20+ min from source)
## Step 6: Run Setup
## Step 5: Run Setup
```bash
cd /opt/stegasoo
./rpi/setup.sh # Detects local tarball, skips download
./rpi/setup.sh
```
### From-Source Build (optional)
The setup script:
- Verifies Python 3.11+ (system Python, no pyenv needed)
- Installs dependencies via apt and pip
- jpeglib installs cleanly (no ARM patching like jpegio)
- Creates and enables systemd service
Install time: **5-10 minutes** (from source)
### Pre-built Venv (optional)
For faster installs, you can provide a pre-built venv tarball:
To build without the pre-built tarball:
```bash
./rpi/setup.sh --no-prebuilt # Takes 15-20 minutes
# On your host machine:
scp rpi/stegasoo-rpi-venv-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
# Then on Pi:
cd /opt/stegasoo && ./rpi/setup.sh # Detects local tarball, skips pip build
```
## Step 7: Test It Works
Install time with pre-built: **~2 minutes**
## Step 6: Test It Works
```bash
sudo systemctl start stegasoo
@@ -78,7 +77,7 @@ curl -k https://localhost:5000
# Should return HTML
```
## Step 8: Sanitize for Distribution
## Step 7: Sanitize for Distribution
```bash
# Full sanitize (for final image - removes WiFi, shuts down)
@@ -98,7 +97,7 @@ This removes:
The script validates all cleanup steps before finishing.
## Step 9: Pull the Image
## Step 8: Pull the Image
Remove SD card, insert into your Linux machine:
@@ -107,12 +106,12 @@ Remove SD card, insert into your Linux machine:
lsblk
# Pull image (auto-resizes to 16GB, compresses with zstd)
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
```
The script automatically resizes rootfs to 16GB, disables auto-expand, and compresses.
The script automatically resizes rootfs to 16GB (for smaller download), preserves auto-expand, and compresses.
## Step 10: Distribute
## Step 9: Distribute
Upload `.img.zst` to GitHub Releases.
@@ -130,36 +129,31 @@ zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
---
## Creating the Pre-built Tarball
## Creating the Pre-built Venv Tarball
After a successful from-source build, create the pre-built tarball for future installs:
```bash
# On the Pi after successful setup:
cd ~
cd /opt/stegasoo
# 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
# Strip caches and tests from venv (saves ~100MB)
find venv/ -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
find venv/ -type d -name 'tests' -exec rm -rf {} + 2>/dev/null
find 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
tar -cf - venv/ | zstd -19 -T0 > /tmp/stegasoo-rpi-venv-arm64.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
# Check size (should be ~40-50MB)
ls -lh /tmp/stegasoo-rpi-venv-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
scp admin@stegasoo.local:/tmp/stegasoo-rpi-venv-arm64.tar.zst ./rpi/
# Upload to GitHub releases as stegasoo-rpi-venv-arm64.tar.zst
```
---
@@ -169,18 +163,15 @@ scp admin@stegasoo.local:/tmp/stegasoo-rpi-runtime-env-arm64.tar.zst ./
```bash
# On Pi (after SSH):
sudo chown admin:admin /opt
sudo apt-get update && sudo apt-get install -y git zstd jq
cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
sudo apt-get update && sudo apt-get install -y git
cd /opt && git clone -b 4.2 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):
# Run setup:
cd /opt/stegasoo && ./rpi/setup.sh
sudo systemctl start stegasoo
curl -k https://localhost:5000
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
# On host (pull image - auto-resizes to 16GB):
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
```

View File

@@ -4,7 +4,7 @@ Scripts and resources for deploying Stegasoo on Raspberry Pi.
## Quick Install
On a fresh Raspberry Pi OS Lite (64-bit) installation:
On a fresh Raspberry Pi OS (64-bit) installation:
```bash
# Pre-setup (git not included in Lite image)
@@ -13,16 +13,16 @@ sudo apt-get update && sudo apt-get install -y git
# Clone and run setup
cd /opt
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
cd stegasoo
./rpi/setup.sh
```
## What the Setup Script Does
1. **Installs system dependencies** - build tools, libraries
2. **Installs Python 3.12** - via pyenv (Pi OS ships with 3.13 which is incompatible)
3. **Builds jpegio for ARM** - patches x86-specific flags
1. **Verifies Python 3.11+** - uses system Python (no pyenv needed)
2. **Installs system dependencies** - build tools, libraries
3. **Installs jpeglib** - DCT steganography (Python 3.11-3.14 compatible)
4. **Installs Stegasoo** - with web UI and all dependencies
5. **Creates systemd service** - auto-starts on boot
6. **Enables the service** - ready to start
@@ -30,11 +30,18 @@ cd stegasoo
## Requirements
- Raspberry Pi 4 or 5
- Raspberry Pi OS Lite (64-bit) - Bookworm or later
- Raspberry Pi OS (64-bit) - Bookworm (Python 3.11) or Trixie (Python 3.13)
- 4GB+ RAM recommended (2GB minimum)
- 16GB+ SD card (pre-built images are 16GB)
- Internet connection
### Python Compatibility
| Raspberry Pi OS | Python | Supported |
|-----------------|--------|-----------|
| Bookworm | 3.11 | Yes |
| Trixie | 3.13 | Yes |
### Performance
On a Pi 4 at 2GHz with USB 3.0 NVMe, expect ~60 seconds to encode/decode a 10MB JPEG with full encryption (passphrase + PIN + reference photo).
@@ -159,7 +166,7 @@ sudo apt-get update && sudo apt-get install -y git
# Clone and run setup
cd /opt
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
cd stegasoo
./rpi/setup.sh
```
@@ -200,12 +207,12 @@ After Pi shuts down, remove SD card and on another Linux machine:
lsblk
# Pull image (auto-resizes to 16GB, compresses with zstd)
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
```
The `pull-image.sh` script automatically:
- Resizes rootfs to exactly 16GB (consistent image size)
- Disables Pi OS auto-expand
- 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

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

@@ -0,0 +1,70 @@
#!/bin/bash
#
# Build Stegasoo Pi venv Tarball
# Run this ON THE PI after a successful from-source build
#
# Creates: stegasoo-rpi-venv-arm64.tar.zst (~40-50MB)
# Contains: venv with all dependencies (uses system Python 3.11+)
#
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_FILE="${1:-$HOME/stegasoo-rpi-venv-arm64.tar.zst}"
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Stegasoo Pi venv 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 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/2]${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 tarball
echo -e "${GREEN}[2/2]${NC} Creating tarball..."
cd "$INSTALL_DIR"
tar -cf - venv/ | zstd -19 -T0 > "$OUTPUT_FILE"
# Summary
FINAL_SIZE=$(ls -lh "$OUTPUT_FILE" | awk '{print $5}')
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, place at:"
echo " rpi/stegasoo-rpi-venv-arm64.tar.zst"
echo ""
echo "Or upload to GitHub releases for automatic download."

View File

@@ -53,6 +53,48 @@ 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
# =============================================================================
@@ -137,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

View File

@@ -80,9 +80,9 @@ if [ -z "$1" ]; then
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
echo ""
echo "Examples:"
echo " $0 stegasoo-rpi-4.1.1.img.zst # auto-detect SD card"
echo " $0 stegasoo-rpi-4.1.1.img.zst.zip # from GitHub release"
echo " $0 stegasoo-rpi-4.1.1.img.zst /dev/sdb # specify device"
echo " $0 stegasoo-rpi-4.2.1.img.zst # auto-detect SD card"
echo " $0 stegasoo-rpi-4.2.1.img.zst.zip # from GitHub release"
echo " $0 stegasoo-rpi-4.2.1.img.zst /dev/sdb # specify device"
exit 1
fi
@@ -249,16 +249,9 @@ if [ -n "$MOUNTED" ]; then
done
fi
# Ask about wiping
# 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
if [[ "$wipe_confirm" =~ ^[Yy]$ ]]; then
echo "Wiping partition table..."
sudo wipefs -a "$SELECTED"
sudo dd if=/dev/zero of="$SELECTED" bs=1M count=10 status=none
sync
echo " Wiped clean"
fi
# Final confirmation
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
@@ -272,73 +265,65 @@ 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 ""
# Try rpi-imager first (faster, native support for compressed images)
if command -v rpi-imager &> /dev/null; then
echo -e "${YELLOW}Using rpi-imager...${NC}"
if rpi-imager --cli --disable-verify "$IMAGE" "$SELECTED"; then
# rpi-imager succeeded
:
else
echo -e "${YELLOW}rpi-imager failed, falling back to dd...${NC}"
# Fall through to dd
USE_DD=true
fi
# 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) 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
USE_DD=true
fi
# Fallback to dd
if [ "$USE_DD" = true ]; then
if [ "$HAS_PV" = true ]; then
echo -e "${YELLOW}Using dd with progress...${NC}"
if [ "$COMPRESSED" = true ]; then
case "$COMP_TYPE" in
xz) pv "$IMAGE" | xzcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
zst) pv "$IMAGE" | zstdcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
gz) pv "$IMAGE" | zcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
esac
else
pv "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null
fi
else
echo -e "${YELLOW}Using dd (no progress - install pv for progress bar)...${NC}"
if [ "$COMPRESSED" = true ]; then
case "$COMP_TYPE" in
xz) xzcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
zst) zstdcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
gz) zcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
esac
else
dd if="$IMAGE" of="$SELECTED" bs=4M conv=fsync status=progress
fi
fi
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}"
# Wait for partitions to appear
sleep 2
partprobe "$SELECTED" 2>/dev/null || true
sleep 1
# Determine boot partition
if [[ "$SELECTED" == *"nvme"* ]] || [[ "$SELECTED" == *"mmcblk"* ]]; then
BOOT_PART="${SELECTED}p1"
else
BOOT_PART="${SELECTED}1"
fi
if [ -b "$BOOT_PART" ]; then
MOUNT_DIR=$(mktemp -d)
if mount "$BOOT_PART" "$MOUNT_DIR" 2>/dev/null; then

View File

@@ -2,56 +2,39 @@
This directory contains patches for dependencies that need modifications to build on ARM64.
## Current Status (v4.2+)
As of Stegasoo 4.2, we use **jpeglib** instead of jpegio. The jpeglib build process is handled inline in `setup.sh` and includes:
- Cloning from GitHub (PyPI tarball missing headers)
- Downloading libjpeg headers for each version (6b through 9f)
- Patching setup.py to skip turbo/mozjpeg (need cmake-generated headers)
See `setup.sh` for the full implementation.
## Legacy: jpegio Patches (v4.1 and earlier)
The `jpegio/` directory contains patches for the old jpegio dependency, which required removing x86-specific `-m64` compiler flags. These are no longer used but kept for reference.
## jpeglib Helper Script
The `jpeglib/install-jpeglib-arm64.sh` script is a standalone version of the jpeglib build process. It's not used by setup.sh (which has the logic inline) but can be useful for manual testing or debugging.
## Structure
```
patches/
<package>/
arm64.patch # Standard unified diff patch file
apply-patch.sh # Script with fallback strategies
jpegio/ # Legacy (v4.1) - not used in v4.2+
arm64.patch
apply-patch.sh
jpeglib/ # Reference script for manual builds
install-jpeglib-arm64.sh
```
## How It Works
## Adding New Patches
The `apply-patch.sh` script tries multiple strategies in order:
1. **Patch file** - Apply the `.patch` file using `patch -p1`
2. **Sed fallback** - Use sed for simple string replacements
3. **Python fallback** - Use regex for flexible pattern matching
This layered approach handles:
- Exact matches (patch file works)
- Minor upstream changes (sed catches variations)
- Significant changes (Python regex is most flexible)
- Already patched files (detected and skipped)
## Adding a New Patch
If a new dependency needs ARM64 patches:
1. Create a directory: `patches/<package>/`
2. Create the patch file: `git diff > arm64.patch`
3. Create `apply-patch.sh` with appropriate fallback logic
4. Update `setup.sh` to call the patch script
## jpegio Patch
The jpegio library includes x86-specific `-m64` compiler flags that fail on ARM64.
The patch removes these flags by replacing:
```python
cargs.append('-m64')
```
with:
```python
pass # ARM64: removed x86-specific -m64 flag
```
## Updating Patches
When upstream changes break a patch:
1. Clone the new version
2. Make the necessary modifications
3. Generate a new patch: `diff -u original modified > arm64.patch`
4. Test on a fresh Pi install
2. Add patch files or helper scripts
3. Update `setup.sh` to apply the patch during installation

View File

@@ -0,0 +1,57 @@
#!/bin/bash
#
# Install jpeglib on ARM64 Linux (Raspberry Pi)
# Works around missing headers in the source tarball
#
# Usage: ./install-jpeglib-arm64.sh
#
set -e
echo "Installing jpeglib for ARM64..."
# Create temp directory
WORKDIR=$(mktemp -d)
cd "$WORKDIR"
# Download jpeglib source
echo " Downloading jpeglib source..."
pip download jpeglib==1.0.2 --no-binary :all: --no-deps -d . -q
tar -xzf jpeglib-1.0.2.tar.gz
cd jpeglib-1.0.2
# Download official libjpeg sources and copy headers
echo " Downloading libjpeg headers..."
CJPEGLIB="src/jpeglib/cjpeglib"
# libjpeg 6b
curl -sL "https://www.ijg.org/files/jpegsrc.v6b.tar.gz" | tar -xzf -
cp jpeg-6b/*.h "$CJPEGLIB/6b/"
# libjpeg 7-9f (all use similar headers from 9e)
curl -sL "https://www.ijg.org/files/jpegsrc.v9f.tar.gz" | tar -xzf -
for v in 7 8 8a 8b 8c 8d 9 9a 9b 9c 9d 9e 9f; do
cp jpeg-9f/*.h "$CJPEGLIB/$v/"
done
# libjpeg-turbo versions
curl -sL "https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/2.1.0.tar.gz" | tar -xzf -
for v in turbo120 turbo130 turbo140 turbo150 turbo200 turbo210; do
cp libjpeg-turbo-2.1.0/*.h "$CJPEGLIB/$v/" 2>/dev/null || true
done
# mozjpeg versions
curl -sL "https://github.com/mozilla/mozjpeg/archive/refs/tags/v4.0.3.tar.gz" | tar -xzf -
for v in mozjpeg101 mozjpeg201 mozjpeg300 mozjpeg403; do
cp mozjpeg-4.0.3/*.h "$CJPEGLIB/$v/" 2>/dev/null || true
done
# Build and install
echo " Building jpeglib..."
pip install . -q
# Cleanup
cd /
rm -rf "$WORKDIR"
echo " Done! jpeglib installed successfully."

View File

@@ -3,7 +3,7 @@
# Resizes rootfs to 16GB for consistent image size, then pulls
#
# Usage: ./pull-image.sh <device> <output.img.zst>
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.1.5.img.zst
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.2.1.img.zst
set -e
@@ -15,7 +15,7 @@ NC='\033[0m'
if [ $# -ne 2 ]; then
echo "Usage: $0 <device> <output.img.zst>"
echo "Example: $0 /dev/sdb stegasoo-rpi-4.1.5.img.zst"
echo "Example: $0 /dev/sdb stegasoo-rpi-4.2.1.img.zst"
exit 1
fi
@@ -123,25 +123,6 @@ else
echo -e "${GREEN} Rootfs already ~16GB${NC}"
fi
# ============================================================================
# Disable auto-expand on first boot
# ============================================================================
echo
echo -e "${YELLOW}Disabling auto-expand...${NC}"
TEMP_ROOT=$(mktemp -d)
mount "$ROOT_PART" "$TEMP_ROOT"
# Remove resize2fs_once service if it exists
rm -f "$TEMP_ROOT/etc/init.d/resize2fs_once"
rm -f "$TEMP_ROOT/etc/rc3.d/S01resize2fs_once"
# Disable the systemd resize service
rm -f "$TEMP_ROOT/etc/systemd/system/multi-user.target.wants/rpi-resizerootfs.service"
umount "$TEMP_ROOT"
rmdir "$TEMP_ROOT"
echo -e "${GREEN} Auto-expand disabled${NC}"
# ============================================================================
# Pull image
# ============================================================================

View File

@@ -1,19 +1,19 @@
#!/bin/bash
#
# Stegasoo Pi Test Kickoff Script
# Automates: flash -> wait for boot -> setup -> test
# Stegasoo Remote Pi Build Script
# Waits for Pi to be reachable, then sets up Stegasoo
#
# Usage: ./kickoff-pi-test.sh <image.img.zst> </dev/sdX>
# Usage: ./remote-build-pi.sh [host] [user] [pass]
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Pi connection settings
PI_HOST="stegasoo.local"
PI_USER="admin"
PI_PASS="stegasoo"
# Pi connection settings (defaults)
PI_HOST="${1:-stegasoo.local}"
PI_USER="${2:-admin}"
PI_PASS="${3:-stegasoo}"
# Colors
RED='\033[0;31m'
@@ -26,10 +26,9 @@ NC='\033[0m'
# Helper functions
# -----------------------------------------------------------------------------
# Wait for Pi to be reachable
wait_for_pi() {
local attempt=1
ssh-keygen -R "$PI_HOST" 2>/dev/null
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
@@ -39,29 +38,25 @@ wait_for_pi() {
done
printf "\r${GREEN}✓ Ready after %d attempts${NC}\n" "$attempt"
printf '\a' # Terminal bell
printf '\a'
}
# Run command on Pi (non-interactive)
run_on_pi() {
sshpass -p "$PI_PASS" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
}
# Run command on Pi (interactive/PTY)
run_on_pi_interactive() {
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
}
# Copy file to Pi
scp_to_pi() {
local src="$1"
local dst="$2"
sshpass -p "$PI_PASS" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$src" "$PI_USER@$PI_HOST:$dst"
}
# Interactive SSH session
ssh_pi() {
ssh-keygen -R "$PI_HOST" 2>/dev/null
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" "$@"
}
@@ -69,92 +64,48 @@ ssh_pi() {
# Main
# -----------------------------------------------------------------------------
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <image.img.zst> </dev/sdX>"
echo ""
echo "Example: $0 stegasoo-v4.1.img.zst /dev/sda"
exit 1
fi
IMAGE="$1"
DEVICE="$2"
if [[ ! -f "$IMAGE" ]]; then
echo -e "${RED}Error: Image file not found: $IMAGE${NC}"
exit 1
fi
if [[ ! -b "$DEVICE" ]]; then
echo -e "${RED}Error: Device not found: $DEVICE${NC}"
exit 1
fi
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ Stegasoo Pi Test Kickoff${NC}"
echo -e "${CYAN}║ Stegasoo Remote Pi Build${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "Image: ${YELLOW}$IMAGE${NC}"
echo -e "Device: ${YELLOW}$DEVICE${NC}"
echo -e "Host: ${YELLOW}$PI_HOST${NC}"
echo -e "User: ${YELLOW}$PI_USER${NC}"
echo ""
# -----------------------------------------------------------------------------
# Step 1: Flash the image
# Step 1: Wait for Pi to be ready
# -----------------------------------------------------------------------------
echo -e "${GREEN}[1/8]${NC} Flashing image..."
echo ""
# Auto-answer: "yes" for confirm, "y" for wipe, "y" for resize
printf 'yes\ny\ny\n' | "$SCRIPT_DIR/flash-stock-img.sh" "$IMAGE" "$DEVICE"
echo ""
echo -e "${GREEN}[2/8]${NC} Flash complete! Waiting for SD card insertion..."
echo ""
# -----------------------------------------------------------------------------
# Step 2: Wait for user to insert SD card
# -----------------------------------------------------------------------------
echo -e "${YELLOW}════════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Insert SD card into Pi and power on${NC}"
echo -e "${YELLOW}════════════════════════════════════════════════════════════════${NC}"
echo ""
read -p "Press ENTER when Pi is booting..."
echo ""
# -----------------------------------------------------------------------------
# Step 3: Wait for Pi to be ready
# -----------------------------------------------------------------------------
echo -e "${GREEN}[3/8]${NC} Waiting for Pi to boot..."
echo -e "${GREEN}[1/6]${NC} Waiting for Pi..."
echo ""
wait_for_pi
# -----------------------------------------------------------------------------
# Step 4: Pre-setup (install dependencies)
# Step 2: Install dependencies
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[4/8]${NC} Installing dependencies on Pi..."
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"
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 5: Clone repo
# Step 3: Clone repo
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[5/8]${NC} Cloning Stegasoo repo..."
echo -e "${GREEN}[3/6]${NC} Cloning Stegasoo repo..."
echo ""
run_on_pi "cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo"
run_on_pi "cd /opt && rm -rf stegasoo && git clone https://github.com/adlee-was-taken/stegasoo.git stegasoo"
# -----------------------------------------------------------------------------
# Step 6: Copy pre-built tarball
# Step 4: Copy pre-built tarball
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[6/8]${NC} Copying pre-built tarball to Pi..."
echo -e "${GREEN}[4/6]${NC} Copying pre-built tarball to Pi..."
echo ""
TARBALL="$SCRIPT_DIR/stegasoo-rpi-runtime-env-arm64.tar.zst"
TARBALL="$SCRIPT_DIR/stegasoo-rpi-venv-arm64.tar.zst"
if [[ -f "$TARBALL" ]]; then
scp_to_pi "$TARBALL" "/opt/stegasoo/rpi/"
echo -e " ${GREEN}${NC} Tarball copied"
@@ -164,19 +115,19 @@ else
fi
# -----------------------------------------------------------------------------
# Step 7: Run setup
# Step 5: Run setup
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[7/8]${NC} Running setup.sh on Pi..."
echo -e "${GREEN}[5/6]${NC} Running setup.sh on Pi..."
echo ""
run_on_pi_interactive "cd /opt/stegasoo && ./rpi/setup.sh"
# -----------------------------------------------------------------------------
# Step 8: Test it works
# Step 6: Test it works
# -----------------------------------------------------------------------------
echo ""
echo -e "${GREEN}[8/8]${NC} Testing Stegasoo..."
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"
@@ -186,7 +137,7 @@ echo -e "${GREEN}═════════════════════
echo -e "${GREEN} Build complete! Pi is ready for testing.${NC}"
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "Access: ${YELLOW}https://stegasoo.local:5000${NC}"
echo -e "Access: ${YELLOW}https://$PI_HOST:5000${NC}"
echo ""
read -p "Press ENTER to SSH into Pi for manual testing..."

View File

@@ -264,49 +264,25 @@ if [ -n "$STEGASOO_DIR" ] && [ -d "$STEGASOO_DIR/venv" ]; then
echo " Venv broken or stegasoo not installed, rebuilding..."
rm -rf "$STEGASOO_DIR/venv"
# Find Python 3.12 (prefer pyenv, fall back to system)
USER_HOME=$(eval echo "~$STEGASOO_USER")
PYENV_PYTHON="$USER_HOME/.pyenv/versions/3.12*/bin/python"
if compgen -G "$PYENV_PYTHON" > /dev/null 2>&1; then
PYTHON_BIN=$(ls $PYENV_PYTHON 2>/dev/null | head -1)
echo " Using pyenv Python: $PYTHON_BIN"
elif command -v python3.12 &>/dev/null; then
PYTHON_BIN="python3.12"
echo " Using system Python 3.12"
else
PYTHON_BIN="python3"
echo " Warning: Python 3.12 not found, using $($PYTHON_BIN --version)"
fi
sudo -u "$STEGASOO_USER" "$PYTHON_BIN" -m venv "$STEGASOO_DIR/venv"
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet --upgrade pip setuptools wheel
# On ARM64, jpegio needs patching before install
ARCH=$(uname -m)
if [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then
echo " Building jpegio for ARM64 (this may take a minute)..."
# Install build deps
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet cython numpy
JPEGIO_DIR="/tmp/jpegio-build-$$"
rm -rf "$JPEGIO_DIR"
if git clone https://github.com/dwgoon/jpegio.git "$JPEGIO_DIR" 2>/dev/null; then
# Apply patch to remove -m64 flag
if [ -f "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
bash "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
else
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
fi
# Change ownership so user can build
chown -R "$STEGASOO_USER:$STEGASOO_USER" "$JPEGIO_DIR"
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install "$JPEGIO_DIR"
rm -rf "$JPEGIO_DIR"
else
echo " Warning: Failed to clone jpegio, DCT mode may not work"
# Find system Python 3.11+ (no pyenv needed)
PYTHON_BIN=""
for py in python3.14 python3.13 python3.12 python3.11 python3; do
if command -v "$py" &>/dev/null; then
PYTHON_BIN=$(command -v "$py")
break
fi
fi
done
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]"
echo " Venv rebuilt and stegasoo installed"
if [ -z "$PYTHON_BIN" ]; then
echo " Error: Python 3.11+ not found"
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
else
echo " Using: $PYTHON_BIN ($($PYTHON_BIN --version 2>&1))"
sudo -u "$STEGASOO_USER" "$PYTHON_BIN" -m venv "$STEGASOO_DIR/venv"
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet --upgrade pip setuptools wheel
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]"
echo " Venv rebuilt and stegasoo installed"
fi
else
echo " Venv OK"
fi

View File

@@ -4,14 +4,14 @@
# Tested on: Raspberry Pi 4/5 with Raspberry Pi OS (64-bit)
#
# Usage:
# curl -sSL https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.1/rpi/setup.sh | bash
# curl -sSL https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.2/rpi/setup.sh | bash
# # or
# wget -qO- https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.1/rpi/setup.sh | bash
# wget -qO- https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.2/rpi/setup.sh | bash
#
# What this script does:
# 1. Installs system dependencies
# 2. Installs Python 3.12 via pyenv (Pi OS ships with 3.13 which is incompatible)
# 3. Patches and builds jpegio for ARM
# 2. Verifies Python 3.11+ (uses system Python)
# 3. Installs jpeglib for DCT steganography (Python 3.11-3.14 compatible)
# 4. Installs Stegasoo with web UI
# 5. Creates systemd service for auto-start
# 6. Enables the service
@@ -75,9 +75,8 @@ show_help() {
echo ""
echo " Available variables:"
echo " INSTALL_DIR Install location (default: /opt/stegasoo)"
echo " PYTHON_VERSION Python version (default: 3.12)"
echo " STEGASOO_REPO Git repo URL"
echo " STEGASOO_BRANCH Git branch (default: 4.1)"
echo " STEGASOO_BRANCH Git branch (default: 4.2)"
echo ""
echo " Example:"
echo " export INSTALL_DIR=\"/home/pi/stegasoo\""
@@ -95,10 +94,8 @@ done
# Default configuration
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
STEGASOO_REPO="${STEGASOO_REPO:-https://github.com/adlee-was-taken/stegasoo.git}"
STEGASOO_BRANCH="${STEGASOO_BRANCH:-4.1}"
JPEGIO_REPO="https://github.com/dwgoon/jpegio.git"
STEGASOO_BRANCH="${STEGASOO_BRANCH:-4.2}"
# Load config files (system, then user - user overrides system)
for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf"; do
@@ -112,7 +109,7 @@ clear
print_banner "Raspberry Pi Setup"
echo ""
echo " This will install Stegasoo with full DCT support"
echo " Estimated time: ~2 minutes (pre-built) or 15-20 min (from source)"
echo " Estimated time: ~2 minutes (pre-built) or 5-10 min (from source)"
echo ""
# Check if running on ARM
@@ -123,6 +120,63 @@ if [[ "$ARCH" != "aarch64" && "$ARCH" != "arm64" ]]; then
exit 1
fi
# =============================================================================
# Python Version Check
# =============================================================================
echo -e "${GREEN}Checking Python version...${NC}"
# Find system Python
SYSTEM_PYTHON=""
for py in python3.14 python3.13 python3.12 python3.11 python3; do
if command -v "$py" &>/dev/null; then
SYSTEM_PYTHON=$(command -v "$py")
break
fi
done
if [ -z "$SYSTEM_PYTHON" ]; then
echo -e "${RED}Error: Python 3 not found.${NC}"
echo "Please install Python 3.11 or later:"
echo " sudo apt-get install python3"
exit 1
fi
# Get version numbers
PY_VERSION=$("$SYSTEM_PYTHON" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PY_MAJOR=$("$SYSTEM_PYTHON" -c 'import sys; print(sys.version_info.major)')
PY_MINOR=$("$SYSTEM_PYTHON" -c 'import sys; print(sys.version_info.minor)')
echo " Found: $SYSTEM_PYTHON (Python $PY_VERSION)"
# Check version range (3.11 <= version <= 3.14)
if [ "$PY_MAJOR" -ne 3 ]; then
echo -e "${RED}Error: Python 3 required, found Python $PY_MAJOR${NC}"
exit 1
fi
if [ "$PY_MINOR" -lt 11 ]; then
echo -e "${RED}Error: Python 3.11+ required, found Python $PY_VERSION${NC}"
echo ""
echo "Raspberry Pi OS Bookworm ships with Python 3.11."
echo "Raspberry Pi OS Trixie ships with Python 3.13."
echo ""
echo "Please upgrade your Raspberry Pi OS or install Python 3.11+."
exit 1
fi
if [ "$PY_MINOR" -gt 14 ]; then
echo -e "${YELLOW}Warning: Python $PY_VERSION detected.${NC}"
echo "Stegasoo is tested with Python 3.11-3.14."
echo "Newer versions may work but are not officially supported."
read -p "Continue anyway? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
echo -e " ${GREEN}${NC} Python $PY_VERSION supported"
# Check available memory
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
if [ "$TOTAL_MEM" -lt 2000 ]; then
@@ -136,8 +190,11 @@ if [ "$TOTAL_MEM" -lt 2000 ]; then
fi
fi
# Create /opt/stegasoo with proper permissions
echo -e "${GREEN}[1/12]${NC} Setting up install directory..."
# =============================================================================
# Installation
# =============================================================================
echo -e "${GREEN}[1/9]${NC} Setting up install directory..."
if [ ! -d "$INSTALL_DIR" ]; then
sudo mkdir -p "$INSTALL_DIR"
sudo chown "$USER:$USER" "$INSTALL_DIR"
@@ -148,7 +205,7 @@ else
echo " $INSTALL_DIR exists, updated ownership"
fi
echo -e "${GREEN}[2/12]${NC} Installing system dependencies..."
echo -e "${GREEN}[2/9]${NC} Installing system dependencies..."
sudo apt-get update
sudo apt-get install -y \
build-essential \
@@ -170,9 +227,11 @@ sudo apt-get install -y \
libzbar0 \
libjpeg-dev \
python3-dev \
python3-venv \
python3-pip \
btop
echo -e "${GREEN}[3/12]${NC} Installing gum (TUI toolkit)..."
echo -e "${GREEN}[3/9]${NC} Installing gum (TUI toolkit)..."
# Add Charm repo for gum
if ! command -v gum &>/dev/null; then
sudo mkdir -p /etc/apt/keyrings
@@ -184,7 +243,21 @@ else
echo " gum already installed"
fi
echo -e "${GREEN}[4/12]${NC} Cloning Stegasoo..."
# 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 " mkcert already installed"
fi
echo -e "${GREEN}[4/9]${NC} Cloning Stegasoo..."
# Clone Stegasoo first (needed to check for pre-built tarball)
if [ -d "$INSTALL_DIR/.git" ]; then
@@ -198,17 +271,16 @@ else
cd "$INSTALL_DIR"
fi
# 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}"
# Pre-built venv tarball (skips pip compile time)
PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-rpi-venv-arm64.tar.zst"
PREBUILT_URL="${PREBUILT_URL:-https://github.com/adlee-was-taken/stegasoo/releases/download/v4.2.1/stegasoo-rpi-venv-arm64.tar.zst}"
USE_PREBUILT=true
# 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}"
echo -e "${GREEN}Found local pre-built venv - fast install mode${NC}"
else
echo -e "${GREEN}Will download pre-built environment - fast install mode${NC}"
echo -e "${GREEN}Will download pre-built venv - fast install mode${NC}"
fi
# Allow --no-prebuilt flag to force from-source build
@@ -217,44 +289,30 @@ if [[ " $* " =~ " --no-prebuilt " ]] || [[ " $* " =~ " --from-source " ]]; then
echo -e "${YELLOW}Building from source (--no-prebuilt specified)${NC}"
fi
# Fast path: use pre-built environment if available
echo -e "${GREEN}[5/9]${NC} Setting up Python environment..."
if [ "$USE_PREBUILT" = true ]; then
echo -e "${GREEN}[5/8]${NC} Installing pre-built Python environment..."
# Fast path: use pre-built venv
# Download if local file doesn't exist
if [ ! -f "$PREBUILT_TARBALL" ]; then
echo " Downloading pre-built environment (~50MB)..."
echo " Downloading pre-built venv (~50MB)..."
curl -L -o "$PREBUILT_TARBALL" "$PREBUILT_URL"
fi
# Extract pre-built environment (includes pyenv Python + venv)
echo " Extracting pre-built environment..."
zstd -d "$PREBUILT_TARBALL" --stdout | tar -xf - -C "$HOME"
# Extract pre-built venv
echo " Extracting pre-built venv..."
zstd -d "$PREBUILT_TARBALL" --stdout | tar -xf - -C "$INSTALL_DIR"
# Setup pyenv in current shell
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
pyenv global $PYTHON_VERSION
# Fix venv Python symlinks to point to system Python
echo " Updating venv to use system Python..."
rm -f "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/venv/bin/python3"
ln -s "$SYSTEM_PYTHON" "$INSTALL_DIR/venv/bin/python"
ln -s "$SYSTEM_PYTHON" "$INSTALL_DIR/venv/bin/python3"
# 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"
# Update pip shebang if needed
if [ -f "$INSTALL_DIR/venv/bin/pip" ]; then
sed -i "1s|^#!.*|#!$INSTALL_DIR/venv/bin/python|" "$INSTALL_DIR/venv/bin/pip" 2>/dev/null || true
fi
# Activate and verify
@@ -263,105 +321,87 @@ if [ "$USE_PREBUILT" = true ]; then
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..."
echo " 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..."
# Build from source
echo -e " ${YELLOW}Building from source (this takes 5-10 minutes)${NC}"
# 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 -)"
# Create venv with system Python
if [ ! -d "$INSTALL_DIR/venv" ]; then
"$SYSTEM_PYTHON" -m venv "$INSTALL_DIR/venv"
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
source "$INSTALL_DIR/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..."
# Upgrade pip and install build tools
pip install --upgrade pip setuptools wheel
# Clone jpegio
JPEGIO_DIR="/tmp/jpegio-build"
rm -rf "$JPEGIO_DIR"
git clone "$JPEGIO_REPO" "$JPEGIO_DIR"
# Install jpeglib (no ARM64 wheel, PyPI tarball missing headers - use GitHub)
echo " Installing jpeglib for ARM64..."
JPEGLIB_WORKDIR=$(mktemp -d)
cd "$JPEGLIB_WORKDIR"
# 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
# Clone from GitHub (PyPI source tarball is missing .h files)
echo " Cloning jpeglib from GitHub..."
git clone --depth 1 --branch 1.0.2 https://github.com/martinbenes1996/jpeglib.git
cd jpeglib
CJPEGLIB="src/jpeglib/cjpeglib"
cd "$JPEGIO_DIR"
# Fix broken include paths in setup.py (uses jpeglib/ but files are in src/jpeglib/)
ln -s src/jpeglib jpeglib
# Build jpegio into venv
pip install --upgrade pip setuptools wheel cython numpy
# Download libjpeg headers (not included in repo either)
# Each version needs EXACT matching headers (APIs differ between versions)
echo " Downloading libjpeg headers (all versions)..."
# Download each version separately (APIs are incompatible between versions)
for v in 6b 7 8 8a 8b 8c 8d 9 9a 9b 9c 9d 9e 9f; do
echo " libjpeg $v..."
curl -sL "https://www.ijg.org/files/jpegsrc.v${v}.tar.gz" | tar -xzf -
cp jpeg-${v}/*.h "$CJPEGLIB/$v/" 2>/dev/null || cp jpeg-${v//.}/*.h "$CJPEGLIB/$v/" 2>/dev/null || true
done
# Skip turbo/mozjpeg - they need cmake-generated headers
# Only remove dict entries (lines 49-59), keep if blocks (they're safe when is_turbo=False)
echo " Patching setup.py to skip turbo/mozjpeg (need cmake)..."
python3 << 'PYPATCH'
with open('setup.py', 'r') as f:
lines = f.readlines()
filtered = []
for line in lines:
# Only skip dict entries like "'turbo120': ..." or "'mozjpeg101': ..."
stripped = line.strip()
if stripped.startswith("'turbo") and ':' in stripped:
continue
if stripped.startswith("'mozjpeg") and ':' in stripped:
continue
if stripped.startswith("# 'turbo"): # commented turbo line
continue
filtered.append(line)
with open('setup.py', 'w') as f:
f.writelines(filtered)
PYPATCH
# Build and install
echo " Building jpeglib (this takes a few minutes)..."
pip install .
cd "$INSTALL_DIR"
rm -rf "$JPEGIO_DIR"
rm -rf "$JPEGLIB_WORKDIR"
echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..."
# Install dependencies (jpegio already in venv, won't re-download)
# Install remaining dependencies
echo " Installing remaining dependencies..."
pip install -e ".[web]"
STEP_OFFSET=0
fi
echo -e "${GREEN}[9/12]${NC} Creating systemd service..."
echo -e " ${GREEN}${NC} Stegasoo installed"
# Create systemd service file
echo -e "${GREEN}[6/9]${NC} Creating systemd services..."
# Create systemd service file for Web UI
sudo tee /etc/systemd/system/stegasoo.service > /dev/null <<EOF
[Unit]
Description=Stegasoo Web UI
@@ -383,12 +423,53 @@ RestartSec=5
WantedBy=multi-user.target
EOF
echo -e "${GREEN}[10/12]${NC} Enabling service..."
# Create systemd service file for REST API (optional)
sudo tee /etc/systemd/system/stegasoo-api.service > /dev/null <<EOF
[Unit]
Description=Stegasoo REST API
After=network.target
[Service]
Type=simple
User=$USER
WorkingDirectory=$INSTALL_DIR/frontends/api
Environment="PATH=$INSTALL_DIR/venv/bin:/usr/bin"
Environment="PYTHONPATH=$INSTALL_DIR/src"
ExecStart=$INSTALL_DIR/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
echo -e "${GREEN}[7/9]${NC} Enabling services..."
sudo systemctl daemon-reload
sudo systemctl enable stegasoo.service
echo -e "${GREEN}[11/12]${NC} Setting up user environment..."
# Prompt for REST API service (optional, with security warning)
echo ""
echo -e "${CYAN}Would you like to enable the REST API service? (port 8000)${NC}"
echo ""
echo -e " ${RED}⚠ WARNING: The REST API has NO AUTHENTICATION${NC}"
echo " Anyone on your network can use it to encode/decode messages."
echo " Only enable if you understand the security implications."
echo ""
echo " The Web UI (port 5000) has authentication and works independently."
echo ""
read -p "Enable REST API (no auth)? [y/N]: " ENABLE_API
if [[ "$ENABLE_API" =~ ^[Yy]$ ]]; then
sudo systemctl enable stegasoo-api.service
STEGASOO_API_ENABLED=true
echo -e " ${YELLOW}${NC} REST API enabled on port 8000 ${RED}(no authentication)${NC}"
else
STEGASOO_API_ENABLED=false
echo -e " ${GREEN}${NC} REST API not enabled (recommended)"
echo " Can enable later with: sudo systemctl enable --now stegasoo-api"
fi
echo -e "${GREEN}[8/9]${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'
@@ -414,7 +495,15 @@ if [ -f "$INSTALL_DIR/rpi/skel/.bashrc" ]; then
fi
fi
echo -e "${GREEN}[12/12]${NC} Setting up login banner..."
# 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}[9/9]${NC} Setting up login banner..."
# Create dynamic MOTD script
sudo tee /etc/profile.d/stegasoo-motd.sh > /dev/null <<'MOTDEOF'
@@ -543,9 +632,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."
@@ -593,19 +688,40 @@ if [ "$ENABLE_HTTPS" = "true" ]; then
LOCAL_IP=$(hostname -I | awk '{print $1}')
PI_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=$PI_HOSTNAME" \
-addext "subjectAltName=DNS:$PI_HOSTNAME,DNS:$PI_HOSTNAME.local,DNS:localhost,IP:$LOCAL_IP,IP:127.0.0.1" \
2>/dev/null
# 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"
echo -e " ${GREEN}${NC} SSL certificates generated"
fi
# Setup port 443 redirect if requested
@@ -678,6 +794,14 @@ echo " Start: sudo systemctl start stegasoo"
echo " Stop: sudo systemctl stop stegasoo"
echo " Status: sudo systemctl status stegasoo"
echo " Logs: journalctl -u stegasoo -f"
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
echo ""
echo -e "${GREEN}REST API Commands:${NC}"
echo " Start: sudo systemctl start stegasoo-api"
echo " Stop: sudo systemctl stop stegasoo-api"
echo " Status: sudo systemctl status stegasoo-api"
echo " Logs: journalctl -u stegasoo-api -f"
fi
echo ""
# Offer to start now
@@ -685,9 +809,12 @@ read -p "Start Stegasoo now? [Y/n] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
sudo systemctl start stegasoo
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
sudo systemctl start stegasoo-api
fi
sleep 2
if systemctl is-active --quiet stegasoo; then
echo -e "${GREEN}✓ Stegasoo is running!${NC}"
echo -e "${GREEN}✓ Stegasoo Web UI is running!${NC}"
if [ "$ENABLE_HTTPS" = "true" ]; then
if [ "$USE_PORT_443" = "true" ]; then
echo -e " Create admin: ${YELLOW}https://$PI_HOST.local/setup${NC} or ${YELLOW}https://$PI_IP/setup${NC}"
@@ -697,6 +824,13 @@ if [[ ! $REPLY =~ ^[Nn]$ ]]; then
else
echo -e " Create admin: ${YELLOW}http://$PI_HOST.local:5000/setup${NC} or ${YELLOW}http://$PI_IP:5000/setup${NC}"
fi
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
if systemctl is-active --quiet stegasoo-api; then
echo -e "${GREEN}✓ Stegasoo REST API is running on port 8000${NC}"
else
echo -e "${YELLOW}⚠ REST API failed to start. Check logs:${NC} journalctl -u stegasoo-api -f"
fi
fi
else
echo -e "${RED}✗ Failed to start. Check logs:${NC} journalctl -u stegasoo -f"
fi

98
scripts/build.sh Executable file
View File

@@ -0,0 +1,98 @@
#!/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}"
;;
rebuild)
echo -e "${YELLOW}Full rebuild from scratch (no cache)...${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
$SUDO docker build --no-cache -f "$DOCKER_DIR/Dockerfile.base" -t stegasoo-base:latest .
$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}"
;;
*)
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 Rebuild services without cache (uses existing base)"
echo " rebuild Complete rebuild with no cache (base + services)"
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"
echo " 4. Nuclear: $0 rebuild"
;;
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 (-k for self-signed certs)
if ! curl -sk "$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 --ignore-certificate-errors \
"$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

View File

@@ -7,7 +7,7 @@ Changes in v4.0.0:
- encode() and decode() now accept channel_key parameter
"""
__version__ = "4.1.3"
__version__ = "4.2.1"
# Core functionality
# Channel key management (v4.0.0)

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

@@ -80,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,
@@ -183,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.
@@ -214,18 +199,6 @@ def encode(
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
@@ -247,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,
}
@@ -259,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
@@ -270,8 +241,20 @@ def encode(
with open(carrier, "rb") as f:
carrier_data = f.read()
# Determine output path
output = output or f"{Path(carrier).stem}_encoded.png"
# Determine output path and format
# Default to JPEG for JPEG carriers (preserves DCT mode benefits)
carrier_ext = Path(carrier).suffix.lower()
if not output:
if carrier_ext in ('.jpg', '.jpeg'):
output = f"{Path(carrier).stem}_encoded.jpg"
else:
output = f"{Path(carrier).stem}_encoded.png"
# Detect output format from extension
output_ext = Path(output).suffix.lower()
use_dct = output_ext in ('.jpg', '.jpeg')
from .steganography import EMBED_MODE_DCT, EMBED_MODE_LSB
try:
if file_payload:
@@ -282,6 +265,8 @@ def encode(
carrier_image=carrier_data,
passphrase=passphrase,
pin=pin,
embed_mode=EMBED_MODE_DCT if use_dct else EMBED_MODE_LSB,
dct_output_format="jpeg" if use_dct else "png",
)
else:
# Encode message
@@ -291,6 +276,8 @@ def encode(
carrier_image=carrier_data,
passphrase=passphrase,
pin=pin,
embed_mode=EMBED_MODE_DCT if use_dct else EMBED_MODE_LSB,
dct_output_format="jpeg" if use_dct else "png",
)
# Write output
@@ -306,7 +293,6 @@ def encode(
"reference": reference,
"output": output,
"payload_type": payload_type,
"compression": algorithm_name(compression_algo),
},
indent=2,
)
@@ -314,7 +300,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"):
@@ -474,13 +459,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")
@@ -494,8 +472,6 @@ def batch_encode(
suffix,
passphrase,
pin,
compress,
algorithm,
recursive,
jobs,
verbose,
@@ -530,7 +506,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,
)
@@ -821,10 +796,6 @@ def info(ctx, full):
"fingerprint": channel_fingerprint,
"source": channel_source,
} if channel_fingerprint else None,
"compression": {
"available": [algorithm_name(a) for a in get_available_algorithms()],
"lz4_installed": HAS_LZ4,
},
"limits": {
"max_message_bytes": MAX_MESSAGE_SIZE,
"max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE,
@@ -859,7 +830,7 @@ def info(ctx, full):
masked = f"{channel_fingerprint[:4]}••••••••{channel_fingerprint[-4:]}"
click.echo(f" Channel: {masked}")
else:
click.echo(" Channel: \033[33mpublic\033[0m")
click.echo(" Channel: public")
# DCT
dct_status = "\033[32m✓ enabled\033[0m" if has_dct else "\033[31m✗ disabled\033[0m"
@@ -1342,6 +1313,203 @@ def tools_exif(image, clear, set_fields, output, as_json):
raise click.UsageError(str(e))
@tools.command("compress")
@click.argument("image", type=click.Path(exists=True))
@click.option("-q", "--quality", type=int, default=75, help="JPEG quality (1-100, default: 75)")
@click.option("-o", "--output", type=click.Path(), help="Output file (default: <name>_q<quality>.jpg)")
def tools_compress(image, quality, output):
"""Compress a JPEG image.
DCT steganography survives JPEG compression! Use this to reduce file size
while preserving hidden data.
Examples:
stegasoo tools compress photo.jpg -q 60
stegasoo tools compress photo.jpg -q 80 -o smaller.jpg
"""
from PIL import Image
import io
if not 1 <= quality <= 100:
raise click.UsageError("Quality must be between 1 and 100")
with open(image, "rb") as f:
image_data = f.read()
img = Image.open(io.BytesIO(image_data))
# Convert to RGB if needed (JPEG doesn't support alpha)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=quality)
compressed_data = buffer.getvalue()
if not output:
stem = Path(image).stem
output = f"{stem}_q{quality}.jpg"
with open(output, "wb") as f:
f.write(compressed_data)
orig_size = len(image_data)
new_size = len(compressed_data)
reduction = (1 - new_size / orig_size) * 100
click.echo(f"Compressed to: {output}")
click.echo(f" Original: {orig_size:,} bytes")
click.echo(f" Compressed: {new_size:,} bytes ({reduction:.1f}% smaller)")
@tools.command("rotate")
@click.argument("image", type=click.Path(exists=True))
@click.option("-r", "--rotation", type=click.Choice(["90", "180", "270"]), help="Rotation degrees clockwise")
@click.option("--flip-h", is_flag=True, help="Flip horizontally")
@click.option("--flip-v", is_flag=True, help="Flip vertically")
@click.option("-o", "--output", type=click.Path(), help="Output file")
def tools_rotate(image, rotation, flip_h, flip_v, output):
"""Rotate and/or flip an image.
For JPEGs, uses lossless jpegtran rotation which preserves DCT steganography.
For other formats, uses PIL (re-encodes the image).
Examples:
stegasoo tools rotate photo.jpg -r 90
stegasoo tools rotate photo.jpg -r 180 --flip-h -o rotated.jpg
"""
from PIL import Image
import io
import shutil
with open(image, "rb") as f:
image_data = f.read()
# Must have rotation or flip
if not rotation and not flip_h and not flip_v:
raise click.UsageError("Must specify at least one of -r/--rotation, --flip-h, or --flip-v")
img = Image.open(io.BytesIO(image_data))
is_jpeg = img.format == "JPEG"
img.close()
rotation_deg = int(rotation) if rotation else 0
# For JPEGs, use lossless jpegtran
if is_jpeg and shutil.which("jpegtran"):
from .dct_steganography import _jpegtran_rotate
result_data = image_data
# Apply rotation
if rotation_deg in (90, 180, 270):
result_data = _jpegtran_rotate(result_data, rotation_deg)
# Apply flips using jpegtran
if flip_h or flip_v:
import subprocess
import tempfile
import os
for flip_type in (["horizontal"] if flip_h else []) + (["vertical"] if flip_v else []):
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(result_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
subprocess.run(
["jpegtran", "-flip", flip_type, "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30, check=True
)
with open(output_path, "rb") as f:
result_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
ext = "jpg"
click.echo(" (Used lossless jpegtran - DCT stego preserved)")
else:
# Use PIL for non-JPEGs
img = Image.open(io.BytesIO(image_data))
# PIL rotation is counter-clockwise, we want clockwise
if rotation_deg:
pil_rotation = {90: 270, 180: 180, 270: 90}[rotation_deg]
img = img.rotate(pil_rotation, expand=True)
if flip_h:
img = img.transpose(Image.FLIP_LEFT_RIGHT)
if flip_v:
img = img.transpose(Image.FLIP_TOP_BOTTOM)
buffer = io.BytesIO()
img.save(buffer, format="PNG")
result_data = buffer.getvalue()
ext = "png"
if not output:
stem = Path(image).stem
suffix = "rotated" if rotation_deg else "flipped"
output = f"{stem}_{suffix}.{ext}"
with open(output, "wb") as f:
f.write(result_data)
click.echo(f"Saved to: {output}")
@tools.command("convert")
@click.argument("image", type=click.Path(exists=True))
@click.option("-f", "--format", "fmt", type=click.Choice(["png", "jpg", "bmp", "webp"]), required=True, help="Output format")
@click.option("-q", "--quality", type=int, default=95, help="Quality for lossy formats (default: 95)")
@click.option("-o", "--output", type=click.Path(), help="Output file")
def tools_convert(image, fmt, quality, output):
"""Convert image to a different format.
Examples:
stegasoo tools convert photo.png -f jpg
stegasoo tools convert photo.jpg -f png -o lossless.png
"""
from PIL import Image
import io
with open(image, "rb") as f:
image_data = f.read()
img = Image.open(io.BytesIO(image_data))
# Handle format-specific conversions
save_format = {"jpg": "JPEG", "png": "PNG", "bmp": "BMP", "webp": "WEBP"}[fmt]
if save_format == "JPEG" and img.mode in ("RGBA", "P"):
img = img.convert("RGB")
buffer = io.BytesIO()
if save_format in ("JPEG", "WEBP"):
img.save(buffer, format=save_format, quality=quality)
else:
img.save(buffer, format=save_format)
result_data = buffer.getvalue()
if not output:
stem = Path(image).stem
output = f"{stem}.{fmt}"
with open(output, "wb") as f:
f.write(result_data)
click.echo(f"Converted to: {output}")
# =============================================================================
# ADMIN COMMANDS (Web UI administration)
# =============================================================================
@@ -1500,6 +1668,301 @@ def admin_generate_key(show_qr):
click.echo("go to Account > Recovery Key > Regenerate")
# =============================================================================
# API COMMANDS (REST API management)
# =============================================================================
def _setup_frontends_path():
"""Add frontends directory to sys.path for importing API/web modules."""
import sys
# Try multiple possible locations
possible_paths = [
# Development: stegasoo/frontends
Path(__file__).parent.parent.parent / "frontends",
# Installed package: site-packages/frontends
Path(__file__).parent.parent / "frontends",
]
for path in possible_paths:
if path.exists() and str(path) not in sys.path:
sys.path.insert(0, str(path))
return True
return False
@cli.group()
@click.pass_context
def api(ctx):
"""REST API management commands."""
pass
@api.group("keys")
def api_keys():
"""Manage API keys for authentication."""
pass
@api_keys.command("list")
@click.option("--location", type=click.Choice(["user", "project", "all"]), default="all",
help="Config location to list keys from")
def api_keys_list(location):
"""List configured API keys.
Shows key names and creation dates (not actual keys).
Examples:
stegasoo api keys list
stegasoo api keys list --location user
"""
_setup_frontends_path()
try:
from api.auth import list_api_keys, get_api_key_status
except ImportError:
raise click.ClickException("API frontend not available")
status = get_api_key_status()
click.echo(f"\nAPI Key Authentication: {'Enabled' if status['enabled'] else 'Disabled'}")
click.echo(f"Total keys: {status['total_keys']}")
click.echo(f"Environment variable: {'Set' if status['env_configured'] else 'Not set'}")
locations = ["user", "project"] if location == "all" else [location]
for loc in locations:
keys = list_api_keys(loc)
click.echo(f"\n{loc.title()} keys ({len(keys)}):")
if keys:
for k in keys:
click.echo(f" - {k['name']} (created: {k['created'][:10]})")
else:
click.echo(" (none)")
@api_keys.command("create")
@click.argument("name")
@click.option("--location", type=click.Choice(["user", "project"]), default="user",
help="Where to store the key")
def api_keys_create(name, location):
"""Create a new API key.
The key is shown ONCE and cannot be retrieved again.
Save it immediately!
Examples:
stegasoo api keys create laptop
stegasoo api keys create automation --location project
"""
_setup_frontends_path()
try:
from api.auth import add_api_key
except ImportError:
raise click.ClickException("API frontend not available")
try:
key = add_api_key(name, location)
click.echo(f"\nAPI Key created: {name}")
click.echo("" * 60)
click.echo(f" {key}")
click.echo("" * 60)
click.echo("\nSave this key NOW! It cannot be retrieved again.")
click.echo(f"Stored in: {location} config")
except ValueError as e:
raise click.ClickException(str(e))
@api_keys.command("delete")
@click.argument("name")
@click.option("--location", type=click.Choice(["user", "project"]), default="user",
help="Config location")
def api_keys_delete(name, location):
"""Delete an API key by name.
Examples:
stegasoo api keys delete laptop
stegasoo api keys delete automation --location project
"""
_setup_frontends_path()
try:
from api.auth import remove_api_key
except ImportError:
raise click.ClickException("API frontend not available")
if remove_api_key(name, location):
click.echo(f"Deleted API key: {name}")
else:
raise click.ClickException(f"Key '{name}' not found in {location} config")
@api.group("tls")
def api_tls():
"""Manage TLS certificates for HTTPS."""
pass
@api_tls.command("generate")
@click.option("--hostname", default="localhost", help="Server hostname for certificate")
@click.option("--days", default=365, help="Certificate validity in days")
@click.option("--output", "-o", type=click.Path(), help="Output directory (default: ~/.stegasoo/certs)")
def api_tls_generate(hostname, days, output):
"""Generate self-signed TLS certificate.
Creates a certificate valid for:
- The specified hostname
- localhost / 127.0.0.1
- hostname.local (for mDNS)
- All detected local network IPs
Examples:
stegasoo api tls generate
stegasoo api tls generate --hostname myserver --days 730
stegasoo api tls generate -o /etc/stegasoo/certs
"""
_setup_frontends_path()
try:
from web.ssl_utils import generate_self_signed_cert, get_cert_paths
except ImportError:
raise click.ClickException("Web frontend not available (ssl_utils required)")
if output:
base_dir = Path(output)
else:
base_dir = Path.home() / ".stegasoo"
click.echo(f"Generating TLS certificate for: {hostname}")
click.echo(f"Validity: {days} days")
cert_path, key_path = generate_self_signed_cert(base_dir, hostname, days)
click.echo(f"\nCertificate: {cert_path}")
click.echo(f"Private Key: {key_path}")
click.echo("\nTo use with the API:")
click.echo(f" uvicorn main:app --ssl-certfile {cert_path} --ssl-keyfile {key_path}")
@api_tls.command("info")
@click.option("--cert", "-c", type=click.Path(exists=True), help="Certificate file (default: ~/.stegasoo/certs/server.crt)")
def api_tls_info(cert):
"""Show information about a TLS certificate.
Examples:
stegasoo api tls info
stegasoo api tls info --cert /path/to/server.crt
"""
from cryptography import x509
from cryptography.hazmat.primitives import serialization
if not cert:
cert = Path.home() / ".stegasoo" / "certs" / "server.crt"
if not cert.exists():
raise click.ClickException(f"No certificate found at {cert}. Generate one with: stegasoo api tls generate")
cert_data = Path(cert).read_bytes()
certificate = x509.load_pem_x509_certificate(cert_data)
click.echo(f"\nCertificate: {cert}")
click.echo("" * 50)
click.echo(f"Subject: {certificate.subject.rfc4514_string()}")
click.echo(f"Issuer: {certificate.issuer.rfc4514_string()}")
click.echo(f"Serial: {certificate.serial_number}")
click.echo(f"Valid from: {certificate.not_valid_before_utc}")
click.echo(f"Valid until: {certificate.not_valid_after_utc}")
# Check expiry
import datetime
now = datetime.datetime.now(datetime.timezone.utc)
if certificate.not_valid_after_utc < now:
click.echo("\nStatus: EXPIRED")
elif certificate.not_valid_after_utc < now + datetime.timedelta(days=30):
days_left = (certificate.not_valid_after_utc - now).days
click.echo(f"\nStatus: Expires in {days_left} days (consider renewal)")
else:
days_left = (certificate.not_valid_after_utc - now).days
click.echo(f"\nStatus: Valid ({days_left} days remaining)")
# Show SANs
try:
san_ext = certificate.extensions.get_extension_for_class(x509.SubjectAlternativeName)
click.echo("\nSubject Alternative Names:")
for name in san_ext.value:
click.echo(f" - {name.value}")
except x509.ExtensionNotFound:
pass
@api.command("serve")
@click.option("--host", default="127.0.0.1", help="Host to bind to")
@click.option("--port", default=8000, help="Port to bind to")
@click.option("--ssl/--no-ssl", default=True, help="Enable/disable TLS")
@click.option("--cert", type=click.Path(exists=True), help="TLS certificate file")
@click.option("--key", type=click.Path(exists=True), help="TLS private key file")
@click.option("--reload", "do_reload", is_flag=True, help="Enable auto-reload for development")
def api_serve(host, port, ssl, cert, key, do_reload):
"""Start the REST API server.
By default starts with TLS using certificates from ~/.stegasoo/certs/.
If no certificates exist, they are generated automatically.
Examples:
stegasoo api serve
stegasoo api serve --host 0.0.0.0 --port 8443
stegasoo api serve --no-ssl
stegasoo api serve --cert /path/to/cert.pem --key /path/to/key.pem
"""
_setup_frontends_path()
# Determine cert paths
if ssl:
if cert and key:
cert_path, key_path = cert, key
else:
try:
from web.ssl_utils import ensure_certs
base_dir = Path.home() / ".stegasoo"
cert_path, key_path = ensure_certs(base_dir, host if host != "0.0.0.0" else "localhost")
except ImportError:
raise click.ClickException("ssl_utils not available")
click.echo(f"Starting API server with TLS on https://{host}:{port}")
click.echo(f"Certificate: {cert_path}")
else:
cert_path = key_path = None
click.echo(f"Starting API server on http://{host}:{port}")
click.echo("WARNING: TLS disabled - connections are not encrypted!")
# Import and run uvicorn
try:
import uvicorn
except ImportError:
raise click.ClickException("uvicorn not installed. Install with: pip install uvicorn")
uvicorn_kwargs = {
"app": "api.main:app",
"host": host,
"port": port,
"reload": do_reload,
}
if ssl and cert_path and key_path:
uvicorn_kwargs["ssl_certfile"] = str(cert_path)
uvicorn_kwargs["ssl_keyfile"] = str(key_path)
uvicorn.run(**uvicorn_kwargs)
def main():
"""Entry point for CLI."""
cli(obj={})

View File

@@ -17,6 +17,14 @@ try:
except ImportError:
HAS_LZ4 = False
# Optional ZSTD support (best ratio, fast)
try:
import zstandard as zstd
HAS_ZSTD = True
except ImportError:
HAS_ZSTD = False
class CompressionAlgorithm(IntEnum):
"""Supported compression algorithms."""
@@ -24,6 +32,7 @@ class CompressionAlgorithm(IntEnum):
NONE = 0
ZLIB = 1
LZ4 = 2
ZSTD = 3 # v4.2.0: Best ratio, fast compression
# Magic bytes for compressed payloads
@@ -72,6 +81,15 @@ def compress(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm
algorithm = CompressionAlgorithm.ZLIB
else:
compressed = lz4.frame.compress(data)
elif algorithm == CompressionAlgorithm.ZSTD:
if not HAS_ZSTD:
# Fall back to zlib if ZSTD not available
compressed = zlib.compress(data, level=ZLIB_LEVEL)
algorithm = CompressionAlgorithm.ZLIB
else:
cctx = zstd.ZstdCompressor(level=19) # High compression level
compressed = cctx.compress(data)
else:
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
@@ -123,6 +141,15 @@ def decompress(data: bytes) -> bytes:
result = lz4.frame.decompress(compressed_data)
except Exception as e:
raise CompressionError(f"LZ4 decompression failed: {e}")
elif algorithm == CompressionAlgorithm.ZSTD:
if not HAS_ZSTD:
raise CompressionError("ZSTD compression used but zstandard package not installed")
try:
dctx = zstd.ZstdDecompressor()
result = dctx.decompress(compressed_data)
except Exception as e:
raise CompressionError(f"ZSTD decompression failed: {e}")
else:
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
@@ -181,6 +208,9 @@ def estimate_compressed_size(
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
elif algorithm == CompressionAlgorithm.LZ4 and HAS_LZ4:
compressed_sample = lz4.frame.compress(sample)
elif algorithm == CompressionAlgorithm.ZSTD and HAS_ZSTD:
cctx = zstd.ZstdCompressor(level=19)
compressed_sample = cctx.compress(sample)
else:
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
@@ -195,14 +225,24 @@ def get_available_algorithms() -> list[CompressionAlgorithm]:
algorithms = [CompressionAlgorithm.NONE, CompressionAlgorithm.ZLIB]
if HAS_LZ4:
algorithms.append(CompressionAlgorithm.LZ4)
if HAS_ZSTD:
algorithms.append(CompressionAlgorithm.ZSTD)
return algorithms
def get_best_algorithm() -> CompressionAlgorithm:
"""Get the best available compression algorithm (prefer ZSTD > ZLIB > LZ4)."""
if HAS_ZSTD:
return CompressionAlgorithm.ZSTD
return CompressionAlgorithm.ZLIB
def algorithm_name(algo: CompressionAlgorithm) -> str:
"""Get human-readable algorithm name."""
names = {
CompressionAlgorithm.NONE: "None",
CompressionAlgorithm.ZLIB: "Zlib (deflate)",
CompressionAlgorithm.LZ4: "LZ4 (fast)",
CompressionAlgorithm.ZSTD: "Zstd (best)",
}
return names.get(algo, "Unknown")

View File

@@ -1,9 +1,15 @@
"""
Stegasoo Constants and Configuration (v4.0.2 - Web UI Authentication)
Stegasoo Constants and Configuration (v4.2.0 - Performance & Compression)
Central location for all magic numbers, limits, and crypto parameters.
All version numbers, limits, and configuration values should be defined here.
CHANGES in v4.2.0:
- Added zstd compression for QR codes (better ratio than zlib)
- RSA key size capped at 3072 bits (4096 too large for QR codes)
- Progress bar improvements for encode/decode operations
- File auto-expire increased to 10 minutes
CHANGES in v4.0.2:
- Added Web UI authentication with SQLite3 user storage
- Added optional HTTPS with auto-generated self-signed certificates
@@ -19,13 +25,14 @@ BREAKING CHANGES in v3.2.0:
- Renamed day_phrase → passphrase throughout codebase
"""
import importlib.resources
from pathlib import Path
# ============================================================================
# VERSION
# ============================================================================
__version__ = "4.1.5"
__version__ = "4.2.1"
# ============================================================================
# FILE FORMAT
@@ -98,7 +105,7 @@ DEFAULT_PHRASE_WORDS = DEFAULT_PASSPHRASE_WORDS
# RSA configuration
MIN_RSA_BITS = 2048
VALID_RSA_SIZES = (2048, 3072, 4096)
VALID_RSA_SIZES = (2048, 3072) # 4096 removed - too large for QR codes
DEFAULT_RSA_BITS = 2048
MIN_KEY_PASSWORD_LENGTH = 8
@@ -108,8 +115,8 @@ MIN_KEY_PASSWORD_LENGTH = 8
# ============================================================================
# Temporary file storage
TEMP_FILE_EXPIRY = 300 # 5 minutes in seconds
TEMP_FILE_EXPIRY_MINUTES = 5
TEMP_FILE_EXPIRY = 600 # 10 minutes in seconds
TEMP_FILE_EXPIRY_MINUTES = 10
# Thumbnail settings
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnails
@@ -171,15 +178,32 @@ BATCH_OUTPUT_SUFFIX = "_encoded"
def get_data_dir() -> Path:
"""Get the data directory path."""
# Check multiple locations
"""Get the data directory path.
Checks locations in order:
1. Package data (installed via pip/wheel) using importlib.resources
2. Development layout (src/stegasoo -> project root/data)
3. Docker container (/app/data)
4. Current working directory fallbacks
"""
# Try package data first (works when installed via pip)
try:
pkg_data = importlib.resources.files("stegasoo.data")
# Check if the package data directory exists and has our files
if (pkg_data / "bip39-words.txt").is_file():
# Return as Path - importlib.resources.files returns a Traversable
return Path(str(pkg_data))
except (ModuleNotFoundError, TypeError):
pass
# Fallback to file-based locations
# From src/stegasoo/constants.py:
# .parent = src/stegasoo/
# .parent.parent = src/
# .parent.parent.parent = project root (where data/ lives)
candidates = [
Path(__file__).parent / "data", # Installed package (stegasoo/data/)
Path(__file__).parent.parent.parent / "data", # Development: src/stegasoo -> project root
Path(__file__).parent / "data", # Installed package
Path("/app/data"), # Docker
Path.cwd() / "data", # Current directory
Path.cwd().parent / "data", # One level up from cwd
@@ -190,8 +214,8 @@ def get_data_dir() -> Path:
if path.exists():
return path
# Default to first candidate
return candidates[0]
# Default to package data path for clearer error messages
return Path(__file__).parent / "data"
def get_bip39_words() -> list[str]:

View File

@@ -0,0 +1 @@
# Package data directory for stegasoo

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ Why is this cool?
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)
2. JPEG output: We use jpeglib to directly tweak the coefficients (chef's kiss)
v4.1.0 - The "please stop corrupting my data" release:
- Reed-Solomon error correction (can fix up to 16 byte errors per chunk)
@@ -24,7 +24,7 @@ v3.2.0-patch2 - The "scipy why are you like this" release:
- 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)
Requires: scipy (PNG mode), optionally jpeglib (JPEG mode), reedsolo (error correction)
"""
import gc
@@ -35,32 +35,35 @@ from dataclasses import dataclass
from enum import Enum
import numpy as np
from PIL import Image
from PIL import Image, ImageOps
# Check for scipy availability (for PNG/DCT mode)
# Prefer scipy.fft (newer, more stable) over scipy.fftpack
try:
from scipy.fft import dct, idct
from scipy.fft import dct, idct, dctn, idctn
HAS_SCIPY = True
except ImportError:
try:
from scipy.fftpack import dct, idct
from scipy.fftpack import dct, idct, dctn, idctn
HAS_SCIPY = True
except ImportError:
HAS_SCIPY = False
dct = None
idct = None
dctn = None
idctn = None
# Check for jpegio availability (for proper JPEG mode)
# Check for jpeglib availability (for proper JPEG mode)
# jpeglib replaces jpegio for Python 3.13+ compatibility
try:
import jpegio as jio
import jpeglib
HAS_JPEGIO = True
HAS_JPEGIO = True # Keep variable name for compatibility
except ImportError:
HAS_JPEGIO = False
jio = None
jpeglib = None
# Import custom exceptions
from .exceptions import InvalidMagicBytesError
@@ -403,31 +406,72 @@ def _safe_idct2(block: np.ndarray) -> np.ndarray:
# ============================================================================
def _apply_exif_orientation(image_data: bytes) -> bytes:
"""
Apply EXIF orientation to image and return corrected bytes.
Portrait photos from cameras often have EXIF orientation metadata that
tells viewers to rotate the image for display. However, the raw pixel
data is stored in landscape orientation. This function applies that
rotation to the pixel data so the output matches what users expect.
Without this, a portrait photo encoded with DCT would come out rotated
90 degrees because we'd embed in the raw (landscape) orientation.
"""
img = Image.open(io.BytesIO(image_data))
original_format = img.format or "JPEG"
# Apply EXIF orientation (rotates/flips pixels to match EXIF tag)
# This also removes the EXIF orientation tag since it's now baked in
corrected = ImageOps.exif_transpose(img)
# If no change was needed, return original data unchanged
if corrected is img:
img.close()
return image_data
# Save corrected image back to bytes
output = io.BytesIO()
if original_format == "JPEG":
if corrected.mode in ("RGBA", "P"):
corrected = corrected.convert("RGB")
corrected.save(output, format="JPEG", quality=95)
else:
corrected.save(output, format="PNG")
img.close()
corrected.close()
output.seek(0)
return output.getvalue()
def _to_grayscale(image_data: bytes) -> np.ndarray:
img = Image.open(io.BytesIO(image_data))
gray = img.convert("L")
return np.array(gray, dtype=np.float64, copy=True, order="C")
return np.array(gray, dtype=np.float32, copy=True, order="C")
def _extract_y_channel(image_data: bytes) -> np.ndarray:
"""Extract Y (luminance) channel - float32 for memory efficiency."""
img = Image.open(io.BytesIO(image_data))
if img.mode != "RGB":
img = img.convert("RGB")
rgb = np.array(img, dtype=np.float64, copy=True, order="C")
rgb = np.array(img, dtype=np.float32, copy=True, order="C")
Y = 0.299 * rgb[:, :, 0] + 0.587 * rgb[:, :, 1] + 0.114 * rgb[:, :, 2]
return np.array(Y, dtype=np.float64, copy=True, order="C")
return np.array(Y, dtype=np.float32, copy=True, order="C")
def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]:
"""Pad image to block boundaries - uses float32 for memory efficiency."""
h, w = image.shape
new_h = ((h + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
new_w = ((w + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
if new_h == h and new_w == w:
return np.array(image, dtype=np.float64, copy=True, order="C"), (h, w)
return np.array(image, dtype=np.float32, copy=True, order="C"), (h, w)
padded = np.zeros((new_h, new_w), dtype=np.float64, order="C")
padded = np.zeros((new_h, new_w), dtype=np.float32, order="C")
padded[:h, :w] = image
# Simple edge replication for padding
@@ -444,8 +488,9 @@ def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]:
def _unpad_image(image: np.ndarray, original_size: tuple[int, int]) -> np.ndarray:
"""Remove padding - uses float32 for memory efficiency."""
h, w = original_size
return np.array(image[:h, :w], dtype=np.float64, copy=True, order="C")
return np.array(image[:h, :w], dtype=np.float32, copy=True, order="C")
def _embed_bit_in_coeff(coef: float, bit: int, quant_step: int = QUANT_STEP) -> float:
@@ -543,20 +588,23 @@ def _rgb_to_ycbcr(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
- 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.
Uses float32 to reduce memory usage (~50% savings vs float64).
"""
R = rgb[:, :, 0].astype(np.float64)
G = rgb[:, :, 1].astype(np.float64)
B = rgb[:, :, 2].astype(np.float64)
# Use float32 - sufficient precision for 8-bit images, halves memory
R = rgb[:, :, 0].astype(np.float32)
G = rgb[:, :, 1].astype(np.float32)
B = rgb[:, :, 2].astype(np.float32)
# 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")
Y = np.array(0.299 * R + 0.587 * G + 0.114 * B, dtype=np.float32, 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"
128 - 0.168736 * R - 0.331264 * G + 0.5 * B, dtype=np.float32, 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"
128 + 0.5 * R - 0.418688 * G - 0.081312 * B, dtype=np.float32, copy=True, order="C"
)
return Y, Cb, Cr
@@ -569,11 +617,12 @@ def _ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray) -> np.ndarray:
After embedding in the Y channel, we need to reconstruct RGB for display.
The Cb/Cr channels are unchanged - we only touched luminance.
"""
# Use float32 for memory efficiency
R = Y + 1.402 * (Cr - 128)
G = Y - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 128)
B = Y + 1.772 * (Cb - 128)
rgb = np.zeros((Y.shape[0], Y.shape[1], 3), dtype=np.float64, order="C")
rgb = np.zeros((Y.shape[0], Y.shape[1], 3), dtype=np.float32, order="C")
rgb[:, :, 0] = R
rgb[:, :, 1] = G
rgb[:, :, 2] = B
@@ -733,7 +782,7 @@ def estimate_capacity_comparison(image_data: bytes) -> dict:
},
"jpeg_native": {
"available": HAS_JPEGIO,
"note": "Uses jpegio for proper JPEG coefficient embedding",
"note": "Uses jpeglib for proper JPEG coefficient embedding",
},
}
@@ -753,6 +802,10 @@ def embed_in_dct(
if color_mode not in ("color", "grayscale"):
color_mode = "color"
# Apply EXIF orientation to carrier image before embedding
# This ensures portrait photos are embedded in their correct visual orientation
carrier_image = _apply_exif_orientation(carrier_image)
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
@@ -818,8 +871,8 @@ def _embed_scipy_dct_safe(
if img.mode == "RGBA":
img = img.convert("RGB")
# Process color image
rgb = np.array(img, dtype=np.float64, copy=True, order="C")
# Process color image (float32 for memory efficiency)
rgb = np.array(img, dtype=np.float32, copy=True, order="C")
img.close()
Y, Cb, Cr = _rgb_to_ycbcr(rgb)
@@ -891,61 +944,105 @@ def _embed_in_channel_safe(
progress_file: str | None = None,
) -> np.ndarray:
"""
Embed bits in channel using safe DCT operations.
Embed bits in channel using vectorized DCT operations.
Processes one block at a time with fresh array allocations.
Processes blocks in batches for ~10x speedup over sequential processing.
"""
h, w = channel.shape
# Create result with explicit new memory
result = np.array(channel, dtype=np.float64, copy=True, order="C")
# Create result with explicit new memory (float32 for memory efficiency)
result = np.array(channel, dtype=np.float32, copy=True, order="C")
# Pre-compute embed positions as numpy indices
embed_rows = np.array([pos[0] for pos in DEFAULT_EMBED_POSITIONS])
embed_cols = np.array([pos[1] for pos in DEFAULT_EMBED_POSITIONS])
bits_per_block = len(DEFAULT_EMBED_POSITIONS)
# Calculate how many blocks we need
total_bits = len(bits)
blocks_needed = (total_bits + bits_per_block - 1) // bits_per_block
blocks_to_process = min(blocks_needed, len(block_order))
# Initial progress write - signals Argon2/prep is done, embedding starting
if progress_file:
_write_progress(progress_file, 5, 100, "embedding")
# Vectorized embedding: process blocks in batches
BATCH_SIZE = 500
bit_idx = 0
total_blocks = len(block_order)
block_idx = 0
for block_idx, block_num in enumerate(block_order):
if bit_idx >= len(bits):
break
while block_idx < blocks_to_process and bit_idx < total_bits:
# Determine batch size
batch_end = min(block_idx + BATCH_SIZE, blocks_to_process)
batch_order = block_order[block_idx:batch_end]
batch_count = len(batch_order)
by = (block_num // blocks_x) * BLOCK_SIZE
bx = (block_num % blocks_x) * BLOCK_SIZE
# Extract blocks into 3D array (float32 for memory efficiency)
blocks = np.zeros((batch_count, BLOCK_SIZE, BLOCK_SIZE), dtype=np.float32)
block_positions = []
for i, block_num in enumerate(batch_order):
by = (block_num // blocks_x) * BLOCK_SIZE
bx = (block_num % blocks_x) * BLOCK_SIZE
blocks[i] = result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE]
block_positions.append((by, bx))
# Extract block - create brand new array
block = np.array(
result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE],
dtype=np.float64,
copy=True,
order="C",
)
# Vectorized 2D DCT on all blocks at once
dct_blocks = dctn(blocks, axes=(1, 2), norm="ortho")
# Apply safe DCT (row-by-row)
dct_block = _safe_dct2(block)
# Embed bits
for pos in DEFAULT_EMBED_POSITIONS:
if bit_idx >= len(bits):
# Embed bits in each block (vectorized where possible)
for i in range(batch_count):
if bit_idx >= total_bits:
break
dct_block[pos[0], pos[1]] = _embed_bit_in_coeff(
float(dct_block[pos[0], pos[1]]), bits[bit_idx]
)
bit_idx += 1
# Apply safe inverse DCT
modified_block = _safe_idct2(dct_block)
# Get bits for this block
block_bits = bits[bit_idx : bit_idx + bits_per_block]
num_bits = len(block_bits)
# Copy back
result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE] = modified_block
if num_bits == bits_per_block:
# Full block - vectorized embedding
coeffs = dct_blocks[i, embed_rows, embed_cols]
bit_array = np.array(block_bits)
# QIM embedding: round to grid, adjust for bit
quantized = np.round(coeffs / QUANT_STEP).astype(int)
# If quantized % 2 != bit, nudge coefficient
needs_adjust = (quantized % 2) != bit_array
# Determine direction to nudge
dct_blocks[i, embed_rows[needs_adjust], embed_cols[needs_adjust]] = (
(quantized[needs_adjust] + (1 - 2 * (quantized[needs_adjust] % 2 == 1))) * QUANT_STEP
).astype(np.float64)
# For bits that already match, just quantize
dct_blocks[i, embed_rows[~needs_adjust], embed_cols[~needs_adjust]] = (
quantized[~needs_adjust] * QUANT_STEP
).astype(np.float64)
else:
# Partial block - process remaining bits individually
for j, bit in enumerate(block_bits):
row, col = embed_rows[j], embed_cols[j]
dct_blocks[i, row, col] = _embed_bit_in_coeff(
float(dct_blocks[i, row, col]), bit
)
# Clean up this iteration
del block, dct_block, modified_block
bit_idx += num_bits
# Vectorized inverse DCT
modified_blocks = idctn(dct_blocks, axes=(1, 2), norm="ortho")
# Copy modified blocks back to result
for i, (by, bx) in enumerate(block_positions):
result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE] = modified_blocks[i]
# Cleanup
del blocks, dct_blocks, modified_blocks
block_idx = batch_end
# Report progress periodically
if progress_file and block_idx % PROGRESS_INTERVAL == 0:
_write_progress(progress_file, block_idx, total_blocks, "embedding")
_write_progress(progress_file, block_idx, blocks_to_process, "embedding")
# Final progress update
if progress_file:
_write_progress(progress_file, total_blocks, total_blocks, "finalizing")
_write_progress(progress_file, blocks_to_process, blocks_to_process, "finalizing")
# Force garbage collection
gc.collect()
@@ -1029,7 +1126,7 @@ def _embed_jpegio(
flags = FLAG_COLOR_MODE if color_mode == "color" else 0
try:
jpeg = jio.read(input_path)
jpeg = jpeglib.to_jpegio(jpeglib.read_dct(input_path))
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
all_positions = _jpegio_get_usable_positions(coef_array)
@@ -1064,6 +1161,10 @@ def _embed_jpegio(
total_bits = len(bits)
progress_interval = max(total_bits // 20, 100) # Report ~20 times or every 100 bits
# Initial progress write - signals prep is done, embedding starting
if progress_file:
_write_progress(progress_file, 5, 100, "embedding")
for bit_idx, pos_idx in enumerate(order):
if bit_idx >= len(bits):
break
@@ -1087,7 +1188,7 @@ def _embed_jpegio(
if progress_file:
_write_progress(progress_file, total_bits, total_bits, "saving")
jio.write(jpeg, output_path)
jpeg.write(output_path)
with open(output_path, "rb") as f:
stego_bytes = f.read()
@@ -1115,24 +1216,261 @@ def _embed_jpegio(
pass
def extract_from_dct(stego_image: bytes, seed: bytes) -> bytes:
"""Extract data from DCT stego image."""
img = Image.open(io.BytesIO(stego_image))
fmt = img.format
def _jpegtran_available() -> bool:
"""Check if jpegtran is available on the system."""
import shutil
return shutil.which("jpegtran") is not None
def _jpegtran_rotate(image_data: bytes, rotation: int) -> bytes:
"""
Losslessly rotate a JPEG using jpegtran.
This preserves DCT coefficients by rearranging blocks rather than
re-encoding. Essential for rotating stego images without destroying
the hidden data.
Args:
image_data: JPEG image bytes
rotation: Degrees clockwise (90, 180, or 270)
Returns:
Rotated JPEG bytes with DCT coefficients preserved
"""
import subprocess
import tempfile
import os
if rotation not in (90, 180, 270):
raise ValueError(f"Invalid rotation: {rotation}")
# Write input to temp file
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(image_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
# jpegtran -rotate 90|180|270 -copy all
# -copy all: preserve all metadata
# NOTE: Don't use -trim as it drops edge blocks and destroys stego data
# NOTE: Don't use -perfect as it fails on images with non-MCU-aligned edges
result = subprocess.run(
["jpegtran", "-rotate", str(rotation), "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True,
timeout=30
)
if result.returncode != 0:
raise RuntimeError(f"jpegtran failed: {result.stderr.decode()}")
with open(output_path, "rb") as f:
return f.read()
finally:
for path in [input_path, output_path]:
try:
os.unlink(path)
except OSError:
pass
def _rotate_image_bytes(image_data: bytes, rotation: int, lossless: bool = True) -> bytes:
"""
Rotate image by 90, 180, or 270 degrees and return as bytes.
For JPEGs with lossless=True (default), uses jpegtran to preserve DCT
coefficients. This is essential for rotating stego images.
For PNGs or when jpegtran is unavailable, uses PIL (which re-encodes
but PNGs are lossless anyway).
"""
img = Image.open(io.BytesIO(image_data))
original_format = img.format or "PNG"
img.close()
if fmt == "JPEG" and HAS_JPEGIO:
# Use jpegtran for lossless JPEG rotation
if lossless and original_format == "JPEG" and _jpegtran_available():
return _jpegtran_rotate(image_data, rotation)
# Fallback to PIL for PNGs or when jpegtran unavailable
img = Image.open(io.BytesIO(image_data))
# PIL rotation is counter-clockwise, we want clockwise
# 90 CW = 270 CCW, 180 = 180, 270 CW = 90 CCW
pil_rotation = {90: 270, 180: 180, 270: 90}[rotation]
rotated = img.rotate(pil_rotation, expand=True)
output = io.BytesIO()
# Save in original format if possible, fallback to PNG
save_format = original_format if original_format in ("JPEG", "PNG") else "PNG"
if save_format == "JPEG":
rotated.save(output, format="JPEG", quality=95)
else:
rotated.save(output, format="PNG")
output.seek(0)
return output.getvalue()
def _quick_validate_dct_header(image_data: bytes, seed: bytes) -> bool:
"""
Quick validation that only extracts enough DCT data to check magic bytes.
Returns True if header looks valid, False otherwise.
This is much faster than full extraction - only processes first ~8 blocks.
"""
try:
# Convert to grayscale for quick check
gray = _to_grayscale(image_data)
height, width = gray.shape
padded, _ = _pad_to_blocks(gray)
padded_h, padded_w = padded.shape
blocks_x = padded_w // BLOCK_SIZE
num_blocks = (padded_h // BLOCK_SIZE) * blocks_x
# Generate block order
block_order = _generate_block_order(num_blocks, seed)
# Only extract first 8 blocks (enough for RS length prefix + header)
# 8 blocks * 16 bits/block = 128 bits = 16 bytes (covers RS prefix)
blocks_needed = min(8, len(block_order))
all_bits = []
for block_num in block_order[:blocks_needed]:
by = (block_num // blocks_x) * BLOCK_SIZE
bx = (block_num % blocks_x) * BLOCK_SIZE
block = padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE].astype(np.float32)
dct_block = dctn(block, norm="ortho")
for row, col in EMBED_POSITIONS:
coef = dct_block[row, col]
bit = _extract_bit_from_coeff(coef)
all_bits.append(bit)
# Check RS format first (3 copies of 8-byte length header)
if len(all_bits) >= RS_LENGTH_PREFIX_SIZE * 8:
length_prefix_bits = all_bits[: RS_LENGTH_PREFIX_SIZE * 8]
length_prefix_bytes = bytes(
[
sum(length_prefix_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
for i in range(RS_LENGTH_PREFIX_SIZE)
]
)
# Check if 2+ copies match (indicates valid RS format)
copies = []
for i in range(RS_LENGTH_COPIES):
start = i * RS_LENGTH_HEADER_SIZE
end = start + RS_LENGTH_HEADER_SIZE
copies.append(length_prefix_bytes[start:end])
from collections import Counter
counter = Counter(copies)
_, count = counter.most_common(1)[0]
if count >= 2:
return True # Looks like valid RS format
# Check legacy format (magic bytes in first 10 bytes)
if len(all_bits) >= HEADER_SIZE * 8:
try:
_parse_header(all_bits[: HEADER_SIZE * 8])
return True # Magic bytes matched
except (ValueError, InvalidMagicBytesError):
pass
return False
except Exception:
return False
def extract_from_dct(
stego_image: bytes,
seed: bytes,
progress_file: str | None = None,
) -> bytes:
"""
Extract data from DCT stego image.
If extraction fails with InvalidMagicBytesError, automatically tries
90°, 180°, and 270° rotations to handle images that were rotated after
encoding (e.g., by external tools or EXIF orientation changes).
Uses quick header validation to skip obviously invalid rotations.
"""
rotations_to_try = [0, 90, 180, 270]
last_error = None
valid_rotations = []
# Phase 1: Quick validation to find candidate rotations
for rotation in rotations_to_try:
if rotation == 0:
image_to_check = stego_image
else:
image_to_check = _rotate_image_bytes(stego_image, rotation)
if _quick_validate_dct_header(image_to_check, seed):
valid_rotations.append((rotation, image_to_check))
# If no rotations pass quick check, try all anyway (fallback)
if not valid_rotations:
# Must try all rotations - quick validation might have failed due to
# scipy vs jpegio differences or other edge cases
for rotation in rotations_to_try:
if rotation == 0:
valid_rotations.append((0, stego_image))
else:
valid_rotations.append((rotation, _rotate_image_bytes(stego_image, rotation)))
# Phase 2: Full extraction on valid candidates
for rotation, image_to_decode in valid_rotations:
try:
return _extract_jpegio(stego_image, seed)
except ValueError:
pass
img = Image.open(io.BytesIO(image_to_decode))
fmt = img.format
img.close()
_check_scipy()
return _extract_scipy_dct_safe(stego_image, seed)
if fmt == "JPEG" and HAS_JPEGIO:
try:
result = _extract_jpegio(image_to_decode, seed, progress_file)
if rotation != 0:
try:
from . import debug
debug.print(f"DCT decode succeeded after {rotation}° rotation")
except Exception:
pass # Don't let debug logging break extraction
return result
except (ValueError, InvalidMagicBytesError) as e:
last_error = e if isinstance(e, InvalidMagicBytesError) else last_error
continue
_check_scipy()
result = _extract_scipy_dct_safe(image_to_decode, seed, progress_file)
if rotation != 0:
try:
from . import debug
debug.print(f"DCT decode succeeded after {rotation}° rotation")
except Exception:
pass # Don't let debug logging break extraction
return result
except InvalidMagicBytesError as e:
last_error = e
continue
# All rotations failed
raise last_error or InvalidMagicBytesError("Not a Stegasoo image (tried all rotations)")
def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
"""Extract using safe DCT operations."""
def _extract_scipy_dct_safe(
stego_image: bytes,
seed: bytes,
progress_file: str | None = None,
) -> bytes:
"""Extract using safe DCT operations with vectorized processing."""
# Progress starts at 25% (decode.py writes 20% for Argon2, 25% before extraction)
img = Image.open(io.BytesIO(stego_image))
width, height = img.size
mode = img.mode
@@ -1156,26 +1494,54 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
block_order = _generate_block_order(num_blocks, seed)
# Vectorized extraction: process blocks in batches for ~10x speedup
# Batch size balances memory usage vs. parallelization benefit
BATCH_SIZE = 500
all_bits = []
for block_num in block_order:
by = (block_num // blocks_x) * BLOCK_SIZE
bx = (block_num % blocks_x) * BLOCK_SIZE
# Pre-compute embed positions as numpy indices for vectorized access
embed_rows = np.array([pos[0] for pos in DEFAULT_EMBED_POSITIONS])
embed_cols = np.array([pos[1] for pos in DEFAULT_EMBED_POSITIONS])
block = np.array(
padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE],
dtype=np.float64,
copy=True,
order="C",
)
dct_block = _safe_dct2(block)
# Progress reporting interval - report frequently for responsive UI
PROGRESS_INTERVAL = 500 # Report every N blocks (matches BATCH_SIZE)
for pos in DEFAULT_EMBED_POSITIONS:
bit = _extract_bit_from_coeff(float(dct_block[pos[0], pos[1]]))
all_bits.append(bit)
block_idx = 0
while block_idx < len(block_order):
# Determine batch size (may be smaller at end)
batch_end = min(block_idx + BATCH_SIZE, len(block_order))
batch_order = block_order[block_idx:batch_end]
batch_count = len(batch_order)
del block, dct_block
# Extract blocks into 3D array (batch_count, 8, 8) - float32 for memory efficiency
blocks = np.zeros((batch_count, BLOCK_SIZE, BLOCK_SIZE), dtype=np.float32)
for i, block_num in enumerate(batch_order):
by = (block_num // blocks_x) * BLOCK_SIZE
bx = (block_num % blocks_x) * BLOCK_SIZE
blocks[i] = padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE]
# Vectorized 2D DCT on all blocks at once (~10-15x faster than sequential)
dct_blocks = dctn(blocks, axes=(1, 2), norm="ortho")
# Extract bits from embed positions (vectorized)
# Shape: (batch_count, num_positions)
coeffs = dct_blocks[:, embed_rows, embed_cols]
# Quantize and extract bits (vectorized)
quantized = np.round(coeffs / QUANT_STEP).astype(int)
bits = (quantized % 2).flatten().tolist()
all_bits.extend(bits)
del blocks, dct_blocks, coeffs, quantized
block_idx = batch_end
# Report progress (scale to 25-70% range, RS decode gets 70-100%)
# Starts at 25% because decode.py writes 25% before calling extraction
if progress_file and block_idx % PROGRESS_INTERVAL < BATCH_SIZE:
extract_pct = 25 + int(45 * block_idx / num_blocks)
_write_progress(progress_file, extract_pct, 100, "extracting")
# Check if we have enough bits (early exit)
if len(all_bits) >= HEADER_SIZE * 8:
try:
_, flags, data_length = _parse_header(all_bits[: HEADER_SIZE * 8])
@@ -1188,6 +1554,9 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
del padded
gc.collect()
# Extraction done, RS decode starts at 70%
_write_progress(progress_file, 70, 100, "decoding")
# Try RS-protected format first (has 24-byte length prefix: 3 copies of 8-byte header)
if HAS_REEDSOLO and len(all_bits) >= RS_LENGTH_PREFIX_SIZE * 8:
# Extract length prefix (24 bytes: 3 copies of 8-byte header for majority voting)
@@ -1240,10 +1609,16 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
]
)
# 75% - bits converted, starting RS decode (slow part)
_write_progress(progress_file, 75, 100, "decoding")
try:
# RS decode to get header + data
raw_payload = _rs_decode(rs_encoded)
# 95% - RS decode done
_write_progress(progress_file, 95, 100, "decoding")
# Parse header from decoded payload
_, flags, data_length = _parse_header(
[((raw_payload[i // 8] >> (7 - i % 8)) & 1) for i in range(HEADER_SIZE * 8)]
@@ -1251,6 +1626,7 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
# Extract data
data = raw_payload[HEADER_SIZE : HEADER_SIZE + data_length]
_write_progress(progress_file, 100, 100, "complete")
return data
except (ValueError, struct.error):
pass # Fall through to legacy format
@@ -1266,13 +1642,20 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
]
)
_write_progress(progress_file, 100, 100, "complete")
return data
def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
def _extract_jpegio(
stego_image: bytes,
seed: bytes,
progress_file: str | None = None,
) -> bytes:
"""Extract using jpegio for JPEG images."""
import os
# Progress starts at 25% (decode.py writes 20% for Argon2, 25% before extraction)
# Normalize JPEG to avoid crashes with quality=100 images
# (shouldn't happen with stego images, but be defensive)
stego_image = _normalize_jpeg_for_jpegio(stego_image)
@@ -1280,12 +1663,14 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
temp_path = _jpegio_bytes_to_file(stego_image, suffix=".jpg")
try:
jpeg = jio.read(temp_path)
jpeg = jpeglib.to_jpegio(jpeglib.read_dct(temp_path))
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
all_positions = _jpegio_get_usable_positions(coef_array)
order = _jpegio_generate_order(len(all_positions), seed)
_write_progress(progress_file, 30, 100, "extracting")
# Try RS-protected format first (has 24-byte length prefix: 3 copies for majority voting)
if HAS_REEDSOLO and len(all_positions) >= RS_LENGTH_PREFIX_SIZE * 8:
# Extract length prefix (24 bytes: 3 copies of 8-byte header)
@@ -1349,9 +1734,12 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
)
try:
_write_progress(progress_file, 75, 100, "decoding")
raw_payload = _rs_decode(rs_encoded)
_write_progress(progress_file, 95, 100, "decoding")
_, flags, data_length = _jpegio_parse_header(raw_payload[:HEADER_SIZE])
data = raw_payload[HEADER_SIZE : HEADER_SIZE + data_length]
_write_progress(progress_file, 100, 100, "complete")
return data
except (ValueError, struct.error):
pass # Fall through to legacy format
@@ -1389,6 +1777,7 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
]
)
_write_progress(progress_file, 100, 100, "complete")
return data
finally:

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