72 Commits

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
130 changed files with 18662 additions and 1189 deletions

5
.claude/settings.json Normal file
View File

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

View File

@@ -37,6 +37,7 @@ 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:

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

11
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# Embedded repos (AUR packaging)
aur-cli-upload/
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
@@ -97,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

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

4
CLI.md
View File

@@ -164,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 |
@@ -180,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"

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 |
@@ -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).

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 |

View File

@@ -1,52 +1,173 @@
## Stegasoo v4.1.7 # v4.3.0 — Audio Steganography
### Mobile UI Polish **Release Date:** 2026-02-27
- **PIN Entry**: Shrunk digit boxes for 9-digit PIN support on mobile
- **Mode Selectors**: DCT/LSB buttons now use consistent button-group styling with icons
- **Navbar**: Left-aligned collapsed menu, shortened channel fingerprint display (`ABCD-••••-3456`)
- **Text Wrapping**: Fixed button text wrapping issues on narrow screens
### Docker Improvements ## Highlights
- **Reorganized**: Docker files moved to `docker/` directory
- `docker/Dockerfile`
- `docker/Dockerfile.base`
- `docker/docker-compose.yml`
- **DCT Fix**: Added Reed-Solomon (`reedsolo`) to Docker images - fixes DCT decode failures
- **Quick Start**: New `docs/DOCKER_QUICKSTART.md` guide
Stegasoo can now hide messages in audio files! This release adds full audio steganography support with two embedding modes:
- **LSB (Least Significant Bit)**: Embeds data directly in audio sample LSBs. High capacity, best for direct file transfers.
- **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
pip install stegasoo[audio]
```
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 ```bash
# Build and run
docker build -f docker/Dockerfile.base -t stegasoo-base:latest .
docker-compose -f docker/docker-compose.yml up -d docker-compose -f docker/docker-compose.yml up -d
``` ```
### Raspberry Pi **Raspberry Pi**
- **First-Boot Wizard**: Can now load existing channel key (for joining team deployments) Flash `stegasoo-rpi-4.2.1.img.zst.zip` to SD card.
- **Project Cleanup**: Moved `pishrink.sh` to `rpi/tools/`
### UI Copy
- Changed "Undetectable" to "Covertly Embedded" on encode page (more accurate)
### Raspberry Pi Image
Download `stegasoo-rpi-4.1.7.img.zst.zip` from Releases.
```bash
# Flash (auto-detects SD card)
sudo ./rpi/flash-image.sh stegasoo-rpi-4.1.7.img.zst.zip
# Or manual
unzip -p stegasoo-rpi-4.1.7.img.zst.zip | zstdcat | sudo dd of=/dev/sdX bs=4M status=progress
```
Default login: `admin` / `stegasoo` 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 -f docker/docker-compose.yml up -d web # Web UI on :5000 ### Release Assets
docker-compose -f docker/docker-compose.yml 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

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

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

@@ -33,7 +33,8 @@ RUN pip install --no-cache-dir \
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 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 \
@@ -48,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"

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

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

View File

@@ -1,6 +1,6 @@
.\" Stegasoo man page .\" Stegasoo man page
.\" Generate with: groff -man -Tascii stegasoo.1 .\" Generate with: groff -man -Tascii stegasoo.1
.TH STEGASOO 1 "January 2026" "Stegasoo 4.1.7" "User Commands" .TH STEGASOO 1 "February 2026" "Stegasoo 4.3.0" "User Commands"
.SH NAME .SH NAME
stegasoo \- steganography with hybrid authentication stegasoo \- steganography with hybrid authentication
.SH SYNOPSIS .SH SYNOPSIS
@@ -12,9 +12,10 @@ stegasoo \- steganography with hybrid authentication
[\fIargs\fR] [\fIargs\fR]
.SH DESCRIPTION .SH DESCRIPTION
.B stegasoo .B stegasoo
hides messages and files in images using PIN + passphrase security. hides messages and files in images and audio using PIN + passphrase security.
It uses LSB (Least Significant Bit) steganography with optional DCT It uses LSB (Least Significant Bit) steganography with optional DCT
(Discrete Cosine Transform) encoding for JPEG resilience. (Discrete Cosine Transform) encoding for JPEG resilience, and supports
audio steganography with LSB and Spread Spectrum modes.
.PP .PP
Messages are encrypted using a hybrid authentication scheme that combines Messages are encrypted using a hybrid authentication scheme that combines
a reference photo (shared secret), passphrase, and PIN code. a reference photo (shared secret), passphrase, and PIN code.
@@ -221,6 +222,83 @@ Reset admin password using recovery key.
.PP .PP
Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR. Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR.
.RE .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 .SS tools
Image security tools. Image security tools.
.PP .PP

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

@@ -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');
}
}
} }
}); });
} }
@@ -154,6 +163,20 @@ 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

@@ -2247,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 {
@@ -2255,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

@@ -340,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>
@@ -362,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>
@@ -559,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>
@@ -567,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

@@ -16,11 +16,11 @@
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="28"> <img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="28">
</a> </a>
{% if channel_configured %} {% if channel_configured %}
<span class="badge bg-success bg-opacity-25 small" style="padding-left: 0.35rem;" title="Private Channel: {{ channel_fingerprint }}"> <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> <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> </span>
{% else %} {% else %}
<span class="badge bg-secondary bg-opacity-25 small text-muted" style="padding-left: 0.35rem;" title="Public Channel: No shared channel key configured. Messages use only passphrase and PIN for encryption."> <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 <i class="bi bi-globe me-1"></i>Public Channel
</span> </span>
{% endif %} {% endif %}

View File

@@ -24,7 +24,11 @@
border-left: 3px solid #ffe699; border-left: 3px solid #ffe699;
} }
.step-accordion .accordion-button::after { .step-accordion .accordion-button::after {
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2); 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);
@@ -158,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">
@@ -172,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">
@@ -213,41 +249,74 @@
</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 --> <!-- 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">
<div class="btn-group" role="group"> <div id="imageModeGroup">
<input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked> <div class="btn-group" role="group">
<label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label> <input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb"> <label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label>
<label class="btn btn-outline-secondary text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label> <input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb">
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}> <label class="btn btn-outline-secondary text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
<label class="btn btn-outline-secondary 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="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
<label class="btn btn-outline-secondary text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
</div>
</div>
<!-- Audio Extraction Modes (hidden by default) -->
<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>
<div class="form-text" id="modeHint"> <div class="form-text" id="modeHint">
@@ -259,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>
@@ -425,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 => {
@@ -442,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');
@@ -460,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';
} }
} }
@@ -493,19 +570,99 @@ 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
// ============================================================================ // ============================================================================

View File

@@ -24,7 +24,11 @@
border-left: 3px solid #ffe699; border-left: 3px solid #ffe699;
} }
.step-accordion .accordion-button::after { .step-accordion .accordion-button::after {
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2); 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);
@@ -126,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>
@@ -141,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">
@@ -168,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-earmark-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>
@@ -204,32 +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">
<div class="btn-group btn-group-sm" role="group"> <div class="d-flex justify-content-between align-items-center">
<input type="radio" class="btn-check" 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>
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label> <span>
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb" {% if not has_dct %}checked{% endif %}> <span class="badge bg-primary me-1" id="lsbAudioCapacityBadge">LSB: -</span>
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label> <span class="badge bg-warning text-dark" id="spreadCapacityBadge">Spread: -</span>
</span>
</div> </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>
<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>
@@ -449,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 => {
@@ -462,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');
@@ -486,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';
@@ -550,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);
@@ -583,6 +855,7 @@ function updatePayloadSection() {
payloadFileInput.setAttribute('required', ''); payloadFileInput.setAttribute('required', '');
} }
updatePayloadSummary(); updatePayloadSummary();
checkCapacity();
} }
payloadTextRadio?.addEventListener('change', updatePayloadSection); payloadTextRadio?.addEventListener('change', updatePayloadSection);
@@ -606,6 +879,7 @@ payloadFileInput?.addEventListener('change', function() {
} else { } else {
fileInfo?.classList.add('d-none'); fileInfo?.classList.add('d-none');
} }
checkCapacity();
}); });
// ============================================================================ // ============================================================================
@@ -615,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();
}); });
// ============================================================================ // ============================================================================
@@ -634,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(() => {});
} }
}); });
@@ -679,7 +956,7 @@ function updateOutputOptions(mode) {
} }
modeRadios.forEach(radio => { modeRadios.forEach(radio => {
radio.addEventListener('change', () => updateOutputOptions(radio.value)); radio.addEventListener('change', () => { updateOutputOptions(radio.value); checkCapacity(); });
}); });
// Initialize output options based on initial mode // Initialize output options based on initial mode

