174 Commits
v4.1.6 ... main

Author SHA1 Message Date
adlee-was-taken
c970261e53 Merge branch 'main' of github.com:adlee-was-taken/stegasoo
Some checks failed
Lint / lint (push) Failing after 1m42s
Lint / typecheck (push) Failing after 30s
Tests / test (3.11) (push) Failing after 1m13s
Tests / test (3.12) (push) Failing after 42s
Tests / test (3.13) (push) Failing after 41s
2026-04-04 16:29:34 -04:00
adlee-was-taken
4607ff27dd Minor fixes 2026-04-04 16:29:20 -04:00
Aaron D. Lee
70b941d55a Pre-consolidation snapshot: backends, steganalysis, platform presets, and WIP changes
Snapshot of all uncommitted work before merging stegasoo into soosef monorepo.
Includes: pluggable backends registry, steganalysis detection, platform presets,
and various in-progress modifications across core modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:56:36 -04:00
Aaron D. Lee
14fce4d3ed More video work, planning, etc. -- Need to mark things EXPERIMENTAL. 2026-03-24 16:00:30 -04:00
adlee-was-taken
05382c4081 Merge carrier type toggle into Step 1 accordion, reduce to 3 steps
Remove the dedicated Carrier Type accordion step and merge the
Image/Audio toggle into the Carrier & Mode step. The toggle now sits
in a two-column row aligned with the embedding mode buttons. Steps
renumbered from 4 to 3, carrier label changed to "Carrier File",
mode hint updates on carrier type switch via dispatched change events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:27:02 -05:00
adlee-was-taken
ef5a9ce9cb Add per-channel hybrid audio spread spectrum and env feature toggles
Spread spectrum v2: independent per-channel embedding with round-robin
bit distribution, preserving spatial stereo/surround mix. Adaptive chip
tiers (256/512/1024) trade capacity for lossy codec robustness. LFE
channel skipped for 5.1+ layouts. v2 header (20B) with backward-
compatible v0 decode fallback.

Environment toggles (STEGASOO_AUDIO, STEGASOO_VIDEO) gate audio/video
features for minimal builds (e.g. Raspberry Pi image-only). Values:
auto (default, detect deps), 1/true (force on), 0/false (force off).

Web UI fixes: accordion defaults to step 1 on load, chevron arrow
styling, required attribute toggling for audio carrier type switch,
"Images & Mode" renamed to "Reference, Carrier, Mode".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:58:40 -05:00
adlee-was-taken
0248bec813 Add audio steganography with LSB and spread spectrum modes
Implement two audio embedding modes following the same multi-factor
authentication pipeline as image steganography (passphrase + PIN +
optional RSA key + optional channel key):

- audio_lsb: High-capacity LSB embedding in PCM samples for lossless
  formats (WAV/FLAC). Uses ChaCha20-keyed sample index selection.
- audio_spread: Direct-sequence spread spectrum (DSSS) with ChaCha20-
  keyed bipolar chip codes, Reed-Solomon error correction, and 3-copy
  majority-voted length headers. Designed to survive lossy compression.

New files:
- audio_steganography.py: LSB embed/extract on PCM samples
- spread_steganography.py: Spread spectrum embed/extract
- audio_utils.py: Format detection, transcoding, validation helpers
- tests/test_audio.py: 22 tests covering both modes end-to-end

Updated encode.py, decode.py, cli.py (audio-encode/audio-decode
commands), constants.py, models.py, exceptions.py, validation.py,
__init__.py, and pyproject.toml ([audio] extra).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 20:26:07 -05:00
adlee-was-taken
7aeb26e003 Add CLAUDE.md project guide and worktree documentation
CLAUDE.md gives Claude Code instant context on architecture, commands,
conventions, security-critical modules, and public API surface.
docs/CLAUDE_WORKTREES.md is a beginner-friendly guide to using Claude
Code with git worktrees for isolated feature work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:45:09 -05:00
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
142 changed files with 20547 additions and 1758 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"church@church": true
}
}

View File

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

View File

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

View File

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