View File

@@ -12,6 +12,20 @@
</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 -->
@@ -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,7 +53,28 @@
<!-- 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>
@@ -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>
@@ -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>

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 (for smaller download), preserves 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,7 +207,7 @@ 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:

View File

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

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

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,65 +62,65 @@ 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
# ============================================================================ # ============================================================================
@@ -135,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
@@ -150,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
@@ -159,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
@@ -178,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

@@ -105,7 +105,7 @@ echo ""
echo -e "${GREEN}[4/6]${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"

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
@@ -198,7 +257,7 @@ else
echo " mkcert already installed" echo " mkcert already installed"
fi fi
echo -e "${GREEN}[4/12]${NC} Cloning Stegasoo..." 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
@@ -212,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
@@ -231,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
@@ -277,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
@@ -397,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'
@@ -436,7 +503,7 @@ if [ -f "$INSTALL_DIR/docs/stegasoo.1" ]; then
echo " Installed man page (man stegasoo)" echo " Installed man page (man stegasoo)"
fi fi
echo -e "${GREEN}[12/12]${NC} Setting up login banner..." 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'
@@ -727,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
@@ -734,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}"
@@ -746,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
}

View File

@@ -68,20 +68,31 @@ case "${1:-fast}" in
echo -e "${GREEN}Cleaned!${NC}" 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 -e "${CYAN}Stegasoo Build Script${NC}"
echo "" echo ""
echo "Usage: $0 [command]" echo "Usage: $0 [command]"
echo "" echo ""
echo "Commands:" echo "Commands:"
echo " base Build the base image (one-time, 5-10 min)" echo " base Build the base image (one-time, 5-10 min)"
echo " fast Fast build using base image (default, ~10 sec)" echo " fast Fast build using base image (default, ~10 sec)"
echo " full Full rebuild from scratch (slow, no base needed)" echo " full Rebuild services without cache (uses existing base)"
echo " clean Remove all images and volumes" echo " rebuild Complete rebuild with no cache (base + services)"
echo " clean Remove all images and volumes"
echo "" echo ""
echo "Typical workflow:" echo "Typical workflow:"
echo " 1. First time: $0 base" echo " 1. First time: $0 base"
echo " 2. Daily dev: $0 fast" echo " 2. Daily dev: $0 fast"
echo " 3. Deps change: $0 base" echo " 3. Deps change: $0 base"
echo " 4. Nuclear: $0 rebuild"
;; ;;
esac esac

View File

@@ -33,8 +33,8 @@ for cmd in chromium magick curl; do
fi fi
done done
# Check if server is running # Check if server is running (-k for self-signed certs)
if ! curl -s "$BASE_URL" > /dev/null 2>&1; then if ! curl -sk "$BASE_URL" > /dev/null 2>&1; then
echo "Error: Server not responding at $BASE_URL" echo "Error: Server not responding at $BASE_URL"
echo "Start with: STEGASOO_AUTH_ENABLED=false python frontends/web/app.py" echo "Start with: STEGASOO_AUTH_ENABLED=false python frontends/web/app.py"
exit 1 exit 1
@@ -49,7 +49,7 @@ capture() {
printf " %-20s <- %s\n" "$name" "$route" printf " %-20s <- %s\n" "$name" "$route"
chromium --headless --screenshot="$OUTPUT_DIR/$name.png" \ chromium --headless --screenshot="$OUTPUT_DIR/$name.png" \
--window-size="$WINDOW_SIZE" --hide-scrollbars \ --window-size="$WINDOW_SIZE" --hide-scrollbars \
--disable-gpu --no-sandbox \ --disable-gpu --no-sandbox --ignore-certificate-errors \
"$url" 2>/dev/null "$url" 2>/dev/null
} }

View File

@@ -7,7 +7,7 @@ Changes in v4.0.0:
- encode() and decode() now accept channel_key parameter - encode() and decode() now accept channel_key parameter
""" """
__version__ = "4.1.7" __version__ = "4.3.0"
# Core functionality # Core functionality
# Channel key management (v4.0.0) # Channel key management (v4.0.0)
@@ -22,6 +22,9 @@ from .channel import (
validate_channel_key, validate_channel_key,
) )
# Audio support — gated by STEGASOO_AUDIO env var and dependency availability
from .constants import AUDIO_ENABLED, VIDEO_ENABLED
# Crypto functions # Crypto functions
from .crypto import get_active_channel_key, get_channel_fingerprint, has_argon2 from .crypto import get_active_channel_key, get_channel_fingerprint, has_argon2
from .decode import decode, decode_file, decode_text from .decode import decode, decode_file, decode_text
@@ -43,6 +46,16 @@ from .image_utils import (
get_image_info, get_image_info,
) )
# Backend registry
from .backends import EmbeddingBackend, registry as backend_registry
# Platform presets
from .platform_presets import PLATFORMS, get_preset
# Steganalysis
from .steganalysis import check_image
from .backends.registry import BackendNotFoundError
# Steganography functions # Steganography functions
from .steganography import ( from .steganography import (
calculate_capacity_by_mode, calculate_capacity_by_mode,
@@ -54,6 +67,44 @@ from .steganography import (
# Utilities # Utilities
from .utils import generate_filename from .utils import generate_filename
HAS_AUDIO_SUPPORT = AUDIO_ENABLED
HAS_VIDEO_SUPPORT = VIDEO_ENABLED
if AUDIO_ENABLED:
from .audio_utils import (
detect_audio_format,
get_audio_info,
has_ffmpeg_support,
validate_audio,
)
from .decode import decode_audio
from .encode import encode_audio
else:
detect_audio_format = None
get_audio_info = None
has_ffmpeg_support = None
validate_audio = None
encode_audio = None
decode_audio = None
# Video support — gated by STEGASOO_VIDEO env var and ffmpeg + audio deps
if VIDEO_ENABLED:
from .decode import decode_video
from .encode import encode_video
from .video_utils import (
calculate_video_capacity,
detect_video_format,
get_video_info,
validate_video,
)
else:
detect_video_format = None
get_video_info = None
validate_video = None
calculate_video_capacity = None
encode_video = None
decode_video = None
# QR Code utilities - optional, may not be available # QR Code utilities - optional, may not be available
try: try:
from .qr_utils import ( from .qr_utils import (
@@ -88,9 +139,14 @@ validate_carrier = validate_image
# Constants # Constants
from .constants import ( from .constants import (
DEFAULT_PASSPHRASE_WORDS, DEFAULT_PASSPHRASE_WORDS,
EMBED_MODE_AUDIO_AUTO,
EMBED_MODE_AUDIO_LSB,
EMBED_MODE_AUDIO_SPREAD,
EMBED_MODE_AUTO, EMBED_MODE_AUTO,
EMBED_MODE_DCT, EMBED_MODE_DCT,
EMBED_MODE_LSB, EMBED_MODE_LSB,
EMBED_MODE_VIDEO_AUTO,
EMBED_MODE_VIDEO_LSB,
FORMAT_VERSION, FORMAT_VERSION,
LOSSLESS_FORMATS, LOSSLESS_FORMATS,
MAX_FILE_PAYLOAD_SIZE, MAX_FILE_PAYLOAD_SIZE,
@@ -106,6 +162,11 @@ from .constants import (
# Exceptions # Exceptions
from .exceptions import ( from .exceptions import (
AudioCapacityError,
AudioError,
AudioExtractionError,
AudioTranscodeError,
AudioValidationError,
CapacityError, CapacityError,
CryptoError, CryptoError,
DecryptionError, DecryptionError,
@@ -127,11 +188,21 @@ from .exceptions import (
SecurityFactorError, SecurityFactorError,
SteganographyError, SteganographyError,
StegasooError, StegasooError,
UnsupportedAudioFormatError,
UnsupportedVideoFormatError,
ValidationError, ValidationError,
VideoCapacityError,
VideoError,
VideoExtractionError,
VideoTranscodeError,
VideoValidationError,
) )
# Models # Models
from .models import ( from .models import (
AudioCapacityInfo,
AudioEmbedStats,
AudioInfo,
CapacityComparison, CapacityComparison,
Credentials, Credentials,
DecodeResult, DecodeResult,
@@ -140,8 +211,13 @@ from .models import (
GenerateResult, GenerateResult,
ImageInfo, ImageInfo,
ValidationResult, ValidationResult,
VideoCapacityInfo,
VideoEmbedStats,
VideoInfo,
) )
from .validation import ( from .validation import (
validate_audio_embed_mode,
validate_audio_file,
validate_dct_color_mode, validate_dct_color_mode,
validate_dct_output_format, validate_dct_output_format,
validate_embed_mode, validate_embed_mode,
@@ -164,6 +240,24 @@ __all__ = [
"decode", "decode",
"decode_file", "decode_file",
"decode_text", "decode_text",
# Audio (v4.3.0)
"encode_audio",
"decode_audio",
"detect_audio_format",
"get_audio_info",
"has_ffmpeg_support",
"validate_audio",
"HAS_AUDIO_SUPPORT",
"HAS_VIDEO_SUPPORT",
"validate_audio_embed_mode",
"validate_audio_file",
# Video (v4.4.0)
"encode_video",
"decode_video",
"detect_video_format",
"get_video_info",
"validate_video",
"calculate_video_capacity",
# Generation # Generation
"generate_pin", "generate_pin",
"generate_passphrase", "generate_passphrase",
@@ -189,6 +283,15 @@ __all__ = [
"generate_filename", "generate_filename",
# Crypto # Crypto
"has_argon2", "has_argon2",
# Backends
"EmbeddingBackend",
"backend_registry",
"BackendNotFoundError",
# Platform presets
"get_preset",
"PLATFORMS",
# Steganalysis
"check_image",
# Steganography # Steganography
"has_dct_support", "has_dct_support",
"calculate_capacity_by_mode", "calculate_capacity_by_mode",
@@ -221,6 +324,14 @@ __all__ = [
"FilePayload", "FilePayload",
"Credentials", "Credentials",
"ValidationResult", "ValidationResult",
# Audio models
"AudioEmbedStats",
"AudioInfo",
"AudioCapacityInfo",
# Video models
"VideoEmbedStats",
"VideoInfo",
"VideoCapacityInfo",
# Exceptions # Exceptions
"StegasooError", "StegasooError",
"ValidationError", "ValidationError",
@@ -244,6 +355,20 @@ __all__ = [
"ReedSolomonError", "ReedSolomonError",
"NoDataFoundError", "NoDataFoundError",
"ModeMismatchError", "ModeMismatchError",
# Audio exceptions
"AudioError",
"AudioValidationError",
"AudioCapacityError",
"AudioExtractionError",
"AudioTranscodeError",
"UnsupportedAudioFormatError",
# Video exceptions
"VideoError",
"VideoValidationError",
"VideoCapacityError",
"VideoExtractionError",
"VideoTranscodeError",
"UnsupportedVideoFormatError",
# Constants # Constants
"FORMAT_VERSION", "FORMAT_VERSION",
"MIN_PASSPHRASE_WORDS", "MIN_PASSPHRASE_WORDS",
@@ -266,4 +391,11 @@ __all__ = [
"EMBED_MODE_LSB", "EMBED_MODE_LSB",
"EMBED_MODE_DCT", "EMBED_MODE_DCT",
"EMBED_MODE_AUTO", "EMBED_MODE_AUTO",
# Audio constants
"EMBED_MODE_AUDIO_LSB",
"EMBED_MODE_AUDIO_SPREAD",
"EMBED_MODE_AUDIO_AUTO",
# Video constants
"EMBED_MODE_VIDEO_LSB",
"EMBED_MODE_VIDEO_AUTO",
] ]

View File

@@ -0,0 +1,520 @@
"""
Stegasoo Audio Steganography — LSB Embedding/Extraction (v4.3.0)
LSB (Least Significant Bit) embedding for PCM audio samples.
Hides data in the least significant bit(s) of audio samples, analogous to
how steganography.py hides data in pixel LSBs. The carrier audio must be
lossless (WAV or FLAC) — lossy codecs (MP3, OGG, AAC) destroy LSBs.
Uses ChaCha20 as a CSPRNG for pseudo-random sample index selection,
ensuring that without the key an attacker cannot determine which samples
were modified.
Supports:
- 16-bit PCM (int16 samples)
- 24-bit PCM (int32 samples from soundfile)
- Float audio (converted to int16 before embedding)
- 1 or 2 bits per sample embedding depth
- Mono and multi-channel audio (flattened for embedding)
"""
import io
import struct
import numpy as np
import soundfile as sf
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
from .constants import (
AUDIO_MAGIC_LSB,
EMBED_MODE_AUDIO_LSB,
)
from .debug import debug
from .exceptions import AudioCapacityError, AudioError
from .models import AudioEmbedStats
from .steganography import ENCRYPTION_OVERHEAD
# Progress reporting interval — write every N samples
PROGRESS_INTERVAL = 5000
# =============================================================================
# PROGRESS REPORTING
# =============================================================================
def _write_progress(progress_file: str | None, current: int, total: int, phase: str = "embedding"):
"""Write progress to file for frontend polling."""
if progress_file is None:
return
try:
import json
with open(progress_file, "w") as f:
json.dump(
{
"current": current,
"total": total,
"percent": round((current / total) * 100, 1) if total > 0 else 0,
"phase": phase,
},
f,
)
except Exception:
pass # Don't let progress writing break encoding
# =============================================================================
# CAPACITY
# =============================================================================
def calculate_audio_lsb_capacity(
audio_data: bytes,
bits_per_sample: int = 1,
) -> int:
"""
Calculate the maximum bytes that can be embedded in a WAV/FLAC file via LSB.
Reads the carrier audio with soundfile, counts the total number of individual
sample values (num_frames * channels), and computes how many payload bytes
can be hidden at the given bit depth, minus the fixed encryption overhead.
Args:
audio_data: Raw bytes of a WAV or FLAC file.
bits_per_sample: Number of LSBs to use per sample (1 or 2).
Returns:
Maximum embeddable payload size in bytes (after subtracting overhead).
Raises:
AudioError: If the audio cannot be read or is in an unsupported format.
"""
debug.validate(
bits_per_sample in (1, 2), f"bits_per_sample must be 1 or 2, got {bits_per_sample}"
)
try:
info = sf.info(io.BytesIO(audio_data))
except Exception as e:
raise AudioError(f"Failed to read audio file: {e}") from e
num_samples = info.frames * info.channels
total_bits = num_samples * bits_per_sample
max_bytes = total_bits // 8
capacity = max(0, max_bytes - ENCRYPTION_OVERHEAD)
debug.print(
f"Audio LSB capacity: {capacity} bytes "
f"({num_samples} samples, {bits_per_sample} bit(s)/sample, "
f"{info.samplerate} Hz, {info.channels} ch)"
)
return capacity
# =============================================================================
# SAMPLE INDEX GENERATION (ChaCha20 CSPRNG)
# =============================================================================
#
# Identical strategy to generate_pixel_indices in steganography.py:
# - >= 50% capacity utilisation: full Fisher-Yates shuffle, take first N
# - < 50%: direct random sampling with collision handling
#
# The key MUST be 32 bytes (same derivation path as the pixel key).
@debug.time
def generate_sample_indices(key: bytes, num_samples: int, num_needed: int) -> list[int]:
"""
Generate pseudo-random sample indices using ChaCha20 as a CSPRNG.
Produces a deterministic sequence of unique sample indices so that
the same key always yields the same embedding locations.
Args:
key: 32-byte key for the ChaCha20 cipher.
num_samples: Total number of samples in the carrier audio.
num_needed: How many unique sample indices are required.
Returns:
List of ``num_needed`` unique indices in [0, num_samples).
Raises:
AssertionError (via debug.validate): On invalid arguments.
"""
debug.validate(len(key) == 32, f"Sample key must be 32 bytes, got {len(key)}")
debug.validate(num_samples > 0, f"Number of samples must be positive, got {num_samples}")
debug.validate(num_needed > 0, f"Number needed must be positive, got {num_needed}")
debug.validate(
num_needed <= num_samples,
f"Cannot select {num_needed} samples from {num_samples} available",
)
debug.print(f"Generating {num_needed} sample indices from {num_samples} total samples")
# Strategy 1: Full Fisher-Yates shuffle when we need many indices
if num_needed >= num_samples // 2:
debug.print(f"Using full shuffle (needed {num_needed}/{num_samples} samples)")
nonce = b"\x00" * 16
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
encryptor = cipher.encryptor()
indices = list(range(num_samples))
random_bytes = encryptor.update(b"\x00" * (num_samples * 4))
for i in range(num_samples - 1, 0, -1):
j_bytes = random_bytes[(num_samples - 1 - i) * 4 : (num_samples - i) * 4]
j = int.from_bytes(j_bytes, "big") % (i + 1)
indices[i], indices[j] = indices[j], indices[i]
selected = indices[:num_needed]
debug.print(f"Generated {len(selected)} indices via shuffle")
return selected
# Strategy 2: Direct sampling for lower utilisation
debug.print(f"Using optimized selection (needed {num_needed}/{num_samples} samples)")
selected: list[int] = []
used: set[int] = set()
nonce = b"\x00" * 16
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
encryptor = cipher.encryptor()
# Pre-generate 2x bytes to handle expected collisions
bytes_needed = (num_needed * 2) * 4
random_bytes = encryptor.update(b"\x00" * bytes_needed)
byte_offset = 0
collisions = 0
while len(selected) < num_needed and byte_offset < len(random_bytes) - 4:
idx = int.from_bytes(random_bytes[byte_offset : byte_offset + 4], "big") % num_samples
byte_offset += 4
if idx not in used:
used.add(idx)
selected.append(idx)
else:
collisions += 1
# Edge case: ran out of pre-generated bytes (very high collision rate)
if len(selected) < num_needed:
debug.print(f"Need {num_needed - len(selected)} more indices, generating...")
extra_needed = num_needed - len(selected)
for _ in range(extra_needed * 2):
extra_bytes = encryptor.update(b"\x00" * 4)
idx = int.from_bytes(extra_bytes, "big") % num_samples
if idx not in used:
used.add(idx)
selected.append(idx)
if len(selected) == num_needed:
break
debug.print(f"Generated {len(selected)} indices with {collisions} collisions")
debug.validate(
len(selected) == num_needed,
f"Failed to generate enough indices: {len(selected)}/{num_needed}",
)
return selected
# =============================================================================
# EMBEDDING
# =============================================================================
@debug.time
def embed_in_audio_lsb(
data: bytes,
carrier_audio: bytes,
sample_key: bytes,
bits_per_sample: int = 1,
progress_file: str | None = None,
) -> tuple[bytes, AudioEmbedStats]:
"""
Embed data into PCM audio samples using LSB steganography.
The payload is prepended with a 4-byte magic header (``AUDIO_MAGIC_LSB``)
and a 4-byte big-endian length prefix, then converted to a binary string.
Pseudo-random sample indices are generated from ``sample_key`` and the
corresponding sample LSBs are overwritten.
The modified audio is written back as a 16-bit PCM WAV file.
Args:
data: Encrypted payload bytes to embed.
carrier_audio: Raw bytes of the carrier WAV/FLAC file.
sample_key: 32-byte key for sample index generation.
bits_per_sample: LSBs to use per sample (1 or 2).
progress_file: Optional path for progress JSON (frontend polling).
Returns:
Tuple of (stego WAV bytes, AudioEmbedStats).
Raises:
AudioCapacityError: If the payload is too large for the carrier.
AudioError: On any other embedding failure.
"""
debug.print(f"Audio LSB embedding {len(data)} bytes")
debug.data(sample_key, "Sample key for embedding")
debug.validate(
bits_per_sample in (1, 2), f"bits_per_sample must be 1 or 2, got {bits_per_sample}"
)
debug.validate(len(sample_key) == 32, f"Sample key must be 32 bytes, got {len(sample_key)}")
try:
# 1. Read carrier audio as float64 (handles all subtypes correctly)
buf = io.BytesIO(carrier_audio)
float_samples, samplerate = sf.read(buf, dtype="float64", always_2d=True)
original_shape = float_samples.shape
channels = original_shape[1]
duration = original_shape[0] / samplerate
# Detect original subtype for output
buf.seek(0)
carrier_info = sf.info(buf)
output_subtype = carrier_info.subtype or "PCM_16"
debug.print(
f"Carrier audio: {samplerate} Hz, {channels} ch, "
f"{original_shape[0]} frames, {duration:.2f}s, subtype={output_subtype}"
)
# Convert float64 → int16 for LSB manipulation (32768 matches libsndfile normalization)
samples = np.clip(float_samples * 32768.0, -32768, 32767).astype(np.int16)
# Flatten to 1D for embedding
flat_samples = samples.flatten().copy()
num_samples = len(flat_samples)
# 2. Prepend magic + length prefix
header = AUDIO_MAGIC_LSB + struct.pack(">I", len(data))
payload = header + data
debug.print(
f"Payload with header: {len(payload)} bytes (magic 4 + len 4 + data {len(data)})"
)
# 3. Check capacity
max_bytes = (num_samples * bits_per_sample) // 8
if len(payload) > max_bytes:
debug.print(f"Capacity error: need {len(payload)}, have {max_bytes}")
raise AudioCapacityError(len(payload), max_bytes)
debug.print(
f"Capacity usage: {len(payload)}/{max_bytes} bytes "
f"({len(payload) / max_bytes * 100:.1f}%)"
)
# 4. Convert payload to binary string
binary_data = "".join(format(b, "08b") for b in payload)
samples_needed = (len(binary_data) + bits_per_sample - 1) // bits_per_sample
debug.print(f"Need {samples_needed} samples to embed {len(binary_data)} bits")
# 5. Generate pseudo-random sample indices
selected_indices = generate_sample_indices(sample_key, num_samples, samples_needed)
# 6. Modify LSBs of selected samples
lsb_mask = (1 << bits_per_sample) - 1
bit_idx = 0
modified_count = 0
total_to_process = len(selected_indices)
# Initial progress
if progress_file:
_write_progress(progress_file, 5, 100, "embedding")
for progress_idx, sample_idx in enumerate(selected_indices):
if bit_idx >= len(binary_data):
break
bits = binary_data[bit_idx : bit_idx + bits_per_sample].ljust(bits_per_sample, "0")
bit_val = int(bits, 2)
sample_val = flat_samples[sample_idx]
# Work in unsigned 16-bit space to avoid overflow
unsigned_val = int(sample_val) & 0xFFFF
new_unsigned = (unsigned_val & ~lsb_mask) | bit_val
# Convert back to signed int16
new_val = np.int16(new_unsigned if new_unsigned < 32768 else new_unsigned - 65536)
if sample_val != new_val:
flat_samples[sample_idx] = new_val
modified_count += 1
bit_idx += bits_per_sample
# Report progress periodically
if progress_file and progress_idx % PROGRESS_INTERVAL == 0:
_write_progress(progress_file, progress_idx, total_to_process, "embedding")
# Final progress before save
if progress_file:
_write_progress(progress_file, total_to_process, total_to_process, "saving")
debug.print(f"Modified {modified_count} samples (out of {samples_needed} selected)")
# 7. Reshape and write back as PCM_16 WAV
# LSB steganography requires integer samples — writing as FLOAT/DOUBLE
# destroys LSBs due to float32 precision loss (33k/65k values fail round-trip).
stego_samples = flat_samples.reshape(original_shape)
output_buf = io.BytesIO()
sf.write(output_buf, stego_samples, samplerate, format="WAV", subtype="PCM_16")
output_buf.seek(0)
stego_bytes = output_buf.getvalue()
stats = AudioEmbedStats(
samples_modified=modified_count,
total_samples=num_samples,
capacity_used=len(payload) / max_bytes,
bytes_embedded=len(payload),
sample_rate=samplerate,
channels=channels,
duration_seconds=duration,
embed_mode=EMBED_MODE_AUDIO_LSB,
)
debug.print(f"Audio LSB embedding complete: {len(stego_bytes)} byte WAV")
return stego_bytes, stats
except AudioCapacityError:
raise
except Exception as e:
debug.exception(e, "embed_in_audio_lsb")
raise AudioError(f"Failed to embed data in audio: {e}") from e
# =============================================================================
# EXTRACTION
# =============================================================================
@debug.time
def extract_from_audio_lsb(
audio_data: bytes,
sample_key: bytes,
bits_per_sample: int = 1,
progress_file: str | None = None,
) -> bytes | None:
"""
Extract hidden data from audio using LSB steganography.
Reads the stego audio, generates the same pseudo-random sample indices
from ``sample_key``, extracts the LSBs, and reconstructs the payload.
Verifies the ``AUDIO_MAGIC_LSB`` header before returning.
Args:
audio_data: Raw bytes of the stego WAV file.
sample_key: 32-byte key (must match the one used for embedding).
bits_per_sample: LSBs per sample (must match embedding).
progress_file: Optional path for progress JSON.
Returns:
Extracted payload bytes (without magic/length prefix), or ``None``
if extraction fails (wrong key, no data, corrupted).
"""
debug.print(f"Audio LSB extracting from {len(audio_data)} byte audio")
debug.data(sample_key, "Sample key for extraction")
debug.validate(
bits_per_sample in (1, 2), f"bits_per_sample must be 1 or 2, got {bits_per_sample}"
)
try:
# 1. Read audio as int16 directly (stego output is always PCM_16)
samples, samplerate = sf.read(io.BytesIO(audio_data), dtype="int16", always_2d=True)
flat_samples = samples.flatten()
num_samples = len(flat_samples)
debug.print(f"Audio: {samplerate} Hz, {samples.shape[1]} ch, {num_samples} total samples")
# 2. Extract initial samples to find magic bytes + length (8 bytes = 64 bits)
header_bits_needed = 64 # 4 bytes magic + 4 bytes length
header_samples_needed = (header_bits_needed + bits_per_sample - 1) // bits_per_sample + 10
if header_samples_needed > num_samples:
debug.print("Audio too small to contain header")
return None
initial_indices = generate_sample_indices(sample_key, num_samples, header_samples_needed)
binary_data = ""
for sample_idx in initial_indices:
val = int(flat_samples[sample_idx]) & 0xFFFF
for bit_pos in range(bits_per_sample - 1, -1, -1):
binary_data += str((val >> bit_pos) & 1)
# 3. Verify magic bytes
if len(binary_data) < 64:
debug.print(f"Not enough bits for header: {len(binary_data)}/64")
return None
magic_bits = binary_data[:32]
magic_bytes = int(magic_bits, 2).to_bytes(4, "big")
if magic_bytes != AUDIO_MAGIC_LSB:
debug.print(f"Magic mismatch: got {magic_bytes!r}, expected {AUDIO_MAGIC_LSB!r}")
return None
debug.print("Magic bytes verified: AUDL")
# 4. Parse length
length_bits = binary_data[32:64]
data_length = struct.unpack(">I", int(length_bits, 2).to_bytes(4, "big"))[0]
debug.print(f"Extracted length: {data_length} bytes")
# Sanity check length
max_possible = (num_samples * bits_per_sample) // 8 - 8 # minus header
if data_length > max_possible or data_length < 1:
debug.print(f"Invalid data length: {data_length} (max possible: {max_possible})")
return None
# 5. Extract full payload
total_bits = (8 + data_length) * 8 # header (8 bytes) + payload
total_samples_needed = (total_bits + bits_per_sample - 1) // bits_per_sample
if total_samples_needed > num_samples:
debug.print(f"Need {total_samples_needed} samples but only {num_samples} available")
return None
debug.print(f"Need {total_samples_needed} samples to extract {data_length} bytes")
selected_indices = generate_sample_indices(sample_key, num_samples, total_samples_needed)
# Initial progress
if progress_file:
_write_progress(progress_file, 5, 100, "extracting")
binary_data = ""
for progress_idx, sample_idx in enumerate(selected_indices):
val = int(flat_samples[sample_idx]) & 0xFFFF
for bit_pos in range(bits_per_sample - 1, -1, -1):
binary_data += str((val >> bit_pos) & 1)
if progress_file and progress_idx % PROGRESS_INTERVAL == 0:
_write_progress(progress_file, progress_idx, total_samples_needed, "extracting")
if progress_file:
_write_progress(progress_file, total_samples_needed, total_samples_needed, "extracting")
# Skip the 8-byte header (magic + length) = 64 bits
data_bits = binary_data[64 : 64 + (data_length * 8)]
if len(data_bits) < data_length * 8:
debug.print(f"Insufficient bits: {len(data_bits)} < {data_length * 8}")
return None
# Convert bits back to bytes
data_bytes = bytearray()
for i in range(0, len(data_bits), 8):
byte_bits = data_bits[i : i + 8]
if len(byte_bits) == 8:
data_bytes.append(int(byte_bits, 2))
debug.print(f"Audio LSB successfully extracted {len(data_bytes)} bytes")
return bytes(data_bytes)
except Exception as e:
debug.exception(e, "extract_from_audio_lsb")
return None

540
src/stegasoo/audio_utils.py Normal file
View File

@@ -0,0 +1,540 @@
"""
Stegasoo Audio Utilities (v4.3.0)
Audio format detection, transcoding, and metadata extraction for audio steganography.
Dependencies:
- soundfile (sf): Fast WAV/FLAC reading without ffmpeg
- pydub: MP3/OGG/AAC transcoding (wraps ffmpeg)
Both are optional — functions degrade gracefully when unavailable.
"""
from __future__ import annotations
import io
import shutil
from .constants import (
EMBED_MODE_AUDIO_AUTO,
MAX_AUDIO_DURATION,
MAX_AUDIO_FILE_SIZE,
MAX_AUDIO_SAMPLE_RATE,
MIN_AUDIO_SAMPLE_RATE,
VALID_AUDIO_EMBED_MODES,
)
from .debug import get_logger
from .exceptions import AudioTranscodeError, AudioValidationError, UnsupportedAudioFormatError
from .models import AudioInfo, ValidationResult
logger = get_logger(__name__)
# =============================================================================
# FFMPEG AVAILABILITY
# =============================================================================
def has_ffmpeg_support() -> bool:
"""Check if ffmpeg is available on the system.
Returns:
True if ffmpeg is found on PATH, False otherwise.
"""
return shutil.which("ffmpeg") is not None
# =============================================================================
# FORMAT DETECTION
# =============================================================================
def detect_audio_format(audio_data: bytes) -> str:
"""Detect audio format from magic bytes.
Examines the first bytes of audio data to identify the container format.
Magic byte signatures:
- WAV: b"RIFF" at offset 0 + b"WAVE" at offset 8
- FLAC: b"fLaC" at offset 0
- MP3: b"\\xff\\xfb", b"\\xff\\xf3", b"\\xff\\xf2" (sync bytes) or b"ID3" (ID3 tag)
- OGG (Vorbis/Opus): b"OggS" at offset 0
- AAC: b"\\xff\\xf1" or b"\\xff\\xf9" (ADTS header)
- M4A/MP4: b"ftyp" at offset 4
Args:
audio_data: Raw audio file bytes.
Returns:
Format string: "wav", "flac", "mp3", "ogg", "aac", "m4a", or "unknown".
"""
if len(audio_data) < 12:
logger.debug("detect_audio_format: data too short (%d bytes)", len(audio_data))
return "unknown"
# WAV: RIFF....WAVE
if audio_data[:4] == b"RIFF" and audio_data[8:12] == b"WAVE":
logger.debug("Detected WAV format (%d bytes)", len(audio_data))
return "wav"
# FLAC
if audio_data[:4] == b"fLaC":
return "flac"
# OGG (Vorbis or Opus)
if audio_data[:4] == b"OggS":
return "ogg"
# MP3 with ID3 tag
if audio_data[:3] == b"ID3":
return "mp3"
# MP3 sync bytes (MPEG audio frame header)
if len(audio_data) >= 2 and audio_data[:2] in (b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"):
return "mp3"
# M4A/MP4 container: "ftyp" at offset 4
if audio_data[4:8] == b"ftyp":
return "m4a"
# AAC ADTS header
if len(audio_data) >= 2 and audio_data[:2] in (b"\xff\xf1", b"\xff\xf9"):
return "aac"
return "unknown"
# =============================================================================
# TRANSCODING
# =============================================================================
def transcode_to_wav(audio_data: bytes) -> bytes:
"""Transcode any supported audio format to WAV PCM format.
Uses soundfile directly for WAV/FLAC (no ffmpeg needed).
Uses pydub (wraps ffmpeg) for lossy formats (MP3, OGG, AAC, M4A).
Args:
audio_data: Raw audio file bytes in any supported format.
Returns:
WAV PCM file bytes (16-bit, original sample rate).
Raises:
AudioTranscodeError: If transcoding fails.
UnsupportedAudioFormatError: If the format cannot be detected.
"""
fmt = detect_audio_format(audio_data)
logger.info("transcode_to_wav: input format=%s, size=%d bytes", fmt, len(audio_data))
if fmt == "unknown":
raise UnsupportedAudioFormatError(
"Cannot detect audio format. Supported: WAV, FLAC, MP3, OGG, AAC, M4A."
)
# WAV files: validate with soundfile but return as-is if already PCM
if fmt == "wav":
try:
import soundfile as sf
buf = io.BytesIO(audio_data)
info = sf.info(buf)
if info.subtype in ("PCM_16", "PCM_24", "PCM_32", "FLOAT", "DOUBLE"):
# Re-encode to ensure consistent PCM_16 output
buf.seek(0)
data, samplerate = sf.read(buf, dtype="int16")
out = io.BytesIO()
sf.write(out, data, samplerate, format="WAV", subtype="PCM_16")
return out.getvalue()
except ImportError:
raise AudioTranscodeError("soundfile package is required for WAV processing")
except Exception as e:
raise AudioTranscodeError(f"Failed to process WAV: {e}")
# FLAC: use soundfile (fast, no ffmpeg)
if fmt == "flac":
try:
import soundfile as sf
buf = io.BytesIO(audio_data)
data, samplerate = sf.read(buf, dtype="int16")
out = io.BytesIO()
sf.write(out, data, samplerate, format="WAV", subtype="PCM_16")
return out.getvalue()
except ImportError:
raise AudioTranscodeError("soundfile package is required for FLAC processing")
except Exception as e:
raise AudioTranscodeError(f"Failed to transcode FLAC to WAV: {e}")
# Lossy formats (MP3, OGG, AAC, M4A): use pydub + ffmpeg
return _transcode_with_pydub(audio_data, fmt, "wav")
def transcode_to_mp3(audio_data: bytes, bitrate: str = "256k") -> bytes:
"""Transcode audio to MP3 format.
Uses pydub (wraps ffmpeg) for transcoding.
Args:
audio_data: Raw audio file bytes in any supported format.
bitrate: Target MP3 bitrate (e.g., "128k", "192k", "256k", "320k").
Returns:
MP3 file bytes.
Raises:
AudioTranscodeError: If transcoding fails or pydub/ffmpeg unavailable.
"""
fmt = detect_audio_format(audio_data)
if fmt == "unknown":
raise UnsupportedAudioFormatError(
"Cannot detect audio format. Supported: WAV, FLAC, MP3, OGG, AAC, M4A."
)
try:
from pydub import AudioSegment
except ImportError:
raise AudioTranscodeError(
"pydub package is required for MP3 transcoding. Install with: pip install pydub"
)
if not has_ffmpeg_support():
raise AudioTranscodeError(
"ffmpeg is required for MP3 transcoding. Install ffmpeg on your system."
)
try:
# Map our format names to pydub format names
pydub_fmt = _pydub_format(fmt)
buf = io.BytesIO(audio_data)
audio = AudioSegment.from_file(buf, format=pydub_fmt)
out = io.BytesIO()
audio.export(out, format="mp3", bitrate=bitrate)
return out.getvalue()
except Exception as e:
raise AudioTranscodeError(f"Failed to transcode to MP3: {e}")
def _transcode_with_pydub(audio_data: bytes, src_fmt: str, dst_fmt: str) -> bytes:
"""Transcode audio using pydub (requires ffmpeg).
Args:
audio_data: Raw audio bytes.
src_fmt: Source format string (our naming).
dst_fmt: Destination format string ("wav" or "mp3").
Returns:
Transcoded audio bytes.
Raises:
AudioTranscodeError: If transcoding fails.
"""
try:
from pydub import AudioSegment
except ImportError:
raise AudioTranscodeError(
"pydub package is required for audio transcoding. Install with: pip install pydub"
)
if not has_ffmpeg_support():
raise AudioTranscodeError(
"ffmpeg is required for audio transcoding. Install ffmpeg on your system."
)
try:
pydub_fmt = _pydub_format(src_fmt)
buf = io.BytesIO(audio_data)
audio = AudioSegment.from_file(buf, format=pydub_fmt)
out = io.BytesIO()
if dst_fmt == "wav":
audio.export(out, format="wav")
else:
audio.export(out, format=dst_fmt)
return out.getvalue()
except Exception as e:
raise AudioTranscodeError(f"Failed to transcode {src_fmt} to {dst_fmt}: {e}")
def _pydub_format(fmt: str) -> str:
"""Map our format names to pydub/ffmpeg format names.
Args:
fmt: Our internal format name.
Returns:
pydub-compatible format string.
"""
mapping = {
"wav": "wav",
"flac": "flac",
"mp3": "mp3",
"ogg": "ogg",
"aac": "aac",
"m4a": "m4a",
}
return mapping.get(fmt, fmt)
# =============================================================================
# METADATA EXTRACTION
# =============================================================================
def get_audio_info(audio_data: bytes) -> AudioInfo:
"""Extract audio metadata from raw audio bytes.
Uses soundfile for WAV/FLAC (fast, no ffmpeg dependency).
Falls back to pydub for other formats (requires ffmpeg).
Args:
audio_data: Raw audio file bytes.
Returns:
AudioInfo dataclass with sample rate, channels, duration, etc.
Raises:
UnsupportedAudioFormatError: If the format cannot be detected.
AudioTranscodeError: If metadata extraction fails.
"""
fmt = detect_audio_format(audio_data)
if fmt == "unknown":
raise UnsupportedAudioFormatError(
"Cannot detect audio format. Supported: WAV, FLAC, MP3, OGG, AAC, M4A."
)
# WAV and FLAC: use soundfile (fast)
if fmt in ("wav", "flac"):
return _get_info_soundfile(audio_data, fmt)
# Lossy formats: use pydub
return _get_info_pydub(audio_data, fmt)
def _get_info_soundfile(audio_data: bytes, fmt: str) -> AudioInfo:
"""Extract audio info using soundfile (WAV/FLAC).
Args:
audio_data: Raw audio bytes.
fmt: Format string ("wav" or "flac").
Returns:
AudioInfo with metadata.
"""
try:
import soundfile as sf
except ImportError:
raise AudioTranscodeError(
"soundfile package is required. Install with: pip install soundfile"
)
try:
buf = io.BytesIO(audio_data)
info = sf.info(buf)
# Determine bit depth from subtype
bit_depth = _bit_depth_from_subtype(info.subtype)
return AudioInfo(
sample_rate=info.samplerate,
channels=info.channels,
duration_seconds=info.duration,
num_samples=info.frames,
format=fmt,
bitrate=None,
bit_depth=bit_depth,
)
except Exception as e:
raise AudioTranscodeError(f"Failed to read {fmt.upper()} metadata: {e}")
def _bit_depth_from_subtype(subtype: str) -> int | None:
"""Determine bit depth from soundfile subtype string.
Args:
subtype: Soundfile subtype (e.g., "PCM_16", "PCM_24", "FLOAT").
Returns:
Bit depth as integer, or None if unknown.
"""
subtype_map = {
"PCM_S8": 8,
"PCM_U8": 8,
"PCM_16": 16,
"PCM_24": 24,
"PCM_32": 32,
"FLOAT": 32,
"DOUBLE": 64,
}
return subtype_map.get(subtype)
def _get_info_pydub(audio_data: bytes, fmt: str) -> AudioInfo:
"""Extract audio info using pydub (lossy formats).
Args:
audio_data: Raw audio bytes.
fmt: Format string ("mp3", "ogg", "aac", "m4a").
Returns:
AudioInfo with metadata.
"""
try:
from pydub import AudioSegment
except ImportError:
raise AudioTranscodeError(
"pydub package is required for audio metadata. Install with: pip install pydub"
)
if not has_ffmpeg_support():
raise AudioTranscodeError(
"ffmpeg is required for audio metadata extraction. Install ffmpeg on your system."
)
try:
pydub_fmt = _pydub_format(fmt)
buf = io.BytesIO(audio_data)
audio = AudioSegment.from_file(buf, format=pydub_fmt)
num_samples = int(audio.frame_count())
duration = audio.duration_seconds
sample_rate = audio.frame_rate
channels = audio.channels
# Estimate bitrate from file size and duration
bitrate = None
if duration > 0:
bitrate = int((len(audio_data) * 8) / duration)
return AudioInfo(
sample_rate=sample_rate,
channels=channels,
duration_seconds=duration,
num_samples=num_samples,
format=fmt,
bitrate=bitrate,
bit_depth=audio.sample_width * 8 if audio.sample_width else None,
)
except Exception as e:
raise AudioTranscodeError(f"Failed to read {fmt.upper()} metadata: {e}")
# =============================================================================
# VALIDATION
# =============================================================================
def validate_audio(
audio_data: bytes,
name: str = "Audio",
check_duration: bool = True,
) -> ValidationResult:
"""Validate audio data for steganography.
Checks:
- Not empty
- Not too large (MAX_AUDIO_FILE_SIZE)
- Valid audio format (detectable via magic bytes)
- Duration within limits (MAX_AUDIO_DURATION) if check_duration=True
- Sample rate within limits (MIN_AUDIO_SAMPLE_RATE to MAX_AUDIO_SAMPLE_RATE)
Args:
audio_data: Raw audio file bytes.
name: Descriptive name for error messages (default: "Audio").
check_duration: Whether to enforce duration limit (default: True).
Returns:
ValidationResult with audio info in details (sample_rate, channels,
duration, num_samples, format) on success.
"""
if not audio_data:
return ValidationResult.error(f"{name} is required")
if len(audio_data) > MAX_AUDIO_FILE_SIZE:
size_mb = len(audio_data) / (1024 * 1024)
max_mb = MAX_AUDIO_FILE_SIZE / (1024 * 1024)
return ValidationResult.error(
f"{name} too large ({size_mb:.1f} MB). Maximum: {max_mb:.0f} MB"
)
# Detect format
fmt = detect_audio_format(audio_data)
if fmt == "unknown":
return ValidationResult.error(
f"Could not detect {name} format. " "Supported formats: WAV, FLAC, MP3, OGG, AAC, M4A."
)
# Extract metadata for further validation
try:
info = get_audio_info(audio_data)
except (AudioTranscodeError, UnsupportedAudioFormatError) as e:
return ValidationResult.error(f"Could not read {name}: {e}")
except Exception as e:
return ValidationResult.error(f"Could not read {name}: {e}")
# Check duration
if check_duration and info.duration_seconds > MAX_AUDIO_DURATION:
return ValidationResult.error(
f"{name} too long ({info.duration_seconds:.1f}s). "
f"Maximum: {MAX_AUDIO_DURATION}s ({MAX_AUDIO_DURATION // 60} minutes)"
)
# Check sample rate
if info.sample_rate < MIN_AUDIO_SAMPLE_RATE:
return ValidationResult.error(
f"{name} sample rate too low ({info.sample_rate} Hz). "
f"Minimum: {MIN_AUDIO_SAMPLE_RATE} Hz"
)
if info.sample_rate > MAX_AUDIO_SAMPLE_RATE:
return ValidationResult.error(
f"{name} sample rate too high ({info.sample_rate} Hz). "
f"Maximum: {MAX_AUDIO_SAMPLE_RATE} Hz"
)
return ValidationResult.ok(
sample_rate=info.sample_rate,
channels=info.channels,
duration=info.duration_seconds,
num_samples=info.num_samples,
format=info.format,
bitrate=info.bitrate,
bit_depth=info.bit_depth,
)
def require_valid_audio(audio_data: bytes, name: str = "Audio") -> None:
"""Validate audio, raising AudioValidationError on failure.
Args:
audio_data: Raw audio file bytes.
name: Descriptive name for error messages.
Raises:
AudioValidationError: If validation fails.
"""
result = validate_audio(audio_data, name)
if not result.is_valid:
raise AudioValidationError(result.error_message)
def validate_audio_embed_mode(mode: str) -> ValidationResult:
"""Validate audio embedding mode string.
Args:
mode: Embedding mode to validate (e.g., "audio_lsb", "audio_mdct", "audio_auto").
Returns:
ValidationResult with mode in details on success.
"""
valid_modes = VALID_AUDIO_EMBED_MODES | {EMBED_MODE_AUDIO_AUTO}
if mode not in valid_modes:
return ValidationResult.error(
f"Invalid audio embed_mode: '{mode}'. "
f"Valid options: {', '.join(sorted(valid_modes))}"
)
return ValidationResult.ok(mode=mode)

View File

@@ -0,0 +1,31 @@
"""
Stegasoo embedding backends.
Provides a typed plugin interface for all embedding algorithms.
Backends register with the module-level ``registry`` on import.
Usage::
from stegasoo.backends import registry
backend = registry.get("lsb")
stego, stats = backend.embed(data, carrier, key)
"""
from .dct import DCTBackend
from .lsb import LSBBackend
from .protocol import EmbeddingBackend
from .registry import BackendNotFoundError, BackendRegistry, registry
# Auto-register built-in backends
registry.register(LSBBackend())
registry.register(DCTBackend())
__all__ = [
"EmbeddingBackend",
"BackendRegistry",
"BackendNotFoundError",
"registry",
"LSBBackend",
"DCTBackend",
]

View File

@@ -0,0 +1,69 @@
"""
DCT (Discrete Cosine Transform) image embedding backend.
Wraps the existing frequency-domain DCT functions in dct_steganography.py.
"""
from __future__ import annotations
from typing import Any
class DCTBackend:
"""Frequency-domain DCT embedding for JPEG-resilient steganography."""
@property
def mode(self) -> str:
return "dct"
@property
def carrier_type(self) -> str:
return "image"
def is_available(self) -> bool:
from ..dct_steganography import HAS_SCIPY
return HAS_SCIPY
def embed(
self,
data: bytes,
carrier: bytes,
key: bytes,
*,
progress_file: str | None = None,
**options: Any,
) -> tuple[bytes, Any]:
from ..dct_steganography import embed_in_dct
output_format = options.get("dct_output_format", "png")
color_mode = options.get("dct_color_mode", "color")
quant_step = options.get("quant_step")
jpeg_quality = options.get("jpeg_quality")
max_dimension = options.get("max_dimension")
return embed_in_dct(
data, carrier, key, output_format, color_mode, progress_file,
quant_step=quant_step, jpeg_quality=jpeg_quality, max_dimension=max_dimension,
)
def extract(
self,
carrier: bytes,
key: bytes,
*,
progress_file: str | None = None,
**options: Any,
) -> bytes | None:
from ..dct_steganography import extract_from_dct
quant_step = options.get("quant_step")
try:
return extract_from_dct(carrier, key, progress_file, quant_step=quant_step)
except Exception:
return None
def calculate_capacity(self, carrier: bytes, **options: Any) -> int:
from ..dct_steganography import calculate_dct_capacity
info = calculate_dct_capacity(carrier)
return info.usable_capacity_bytes

View File

@@ -0,0 +1,63 @@
"""
LSB (Least Significant Bit) image embedding backend.
Wraps the existing spatial-domain LSB functions in steganography.py.
"""
from __future__ import annotations
from typing import Any
class LSBBackend:
"""Spatial-domain LSB embedding for lossless image formats."""
@property
def mode(self) -> str:
return "lsb"
@property
def carrier_type(self) -> str:
return "image"
def is_available(self) -> bool:
return True # Only needs Pillow, which is always present
def embed(
self,
data: bytes,
carrier: bytes,
key: bytes,
*,
progress_file: str | None = None,
**options: Any,
) -> tuple[bytes, Any]:
from ..steganography import _embed_lsb
bits_per_channel = options.get("bits_per_channel", 1)
output_format = options.get("output_format", None)
stego_bytes, stats, ext = _embed_lsb(
data, carrier, key, bits_per_channel, output_format, progress_file
)
# Attach output extension to stats for callers that need it
stats.output_extension = ext # type: ignore[attr-defined]
return stego_bytes, stats
def extract(
self,
carrier: bytes,
key: bytes,
*,
progress_file: str | None = None,
**options: Any,
) -> bytes | None:
from ..steganography import _extract_lsb
bits_per_channel = options.get("bits_per_channel", 1)
return _extract_lsb(carrier, key, bits_per_channel)
def calculate_capacity(self, carrier: bytes, **options: Any) -> int:
from ..steganography import calculate_capacity
bits_per_channel = options.get("bits_per_channel", 1)
return calculate_capacity(carrier, bits_per_channel)

View File

@@ -0,0 +1,91 @@
"""
Embedding backend protocol definition.
All embedding backends (LSB, DCT, audio, video, etc.) implement this protocol,
enabling registry-based dispatch instead of if/elif chains.
"""
from __future__ import annotations
from typing import Any, Protocol, runtime_checkable
@runtime_checkable
class EmbeddingBackend(Protocol):
"""Protocol that all embedding backends must satisfy.
Each backend handles a specific embedding mode (e.g. 'lsb', 'dct',
'audio_lsb', 'audio_spread') for a specific carrier type ('image',
'audio', 'video').
"""
@property
def mode(self) -> str:
"""The embedding mode identifier (e.g. 'lsb', 'dct')."""
...
@property
def carrier_type(self) -> str:
"""The carrier media type: 'image', 'audio', or 'video'."""
...
def is_available(self) -> bool:
"""Whether this backend's dependencies are installed."""
...
def embed(
self,
data: bytes,
carrier: bytes,
key: bytes,
*,
progress_file: str | None = None,
**options: Any,
) -> tuple[bytes, Any]:
"""Embed data into a carrier.
Args:
data: Encrypted payload bytes.
carrier: Raw carrier file bytes (image, audio, etc.).
key: Derived key for pixel/sample selection.
progress_file: Optional progress file path.
**options: Backend-specific options (bits_per_channel,
output_format, color_mode, chip_tier, etc.).
Returns:
Tuple of (stego carrier bytes, embed stats).
"""
...
def extract(
self,
carrier: bytes,
key: bytes,
*,
progress_file: str | None = None,
**options: Any,
) -> bytes | None:
"""Extract data from a carrier.
Args:
carrier: Stego carrier file bytes.
key: Derived key for pixel/sample selection.
progress_file: Optional progress file path.
**options: Backend-specific options.
Returns:
Extracted payload bytes, or None if no payload found.
"""
...
def calculate_capacity(self, carrier: bytes, **options: Any) -> int:
"""Calculate maximum embeddable payload size in bytes.
Args:
carrier: Raw carrier file bytes.
**options: Backend-specific options (e.g. bits_per_channel).
Returns:
Maximum payload capacity in bytes.
"""
...

View File

@@ -0,0 +1,63 @@
"""
Backend registry for embedding mode dispatch.
Backends register themselves by mode string. The registry replaces
if/elif dispatch in steganography.py with a lookup table.
"""
from __future__ import annotations
from ..exceptions import StegasooError
from .protocol import EmbeddingBackend
class BackendNotFoundError(StegasooError):
"""Raised when a requested backend mode is not registered."""
class BackendRegistry:
"""Registry mapping mode strings to embedding backends."""
def __init__(self) -> None:
self._backends: dict[str, EmbeddingBackend] = {}
def register(self, backend: EmbeddingBackend) -> None:
"""Register a backend for its mode string."""
self._backends[backend.mode] = backend
def get(self, mode: str) -> EmbeddingBackend:
"""Look up a backend by mode. Raises BackendNotFoundError if not found."""
if mode not in self._backends:
available = ", ".join(sorted(self._backends.keys())) or "(none)"
raise BackendNotFoundError(
f"No backend registered for mode '{mode}'. Available: {available}"
)
return self._backends[mode]
def has(self, mode: str) -> bool:
"""Check if a backend is registered for the given mode."""
return mode in self._backends
def available_modes(self, carrier_type: str | None = None) -> list[str]:
"""List registered mode strings, optionally filtered by carrier type.
Only includes modes whose backend reports is_available() == True.
"""
return sorted(
mode
for mode, backend in self._backends.items()
if backend.is_available()
and (carrier_type is None or backend.carrier_type == carrier_type)
)
def all_modes(self, carrier_type: str | None = None) -> list[str]:
"""List all registered mode strings (including unavailable ones)."""
return sorted(
mode
for mode, backend in self._backends.items()
if carrier_type is None or backend.carrier_type == carrier_type
)
# Module-level singleton
registry = BackendRegistry()

View File

@@ -69,6 +69,7 @@ def _get_machine_key() -> bytes:
# Fallback to hostname # Fallback to hostname
if not machine_id: if not machine_id:
import socket import socket
machine_id = socket.gethostname() machine_id = socket.gethostname()
# Hash to get consistent 32 bytes # Hash to get consistent 32 bytes
@@ -87,10 +88,7 @@ def _encrypt_for_storage(plaintext: str) -> str:
plaintext_bytes = plaintext.encode() plaintext_bytes = plaintext.encode()
# XOR with key (cycling if needed) # XOR with key (cycling if needed)
encrypted = bytes( encrypted = bytes(pb ^ key[i % len(key)] for i, pb in enumerate(plaintext_bytes))
pb ^ key[i % len(key)]
for i, pb in enumerate(plaintext_bytes)
)
return ENCRYPTED_PREFIX + base64.b64encode(encrypted).decode() return ENCRYPTED_PREFIX + base64.b64encode(encrypted).decode()
@@ -108,14 +106,11 @@ def _decrypt_from_storage(stored: str) -> str | None:
return stored return stored
try: try:
encrypted = base64.b64decode(stored[len(ENCRYPTED_PREFIX):]) encrypted = base64.b64decode(stored[len(ENCRYPTED_PREFIX) :])
key = _get_machine_key() key = _get_machine_key()
# XOR to decrypt # XOR to decrypt
decrypted = bytes( decrypted = bytes(eb ^ key[i % len(key)] for i, eb in enumerate(encrypted))
eb ^ key[i % len(key)]
for i, eb in enumerate(encrypted)
)
return decrypted.decode() return decrypted.decode()
except Exception: except Exception:
@@ -413,7 +408,11 @@ def get_channel_status() -> dict:
try: try:
stored = config_path.read_text().strip() stored = config_path.read_text().strip()
file_key = _decrypt_from_storage(stored) file_key = _decrypt_from_storage(stored)
if file_key and validate_channel_key(file_key) and format_channel_key(file_key) == key: if (
file_key
and validate_channel_key(file_key)
and format_channel_key(file_key) == key
):
source = str(config_path) source = str(config_path)
break break
except (OSError, PermissionError, ValueError): except (OSError, PermissionError, ValueError):
@@ -485,7 +484,9 @@ def resolve_channel_key(
>>> resolve_channel_key("ABCD-1234-...") # -> "ABCD-1234-..." >>> resolve_channel_key("ABCD-1234-...") # -> "ABCD-1234-..."
>>> resolve_channel_key(file_path="key.txt") # reads from file >>> resolve_channel_key(file_path="key.txt") # reads from file
""" """
debug.print(f"resolve_channel_key: value={value}, file_path={file_path}, no_channel={no_channel}") debug.print(
f"resolve_channel_key: value={value}, file_path={file_path}, no_channel={no_channel}"
)
# no_channel flag takes precedence # no_channel flag takes precedence
if no_channel: if no_channel:

File diff suppressed because it is too large Load Diff

View File

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

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