19
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# Embedded repos (AUR packaging)
aur-cli-upload/
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
@@ -64,9 +67,13 @@ htmlcov/
# Output test files. # Output test files.
test_data/*.png test_data/*.png
# Dev scripts (local convenience scripts - except validate-release.sh) # Dev scripts (local convenience scripts - except these)
scripts/* scripts/*
!scripts/validate-release.sh !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 # Web UI auth database and SSL certs
instance/ instance/
@@ -80,8 +87,8 @@ tests/
*.img *.img
*.img.xz *.img.xz
*.img.zst *.img.zst
pishrink.sh
*.img.zst.zip *.img.zst.zip
rpi/tools/pishrink.sh
# Temp file storage # Temp file storage
frontends/web/temp_files/ frontends/web/temp_files/
@@ -93,3 +100,11 @@ rpi/*.tar.zst.zip
rpi/*.img rpi/*.img
rpi/*.img.zst rpi/*.img.zst
rpi/*.img.zst.zip 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:** **Docker with channel key:**
```bash ```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 Configuration
### docker-compose.yml ### docker/docker-compose.yml
```yaml ```yaml
x-common-env: &common-env x-common-env: &common-env

View File

@@ -5,6 +5,25 @@ All notable changes to Stegasoo will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org). and this project adheres to [Semantic Versioning](https://semver.org).
## [4.3.0] - 2026-02-27
### Added
- **Audio Steganography** — Hide messages in audio files (WAV, FLAC, MP3, OGG, AAC, M4A)
- LSB mode: Direct least-significant-bit embedding in audio samples
- Spread Spectrum mode: Noise-resistant encoding using pseudo-random spreading
- Automatic format transcoding to WAV for embedding
- Full CLI support: `stegasoo audio-encode`, `audio-decode`, `audio-info`
- REST API endpoints: `/audio/encode`, `/audio/decode`, `/audio/info`
- Web UI: Unified encode/decode pages with carrier type selector (Image/Audio)
- New `AudioCapacityInfo`, `AudioEmbedStats`, `AudioInfo` model classes
- Audio-specific exceptions: `AudioError`, `AudioValidationError`, `AudioCapacityError`, `AudioExtractionError`, `AudioTranscodeError`, `UnsupportedAudioFormatError`
- Subprocess isolation for audio operations (crash protection)
- `debug.py` module for structured logging across all steganography operations
### Changed
- Encode/Decode web pages now have a "Carrier Type" step to switch between Image and Audio
- Version bumped to 4.3.0
## [4.1.5] - 2026-01-07 ## [4.1.5] - 2026-01-07
### Added ### Added
@@ -201,6 +220,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
- CLI interface - CLI interface
- Basic PIN authentication - Basic PIN authentication
[4.3.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.2.1...v4.3.0
[4.1.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5 [4.1.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5
[4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3 [4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0 [4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0

114
CLAUDE.md Normal file
View File

@@ -0,0 +1,114 @@
# Stegasoo — Claude Code Project Guide
Stegasoo is a secure steganography toolkit with hybrid photo + passphrase + PIN authentication.
Version 4.3.0 · Python >=3.11 · MIT License
## Quick commands
```bash
pip install -e ".[dev]" # Install for development (includes all extras)
pytest # Run tests (coverage reported automatically)
black src/ tests/ frontends/ # Format code
ruff check src/ tests/ frontends/ --fix # Lint (auto-fix)
mypy src/ # Type check
pre-commit run --all-files # Run all pre-commit hooks
PYTHONPATH=src python -m stegasoo.cli # Run CLI directly without install
```
## Architecture
```
src/stegasoo/ Core library
crypto.py Argon2id / PBKDF2 key derivation + AES-256-GCM encryption
steganography.py LSB spatial embedding
dct_steganography.py DCT domain embedding (JPEG-safe, needs [dct] extras)
validation.py Input validation for all security factors
constants.py All magic numbers, crypto params, limits
models.py Dataclasses (EncodeResult, DecodeResult, etc.)
encode.py / decode.py High-level encode/decode orchestration
channel.py Channel key management (v4.0+)
audio_steganography.py LSB audio embedding/extraction (v4.3.0)
spread_steganography.py Spread spectrum audio embedding (v4.3.0)
audio_utils.py Audio format detection, validation, transcoding (v4.3.0)
debug.py Structured logging for operations (v4.3.0)
compression.py Zstandard / zlib / lz4 payload compression
cli.py Click CLI entry point
generate.py Credential generation (passphrase, PIN, RSA keys)
exceptions.py Exception hierarchy (all inherit StegasooError)
__init__.py Public API surface (__all__)
frontends/web/ Flask web UI (entry: app.py)
frontends/api/ FastAPI REST API (entry: main.py)
frontends/cli/ CLI extras
tests/ Pytest suite
test_stegasoo.py Single test file covering core library
```
### Entry points
| Interface | Entry point | Install extra |
|-----------|-------------|---------------|
| CLI | `stegasoo.cli:main` (`stegasoo` command) | `[cli]` |
| Web UI | `frontends/web/app.py` | `[web]` |
| REST API | `frontends/api/main.py` | `[api]` |
## Code conventions
- **Formatter**: Black, 100-char line length
- **Linter**: Ruff — rules E, F, I, N, W, UP (E501 ignored). N803/N806 suppressed in `dct_steganography.py` for colorspace variable names
- **Type hints**: Required on all new code. `mypy` with `ignore_missing_imports = true`
- **Pre-commit hooks**: ruff, ruff-format, trailing-whitespace, end-of-file-fixer, check-yaml, check-toml, check-added-large-files (1MB), check-merge-conflict, debug-statements, bandit (excludes tests/)
- **Branch naming**: `feature/`, `fix/`, `docs/`, `refactor/`
- **Commits**: Imperative mood, clear subject line. Include what + why
## Security-critical modules
These files implement the cryptographic and steganographic core. Changes require extra care, thorough test coverage, and careful review:
- **`crypto.py`** — Argon2id KDF (256 MB / 4 iterations / 4 parallelism) + PBKDF2 fallback (600K iterations) → AES-256-GCM authenticated encryption
- **`steganography.py`** — LSB spatial embedding/extraction
- **`dct_steganography.py`** — DCT domain embedding with Reed-Solomon error correction
- **`validation.py`** — Input validation for all security factors (PIN, passphrase, image, RSA key, channel key)
- **`constants.py`** — Crypto parameters (salt sizes, iteration counts, Argon2 memory cost, format versions). Do not change these casually — they affect backward compatibility and security margins
## Public API
`src/stegasoo/__init__.py` defines the full public API surface via `__all__`. Any new public function must be:
1. Imported in `__init__.py`
2. Added to the `__all__` list
## Testing
- Single test file: `tests/test_stegasoo.py`
- Requires `pip install -e ".[dev]"` (includes DCT dependencies)
- Coverage is reported automatically via pytest config (`--cov=stegasoo --cov-report=term-missing`)
- Run: `pytest` (no extra flags needed)
## Worktree workflow
When working on features or fixes that touch multiple files, prefer using a git worktree for isolation:
```bash
# Claude Code can create worktrees automatically via /worktree or EnterWorktree
# Manual creation:
git worktree add .claude/worktrees/<name> -b <branch-name>
```
### Guidelines for worktree usage
- **Use worktrees for**: multi-file refactors, experimental changes, anything that might need to be discarded
- **Worktree location**: `.claude/worktrees/` (gitignored by Claude Code)
- **Branch from**: always branch from `main` unless working on a version branch (e.g., `4.2`)
- **Naming**: use the same conventions as branches — `feature/description`, `fix/description`, etc.
- **Cleanup**: worktrees in `.claude/worktrees/` are ephemeral. Remove with `git worktree remove <path>` when done
- **Testing in worktrees**: run `pip install -e ".[dev]"` inside the worktree before running tests, since the editable install points to the worktree's source
- **Merging back**: create a PR from the worktree branch, or merge locally into `main`
## Useful context
- BIP-39 wordlist lives at `src/stegasoo/data/bip39-words.txt` (used for passphrase generation)
- Docker support: `src/stegasoo/Dockerfile` + `docs/DOCKER_QUICKSTART.md`
- Raspberry Pi builds: `rpi/` directory
- AUR packages: `aur/`, `aur-cli/`, `aur-api/`
- Version is defined in both `pyproject.toml` and `src/stegasoo/__init__.py` — keep them in sync

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 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 ## What's New in v4.1.0
@@ -152,7 +164,7 @@ stegasoo generate [OPTIONS]
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN | | `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key | | `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
| `--pin-length` | | 6-9 | 6 | PIN length in digits | | `--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 | | `--words` | | 3-12 | 4 | Words in passphrase |
| `--output` | `-o` | path | | Save RSA key to file | | `--output` | `-o` | path | | Save RSA key to file |
| `--password` | `-p` | string | | Password for RSA key file | | `--password` | `-p` | string | | Password for RSA key file |
@@ -168,7 +180,7 @@ stegasoo generate
stegasoo generate --words 6 stegasoo generate --words 6
# Generate with RSA key # Generate with RSA key
stegasoo generate --rsa --rsa-bits 4096 stegasoo generate --rsa --rsa-bits 3072
# Save RSA key to encrypted file # Save RSA key to encrypted file
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword" 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 Deployment
**docker-compose.yml:** **docker/docker-compose.yml:**
```yaml ```yaml
x-common-env: &common-env x-common-env: &common-env
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-} 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 ```bash
# Build and start all services # Build and start all services
docker-compose up -d docker-compose -f docker/docker-compose.yml up -d
# Check status # Check status
docker-compose ps docker-compose -f docker/docker-compose.yml ps
``` ```
Access: Access:
- **Web UI**: http://localhost:5000 - **Web UI**: https://localhost:5000 (HTTPS with self-signed cert)
- **REST API**: http://localhost:8000 - **REST API**: http://localhost:8000
## Services ## Services
@@ -36,9 +36,12 @@ STEGASOO_CHANNEL_KEY=XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
# Web UI authentication (default: enabled) # Web UI authentication (default: enabled)
STEGASOO_AUTH_ENABLED=true STEGASOO_AUTH_ENABLED=true
# HTTPS support (default: disabled) # HTTPS support (default: enabled, generates self-signed cert)
STEGASOO_HTTPS_ENABLED=false STEGASOO_HTTPS_ENABLED=true
STEGASOO_HOSTNAME=localhost STEGASOO_HOSTNAME=localhost
# To disable HTTPS:
# STEGASOO_HTTPS_ENABLED=false
``` ```
### Volume Mounts ### Volume Mounts
@@ -58,10 +61,10 @@ Uses a pre-built base image with all dependencies:
```bash ```bash
# First time only: build the base image # 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) # Build services (fast - only copies app code)
docker-compose build docker-compose -f docker/docker-compose.yml build
``` ```
### Full Build (No Base Image) ### 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): If you don't have the base image, the Dockerfile will build all dependencies (slower):
```bash ```bash
docker-compose build docker-compose -f docker/docker-compose.yml build
``` ```
## Commands ## Commands
```bash ```bash
# Start services # Start services
docker-compose up -d docker-compose -f docker/docker-compose.yml up -d
# View logs # View logs
docker-compose logs -f docker-compose -f docker/docker-compose.yml logs -f
# Stop services # Stop services
docker-compose down docker-compose -f docker/docker-compose.yml down
# Rebuild after code changes # 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) # Full rebuild (no cache)
docker-compose build --no-cache docker-compose -f docker/docker-compose.yml build --no-cache
``` ```
## Resource Limits ## Resource Limits
@@ -109,7 +112,7 @@ Both services include health checks:
Check health status: Check health status:
```bash ```bash
docker-compose ps docker-compose -f docker/docker-compose.yml ps
``` ```
## Production Deployment ## Production Deployment
@@ -126,7 +129,7 @@ For production, consider:
```bash ```bash
# Don't commit .env files with secrets # Don't commit .env files with secrets
export STEGASOO_CHANNEL_KEY=your-key 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 3. **Reverse proxy**: Put behind nginx/traefik for TLS termination
@@ -142,12 +145,12 @@ For production, consider:
### Container won't start ### Container won't start
```bash ```bash
# Check logs # Check logs
docker-compose logs web docker-compose -f docker/docker-compose.yml logs web
docker-compose logs api docker-compose -f docker/docker-compose.yml logs api
``` ```
### Out of memory ### 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 ### Permission errors
The containers run as non-root user `stego` (UID 1000). Ensure volume permissions match. 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 ## Requirements
### ⚠️ Python Version Requirements ### Python Version Requirements
| Python Version | Status | Notes | | Python Version | Status | Notes |
|----------------|--------|-------| |----------------|--------|-------|
| 3.10 | Supported | | | 3.10 | ❌ Not Supported | Dropped in v4.2.1 |
| 3.11 | ✅ Supported | Recommended | | 3.11 | ✅ Supported | Minimum version |
| 3.12 | ✅ Supported | Recommended | | 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 ### Minimum Requirements
| Requirement | Value | | Requirement | Value |
|-------------|-------| |-------------|-------|
| Python | 3.10-3.12 | | Python | 3.11-3.14 |
| RAM | 512 MB minimum (256MB for Argon2) | | RAM | 512 MB minimum (256MB for Argon2) |
| Disk | ~100 MB | | Disk | ~100 MB |
@@ -154,10 +155,10 @@ Build and run individual containers.
#### Build Images #### Build Images
```bash ```bash
# Build all targets # From project root - build all targets
docker build -t stegasoo-web --target web . docker build -t stegasoo-web --target web -f docker/Dockerfile .
docker build -t stegasoo-api --target api . docker build -t stegasoo-api --target api -f docker/Dockerfile .
docker build -t stegasoo-cli --target cli . docker build -t stegasoo-cli --target cli -f docker/Dockerfile .
``` ```
#### Run Web UI #### Run Web UI
@@ -214,17 +215,17 @@ The easiest way to run all services.
```bash ```bash
# Start in background # Start in background
docker-compose up -d docker-compose -f docker/docker-compose.yml up -d
# Start specific service # Start specific service
docker-compose up -d web docker-compose -f docker/docker-compose.yml up -d web
docker-compose up -d api docker-compose -f docker/docker-compose.yml up -d api
# View logs # View logs
docker-compose logs -f docker-compose -f docker/docker-compose.yml logs -f
# Stop all # Stop all
docker-compose down docker-compose -f docker/docker-compose.yml down
``` ```
#### Authentication Configuration (v4.0.2) #### Authentication Configuration (v4.0.2)
@@ -239,7 +240,7 @@ STEGASOO_HOSTNAME=localhost # Hostname for SSL cert
STEGASOO_CHANNEL_KEY= # Optional channel key STEGASOO_CHANNEL_KEY= # Optional channel key
# Then run # 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. 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 ```bash
# Build images and start # Build images and start
docker-compose up -d --build docker-compose -f docker/docker-compose.yml up -d --build
# Force rebuild (no cache) # Force rebuild (no cache)
docker-compose build --no-cache docker-compose -f docker/docker-compose.yml build --no-cache
docker-compose up -d docker-compose -f docker/docker-compose.yml up -d
``` ```
#### Resource Configuration #### Resource Configuration
The `docker-compose.yml` includes resource limits: The `docker/docker-compose.yml` includes resource limits:
```yaml ```yaml
services: services:
@@ -423,16 +424,61 @@ pip install jpegio
### Windows ### Windows
1. Install Python 3.12 from [python.org](https://python.org) (NOT 3.13!) Windows users have three options, listed from easiest to most complex:
2. Install Visual Studio Build Tools
#### 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: 3. Install from pip:
```powershell ```powershell
python -m venv venv python -m venv venv
.\venv\Scripts\activate .\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 ### Raspberry Pi
Stegasoo works on Raspberry Pi 4/5 (4GB+ RAM recommended for Web UI). 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
docker run --memory=768m ... docker run --memory=768m ...
# Docker Compose - edit docker-compose.yml # Docker Compose - edit docker/docker-compose.yml
deploy: deploy:
resources: resources:
limits: limits:

View File

@@ -0,0 +1,294 @@
# Stegasoo Ideas Scout — Implementation Plans (2026-03-24)
Baseline: v4.3.0, Python >=3.11, FORMAT_VERSION 5, no existing users (no backward compat constraints).
---
## Tier 1 — Quick Wins
### 1. Platform-Calibrated DCT Presets
**Description**: `--platform telegram|discord|signal|whatsapp` flag for DCT encode. Bakes in each platform's known recompression parameters. Pre-verifies payload survives before outputting.
**Implementation approach**:
- New file `src/stegasoo/platform_presets.py``PlatformPreset` dataclass + `PRESETS` dict mapping platform → tuned `quant_step`, `jpeg_quality`, `embed_positions`, `max_dimension`, `recompress_quality`
- `dct_steganography.py`: `_embed_scipy_dct_safe()` / `_embed_jpegio()` accept optional preset overrides for `QUANT_STEP`, `DEFAULT_EMBED_POSITIONS`, output quality
- New `pre_verify_survival()` function: encode → re-save at platform quality → extract → pass/fail
- Thread `platform` param through `encode.py``steganography.py` → DCT functions
- `cli.py`: add `--platform` as `click.Choice` + `--verify/--no-verify` (pre-verification doubles encode time)
- LSB + `--platform` should error early — LSB data is destroyed by any JPEG recompression
**Known platform params** (from research):
| Platform | Quality | Max Dimension | Notes |
|----------|---------|---------------|-------|
| Telegram | ~82 | 2560×2560 | ~81KB embeddable |
| Discord | ~85 | Varies (Nitro) | |
| Signal | ~80 | Aggressive | |
| WhatsApp | ~70 | 1600×1600 | Most lossy |
**Go/No-Go metrics**:
- >95% payload survival rate per platform at 1KB message size in automated tests
- Pre-verification correctly predicts real platform behavior (manual validation per platform at least once)
**Complexity**: **M** — new file + parameter threading through 4-5 functions
**Risks**: Platform params change without notice. Add version/date stamps to presets and a `stegasoo tools verify-platform` test command.
---
### 2. Steganalysis Self-Check (`stegasoo check`)
**Description**: New CLI command running chi-square and RS (Regular-Singular) statistical analysis on stego images. Outputs detectability risk level (low/medium/high).
**Implementation approach**:
- New file `src/stegasoo/steganalysis.py`:
- `chi_square_analysis(image_data) -> float` — chi-square statistic on LSB distribution per channel
- `rs_analysis(image_data) -> float` — Regular-Singular groups analysis (requires numpy)
- `assess_risk(chi_p, rs_estimate) -> str` — maps to "low"/"medium"/"high"
- `check_image(image_data) -> dict` — orchestrator
- `cli.py`: new `@cli.command("check")` with `IMAGE` arg, `--json`, `--mode lsb|dct|auto`
- `constants.py`: threshold constants for chi-square p-value and RS boundaries
- `__init__.py`: export `check_image` in `__all__`
- Start LSB-only; DCT steganalysis (calibration attack) deferred
**Go/No-Go metrics**:
- Clean images → consistently "low risk"
- Naive sequential LSB → "high risk"
- Stegasoo LSB at <50% capacity → "low" or "medium"
**Complexity**: **M** — ~150 lines numpy per test, straightforward CLI integration
---
### 3. Python 3.13 DCT Cleanup
**Description**: The `jpegio``jpeglib` migration is already done in code. Remaining work: rename stale `jpegio` references and verify on 3.13.
**Implementation approach**:
- `dct_steganography.py`: rename `HAS_JPEGIO``HAS_JPEGLIB`, `_jpegio_*` functions → `_jpeglib_*`, update constant names (`JPEGIO_MAGIC``JPEGLIB_MAGIC`, etc.)
- Verify `jpeglib.to_jpegio()` compatibility shim — if jpeglib plans to deprecate it, migrate to native API
- Run full test suite on Python 3.13
**Go/No-Go metrics**:
- All DCT tests pass on Python 3.13
- No deprecation warnings from jpeglib
**Complexity**: **S** — renaming and verification only
---
## Tier 2 — Strategic
### 4. Content-Adaptive Embedding (S-UNIWARD/WOW-inspired)
**Description**: Replace uniform-random pixel selection with texture-weighted cost functions. Embed preferentially in busy/textured regions where changes are least detectable. 3-5x harder to detect statistically.
**Implementation approach**:
- New file `src/stegasoo/adaptive_cost.py`:
- `compute_cost_map(image_data) -> np.ndarray` — per-pixel distortion cost via directional high-pass filters (Daubechets wavelet bank / KB filter)
- `select_pixels_by_cost(cost_map, pixel_key, num_needed) -> list[int]` — weighted sampling, still ChaCha20-seeded for determinism
- `steganography.py`:
- `generate_pixel_indices()`: add `cost_map` param, use weighted sampling when provided
- `_embed_lsb()`: compute cost map when adaptive mode enabled
- `_extract_lsb()`: must compute identical cost map to find same pixels
- `dct_steganography.py`: adapt `DEFAULT_EMBED_POSITIONS` per-block based on block texture energy
- Thread `adaptive: bool` through `encode.py`/`decode.py`
- `constants.py`: add `EMBED_MODE_ADAPTIVE_LSB`, filter kernels, cost thresholds
**Go/No-Go metrics**:
- Chi-square test (Feature 2) shows measurable improvement vs uniform-random
- **Critical**: cost map computation is deterministic across platforms (quantize to fixed-point integers)
- Round-trip decode succeeds on Linux x86, Linux ARM, macOS
**Complexity**: **L** — novel algorithm, cross-platform determinism requirement, touches core embedding
**Risks**: Floating-point differences in wavelet computation could break extraction. Mitigate with integer quantization. Increases encode/decode time ~2-3x.
---
### 5. Per-Message Forward Secrecy via HKDF
**Description**: Derive ephemeral per-message encryption keys using HKDF expansion from the Argon2id root key + random nonce. Compromising one message doesn't reveal others.
**Implementation approach**:
- `crypto.py`:
- Add `from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand`
- `derive_message_key(root_key, nonce) -> bytes` — HKDF-Expand with SHA-256
- `encrypt_message()`: generate 16-byte random nonce, derive per-message key, embed nonce in header
- `decrypt_message()`: extract nonce, derive same key
- Also derive pixel selection key via HKDF with different `info` param
- `constants.py`:
- Bump `FORMAT_VERSION` to 6
- `HKDF_INFO_ENCRYPTION = b"stegasoo-v6-encrypt"`, `HKDF_INFO_PIXEL = b"stegasoo-v6-pixel"`
- `MESSAGE_NONCE_SIZE = 16`
- Header grows from 66 → 82 bytes: add `message_nonce(16)` field
- Update `HEADER_OVERHEAD` / `ENCRYPTION_OVERHEAD` in `steganography.py`
**Go/No-Go metrics**:
- Two messages with identical credentials produce different ciphertexts and different pixel locations
- `cryptography` library HKDF works with existing Argon2id output
**Complexity**: **M** — well-defined crypto change, touches security-critical header format
---
### 6. PWA Mobile Interface
**Description**: Convert Flask Web UI to Progressive Web App. Mobile-optimized, installable, offline-capable static pages.
**Implementation approach**:
- New files in `frontends/web/static/`: `manifest.json`, `sw.js`, icon set (192×192, 512×512)
- Base template: add manifest link, theme-color meta, viewport meta, service worker registration
- `app.py`: serve manifest with correct MIME, add cache headers for static assets
- Responsive CSS for encode/decode accordion forms
- Camera capture: `<input type="file" accept="image/*" capture="environment">` for reference photo
- Service worker caches static assets only — NOT encode/decode API endpoints
**Go/No-Go metrics**:
- Lighthouse PWA score >= 90
- Installable on Android Chrome and iOS Safari
- Offline: static pages load, encode/decode shows graceful "offline" message
**Complexity**: **M** — frontend only, no core library changes
**Risks**: Camera capture requires HTTPS (already supported via `ssl_utils.py`).
---
## Tier 3 — Moonshot
### 7. Plausible Deniability / Dual-Payload Mode
**Description**: Two independent encrypted payloads in one carrier, each with different credentials. Reveal decoy under coercion; real payload stays hidden.
**Implementation approach**:
- New file `src/stegasoo/dual_payload.py`:
- `encode_dual(message_a, message_b, carrier, creds_a, creds_b)`
- Partition available pixels into two disjoint pools using different seeds
- **Critical**: ALL images (single or dual) must fill unused pixel pool with random data so single-payload and dual-payload images are indistinguishable
- `steganography.py`: `generate_pixel_indices()` gets `exclude_indices` param
- `decode.py`: each credential set finds a different valid payload; wrong credentials produce garbage
- CLI + Web UI: dual-payload encode workflow
**Go/No-Go metrics**:
- Single-payload and dual-payload images are statistically indistinguishable (chi-square can't differentiate)
- Each payload decodes independently
- Wrong credentials for one payload don't reveal other payload's existence
**Complexity**: **XL** — novel design, halves capacity per payload, challenging UX, needs rigorous security analysis
**Dependencies**: Feature 2 (validation), Feature 4 (detectability reduction)
---
## Architectural Improvements
### 8. EmbeddingBackend Protocol
**Description**: Typed plugin interface for all embedding algorithms. Replace if/elif dispatch in `steganography.py` with a registry.
**Implementation approach**:
- New package `src/stegasoo/backends/`:
- `protocol.py``EmbeddingBackend(Protocol)` with `embed()`, `extract()`, `calculate_capacity()`, `is_available()`
- `lsb.py`, `dct.py` — wrap existing functions
- `registry.py``BackendRegistry` mapping mode strings to backends
- `steganography.py`: `embed_in_image()` / `extract_from_image()` dispatch via registry
- `__init__.py`: export protocol and `register_backend()`
**Complexity**: **M** — implement before Features 4 and 7 (they become new backends)
---
### 9. HKDF Key Separation
Subsumed by Feature 5. The HKDF expansion provides:
- Encryption key: `HKDF-Expand(root_key, info="stegasoo-encrypt", nonce)`
- Pixel selection key: `HKDF-Expand(root_key, info="stegasoo-pixel", nonce)`
- Future: MAC key, padding key, etc.
---
### 10. `[core]` Extra with Minimal Deps
**Description**: Move Pillow to `[image]` extra, base deps = `cryptography` + `argon2-cffi` + `zstandard` only.
**Complexity**: **S** — but Pillow is used in `crypto.py` for photo hashing (core to security model). Only worth it with a concrete headless use case. **Low priority.**
---
## Ecosystem Features
### 11. Aletheia Integration
Optional `--engine aletheia` backend for Feature 2's `stegasoo check`. BSD-licensed, provides SPA/RS/WS attacks + ML classifiers. **Complexity: S** (after Feature 2). **Depends on**: Feature 2.
### 12. C2PA/AI Provenance Watermarking
Embed C2PA metadata alongside stego payloads. **Complexity: L** — C2PA is a complex standard. Potentially conflicts with stego goals (adds detectable metadata). Research-heavy.
### 13. Signal/Matrix Bot
Bot that decodes stego images in a channel using configured channel key. **Complexity: M** — integration work, uses existing `decode()` API.
### 14. Homebrew Tap + Nix Flake
Package distribution for macOS/NixOS. **Complexity: S** — packaging only, no code changes.
---
## Summary Table
| # | Feature | Tier | Size | Dependencies | Primary Files |
|---|---------|------|------|-------------|---------------|
| 1 | Platform DCT Presets | T1 | M | — | new `platform_presets.py`, `dct_steganography.py`, `encode.py`, `cli.py` |
| 2 | Steganalysis Self-Check | T1 | M | — | new `steganalysis.py`, `cli.py`, `constants.py` |
| 3 | Python 3.13 DCT Cleanup | T1 | S | — | `dct_steganography.py` |
| 4 | Content-Adaptive Embedding | T2 | L | numpy, #2 | new `adaptive_cost.py`, `steganography.py`, `constants.py` |
| 5 | HKDF Forward Secrecy | T2 | M | — | `crypto.py`, `constants.py`, `steganography.py` |
| 6 | PWA Mobile Interface | T2 | M | — | `frontends/web/` templates + static |
| 7 | Dual-Payload Mode | T3 | XL | #2, #4 | new `dual_payload.py`, `steganography.py`, `cli.py` |
| 8 | EmbeddingBackend Protocol | Arch | M | — | new `backends/` package, `steganography.py` |
| 9 | HKDF Key Separation | Arch | — | Included in #5 | `crypto.py` |
| 10 | `[core]` Extra | Arch | S | — | `pyproject.toml` |
| 11 | Aletheia Integration | Eco | S | #2 | `steganalysis.py` |
| 12 | C2PA Watermarking | Eco | L | — | new module |
| 13 | Signal/Matrix Bot | Eco | M | — | new `bots/` package |
| 14 | Homebrew + Nix | Eco | S | — | packaging files only |
---
## Suggested Roadmap
### Phase 1 — Foundations (v4.4.0)
1. **#3** Python 3.13 DCT Cleanup (S) — unblocks CI on 3.13
2. **#8** EmbeddingBackend Protocol (M) — architectural cleanup before new embedding work
3. **#2** Steganalysis Self-Check (M) — validation tooling for everything that follows
### Phase 2 — Security & Robustness (v4.5.0)
4. **#5** HKDF Forward Secrecy (M) — FORMAT_VERSION bump to 6, improved crypto
5. **#1** Platform-Calibrated DCT Presets (M) — high user value for social media
6. **#14** Homebrew + Nix (S) — distribution expansion
### Phase 3 — Advanced Steganography (v5.0.0)
7. **#4** Content-Adaptive Embedding (L) — major security improvement
8. **#6** PWA Mobile Interface (M) — parallel frontend work stream
### Phase 4 — Moonshot (v5.x+)
9. **#7** Dual-Payload Mode (XL) — after #2 and #4 are solid
10. **#12** C2PA Watermarking (L) — research-heavy
11. **#13** Signal/Matrix Bot (M) — community-driven
---
## Additional Ideas (Backlog)
- **Animated GIF steganography** — LSB in GIF frames, natural multi-media extension
- **PDF steganography** — whitespace/font metric/embedded image payloads
- **Batch encode** — `stegasoo batch-encode --dir /photos/` with auto carrier selection (BATCH_* constants suggest this was planned)
- **Stego identification** — `stegasoo identify image.png` probes for known stego signatures
- **Per-device credential sync via QR** — channel key as stego image of reference photo
- **`stegasoo verify`** — decode + confirm message matches expected hash without revealing contents

View File

@@ -1,10 +1,10 @@
# Stegasoo # Stegasoo
A secure steganography system for hiding encrypted messages in images using hybrid authentication. A secure steganography system for hiding encrypted messages in images and audio using hybrid authentication.
[![Tests](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml/badge.svg)](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml) [![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) [![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) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
![Security](https://img.shields.io/badge/Security-AES--256--GCM-red) ![Security](https://img.shields.io/badge/Security-AES--256--GCM-red)
@@ -17,15 +17,25 @@ A secure steganography system for hiding encrypted messages in images using hybr
- **Multiple interfaces**: CLI, Web UI, REST API - **Multiple interfaces**: CLI, Web UI, REST API
- **File embedding**: Hide any file type (PDF, ZIP, documents) - **File embedding**: Hide any file type (PDF, ZIP, documents)
- **DCT steganography**: JPEG-resilient embedding for social media - **DCT steganography**: JPEG-resilient embedding for social media
- **Audio steganography**: Hide messages in WAV, FLAC, MP3, OGG, AAC, M4A files (LSB and Spread Spectrum modes)
- **Channel keys**: Private group communication channels - **Channel keys**: Private group communication channels
## Embedding Modes ## Embedding Modes
### Image Modes
| Mode | Capacity (1080p) | JPEG Resilient | Best For | | Mode | Capacity (1080p) | JPEG Resilient | Best For |
|------|------------------|----------------|----------| |------|------------------|----------------|----------|
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps | | **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
| **LSB** | ~750 KB | No | Email, direct file transfer | | **LSB** | ~750 KB | No | Email, direct file transfer |
### Audio Modes
| Mode | Capacity (5 min WAV) | Noise Resistant | Best For |
|------|---------------------|-----------------|----------|
| **LSB** | ~1.3 MB | No | Direct file transfer |
| **Spread Spectrum** | ~160 KB | Yes | Shared files, light processing |
## Web UI ## Web UI
| Home | Encode | Decode | Generate | | Home | Encode | Decode | Generate |
@@ -105,15 +115,18 @@ ruff check src/ tests/ frontends/
## Docker ## Docker
```bash ```bash
# Quick start # Quick start (HTTPS enabled by default)
docker-compose up -d docker-compose -f docker/docker-compose.yml up -d
# Access # Access
# Web UI: http://localhost:5000 # Web UI: https://localhost:5000 (self-signed cert)
# REST API: http://localhost:8000 # 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 ## Raspberry Pi
@@ -143,6 +156,7 @@ See [rpi/README.md](rpi/README.md) for manual installation.
- [UNDER_THE_HOOD.md](UNDER_THE_HOOD.md) - Technical deep-dive - [UNDER_THE_HOOD.md](UNDER_THE_HOOD.md) - Technical deep-dive
- [CHANGELOG.md](CHANGELOG.md) - Version history - [CHANGELOG.md](CHANGELOG.md) - Version history
- [CONTRIBUTING.md](CONTRIBUTING.md) - Contributor guide - [CONTRIBUTING.md](CONTRIBUTING.md) - Contributor guide
- `man stegasoo` - Man page (install: `sudo cp docs/stegasoo.1 /usr/local/share/man/man1/ && sudo mandb`)
## License ## License

View File

@@ -21,12 +21,12 @@ Pre-release validation checklist. Complete all items before tagging a release.
## Docker Validation ## Docker Validation
- [ ] Base image builds: `docker build -f Dockerfile.base -t stegasoo-base:latest .` - [ ] Base image builds: `docker build -f docker/Dockerfile.base -t stegasoo-base:latest .`
- [ ] Web image builds: `docker-compose build web` - [ ] Web image builds: `docker-compose -f docker/docker-compose.yml build web`
- [ ] Container starts: `docker-compose up -d web` - [ ] Container starts: `docker-compose -f docker/docker-compose.yml up -d web`
- [ ] Web UI accessible at http://localhost:5000 - [ ] Web UI accessible at http://localhost:5000
- [ ] Encode/decode works in container - [ ] Encode/decode works in container
- [ ] Container stops cleanly: `docker-compose down` - [ ] Container stops cleanly: `docker-compose -f docker/docker-compose.yml down`
## Release Process ## Release Process

View File

@@ -1,47 +1,173 @@
## Stegasoo v4.1.5 # v4.3.0 — Audio Steganography
### Developer Experience **Release Date:** 2026-02-27
- **Educational Code Comments**: Core modules now include detailed explanations
- DCT: zig-zag coefficient diagrams, QIM embedding math, Reed-Solomon "Voyager" reference
- LSB: visual bit manipulation examples, ChaCha20 pixel selection
- Crypto: multi-factor KDF flow diagrams, Argon2id memory-hardness reasoning
- CLI/Web: architectural patterns for future contributors
### Raspberry Pi Improvements ## Highlights
- **Streamlined Image Creation**: `pull-image.sh` now handles everything
- Auto-resizes rootfs to exactly 16GB (consistent images from any SD card)
- Disables Pi OS auto-expand
- Compresses with zstd
- Optional .zst.zip wrapper for GitHub releases
- **16GB Minimum**: Pre-built images are now 16GB (was variable)
- **Host Requirements**: `rpi/host-requirements.txt` documents all dependencies
- **Test Automation**: `kickoff-pi-test.sh` for one-command flash+test cycles
### MOTD Polish Stegasoo can now hide messages in audio files! This release adds full audio steganography support with two embedding modes:
- Dynamic temperature emoji (ice/cool/fire based on CPU temp)
- Rocket emoji for service status
- Cleaner formatting
### Raspberry Pi Image - **LSB (Least Significant Bit)**: Embeds data directly in audio sample LSBs. High capacity, best for direct file transfers.
Download `stegasoo-rpi-4.1.5.img.zst.zip` from Releases. - **Spread Spectrum**: Spreads data across audio frequencies using pseudo-random sequences. Lower capacity but more resistant to noise and light processing.
## What's New
### Audio Steganography
- Support for WAV, FLAC, MP3, OGG, AAC, and M4A input formats
- Automatic transcoding to WAV (16-bit PCM) for embedding
- Same security model: reference photo + passphrase + PIN/RSA + channel key
- Full CLI, REST API, and Web UI support
### Unified Web UI
- Encode and Decode pages now feature a "Carrier Type" selector
- Switch between Image and Audio modes without leaving the page
- Audio capacity display shows LSB and Spread Spectrum capacities
- Audio preview player on encode result page
### New Modules
- `audio_steganography.py` — LSB audio embedding/extraction
- `spread_steganography.py` — Spread spectrum embedding/extraction
- `audio_utils.py` — Audio format detection, validation, transcoding
- `debug.py` — Structured logging for all operations
## Upgrade Notes
Audio steganography requires `numpy` and `soundfile` packages. Install with:
```bash ```bash
# Flash (auto-detects SD card) pip install stegasoo[audio]
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
``` ```
For full audio format support (MP3, AAC, etc.), install FFmpeg on your system.
---
## Stegasoo v4.2.1
### API Security
**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)
**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
### CLI Improvements
**New API Management Commands**
```bash
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` Default login: `admin` / `stegasoo`
First boot runs the setup wizard for WiFi, HTTPS, and channel key configuration. ### Requirements
### Docker - Python 3.11 - 3.14 (dropped 3.10 support)
```bash
docker-compose up -d web # Web UI on :5000 ### Release Assets
docker-compose up -d api # REST API on :8000
``` | 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 ### Full Changelog
See [CHANGELOG.md](CHANGELOG.md) for complete version history. See [CHANGELOG.md](CHANGELOG.md) for complete version history.

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 ### Docker Configuration
```yaml ```yaml
# docker-compose.yml # docker/docker-compose.yml
services: services:
web: web:
environment: environment:
@@ -360,7 +360,7 @@ gunicorn --bind 0.0.0.0:5000 --workers 2 --threads 4 --timeout 60 app:app
**Docker:** **Docker:**
```bash ```bash
docker-compose up web docker-compose -f docker/docker-compose.yml up web
``` ```
### First-Time Setup ### 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 | | Use PIN | on/off | on | Generate a numeric PIN |
| PIN length | 6-9 | 6 | Digits in the PIN | | PIN length | 6-9 | 6 | Digits in the PIN |
| Use RSA Key | on/off | off | Generate an RSA key pair | | 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 #### Entropy Calculator
@@ -1245,7 +1245,7 @@ volumes:
```bash ```bash
pip install scipy pip install scipy
# Or rebuild Docker image # Or rebuild Docker image
docker-compose build --no-cache docker-compose -f docker/docker-compose.yml build --no-cache
``` ```
### Browser Compatibility ### Browser Compatibility

30
agentstuff/pyproject.toml Normal file
View File

@@ -0,0 +1,30 @@
[project]
name = "sentiment-agent"
version = "0.1.0"
description = "AI agent for gathering data and performing sentiment analysis"
requires-python = ">=3.11"
dependencies = [
"claude-agent-sdk",
"anyio",
"httpx",
]
[project.optional-dependencies]
dev = [
"pytest",
"ruff",
"mypy",
]
[project.scripts]
sentiment-agent = "sentiment_agent.main:main"
[tool.ruff]
line-length = 100
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
python_version = "3.11"
ignore_missing_imports = true

View File

@@ -0,0 +1,3 @@
"""Sentiment analysis agent powered by Claude Agent SDK."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,115 @@
"""Core sentiment analysis agent using Claude Agent SDK."""
from __future__ import annotations
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
ResultMessage,
TextBlock,
)
from sentiment_agent.config import SafetyConfig
from sentiment_agent.tools import create_social_tools_server
SYSTEM_PROMPT = """\
You are a sentiment analysis agent. Your job is to gather data from multiple \
platforms and produce a structured, evidence-based sentiment report.
## Rules — you MUST follow these
1. **Budget awareness.** You have a limited API call budget. Call \
`get_api_budget_status` before starting and after every few tool calls. \
Stop gathering data when you have <5 calls remaining and begin your analysis.
2. **Credibility first.** Every tool result includes credibility scores and \
bot/disinfo flags. You MUST:
- NEVER quote or cite posts marked `likely_inauthentic` (score < 0.3).
- Flag posts marked `suspicious` (score 0.30.5) with a warning when citing them.
- Give more weight to `likely_authentic` posts (score ≥ 0.7).
- If coordination warnings appear (copy-paste campaigns, burst posting), \
call them out prominently in your report.
3. **Platform diversity.** Gather from at least 2 different platforms before \
analyzing. Do not over-index on a single source.
4. **No fabrication.** Only report on data you actually retrieved. If a tool \
call fails or returns no results, say so — do not invent data.
5. **Structured output.** Your final report MUST include these sections:
- **Data Quality Summary**: platforms queried, posts analyzed vs excluded, \
coordination warnings
- **Overall Sentiment**: score (-1.0 to +1.0) and label \
(very negative / negative / mixed / neutral / positive / very positive)
- **Platform Breakdown**: sentiment per platform with sample size
- **Key Themes**: top 3-5 themes with sentiment direction
- **Credibility Concerns**: any bot networks, disinfo patterns, or \
coordinated campaigns detected
- **Notable Quotes**: 3-5 representative quotes (authentic sources only, \
with credibility score noted)
- **Confidence Assessment**: how confident you are in the analysis given \
data quality and volume
6. **Scope discipline.** Stay focused on the requested topic. Do not expand \
scope, follow tangents, or analyze adjacent topics unless explicitly asked.
7. **No side effects.** Do not write files, run commands, or take any action \
beyond reading data and producing your report.
"""
async def run_sentiment_analysis(
topic: str,
sources: list[str] | None = None,
config: SafetyConfig | None = None,
) -> str:
"""Run the sentiment analysis agent on a given topic.
Args:
topic: The topic or subject to analyze sentiment for.
sources: Optional list of URLs or data sources to analyze.
config: Safety configuration. Defaults to SafetyConfig.from_env().
Returns:
The agent's sentiment analysis report.
"""
config = config or SafetyConfig.from_env()
source_instructions = ""
if sources:
source_list = "\n".join(f"- {s}" for s in sources)
source_instructions = f"\n\nAlso analyze these specific sources:\n{source_list}"
prompt = (
f"Perform a sentiment analysis on the following topic: {topic}\n\n"
"Start by calling `get_api_budget_status` to check your budget, then "
"gather data from multiple platforms (Reddit, Hacker News, Bluesky if "
"configured, and web search). Pay close attention to credibility scores "
"and coordination warnings in the results."
f"{source_instructions}"
)
social_server = create_social_tools_server(config)
options = ClaudeAgentOptions(
# Only allow read-only tools — no Write/Bash to prevent side effects
allowed_tools=["WebSearch", "WebFetch", "Read"],
max_turns=config.max_turns,
max_budget_usd=config.max_budget_usd,
mcp_servers={"social": social_server},
system_prompt=SYSTEM_PROMPT,
)
result_text = ""
async with ClaudeSDKClient(options=options) as client:
await client.query(prompt)
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(block.text, end="", flush=True)
if isinstance(message, ResultMessage):
result_text = message.result
return result_text

View File

@@ -0,0 +1 @@
"""API clients for social media and forum data sources."""

View File

@@ -0,0 +1,166 @@
"""Bluesky client using the AT Protocol API.
Search requires authentication. Set BLUESKY_HANDLE and BLUESKY_APP_PASSWORD
env vars. Create an app password at: https://bsky.app/settings/app-passwords
Thread fetching works without auth via the public API.
"""
import os
import httpx
BSKY_PUBLIC_API = "https://public.api.bsky.app"
BSKY_AUTH_API = "https://bsky.social"
async def _get_session() -> dict | None:
"""Authenticate with Bluesky and return session tokens, or None if no creds."""
handle = os.environ.get("BLUESKY_HANDLE")
app_password = os.environ.get("BLUESKY_APP_PASSWORD")
if not handle or not app_password:
return None
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{BSKY_AUTH_API}/xrpc/com.atproto.server.createSession",
json={"identifier": handle, "password": app_password},
)
resp.raise_for_status()
return resp.json()
def _format_post(post_view: dict) -> dict:
"""Extract relevant fields from an AT Protocol post view."""
post = post_view.get("post", post_view)
record = post.get("record", {})
author = post.get("author", {})
return {
"text": record.get("text", ""),
"author_handle": author.get("handle", ""),
"author_display_name": author.get("displayName", ""),
"created_at": record.get("createdAt", ""),
"like_count": post.get("likeCount", 0),
"repost_count": post.get("repostCount", 0),
"reply_count": post.get("replyCount", 0),
"uri": post.get("uri", ""),
"cid": post.get("cid", ""),
"url": _uri_to_url(post.get("uri", ""), author.get("handle", "")),
}
def _uri_to_url(uri: str, handle: str) -> str:
"""Convert an at:// URI to a bsky.app URL."""
# at://did:plc:xxx/app.bsky.feed.post/rkey -> https://bsky.app/profile/handle/post/rkey
if not uri.startswith("at://"):
return ""
parts = uri.split("/")
if len(parts) >= 5:
rkey = parts[-1]
return f"https://bsky.app/profile/{handle}/post/{rkey}"
return ""
async def search_posts(query: str, limit: int = 25, sort: str = "top") -> list[dict]:
"""Search Bluesky for posts matching a query.
Requires BLUESKY_HANDLE and BLUESKY_APP_PASSWORD env vars.
Args:
query: Search terms.
limit: Max results (capped at 100).
sort: "top" (most liked) or "latest" (chronological).
Returns:
List of post dicts with: text, author_handle, author_display_name,
created_at, like_count, repost_count, reply_count, uri, url.
Raises:
RuntimeError: If Bluesky credentials are not configured.
"""
session = await _get_session()
if not session:
raise RuntimeError(
"Bluesky search requires authentication. "
"Set BLUESKY_HANDLE and BLUESKY_APP_PASSWORD environment variables. "
"Create an app password at: https://bsky.app/settings/app-passwords"
)
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{BSKY_AUTH_API}/xrpc/app.bsky.feed.searchPosts",
params={
"q": query,
"limit": min(limit, 100),
"sort": sort,
},
headers={"Authorization": f"Bearer {session['accessJwt']}"},
)
resp.raise_for_status()
data = resp.json()
return [_format_post(p) for p in data.get("posts", [])]
async def get_thread(uri: str, depth: int = 6) -> dict:
"""Fetch a Bluesky thread by AT URI or bsky.app URL.
Args:
uri: Either an at:// URI or a https://bsky.app/profile/.../post/... URL.
depth: How many levels of replies to fetch (max 1000).
Returns:
Dict with "post" (the root post) and "replies" (list of reply post dicts).
"""
# Convert bsky.app URL to AT URI if needed
if uri.startswith("https://bsky.app/"):
uri = await _resolve_url_to_uri(uri)
headers = {}
session = await _get_session()
if session:
headers["Authorization"] = f"Bearer {session['accessJwt']}"
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{BSKY_PUBLIC_API}/xrpc/app.bsky.feed.getPostThread",
params={"uri": uri, "depth": min(depth, 1000)},
headers=headers,
)
resp.raise_for_status()
data = resp.json()
thread = data.get("thread", {})
root_post = _format_post(thread) if "post" in thread else {}
replies = []
for reply in thread.get("replies", []):
if "post" in reply:
replies.append(_format_post(reply))
# Include nested replies one level deep
for nested in reply.get("replies", []):
if "post" in nested:
replies.append(_format_post(nested))
return {"post": root_post, "replies": replies}
async def _resolve_url_to_uri(url: str) -> str:
"""Convert a bsky.app URL to an AT URI by resolving the handle."""
# https://bsky.app/profile/handle.bsky.social/post/rkey
parts = url.rstrip("/").split("/")
if len(parts) < 6:
raise ValueError(f"Invalid Bluesky URL: {url}")
handle = parts[4] # profile/{handle}
rkey = parts[6] # post/{rkey}
# Resolve handle to DID
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{BSKY_PUBLIC_API}/xrpc/com.atproto.identity.resolveHandle",
params={"handle": handle},
)
resp.raise_for_status()
did = resp.json()["did"]
return f"at://{did}/app.bsky.feed.post/{rkey}"

View File

@@ -0,0 +1,78 @@
"""Hacker News client using the Algolia HN Search API.
No authentication required. Docs: https://hn.algolia.com/api
"""
import httpx
HN_API_BASE = "https://hn.algolia.com/api/v1"
async def search_stories(query: str, limit: int = 25) -> list[dict]:
"""Search HN for stories matching a query.
Returns a list of story dicts with: title, url, author, points,
num_comments, created_at, objectID, story_text.
"""
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{HN_API_BASE}/search",
params={
"query": query,
"tags": "story",
"hitsPerPage": min(limit, 50),
},
)
resp.raise_for_status()
data = resp.json()
results = []
for hit in data.get("hits", []):
results.append(
{
"title": hit.get("title", ""),
"url": hit.get("url", ""),
"author": hit.get("author", ""),
"points": hit.get("points", 0),
"num_comments": hit.get("num_comments", 0),
"created_at": hit.get("created_at", ""),
"object_id": hit.get("objectID", ""),
"story_text": hit.get("story_text") or "",
"hn_url": f"https://news.ycombinator.com/item?id={hit.get('objectID', '')}",
}
)
return results
async def search_comments(query: str, limit: int = 25) -> list[dict]:
"""Search HN for comments matching a query.
Returns a list of comment dicts with: comment_text, author, points,
created_at, story_title, story_url.
"""
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{HN_API_BASE}/search",
params={
"query": query,
"tags": "comment",
"hitsPerPage": min(limit, 50),
},
)
resp.raise_for_status()
data = resp.json()
results = []
for hit in data.get("hits", []):
results.append(
{
"comment_text": hit.get("comment_text", ""),
"author": hit.get("author", ""),
"points": hit.get("points", 0),
"created_at": hit.get("created_at", ""),
"story_title": hit.get("story_title", ""),
"story_url": hit.get("story_url", ""),
"hn_url": f"https://news.ycombinator.com/item?id={hit.get('objectID', '')}",
}
)
return results

View File

@@ -0,0 +1,117 @@
"""Reddit client using the public JSON API.
No authentication required for read-only search. Reddit requires a descriptive
User-Agent header — requests with generic UAs get 429'd.
"""
import httpx
REDDIT_BASE = "https://www.reddit.com"
USER_AGENT = "sentiment-agent/0.1.0 (research; sentiment analysis tool)"
async def search_posts(
query: str,
subreddit: str = "all",
sort: str = "relevance",
time_filter: str = "month",
limit: int = 25,
) -> list[dict]:
"""Search Reddit for posts matching a query.
Args:
query: Search terms.
subreddit: Subreddit to search within, or "all" for site-wide.
sort: One of "relevance", "hot", "top", "new", "comments".
time_filter: One of "hour", "day", "week", "month", "year", "all".
limit: Max results (capped at 100 by Reddit).
Returns:
List of post dicts with: title, selftext, author, score,
num_comments, subreddit, url, permalink, created_utc.
"""
url = f"{REDDIT_BASE}/r/{subreddit}/search.json"
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(
url,
params={
"q": query,
"sort": sort,
"t": time_filter,
"limit": min(limit, 100),
"restrict_sr": "on" if subreddit != "all" else "off",
},
headers={"User-Agent": USER_AGENT},
)
resp.raise_for_status()
data = resp.json()
results = []
for child in data.get("data", {}).get("children", []):
post = child.get("data", {})
results.append(
{
"title": post.get("title", ""),
"selftext": (post.get("selftext") or "")[:2000],
"author": post.get("author", "[deleted]"),
"score": post.get("score", 0),
"upvote_ratio": post.get("upvote_ratio", 0),
"num_comments": post.get("num_comments", 0),
"subreddit": post.get("subreddit", ""),
"url": post.get("url", ""),
"permalink": f"https://reddit.com{post.get('permalink', '')}",
"created_utc": post.get("created_utc", 0),
"is_self": post.get("is_self", False),
}
)
return results
async def get_post_comments(
permalink: str,
sort: str = "top",
limit: int = 25,
) -> list[dict]:
"""Fetch top-level comments for a Reddit post.
Args:
permalink: The post's permalink path (e.g., "/r/python/comments/abc123/title/").
sort: Comment sort order: "top", "best", "new", "controversial".
limit: Max comments to return.
Returns:
List of comment dicts with: body, author, score, created_utc.
"""
# Strip domain if full URL was passed
if permalink.startswith("https://"):
permalink = permalink.replace("https://reddit.com", "")
permalink = permalink.replace("https://www.reddit.com", "")
url = f"{REDDIT_BASE}{permalink}.json"
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(
url,
params={"sort": sort, "limit": limit},
headers={"User-Agent": USER_AGENT},
)
resp.raise_for_status()
data = resp.json()
# Reddit returns [post_listing, comments_listing]
if not isinstance(data, list) or len(data) < 2:
return []
results = []
for child in data[1].get("data", {}).get("children", []):
if child.get("kind") != "t1":
continue
comment = child.get("data", {})
results.append(
{
"body": (comment.get("body") or "")[:2000],
"author": comment.get("author", "[deleted]"),
"score": comment.get("score", 0),
"created_utc": comment.get("created_utc", 0),
}
)
return results

View File

@@ -0,0 +1,70 @@
"""Configuration and safety limits for the sentiment agent.
All guardrails are centralized here so they can be tuned from one place
or overridden via CLI flags / env vars.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
@dataclass(frozen=True)
class RateLimitConfig:
"""Per-platform rate limiting."""
requests_per_minute: int = 10
burst_size: int = 3 # max concurrent requests
cooldown_after_429: float = 30.0 # seconds to wait after a 429
@dataclass(frozen=True)
class SafetyConfig:
"""Top-level safety rails for the agent."""
# --- Agent-level limits ---
max_turns: int = 20
max_budget_usd: float = 0.50 # hard cap on Claude API spend per run
max_total_api_calls: int = 50 # across ALL platforms combined
max_results_per_call: int = 50 # cap the `limit` param sent to any API
# --- Per-platform rate limits ---
bluesky_rate: RateLimitConfig = field(default_factory=lambda: RateLimitConfig(
requests_per_minute=10, burst_size=2,
))
reddit_rate: RateLimitConfig = field(default_factory=lambda: RateLimitConfig(
requests_per_minute=10, burst_size=2,
))
hackernews_rate: RateLimitConfig = field(default_factory=lambda: RateLimitConfig(
requests_per_minute=15, burst_size=3, # HN Algolia is more generous
))
# --- Content size limits ---
max_post_text_chars: int = 2000 # truncate individual posts beyond this
max_total_content_bytes: int = 500_000 # ~500KB total data gathered before agent stops
# --- Timeout ---
api_timeout_seconds: float = 15.0
# --- Credibility thresholds ---
min_credibility_score: float = 0.3 # posts below this are flagged/excluded
flag_bot_threshold: float = 0.5 # posts between min and this are flagged but included
@classmethod
def from_env(cls) -> SafetyConfig:
"""Build config with env var overrides.
Env vars: SENTIMENT_MAX_TURNS, SENTIMENT_MAX_BUDGET_USD,
SENTIMENT_MAX_API_CALLS, SENTIMENT_MIN_CREDIBILITY.
"""
kwargs: dict = {}
if v := os.environ.get("SENTIMENT_MAX_TURNS"):
kwargs["max_turns"] = int(v)
if v := os.environ.get("SENTIMENT_MAX_BUDGET_USD"):
kwargs["max_budget_usd"] = float(v)
if v := os.environ.get("SENTIMENT_MAX_API_CALLS"):
kwargs["max_total_api_calls"] = int(v)
if v := os.environ.get("SENTIMENT_MIN_CREDIBILITY"):
kwargs["min_credibility_score"] = float(v)
return cls(**kwargs)

View File

@@ -0,0 +1,398 @@
"""Credibility scoring and bot/disinfo detection.
Assigns a 0.01.0 credibility score to each post based on heuristic signals.
Posts below the configured threshold are excluded or flagged so they don't
pollute the sentiment analysis.
Signals are platform-aware — each platform has different indicators of
inauthentic behavior.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
@dataclass
class CredibilityResult:
"""Credibility assessment for a single post."""
score: float # 0.0 (likely bot/disinfo) to 1.0 (likely authentic)
flags: list[str] = field(default_factory=list) # human-readable reasons
is_excluded: bool = False # below min_credibility_score
is_flagged: bool = False # between min and flag threshold
@property
def label(self) -> str:
if self.score >= 0.7:
return "likely_authentic"
if self.score >= 0.5:
return "uncertain"
if self.score >= 0.3:
return "suspicious"
return "likely_inauthentic"
# --- Shared heuristics ---
# Common bot patterns in text
_BOT_TEXT_PATTERNS = [
# Crypto/scam spam
re.compile(r"(?i)(dm me|check my bio|link in bio|click here|free giveaway)"),
re.compile(r"(?i)(join my|subscribe to|follow me for|🔥.*🔥.*🔥)"),
# Astroturfing phrases
re.compile(r"(?i)(i (just )?(discovered|found|tried) this (amazing|incredible|awesome))"),
re.compile(r"(?i)(game.?changer|life.?changing|you won'?t believe)"),
# Excessive hashtags (5+)
re.compile(r"(#\w+\s*){5,}"),
# Walls of emojis (10+ consecutive)
re.compile(r"[\U0001F300-\U0001FAFF]{10,}"),
# Repetitive characters (spammy emphasis)
re.compile(r"(.)\1{9,}"),
]
# Coordinated campaign indicators: identical or near-identical text
# This is checked at the batch level, not per-post
def _check_text_patterns(text: str) -> list[str]:
"""Check text against common bot/spam patterns."""
flags = []
for pattern in _BOT_TEXT_PATTERNS:
if pattern.search(text):
flags.append(f"bot_text_pattern: {pattern.pattern[:60]}")
if len(text) < 15:
flags.append("very_short_text")
return flags
def _engagement_ratio_score(
likes: int, reposts: int, replies: int
) -> tuple[float, list[str]]:
"""Score based on engagement ratios.
Authentic posts tend to have a mix of likes, replies, and reposts.
Bot-amplified posts often have inflated likes with very few replies,
or massive repost counts with no discussion.
"""
flags = []
total = likes + reposts + replies
if total == 0:
return 0.5, ["no_engagement"]
# High repost-to-reply ratio suggests amplification without discussion
if reposts > 0 and replies == 0 and reposts > 10:
flags.append(f"high_repost_no_replies: {reposts} reposts, 0 replies")
return 0.3, flags
# Extremely high like count with zero replies is suspicious
if likes > 100 and replies == 0:
flags.append(f"high_likes_no_replies: {likes} likes, 0 replies")
return 0.4, flags
# Normal engagement
return min(1.0, 0.5 + (replies / max(total, 1)) * 0.5), flags
# --- Platform-specific scoring ---
def score_bluesky_post(post: dict) -> CredibilityResult:
"""Score a Bluesky post for credibility."""
score = 1.0
flags: list[str] = []
text = post.get("text", "")
handle = post.get("author_handle", "")
display_name = post.get("author_display_name", "")
likes = post.get("like_count", 0)
reposts = post.get("repost_count", 0)
replies = post.get("reply_count", 0)
# Text pattern checks
text_flags = _check_text_patterns(text)
if text_flags:
score -= 0.15 * len(text_flags)
flags.extend(text_flags)
# Handle heuristics
# Randomly generated handles (long hex/number strings)
if re.match(r"^[a-f0-9]{8,}\.", handle):
flags.append(f"random_handle: {handle}")
score -= 0.3
# No display name set
if not display_name or display_name == handle:
flags.append("no_display_name")
score -= 0.1
# Engagement ratio
eng_score, eng_flags = _engagement_ratio_score(likes, reposts, replies)
flags.extend(eng_flags)
score = score * 0.6 + eng_score * 0.4
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
def score_reddit_post(post: dict) -> CredibilityResult:
"""Score a Reddit post for credibility."""
score = 1.0
flags: list[str] = []
text = post.get("selftext", "") or post.get("title", "")
author = post.get("author", "")
upvote_ratio = post.get("upvote_ratio", 0.5)
post_score = post.get("score", 0)
num_comments = post.get("num_comments", 0)
# Text patterns
text_flags = _check_text_patterns(text)
if text_flags:
score -= 0.15 * len(text_flags)
flags.extend(text_flags)
# Deleted author
if author in ("[deleted]", "[removed]"):
flags.append("deleted_author")
score -= 0.2
# Suspicious username patterns (random alphanumeric + numbers)
if re.match(r"^[A-Za-z]+[-_]?\d{4,}$", author):
flags.append(f"auto_generated_username: {author}")
score -= 0.15
# Very controversial ratio (lots of up AND down votes)
if upvote_ratio < 0.4 and post_score > 0:
flags.append(f"highly_controversial: {upvote_ratio:.0%} upvote ratio")
score -= 0.1
# High score but zero comments = potential vote manipulation
if post_score > 100 and num_comments == 0:
flags.append(f"high_score_no_comments: {post_score} score, 0 comments")
score -= 0.2
# Low-effort cross-post spam: very short title, external link, no selftext
if (
len(post.get("title", "")) < 20
and not post.get("is_self", True)
and not post.get("selftext")
):
flags.append("possible_link_spam")
score -= 0.1
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
def score_reddit_comment(comment: dict) -> CredibilityResult:
"""Score a Reddit comment for credibility."""
score = 1.0
flags: list[str] = []
body = comment.get("body", "")
author = comment.get("author", "")
comment_score = comment.get("score", 0)
text_flags = _check_text_patterns(body)
if text_flags:
score -= 0.15 * len(text_flags)
flags.extend(text_flags)
if author in ("[deleted]", "[removed]"):
flags.append("deleted_author")
score -= 0.2
if re.match(r"^[A-Za-z]+[-_]?\d{4,}$", author):
flags.append(f"auto_generated_username: {author}")
score -= 0.15
# Heavily downvoted
if comment_score < -5:
flags.append(f"heavily_downvoted: {comment_score}")
score -= 0.15
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
def score_hackernews_post(post: dict) -> CredibilityResult:
"""Score a HN story for credibility.
HN is generally higher-signal than social media, but we still check
for low-effort submissions and spammy patterns.
"""
score = 1.0
flags: list[str] = []
title = post.get("title", "")
text = post.get("story_text", "") or title
points = post.get("points", 0)
num_comments = post.get("num_comments", 0)
text_flags = _check_text_patterns(text)
if text_flags:
score -= 0.1 * len(text_flags)
flags.extend(text_flags)
# Zero points = the community didn't find it valuable
if points == 0:
flags.append("zero_points")
score -= 0.1
# HN is generally more credible, start with a bonus
score = min(1.0, score + 0.1)
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
def score_hackernews_comment(comment: dict) -> CredibilityResult:
"""Score a HN comment for credibility."""
score = 1.0
flags: list[str] = []
text = comment.get("comment_text", "")
text_flags = _check_text_patterns(text)
if text_flags:
score -= 0.1 * len(text_flags)
flags.extend(text_flags)
# HN comments are generally higher quality
score = min(1.0, score + 0.1)
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
# --- Batch-level coordination detection ---
def detect_coordination(posts: list[dict], text_key: str = "text") -> list[str]:
"""Detect coordinated inauthentic behavior across a batch of posts.
Looks for:
- Duplicate or near-duplicate text (copy-paste campaigns)
- Burst posting (many posts in a very short window)
- Same talking points with minor variations
Returns a list of warning strings.
"""
warnings: list[str] = []
texts = [p.get(text_key, "") for p in posts if p.get(text_key)]
if not texts:
return warnings
# Exact duplicates
seen: dict[str, int] = {}
for t in texts:
normalized = t.strip().lower()
seen[normalized] = seen.get(normalized, 0) + 1
duplicates = {text: count for text, count in seen.items() if count > 1}
if duplicates:
total_dupes = sum(duplicates.values())
warnings.append(
f"COORDINATION WARNING: {len(duplicates)} duplicate texts found "
f"({total_dupes} total copies). Possible copy-paste campaign."
)
# Near-duplicates: check if many posts share a long common substring
# (simplified: check if >30% of posts start with the same 50+ chars)
if len(texts) >= 5:
prefixes: dict[str, int] = {}
for t in texts:
prefix = t.strip().lower()[:80]
if len(prefix) >= 50:
prefixes[prefix] = prefixes.get(prefix, 0) + 1
for prefix, count in prefixes.items():
if count >= len(texts) * 0.3:
warnings.append(
f"COORDINATION WARNING: {count}/{len(texts)} posts share "
f"a common prefix ({prefix[:50]}...). Possible template campaign."
)
# Burst detection: if timestamps are available
timestamps = []
for p in posts:
created = p.get("created_at") or p.get("created_utc")
if isinstance(created, str):
try:
timestamps.append(datetime.fromisoformat(created.replace("Z", "+00:00")))
except (ValueError, TypeError):
pass
elif isinstance(created, (int, float)):
timestamps.append(datetime.fromtimestamp(created, tz=timezone.utc))
if len(timestamps) >= 5:
timestamps.sort()
# Check if >50% of posts landed within a 5-minute window
window_seconds = 300
for i in range(len(timestamps) - 2):
window_end = timestamps[i] + __import__("datetime").timedelta(seconds=window_seconds)
in_window = sum(1 for t in timestamps if timestamps[i] <= t <= window_end)
if in_window >= len(timestamps) * 0.5:
warnings.append(
f"COORDINATION WARNING: {in_window}/{len(timestamps)} posts "
f"appeared within a 5-minute window. Possible coordinated posting."
)
break
return warnings
def filter_and_annotate(
posts: list[dict],
scorer,
min_score: float = 0.3,
flag_threshold: float = 0.5,
) -> tuple[list[dict], dict]:
"""Score all posts, filter out low-credibility ones, and annotate the rest.
Args:
posts: List of post dicts from any platform.
scorer: A scoring function (e.g., score_reddit_post).
min_score: Posts below this are excluded.
flag_threshold: Posts between min_score and this are flagged.
Returns:
Tuple of (filtered_posts, stats_dict).
Each post in filtered_posts gets a "_credibility" key added.
"""
filtered = []
stats = {
"total": len(posts),
"excluded": 0,
"flagged": 0,
"authentic": 0,
"excluded_reasons": [],
}
for post in posts:
result = scorer(post)
result.is_excluded = result.score < min_score
result.is_flagged = min_score <= result.score < flag_threshold
if result.is_excluded:
stats["excluded"] += 1
stats["excluded_reasons"].append(
{"score": round(result.score, 2), "flags": result.flags}
)
continue
post["_credibility"] = {
"score": round(result.score, 2),
"label": result.label,
"flags": result.flags,
"is_flagged": result.is_flagged,
}
if result.is_flagged:
stats["flagged"] += 1
else:
stats["authentic"] += 1
filtered.append(post)
return filtered, stats

View File

@@ -0,0 +1,66 @@
"""CLI entry point for the sentiment analysis agent."""
import argparse
import anyio
from sentiment_agent.agent import run_sentiment_analysis
from sentiment_agent.config import SafetyConfig
async def async_main(args: argparse.Namespace) -> None:
config = SafetyConfig(
max_turns=args.max_turns,
max_budget_usd=args.max_budget,
max_total_api_calls=args.max_api_calls,
min_credibility_score=args.min_credibility,
flag_bot_threshold=args.flag_threshold,
)
result = await run_sentiment_analysis(
topic=args.topic,
sources=args.sources,
config=config,
)
print("\n" + "=" * 60)
print("SENTIMENT ANALYSIS REPORT")
print("=" * 60)
print(result)
def main() -> None:
parser = argparse.ArgumentParser(
description="Run sentiment analysis on a topic with bot/disinfo detection",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("topic", help="The topic to analyze sentiment for")
parser.add_argument(
"--sources", nargs="*", help="Specific URLs or sources to also analyze"
)
safety = parser.add_argument_group("safety limits")
safety.add_argument(
"--max-turns", type=int, default=20, help="Max agent turns"
)
safety.add_argument(
"--max-budget", type=float, default=0.50, help="Max Claude API spend (USD)"
)
safety.add_argument(
"--max-api-calls", type=int, default=50, help="Max total API calls across all platforms"
)
credibility = parser.add_argument_group("credibility filtering")
credibility.add_argument(
"--min-credibility", type=float, default=0.3,
help="Posts below this score are excluded (0.0-1.0)",
)
credibility.add_argument(
"--flag-threshold", type=float, default=0.5,
help="Posts between min and this are flagged but included (0.0-1.0)",
)
args = parser.parse_args()
anyio.run(async_main, args)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,169 @@
"""Rate limiter and API call budget tracker.
Enforces per-platform rate limits and a global call budget so the agent
can't hammer APIs or run up unbounded costs.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from sentiment_agent.config import RateLimitConfig
class BudgetExhaustedError(Exception):
"""Raised when the global API call budget is spent."""
class RateLimitExceededError(Exception):
"""Raised when a platform's rate limit is hit and cooldown hasn't elapsed."""
@dataclass
class _PlatformState:
"""Tracks call timestamps and active request count for one platform."""
config: RateLimitConfig
call_timestamps: list[float] = field(default_factory=list)
active_requests: int = 0
last_429_at: float = 0.0
class RateLimiter:
"""Manages rate limiting across all platforms + a global call budget.
Usage:
limiter = RateLimiter(max_total_calls=50)
limiter.register_platform("reddit", RateLimitConfig(...))
async with limiter.acquire("reddit"):
await do_reddit_call()
"""
def __init__(self, max_total_calls: int = 50):
self._max_total = max_total_calls
self._total_calls = 0
self._platforms: dict[str, _PlatformState] = {}
self._lock = asyncio.Lock()
@property
def total_calls(self) -> int:
return self._total_calls
@property
def remaining_calls(self) -> int:
return max(0, self._max_total - self._total_calls)
def register_platform(self, name: str, config: RateLimitConfig) -> None:
self._platforms[name] = _PlatformState(config=config)
def acquire(self, platform: str) -> _AcquireContext:
"""Context manager that enforces rate limits before allowing a call."""
return _AcquireContext(self, platform)
async def _acquire(self, platform: str) -> None:
async with self._lock:
if self._total_calls >= self._max_total:
raise BudgetExhaustedError(
f"Global API call budget exhausted ({self._max_total} calls). "
"Increase max_total_api_calls in SafetyConfig to allow more."
)
state = self._platforms.get(platform)
if not state:
raise ValueError(f"Platform '{platform}' not registered with rate limiter")
now = time.monotonic()
# Check 429 cooldown
if state.last_429_at:
elapsed = now - state.last_429_at
if elapsed < state.config.cooldown_after_429:
remaining = state.config.cooldown_after_429 - elapsed
raise RateLimitExceededError(
f"Platform '{platform}' is in cooldown after 429. "
f"Try again in {remaining:.0f}s."
)
state.last_429_at = 0.0
# Check burst limit
if state.active_requests >= state.config.burst_size:
raise RateLimitExceededError(
f"Platform '{platform}' burst limit reached "
f"({state.config.burst_size} concurrent). Wait for a request to finish."
)
# Check RPM: discard timestamps older than 60s, then check count
cutoff = now - 60.0
state.call_timestamps = [t for t in state.call_timestamps if t > cutoff]
if len(state.call_timestamps) >= state.config.requests_per_minute:
oldest = state.call_timestamps[0]
wait_time = 60.0 - (now - oldest)
raise RateLimitExceededError(
f"Platform '{platform}' rate limit: {state.config.requests_per_minute}/min. "
f"Try again in {wait_time:.0f}s."
)
# All clear — record the call
state.call_timestamps.append(now)
state.active_requests += 1
self._total_calls += 1
async def _release(self, platform: str) -> None:
async with self._lock:
state = self._platforms.get(platform)
if state:
state.active_requests = max(0, state.active_requests - 1)
def record_429(self, platform: str) -> None:
"""Call this when an API returns 429 to trigger cooldown."""
state = self._platforms.get(platform)
if state:
state.last_429_at = time.monotonic()
def get_stats(self) -> dict:
"""Return current usage stats for logging/reporting."""
stats: dict = {
"total_calls": self._total_calls,
"remaining_calls": self.remaining_calls,
"platforms": {},
}
for name, state in self._platforms.items():
now = time.monotonic()
cutoff = now - 60.0
recent = [t for t in state.call_timestamps if t > cutoff]
stats["platforms"][name] = {
"calls_last_60s": len(recent),
"active_requests": state.active_requests,
"rpm_limit": state.config.requests_per_minute,
"in_cooldown": bool(
state.last_429_at
and (now - state.last_429_at) < state.config.cooldown_after_429
),
}
return stats
class _AcquireContext:
"""Async context manager for rate-limited API calls."""
def __init__(self, limiter: RateLimiter, platform: str):
self._limiter = limiter
self._platform = platform
async def __aenter__(self) -> None:
await self._limiter._acquire(self._platform)
async def __aexit__(self, *exc_info) -> None:
# Check if the call got a 429
if exc_info[0] is not None:
import httpx
exc = exc_info[1]
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 429:
self._limiter.record_429(self._platform)
await self._limiter._release(self._platform)

View File

@@ -0,0 +1,352 @@
"""Custom MCP tools for social media and forum data gathering.
Each tool wraps an API client, enforces rate limits, runs credibility
scoring, and returns MCP-formatted results with bot/disinfo annotations.
"""
from __future__ import annotations
import json
import traceback
from claude_agent_sdk import tool, create_sdk_mcp_server
from sentiment_agent.clients import bluesky, reddit, hackernews
from sentiment_agent.config import SafetyConfig
from sentiment_agent.credibility import (
detect_coordination,
filter_and_annotate,
score_bluesky_post,
score_hackernews_comment,
score_hackernews_post,
score_reddit_comment,
score_reddit_post,
)
from sentiment_agent.ratelimit import BudgetExhaustedError, RateLimiter
# Module-level state — initialized by create_social_tools_server()
_limiter: RateLimiter | None = None
_config: SafetyConfig | None = None
def _get_limiter() -> RateLimiter:
if _limiter is None:
raise RuntimeError("Tools not initialized — call create_social_tools_server() first")
return _limiter
def _get_config() -> SafetyConfig:
if _config is None:
return SafetyConfig()
return _config
def _text_result(text: str) -> dict:
return {"content": [{"type": "text", "text": text}]}
def _error_result(error: str) -> dict:
return {"content": [{"type": "text", "text": f"Error: {error}"}], "isError": True}
def _clamp_limit(requested: int) -> int:
"""Enforce max results per call."""
return min(requested, _get_config().max_results_per_call)
def _format_with_stats(
posts: list[dict],
stats: dict,
coordination_warnings: list[str],
platform: str,
) -> str:
"""Format results with credibility stats prepended."""
header_parts = [
f"Platform: {platform}",
f"Results: {stats['authentic']} authentic, {stats['flagged']} flagged, "
f"{stats['excluded']} excluded (of {stats['total']} total)",
]
if coordination_warnings:
header_parts.append("--- COORDINATION ALERTS ---")
header_parts.extend(coordination_warnings)
header_parts.append("---")
limiter = _get_limiter()
header_parts.append(f"API budget remaining: {limiter.remaining_calls} calls")
header = "\n".join(header_parts)
body = json.dumps(posts, indent=2, default=str)
return f"{header}\n\n{body}"
# --- Bluesky tools ---
@tool(
"search_bluesky",
"Search Bluesky for posts about a topic. Returns posts with text, author, "
"engagement metrics, credibility scores, and bot/disinfo flags. "
"Requires BLUESKY_HANDLE and BLUESKY_APP_PASSWORD env vars.",
{"query": str, "limit": int, "sort": str},
)
async def search_bluesky(args: dict) -> dict:
try:
limiter = _get_limiter()
config = _get_config()
async with limiter.acquire("bluesky"):
posts = await bluesky.search_posts(
query=args["query"],
limit=_clamp_limit(args.get("limit", 25)),
sort=args.get("sort", "top"),
)
if not posts:
return _text_result(f"No Bluesky posts found for: {args['query']}")
coordination = detect_coordination(posts, text_key="text")
filtered, stats = filter_and_annotate(
posts, score_bluesky_post,
min_score=config.min_credibility_score,
flag_threshold=config.flag_bot_threshold,
)
return _text_result(_format_with_stats(filtered, stats, coordination, "Bluesky"))
except BudgetExhaustedError as e:
return _error_result(str(e))
except Exception as e:
return _error_result(f"Bluesky search failed: {e}\n{traceback.format_exc()}")
@tool(
"get_bluesky_thread",
"Fetch a Bluesky thread/post and its replies with credibility scoring. "
"Accepts an at:// URI or https://bsky.app/... URL.",
{"uri": str, "depth": int},
)
async def get_bluesky_thread(args: dict) -> dict:
try:
limiter = _get_limiter()
config = _get_config()
async with limiter.acquire("bluesky"):
thread = await bluesky.get_thread(
uri=args["uri"],
depth=args.get("depth", 6),
)
# Score replies
if thread.get("replies"):
coordination = detect_coordination(thread["replies"], text_key="text")
filtered_replies, stats = filter_and_annotate(
thread["replies"], score_bluesky_post,
min_score=config.min_credibility_score,
flag_threshold=config.flag_bot_threshold,
)
thread["replies"] = filtered_replies
thread["_reply_credibility_stats"] = stats
thread["_coordination_warnings"] = coordination
# Score root post
if thread.get("post"):
result = score_bluesky_post(thread["post"])
thread["post"]["_credibility"] = {
"score": round(result.score, 2),
"label": result.label,
"flags": result.flags,
}
return _text_result(json.dumps(thread, indent=2, default=str))
except BudgetExhaustedError as e:
return _error_result(str(e))
except Exception as e:
return _error_result(f"Bluesky thread fetch failed: {e}\n{traceback.format_exc()}")
# --- Reddit tools ---
@tool(
"search_reddit",
"Search Reddit for posts about a topic. Returns posts with credibility scores "
"and bot/disinfo flags. Posts below the credibility threshold are auto-excluded. "
"Use subreddit='all' for site-wide or specify a subreddit name.",
{"query": str, "subreddit": str, "sort": str, "time_filter": str, "limit": int},
)
async def search_reddit_tool(args: dict) -> dict:
try:
limiter = _get_limiter()
config = _get_config()
async with limiter.acquire("reddit"):
posts = await reddit.search_posts(
query=args["query"],
subreddit=args.get("subreddit", "all"),
sort=args.get("sort", "relevance"),
time_filter=args.get("time_filter", "month"),
limit=_clamp_limit(args.get("limit", 25)),
)
if not posts:
return _text_result(f"No Reddit posts found for: {args['query']}")
coordination = detect_coordination(posts, text_key="title")
filtered, stats = filter_and_annotate(
posts, score_reddit_post,
min_score=config.min_credibility_score,
flag_threshold=config.flag_bot_threshold,
)
return _text_result(_format_with_stats(filtered, stats, coordination, "Reddit"))
except BudgetExhaustedError as e:
return _error_result(str(e))
except Exception as e:
return _error_result(f"Reddit search failed: {e}\n{traceback.format_exc()}")
@tool(
"get_reddit_comments",
"Fetch comments for a Reddit post with credibility scoring. "
"Pass the permalink path or full URL.",
{"permalink": str, "sort": str, "limit": int},
)
async def get_reddit_comments(args: dict) -> dict:
try:
limiter = _get_limiter()
config = _get_config()
async with limiter.acquire("reddit"):
comments = await reddit.get_post_comments(
permalink=args["permalink"],
sort=args.get("sort", "top"),
limit=_clamp_limit(args.get("limit", 25)),
)
if not comments:
return _text_result("No comments found for this post.")
coordination = detect_coordination(comments, text_key="body")
filtered, stats = filter_and_annotate(
comments, score_reddit_comment,
min_score=config.min_credibility_score,
flag_threshold=config.flag_bot_threshold,
)
return _text_result(_format_with_stats(filtered, stats, coordination, "Reddit Comments"))
except BudgetExhaustedError as e:
return _error_result(str(e))
except Exception as e:
return _error_result(f"Reddit comments fetch failed: {e}\n{traceback.format_exc()}")
# --- Hacker News tools ---
@tool(
"search_hackernews",
"Search Hacker News for stories with credibility scoring. "
"No authentication required.",
{"query": str, "limit": int},
)
async def search_hackernews_tool(args: dict) -> dict:
try:
limiter = _get_limiter()
config = _get_config()
async with limiter.acquire("hackernews"):
stories = await hackernews.search_stories(
query=args["query"],
limit=_clamp_limit(args.get("limit", 25)),
)
if not stories:
return _text_result(f"No HN stories found for: {args['query']}")
coordination = detect_coordination(stories, text_key="title")
filtered, stats = filter_and_annotate(
stories, score_hackernews_post,
min_score=config.min_credibility_score,
flag_threshold=config.flag_bot_threshold,
)
return _text_result(_format_with_stats(filtered, stats, coordination, "Hacker News"))
except BudgetExhaustedError as e:
return _error_result(str(e))
except Exception as e:
return _error_result(f"HN search failed: {e}\n{traceback.format_exc()}")
@tool(
"search_hackernews_comments",
"Search Hacker News comments for opinions and discussions with credibility scoring.",
{"query": str, "limit": int},
)
async def search_hackernews_comments(args: dict) -> dict:
try:
limiter = _get_limiter()
config = _get_config()
async with limiter.acquire("hackernews"):
comments = await hackernews.search_comments(
query=args["query"],
limit=_clamp_limit(args.get("limit", 25)),
)
if not comments:
return _text_result(f"No HN comments found for: {args['query']}")
coordination = detect_coordination(comments, text_key="comment_text")
filtered, stats = filter_and_annotate(
comments, score_hackernews_comment,
min_score=config.min_credibility_score,
flag_threshold=config.flag_bot_threshold,
)
return _text_result(
_format_with_stats(filtered, stats, coordination, "HN Comments")
)
except BudgetExhaustedError as e:
return _error_result(str(e))
except Exception as e:
return _error_result(f"HN comment search failed: {e}\n{traceback.format_exc()}")
# --- Budget status tool ---
@tool(
"get_api_budget_status",
"Check remaining API call budget, rate limit status, and per-platform stats. "
"Use this before making more API calls to avoid hitting limits.",
{},
)
async def get_api_budget_status(args: dict) -> dict:
limiter = _get_limiter()
stats = limiter.get_stats()
return _text_result(json.dumps(stats, indent=2, default=str))
# --- Server factory ---
def create_social_tools_server(config: SafetyConfig | None = None):
"""Create an MCP server with all social media/forum tools.
Initializes rate limiting and credibility thresholds from config.
"""
global _limiter, _config
_config = config or SafetyConfig.from_env()
_limiter = RateLimiter(max_total_calls=_config.max_total_api_calls)
_limiter.register_platform("bluesky", _config.bluesky_rate)
_limiter.register_platform("reddit", _config.reddit_rate)
_limiter.register_platform("hackernews", _config.hackernews_rate)
return create_sdk_mcp_server(
"social-tools",
tools=[
search_bluesky,
get_bluesky_thread,
search_reddit_tool,
get_reddit_comments,
search_hackernews_tool,
search_hackernews_comments,
get_api_budget_status,
],
)

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.3.0
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.3.0" "$(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.3.0
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.3.0" "$(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.3.0
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.3.0" "$(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 \ libzbar0 \
libjpeg-dev \ libjpeg-dev \
zlib1g-dev \ zlib1g-dev \
curl \
openssl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install ALL dependencies (slow path) # Install ALL dependencies (slow path)
RUN pip install --no-cache-dir \ RUN pip install --no-cache-dir \
cython numpy scipy>=1.10.0 jpegio>=0.2.0 \ cython numpy scipy>=1.10.0 jpegio>=0.2.0 \
argon2-cffi>=23.0.0 pillow>=10.0.0 cryptography>=41.0.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 \ flask>=3.0.0 gunicorn>=21.0.0 \
fastapi>=0.100.0 "uvicorn[standard]>=0.20.0" python-multipart>=0.0.6 \ 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 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 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 application files (this is all that rebuilds normally!)
COPY src/ src/ COPY src/ src/
COPY data/ data/ COPY data/ data/
@@ -66,6 +75,10 @@ COPY frontends/web/ frontends/web/
# temp_files is for multi-worker temp file sharing # 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 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 # Create non-root user
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads
USER stego USER stego
@@ -77,12 +90,12 @@ ENV PYTHONPATH=/app/src
EXPOSE 5000 EXPOSE 5000
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/')" || exit 1 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 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 # API stage - REST API

View File

@@ -32,7 +32,9 @@ RUN pip install --no-cache-dir \
jpegio>=0.2.0 \ jpegio>=0.2.0 \
argon2-cffi>=23.0.0 \ argon2-cffi>=23.0.0 \
pillow>=10.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) # Install web/api framework packages (also stable)
RUN pip install --no-cache-dir \ RUN pip install --no-cache-dir \
@@ -47,9 +49,9 @@ RUN pip install --no-cache-dir \
lz4>=4.0.0 lz4>=4.0.0
# Verify key packages work # 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 for tracking
LABEL org.opencontainers.image.title="Stegasoo Base" LABEL org.opencontainers.image.title="Stegasoo Base"
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo" 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: web:
build: build:
context: . context: ..
dockerfile: docker/Dockerfile
target: web target: web
container_name: stegasoo-web container_name: stegasoo-web
ports: ports:
@@ -18,7 +19,9 @@ services:
FLASK_ENV: production FLASK_ENV: production
# Authentication (v4.0.2) # Authentication (v4.0.2)
STEGASOO_AUTH_ENABLED: ${STEGASOO_AUTH_ENABLED:-true} 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} STEGASOO_HOSTNAME: ${STEGASOO_HOSTNAME:-localhost}
volumes: volumes:
# Persist auth database and SSL certs (v4.0.2) # Persist auth database and SSL certs (v4.0.2)
@@ -37,7 +40,8 @@ services:
# ============================================================================ # ============================================================================
api: api:
build: build:
context: . context: ..
dockerfile: docker/Dockerfile
target: api target: api
container_name: stegasoo-api container_name: stegasoo-api
ports: ports:

224
docs/CLAUDE_WORKTREES.md Normal file
View File

@@ -0,0 +1,224 @@
# Using Claude Code with Git Worktrees — A Stegasoo Guide
## What is a worktree?
A git worktree is a second (or third, or fourth...) copy of your repo that shares the same `.git` history but lives in its own folder with its own branch. Think of it like opening the same project in a parallel universe — you can hack on a feature in one worktree while keeping `main` pristine in another.
Claude Code has built-in worktree support, so you don't need to memorize any git commands.
## Why bother?
- **Safety net**: Your `main` branch stays untouched. If Claude's changes go sideways, just delete the worktree — zero damage.
- **Easy A/B comparison**: Keep the original code open in one editor tab, Claude's changes in another.
- **Parallel work**: You can keep working in `main` while Claude tinkers in a worktree.
- **Clean PRs**: The worktree branch becomes your PR branch with no stray changes mixed in.
## The 30-second version
1. Ask Claude to work in a worktree
2. Claude creates an isolated copy and works there
3. When done, you either merge or throw it away
That's it. Everything below is details.
---
## How to start a worktree session
### Option A: Ask Claude directly
Just tell Claude you want to work in a worktree:
```
> Let's work in a worktree for this
> Start a worktree called "dct-refactor"
> Can you make these changes in an isolated worktree?
```
Claude will use `EnterWorktree` behind the scenes and switch into it automatically.
### Option B: Use the slash command
```
> /worktree
```
This drops you into a fresh worktree immediately.
### Option C: Tell Claude to launch an agent in a worktree
If you want Claude to do something in the background without touching your working directory:
```
> Run the tests in a worktree so we don't mess up my local state
```
Claude can spin up a sub-agent with `isolation: "worktree"` — it gets its own copy and reports back.
---
## Where do worktrees live?
Claude puts them in:
```
.claude/worktrees/<name>/
```
This directory is inside your repo but ignored by git, so it won't pollute your commits.
## What happens inside a worktree?
The worktree is a full checkout of your repo on a new branch. Claude's working directory switches to it, so all file reads, edits, and commands happen there — not in your main checkout.
**Important for Stegasoo**: The first thing you (or Claude) should do in a fresh worktree is:
```bash
pip install -e ".[dev]"
```
This points your editable install at the worktree's source code instead of your main checkout. Without this, `pytest` will test the wrong copy of the code.
---
## Real-world examples
### Example 1: Feature work
```
You: I want to add lz4 as a default compression option. Let's use a worktree.
Claude: *creates worktree, switches to it*
Claude: *installs dev deps, makes changes, runs tests*
Claude: All tests pass. Ready to merge or open a PR.
You: Looks good, make a PR.
Claude: *pushes branch, creates PR*
```
### Example 2: Risky refactor
```
You: Refactor the crypto module to split KDF logic into its own file.
Do it in a worktree so I can review before touching main.
Claude: *creates worktree "refactor/split-kdf"*
Claude: *does the refactor, runs tests*
You: Hmm, I don't love the approach. Throw it away.
Claude: *removes worktree — main is untouched*
```
### Example 3: Investigate a bug without side effects
```
You: Something's wrong with DCT encoding on large images.
Can you investigate in a worktree? I've got uncommitted work here.
Claude: *creates worktree, adds debug logging, runs tests*
Claude: Found it — the block size calculation overflows at >16MP.
Here's the fix. Want me to apply it to main?
```
---
## When to use a worktree vs. just editing in place
| Situation | Worktree? | Why |
|-----------|-----------|-----|
| Quick one-file fix | No | Overkill — just edit directly |
| Multi-file refactor | Yes | Easy to discard if it goes wrong |
| Touching security-critical code (`crypto.py`, `steganography.py`, etc.) | Yes | Extra safety for sensitive changes |
| Experimental / "let's try this" work | Yes | Zero-cost throwaway |
| You have uncommitted changes you don't want to stash | Yes | Worktree won't touch your working tree |
| Adding a single test | No | Low risk, just do it |
---
## Cleaning up
### If you merged or created a PR
The worktree served its purpose. Clean up:
```bash
git worktree remove .claude/worktrees/<name>
```
Or ask Claude:
```
> Clean up the worktree
```
### If you want to throw everything away
Same command — removing the worktree deletes the directory and its branch reference. Your `main` branch is completely unaffected.
### If Claude's session ends
When a Claude Code session ends while in a worktree, you'll be prompted to keep or remove it. If you keep it, you can resume later:
```bash
cd .claude/worktrees/<name>
# pick up where you left off
```
---
## Branch naming in worktrees
Follow the same conventions as the rest of the project:
| Type | Branch name | Example |
|------|-------------|---------|
| Feature | `feature/description` | `feature/batch-progress-bars` |
| Bug fix | `fix/description` | `fix/dct-overflow-large-images` |
| Docs | `docs/description` | `docs/api-examples` |
| Refactor | `refactor/description` | `refactor/split-crypto-module` |
When Claude creates a worktree automatically, it generates a random branch name. You can rename it before pushing:
```bash
git branch -m <old-name> feature/my-better-name
```
---
## Troubleshooting
### "I ran pytest but it's testing the old code"
You forgot to install in the worktree:
```bash
pip install -e ".[dev]"
```
### "I can't find my worktree"
```bash
git worktree list
```
This shows all worktrees and their paths.
### "I accidentally deleted the worktree folder without removing it from git"
```bash
git worktree prune
```
This cleans up stale worktree references.
### "I want to switch back to my main checkout"
If you're in a Claude Code session that entered a worktree, the session stays in the worktree until it ends. Start a new session to go back to your main checkout, or:
```bash
cd /home/alee/Sources/stegasoo
```
---
## TL;DR
1. Say "use a worktree" when asking Claude to make changes
2. Claude works in an isolated copy — your `main` is safe
3. Merge the good stuff, trash the bad stuff
4. Never think about it again until next time

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 - `use_pin` - checkbox
- `pin_length` - PIN digits (6-9) - `pin_length` - PIN digits (6-9)
- `use_rsa` - checkbox - `use_rsa` - checkbox
- `rsa_bits` - key size (2048/3072/4096) - `rsa_bits` - key size (2048/3072)
**Output panels:** **Output panels:**
- Passphrase display - Passphrase display

418
docs/stegasoo.1 Normal file
View File

@@ -0,0 +1,418 @@
.\" Stegasoo man page
.\" Generate with: groff -man -Tascii stegasoo.1
.TH STEGASOO 1 "February 2026" "Stegasoo 4.3.0" "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 and audio using PIN + passphrase security.
It uses LSB (Least Significant Bit) steganography with optional DCT
(Discrete Cosine Transform) encoding for JPEG resilience, and supports
audio steganography with LSB and Spread Spectrum modes.
.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 audio\-encode
Encode a message or file into an audio file.
.PP
.B stegasoo audio\-encode
.I audio
.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 audio 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 \-\-mode " " [\fIlsb\fR|\fIspread\fR]
Embedding mode: lsb (default) or spread (spread spectrum).
.PP
.B Examples:
.nf
stegasoo audio-encode song.wav -r ref.jpg -m "Secret" --passphrase --pin
stegasoo audio-encode podcast.mp3 -r ref.jpg -f doc.pdf --mode spread
.fi
.SS audio\-decode
Decode a message or file from a stego audio file.
.PP
.B stegasoo audio\-decode
.I audio
.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 audio-decode stego.wav -r ref.jpg --passphrase --pin
stegasoo audio-decode stego.wav -r ref.jpg -o ./extracted/
.fi
.SS audio\-info
Display audio file information and steganographic capacity.
.PP
.B stegasoo audio\-info
.I audio
[\fB\-\-json\fR]
.PP
Shows format, sample rate, channels, bit depth, duration, and embedding
capacity for both LSB and Spread Spectrum modes.
.PP
.B Examples:
.nf
stegasoo audio-info song.wav
stegasoo audio-info podcast.mp3 --json
.fi
.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

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

@@ -0,0 +1,257 @@
"""
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 fastapi import 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 (OSError, json.JSONDecodeError):
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: str | None = 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: str | None = Security(API_KEY_HEADER)) -> str | None:
"""
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

File diff suppressed because it is too large Load Diff

View File

View File

@@ -120,6 +120,7 @@ try:
from stegasoo.qr_utils import ( # noqa: F401 from stegasoo.qr_utils import ( # noqa: F401
can_fit_in_qr, can_fit_in_qr,
extract_key_from_qr_file, extract_key_from_qr_file,
generate_qr_ascii,
generate_qr_code, generate_qr_code,
has_qr_read, has_qr_read,
has_qr_write, has_qr_write,
@@ -136,6 +137,9 @@ except ImportError:
def has_qr_write() -> bool: def has_qr_write() -> bool:
return False return False
def generate_qr_ascii(*args, **kwargs):
raise RuntimeError("QR code generation not available")
# ============================================================================ # ============================================================================
# CLI SETUP # 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})", help=f"PIN length (6-9, default: {DEFAULT_PIN_LENGTH})",
) )
@click.option( @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( @click.option(
"--words", "--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("--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("--password", "-p", help="Password for RSA key file")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON") @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. Generate credentials for encoding/decoding.
@@ -261,13 +271,18 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
Examples: Examples:
stegasoo generate stegasoo generate
stegasoo generate --words 5 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 -o mykey.pem -p "secretpassword"
stegasoo generate --rsa --qr key.png
stegasoo generate --rsa --qr-ascii
stegasoo generate --no-pin --rsa stegasoo generate --no-pin --rsa
""" """
if not pin and not rsa: if not pin and not rsa:
raise click.UsageError("Must enable at least one of --pin or --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: if output and not password:
raise click.UsageError("--password is required when saving RSA key to file") 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(creds.rsa_key_pem)
click.echo() 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("─── 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.secho("─── SECURITY ───", fg="green")
click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)") click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)")
if creds.pin: if creds.pin:

View File

File diff suppressed because it is too large Load Diff

View File

@@ -77,14 +77,10 @@ def init_db():
db = get_db() db = get_db()
# Check if we need to migrate from old single-user schema # Check if we need to migrate from old single-user schema
cursor = db.execute( cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'")
"SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'"
)
has_old_table = cursor.fetchone() is not None has_old_table = cursor.fetchone() is not None
cursor = db.execute( cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
)
has_new_table = cursor.fetchone() is not None has_new_table = cursor.fetchone() is not None
if has_old_table and not has_new_table: if has_old_table and not has_new_table:
@@ -189,9 +185,7 @@ def _ensure_channel_keys_table(db: sqlite3.Connection):
def _ensure_app_settings_table(db: sqlite3.Connection): def _ensure_app_settings_table(db: sqlite3.Connection):
"""Ensure app_settings table exists (v4.1.0 migration).""" """Ensure app_settings table exists (v4.1.0 migration)."""
cursor = db.execute( cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'")
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
)
if cursor.fetchone() is None: if cursor.fetchone() is None:
db.executescript(""" db.executescript("""
CREATE TABLE IF NOT EXISTS app_settings ( CREATE TABLE IF NOT EXISTS app_settings (
@@ -212,9 +206,7 @@ def _ensure_app_settings_table(db: sqlite3.Connection):
def get_app_setting(key: str) -> str | None: def get_app_setting(key: str) -> str | None:
"""Get an app-level setting value.""" """Get an app-level setting value."""
db = get_db() db = get_db()
row = db.execute( row = db.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone()
"SELECT value FROM app_settings WHERE key = ?", (key,)
).fetchone()
return row["value"] if row else None return row["value"] if row else None
@@ -384,12 +376,10 @@ def get_user_by_username(username: str) -> User | None:
def get_all_users() -> list[User]: def get_all_users() -> list[User]:
"""Get all users, admins first, then by creation date.""" """Get all users, admins first, then by creation date."""
db = get_db() db = get_db()
rows = db.execute( rows = db.execute("""
"""
SELECT id, username, role, created_at FROM users SELECT id, username, role, created_at FROM users
ORDER BY role = 'admin' DESC, created_at ASC ORDER BY role = 'admin' DESC, created_at ASC
""" """).fetchall()
).fetchall()
return [ return [
User( User(
id=row["id"], id=row["id"],
@@ -596,9 +586,7 @@ def create_admin_user(username: str, password: str) -> tuple[bool, str]:
return success, msg return success, msg
def change_password( def change_password(user_id: int, current_password: str, new_password: str) -> tuple[bool, str]:
user_id: int, current_password: str, new_password: str
) -> tuple[bool, str]:
"""Change a user's password (requires current password).""" """Change a user's password (requires current password)."""
user = get_user_by_id(user_id) user = get_user_by_id(user_id)
if not user: if not user:
@@ -667,9 +655,7 @@ def delete_user(user_id: int, current_user_id: int) -> tuple[bool, str]:
# Check if this is the last admin # Check if this is the last admin
if user.role == ROLE_ADMIN: if user.role == ROLE_ADMIN:
db = get_db() db = get_db()
admin_count = db.execute( admin_count = db.execute("SELECT COUNT(*) FROM users WHERE role = 'admin'").fetchone()[0]
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
).fetchone()[0]
if admin_count <= 1: if admin_count <= 1:
return False, "Cannot delete the last admin" return False, "Cannot delete the last admin"
@@ -848,9 +834,7 @@ def save_channel_key(
return False, "This channel key is already saved", None return False, "This channel key is already saved", None
def update_channel_key_name( def update_channel_key_name(key_id: int, user_id: int, new_name: str) -> tuple[bool, str]:
key_id: int, user_id: int, new_name: str
) -> tuple[bool, str]:
"""Update the name of a saved channel key.""" """Update the name of a saved channel key."""
new_name = new_name.strip() new_name = new_name.strip()
if not new_name: if not new_name:

View File

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

View File

@@ -81,10 +81,12 @@ def generate_self_signed_cert(
) )
# Create certificate # Create certificate
subject = issuer = x509.Name([ subject = issuer = x509.Name(
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"), [
x509.NameAttribute(NameOID.COMMON_NAME, hostname), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
]) x509.NameAttribute(NameOID.COMMON_NAME, hostname),
]
)
# Subject Alternative Names # Subject Alternative Names
san_list = [ san_list = [
@@ -112,7 +114,7 @@ def generate_self_signed_cert(
except (ipaddress.AddressValueError, ValueError): except (ipaddress.AddressValueError, ValueError):
pass pass
now = datetime.datetime.now(datetime.timezone.utc) now = datetime.datetime.now(datetime.UTC)
cert = ( cert = (
x509.CertificateBuilder() x509.CertificateBuilder()
.subject_name(subject) .subject_name(subject)

View File

@@ -95,7 +95,16 @@ const Stegasoo = {
if (!isPayloadZone && !isQrZone) { if (!isPayloadZone && !isQrZone) {
input.addEventListener('change', function() { input.addEventListener('change', function() {
if (this.files && this.files[0]) { if (this.files && this.files[0]) {
Stegasoo.showImagePreview(this.files[0], preview, label, zone); const file = this.files[0];
if (file.type.startsWith('image/') && preview) {
Stegasoo.showImagePreview(file, preview, label, zone);
} else if (file.type.startsWith('audio/') || !file.type.startsWith('image/')) {
// Audio or non-image files: show file info instead of image preview
Stegasoo.showAudioFileInfo(file, zone);
if (label) {
label.classList.add('d-none');
}
}
} }
}); });
} }
@@ -153,7 +162,21 @@ const Stegasoo = {
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}, },
/**
* Format audio file info for display in drop zones (v4.3.0)
*/
showAudioFileInfo(file, zone) {
const filenameEl = zone.querySelector('.pixel-data-filename span, .scan-data-filename span');
const sizeEl = zone.querySelector('.pixel-data-value, .scan-data-value');
if (filenameEl) filenameEl.textContent = file.name;
if (sizeEl) {
const kb = file.size / 1024;
sizeEl.textContent = kb >= 1024 ? (kb / 1024).toFixed(1) + ' MB' : kb.toFixed(1) + ' KB';
}
zone.classList.add('has-file');
},
// ======================================================================== // ========================================================================
// REFERENCE PHOTO SCAN ANIMATION // REFERENCE PHOTO SCAN ANIMATION
// ======================================================================== // ========================================================================
@@ -951,13 +974,13 @@ const Stegasoo = {
body: formData, body: formData,
}); });
const result = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to start encode'); throw new Error((result && result.error) || 'Failed to start encode');
} }
const result = await response.json(); if (result && result.error) {
if (result.error) {
throw new Error(result.error); throw new Error(result.error);
} }
@@ -1009,7 +1032,9 @@ const Stegasoo = {
const percent = progressData.percent || 0; const percent = progressData.percent || 0;
const phase = progressData.phase || 'processing'; 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 // Continue polling
setTimeout(poll, 500); setTimeout(poll, 500);
@@ -1029,11 +1054,15 @@ const Stegasoo = {
formatPhase(phase) { formatPhase(phase) {
const phases = { const phases = {
'starting': 'Starting...', 'starting': 'Starting...',
'initializing': 'Initializing...', 'initializing': 'Deriving keys (may take a moment)...',
'embedding': 'Embedding data...', 'embedding': 'Embedding data...',
'saving': 'Saving image...', 'saving': 'Saving image...',
'finalizing': 'Finalizing...', 'finalizing': 'Finalizing...',
'complete': 'Complete!', 'complete': 'Complete!',
// Audio encode phases (v4.3.0)
'audio_transcoding': 'Transcoding audio...',
'audio_embedding': 'Embedding in audio...',
'spread_embedding': 'Spread spectrum embedding...',
}; };
return phases[phase] || phase; return phases[phase] || phase;
}, },
@@ -1070,8 +1099,9 @@ const Stegasoo = {
document.body.appendChild(modal); document.body.appendChild(modal);
} }
// Reset progress // Reset progress tracking and start with indeterminate state
this.updateProgress(0, 'Initializing...'); this.resetProgressTracking();
this.updateProgress(0, 'Initializing...', true);
// Show modal // Show modal
const bsModal = new bootstrap.Modal(modal); const bsModal = new bootstrap.Modal(modal);
@@ -1090,16 +1120,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 progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText'); const progressText = document.getElementById('progressText');
const phaseText = document.getElementById('progressPhase'); const phaseText = document.getElementById('progressPhase');
if (progressBar) progressBar.style.width = percent + '%'; if (indeterminate) {
if (progressText) progressText.textContent = Math.round(percent) + '%'; // Barber pole animation at full width, no percentage
if (phaseText) phaseText.textContent = phase; 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;
}
}, },
// ======================================================================== // ========================================================================
@@ -1187,7 +1248,9 @@ const Stegasoo = {
const percent = progressData.percent || 0; const percent = progressData.percent || 0;
const phase = progressData.phase || 'processing'; 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 // Continue polling
setTimeout(poll, 500); setTimeout(poll, 500);
@@ -1207,12 +1270,19 @@ const Stegasoo = {
formatDecodePhase(phase) { formatDecodePhase(phase) {
const phases = { const phases = {
'starting': 'Starting...', 'starting': 'Starting...',
'initializing': 'Deriving keys (may take a moment)...',
'loading': 'Deriving keys (may take a moment)...',
'reading': 'Reading image...', 'reading': 'Reading image...',
'extracting': 'Extracting data...', 'extracting': 'Extracting data...',
'decoding': 'Decoding data...',
'decrypting': 'Decrypting...', 'decrypting': 'Decrypting...',
'verifying': 'Verifying...', 'verifying': 'Verifying...',
'finalizing': 'Finalizing...', 'finalizing': 'Finalizing...',
'complete': 'Complete!', 'complete': 'Complete!',
// Audio decode phases (v4.3.0)
'audio_transcoding': 'Transcoding audio...',
'audio_extracting': 'Extracting from audio...',
'spread_extracting': 'Spread spectrum extracting...',
}; };
return phases[phase] || phase; return phases[phase] || phase;
}, },

View File

@@ -16,7 +16,7 @@
--overlay-dark: rgba(0, 0, 0, 0.3); --overlay-dark: rgba(0, 0, 0, 0.3);
--overlay-light: rgba(255, 255, 255, 0.05); --overlay-light: rgba(255, 255, 255, 0.05);
--day-highlight: #E3FF54; /* Bright yellow/green for day of week */ --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 */
} }
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
@@ -116,6 +116,31 @@
pointer-events: none; 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 Security Factor Boxes - Matches drop-zone dashed border style
---------------------------------------------------------------------------- */ ---------------------------------------------------------------------------- */
@@ -153,11 +178,22 @@ body {
z-index: 1030; /* Above page content for dropdowns */ z-index: 1030; /* Above page content for dropdowns */
} }
.navbar > .container {
padding-left: 0;
}
/* Ensure navbar dropdown appears above all page content */ /* Ensure navbar dropdown appears above all page content */
.navbar .dropdown-menu { .navbar .dropdown-menu {
z-index: 1031; 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 - Floating Label on Hover (label floats below, no layout shift)
---------------------------------------------------------------------------- */ ---------------------------------------------------------------------------- */
@@ -192,26 +228,22 @@ body {
left: 50%; left: 50%;
transform: translateX(-50%) translateY(-4px); transform: translateX(-50%) translateY(-4px);
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 700; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 1px;
white-space: nowrap; white-space: nowrap;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
color: var(--header-gold); color: var(--header-gold);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
background: linear-gradient(135deg, rgba(74, 40, 96, 0.95) 0%, rgba(85, 112, 212, 0.9) 100%);
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: opacity 0.2s ease, transition: opacity 0.2s ease,
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1040; z-index: 1040;
} }
.nav-expand:hover { .nav-expand:hover {
background: linear-gradient(135deg, rgba(74, 40, 96, 0.5) 0%, rgba(85, 112, 212, 0.4) 100%); background: linear-gradient(135deg, rgba(74, 40, 96, 0.25) 0%, rgba(85, 112, 212, 0.2) 100%);
box-shadow: 0 0 12px rgba(102, 126, 234, 0.25), box-shadow: 0 0 8px rgba(102, 126, 234, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.1); inset 0 1px 0 rgba(255, 255, 255, 0.1);
} }
@@ -1228,7 +1260,8 @@ footer {
---------------------------------------------------------------------------- */ ---------------------------------------------------------------------------- */
#rsaQrSection { #rsaQrSection {
display: flex; display: flex;
justify-content: center; flex-direction: column;
align-items: center;
} }
#rsaQrSection .drop-zone { #rsaQrSection .drop-zone {
@@ -1854,7 +1887,7 @@ footer {
.tools-ribbon-group { .tools-ribbon-group {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.5rem;
} }
.tools-ribbon-divider { .tools-ribbon-divider {
@@ -1871,8 +1904,8 @@ footer {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 52px; width: 64px;
height: 48px; height: 52px;
padding: 0.25rem; padding: 0.25rem;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 0.375rem; border-radius: 0.375rem;
@@ -1888,15 +1921,18 @@ footer {
} }
.tool-icon-btn span { .tool-icon-btn span {
font-size: 0.6rem; font-size: 0.62rem;
font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.3px; letter-spacing: 1px;
} }
.tool-icon-btn:hover { .tool-icon-btn:hover {
background: rgba(139, 92, 246, 0.15); background: rgba(255, 230, 150, 0.1);
border-color: rgba(139, 92, 246, 0.3); border-color: rgba(255, 230, 150, 0.3);
color: rgba(255, 255, 255, 0.95); color: var(--header-gold);
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.33);
} }
.tool-icon-btn.active { .tool-icon-btn.active {
@@ -2211,7 +2247,7 @@ footer {
display: none; display: none;
width: 100%; width: 100%;
flex: 1; flex: 1;
padding: 1.25rem; padding: 0.5rem;
} }
.tool-section.active { .tool-section.active {
@@ -2219,33 +2255,92 @@ footer {
flex-direction: column; flex-direction: column;
} }
/* EXIF Table in Results */ /* EXIF Grid Layout */
.tool-exif-table { .exif-grid {
font-size: 0.8rem; display: grid;
max-height: 250px; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.3rem;
max-height: 280px;
overflow-y: auto; overflow-y: auto;
padding: 0.15rem;
} }
.tool-exif-table table { .exif-card {
width: 100%; background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 4px;
padding: 0.25rem 0.4rem;
} }
.tool-exif-table th, .exif-card:hover {
.tool-exif-table td { background: rgba(255, 255, 255, 0.06);
padding: 0.35rem 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.tool-exif-table th { .exif-card-label {
font-size: 0.55rem;
font-weight: 500; font-weight: 500;
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.4);
text-align: left; text-transform: uppercase;
width: 40%; letter-spacing: 0.02em;
margin-bottom: 0.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.tool-exif-table td { .exif-card-value {
font-size: 0.7rem;
font-family: 'SF Mono', 'Consolas', monospace; font-family: 'SF Mono', 'Consolas', monospace;
word-break: break-all; 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 */ /* Loading State */

View File

@@ -3,7 +3,7 @@
Stegasoo Subprocess Worker (v4.0.0) Stegasoo Subprocess Worker (v4.0.0)
This script runs in a subprocess and handles encode/decode operations. 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: CHANGES in v4.0.0:
- Added channel_key support for encode/decode operations - Added channel_key support for encode/decode operations
@@ -19,6 +19,8 @@ Usage:
import base64 import base64
import json import json
import logging
import os
import sys import sys
import traceback import traceback
from pathlib import Path from pathlib import Path
@@ -27,6 +29,24 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
# Configure logging for worker subprocess
_log_level = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
if _log_level and hasattr(logging, _log_level):
logging.basicConfig(
level=getattr(logging, _log_level),
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%H:%M:%S",
stream=sys.stderr,
)
elif os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
logging.basicConfig(
level=logging.DEBUG,
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%H:%M:%S",
stream=sys.stderr,
)
logger = logging.getLogger("stegasoo.worker")
def _resolve_channel_key(channel_key_param): def _resolve_channel_key(channel_key_param):
""" """
@@ -73,6 +93,7 @@ def _get_channel_info(resolved_key):
def encode_operation(params: dict) -> dict: def encode_operation(params: dict) -> dict:
"""Handle encode operation.""" """Handle encode operation."""
logger.debug("encode_operation: mode=%s", params.get("embed_mode", "lsb"))
from stegasoo import FilePayload, encode from stegasoo import FilePayload, encode
# Decode base64 inputs # Decode base64 inputs
@@ -142,6 +163,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
return return
try: try:
import json import json
with open(progress_file, "w") as f: with open(progress_file, "w") as f:
json.dump({"percent": percent, "phase": phase}, f) json.dump({"percent": percent, "phase": phase}, f)
except Exception: except Exception:
@@ -150,6 +172,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
def decode_operation(params: dict) -> dict: def decode_operation(params: dict) -> dict:
"""Handle decode operation.""" """Handle decode operation."""
logger.debug("decode_operation: mode=%s", params.get("embed_mode", "auto"))
from stegasoo import decode from stegasoo import decode
progress_file = params.get("progress_file") progress_file = params.get("progress_file")
@@ -171,8 +194,7 @@ def decode_operation(params: dict) -> dict:
# Resolve channel key (v4.0.0) # Resolve channel key (v4.0.0)
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto")) 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 # Call decode with correct parameter names
result = decode( result = decode(
stego_image=stego_data, stego_image=stego_data,
@@ -183,9 +205,9 @@ def decode_operation(params: dict) -> dict:
rsa_password=params.get("rsa_password"), rsa_password=params.get("rsa_password"),
embed_mode=params.get("embed_mode", "auto"), embed_mode=params.get("embed_mode", "auto"),
channel_key=resolved_channel_key, # v4.0.0 channel_key=resolved_channel_key, # v4.0.0
progress_file=progress_file, # v4.2.0: pass through for real-time progress
) )
# Library writes 100% "complete" - no need for worker to write again
_write_decode_progress(progress_file, 90, "finalizing")
if result.is_file: if result.is_file:
return { return {
@@ -234,6 +256,145 @@ def capacity_check_operation(params: dict) -> dict:
} }
def encode_audio_operation(params: dict) -> dict:
"""Handle audio encode operation (v4.3.0)."""
logger.debug("encode_audio_operation: mode=%s", params.get("embed_mode", "audio_lsb"))
from stegasoo import FilePayload, encode_audio
carrier_data = base64.b64decode(params["carrier_b64"])
reference_data = base64.b64decode(params["reference_b64"])
# Optional RSA key
rsa_key_data = None
if params.get("rsa_key_b64"):
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
# Determine payload type
if params.get("file_b64"):
file_data = base64.b64decode(params["file_b64"])
payload = FilePayload(
data=file_data,
filename=params.get("file_name", "file"),
mime_type=params.get("file_mime", "application/octet-stream"),
)
else:
payload = params.get("message", "")
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
# Resolve chip_tier from params (None means use default)
chip_tier_val = params.get("chip_tier")
if chip_tier_val is not None:
chip_tier_val = int(chip_tier_val)
stego_audio, stats = encode_audio(
message=payload,
reference_photo=reference_data,
carrier_audio=carrier_data,
passphrase=params.get("passphrase", ""),
pin=params.get("pin"),
rsa_key_data=rsa_key_data,
rsa_password=params.get("rsa_password"),
embed_mode=params.get("embed_mode", "audio_lsb"),
channel_key=resolved_channel_key,
progress_file=params.get("progress_file"),
chip_tier=chip_tier_val,
)
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
return {
"success": True,
"stego_b64": base64.b64encode(stego_audio).decode("ascii"),
"stats": {
"samples_modified": stats.samples_modified,
"total_samples": stats.total_samples,
"capacity_used": stats.capacity_used,
"bytes_embedded": stats.bytes_embedded,
"sample_rate": stats.sample_rate,
"channels": stats.channels,
"duration_seconds": stats.duration_seconds,
"embed_mode": stats.embed_mode,
},
"channel_mode": channel_mode,
"channel_fingerprint": channel_fingerprint,
}
def decode_audio_operation(params: dict) -> dict:
"""Handle audio decode operation (v4.3.0)."""
logger.debug("decode_audio_operation: mode=%s", params.get("embed_mode", "audio_auto"))
from stegasoo import decode_audio
progress_file = params.get("progress_file")
_write_decode_progress(progress_file, 5, "reading")
stego_data = base64.b64decode(params["stego_b64"])
reference_data = base64.b64decode(params["reference_b64"])
_write_decode_progress(progress_file, 15, "reading")
rsa_key_data = None
if params.get("rsa_key_b64"):
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
result = decode_audio(
stego_audio=stego_data,
reference_photo=reference_data,
passphrase=params.get("passphrase", ""),
pin=params.get("pin"),
rsa_key_data=rsa_key_data,
rsa_password=params.get("rsa_password"),
embed_mode=params.get("embed_mode", "audio_auto"),
channel_key=resolved_channel_key,
progress_file=progress_file,
)
if result.is_file:
return {
"success": True,
"is_file": True,
"file_b64": base64.b64encode(result.file_data).decode("ascii"),
"filename": result.filename,
"mime_type": result.mime_type,
}
else:
return {
"success": True,
"is_file": False,
"message": result.message,
}
def audio_info_operation(params: dict) -> dict:
"""Handle audio info operation (v4.3.0)."""
from stegasoo import get_audio_info
from stegasoo.audio_steganography import calculate_audio_lsb_capacity
from stegasoo.spread_steganography import calculate_audio_spread_capacity
audio_data = base64.b64decode(params["audio_b64"])
info = get_audio_info(audio_data)
lsb_capacity = calculate_audio_lsb_capacity(audio_data)
spread_capacity = calculate_audio_spread_capacity(audio_data)
return {
"success": True,
"info": {
"sample_rate": info.sample_rate,
"channels": info.channels,
"duration_seconds": round(info.duration_seconds, 2),
"num_samples": info.num_samples,
"format": info.format,
"bit_depth": info.bit_depth,
"capacity_lsb": lsb_capacity,
"capacity_spread": spread_capacity.usable_capacity_bytes,
},
}
def channel_status_operation(params: dict) -> dict: def channel_status_operation(params: dict) -> dict:
"""Handle channel status check (v4.0.0).""" """Handle channel status check (v4.0.0)."""
from stegasoo import get_channel_status from stegasoo import get_channel_status
@@ -264,6 +425,7 @@ def main():
else: else:
params = json.loads(input_text) params = json.loads(input_text)
operation = params.get("operation") operation = params.get("operation")
logger.info("Worker handling operation: %s", operation)
if operation == "encode": if operation == "encode":
output = encode_operation(params) output = encode_operation(params)
@@ -275,6 +437,13 @@ def main():
output = capacity_check_operation(params) output = capacity_check_operation(params)
elif operation == "channel_status": elif operation == "channel_status":
output = channel_status_operation(params) output = channel_status_operation(params)
# Audio operations (v4.3.0)
elif operation == "encode_audio":
output = encode_audio_operation(params)
elif operation == "decode_audio":
output = decode_audio_operation(params)
elif operation == "audio_info":
output = audio_info_operation(params)
else: else:
output = {"success": False, "error": f"Unknown operation: {operation}"} output = {"success": False, "error": f"Unknown operation: {operation}"}

View File

@@ -115,6 +115,35 @@ class CapacityResult:
error: str | None = None error: str | None = None
@dataclass
class AudioEncodeResult:
"""Result from audio encode operation (v4.3.0)."""
success: bool
stego_data: bytes | None = None
stats: dict[str, Any] | None = None
channel_mode: str | None = None
channel_fingerprint: str | None = None
error: str | None = None
error_type: str | None = None
@dataclass
class AudioInfoResult:
"""Result from audio info operation (v4.3.0)."""
success: bool
sample_rate: int = 0
channels: int = 0
duration_seconds: float = 0.0
num_samples: int = 0
format: str = ""
bit_depth: int | None = None
capacity_lsb: int = 0
capacity_spread: int = 0
error: str | None = None
@dataclass @dataclass
class ChannelStatusResult: class ChannelStatusResult:
"""Result from channel status check (v4.0.0).""" """Result from channel status check (v4.0.0)."""
@@ -132,7 +161,7 @@ class SubprocessStego:
""" """
Subprocess-isolated steganography operations. 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. crashes, only the subprocess dies - Flask keeps running.
""" """
@@ -456,6 +485,201 @@ class SubprocessStego:
error=result.get("error", "Unknown error"), error=result.get("error", "Unknown error"),
) )
# =========================================================================
# Audio Steganography (v4.3.0)
# =========================================================================
def encode_audio(
self,
carrier_data: bytes,
reference_data: bytes,
message: str | None = None,
file_data: bytes | None = None,
file_name: str | None = None,
file_mime: str | None = None,
passphrase: str = "",
pin: str | None = None,
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
embed_mode: str = "audio_lsb",
channel_key: str | None = "auto",
timeout: int | None = None,
progress_file: str | None = None,
chip_tier: int | None = None,
) -> AudioEncodeResult:
"""
Encode a message or file into an audio carrier.
Args:
carrier_data: Carrier audio bytes (WAV, FLAC, MP3, etc.)
reference_data: Reference photo bytes
message: Text message to encode (if not file)
file_data: File bytes to encode (if not message)
file_name: Original filename (for file payload)
file_mime: MIME type (for file payload)
passphrase: Encryption passphrase
pin: Optional PIN
rsa_key_data: Optional RSA key PEM bytes
rsa_password: RSA key password if encrypted
embed_mode: 'audio_lsb' or 'audio_spread'
channel_key: 'auto', 'none', or explicit key
timeout: Operation timeout (default 300s for audio)
progress_file: Path to write progress updates
Returns:
AudioEncodeResult with stego audio data on success
"""
params = {
"operation": "encode_audio",
"carrier_b64": base64.b64encode(carrier_data).decode("ascii"),
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
"message": message,
"passphrase": passphrase,
"pin": pin,
"embed_mode": embed_mode,
"channel_key": channel_key,
"progress_file": progress_file,
"chip_tier": chip_tier,
}
if file_data:
params["file_b64"] = base64.b64encode(file_data).decode("ascii")
params["file_name"] = file_name
params["file_mime"] = file_mime
if rsa_key_data:
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
params["rsa_password"] = rsa_password
# Audio operations can be slower (especially spread spectrum)
result = self._run_worker(params, timeout or 300)
if result.get("success"):
return AudioEncodeResult(
success=True,
stego_data=base64.b64decode(result["stego_b64"]),
stats=result.get("stats"),
channel_mode=result.get("channel_mode"),
channel_fingerprint=result.get("channel_fingerprint"),
)
else:
return AudioEncodeResult(
success=False,
error=result.get("error", "Unknown error"),
error_type=result.get("error_type"),
)
def decode_audio(
self,
stego_data: bytes,
reference_data: bytes,
passphrase: str = "",
pin: str | None = None,
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
embed_mode: str = "audio_auto",
channel_key: str | None = "auto",
timeout: int | None = None,
progress_file: str | None = None,
) -> DecodeResult:
"""
Decode a message or file from stego audio.
Args:
stego_data: Stego audio bytes
reference_data: Reference photo bytes
passphrase: Decryption passphrase
pin: Optional PIN
rsa_key_data: Optional RSA key PEM bytes
rsa_password: RSA key password if encrypted
embed_mode: 'audio_auto', 'audio_lsb', or 'audio_spread'
channel_key: 'auto', 'none', or explicit key
timeout: Operation timeout (default 300s for audio)
progress_file: Path to write progress updates
Returns:
DecodeResult with message or file_data on success
"""
params = {
"operation": "decode_audio",
"stego_b64": base64.b64encode(stego_data).decode("ascii"),
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
"passphrase": passphrase,
"pin": pin,
"embed_mode": embed_mode,
"channel_key": channel_key,
"progress_file": progress_file,
}
if rsa_key_data:
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
params["rsa_password"] = rsa_password
result = self._run_worker(params, timeout or 300)
if result.get("success"):
if result.get("is_file"):
return DecodeResult(
success=True,
is_file=True,
file_data=base64.b64decode(result["file_b64"]),
filename=result.get("filename"),
mime_type=result.get("mime_type"),
)
else:
return DecodeResult(
success=True,
is_file=False,
message=result.get("message"),
)
else:
return DecodeResult(
success=False,
error=result.get("error", "Unknown error"),
error_type=result.get("error_type"),
)
def audio_info(
self,
audio_data: bytes,
timeout: int | None = None,
) -> AudioInfoResult:
"""
Get audio file information and steganographic capacity.
Args:
audio_data: Audio file bytes
timeout: Operation timeout in seconds
Returns:
AudioInfoResult with metadata and capacity info
"""
params = {
"operation": "audio_info",
"audio_b64": base64.b64encode(audio_data).decode("ascii"),
}
result = self._run_worker(params, timeout)
if result.get("success"):
info = result.get("info", {})
return AudioInfoResult(
success=True,
sample_rate=info.get("sample_rate", 0),
channels=info.get("channels", 0),
duration_seconds=info.get("duration_seconds", 0.0),
num_samples=info.get("num_samples", 0),
format=info.get("format", ""),
bit_depth=info.get("bit_depth"),
capacity_lsb=info.get("capacity_lsb", 0),
capacity_spread=info.get("capacity_spread", 0),
)
else:
return AudioInfoResult(
success=False,
error=result.get("error", "Unknown error"),
)
def get_channel_status( def get_channel_status(
self, self,
reveal: bool = False, reveal: bool = False,

View File

@@ -271,8 +271,7 @@
<div class="card-body"> <div class="card-body">
<p class="small mb-2">Uses server-configured key if available, otherwise public mode.</p> <p class="small mb-2">Uses server-configured key if available, otherwise public mode.</p>
<ul class="small mb-0"> <ul class="small mb-0">
<li>Set via <code>STEGASOO_CHANNEL_KEY</code> env var</li> <li>Server admin configures the shared key</li>
<li>Or <code>channel_key</code> in config file</li>
<li>All users share the same channel</li> <li>All users share the same channel</li>
</ul> </ul>
</div> </div>
@@ -321,7 +320,6 @@
<i class="bi bi-shield-lock me-2"></i> <i class="bi bi-shield-lock me-2"></i>
<strong>This server has a channel key configured:</strong> <strong>This server has a channel key configured:</strong>
<code class="ms-2">{{ channel_fingerprint }}</code> <code class="ms-2">{{ channel_fingerprint }}</code>
<span class="text-muted ms-2">({{ channel_source }})</span>
</div> </div>
{% else %} {% else %}
<div class="alert alert-info mt-3 mb-0"> <div class="alert alert-info mt-3 mb-0">
@@ -342,11 +340,13 @@
<!-- Current Version - Prominent --> <!-- Current Version - Prominent -->
<div class="alert alert-success mb-4"> <div class="alert alert-success mb-4">
<div class="d-flex align-items-center"> <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> <div>
<strong>Progress bars</strong> for encode operations, <strong>Security & API improvements:</strong>
<strong>mobile-responsive polish</strong>, API key authentication,
DCT decode bug fix, release validation script TLS with self-signed certs,
CLI tools (compress, rotate, convert),
jpegtran lossless JPEG rotation
</div> </div>
</div> </div>
</div> </div>
@@ -364,6 +364,10 @@
<div class="accordion-body p-0"> <div class="accordion-body p-0">
<table class="table table-dark table-sm small mb-0"> <table class="table table-dark table-sm small mb-0">
<tbody> <tbody>
<tr>
<td width="80"><strong>4.1.7</strong></td>
<td>Progress bars for encode, mobile polish, release validation</td>
</tr>
<tr> <tr>
<td width="80"><strong>4.1.1</strong></td> <td width="80"><strong>4.1.1</strong></td>
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td> <td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
@@ -561,7 +565,7 @@
</tr> </tr>
<tr> <tr>
<td><i class="bi bi-clock me-2"></i>File expiry</td> <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>
<tr> <tr>
<td><i class="bi bi-key me-2"></i>PIN</td> <td><i class="bi bi-key me-2"></i>PIN</td>
@@ -569,7 +573,7 @@
</tr> </tr>
<tr> <tr>
<td><i class="bi bi-shield me-2"></i>RSA keys</td> <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>
<tr> <tr>
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td> <td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>

View File

@@ -11,14 +11,19 @@
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-lg navbar-dark"> <nav class="navbar navbar-expand-lg navbar-dark">
<div class="container"> <div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="/"> <a class="navbar-brand" href="/" style="padding-left: 6px; margin-right: 8px;">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="36" class="me-2"> <img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="28">
<span style="position: relative; display: inline-block; margin-top: -14px;">
<span class="fw-bold title-gold">Stegasoo</span>
<span class="badge bg-success" style="position: absolute; font-size: 0.45rem; bottom: -8px; right: 6px;">v4.1</span>
</span>
</a> </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"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>

View File

@@ -6,20 +6,29 @@
<style> <style>
/* Accordion styling */ /* Accordion styling */
.step-accordion .accordion-button { .step-accordion .accordion-button {
background: rgba(30, 40, 50, 0.6); background: rgba(35, 45, 55, 0.8);
color: #fff; color: #fff;
padding: 0.75rem 1rem; 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; 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) { .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; color: #fff;
box-shadow: inset 0 1px 0 rgba(99, 179, 237, 0.1); box-shadow: inset 0 1px 0 rgba(255, 230, 153, 0.1);
border-left: 3px solid rgba(99, 179, 237, 0.6); border-left: 3px solid #ffe699;
} }
.step-accordion .accordion-button::after { .step-accordion .accordion-button::after {
filter: invert(1); filter: brightness(0) invert(1);
opacity: 0.5;
}
.step-accordion .accordion-button:not(.collapsed)::after {
opacity: 0.9;
} }
.step-accordion .accordion-body { .step-accordion .accordion-body {
background: rgba(30, 40, 50, 0.4); background: rgba(30, 40, 50, 0.4);
@@ -106,46 +115,7 @@
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important; box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
} }
/* QR Crop Animation */ /* QR Crop Animation - uses .qr-scan-container from style.css */
.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);
}
</style> </style>
<div class="row justify-content-center"> <div class="row justify-content-center">
@@ -192,7 +162,7 @@
<div class="alert alert-warning small"> <div class="alert alert-warning small">
<i class="bi bi-clock me-1"></i> <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> </div>
<a href="/decode" class="btn btn-outline-light w-100"> <a href="/decode" class="btn btn-outline-light w-100">
@@ -206,19 +176,51 @@
<div class="accordion step-accordion" id="decodeAccordion"> <div class="accordion step-accordion" id="decodeAccordion">
<!-- ================================================================ <!-- ================================================================
STEP 1: IMAGES & MODE STEP 1: CARRIER TYPE (v4.3.0)
================================================================ -->
<div class="accordion-item" id="carrierTypeStep">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepCarrierType">
<span class="step-title">
<span class="step-number" id="stepCarrierTypeNumber">1</span>
<i class="bi bi-collection me-1"></i> Carrier Type
</span>
<span class="step-summary" id="stepCarrierTypeSummary"></span>
</button>
</h2>
<div id="stepCarrierType" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
<div class="accordion-body">
<input type="hidden" name="carrier_type" id="carrierTypeInput" value="image">
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="carrier_type_select" id="typeImage" value="image" checked>
<label class="btn btn-outline-secondary" for="typeImage">
<i class="bi bi-image me-1"></i> Image
</label>
<input type="radio" class="btn-check" name="carrier_type_select" id="typeAudio" value="audio"
{% if not has_audio %}disabled{% endif %}>
<label class="btn btn-outline-secondary {% if not has_audio %}disabled text-muted{% endif %}" for="typeAudio">
<i class="bi bi-music-note-beamed me-1"></i> Audio
{% if not has_audio %}<small class="d-block" style="font-size: 0.65rem;">(not available)</small>{% endif %}
</label>
</div>
</div>
</div>
</div>
<!-- ================================================================
STEP 2: IMAGES & MODE
================================================================ --> ================================================================ -->
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
<span class="step-title"> <span class="step-title">
<span class="step-number" id="stepImagesNumber">1</span> <span class="step-number" id="stepImagesNumber">2</span>
<i class="bi bi-images me-1"></i> Images & Mode <i class="bi bi-images me-1"></i> Reference, Carrier, Mode
</span> </span>
<span class="step-summary" id="stepImagesSummary">Select reference & stego</span> <span class="step-summary" id="stepImagesSummary">Select reference & stego</span>
</button> </button>
</h2> </h2>
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion"> <div id="stepImages" class="accordion-collapse collapse" data-bs-parent="#decodeAccordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="row"> <div class="row">
@@ -247,46 +249,75 @@
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label"> <div id="imageStegoSection">
<i class="bi bi-file-earmark-image me-1"></i> Stego Image <label class="form-label">
</label> <i class="bi bi-file-earmark-image me-1"></i> Stego Image
<div class="drop-zone pixel-container" id="stegoDropZone"> </label>
<input type="file" name="stego_image" accept="image/*" required id="stegoInput"> <div class="drop-zone pixel-container" id="stegoDropZone">
<div class="drop-zone-label"> <input type="file" name="stego_image" accept="image/*" required id="stegoInput">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i> <div class="drop-zone-label">
<span class="text-muted">Drop image or click</span> <i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
</div> <span class="text-muted">Drop image or click</span>
<img class="drop-zone-preview d-none" id="stegoPreview"> </div>
<div class="pixel-blocks"></div> <img class="drop-zone-preview d-none" id="stegoPreview">
<div class="pixel-scan-line"></div> <div class="pixel-blocks"></div>
<div class="pixel-corners"> <div class="pixel-scan-line"></div>
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div> <div class="pixel-corners">
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div> <div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
</div> <div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
<div class="pixel-data-panel"> </div>
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="stegoFileName">image.png</span></div> <div class="pixel-data-panel">
<div class="pixel-data-row"><span class="pixel-status-badge">Stego Loaded</span><span class="pixel-data-value" id="stegoFileSize">--</span></div> <div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="stegoFileName">image.png</span></div>
<div class="pixel-dimensions" id="stegoDims">-- x -- px</div> <div class="pixel-data-row"><span class="pixel-status-badge">Stego Loaded</span><span class="pixel-data-value" id="stegoFileSize">--</span></div>
<div class="pixel-dimensions" id="stegoDims">-- x -- px</div>
</div>
</div> </div>
<div class="form-text">Image containing the hidden message</div>
</div>
<!-- Audio Stego (hidden by default) -->
<div class="d-none" id="audioStegoSection">
<label class="form-label">
<i class="bi bi-file-earmark-music me-1"></i> Stego Audio
</label>
<div class="drop-zone pixel-container" id="audioStegoDropZone">
<input type="file" name="stego_audio" accept="audio/*" id="audioStegoInput">
<div class="drop-zone-label">
<i class="bi bi-music-note-beamed fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop audio or click</span>
</div>
<div class="pixel-data-panel">
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="audioStegoFileName">audio.wav</span></div>
<div class="pixel-data-row"><span class="pixel-status-badge">Audio Loaded</span><span class="pixel-data-value" id="audioStegoFileSize">--</span></div>
</div>
</div>
<div class="form-text">Audio file containing the hidden message</div>
</div> </div>
<div class="form-text">Image containing the hidden message</div>
</div> </div>
</div> </div>
<!-- Extraction Mode (compact inline) --> <!-- Extraction Mode -->
<div class="d-flex gap-2 align-items-center flex-wrap mb-2"> <div class="d-flex gap-2 align-items-center flex-wrap mb-2">
<label class="mode-btn mode-btn-sm active" id="autoModeCard" for="modeAuto"> <div id="imageModeGroup">
<input class="form-check-input" type="radio" name="embed_mode" id="modeAuto" value="auto" checked> <div class="btn-group" role="group">
<i class="bi bi-magic text-success"></i> Auto <input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
</label> <label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label>
<label class="mode-btn mode-btn-sm" id="lsbModeCard" for="modeLsb"> <input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb">
<input class="form-check-input" type="radio" 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>
<i class="bi bi-grid-3x3-gap text-primary"></i> LSB <input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
</label> <label class="btn btn-outline-secondary text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
<label class="mode-btn mode-btn-sm {% if not has_dct %}opacity-50{% endif %}" id="dctModeCard" for="modeDct"> </div>
<input class="form-check-input" type="radio" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}> </div>
<i class="bi bi-soundwave text-warning"></i> DCT <!-- Audio Extraction Modes (hidden by default) -->
</label> <div class="d-none" id="audioModeGroup">
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioAuto" value="audio_auto">
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioAuto"><i class="bi bi-magic me-1"></i>Auto</label>
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioLsb" value="audio_lsb">
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioSpread" value="audio_spread">
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioSpread"><i class="bi bi-broadcast me-1"></i>Spread</label>
</div>
</div>
</div> </div>
<div class="form-text" id="modeHint"> <div class="form-text" id="modeHint">
<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT <i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT
@@ -297,13 +328,13 @@
</div> </div>
<!-- ================================================================ <!-- ================================================================
STEP 2: SECURITY STEP 3: SECURITY
================================================================ --> ================================================================ -->
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
<span class="step-title"> <span class="step-title">
<span class="step-number" id="stepSecurityNumber">2</span> <span class="step-number" id="stepSecurityNumber">3</span>
<i class="bi bi-shield-lock me-1"></i> Security <i class="bi bi-shield-lock me-1"></i> Security
</span> </span>
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span> <span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
@@ -390,7 +421,7 @@
<i class="bi bi-qr-code-scan fs-5 d-block text-muted mb-1"></i> <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> <span class="text-muted small">Drop QR image</span>
</div> </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-original" id="qrOriginal" alt="Original">
<img class="qr-cropped" id="qrCropped" alt="Cropped"> <img class="qr-cropped" id="qrCropped" alt="Cropped">
</div> </div>
@@ -463,7 +494,10 @@
const modeHints = { const modeHints = {
auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' }, auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' },
lsb: { icon: 'hdd', text: 'For email and direct transfers' }, lsb: { icon: 'hdd', text: 'For email and direct transfers' },
dct: { icon: 'phone', text: 'For social media images' } dct: { icon: 'phone', text: 'For social media images' },
audio_auto: { icon: 'lightning', text: 'Tries LSB first, then Spread Spectrum' },
audio_lsb: { icon: 'grid-3x3-gap', text: 'Direct bit embedding in audio samples' },
audio_spread: { icon: 'broadcast', text: 'Noise-resistant spread spectrum encoding' }
}; };
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => { document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
@@ -480,9 +514,14 @@ document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
// ACCORDION SUMMARY UPDATES // ACCORDION SUMMARY UPDATES
// ============================================================================ // ============================================================================
const carrierTypeInput = document.getElementById('carrierTypeInput');
function updateImagesSummary() { function updateImagesSummary() {
const ref = document.getElementById('refPhotoInput')?.files[0]; const ref = document.getElementById('refPhotoInput')?.files[0];
const stego = document.getElementById('stegoInput')?.files[0]; const isAudio = carrierTypeInput?.value === 'audio';
const stego = isAudio
? document.getElementById('audioStegoInput')?.files[0]
: document.getElementById('stegoInput')?.files[0];
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'AUTO'; const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'AUTO';
const summary = document.getElementById('stepImagesSummary'); const summary = document.getElementById('stepImagesSummary');
const stepNum = document.getElementById('stepImagesNumber'); const stepNum = document.getElementById('stepImagesNumber');
@@ -498,12 +537,12 @@ function updateImagesSummary() {
summary.textContent = ref ? ref.name.slice(0, 15) : stego.name.slice(0, 15); summary.textContent = ref ? ref.name.slice(0, 15) : stego.name.slice(0, 15);
summary.classList.remove('has-content'); summary.classList.remove('has-content');
stepNum.classList.remove('complete'); stepNum.classList.remove('complete');
stepNum.textContent = '1'; stepNum.textContent = '2';
} else { } else {
summary.textContent = 'Select reference & stego'; summary.textContent = isAudio ? 'Select reference & audio' : 'Select reference & stego';
summary.classList.remove('has-content'); summary.classList.remove('has-content');
stepNum.classList.remove('complete'); stepNum.classList.remove('complete');
stepNum.textContent = '1'; stepNum.textContent = '2';
} }
} }
@@ -531,32 +570,107 @@ function updateSecuritySummary() {
summary.textContent = 'Passphrase & keys'; summary.textContent = 'Passphrase & keys';
summary.classList.remove('has-content'); summary.classList.remove('has-content');
stepNum.classList.remove('complete'); stepNum.classList.remove('complete');
stepNum.textContent = '2'; stepNum.textContent = '3';
} }
} }
// Attach listeners // Attach listeners
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary); document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
document.getElementById('stegoInput')?.addEventListener('change', updateImagesSummary); document.getElementById('stegoInput')?.addEventListener('change', updateImagesSummary);
document.getElementById('audioStegoInput')?.addEventListener('change', updateImagesSummary);
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary)); document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
document.querySelectorAll('#audioModeGroup input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
document.getElementById('passphraseInput')?.addEventListener('input', updateSecuritySummary); document.getElementById('passphraseInput')?.addEventListener('input', updateSecuritySummary);
document.getElementById('pinInput')?.addEventListener('input', updateSecuritySummary); document.getElementById('pinInput')?.addEventListener('input', updateSecuritySummary);
document.querySelector('input[name="rsa_key"]')?.addEventListener('change', updateSecuritySummary); document.querySelector('input[name="rsa_key"]')?.addEventListener('change', updateSecuritySummary);
// ============================================================================
// CARRIER TYPE TOGGLE (v4.3.0)
// ============================================================================
const carrierTypeRadios = document.querySelectorAll('input[name="carrier_type_select"]');
const imageStegoSection = document.getElementById('imageStegoSection');
const audioStegoSection = document.getElementById('audioStegoSection');
const imageModeGroup = document.getElementById('imageModeGroup');
const audioModeGroup = document.getElementById('audioModeGroup');
const stepCarrierTypeSummary = document.getElementById('stepCarrierTypeSummary');
carrierTypeRadios.forEach(radio => {
radio.addEventListener('change', function() {
const isAudio = this.value === 'audio';
carrierTypeInput.value = this.value;
// Toggle stego sections
if (imageStegoSection) imageStegoSection.classList.toggle('d-none', isAudio);
if (audioStegoSection) audioStegoSection.classList.toggle('d-none', !isAudio);
// Toggle required attribute so hidden inputs don't block form submission
const imgStego = document.getElementById('stegoInput');
const audStego = document.getElementById('audioStegoInput');
if (imgStego) { if (isAudio) imgStego.removeAttribute('required'); else imgStego.setAttribute('required', ''); }
if (audStego) { if (isAudio) audStego.setAttribute('required', ''); else audStego.removeAttribute('required'); }
// Toggle mode groups
if (imageModeGroup) imageModeGroup.classList.toggle('d-none', isAudio);
if (audioModeGroup) audioModeGroup.classList.toggle('d-none', !isAudio);
// Update summary
if (stepCarrierTypeSummary) {
stepCarrierTypeSummary.textContent = isAudio ? 'Audio' : 'Image';
}
// Select default mode
if (isAudio) {
const audioAuto = document.getElementById('modeAudioAuto');
if (audioAuto) audioAuto.checked = true;
} else {
const autoMode = document.getElementById('modeAuto');
if (autoMode) autoMode.checked = true;
}
// Clear stego file selections
const stegoInput = document.getElementById('stegoInput');
const audioStegoInput = document.getElementById('audioStegoInput');
if (stegoInput) stegoInput.value = '';
if (audioStegoInput) audioStegoInput.value = '';
// Reset previews
document.getElementById('stegoPreview')?.classList.add('d-none');
// Update mode hint
const hint = document.getElementById('modeHint');
if (hint) {
if (isAudio) {
hint.innerHTML = '<i class="bi bi-lightning me-1"></i>Tries LSB first, then Spread Spectrum';
} else {
hint.innerHTML = '<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT';
}
}
updateImagesSummary();
});
});
// Audio stego file info display
const audioStegoInput = document.getElementById('audioStegoInput');
audioStegoInput?.addEventListener('change', function() {
if (this.files && this.files[0]) {
const file = this.files[0];
document.getElementById('audioStegoFileName').textContent = file.name;
document.getElementById('audioStegoFileSize').textContent = (file.size / 1024).toFixed(1) + ' KB';
updateImagesSummary();
}
});
// ============================================================================ // ============================================================================
// MODE SWITCHING // MODE SWITCHING
// ============================================================================ // ============================================================================
const modeRadios = document.querySelectorAll('input[name="embed_mode"]'); // Apply disabled styling to DCT if not available
const modeBtns = { 'auto': document.getElementById('autoModeCard'), 'lsb': document.getElementById('lsbModeCard'), 'dct': document.getElementById('dctModeCard') }; if (document.getElementById('modeDct')?.disabled) {
document.getElementById('dctModeLabel')?.classList.add('disabled', 'text-muted');
modeRadios.forEach(radio => { }
radio.addEventListener('change', () => {
Object.values(modeBtns).forEach(btn => btn?.classList.remove('active'));
modeBtns[radio.value]?.classList.add('active');
});
});
// ============================================================================ // ============================================================================
// LOADING STATE // LOADING STATE

View File

@@ -6,20 +6,29 @@
<style> <style>
/* Accordion styling */ /* Accordion styling */
.step-accordion .accordion-button { .step-accordion .accordion-button {
background: rgba(30, 40, 50, 0.6); background: rgba(35, 45, 55, 0.8);
color: #fff; color: #fff;
padding: 0.75rem 1rem; 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; 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) { .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; color: #fff;
box-shadow: inset 0 1px 0 rgba(99, 179, 237, 0.1); box-shadow: inset 0 1px 0 rgba(255, 230, 153, 0.1);
border-left: 3px solid rgba(99, 179, 237, 0.6); border-left: 3px solid #ffe699;
} }
.step-accordion .accordion-button::after { .step-accordion .accordion-button::after {
filter: invert(1); filter: brightness(0) invert(1);
opacity: 0.5;
}
.step-accordion .accordion-button:not(.collapsed)::after {
opacity: 0.9;
} }
.step-accordion .accordion-body { .step-accordion .accordion-body {
background: rgba(30, 40, 50, 0.4); background: rgba(30, 40, 50, 0.4);
@@ -106,46 +115,7 @@
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important; box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
} }
/* QR Crop Animation */ /* QR Crop Animation - uses .qr-scan-container from style.css */
.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);
}
</style> </style>
<div class="row justify-content-center"> <div class="row justify-content-center">
@@ -160,14 +130,14 @@
<div class="accordion step-accordion" id="encodeAccordion"> <div class="accordion step-accordion" id="encodeAccordion">
<!-- ================================================================ <!-- ================================================================
STEP 1: IMAGES STEP 1: CARRIER & MODE
================================================================ --> ================================================================ -->
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages"> <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
<span class="step-title"> <span class="step-title">
<span class="step-number" id="stepImagesNumber">1</span> <span class="step-number" id="stepImagesNumber">1</span>
<i class="bi bi-images me-1"></i> Images & Mode <i class="bi bi-images me-1"></i> Carrier & Mode
</span> </span>
<span class="step-summary" id="stepImagesSummary">Select reference & carrier</span> <span class="step-summary" id="stepImagesSummary">Select reference & carrier</span>
</button> </button>
@@ -175,6 +145,8 @@
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#encodeAccordion"> <div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#encodeAccordion">
<div class="accordion-body"> <div class="accordion-body">
<input type="hidden" name="carrier_type" id="carrierTypeInput" value="image">
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label"> <label class="form-label">
@@ -202,28 +174,47 @@
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label"> <label class="form-label">
<i class="bi bi-file-image me-1"></i> Carrier Image <i class="bi bi-file-earmark me-1"></i> Carrier File
</label> </label>
<div class="drop-zone pixel-container" id="carrierDropZone"> <div id="imageCarrierSection">
<input type="file" name="carrier" accept="image/*" required id="carrierInput"> <div class="drop-zone pixel-container" id="carrierDropZone">
<div class="drop-zone-label"> <input type="file" name="carrier" accept="image/*" required id="carrierInput">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i> <div class="drop-zone-label">
<span class="text-muted">Drop image or click</span> <i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
</div> <span class="text-muted">Drop image or click</span>
<img class="drop-zone-preview d-none" id="carrierPreview"> </div>
<div class="pixel-blocks"></div> <img class="drop-zone-preview d-none" id="carrierPreview">
<div class="pixel-scan-line"></div> <div class="pixel-blocks"></div>
<div class="pixel-corners"> <div class="pixel-scan-line"></div>
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div> <div class="pixel-corners">
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div> <div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
</div> <div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
<div class="pixel-data-panel"> </div>
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="carrierFileName">image.jpg</span></div> <div class="pixel-data-panel">
<div class="pixel-data-row"><span class="pixel-status-badge">Carrier Loaded</span><span class="pixel-data-value" id="carrierFileSize">--</span></div> <div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="carrierFileName">image.jpg</span></div>
<div class="pixel-dimensions" id="carrierDims">-- x -- px</div> <div class="pixel-data-row"><span class="pixel-status-badge">Carrier Loaded</span><span class="pixel-data-value" id="carrierFileSize">--</span></div>
<div class="pixel-dimensions" id="carrierDims">-- x -- px</div>
</div>
</div> </div>
<div class="form-text" id="imageCarrierHint">Image to hide your message in</div>
</div>
<!-- Audio Carrier (hidden by default, shown when audio type selected) -->
<div class="d-none" id="audioCarrierSection">
<div class="drop-zone pixel-container" id="audioCarrierDropZone">
<input type="file" name="audio_carrier" accept="audio/*" id="audioCarrierInput">
<div class="drop-zone-label">
<i class="bi bi-music-note-beamed fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop audio or click</span>
</div>
<div class="pixel-data-panel">
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="audioCarrierFileName">audio.wav</span></div>
<div class="pixel-data-row"><span class="pixel-status-badge">Audio Loaded</span><span class="pixel-data-value" id="audioCarrierFileSize">--</span></div>
<div class="pixel-dimensions" id="audioCarrierDuration">--:-- duration</div>
</div>
</div>
<div class="form-text" id="audioCarrierHint">Audio file to hide your message in</div>
</div> </div>
<div class="form-text">Image to hide your message in</div>
</div> </div>
</div> </div>
@@ -238,35 +229,76 @@
</div> </div>
</div> </div>
<!-- Embedding Mode (compact inline) --> <!-- Audio Capacity Info (v4.3.0) -->
<div class="d-flex gap-2 align-items-center flex-wrap mb-2"> <div class="alert alert-info small d-none mb-3" id="audioCapacityPanel">
<label class="mode-btn mode-btn-sm {% if not has_dct %}opacity-50{% endif %} {% if has_dct %}active{% endif %}" id="dctModeCard" for="modeDct"> <div class="d-flex justify-content-between align-items-center">
<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 %}> <span><i class="bi bi-music-note-beamed me-1"></i><span id="audioInfo">-</span></span>
<i class="bi bi-soundwave text-warning"></i> DCT <span>
</label> <span class="badge bg-primary me-1" id="lsbAudioCapacityBadge">LSB: -</span>
<label class="mode-btn mode-btn-sm {% if not has_dct %}active{% endif %}" id="lsbModeCard" for="modeLsb"> <span class="badge bg-warning text-dark" id="spreadCapacityBadge">Spread: -</span>
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsb" value="lsb" {% if not has_dct %}checked{% endif %}> </span>
<i class="bi bi-grid-3x3-gap text-primary"></i> LSB </div>
</label>
<span class="d-flex gap-2 align-items-center" id="outputOptions">
<span class="text-muted">|</span>
<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>
<span class="text-muted">|</span>
<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>
<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 %} <!-- Capacity Warning -->
<div class="form-text text-danger d-none" id="capacityWarning">
<i class="bi bi-exclamation-triangle-fill me-1"></i><span id="capacityWarningText"></span>
</div>
<!-- Mode & Carrier Type toggles (aligned row) -->
<div class="row">
<div class="col-md-6">
<div id="imageModeGroup">
<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>
<!-- Audio Modes (hidden by default) -->
<div class="d-none" id="audioModeGroup">
<div class="btn-group btn-group-sm mb-2" role="group">
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioLsb" value="audio_lsb">
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeAudioLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioSpread" value="audio_spread">
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeAudioSpread"><i class="bi bi-broadcast me-1"></i>Spread</label>
</div>
</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>
<div class="col-md-6">
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group">
<input type="radio" class="btn-check" name="carrier_type_select" id="typeImage" value="image" checked>
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="typeImage"><i class="bi bi-image me-1"></i>Image</label>
<input type="radio" class="btn-check" name="carrier_type_select" id="typeAudio" value="audio" {% if not has_audio %}disabled{% endif %}>
<label class="btn btn-outline-secondary btn-sm text-nowrap {% if not has_audio %}disabled text-muted{% endif %}" for="typeAudio"><i class="bi bi-music-note-beamed me-1"></i>Audio</label>
</div>
{% if not has_audio %}
<span class="form-text text-warning mb-0" style="font-size: 0.7rem;"><i class="bi bi-exclamation-triangle me-1"></i>Requires numpy + soundfile</span>
{% endif %}
</div>
</div>
</div> </div>
</div> </div>
@@ -426,7 +458,7 @@
<i class="bi bi-qr-code-scan fs-5 d-block text-muted mb-1"></i> <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> <span class="text-muted small">Drop QR image</span>
</div> </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-original" id="qrOriginal" alt="Original">
<img class="qr-cropped" id="qrCropped" alt="Cropped"> <img class="qr-cropped" id="qrCropped" alt="Cropped">
</div> </div>
@@ -471,7 +503,7 @@
</div> </div>
<div class="col-4"> <div class="col-4">
<i class="bi bi-eye-slash fs-5 d-block mb-1 text-warning"></i> <i class="bi bi-eye-slash fs-5 d-block mb-1 text-warning"></i>
Undetectable Covertly Embedded
</div> </div>
</div> </div>
</div> </div>
@@ -486,7 +518,9 @@
// ============================================================================ // ============================================================================
const modeHints = { const modeHints = {
dct: { icon: 'phone', text: 'Survives social media compression' }, dct: { icon: 'phone', text: 'Survives social media compression' },
lsb: { icon: 'hdd', text: 'Higher capacity, outputs Color PNG' } lsb: { icon: 'hdd', text: 'Higher capacity, outputs Color PNG' },
audio_lsb: { icon: 'soundwave', text: 'Highest capacity, lossless carriers only (WAV/FLAC)' },
audio_spread: { icon: 'broadcast', text: 'Lower capacity, survives lossy conversion (MP3/AAC)' }
}; };
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => { document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
@@ -499,13 +533,212 @@ document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
}); });
}); });
// ============================================================================
// CARRIER TYPE TOGGLE (v4.3.0)
// ============================================================================
const carrierTypeRadios = document.querySelectorAll('input[name="carrier_type_select"]');
const carrierTypeInput = document.getElementById('carrierTypeInput');
const imageCarrierSection = document.getElementById('imageCarrierSection');
const audioCarrierSection = document.getElementById('audioCarrierSection');
const imageModeGroup = document.getElementById('imageModeGroup');
const audioModeGroup = document.getElementById('audioModeGroup');
const capacityPanel = document.getElementById('capacityPanel');
const audioCapacityPanel = document.getElementById('audioCapacityPanel');
// Capacity tracking for client-side payload size validation
let capacityBytes = { dct: 0, lsb: 0, audio_lsb: 0, audio_spread: 0 };
function checkCapacity() {
const warning = document.getElementById('capacityWarning');
const warningText = document.getElementById('capacityWarningText');
const encodeBtn = document.getElementById('encodeBtn');
if (!warning || !warningText || !encodeBtn) return;
// Determine payload size
const isText = document.getElementById('payloadText')?.checked;
let payloadSize = 0;
if (isText) {
const msg = document.getElementById('messageInput')?.value || '';
if (msg) payloadSize = new Blob([msg]).size;
} else {
const file = document.getElementById('payloadFileInput')?.files[0];
if (file) payloadSize = file.size;
}
// Get active mode
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'lsb';
const cap = capacityBytes[mode] || 0;
// Update char percent to use real capacity
if (isText) {
const charPercent = document.getElementById('charPercent');
if (charPercent) {
const effectiveCap = cap > 0 ? cap : 250000;
charPercent.textContent = Math.round((payloadSize / effectiveCap) * 100) + '%';
}
}
// Reset badge colors
const badgeMap = {
dct: 'dctCapacityBadge',
lsb: 'lsbCapacityBadge',
audio_lsb: 'lsbAudioCapacityBadge',
audio_spread: 'spreadCapacityBadge'
};
// Restore default badge colors
const dctBadge = document.getElementById('dctCapacityBadge');
const lsbBadge = document.getElementById('lsbCapacityBadge');
const audioLsbBadge = document.getElementById('lsbAudioCapacityBadge');
const spreadBadge = document.getElementById('spreadCapacityBadge');
if (dctBadge) { dctBadge.classList.remove('bg-danger'); dctBadge.classList.add('bg-warning'); }
if (lsbBadge) { lsbBadge.classList.remove('bg-danger'); lsbBadge.classList.add('bg-primary'); }
if (audioLsbBadge) { audioLsbBadge.classList.remove('bg-danger'); audioLsbBadge.classList.add('bg-primary'); }
if (spreadBadge) { spreadBadge.classList.remove('bg-danger'); spreadBadge.classList.add('bg-warning'); }
// No carrier or no payload — clear warning
if (cap === 0 || payloadSize === 0) {
warning.classList.add('d-none');
encodeBtn.disabled = false;
return;
}
if (payloadSize > cap) {
// Exceeds capacity — show warning, turn badge red, disable button
const activeBadge = document.getElementById(badgeMap[mode]);
if (activeBadge) {
activeBadge.classList.remove('bg-primary', 'bg-warning');
activeBadge.classList.add('bg-danger');
}
const needed = (payloadSize / 1024).toFixed(1);
const available = (cap / 1024).toFixed(1);
warningText.textContent = `Payload too large: ${needed} KB needed, only ${available} KB capacity in ${mode.replace('_', ' ').toUpperCase()} mode`;
warning.classList.remove('d-none');
encodeBtn.disabled = true;
} else {
warning.classList.add('d-none');
encodeBtn.disabled = false;
}
}
carrierTypeRadios.forEach(radio => {
radio.addEventListener('change', function() {
const isAudio = this.value === 'audio';
carrierTypeInput.value = this.value;
// Toggle carrier sections
if (imageCarrierSection) imageCarrierSection.classList.toggle('d-none', isAudio);
if (audioCarrierSection) audioCarrierSection.classList.toggle('d-none', !isAudio);
// Toggle required attribute so hidden inputs don't block form submission
const imgCarrier = document.getElementById('carrierInput');
const audCarrier = document.getElementById('audioCarrierInput');
if (imgCarrier) { if (isAudio) imgCarrier.removeAttribute('required'); else imgCarrier.setAttribute('required', ''); }
if (audCarrier) { if (isAudio) audCarrier.setAttribute('required', ''); else audCarrier.removeAttribute('required'); }
// Toggle mode groups
if (imageModeGroup) imageModeGroup.classList.toggle('d-none', isAudio);
if (audioModeGroup) audioModeGroup.classList.toggle('d-none', !isAudio);
// Toggle capacity panels and reset capacity values
if (capacityPanel) capacityPanel.classList.add('d-none');
if (audioCapacityPanel) audioCapacityPanel.classList.add('d-none');
if (isAudio) {
capacityBytes.dct = 0;
capacityBytes.lsb = 0;
} else {
capacityBytes.audio_lsb = 0;
capacityBytes.audio_spread = 0;
}
checkCapacity();
// Select default mode for the active type and update hint
if (isAudio) {
const audioLsb = document.getElementById('modeAudioLsb');
if (audioLsb) { audioLsb.checked = true; audioLsb.dispatchEvent(new Event('change')); }
} else {
// Reset to DCT if available, else LSB
const dctRadio = document.getElementById('modeDct');
const lsbRadio = document.getElementById('modeLsb');
if (dctRadio && !dctRadio.disabled) {
dctRadio.checked = true; dctRadio.dispatchEvent(new Event('change'));
} else if (lsbRadio) {
lsbRadio.checked = true; lsbRadio.dispatchEvent(new Event('change'));
}
}
// Clear carrier file selections
const carrierInput = document.getElementById('carrierInput');
const audioCarrierInput = document.getElementById('audioCarrierInput');
if (carrierInput) carrierInput.value = '';
if (audioCarrierInput) audioCarrierInput.value = '';
// Reset previews
document.getElementById('carrierPreview')?.classList.add('d-none');
// Update step title
const stepImagesTitle = document.querySelector('#stepImages')?.closest('.accordion-item')?.querySelector('.accordion-button .step-title');
if (stepImagesTitle) {
const icon = stepImagesTitle.querySelector('i:not(.step-number i)');
const textNode = stepImagesTitle.childNodes[stepImagesTitle.childNodes.length - 1];
if (icon) {
icon.className = isAudio ? 'bi bi-music-note-beamed me-1' : 'bi bi-images me-1';
}
}
updateImagesSummary();
});
});
// Audio carrier file change handler
const audioCarrierInput = document.getElementById('audioCarrierInput');
audioCarrierInput?.addEventListener('change', function() {
if (this.files && this.files[0]) {
const file = this.files[0];
document.getElementById('audioCarrierFileName').textContent = file.name;
document.getElementById('audioCarrierFileSize').textContent = (file.size / 1024).toFixed(1) + ' KB';
// Fetch audio capacity
const formData = new FormData();
formData.append('carrier', file);
fetch('/api/audio-capacity', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.error) return;
const info = `${data.format || 'Audio'} · ${data.sample_rate}Hz · ${data.channels}ch · ${data.duration}s`;
document.getElementById('audioInfo').textContent = info;
document.getElementById('lsbAudioCapacityBadge').textContent = `LSB: ${(data.lsb_capacity / 1024).toFixed(1)} KB`;
document.getElementById('spreadCapacityBadge').textContent = `Spread: ${(data.spread_capacity / 1024).toFixed(1)} KB`;
capacityBytes.audio_lsb = data.lsb_capacity;
capacityBytes.audio_spread = data.spread_capacity;
document.getElementById('audioCapacityPanel')?.classList.remove('d-none');
checkCapacity();
if (data.duration) {
document.getElementById('audioCarrierDuration').textContent = data.duration + 's duration';
}
}).catch(() => {});
// Trigger the drop zone animation
const dropZone = document.getElementById('audioCarrierDropZone');
if (dropZone) {
dropZone.classList.add('has-file');
}
updateImagesSummary();
}
});
// ============================================================================ // ============================================================================
// ACCORDION SUMMARY UPDATES // ACCORDION SUMMARY UPDATES
// ============================================================================ // ============================================================================
function updateImagesSummary() { function updateImagesSummary() {
const ref = document.getElementById('refPhotoInput')?.files[0]; const ref = document.getElementById('refPhotoInput')?.files[0];
const carrier = document.getElementById('carrierInput')?.files[0]; const isAudio = carrierTypeInput?.value === 'audio';
const carrier = isAudio
? document.getElementById('audioCarrierInput')?.files[0]
: document.getElementById('carrierInput')?.files[0];
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'LSB'; const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'LSB';
const summary = document.getElementById('stepImagesSummary'); const summary = document.getElementById('stepImagesSummary');
const stepNum = document.getElementById('stepImagesNumber'); const stepNum = document.getElementById('stepImagesNumber');
@@ -523,7 +756,7 @@ function updateImagesSummary() {
stepNum.classList.remove('complete'); stepNum.classList.remove('complete');
stepNum.textContent = '1'; stepNum.textContent = '1';
} else { } else {
summary.textContent = 'Select reference & carrier'; summary.textContent = isAudio ? 'Select reference & audio' : 'Select reference & carrier';
summary.classList.remove('has-content'); summary.classList.remove('has-content');
stepNum.classList.remove('complete'); stepNum.classList.remove('complete');
stepNum.textContent = '1'; stepNum.textContent = '1';
@@ -587,7 +820,9 @@ function updateSecuritySummary() {
// Attach listeners // Attach listeners
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary); document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
document.getElementById('carrierInput')?.addEventListener('change', updateImagesSummary); document.getElementById('carrierInput')?.addEventListener('change', updateImagesSummary);
document.getElementById('audioCarrierInput')?.addEventListener('change', updateImagesSummary);
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary)); document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
document.querySelectorAll('#audioModeGroup input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
document.getElementById('messageInput')?.addEventListener('input', updatePayloadSummary); document.getElementById('messageInput')?.addEventListener('input', updatePayloadSummary);
document.getElementById('payloadFileInput')?.addEventListener('change', updatePayloadSummary); document.getElementById('payloadFileInput')?.addEventListener('change', updatePayloadSummary);
@@ -620,6 +855,7 @@ function updatePayloadSection() {
payloadFileInput.setAttribute('required', ''); payloadFileInput.setAttribute('required', '');
} }
updatePayloadSummary(); updatePayloadSummary();
checkCapacity();
} }
payloadTextRadio?.addEventListener('change', updatePayloadSection); payloadTextRadio?.addEventListener('change', updatePayloadSection);
@@ -643,6 +879,7 @@ payloadFileInput?.addEventListener('change', function() {
} else { } else {
fileInfo?.classList.add('d-none'); fileInfo?.classList.add('d-none');
} }
checkCapacity();
}); });
// ============================================================================ // ============================================================================
@@ -652,7 +889,7 @@ payloadFileInput?.addEventListener('change', function() {
messageInput?.addEventListener('input', function() { messageInput?.addEventListener('input', function() {
const count = this.value.length; const count = this.value.length;
document.getElementById('charCount').textContent = count.toLocaleString(); document.getElementById('charCount').textContent = count.toLocaleString();
document.getElementById('charPercent').textContent = Math.round((count / 250000) * 100) + '%'; checkCapacity();
}); });
// ============================================================================ // ============================================================================
@@ -671,7 +908,10 @@ carrierInput?.addEventListener('change', function() {
document.getElementById('carrierDimensions').textContent = `${data.width} x ${data.height}`; document.getElementById('carrierDimensions').textContent = `${data.width} x ${data.height}`;
document.getElementById('lsbCapacityBadge').textContent = `LSB: ${data.lsb.capacity_kb} KB`; document.getElementById('lsbCapacityBadge').textContent = `LSB: ${data.lsb.capacity_kb} KB`;
document.getElementById('dctCapacityBadge').textContent = `DCT: ${data.dct.capacity_kb} KB`; document.getElementById('dctCapacityBadge').textContent = `DCT: ${data.dct.capacity_kb} KB`;
capacityBytes.lsb = Math.round(data.lsb.capacity_kb * 1024);
capacityBytes.dct = Math.round(data.dct.capacity_kb * 1024);
document.getElementById('capacityPanel')?.classList.remove('d-none'); document.getElementById('capacityPanel')?.classList.remove('d-none');
checkCapacity();
}).catch(() => {}); }).catch(() => {});
} }
}); });
@@ -681,7 +921,7 @@ carrierInput?.addEventListener('change', function() {
// ============================================================================ // ============================================================================
const modeRadios = document.querySelectorAll('input[name="embed_mode"]'); const modeRadios = document.querySelectorAll('input[name="embed_mode"]');
const modeBtns = { 'dct': document.getElementById('dctModeCard'), 'lsb': document.getElementById('lsbModeCard') }; const dctModeLabel = document.getElementById('dctModeLabel');
const grayModeInput = document.getElementById('grayMode'); const grayModeInput = document.getElementById('grayMode');
const grayModeLabel = document.getElementById('grayModeLabel'); const grayModeLabel = document.getElementById('grayModeLabel');
const jpegFormatInput = document.getElementById('jpegFormat'); const jpegFormatInput = document.getElementById('jpegFormat');
@@ -689,6 +929,11 @@ const jpegFormatLabel = document.getElementById('jpegFormatLabel');
const colorModeInput = document.getElementById('colorMode'); const colorModeInput = document.getElementById('colorMode');
const pngFormatInput = document.getElementById('pngFormat'); 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) { function updateOutputOptions(mode) {
const isLsb = mode === 'lsb'; const isLsb = mode === 'lsb';
if (isLsb) { if (isLsb) {
@@ -711,11 +956,7 @@ function updateOutputOptions(mode) {
} }
modeRadios.forEach(radio => { modeRadios.forEach(radio => {
radio.addEventListener('change', () => { radio.addEventListener('change', () => { updateOutputOptions(radio.value); checkCapacity(); });
Object.values(modeBtns).forEach(btn => btn?.classList.remove('active'));
modeBtns[radio.value]?.classList.add('active');
updateOutputOptions(radio.value);
});
}); });
// Initialize output options based on initial mode // Initialize output options based on initial mode

View File

@@ -12,12 +12,26 @@
</h5> </h5>
</div> </div>
<div class="card-body text-center"> <div class="card-body text-center">
{% if carrier_type == 'audio' %}
<!-- Audio Preview -->
<div class="my-4">
<div class="text-center">
<i class="bi bi-music-note-beamed text-success" style="font-size: 4rem;"></i>
<div class="mt-2">
<audio controls src="{{ url_for('encode_file_route', file_id=file_id) }}" class="w-100" style="max-width: 400px;"></audio>
</div>
<div class="mt-2 small text-muted">
<i class="bi bi-music-note-beamed me-1"></i>Encoded Audio Preview
</div>
</div>
</div>
{% else %}
<div class="my-4"> <div class="my-4">
{% if thumbnail_url %} {% if thumbnail_url %}
<!-- Thumbnail of the actual encoded image --> <!-- Thumbnail of the actual encoded image -->
<div class="encoded-image-thumbnail"> <div class="encoded-image-thumbnail">
<img src="{{ thumbnail_url }}" <img src="{{ thumbnail_url }}"
alt="Encoded image thumbnail" alt="Encoded image thumbnail"
class="img-thumbnail rounded" class="img-thumbnail rounded"
style="max-width: 250px; max-height: 250px; object-fit: contain;"> style="max-width: 250px; max-height: 250px; object-fit: contain;">
<div class="mt-2 small text-muted"> <div class="mt-2 small text-muted">
@@ -29,8 +43,9 @@
<i class="bi bi-file-earmark-image text-success" style="font-size: 4rem;"></i> <i class="bi bi-file-earmark-image text-success" style="font-size: 4rem;"></i>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<p class="lead mb-4">Your secret has been hidden in the image.</p> <p class="lead mb-4">Your secret has been hidden in the {{ 'audio file' if carrier_type == 'audio' else 'image' }}.</p>
<div class="mb-3"> <div class="mb-3">
<code class="fs-5">{{ filename }}</code> <code class="fs-5">{{ filename }}</code>
@@ -38,11 +53,32 @@
<!-- Mode and format badges --> <!-- Mode and format badges -->
<div class="mb-4"> <div class="mb-4">
{% if embed_mode == 'dct' %} {% if carrier_type == 'audio' %}
<!-- Audio mode badges -->
{% if embed_mode == 'audio_spread' %}
<span class="badge bg-warning text-dark fs-6">
<i class="bi bi-broadcast me-1"></i>Spread Spectrum
</span>
{% else %}
<span class="badge bg-primary fs-6">
<i class="bi bi-grid-3x3-gap me-1"></i>Audio LSB
</span>
{% endif %}
<span class="badge bg-info fs-6 ms-1">
<i class="bi bi-file-earmark-music me-1"></i>WAV
</span>
<div class="small text-muted mt-2">
{% if embed_mode == 'audio_spread' %}
Spread spectrum embedding in audio samples
{% else %}
LSB embedding in audio samples, WAV output
{% endif %}
</div>
{% elif embed_mode == 'dct' %}
<span class="badge bg-info fs-6"> <span class="badge bg-info fs-6">
<i class="bi bi-soundwave me-1"></i>DCT Mode <i class="bi bi-soundwave me-1"></i>DCT Mode
</span> </span>
<!-- Color mode badge (v3.0.1) --> <!-- Color mode badge (v3.0.1) -->
{% if color_mode == 'color' %} {% if color_mode == 'color' %}
<span class="badge bg-success fs-6 ms-1"> <span class="badge bg-success fs-6 ms-1">
@@ -53,7 +89,7 @@
<i class="bi bi-circle-half me-1"></i>Grayscale <i class="bi bi-circle-half me-1"></i>Grayscale
</span> </span>
{% endif %} {% endif %}
<!-- Output format badge --> <!-- Output format badge -->
{% if output_format == 'jpeg' %} {% if output_format == 'jpeg' %}
<span class="badge bg-warning text-dark fs-6 ms-1"> <span class="badge bg-warning text-dark fs-6 ms-1">
@@ -78,7 +114,7 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="badge bg-primary fs-6"> <span class="badge bg-primary fs-6">
<i class="bi bi-grid-3x3-gap me-1"></i>LSB Mode <i class="bi bi-grid-3x3-gap me-1"></i>LSB Mode
@@ -114,7 +150,7 @@
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<a href="{{ url_for('encode_download', file_id=file_id) }}" <a href="{{ url_for('encode_download', file_id=file_id) }}"
class="btn btn-primary btn-lg" id="downloadBtn"> class="btn btn-primary btn-lg" id="downloadBtn">
<i class="bi bi-download me-2"></i>Download Image <i class="bi bi-download me-2"></i>Download {{ 'Audio' if carrier_type == 'audio' else 'Image' }}
</a> </a>
<button type="button" class="btn btn-outline-primary" id="shareBtn" style="display: none;"> <button type="button" class="btn btn-outline-primary" id="shareBtn" style="display: none;">
@@ -128,7 +164,12 @@
<i class="bi bi-exclamation-triangle me-1"></i> <i class="bi bi-exclamation-triangle me-1"></i>
<strong>Important:</strong> <strong>Important:</strong>
<ul class="mb-0 mt-2"> <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>
{% if carrier_type == 'audio' %}
<li>Do <strong>not</strong> re-encode or convert the audio file</li>
<li>WAV format preserves your hidden data losslessly</li>
<li>Sharing via platforms that re-encode audio will destroy the hidden data</li>
{% else %}
<li>Do <strong>not</strong> resize or recompress the image</li> <li>Do <strong>not</strong> resize or recompress the image</li>
{% if embed_mode == 'dct' and output_format == 'jpeg' %} {% if embed_mode == 'dct' and output_format == 'jpeg' %}
<li>JPEG format is lossy - avoid re-saving or editing</li> <li>JPEG format is lossy - avoid re-saving or editing</li>
@@ -141,6 +182,7 @@
<li>Color preserved - extraction works on both color and grayscale</li> <li>Color preserved - extraction works on both color and grayscale</li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endif %}
{% if channel_mode == 'private' %} {% if channel_mode == 'private' %}
<li><i class="bi bi-shield-lock text-warning me-1"></i>Recipient needs the <strong>same channel key</strong> to decode</li> <li><i class="bi bi-shield-lock text-warning me-1"></i>Recipient needs the <strong>same channel key</strong> to decode</li>
{% endif %} {% endif %}
@@ -148,7 +190,7 @@
</div> </div>
<a href="{{ url_for('encode_page') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('encode_page') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-repeat me-2"></i>Encode Another Message <i class="bi bi-arrow-repeat me-2"></i>Encode Another
</a> </a>
</div> </div>
</div> </div>
@@ -162,7 +204,7 @@
const shareBtn = document.getElementById('shareBtn'); const shareBtn = document.getElementById('shareBtn');
const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}"; const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}";
const fileName = "{{ filename }}"; const fileName = "{{ filename }}";
const mimeType = "{{ 'image/jpeg' if embed_mode == 'dct' and output_format == 'jpeg' else 'image/png' }}"; const mimeType = "{{ 'audio/wav' if carrier_type == 'audio' else ('image/jpeg' if embed_mode == 'dct' and output_format == 'jpeg' else 'image/png') }}";
if (navigator.share && navigator.canShare) { if (navigator.share && navigator.canShare) {
// Check if we can share files // Check if we can share files

View File

@@ -65,11 +65,7 @@
<select name="rsa_bits" class="form-select form-select-sm" id="rsaBitsSelect"> <select name="rsa_bits" class="form-select form-select-sm" id="rsaBitsSelect">
<option value="2048" selected>2048 bits (~128 bits entropy)</option> <option value="2048" selected>2048 bits (~128 bits entropy)</option>
<option value="3072">3072 bits (~128 bits entropy)</option> <option value="3072">3072 bits (~128 bits entropy)</option>
<option value="4096">4096 bits (~128 bits entropy)</option>
</select> </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> </div>
</div> </div>
@@ -100,8 +96,8 @@
<span class="input-group-text"><i class="bi bi-key"></i></span> <span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="text" class="form-control font-monospace" id="channelKeyGenerated" <input type="text" class="form-control font-monospace" id="channelKeyGenerated"
placeholder="Click Generate to create a key" readonly> placeholder="Click Generate to create a key" readonly>
<button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn"> <button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn" title="Generate Channel Key">
<i class="bi bi-shuffle me-1"></i>Generate <i class="bi bi-shuffle"></i>
</button> </button>
<button class="btn btn-outline-secondary" type="button" id="copyChannelKeyBtn" disabled title="Copy to clipboard"> <button class="btn btn-outline-secondary" type="button" id="copyChannelKeyBtn" disabled title="Copy to clipboard">
<i class="bi bi-clipboard"></i> <i class="bi bi-clipboard"></i>
@@ -286,12 +282,6 @@
<i class="bi bi-shield-exclamation me-1"></i> <i class="bi bi-shield-exclamation me-1"></i>
<strong>Security note:</strong> The QR code contains your unencrypted private key. <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. 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> </div>
</div> </div>
@@ -483,17 +473,17 @@
/* Responsive */ /* Responsive */
@media (max-width: 576px) { @media (max-width: 576px) {
.pin-container, .passphrase-container { .pin-container, .passphrase-container {
padding: 1rem 1.25rem; padding: 1rem 0.75rem;
} }
.pin-digit-box { .pin-digit-box {
width: 2.25rem; width: 1.9rem;
height: 2.75rem; height: 2.4rem;
font-size: 1.25rem; font-size: 1.15rem;
} }
.pin-digits-row { .pin-digits-row {
gap: 0.35rem; gap: 0.25rem;
} }
.passphrase-text { .passphrase-text {

View File

@@ -3,170 +3,64 @@
{% block title %}Stegasoo - Secure Steganography{% endblock %} {% block title %}Stegasoo - Secure Steganography{% endblock %}
{% block content %} {% 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="d-flex flex-column align-items-center justify-content-center" style="min-height: 70vh;">
<div class="col-12">
<div class="d-flex align-items-end justify-content-center gap-4"> <!-- Hero -->
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="155"> <div class="d-flex align-items-center mb-4" style="gap: 8px;">
<div style="margin-bottom: 40px;"> <div class="position-relative">
<h1 class="display-4 fw-bold mb-2 title-gold"> <img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="80">
Stegasoo <span class="badge bg-success position-absolute" style="bottom: 1px; left: -6px; font-size: 0.6rem;">v4.1</span>
<span class="badge bg-success fs-6 ms-2">v4.1</span>
</h1>
<p class="lead text-muted mb-0">Hide encrypted data in plain sight.</p>
</div>
</div> </div>
</div>
</div>
<!-- Channel Status Banner (v4.0.0) -->
{% if channel_configured %}
<div class="alert alert-success mb-4">
<div class="d-flex align-items-center justify-content-between">
<div> <div>
<i class="bi bi-shield-lock me-2"></i> <h1 class="display-5 fw-bold title-gold mb-0">Stegasoo</h1>
<strong>Private Channel Mode</strong> <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 class="key-capsule">
<span class="badge led-badge-yellow"><span class="led-indicator led-yellow me-1"></span>Key Loaded</span>
<code class="small ms-2">{{ channel_fingerprint }}</code>
</div> </div>
</div> </div>
</div>
{% endif %}
<div class="row g-4 mb-5"> <!-- Action Icons -->
<!-- Encode Card --> <div class="d-flex gap-4">
<div class="col-md-4"> <a href="/encode" class="home-icon"><i class="bi bi-lock-fill"></i><span>Encode</span></a>
<a href="/encode" class="text-decoration-none card-link"> <a href="/decode" class="home-icon"><i class="bi bi-unlock-fill"></i><span>Decode</span></a>
<div class="card h-100 feature-card"> <a href="/generate" class="home-icon"><i class="bi bi-key-fill"></i><span>Generate</span></a>
<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>
</div> </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> </div>
{% endblock %} {% endblock %}

View File

@@ -22,17 +22,17 @@
<div class="tools-ribbon-divider"></div> <div class="tools-ribbon-divider"></div>
<div class="tools-ribbon-group"> <div class="tools-ribbon-group">
<button class="tool-icon-btn" data-tool="strip" title="Strip Metadata"> <button class="tool-icon-btn" data-tool="compress" title="JPEG Compression">
<i class="bi bi-eraser"></i> <i class="bi bi-file-zip"></i>
<span>Strip</span> <span>Compress</span>
</button> </button>
<button class="tool-icon-btn" data-tool="rotate" title="Rotate / Flip"> <button class="tool-icon-btn" data-tool="rotate" title="Rotate / Flip">
<i class="bi bi-arrow-repeat"></i> <i class="bi bi-arrow-repeat"></i>
<span>Rotate</span> <span>Rotate</span>
</button> </button>
<button class="tool-icon-btn" data-tool="compress" title="JPEG Compression"> <button class="tool-icon-btn" data-tool="strip" title="Strip Metadata">
<i class="bi bi-file-zip"></i> <i class="bi bi-eraser"></i>
<span>Compress</span> <span>Strip</span>
</button> </button>
<button class="tool-icon-btn" data-tool="convert" title="Format Convert"> <button class="tool-icon-btn" data-tool="convert" title="Format Convert">
<i class="bi bi-arrow-left-right"></i> <i class="bi bi-arrow-left-right"></i>
@@ -283,10 +283,8 @@
<span>Drop an image to view metadata</span> <span>Drop an image to view metadata</span>
</div> </div>
<div id="exifData" class="d-none"> <div id="exifData" class="d-none">
<div class="tool-exif-table"> <div class="exif-grid" id="exifGrid">
<table> <!-- Cards populated by JS -->
<tbody id="exifTable"></tbody>
</table>
</div> </div>
<div id="exifNoData" class="text-muted text-center py-3 d-none"> <div id="exifNoData" class="text-muted text-center py-3 d-none">
<i class="bi bi-inbox d-block mb-2"></i> <i class="bi bi-inbox d-block mb-2"></i>
@@ -368,6 +366,14 @@
<span class="tool-result-label">Flipped</span> <span class="tool-result-label">Flipped</span>
<span class="tool-result-value" id="rotateFlip">None</span> <span class="tool-result-value" id="rotateFlip">None</span>
</div> </div>
<div class="alert alert-success small mt-3 mb-0" id="rotateJpegSafe" style="display: none;">
<i class="bi bi-check-circle me-1"></i>
<strong>DCT Safe:</strong> Uses jpegtran for lossless JPEG rotation. Your stego data will be preserved.
</div>
<div class="alert alert-warning small mt-3 mb-0" id="rotateNonJpegWarn" style="display: none;">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Note:</strong> Non-JPEG images are re-encoded during rotation.
</div>
</div> </div>
</div> </div>
<div class="tool-results-actions d-none" id="rotateActions"> <div class="tool-results-actions d-none" id="rotateActions">
@@ -634,30 +640,104 @@ setupDropZone('exifZone', 'exifFile', async (file) => {
try { try {
const res = await fetch('/api/tools/exif', { method: 'POST', body: formData }); const res = await fetch('/api/tools/exif', { method: 'POST', body: formData });
// Check for auth redirect or non-JSON response
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
console.error('EXIF API returned non-JSON:', res.status, contentType);
document.getElementById('exifNoData').classList.remove('d-none');
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-exclamation-triangle d-block mb-2"></i>Session expired - please refresh';
document.getElementById('exifEmpty').classList.add('d-none');
document.getElementById('exifData').classList.remove('d-none');
return;
}
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
const tbody = document.getElementById('exifTable'); const grid = document.getElementById('exifGrid');
const entries = Object.entries(data.exif).sort((a, b) => a[0].localeCompare(b[0])); const entries = Object.entries(data.exif);
if (entries.length === 0) { if (entries.length === 0) {
tbody.innerHTML = ''; grid.innerHTML = '';
document.getElementById('exifNoData').classList.remove('d-none'); document.getElementById('exifNoData').classList.remove('d-none');
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-inbox d-block mb-2"></i>No metadata found';
} else { } else {
document.getElementById('exifNoData').classList.add('d-none'); document.getElementById('exifNoData').classList.add('d-none');
tbody.innerHTML = entries.map(([key, value]) => {
// Categorize EXIF fields
const categories = {
'Camera': ['Make', 'Model', 'Software', 'LensMake', 'LensModel', 'BodySerialNumber'],
'Image': ['ImageWidth', 'ImageLength', 'Orientation', 'ResolutionUnit', 'XResolution', 'YResolution', 'ColorSpace', 'ExifImageWidth', 'ExifImageHeight'],
'Date/Time': ['DateTime', 'DateTimeOriginal', 'DateTimeDigitized', 'SubsecTime', 'SubsecTimeOriginal', 'SubsecTimeDigitized', 'OffsetTime', 'OffsetTimeOriginal'],
'Exposure': ['ExposureTime', 'FNumber', 'ExposureProgram', 'ISOSpeedRatings', 'ExposureBiasValue', 'MaxApertureValue', 'MeteringMode', 'Flash', 'FocalLength', 'FocalLengthIn35mmFilm', 'WhiteBalance', 'ExposureMode', 'DigitalZoomRatio', 'SceneCaptureType', 'Contrast', 'Saturation', 'Sharpness'],
'GPS': ['GPSInfo', 'GPSLatitude', 'GPSLatitudeRef', 'GPSLongitude', 'GPSLongitudeRef', 'GPSAltitude', 'GPSAltitudeRef', 'GPSTimeStamp', 'GPSDateStamp'],
};
const categorized = {};
const other = [];
const allCategoryFields = new Set(Object.values(categories).flat());
entries.forEach(([key, value]) => {
let found = false;
for (const [cat, fields] of Object.entries(categories)) {
if (fields.includes(key)) {
if (!categorized[cat]) categorized[cat] = [];
categorized[cat].push([key, value]);
found = true;
break;
}
}
if (!found) other.push([key, value]);
});
// Render cards
let html = '';
const renderCard = ([key, value]) => {
let displayVal = typeof value === 'object' ? JSON.stringify(value) : String(value); let displayVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
if (displayVal.length > 40) displayVal = displayVal.substring(0, 37) + '...'; const needsTruncate = displayVal.length > 60;
return `<tr><th>${key}</th><td title="${String(value)}">${displayVal}</td></tr>`; if (needsTruncate) displayVal = displayVal.substring(0, 57) + '...';
}).join(''); const fullVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
return `<div class="exif-card" title="${fullVal.replace(/"/g, '&quot;')}">
<div class="exif-card-label">${key}</div>
<div class="exif-card-value${needsTruncate ? ' truncated' : ''}">${displayVal}</div>
</div>`;
};
// Render each category
for (const [cat, fields] of Object.entries(categories)) {
if (categorized[cat] && categorized[cat].length > 0) {
html += `<div class="exif-category"><i class="bi bi-${cat === 'Camera' ? 'camera' : cat === 'Image' ? 'image' : cat === 'Date/Time' ? 'clock' : cat === 'Exposure' ? 'aperture' : cat === 'GPS' ? 'geo-alt' : 'tag'} me-1"></i>${cat}</div>`;
html += categorized[cat].map(renderCard).join('');
}
}
// Render other fields
if (other.length > 0) {
html += `<div class="exif-category"><i class="bi bi-three-dots me-1"></i>Other</div>`;
html += other.map(renderCard).join('');
}
grid.innerHTML = html;
} }
document.getElementById('exifEmpty').classList.add('d-none'); document.getElementById('exifEmpty').classList.add('d-none');
document.getElementById('exifData').classList.remove('d-none'); document.getElementById('exifData').classList.remove('d-none');
document.getElementById('exifActions').classList.remove('d-none'); document.getElementById('exifActions').classList.remove('d-none');
} else {
// API returned success: false
console.error('EXIF API error:', data.error);
document.getElementById('exifNoData').classList.remove('d-none');
document.getElementById('exifNoData').innerHTML = `<i class="bi bi-exclamation-triangle d-block mb-2"></i>${data.error || 'Error reading metadata'}`;
document.getElementById('exifEmpty').classList.add('d-none');
document.getElementById('exifData').classList.remove('d-none');
} }
} catch (err) { } catch (err) {
console.error(err); console.error('EXIF fetch error:', err);
document.getElementById('exifNoData').classList.remove('d-none');
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-exclamation-triangle d-block mb-2"></i>Error loading metadata';
document.getElementById('exifEmpty').classList.add('d-none');
document.getElementById('exifData').classList.remove('d-none');
} }
}); });
@@ -796,6 +876,11 @@ setupDropZone('rotateZone', 'rotateFile', async (file) => {
document.getElementById('rotateData').classList.remove('d-none'); document.getElementById('rotateData').classList.remove('d-none');
document.getElementById('rotateActions').classList.remove('d-none'); document.getElementById('rotateActions').classList.remove('d-none');
// Show appropriate DCT warning based on file type
const isJpeg = file.type === 'image/jpeg' || file.name.toLowerCase().match(/\.jpe?g$/);
document.getElementById('rotateJpegSafe').style.display = isJpeg ? 'block' : 'none';
document.getElementById('rotateNonJpegWarn').style.display = isJpeg ? 'none' : 'block';
// Load image to get dimensions, then show preview // Load image to get dimensions, then show preview
const thumb = document.getElementById('rotateThumb'); const thumb = document.getElementById('rotateThumb');
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
@@ -889,6 +974,8 @@ function clearRotate() {
document.getElementById('rotateData').classList.add('d-none'); document.getElementById('rotateData').classList.add('d-none');
document.getElementById('rotateActions').classList.add('d-none'); document.getElementById('rotateActions').classList.add('d-none');
document.getElementById('rotateFileInfo').classList.add('d-none'); document.getElementById('rotateFileInfo').classList.add('d-none');
document.getElementById('rotateJpegSafe').style.display = 'none';
document.getElementById('rotateNonJpegWarn').style.display = 'none';
const thumb = document.getElementById('rotateThumb'); const thumb = document.getElementById('rotateThumb');
thumb.style.transform = ''; thumb.style.transform = '';
thumb.style.width = ''; thumb.style.width = '';
@@ -920,8 +1007,7 @@ document.getElementById('rotateDownload')?.addEventListener('click', async funct
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
const baseName = rotateCurrentFile?.name?.replace(/\.[^.]+$/, '') || 'rotated'; a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || 'rotated.jpg';
a.download = `${baseName}_transformed.png`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);

View File

@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
[project] [project]
name = "stegasoo" name = "stegasoo"
version = "4.1.5" version = "4.3.0"
description = "Secure steganography with hybrid photo + passphrase + PIN authentication" description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
requires-python = ">=3.10" requires-python = ">=3.11"
authors = [ authors = [
{ name = "Aaron D. Lee" } { name = "Aaron D. Lee" }
] ]
@@ -29,9 +29,10 @@ classifiers = [
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Security :: Cryptography", "Topic :: Security :: Cryptography",
"Topic :: Multimedia :: Graphics", "Topic :: Multimedia :: Graphics",
] ]
@@ -40,6 +41,7 @@ dependencies = [
"pillow>=10.0.0", "pillow>=10.0.0",
"cryptography>=41.0.0", "cryptography>=41.0.0",
"argon2-cffi>=23.0.0", "argon2-cffi>=23.0.0",
"zstandard>=0.22.0", # v4.2.0: Default compression algorithm
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -47,7 +49,14 @@ dependencies = [
dct = [ dct = [
"numpy>=2.0.0", "numpy>=2.0.0",
"scipy>=1.10.0", "scipy>=1.10.0",
"jpegio>=0.2.0", "jpeglib>=1.0.0",
"reedsolo>=1.7.0",
]
audio = [
"pydub>=0.25.0",
"numpy>=2.0.0",
"scipy>=1.10.0",
"soundfile>=0.12.0",
"reedsolo>=1.7.0", "reedsolo>=1.7.0",
] ]
cli = [ cli = [
@@ -57,7 +66,7 @@ cli = [
"rich>=13.0.0", "rich>=13.0.0",
] ]
compression = [ compression = [
"lz4>=4.0.0", "lz4>=4.0.0", # Optional: faster but slightly worse ratio than zstd
] ]
web = [ web = [
"flask>=3.0.0", "flask>=3.0.0",
@@ -68,7 +77,7 @@ web = [
# Include DCT support for web UI # Include DCT support for web UI
"numpy>=2.0.0", "numpy>=2.0.0",
"scipy>=1.10.0", "scipy>=1.10.0",
"jpegio>=0.2.0", "jpeglib>=1.0.0",
"reedsolo>=1.7.0", "reedsolo>=1.7.0",
] ]
api = [ api = [
@@ -80,11 +89,11 @@ api = [
# Include DCT support for API # Include DCT support for API
"numpy>=2.0.0", "numpy>=2.0.0",
"scipy>=1.10.0", "scipy>=1.10.0",
"jpegio>=0.2.0", "jpeglib>=1.0.0",
"reedsolo>=1.7.0", "reedsolo>=1.7.0",
] ]
all = [ all = [
"stegasoo[cli,web,api,dct,compression]", "stegasoo[cli,web,api,dct,audio,compression]",
] ]
dev = [ dev = [
"stegasoo[all]", "stegasoo[all]",
@@ -110,7 +119,14 @@ include = [
] ]
[tool.hatch.build.targets.wheel] [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] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
@@ -119,7 +135,7 @@ addopts = "-v --cov=stegasoo --cov-report=term-missing"
[tool.black] [tool.black]
line-length = 100 line-length = 100
target-version = ["py310", "py311", "py312"] target-version = ["py311", "py312", "py313"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
@@ -132,11 +148,13 @@ ignore = ["E501"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
# YCbCr colorspace variables (R, G, B, Y, Cb, Cr) are standard names # YCbCr colorspace variables (R, G, B, Y, Cb, Cr) are standard names
"src/stegasoo/dct_steganography.py" = ["N803", "N806"] "src/stegasoo/dct_steganography.py" = ["N803", "N806"]
# MDCT transform variables (N, X) are standard mathematical names
"src/stegasoo/spread_steganography.py" = ["N803", "N806"]
# Package __init__.py has imports after try/except and aliases - intentional structure # Package __init__.py has imports after try/except and aliases - intentional structure
"src/stegasoo/__init__.py" = ["E402"] "src/stegasoo/__init__.py" = ["E402"]
[tool.mypy] [tool.mypy]
python_version = "3.10" python_version = "3.11"
warn_return_any = true warn_return_any = true
warn_unused_configs = true warn_unused_configs = true
ignore_missing_imports = true ignore_missing_imports = true

View File

@@ -26,51 +26,50 @@ ssh admin@stegasoo.local
## Step 3: Pre-Setup ## Step 3: Pre-Setup
```bash ```bash
# Take ownership of /opt (for pyenv, jpegio builds) # Take ownership of /opt
sudo chown admin:admin /opt sudo chown admin:admin /opt
# Install git and zstd (not included in Lite image) # Install git (not included in Lite image)
sudo apt-get update && sudo apt-get install -y git zstd jq sudo apt-get update && sudo apt-get install -y git
``` ```
## Step 4: Clone Repo ## Step 4: Clone Repo
```bash ```bash
cd /opt 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) ## Step 5: Run Setup
> **Dev-only asset:** This tarball is for building Pi images, not for end users.
> It's available on [Releases](https://github.com/adlee-was-taken/stegasoo/releases) for image builders.
```bash
# On your host machine:
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
```
This tarball contains:
- pyenv with Python 3.12 (pre-compiled for ARM64)
- venv with all dependencies (jpegio, scipy, etc.)
Install time: **~2 minutes** (vs 20+ min from source)
## Step 6: Run Setup
```bash ```bash
cd /opt/stegasoo 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 ```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 ```bash
sudo systemctl start stegasoo sudo systemctl start stegasoo
@@ -78,7 +77,7 @@ curl -k https://localhost:5000
# Should return HTML # Should return HTML
``` ```
## Step 8: Sanitize for Distribution ## Step 7: Sanitize for Distribution
```bash ```bash
# Full sanitize (for final image - removes WiFi, shuts down) # Full sanitize (for final image - removes WiFi, shuts down)
@@ -98,7 +97,7 @@ This removes:
The script validates all cleanup steps before finishing. 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: Remove SD card, insert into your Linux machine:
@@ -107,12 +106,12 @@ Remove SD card, insert into your Linux machine:
lsblk lsblk
# Pull image (auto-resizes to 16GB, compresses with zstd) # 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. 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: After a successful from-source build, create the pre-built tarball for future installs:
```bash ```bash
# On the Pi after successful setup: # On the Pi after successful setup:
cd ~ cd /opt/stegasoo
# Strip caches and tests from venv (295MB → 208MB) # Strip caches and tests from venv (saves ~100MB)
find /opt/stegasoo/venv/ -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null find 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 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 find venv/ -type d -name 'test' -exec rm -rf {} + 2>/dev/null
# Create venv tarball # Create venv tarball
cd /opt/stegasoo tar -cf - venv/ | zstd -19 -T0 > /tmp/stegasoo-rpi-venv-arm64.tar.zst
tar -cf - venv/ | zstd -19 -T0 > ~/stegasoo-venv.tar.zst
# Create combined tarball (pyenv + venv pointer) # Check size (should be ~40-50MB)
cd ~ ls -lh /tmp/stegasoo-rpi-venv-arm64.tar.zst
tar -cf - .pyenv stegasoo-venv.tar.zst | zstd -19 -T0 > /tmp/stegasoo-rpi-runtime-env-arm64.tar.zst
# Check size (should be ~50-60MB)
ls -lh /tmp/stegasoo-rpi-runtime-env-arm64.tar.zst
``` ```
Pull to host and upload to GitHub releases: Pull to host and upload to GitHub releases:
```bash ```bash
# On host: # On host:
scp admin@stegasoo.local:/tmp/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-runtime-env-arm64.tar.zst # 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 ```bash
# On Pi (after SSH): # On Pi (after SSH):
sudo chown admin:admin /opt sudo chown admin:admin /opt
sudo apt-get update && sudo apt-get install -y git zstd jq sudo apt-get update && sudo apt-get install -y git
cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo cd /opt && git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
# On host (copy tarball): # Run setup:
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
# On Pi (run setup):
cd /opt/stegasoo && ./rpi/setup.sh cd /opt/stegasoo && ./rpi/setup.sh
sudo systemctl start stegasoo sudo systemctl start stegasoo
curl -k https://localhost:5000 curl -k https://localhost:5000
sudo /opt/stegasoo/rpi/sanitize-for-image.sh sudo /opt/stegasoo/rpi/sanitize-for-image.sh
# On host (pull image - auto-resizes to 16GB): # 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 ## Quick Install
On a fresh Raspberry Pi OS Lite (64-bit) installation: On a fresh Raspberry Pi OS (64-bit) installation:
```bash ```bash
# Pre-setup (git not included in Lite image) # 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 # Clone and run setup
cd /opt 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 cd stegasoo
./rpi/setup.sh ./rpi/setup.sh
``` ```
## What the Setup Script Does ## What the Setup Script Does
1. **Installs system dependencies** - build tools, libraries 1. **Verifies Python 3.11+** - uses system Python (no pyenv needed)
2. **Installs Python 3.12** - via pyenv (Pi OS ships with 3.13 which is incompatible) 2. **Installs system dependencies** - build tools, libraries
3. **Builds jpegio for ARM** - patches x86-specific flags 3. **Installs jpeglib** - DCT steganography (Python 3.11-3.14 compatible)
4. **Installs Stegasoo** - with web UI and all dependencies 4. **Installs Stegasoo** - with web UI and all dependencies
5. **Creates systemd service** - auto-starts on boot 5. **Creates systemd service** - auto-starts on boot
6. **Enables the service** - ready to start 6. **Enables the service** - ready to start
@@ -30,11 +30,18 @@ cd stegasoo
## Requirements ## Requirements
- Raspberry Pi 4 or 5 - 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) - 4GB+ RAM recommended (2GB minimum)
- 16GB+ SD card (pre-built images are 16GB) - 16GB+ SD card (pre-built images are 16GB)
- Internet connection - Internet connection
### Python Compatibility
| Raspberry Pi OS | Python | Supported |
|-----------------|--------|-----------|
| Bookworm | 3.11 | Yes |
| Trixie | 3.13 | Yes |
### Performance ### 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). 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 # Clone and run setup
cd /opt 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 cd stegasoo
./rpi/setup.sh ./rpi/setup.sh
``` ```
@@ -200,12 +207,12 @@ After Pi shuts down, remove SD card and on another Linux machine:
lsblk lsblk
# Pull image (auto-resizes to 16GB, compresses with zstd) # 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: The `pull-image.sh` script automatically:
- Resizes rootfs to exactly 16GB (consistent image size) - Resizes rootfs to exactly 16GB (for smaller download)
- Disables Pi OS auto-expand - Preserves auto-expand (image fills SD card on first boot)
- Compresses with zstd for fast decompression - Compresses with zstd for fast decompression
### 6. Distribute ### 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 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 # Configuration Variables
# ============================================================================= # =============================================================================
@@ -137,52 +179,100 @@ This is useful if you want to share encoded images only with
specific people (family, team, etc)." specific people (family, team, etc)."
echo "" echo ""
if gum confirm "Generate a private channel key?" --default=false; then CHANNEL_CHOICE=$(gum choose \
echo "" "Skip (public mode)" \
# Generate key to temp file (gum spin doesn't capture stdout well) "Generate new key" \
KEY_FILE=$(mktemp) "Enter existing key")
ERR_FILE=$(mktemp)
VENV_PYTHON="$INSTALL_DIR/venv/bin/python"
gum spin --spinner dot --title "Generating channel key..." -- \
bash -c "'$VENV_PYTHON' -c 'from stegasoo.channel import generate_channel_key; print(generate_channel_key())' > '$KEY_FILE' 2>'$ERR_FILE'"
CHANNEL_KEY=$(cat "$KEY_FILE" 2>/dev/null | head -1) case "$CHANNEL_CHOICE" in
KEY_ERROR=$(cat "$ERR_FILE" 2>/dev/null) "Generate new key")
rm -f "$KEY_FILE" "$ERR_FILE" 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 CHANNEL_KEY=$(cat "$KEY_FILE" 2>/dev/null | head -1)
echo "" KEY_ERROR=$(cat "$ERR_FILE" 2>/dev/null)
gum style --foreground 82 "✓ Channel key generated!" rm -f "$KEY_FILE" "$ERR_FILE"
echo ""
gum style \ if [ -n "$CHANNEL_KEY" ] && [[ "$CHANNEL_KEY" =~ ^[A-Za-z0-9] ]]; then
--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 "" echo ""
gum style --foreground 245 "Error details:" gum style --foreground 82 "✓ Channel key generated!"
echo "$KEY_ERROR" 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 fi
CHANNEL_KEY="" ;;
"Enter existing key")
echo "" echo ""
gum confirm "Continue" --default=true --affirmative="OK" --negative="" gum style --foreground 245 "Enter the channel key from your team/deployment."
fi gum style --foreground 245 "Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
else echo ""
gum style --foreground 214 "→ Using public mode"
sleep 0.5 while true; do
fi 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 # 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 "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
echo "" echo ""
echo "Examples:" echo "Examples:"
echo " $0 stegasoo-rpi-4.1.1.img.zst # auto-detect SD card" echo " $0 stegasoo-rpi-4.2.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.2.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 /dev/sdb # specify device"
exit 1 exit 1
fi fi
@@ -249,16 +249,9 @@ if [ -n "$MOUNTED" ]; then
done done
fi fi
# Ask about wiping # Ask about wiping (defer actual wipe until after final confirmation)
echo echo
read -p "Wipe partition table first? (recommended if having issues) [y/N] " wipe_confirm 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 # Final confirmation
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}" echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
@@ -272,73 +265,65 @@ if [[ ! $REPLY == "yes" ]]; then
exit 1 exit 1
fi 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 ""
echo -e "${GREEN}Flashing image to $SELECTED...${NC}" echo -e "${GREEN}Flashing image to $SELECTED...${NC}"
echo "" echo ""
# Try rpi-imager first (faster, native support for compressed images) # Flash with dd (status=progress shows actual write progress)
if command -v rpi-imager &> /dev/null; then echo -e "${YELLOW}Flashing (this may take several minutes for SD cards)...${NC}"
echo -e "${YELLOW}Using rpi-imager...${NC}" if [ "$COMPRESSED" = true ]; then
if rpi-imager --cli --disable-verify "$IMAGE" "$SELECTED"; then case "$COMP_TYPE" in
# rpi-imager succeeded xz) xzcat "$IMAGE" | sudo dd of="$SELECTED" bs=1M status=progress ;;
: zst) zstdcat "$IMAGE" | sudo dd of="$SELECTED" bs=1M status=progress ;;
else gz) zcat "$IMAGE" | sudo dd of="$SELECTED" bs=1M status=progress ;;
echo -e "${YELLOW}rpi-imager failed, falling back to dd...${NC}" esac
# Fall through to dd
USE_DD=true
fi
else else
USE_DD=true sudo dd if="$IMAGE" of="$SELECTED" bs=1M status=progress
fi
# Fallback to dd
if [ "$USE_DD" = true ]; then
if [ "$HAS_PV" = true ]; then
echo -e "${YELLOW}Using dd with progress...${NC}"
if [ "$COMPRESSED" = true ]; then
case "$COMP_TYPE" in
xz) pv "$IMAGE" | xzcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
zst) pv "$IMAGE" | zstdcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
gz) pv "$IMAGE" | zcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
esac
else
pv "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null
fi
else
echo -e "${YELLOW}Using dd (no progress - install pv for progress bar)...${NC}"
if [ "$COMPRESSED" = true ]; then
case "$COMP_TYPE" in
xz) xzcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
zst) zstdcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
gz) zcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
esac
else
dd if="$IMAGE" of="$SELECTED" bs=4M conv=fsync status=progress
fi
fi
fi fi
echo "" echo ""
echo -e "${GREEN}Syncing...${NC}" echo -e "${GREEN}Syncing...${NC}"
sync 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 # Inject WiFi config if config.json was loaded
if [ "$HAS_CONFIG" = true ]; then if [ "$HAS_CONFIG" = true ]; then
echo "" echo ""
echo -e "${GREEN}Configuring WiFi from config.json...${NC}" 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 if [ -b "$BOOT_PART" ]; then
MOUNT_DIR=$(mktemp -d) MOUNT_DIR=$(mktemp -d)
if mount "$BOOT_PART" "$MOUNT_DIR" 2>/dev/null; then if mount "$BOOT_PART" "$MOUNT_DIR" 2>/dev/null; then

View File

@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# Flash Raspberry Pi image with headless config (Trixie/Bookworm compatible) # Flash Raspberry Pi image with headless config (Trixie/Bookworm compatible)
# Usage: ./flash-stock-img.sh <image.img.xz> <device> # Usage: ./flash-stock-img.sh [-c config.json] <image.img.xz> <device>
# Reads settings from config.json in same directory # Reads settings from config.json in same directory (or specify with -c)
# #
# Uses the same firstrun.sh approach as rpi-imager for compatibility # Uses the same firstrun.sh approach as rpi-imager for compatibility
@@ -10,11 +10,31 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/config.json" CONFIG_FILE="$SCRIPT_DIR/config.json"
# ============================================================================
# Parse options
# ============================================================================
usage() {
echo "Usage: $0 [-c config.json] <image.img.xz> <device>"
echo " -c FILE Use alternate config file (default: config.json in script dir)"
echo "Example: $0 2025-12-04-raspios-trixie-arm64-lite.img.xz /dev/sdb"
echo "Example: $0 -c myconfig.json raspios.img.xz /dev/sdb"
exit 1
}
while getopts "c:h" opt; do
case $opt in
c) CONFIG_FILE="$OPTARG" ;;
h) usage ;;
*) usage ;;
esac
done
shift $((OPTIND - 1))
# ============================================================================ # ============================================================================
# Load config # Load config
# ============================================================================ # ============================================================================
if [ ! -f "$CONFIG_FILE" ]; then if [ ! -f "$CONFIG_FILE" ]; then
echo "Error: config.json not found at $CONFIG_FILE" echo "Error: config file not found at $CONFIG_FILE"
exit 1 exit 1
fi fi
@@ -38,9 +58,7 @@ echo
# Validate args # Validate args
# ============================================================================ # ============================================================================
if [ $# -ne 2 ]; then if [ $# -ne 2 ]; then
echo "Usage: $0 <image.img.xz> <device>" usage
echo "Example: $0 2025-12-04-raspios-trixie-arm64-lite.img.xz /dev/sdb"
exit 1
fi fi
IMAGE="$1" IMAGE="$1"

View File

@@ -2,56 +2,39 @@
This directory contains patches for dependencies that need modifications to build on ARM64. 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 ## Structure
``` ```
patches/ patches/
<package>/ jpegio/ # Legacy (v4.1) - not used in v4.2+
arm64.patch # Standard unified diff patch file arm64.patch
apply-patch.sh # Script with fallback strategies 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: If a new dependency needs ARM64 patches:
1. **Patch file** - Apply the `.patch` file using `patch -p1`
2. **Sed fallback** - Use sed for simple string replacements
3. **Python fallback** - Use regex for flexible pattern matching
This layered approach handles:
- Exact matches (patch file works)
- Minor upstream changes (sed catches variations)
- Significant changes (Python regex is most flexible)
- Already patched files (detected and skipped)
## Adding a New Patch
1. Create a directory: `patches/<package>/` 1. Create a directory: `patches/<package>/`
2. Create the patch file: `git diff > arm64.patch` 2. Add patch files or helper scripts
3. Create `apply-patch.sh` with appropriate fallback logic 3. Update `setup.sh` to apply the patch during installation
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

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 # Resizes rootfs to 16GB for consistent image size, then pulls
# #
# Usage: ./pull-image.sh <device> <output.img.zst> # 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 set -e
@@ -14,9 +14,9 @@ BOLD='\033[1m'
NC='\033[0m' NC='\033[0m'
if [ $# -ne 2 ]; then if [ $# -ne 2 ]; then
echo "Usage: $0 <device> <output.img.zst>" 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 exit 1
fi fi
DEVICE="$1" DEVICE="$1"
@@ -24,13 +24,13 @@ OUTPUT="$2"
# Check for root # Check for root
if [ "$EUID" -ne 0 ]; then if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: Must run as root (sudo)${NC}" echo -e "${RED}Error: Must run as root (sudo)${NC}"
exit 1 exit 1
fi fi
if [ ! -b "$DEVICE" ]; then if [ ! -b "$DEVICE" ]; then
echo -e "${RED}Error: Device not found: $DEVICE${NC}" echo -e "${RED}Error: Device not found: $DEVICE${NC}"
exit 1 exit 1
fi fi
echo -e "${BOLD}Device info:${NC}" echo -e "${BOLD}Device info:${NC}"
@@ -39,14 +39,14 @@ echo
# Find partitions # Find partitions
if [ -b "${DEVICE}1" ]; then if [ -b "${DEVICE}1" ]; then
BOOT_PART="${DEVICE}1" BOOT_PART="${DEVICE}1"
ROOT_PART="${DEVICE}2" ROOT_PART="${DEVICE}2"
elif [ -b "${DEVICE}p1" ]; then elif [ -b "${DEVICE}p1" ]; then
BOOT_PART="${DEVICE}p1" BOOT_PART="${DEVICE}p1"
ROOT_PART="${DEVICE}p2" ROOT_PART="${DEVICE}p2"
else else
echo -e "${RED}Error: Could not find partitions${NC}" echo -e "${RED}Error: Could not find partitions${NC}"
exit 1 exit 1
fi fi
# Unmount any mounted partitions # Unmount any mounted partitions
@@ -62,86 +62,67 @@ echo -e "${BOLD}Checking partition size...${NC}"
# Get current partition size in bytes # Get current partition size in bytes
CURRENT_SIZE=$(blockdev --getsize64 "$ROOT_PART") CURRENT_SIZE=$(blockdev --getsize64 "$ROOT_PART")
TARGET_BYTES=$((16 * 1024 * 1024 * 1024)) # 16GB in bytes TARGET_BYTES=$((16 * 1024 * 1024 * 1024)) # 16GB in bytes
CURRENT_GB=$(echo "scale=2; $CURRENT_SIZE / 1073741824" | bc) CURRENT_GB=$(echo "scale=2; $CURRENT_SIZE / 1073741824" | bc)
echo " Current rootfs size: ${CURRENT_GB}GB" echo " Current rootfs size: ${CURRENT_GB}GB"
if [ "$CURRENT_SIZE" -gt "$TARGET_BYTES" ]; then if [ "$CURRENT_SIZE" -gt "$TARGET_BYTES" ]; then
echo -e "${YELLOW}Resizing rootfs to 16GB...${NC}" echo -e "${YELLOW}Resizing rootfs to 16GB...${NC}"
# Get boot partition end in sectors # Get boot partition end in sectors
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's') BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
# Calculate 16GB in sectors (512 byte sectors) # Calculate 16GB in sectors (512 byte sectors)
ROOT_SIZE_SECTORS=33554432 ROOT_SIZE_SECTORS=33554432
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS)) ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
# SHRINKING: filesystem first, then partition # SHRINKING: filesystem first, then partition
echo " Checking filesystem..." echo " Checking filesystem..."
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
# Shrink filesystem to 15.5GB (leave room for partition overhead) # Shrink filesystem to 15.5GB (leave room for partition overhead)
echo " Shrinking filesystem to 15500M..." echo " Shrinking filesystem to 15500M..."
resize2fs "$ROOT_PART" 15500M resize2fs "$ROOT_PART" 15500M
# Delete and recreate partition 2 with 16GB size # Delete and recreate partition 2 with 16GB size
echo " Shrinking partition to 16GB..." echo " Shrinking partition to 16GB..."
parted -s "$DEVICE" rm 2 parted -s "$DEVICE" rm 2
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
# Refresh partition table # Refresh partition table
partprobe "$DEVICE" partprobe "$DEVICE"
sleep 2 sleep 2
# Expand filesystem to fill the partition exactly # Expand filesystem to fill the partition exactly
echo " Expanding filesystem to fill partition..." echo " Expanding filesystem to fill partition..."
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
resize2fs "$ROOT_PART" resize2fs "$ROOT_PART"
echo -e "${GREEN} Rootfs resized to 16GB${NC}" echo -e "${GREEN} Rootfs resized to 16GB${NC}"
elif [ "$CURRENT_SIZE" -lt "$TARGET_BYTES" ]; then elif [ "$CURRENT_SIZE" -lt "$TARGET_BYTES" ]; then
echo -e "${YELLOW} Rootfs is smaller than 16GB - expanding...${NC}" echo -e "${YELLOW} Rootfs is smaller than 16GB - expanding...${NC}"
# Get boot partition end in sectors # Get boot partition end in sectors
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's') BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
ROOT_SIZE_SECTORS=33554432 ROOT_SIZE_SECTORS=33554432
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS)) ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
# EXPANDING: partition first, then filesystem # EXPANDING: partition first, then filesystem
parted -s "$DEVICE" rm 2 parted -s "$DEVICE" rm 2
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
partprobe "$DEVICE" partprobe "$DEVICE"
sleep 2 sleep 2
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
resize2fs "$ROOT_PART" resize2fs "$ROOT_PART"
echo -e "${GREEN} Rootfs expanded to 16GB${NC}" echo -e "${GREEN} Rootfs expanded to 16GB${NC}"
else else
echo -e "${GREEN} Rootfs already ~16GB${NC}" echo -e "${GREEN} Rootfs already ~16GB${NC}"
fi 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 # Pull image
# ============================================================================ # ============================================================================
@@ -154,8 +135,8 @@ echo
END_SECTOR=$(parted -s "$DEVICE" unit s print | grep "^ 2" | awk '{print $3}' | tr -d 's') END_SECTOR=$(parted -s "$DEVICE" unit s print | grep "^ 2" | awk '{print $3}' | tr -d 's')
if [ -z "$END_SECTOR" ]; then if [ -z "$END_SECTOR" ]; then
echo -e "${RED}Error: Could not determine partition 2 end sector${NC}" echo -e "${RED}Error: Could not determine partition 2 end sector${NC}"
exit 1 exit 1
fi fi
# Add a small buffer (1MB = 2048 sectors) for safety # Add a small buffer (1MB = 2048 sectors) for safety
@@ -169,8 +150,8 @@ echo
read -p "Proceed with image pull? [Y/n] " confirm read -p "Proceed with image pull? [Y/n] " confirm
if [[ "$confirm" =~ ^[Nn]$ ]]; then if [[ "$confirm" =~ ^[Nn]$ ]]; then
echo "Aborted." echo "Aborted."
exit 1 exit 1
fi fi
echo echo
@@ -178,13 +159,13 @@ echo -e "${GREEN}Pulling image...${NC}"
echo echo
# Use pv if available for progress, otherwise fallback to dd status # Use pv if available for progress, otherwise fallback to dd status
if command -v pv &> /dev/null; then if command -v pv &>/dev/null; then
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS 2>/dev/null | \ dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS 2>/dev/null |
pv -s $TOTAL_BYTES | \ pv -s $TOTAL_BYTES |
zstd -T0 -3 > "$OUTPUT" zstd -T0 -19 --ultra >"$OUTPUT"
else else
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS status=progress | \ dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS status=progress |
zstd -T0 -3 > "$OUTPUT" zstd -T0 -19 --ultra >"$OUTPUT"
fi fi
echo echo
@@ -197,16 +178,16 @@ ls -lh "$OUTPUT"
echo echo
read -p "Create .zst.zip wrapper for GitHub? [y/N] " zip_confirm read -p "Create .zst.zip wrapper for GitHub? [y/N] " zip_confirm
if [[ "$zip_confirm" =~ ^[Yy]$ ]]; then if [[ "$zip_confirm" =~ ^[Yy]$ ]]; then
ZIP_OUTPUT="${OUTPUT}.zip" ZIP_OUTPUT="${OUTPUT}.zip"
echo -e "${YELLOW}Creating zip wrapper (store mode, no compression)...${NC}" echo -e "${YELLOW}Creating zip wrapper (store mode, no compression)...${NC}"
zip -0 "$ZIP_OUTPUT" "$OUTPUT" zip -0 "$ZIP_OUTPUT" "$OUTPUT"
echo -e "${GREEN}Done!${NC} Upload this to GitHub Releases:" echo -e "${GREEN}Done!${NC} Upload this to GitHub Releases:"
ls -lh "$ZIP_OUTPUT" ls -lh "$ZIP_OUTPUT"
echo echo
echo "Users can flash with:" echo "Users can flash with:"
echo " sudo ./rpi/flash-image.sh $ZIP_OUTPUT" echo " sudo ./rpi/flash-image.sh $ZIP_OUTPUT"
else else
echo echo
echo "To verify:" echo "To verify:"
echo " zstdcat $OUTPUT | fdisk -l /dev/stdin" echo " zstdcat $OUTPUT | fdisk -l /dev/stdin"
fi fi

View File

@@ -1,19 +1,19 @@
#!/bin/bash #!/bin/bash
# #
# Stegasoo Pi Test Kickoff Script # Stegasoo Remote Pi Build Script
# Automates: flash -> wait for boot -> setup -> test # 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 set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Pi connection settings # Pi connection settings (defaults)
PI_HOST="stegasoo.local" PI_HOST="${1:-stegasoo.local}"
PI_USER="admin" PI_USER="${2:-admin}"
PI_PASS="stegasoo" PI_PASS="${3:-stegasoo}"
# Colors # Colors
RED='\033[0;31m' RED='\033[0;31m'
@@ -26,10 +26,9 @@ NC='\033[0m'
# Helper functions # Helper functions
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Wait for Pi to be reachable
wait_for_pi() { wait_for_pi() {
local attempt=1 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..." 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 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 done
printf "\r${GREEN}✓ Ready after %d attempts${NC}\n" "$attempt" 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() { run_on_pi() {
sshpass -p "$PI_PASS" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@" 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() { run_on_pi_interactive() {
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@" sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
} }
# Copy file to Pi
scp_to_pi() { scp_to_pi() {
local src="$1" local src="$1"
local dst="$2" local dst="$2"
sshpass -p "$PI_PASS" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$src" "$PI_USER@$PI_HOST:$dst" sshpass -p "$PI_PASS" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$src" "$PI_USER@$PI_HOST:$dst"
} }
# Interactive SSH session
ssh_pi() { 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" "$@" sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
} }
@@ -69,92 +64,48 @@ ssh_pi() {
# Main # 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}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ Stegasoo Pi Test Kickoff${NC}" echo -e "${CYAN}║ Stegasoo Remote Pi Build${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}" echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo "" echo ""
echo -e "Image: ${YELLOW}$IMAGE${NC}" echo -e "Host: ${YELLOW}$PI_HOST${NC}"
echo -e "Device: ${YELLOW}$DEVICE${NC}" echo -e "User: ${YELLOW}$PI_USER${NC}"
echo "" echo ""
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Step 1: Flash the image # Step 1: Wait for Pi to be ready
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
echo -e "${GREEN}[1/8]${NC} Flashing image..." echo -e "${GREEN}[1/6]${NC} Waiting for Pi..."
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 "" echo ""
wait_for_pi wait_for_pi
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Step 4: Pre-setup (install dependencies) # Step 2: Install dependencies
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
echo "" echo ""
echo -e "${GREEN}[4/8]${NC} Installing dependencies on Pi..." echo -e "${GREEN}[2/6]${NC} Installing dependencies on Pi..."
echo "" 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 ""
echo -e "${GREEN}[5/8]${NC} Cloning Stegasoo repo..." echo -e "${GREEN}[3/6]${NC} Cloning Stegasoo repo..."
echo "" 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 ""
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 "" echo ""
TARBALL="$SCRIPT_DIR/stegasoo-rpi-runtime-env-arm64.tar.zst" TARBALL="$SCRIPT_DIR/stegasoo-rpi-venv-arm64.tar.zst"
if [[ -f "$TARBALL" ]]; then if [[ -f "$TARBALL" ]]; then
scp_to_pi "$TARBALL" "/opt/stegasoo/rpi/" scp_to_pi "$TARBALL" "/opt/stegasoo/rpi/"
echo -e " ${GREEN}${NC} Tarball copied" echo -e " ${GREEN}${NC} Tarball copied"
@@ -164,19 +115,19 @@ else
fi fi
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Step 7: Run setup # Step 5: Run setup
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
echo "" echo ""
echo -e "${GREEN}[7/8]${NC} Running setup.sh on Pi..." echo -e "${GREEN}[5/6]${NC} Running setup.sh on Pi..."
echo "" echo ""
run_on_pi_interactive "cd /opt/stegasoo && ./rpi/setup.sh" run_on_pi_interactive "cd /opt/stegasoo && ./rpi/setup.sh"
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Step 8: Test it works # Step 6: Test it works
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
echo "" echo ""
echo -e "${GREEN}[8/8]${NC} Testing Stegasoo..." echo -e "${GREEN}[6/6]${NC} Testing Stegasoo..."
echo "" echo ""
run_on_pi "sudo systemctl start stegasoo && sleep 2 && curl -sk https://localhost:5000 | head -5" 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} Build complete! Pi is ready for testing.${NC}"
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}" echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
echo "" echo ""
echo -e "Access: ${YELLOW}https://stegasoo.local:5000${NC}" echo -e "Access: ${YELLOW}https://$PI_HOST:5000${NC}"
echo "" echo ""
read -p "Press ENTER to SSH into Pi for manual testing..." 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..." echo " Venv broken or stegasoo not installed, rebuilding..."
rm -rf "$STEGASOO_DIR/venv" rm -rf "$STEGASOO_DIR/venv"
# Find Python 3.12 (prefer pyenv, fall back to system) # Find system Python 3.11+ (no pyenv needed)
USER_HOME=$(eval echo "~$STEGASOO_USER") PYTHON_BIN=""
PYENV_PYTHON="$USER_HOME/.pyenv/versions/3.12*/bin/python" for py in python3.14 python3.13 python3.12 python3.11 python3; do
if compgen -G "$PYENV_PYTHON" > /dev/null 2>&1; then if command -v "$py" &>/dev/null; then
PYTHON_BIN=$(ls $PYENV_PYTHON 2>/dev/null | head -1) PYTHON_BIN=$(command -v "$py")
echo " Using pyenv Python: $PYTHON_BIN" break
elif command -v python3.12 &>/dev/null; then
PYTHON_BIN="python3.12"
echo " Using system Python 3.12"
else
PYTHON_BIN="python3"
echo " Warning: Python 3.12 not found, using $($PYTHON_BIN --version)"
fi
sudo -u "$STEGASOO_USER" "$PYTHON_BIN" -m venv "$STEGASOO_DIR/venv"
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet --upgrade pip setuptools wheel
# On ARM64, jpegio needs patching before install
ARCH=$(uname -m)
if [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then
echo " Building jpegio for ARM64 (this may take a minute)..."
# Install build deps
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet cython numpy
JPEGIO_DIR="/tmp/jpegio-build-$$"
rm -rf "$JPEGIO_DIR"
if git clone https://github.com/dwgoon/jpegio.git "$JPEGIO_DIR" 2>/dev/null; then
# Apply patch to remove -m64 flag
if [ -f "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
bash "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
else
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
fi
# Change ownership so user can build
chown -R "$STEGASOO_USER:$STEGASOO_USER" "$JPEGIO_DIR"
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install "$JPEGIO_DIR"
rm -rf "$JPEGIO_DIR"
else
echo " Warning: Failed to clone jpegio, DCT mode may not work"
fi fi
fi done
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]" if [ -z "$PYTHON_BIN" ]; then
echo " Venv rebuilt and stegasoo installed" 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 else
echo " Venv OK" echo " Venv OK"
fi fi

View File

@@ -4,14 +4,14 @@
# Tested on: Raspberry Pi 4/5 with Raspberry Pi OS (64-bit) # Tested on: Raspberry Pi 4/5 with Raspberry Pi OS (64-bit)
# #
# Usage: # 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 # # 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: # What this script does:
# 1. Installs system dependencies # 1. Installs system dependencies
# 2. Installs Python 3.12 via pyenv (Pi OS ships with 3.13 which is incompatible) # 2. Verifies Python 3.11+ (uses system Python)
# 3. Patches and builds jpegio for ARM # 3. Installs jpeglib for DCT steganography (Python 3.11-3.14 compatible)
# 4. Installs Stegasoo with web UI # 4. Installs Stegasoo with web UI
# 5. Creates systemd service for auto-start # 5. Creates systemd service for auto-start
# 6. Enables the service # 6. Enables the service
@@ -75,9 +75,8 @@ show_help() {
echo "" echo ""
echo " Available variables:" echo " Available variables:"
echo " INSTALL_DIR Install location (default: /opt/stegasoo)" echo " INSTALL_DIR Install location (default: /opt/stegasoo)"
echo " PYTHON_VERSION Python version (default: 3.12)"
echo " STEGASOO_REPO Git repo URL" echo " STEGASOO_REPO Git repo URL"
echo " STEGASOO_BRANCH Git branch (default: 4.1)" echo " STEGASOO_BRANCH Git branch (default: 4.2)"
echo "" echo ""
echo " Example:" echo " Example:"
echo " export INSTALL_DIR=\"/home/pi/stegasoo\"" echo " export INSTALL_DIR=\"/home/pi/stegasoo\""
@@ -95,10 +94,8 @@ done
# Default configuration # Default configuration
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}" 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_REPO="${STEGASOO_REPO:-https://github.com/adlee-was-taken/stegasoo.git}"
STEGASOO_BRANCH="${STEGASOO_BRANCH:-4.1}" STEGASOO_BRANCH="${STEGASOO_BRANCH:-4.2}"
JPEGIO_REPO="https://github.com/dwgoon/jpegio.git"
# Load config files (system, then user - user overrides system) # Load config files (system, then user - user overrides system)
for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf"; do for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf"; do
@@ -112,7 +109,7 @@ clear
print_banner "Raspberry Pi Setup" print_banner "Raspberry Pi Setup"
echo "" echo ""
echo " This will install Stegasoo with full DCT support" 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 "" echo ""
# Check if running on ARM # Check if running on ARM
@@ -123,6 +120,63 @@ if [[ "$ARCH" != "aarch64" && "$ARCH" != "arm64" ]]; then
exit 1 exit 1
fi 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 # Check available memory
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}') TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
if [ "$TOTAL_MEM" -lt 2000 ]; then if [ "$TOTAL_MEM" -lt 2000 ]; then
@@ -136,8 +190,11 @@ if [ "$TOTAL_MEM" -lt 2000 ]; then
fi fi
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 if [ ! -d "$INSTALL_DIR" ]; then
sudo mkdir -p "$INSTALL_DIR" sudo mkdir -p "$INSTALL_DIR"
sudo chown "$USER:$USER" "$INSTALL_DIR" sudo chown "$USER:$USER" "$INSTALL_DIR"
@@ -148,7 +205,7 @@ else
echo " $INSTALL_DIR exists, updated ownership" echo " $INSTALL_DIR exists, updated ownership"
fi 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 update
sudo apt-get install -y \ sudo apt-get install -y \
build-essential \ build-essential \
@@ -170,9 +227,11 @@ sudo apt-get install -y \
libzbar0 \ libzbar0 \
libjpeg-dev \ libjpeg-dev \
python3-dev \ python3-dev \
python3-venv \
python3-pip \
btop 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 # Add Charm repo for gum
if ! command -v gum &>/dev/null; then if ! command -v gum &>/dev/null; then
sudo mkdir -p /etc/apt/keyrings sudo mkdir -p /etc/apt/keyrings
@@ -184,7 +243,21 @@ else
echo " gum already installed" echo " gum already installed"
fi 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) # Clone Stegasoo first (needed to check for pre-built tarball)
if [ -d "$INSTALL_DIR/.git" ]; then if [ -d "$INSTALL_DIR/.git" ]; then
@@ -198,17 +271,16 @@ else
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
fi fi
# Pre-built environment tarball (skips 20+ min compile time) # Pre-built venv tarball (skips pip compile time)
# Includes both pyenv Python 3.12 AND venv with all dependencies PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-rpi-venv-arm64.tar.zst"
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.2.1/stegasoo-rpi-venv-arm64.tar.zst}"
PREBUILT_URL="${PREBUILT_URL:-https://github.com/adlee-was-taken/stegasoo/releases/download/v4.1.5/stegasoo-rpi-runtime-env-arm64.tar.zst}"
USE_PREBUILT=true USE_PREBUILT=true
# Use local tarball if present, otherwise will download # Use local tarball if present, otherwise will download
if [ -f "$PREBUILT_TARBALL" ]; then 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 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 fi
# Allow --no-prebuilt flag to force from-source build # 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}" echo -e "${YELLOW}Building from source (--no-prebuilt specified)${NC}"
fi fi
# Fast path: use pre-built environment if available echo -e "${GREEN}[5/9]${NC} Setting up Python environment..."
if [ "$USE_PREBUILT" = true ]; then 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 # Download if local file doesn't exist
if [ ! -f "$PREBUILT_TARBALL" ]; then if [ ! -f "$PREBUILT_TARBALL" ]; then
echo " Downloading pre-built environment (~50MB)..." echo " Downloading pre-built venv (~50MB)..."
curl -L -o "$PREBUILT_TARBALL" "$PREBUILT_URL" curl -L -o "$PREBUILT_TARBALL" "$PREBUILT_URL"
fi fi
# Extract pre-built environment (includes pyenv Python + venv) # Extract pre-built venv
echo " Extracting pre-built environment..." echo " Extracting pre-built venv..."
zstd -d "$PREBUILT_TARBALL" --stdout | tar -xf - -C "$HOME" zstd -d "$PREBUILT_TARBALL" --stdout | tar -xf - -C "$INSTALL_DIR"
# Setup pyenv in current shell # Fix venv Python symlinks to point to system Python
export PYENV_ROOT="$HOME/.pyenv" echo " Updating venv to use system Python..."
export PATH="$PYENV_ROOT/bin:$PATH" rm -f "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/venv/bin/python3"
eval "$(pyenv init -)" ln -s "$SYSTEM_PYTHON" "$INSTALL_DIR/venv/bin/python"
pyenv global $PYTHON_VERSION ln -s "$SYSTEM_PYTHON" "$INSTALL_DIR/venv/bin/python3"
# Add to .bashrc if not already there # Update pip shebang if needed
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then if [ -f "$INSTALL_DIR/venv/bin/pip" ]; then
echo '' >> ~/.bashrc sed -i "1s|^#!.*|#!$INSTALL_DIR/venv/bin/python|" "$INSTALL_DIR/venv/bin/pip" 2>/dev/null || true
echo '# pyenv' >> ~/.bashrc
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
fi
# Verify Python
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
echo -e " ${GREEN}${NC} Python: $INSTALLED_PY"
# Extract venv to install dir
echo -e "${GREEN}[6/8]${NC} Setting up virtual environment..."
if [ -f "$HOME/stegasoo-venv.tar.zst" ]; then
zstd -d "$HOME/stegasoo-venv.tar.zst" --stdout | tar -xf - -C "$INSTALL_DIR"
rm "$HOME/stegasoo-venv.tar.zst"
fi fi
# Activate and verify # Activate and verify
@@ -263,105 +321,87 @@ if [ "$USE_PREBUILT" = true ]; then
echo -e " ${GREEN}${NC} venv Python: $VENV_PY" echo -e " ${GREEN}${NC} venv Python: $VENV_PY"
# Install stegasoo package in editable mode (quick, no compile) # 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 pip install -e "." --quiet
# Adjust step numbers for rest of script
STEP_OFFSET=-4
else 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 # Create venv with system Python
if [ ! -d "$HOME/.pyenv" ]; then if [ ! -d "$INSTALL_DIR/venv" ]; then
curl https://pyenv.run | bash "$SYSTEM_PYTHON" -m venv "$INSTALL_DIR/venv"
# Add pyenv to current shell
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
# Add to .bashrc if not already there
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
echo '' >> ~/.bashrc
echo '# pyenv' >> ~/.bashrc
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
fi
else
echo " pyenv already installed"
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
fi fi
source "$INSTALL_DIR/venv/bin/activate"
# Install Python 3.12 if not present
if ! pyenv versions | grep -q "$PYTHON_VERSION"; then
echo " Building Python $PYTHON_VERSION (this takes ~10 minutes)..."
pyenv install $PYTHON_VERSION
else
echo " Python $PYTHON_VERSION already installed"
fi
pyenv global $PYTHON_VERSION
# Verify Python version
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
if [ "$INSTALLED_PY" != "$PYTHON_VERSION" ]; then
echo -e "${RED}Error: Python $PYTHON_VERSION not active. Got: $INSTALLED_PY${NC}"
exit 1
fi
echo -e "${GREEN}[6/12]${NC} Creating Python virtual environment..."
echo -e " ${YELLOW}Note: No pre-built venv found. Building from source (20+ min)${NC}"
echo -e " ${YELLOW}To speed up future installs, add stegasoo-venv-pi-arm64.tar.gz to rpi/${NC}"
# Create venv with pyenv Python (not system Python)
# Use pyenv which to get actual path (handles 3.12 -> 3.12.12 mapping)
PYENV_PYTHON=$(pyenv which python)
echo " Using Python: $PYENV_PYTHON"
if [ ! -d "venv" ]; then
"$PYENV_PYTHON" -m venv venv
fi
source venv/bin/activate
# Verify we're using the right Python # Verify we're using the right Python
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2) VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
echo " venv Python: $VENV_PY" 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 # Install jpeglib (no ARM64 wheel, PyPI tarball missing headers - use GitHub)
JPEGIO_DIR="/tmp/jpegio-build" echo " Installing jpeglib for ARM64..."
rm -rf "$JPEGIO_DIR" JPEGLIB_WORKDIR=$(mktemp -d)
git clone "$JPEGIO_REPO" "$JPEGIO_DIR" cd "$JPEGLIB_WORKDIR"
# Apply ARM64 patch # Clone from GitHub (PyPI source tarball is missing .h files)
if [ -f "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then echo " Cloning jpeglib from GitHub..."
bash "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR" git clone --depth 1 --branch 1.0.2 https://github.com/martinbenes1996/jpeglib.git
else cd jpeglib
echo " Applying inline ARM64 patch..." CJPEGLIB="src/jpeglib/cjpeglib"
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
fi
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 # Download libjpeg headers (not included in repo either)
pip install --upgrade pip setuptools wheel cython numpy # 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 . pip install .
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
rm -rf "$JPEGIO_DIR" rm -rf "$JPEGLIB_WORKDIR"
echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..." # Install remaining dependencies
echo " Installing remaining dependencies..."
# Install dependencies (jpegio already in venv, won't re-download)
pip install -e ".[web]" pip install -e ".[web]"
STEP_OFFSET=0
fi 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 sudo tee /etc/systemd/system/stegasoo.service > /dev/null <<EOF
[Unit] [Unit]
Description=Stegasoo Web UI Description=Stegasoo Web UI
@@ -383,12 +423,53 @@ RestartSec=5
WantedBy=multi-user.target WantedBy=multi-user.target
EOF 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 daemon-reload
sudo systemctl enable stegasoo.service 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 # Add stegasoo venv and rpi scripts to PATH for all users
sudo tee /etc/profile.d/stegasoo-path.sh > /dev/null <<'PATHEOF' 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
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 # Create dynamic MOTD script
sudo tee /etc/profile.d/stegasoo-motd.sh > /dev/null <<'MOTDEOF' 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 read -p "Generate a private channel key? [y/N] " -n 1 -r
echo echo
if [[ $REPLY =~ ^[Yy]$ ]]; then if [[ $REPLY =~ ^[Yy]$ ]]; then
# Generate channel key using the CLI # Generate channel key and save encrypted to config
CHANNEL_KEY=$($INSTALL_DIR/venv/bin/python -c "from stegasoo.channel import generate_channel_key; print(generate_channel_key())") 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} Channel key generated: ${YELLOW}$CHANNEL_KEY${NC}"
echo -e " ${GREEN}${NC} Key saved (encrypted) to ~/.stegasoo/channel.key"
echo "" echo ""
echo -e " ${RED}IMPORTANT: Save this key!${NC} You'll need to share it with anyone" 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." 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}') LOCAL_IP=$(hostname -I | awk '{print $1}')
PI_HOSTNAME=$(hostname) PI_HOSTNAME=$(hostname)
# Generate cert with SANs for IP, hostname, and localhost # Try mkcert first (creates browser-trusted certs - no warning screen!)
openssl req -x509 -newkey rsa:2048 \ if command -v mkcert &> /dev/null; then
-keyout "$CERT_DIR/server.key" \ echo " Using mkcert for browser-trusted certificates..."
-out "$CERT_DIR/server.crt" \ cd "$CERT_DIR"
-days 365 -nodes \ mkcert -key-file server.key -cert-file server.crt \
-subj "/O=Stegasoo/CN=$PI_HOSTNAME" \ "$PI_HOSTNAME" "$PI_HOSTNAME.local" localhost "$LOCAL_IP" 127.0.0.1 ::1
-addext "subjectAltName=DNS:$PI_HOSTNAME,DNS:$PI_HOSTNAME.local,DNS:localhost,IP:$LOCAL_IP,IP:127.0.0.1" \
2>/dev/null # 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 # Fix permissions
chmod 600 "$CERT_DIR/server.key" chmod 600 "$CERT_DIR/server.key"
chown -R "$USER:$USER" "$CERT_DIR" chown -R "$USER:$USER" "$CERT_DIR"
echo -e " ${GREEN}${NC} SSL certificates generated"
fi fi
# Setup port 443 redirect if requested # Setup port 443 redirect if requested
@@ -678,6 +794,14 @@ echo " Start: sudo systemctl start stegasoo"
echo " Stop: sudo systemctl stop stegasoo" echo " Stop: sudo systemctl stop stegasoo"
echo " Status: sudo systemctl status stegasoo" echo " Status: sudo systemctl status stegasoo"
echo " Logs: journalctl -u stegasoo -f" 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 "" echo ""
# Offer to start now # Offer to start now
@@ -685,9 +809,12 @@ read -p "Start Stegasoo now? [Y/n] " -n 1 -r
echo echo
if [[ ! $REPLY =~ ^[Nn]$ ]]; then if [[ ! $REPLY =~ ^[Nn]$ ]]; then
sudo systemctl start stegasoo sudo systemctl start stegasoo
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
sudo systemctl start stegasoo-api
fi
sleep 2 sleep 2
if systemctl is-active --quiet stegasoo; then 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 [ "$ENABLE_HTTPS" = "true" ]; then
if [ "$USE_PORT_443" = "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}" 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 else
echo -e " Create admin: ${YELLOW}http://$PI_HOST.local:5000/setup${NC} or ${YELLOW}http://$PI_IP:5000/setup${NC}" echo -e " Create admin: ${YELLOW}http://$PI_HOST.local:5000/setup${NC} or ${YELLOW}http://$PI_IP:5000/setup${NC}"
fi 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 else
echo -e "${RED}✗ Failed to start. Check logs:${NC} journalctl -u stegasoo -f" echo -e "${RED}✗ Failed to start. Check logs:${NC} journalctl -u stegasoo -f"
fi fi

13
rpi/train_proj.json Normal file
View File

@@ -0,0 +1,13 @@
{
"hostname": "running_trains",
"username": "admin",
"password": "runthemtrains",
"wifiSSID": "WitchHazelWrecked",
"wifiPassword": "BeefPigsMoo",
"wifiCountry": "US",
"locale": "en_US.UTF-8",
"keyboardLayout": "us",
"timezone": "America/New_York",
"enableSSH": true
}

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

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