224 Commits
v4.1.3 ... main

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Updated systemd service to use TLS.

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

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

Rotate tool supports flip-only operations without rotation.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Significant improvement for Pi deployments with limited RAM.

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

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

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

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

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

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

Contains pyenv + Python 3.12 + venv with all deps.

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

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

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

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

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

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

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

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

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

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

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

Validates entered keys using Python channel module before accepting.

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

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

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

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

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

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

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

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

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

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

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

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

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

Compression will be properly implemented in v4.1.8.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Typography consistency across nav, homepage, and tools.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Includes quick install command for Debian/Ubuntu.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Single source of truth for ASCII banner styling.

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

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

This ensures the cert includes proper hostname.local SAN.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 12:59:59 -05:00
Aaron D. Lee
9f03b69408 Add 4.1.4 planning doc and release notes 2026-01-06 12:45:48 -05:00
adlee-was-taken
cce2007c6e Merge pull request #11 from adlee-was-taken/main
Simple doc fixes.
2026-01-06 12:19:33 -05:00
adlee-was-taken
52f43d3a86 Fix formatting in UNDER_THE_HOOD.md 2026-01-06 12:16:15 -05:00
adlee-was-taken
85a7092d55 Fix formatting inconsistencies in UNDER_THE_HOOD.md 2026-01-06 12:14:24 -05:00
adlee-was-taken
4b37a81087 Fix formatting in STEGASOO ARCHITECTURE diagram 2026-01-06 12:12:47 -05:00
adlee-was-taken
31941dc3f5 Update email for reporting security vulnerabilities
Updated the email address for reporting vulnerabilities.
2026-01-06 11:58:26 -05:00
adlee-was-taken
9a7e4ddce7 Change security vulnerability reporting email
Updated contact email for reporting vulnerabilities.
2026-01-06 11:58:16 -05:00
adlee-was-taken
0424dd34d5 Update security policy for version support 2026-01-06 11:57:38 -05:00
adlee-was-taken
2127b916f3 Revise notes for supported versions
Updated notes for supported versions in SECURITY.md.
2026-01-06 11:56:26 -05:00
adlee-was-taken
f8e65890e5 Update supported versions in SECURITY.md
Removed the note about EOL for version 4.1.x.
2026-01-06 11:55:42 -05:00
adlee-was-taken
5861ab0e1e Update supported versions and EOL notes in SECURITY.md 2026-01-06 11:55:28 -05:00
adlee-was-taken
5309a08aaf Update Python version requirement to 3.10 - 3.12 2026-01-06 11:46:40 -05:00
170 changed files with 26663 additions and 4230 deletions

5
.claude/settings.json Normal file
View File

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

View File

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

View File

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

View File

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

28
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# Embedded repos (AUR packaging)
aur-cli-upload/
# Python
__pycache__/
*.py[cod]
@@ -64,11 +67,16 @@ htmlcov/
# Output test files.
test_data/*.png
# Dev scripts (local convenience scripts - except validate-release.sh)
# Dev scripts (local convenience scripts - except these)
scripts/*
!scripts/validate-release.sh
!scripts/smoke-test.sh
!scripts/setup-trusted-certs.sh
!scripts/screenshots.sh
!scripts/build.sh
# Web UI auth database and SSL certs
instance/
frontends/web/instance/
frontends/web/certs/
@@ -79,8 +87,24 @@ tests/
*.img
*.img.xz
*.img.zst
pishrink.sh
*.img.zst.zip
rpi/tools/pishrink.sh
# Temp file storage
frontends/web/temp_files/
rpi/config.json
# Pre-built Pi tarballs and images (release assets, too large for git)
rpi/*.tar.zst
rpi/*.tar.zst.zip
rpi/*.img
rpi/*.img.zst
rpi/*.img.zst.zip
# AUR build artifacts
aur-upload/
aur/.SRCINFO
aur/*.pkg.tar.zst
# Docker pre-built images and deps (release assets, too large for git)
docker/*.tar.zst

4
API.md
View File

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

View File

@@ -5,6 +5,46 @@ All notable changes to Stegasoo will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org).
## [4.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
### Added
- **Developer Documentation**: Educational comments throughout core modules
- DCT module: zig-zag diagrams, QIM explanation, Reed-Solomon deep dive
- LSB module: visual bit embedding examples, ChaCha20 pixel selection
- Crypto module: multi-factor KDF flow diagrams, Argon2id reasoning
- CLI module: Click patterns (groups, JSON output, secure input)
- Web UI module: Flask architecture, subprocess isolation, async jobs
- **Pi Test Automation**: `rpi/kickoff-pi-test.sh` script
- One command to flash, wait for boot, setup, and smoke test
- Self-contained (no dotfile dependencies)
- **v4.2 Wishlist**: `WISHLIST-4.2.md` for blue-sky ideas (GPU acceleration)
### Changed
- **Pi MOTD Improvements**:
- Dynamic temperature emoji (ice/cool/fire based on temp)
- Rocket emoji for service status, globe emoji for URL
- Shortened Debian boilerplate message
- Fixed escaped variable syntax in heredoc
## [4.1.3] - 2026-01-05
### Added
@@ -180,6 +220,8 @@ and this project adheres to [Semantic Versioning](https://semver.org).
- CLI interface
- 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.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0
[4.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.1...v4.0.2

114
CLAUDE.md Normal file
View File

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

18
CLI.md
View File

@@ -64,6 +64,18 @@ python -c "from stegasoo import has_dct_support; print('DCT:', 'available' if ha
stegasoo channel show
```
### Man Page
```bash
# Install man page
sudo mkdir -p /usr/local/share/man/man1
sudo cp docs/stegasoo.1 /usr/local/share/man/man1/
sudo mandb
# View
man stegasoo
```
---
## What's New in v4.1.0
@@ -152,7 +164,7 @@ stegasoo generate [OPTIONS]
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
| `--pin-length` | | 6-9 | 6 | PIN length in digits |
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072, 4096) |
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072) |
| `--words` | | 3-12 | 4 | Words in passphrase |
| `--output` | `-o` | path | | Save RSA key to file |
| `--password` | `-p` | string | | Password for RSA key file |
@@ -168,7 +180,7 @@ stegasoo generate
stegasoo generate --words 6
# Generate with RSA key
stegasoo generate --rsa --rsa-bits 4096
stegasoo generate --rsa --rsa-bits 3072
# Save RSA key to encrypted file
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"
@@ -798,7 +810,7 @@ stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456
### Docker Deployment
**docker-compose.yml:**
**docker/docker-compose.yml:**
```yaml
x-common-env: &common-env
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}

View File

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

View File

@@ -6,14 +6,14 @@ Stegasoo provides Docker images for both the Web UI and REST API.
```bash
# Build and start all services
docker-compose up -d
docker-compose -f docker/docker-compose.yml up -d
# Check status
docker-compose ps
docker-compose -f docker/docker-compose.yml ps
```
Access:
- **Web UI**: http://localhost:5000
- **Web UI**: https://localhost:5000 (HTTPS with self-signed cert)
- **REST API**: http://localhost:8000
## Services
@@ -36,9 +36,12 @@ STEGASOO_CHANNEL_KEY=XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
# Web UI authentication (default: enabled)
STEGASOO_AUTH_ENABLED=true
# HTTPS support (default: disabled)
STEGASOO_HTTPS_ENABLED=false
# HTTPS support (default: enabled, generates self-signed cert)
STEGASOO_HTTPS_ENABLED=true
STEGASOO_HOSTNAME=localhost
# To disable HTTPS:
# STEGASOO_HTTPS_ENABLED=false
```
### Volume Mounts
@@ -58,10 +61,10 @@ Uses a pre-built base image with all dependencies:
```bash
# First time only: build the base image
docker build -f Dockerfile.base -t stegasoo-base:latest .
docker build -f docker/Dockerfile.base -t stegasoo-base:latest .
# Build services (fast - only copies app code)
docker-compose build
docker-compose -f docker/docker-compose.yml build
```
### Full Build (No Base Image)
@@ -69,26 +72,26 @@ docker-compose build
If you don't have the base image, the Dockerfile will build all dependencies (slower):
```bash
docker-compose build
docker-compose -f docker/docker-compose.yml build
```
## Commands
```bash
# Start services
docker-compose up -d
docker-compose -f docker/docker-compose.yml up -d
# View logs
docker-compose logs -f
docker-compose -f docker/docker-compose.yml logs -f
# Stop services
docker-compose down
docker-compose -f docker/docker-compose.yml down
# Rebuild after code changes
docker-compose build && docker-compose up -d
docker-compose -f docker/docker-compose.yml build && docker-compose -f docker/docker-compose.yml up -d
# Full rebuild (no cache)
docker-compose build --no-cache
docker-compose -f docker/docker-compose.yml build --no-cache
```
## Resource Limits
@@ -109,7 +112,7 @@ Both services include health checks:
Check health status:
```bash
docker-compose ps
docker-compose -f docker/docker-compose.yml ps
```
## Production Deployment
@@ -126,7 +129,7 @@ For production, consider:
```bash
# Don't commit .env files with secrets
export STEGASOO_CHANNEL_KEY=your-key
docker-compose up -d
docker-compose -f docker/docker-compose.yml up -d
```
3. **Reverse proxy**: Put behind nginx/traefik for TLS termination
@@ -142,12 +145,12 @@ For production, consider:
### Container won't start
```bash
# Check logs
docker-compose logs web
docker-compose logs api
docker-compose -f docker/docker-compose.yml logs web
docker-compose -f docker/docker-compose.yml logs api
```
### Out of memory
Increase Docker's memory allocation or reduce worker count in Dockerfile.
Increase Docker's memory allocation or reduce worker count in `docker/Dockerfile`.
### Permission errors
The containers run as non-root user `stego` (UID 1000). Ensure volume permissions match.

View File

@@ -20,22 +20,23 @@ Complete installation instructions for all platforms and deployment methods.
## Requirements
### ⚠️ Python Version Requirements
### Python Version Requirements
| Python Version | Status | Notes |
|----------------|--------|-------|
| 3.10 | Supported | |
| 3.11 | ✅ Supported | Recommended |
| 3.10 | ❌ Not Supported | Dropped in v4.2.1 |
| 3.11 | ✅ Supported | Minimum version |
| 3.12 | ✅ Supported | Recommended |
| 3.13 | **Not Supported** | jpegio C extension incompatible |
| 3.13 | Supported | |
| 3.14 | ✅ Supported | Tested on Arch |
**Important:** Python 3.13 (released October 2024) is **not compatible** with jpegio due to C extension ABI changes. Use Python 3.12 or earlier.
**Note:** v4.2.1 switched from `jpegio` to `jpeglib` for DCT steganography, enabling Python 3.11-3.14 support.
### Minimum Requirements
| Requirement | Value |
|-------------|-------|
| Python | 3.10-3.12 |
| Python | 3.11-3.14 |
| RAM | 512 MB minimum (256MB for Argon2) |
| Disk | ~100 MB |
@@ -154,10 +155,10 @@ Build and run individual containers.
#### Build Images
```bash
# Build all targets
docker build -t stegasoo-web --target web .
docker build -t stegasoo-api --target api .
docker build -t stegasoo-cli --target cli .
# From project root - build all targets
docker build -t stegasoo-web --target web -f docker/Dockerfile .
docker build -t stegasoo-api --target api -f docker/Dockerfile .
docker build -t stegasoo-cli --target cli -f docker/Dockerfile .
```
#### Run Web UI
@@ -214,17 +215,17 @@ The easiest way to run all services.
```bash
# Start in background
docker-compose up -d
docker-compose -f docker/docker-compose.yml up -d
# Start specific service
docker-compose up -d web
docker-compose up -d api
docker-compose -f docker/docker-compose.yml up -d web
docker-compose -f docker/docker-compose.yml up -d api
# View logs
docker-compose logs -f
docker-compose -f docker/docker-compose.yml logs -f
# Stop all
docker-compose down
docker-compose -f docker/docker-compose.yml down
```
#### Authentication Configuration (v4.0.2)
@@ -239,7 +240,7 @@ STEGASOO_HOSTNAME=localhost # Hostname for SSL cert
STEGASOO_CHANNEL_KEY= # Optional channel key
# Then run
docker-compose up -d web
docker-compose -f docker/docker-compose.yml up -d web
```
On first access, you'll be prompted to create an admin account. The database and SSL certs are persisted in Docker volumes.
@@ -255,16 +256,16 @@ On first access, you'll be prompted to create an admin account. The database and
```bash
# Build images and start
docker-compose up -d --build
docker-compose -f docker/docker-compose.yml up -d --build
# Force rebuild (no cache)
docker-compose build --no-cache
docker-compose up -d
docker-compose -f docker/docker-compose.yml build --no-cache
docker-compose -f docker/docker-compose.yml up -d
```
#### Resource Configuration
The `docker-compose.yml` includes resource limits:
The `docker/docker-compose.yml` includes resource limits:
```yaml
services:
@@ -423,16 +424,61 @@ pip install jpegio
### Windows
1. Install Python 3.12 from [python.org](https://python.org) (NOT 3.13!)
2. Install Visual Studio Build Tools
Windows users have three options, listed from easiest to most complex:
#### Option 1: Docker Desktop (Recommended)
The easiest way to run Stegasoo on Windows. No Python installation needed.
1. Install [Docker Desktop](https://www.docker.com/products/docker-desktop/)
2. Enable WSL2 backend when prompted
3. Clone and run:
```powershell
git clone https://github.com/adlee-was-taken/stegasoo.git
cd stegasoo
docker-compose -f docker/docker-compose.yml up -d web
```
Access at http://localhost:5000
#### Option 2: WSL2 (Windows Subsystem for Linux)
Run the Linux version natively on Windows.
```powershell
# Install WSL2 with Ubuntu
wsl --install -d Ubuntu
# Open Ubuntu terminal, then follow Linux instructions:
sudo apt-get update
sudo apt-get install -y python3.12 python3.12-venv libzbar0 libjpeg-dev
git clone https://github.com/adlee-was-taken/stegasoo.git
cd stegasoo
python3.12 -m venv venv
source venv/bin/activate
pip install -e ".[all]"
stegasoo --version
```
#### Option 3: Native Windows (Advanced)
Native Windows installation requires Visual Studio Build Tools for compiling C extensions.
1. Install Python 3.11 or 3.12 from [python.org](https://python.org)
2. Install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with "Desktop development with C++"
3. Install from pip:
```powershell
python -m venv venv
.\venv\Scripts\activate
pip install stegasoo[all]
pip install stegasoo[cli] # CLI only (easiest)
# or
pip install stegasoo[all] # Full install (may require additional setup)
```
**Note:** Native Windows installation may have issues with `jpegio` (DCT mode). Docker or WSL2 is recommended for full functionality.
### Raspberry Pi
Stegasoo works on Raspberry Pi 4/5 (4GB+ RAM recommended for Web UI).
@@ -852,7 +898,7 @@ Argon2 needs 256MB per operation. Increase container memory:
# Docker run
docker run --memory=768m ...
# Docker Compose - edit docker-compose.yml
# Docker Compose - edit docker/docker-compose.yml
deploy:
resources:
limits:

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

View File

@@ -1,250 +0,0 @@
# Stegasoo 4.1.2 Plan
## Release Theme
Polish and UX improvements after the 4.1.1 stability release.
---
## 1. Real Progress Bar for Encode/Decode
**Status:** Done
**Problem:** Users see elapsed time but no indication of how far along the operation is. Long DCT encodes on Pi can take 2-3 minutes with no feedback.
**Solution:** Polling + progress file approach
### Backend Changes
1. **dct_steganography.py** - Write progress during block loop:
```python
if progress_file and block_num % 50 == 0:
with open(progress_file, 'w') as f:
json.dump({"current": block_num, "total": total_blocks, "phase": "embedding"}, f)
```
2. **app.py** - New endpoints:
- `POST /encode` returns `job_id`, starts subprocess
- `GET /encode/progress/<job_id>` returns progress JSON
- `GET /encode/result/<job_id>` returns final result when done
3. **Subprocess wrapper** - Pass progress file path to encode/decode functions
### Frontend Changes
1. **stegasoo.js** - After form submit:
- Show progress bar (Bootstrap progress component)
- Poll `/encode/progress/{job_id}` every 500ms
- Update bar width and percentage text
- Show phase (hashing, embedding, encoding, etc.)
2. **Templates** - Add progress bar markup to encode.html and decode.html
### Files to Modify
- `src/stegasoo/dct_steganography.py`
- `frontends/web/app.py`
- `frontends/web/static/js/stegasoo.js`
- `frontends/web/templates/encode.html`
- `frontends/web/templates/decode.html`
---
## 2. Granular Decode Error Messages
**Status:** Done
**Problem:** Decode failures show generic "Decryption failed" - users don't know if it's wrong photo, wrong passphrase, wrong PIN, corrupted image, or format mismatch.
**Solution:** Bubble up specific error types from library to UI
### Implementation
- Added new exceptions: InvalidMagicBytesError, ReedSolomonError, NoDataFoundError, ModeMismatchError
- DCT decode now raises InvalidMagicBytesError for wrong magic bytes
- DCT decode now raises ReedSolomonError (renamed from reedsolo's) for corruption
- app.py catches specific exceptions with user-friendly messages:
- Invalid magic → "Try a different mode (LSB/DCT)"
- RS error → "Image too corrupted, may have been re-saved"
- Invalid header → "Image may have been modified"
- Decryption error → "Wrong credentials"
### Files Modified
- `src/stegasoo/exceptions.py` (new exceptions)
- `src/stegasoo/__init__.py` (exports)
- `src/stegasoo/dct_steganography.py` (raise specific exceptions)
- `frontends/web/app.py` (catch and display)
---
## 3. Mobile-Responsive Polish
**Status:** Done
**Problem:** UI works on mobile but has rough edges - cramped buttons, hard-to-tap targets, awkward layouts on small screens.
**Solution:** Targeted CSS/layout fixes for mobile breakpoints
### Areas to Improve
1. **Encode/Decode Forms:**
- Stack image drop zones vertically on mobile (currently side-by-side)
- Larger touch targets for file inputs
- Full-width buttons on small screens
- Passphrase input readable at smaller sizes
2. **Navigation:**
- Hamburger menu for mobile navbar (if not already)
- Sticky header doesn't eat too much screen
- Easy thumb reach for main actions
3. **Results/Output:**
- Download buttons full-width on mobile
- QR codes sized appropriately
- Click-to-copy message box works well with touch
4. **Drop Zones:**
- Larger tap targets
- Visual feedback for touch (not just hover)
- Camera integration hint on mobile ("Tap to take photo or choose file")
### Testing Targets
- iPhone SE (small)
- iPhone 14 (medium)
- iPad (tablet)
- Android Chrome
### Files to Modify
- `frontends/web/static/css/style.css` (or new mobile.css)
- `frontends/web/templates/encode.html`
- `frontends/web/templates/decode.html`
- `frontends/web/templates/base.html` (navbar)
---
## Testing Checklist
- [ ] Progress bar works on localhost
- [ ] Progress bar works on Pi (slower, more visible)
- [ ] Cancellation handling (what if user navigates away?)
- [ ] Error states display correctly
- [ ] Smoke test passes
---
## 4. Forced First-Login Setup
**Status:** Done
**Problem:** Users can navigate the app without creating an admin account first. Should force password setup before anything else.
**Solution:** Middleware/decorator that redirects to setup page if no users exist.
### Implementation
- Added `@app.before_request` hook that redirects to /setup if no users exist
- Skips redirect for static files and setup-related routes
### Files Modified
- `frontends/web/app.py` (added require_setup before_request hook)
---
## 5. Dropzone UX Fixes
**Status:** Done
**Problem:** Dropzone has some interaction bugs:
- Dropzone doesn't clear properly if first QR image fails
- Can't click on image preview to replace file (have to click surrounding border)
**Solution:** Fix JS event handling and state management
### Implementation
- Added click handler on preview images to trigger file input
- Made entire drop zone clickable (not just label)
- QR zone now resets after 2 seconds on error, allowing retry
- Clear file input on QR error so same file can be re-selected
### Files Modified
- `frontends/web/static/js/stegasoo.js`
---
## 6. Smoke Test Benchmarking
**Status:** Done
**Problem:** No way to measure encode/decode performance or track regressions.
**Solution:** Add timing to smoke tests using `hyperfine` or `time`.
### Implementation
- Added `--benchmark` flag to run encode/decode benchmarks after tests
- Added `--runs=N` flag to customize number of benchmark runs (default: 5)
- Uses hyperfine if available for precise timing with warmup
- Falls back to manual timing with bc if hyperfine not installed
- Outputs min/max/avg stats for both encode and decode operations
### Files Modified
- `tests/smoke-test.sh`
---
## 7. Docker Cleanup
**Status:** Done (4.1.1)
**Problem:** Docker build context is larger than needed (includes test images, rpi scripts, etc.)
**Solution:** Added `.dockerignore` and fixed volume permissions in Dockerfile
### Files Modified
- `.dockerignore` (created)
- `Dockerfile` (instance dir permissions)
---
## 8. Release Validation Script
**Status:** Done
**Problem:** Manual release checklist is error-prone. Need automated validation.
**Solution:** Script that runs through testable checklist items
### Features
- Run pytest
- Build and test Docker image
- SSH to Pi and run smoke test (optional, if PI_IP provided)
- Report pass/fail summary
### Files to Create
- `scripts/validate-release.sh`
---
## 9. Smoke Test Docker Support
**Status:** Done
**Problem:** Smoke test expects systemd service, doesn't auto-create admin for Docker.
**Solution:** Make smoke test Docker-aware
### Features
- Skip systemd checks if not on Pi/Linux with systemd
- Auto-detect fresh Docker (no users) and create admin via /setup
- Add `--docker` flag to skip Pi-specific checks
### Implementation
- Added `--docker` flag that sets localhost and skips SSH/systemd checks
- Docker health check verifies container responds with HTTP 200/302
- Header shows "Docker Smoke Test" in Docker mode
### Files Modified
- `rpi/smoke-test.sh`
---
## Notes
- Keep 4.1.2 focused - 9 features (9 done)
- Don't break DCT compatibility (4.1.1 RS format is stable)
- Test on Pi before release

View File

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

97
PLAN-4.1.6.md Normal file
View File

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

View File

@@ -1,10 +1,10 @@
# 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)
[![Lint](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml/badge.svg)](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml)
![Python](https://img.shields.io/badge/Python-3.10--3.12-blue)
![Python](https://img.shields.io/badge/Python-3.11--3.14-blue)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
![Security](https://img.shields.io/badge/Security-AES--256--GCM-red)
@@ -17,15 +17,25 @@ A secure steganography system for hiding encrypted messages in images using hybr
- **Multiple interfaces**: CLI, Web UI, REST API
- **File embedding**: Hide any file type (PDF, ZIP, documents)
- **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
## Embedding Modes
### Image Modes
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|------|------------------|----------------|----------|
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
| **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
| Home | Encode | Decode | Generate |
@@ -105,15 +115,18 @@ ruff check src/ tests/ frontends/
## Docker
```bash
# Quick start
docker-compose up -d
# Quick start (HTTPS enabled by default)
docker-compose -f docker/docker-compose.yml up -d
# Access
# Web UI: http://localhost:5000
# Web UI: https://localhost:5000 (self-signed cert)
# REST API: http://localhost:8000
# Disable HTTPS if needed:
STEGASOO_HTTPS_ENABLED=false docker-compose -f docker/docker-compose.yml up -d
```
See [DOCKER.md](DOCKER.md) for full documentation.
See [DOCKER.md](DOCKER.md) and [docs/DOCKER_QUICKSTART.md](docs/DOCKER_QUICKSTART.md) for full documentation.
## Raspberry Pi
@@ -143,6 +156,7 @@ See [rpi/README.md](rpi/README.md) for manual installation.
- [UNDER_THE_HOOD.md](UNDER_THE_HOOD.md) - Technical deep-dive
- [CHANGELOG.md](CHANGELOG.md) - Version history
- [CONTRIBUTING.md](CONTRIBUTING.md) - Contributor guide
- `man stegasoo` - Man page (install: `sudo cp docs/stegasoo.1 /usr/local/share/man/man1/ && sudo mandb`)
## License

View File

@@ -1,93 +0,0 @@
# Stegasoo 4.1.1 Release Notes
**Release Date:** January 5, 2026
## Highlights
- **Reed-Solomon Error Correction** - DCT steganography now includes RS error correction, making encoded images more resilient to minor corruption and compression artifacts
- **Completely Rewritten Pi Setup** - Fresh install tested and validated, works reliably from scratch
- **SSH Login Banner** - See your Stegasoo URL immediately on SSH login
## New Features
### Reed-Solomon Error Correction
DCT-encoded images now include Reed-Solomon error correction codes, allowing recovery from minor image corruption. This significantly improves reliability when images are shared through platforms that may slightly modify them.
### SSH Login Banner (MOTD)
When you SSH into your Stegasoo Pi, you'll now see:
```
___ _____ ___ ___ _ ___ ___ ___
/ __||_ _|| __| / __| /_\ / __| / _ \ / _ \
\__ \ | | | _| | (_ | / _ \ \__ \ | (_) || (_) |
|___/ |_| |___| \___//_/ \_\|___/ \___/ \___/
● Stegasoo is running
https://192.168.0.4
```
### Elapsed Time Counter
Encode/decode buttons now show elapsed time during operations.
### Click-to-Copy Decoded Message
Click the decoded message box to copy to clipboard (no button needed).
### Overclock Wizard Option
First-boot wizard now offers optional CPU overclocking for Pi 4/5 with active cooling.
## Improvements
### Setup Script (setup.sh)
- Fixed pyenv Python path resolution (handles 3.12 → 3.12.12 mapping)
- Changed default install location to `/opt/stegasoo`
- Fixed jpegio build order (clone stegasoo first, then build jpegio into venv)
- Added python3-dev to dependencies
- Added btop for system monitoring
- Shows `/setup` URL at completion for admin account creation
### Sanitize Script
- Now clears port 443 iptables redirect (clean slate for wizard)
- Removes overclock settings before imaging
### Documentation
- Updated all docs to reference `/opt/stegasoo` path
- Added pre-setup steps (chown /opt, install git)
- Added Pi 4 performance baseline (~60s for 10MB JPEG)
### About Page
- Redesigned "Limits & Specs" section with key stats cards and accordion
## Bug Fixes
- Fixed DCT steganography for non-8-aligned images
- Fixed MOTD port detection (was using iptables which requires root)
- Fixed smoke test `--443` flag parsing
## Performance
On a Raspberry Pi 4 at 2GHz with USB 3.0 NVMe:
- ~50 seconds to encode a 10MB JPEG
- ~60 seconds to decode a 10MB JPEG
- Full encryption: passphrase + PIN + reference photo
## Upgrade Notes
If upgrading from 4.1.0:
```bash
cd /opt/stegasoo # or ~/stegasoo
git pull origin 4.1
```
For fresh installs, see the [Pi README](rpi/README.md).
## Pre-built Images
- `stegasoo-rpi-4.1.1_20260105-2.img.zst` - Raspberry Pi 4/5 image
Flash with:
```bash
zstdcat stegasoo-rpi-4.1.1_20260105-2.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
```
---
Full changelog: [v4.1.0...v4.1.1](https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.1)

View File

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

173
RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1,173 @@
# v4.3.0 — Audio Steganography
**Release Date:** 2026-02-27
## Highlights
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
docker-compose -f docker/docker-compose.yml up -d
```
**Raspberry Pi**
Flash `stegasoo-rpi-4.2.1.img.zst.zip` to SD card.
Default login: `admin` / `stegasoo`
### Requirements
- Python 3.11 - 3.14 (dropped 3.10 support)
### Release Assets
| File | Description |
|------|-------------|
| `stegasoo-rpi-4.2.1.img.zst.zip` | Raspberry Pi SD card image |
| `stegasoo-docker-base-4.2.1.tar.zst` | Docker base image |
| Source code (zip/tar.gz) | Auto-generated |
---
## Stegasoo v4.2.0
### Performance Optimizations
Major performance improvements for Raspberry Pi and resource-constrained deployments.
#### DCT Vectorization (~14x faster)
- Batch DCT processing using `scipy.fft.dctn` with `axes=(1,2)`
- Processes 500 blocks at once instead of one-by-one
- Decode time reduced from ~2.6s to ~0.8s on 1MB images
#### Memory Optimization (50% reduction)
- Switched from `float64` to `float32` for all DCT operations
- Peak RAM: 211 MB → 107 MB for encode, 104 MB → 52 MB for decode
- Critical for Pi 3/4 avoiding swap thrashing
#### Progress Callbacks for Decode
- `progress_file` parameter added to `decode()` and extraction functions
- UI can now show decode progress (phases: loading, extracting, decoding, complete)
- JSON format: `{"current": 80, "total": 100, "percent": 80.0, "phase": "decoding"}`
#### Async API Endpoints
- Encode/decode operations now run in thread pool via `asyncio.to_thread()`
- API server can handle concurrent requests without blocking
- Essential for multi-user Pi deployments
### Compression
#### Zstd Default Compression
- `zstandard` is now a core dependency (always installed)
- Better compression ratio than zlib for QR code RSA keys
- New `STEGASOO-ZS:` prefix for zstd, backward compatible with `STEGASOO-Z:` (zlib)
### QR Code Generation
#### CLI Support
- `stegasoo generate --rsa --qr key.png` - save RSA key as QR image (PNG/JPG)
- `stegasoo generate --rsa --qr-ascii` - print ASCII QR to terminal
#### API Support
- `POST /generate-key-qr` - generate QR from RSA key
- Supports `png`, `jpg`, and `ascii` output formats
- Uses zstd compression by default
### Other Changes
- RSA key size capped at 3072 bits (4096 too large for QR codes)
- File auto-expire increased to 10 minutes
- Progress bar "candy cane" animation during Argon2 key derivation
- Optional API service in Pi setup (with security warning)
### Summary
| Metric | v4.1.7 | v4.2.0 | Improvement |
|--------|--------|--------|-------------|
| Decode (1MB) | ~2.6s | ~0.8s | **70% faster** |
| Peak RAM | 211 MB | 107 MB | **50% less** |
| Concurrent API | No | Yes | check |
| QR Compression | zlib | zstd | **~15% smaller** |
### Full Changelog
See [CHANGELOG.md](CHANGELOG.md) for complete version history.

View File

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

284
SECURITY_AUDIT_PLAN.md Normal file
View File

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

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

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

View File

@@ -177,7 +177,7 @@ python app.py
### Docker Configuration
```yaml
# docker-compose.yml
# docker/docker-compose.yml
services:
web:
environment:
@@ -360,7 +360,7 @@ gunicorn --bind 0.0.0.0:5000 --workers 2 --threads 4 --timeout 60 app:app
**Docker:**
```bash
docker-compose up web
docker-compose -f docker/docker-compose.yml up web
```
### First-Time Setup
@@ -411,7 +411,7 @@ Create a new set of credentials for steganography operations.
| Use PIN | on/off | on | Generate a numeric PIN |
| PIN length | 6-9 | 6 | Digits in the PIN |
| Use RSA Key | on/off | off | Generate an RSA key pair |
| RSA key size | 2048/3072/4096 | 2048 | Key size in bits |
| RSA key size | 2048/3072 | 2048 | Key size in bits |
#### Entropy Calculator
@@ -1245,7 +1245,7 @@ volumes:
```bash
pip install scipy
# Or rebuild Docker image
docker-compose build --no-cache
docker-compose -f docker/docker-compose.yml build --no-cache
```
### Browser Compatibility

42
WISHLIST-4.2.md Normal file
View File

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

30
agentstuff/pyproject.toml Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

23
aur-api/.SRCINFO Normal file
View File

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

109
aur-api/PKGBUILD Normal file
View File

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

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

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

View File

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

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

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

22
aur-cli/.SRCINFO Normal file
View File

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

69
aur-cli/PKGBUILD Normal file
View File

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

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

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

View File

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

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

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

120
aur/PKGBUILD Normal file
View File

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

90
aur/README.md Normal file
View File

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

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

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

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
data/WebUI_Recover.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
data/WebUI_Tools.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

@@ -32,7 +32,9 @@ RUN pip install --no-cache-dir \
jpegio>=0.2.0 \
argon2-cffi>=23.0.0 \
pillow>=10.0.0 \
cryptography>=41.0.0
cryptography>=41.0.0 \
reedsolo>=1.7.0 \
zstandard>=0.22.0
# Install web/api framework packages (also stable)
RUN pip install --no-cache-dir \
@@ -47,9 +49,9 @@ RUN pip install --no-cache-dir \
lz4>=4.0.0
# Verify key packages work
RUN python -c "import jpegio; import scipy; import numpy; print('jpegio + scipy + numpy OK')"
RUN python -c "import jpegio; import scipy; import numpy; import zstandard; print('jpegio + scipy + numpy + zstd OK')"
# Label for tracking
LABEL org.opencontainers.image.title="Stegasoo Base"
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
LABEL org.opencontainers.image.version="4.0.0"
LABEL org.opencontainers.image.version="4.2.1"

View File

@@ -8,7 +8,8 @@ services:
# ============================================================================
web:
build:
context: .
context: ..
dockerfile: docker/Dockerfile
target: web
container_name: stegasoo-web
ports:
@@ -18,7 +19,9 @@ services:
FLASK_ENV: production
# Authentication (v4.0.2)
STEGASOO_AUTH_ENABLED: ${STEGASOO_AUTH_ENABLED:-true}
STEGASOO_HTTPS_ENABLED: ${STEGASOO_HTTPS_ENABLED:-false}
# HTTPS enabled by default - generates self-signed cert if none provided
# To disable: STEGASOO_HTTPS_ENABLED=false docker-compose up
STEGASOO_HTTPS_ENABLED: ${STEGASOO_HTTPS_ENABLED:-true}
STEGASOO_HOSTNAME: ${STEGASOO_HOSTNAME:-localhost}
volumes:
# Persist auth database and SSL certs (v4.0.2)
@@ -37,7 +40,8 @@ services:
# ============================================================================
api:
build:
context: .
context: ..
dockerfile: docker/Dockerfile
target: api
container_name: stegasoo-api
ports:

224
docs/CLAUDE_WORKTREES.md Normal file
View File

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

162
docs/DOCKER_QUICKSTART.md Normal file
View File

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

View File

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

418
docs/stegasoo.1 Normal file
View File

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

0
frontends/__init__.py Normal file
View File

View File

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

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

File diff suppressed because it is too large Load Diff

View File

View File

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

View File

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

@@ -7,6 +7,7 @@ Uses cryptography library (already a dependency).
import datetime
import ipaddress
import socket
from pathlib import Path
from cryptography import x509
@@ -15,6 +16,33 @@ from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
def _get_local_ips() -> list[str]:
"""Get local IP addresses for this machine."""
ips = []
try:
# Get hostname and resolve to IP
hostname = socket.gethostname()
for addr_info in socket.getaddrinfo(hostname, None, socket.AF_INET):
ip = addr_info[4][0]
if ip not in ips and not ip.startswith("127."):
ips.append(ip)
except Exception:
pass
# Also try connecting to external to get primary interface IP
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
if ip not in ips:
ips.append(ip)
s.close()
except Exception:
pass
return ips
def get_cert_paths(base_dir: Path) -> tuple[Path, Path]:
"""Get paths for cert and key files."""
cert_dir = base_dir / "certs"
@@ -53,10 +81,12 @@ def generate_self_signed_cert(
)
# Create certificate
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
])
subject = issuer = x509.Name(
[
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
]
)
# Subject Alternative Names
san_list = [
@@ -64,13 +94,27 @@ def generate_self_signed_cert(
x509.DNSName("localhost"),
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
]
# Add hostname.local for mDNS access
if not hostname.endswith(".local"):
san_list.append(x509.DNSName(f"{hostname}.local"))
# Add the hostname as IP if it looks like one
try:
san_list.append(x509.IPAddress(ipaddress.IPv4Address(hostname)))
except ipaddress.AddressValueError:
pass
now = datetime.datetime.now(datetime.timezone.utc)
# Add local network IPs
for local_ip in _get_local_ips():
try:
ip_addr = ipaddress.IPv4Address(local_ip)
if x509.IPAddress(ip_addr) not in san_list:
san_list.append(x509.IPAddress(ip_addr))
except (ipaddress.AddressValueError, ValueError):
pass
now = datetime.datetime.now(datetime.UTC)
cert = (
x509.CertificateBuilder()
.subject_name(subject)

View File

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

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

File diff suppressed because one or more lines are too long

View File

@@ -95,7 +95,16 @@ const Stegasoo = {
if (!isPayloadZone && !isQrZone) {
input.addEventListener('change', function() {
if (this.files && this.files[0]) {
Stegasoo.showImagePreview(this.files[0], preview, label, zone);
const file = this.files[0];
if (file.type.startsWith('image/') && preview) {
Stegasoo.showImagePreview(file, preview, label, zone);
} else if (file.type.startsWith('audio/') || !file.type.startsWith('image/')) {
// Audio or non-image files: show file info instead of image preview
Stegasoo.showAudioFileInfo(file, zone);
if (label) {
label.classList.add('d-none');
}
}
}
});
}
@@ -153,7 +162,21 @@ const Stegasoo = {
};
reader.readAsDataURL(file);
},
/**
* 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
// ========================================================================
@@ -333,56 +356,68 @@ const Stegasoo = {
generateEmbedTraces(container, width, height) {
// Color classes for variety
const colors = ['color-yellow', 'color-cyan', 'color-purple', 'color-blue'];
// Generate 6-8 snake paths spread across the whole image
const numPaths = 6 + Math.floor(Math.random() * 3);
for (let p = 0; p < numPaths; p++) {
// Each path gets a random color
const pathColor = colors[Math.floor(Math.random() * colors.length)];
// Distribute starting points across the image
let x = (width * 0.1) + (Math.random() * width * 0.8);
let y = (height * 0.1) + (Math.random() * height * 0.8);
let delay = p * 40;
// Each path has 3-5 segments for more coverage
const numSegments = 3 + Math.floor(Math.random() * 3);
let horizontal = Math.random() > 0.5;
for (let s = 0; s < numSegments; s++) {
const trace = document.createElement('div');
trace.className = 'embed-trace ' + (horizontal ? 'h' : 'v') + ' ' + pathColor;
const length = 30 + Math.random() * 60;
trace.style.left = x + 'px';
trace.style.top = y + 'px';
trace.style.animationDelay = delay + 'ms';
if (horizontal) {
trace.style.width = length + 'px';
} else {
trace.style.height = length + 'px';
// Grid-based distribution: divide image into cells for even coverage
const gridCols = 5;
const gridRows = 4;
const cellWidth = width / gridCols;
const cellHeight = height / gridRows;
let pathIndex = 0;
// Spawn 1-2 paths from each grid cell for even distribution
for (let row = 0; row < gridRows; row++) {
for (let col = 0; col < gridCols; col++) {
// 1-2 paths per cell
const pathsInCell = 1 + Math.floor(Math.random() * 2);
for (let p = 0; p < pathsInCell; p++) {
const pathColor = colors[Math.floor(Math.random() * colors.length)];
// Start within this grid cell (with padding)
let x = (col * cellWidth) + (cellWidth * 0.15) + (Math.random() * cellWidth * 0.7);
let y = (row * cellHeight) + (cellHeight * 0.15) + (Math.random() * cellHeight * 0.7);
let delay = pathIndex * 15;
// Each path has 3-5 short segments
const numSegments = 3 + Math.floor(Math.random() * 3);
let horizontal = Math.random() > 0.5;
for (let s = 0; s < numSegments; s++) {
const trace = document.createElement('div');
trace.className = 'embed-trace ' + (horizontal ? 'h' : 'v') + ' ' + pathColor;
// Shorter segments: 12-30px for denser circuit look
const length = 12 + Math.random() * 18;
trace.style.left = Math.max(0, Math.min(x, width - length)) + 'px';
trace.style.top = Math.max(0, Math.min(y, height - length)) + 'px';
trace.style.animationDelay = delay + 'ms';
if (horizontal) {
trace.style.width = length + 'px';
} else {
trace.style.height = length + 'px';
}
container.appendChild(trace);
// Move position for next segment
if (horizontal) {
x += length * (Math.random() > 0.5 ? 1 : -1);
} else {
y += length * (Math.random() > 0.5 ? 1 : -1);
}
// Keep within bounds
x = Math.max(5, Math.min(x, width - 20));
y = Math.max(5, Math.min(y, height - 20));
// Alternate direction (90 degree turn)
horizontal = !horizontal;
delay += 20;
}
pathIndex++;
}
container.appendChild(trace);
// Move position for next segment
if (horizontal) {
x += length;
} else {
y += length;
}
// Wrap around if out of bounds to keep traces in view
if (x > width - 20) x = 10 + Math.random() * 40;
if (y > height - 20) y = 10 + Math.random() * 40;
if (x < 10) x = width - 60 + Math.random() * 40;
if (y < 10) y = height - 60 + Math.random() * 40;
// Alternate direction (90 degree turn)
horizontal = !horizontal;
delay += 30;
}
}
},
@@ -939,13 +974,13 @@ const Stegasoo = {
body: formData,
});
const result = await response.json().catch(() => null);
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.error) {
if (result && result.error) {
throw new Error(result.error);
}
@@ -997,7 +1032,9 @@ const Stegasoo = {
const percent = progressData.percent || 0;
const phase = progressData.phase || 'processing';
this.updateProgress(percent, this.formatPhase(phase));
// Use indeterminate mode for initializing/starting phases
const isIndeterminate = (phase === 'initializing' || phase === 'starting');
this.updateProgress(percent, this.formatPhase(phase), isIndeterminate);
// Continue polling
setTimeout(poll, 500);
@@ -1017,11 +1054,15 @@ const Stegasoo = {
formatPhase(phase) {
const phases = {
'starting': 'Starting...',
'initializing': 'Initializing...',
'initializing': 'Deriving keys (may take a moment)...',
'embedding': 'Embedding data...',
'saving': 'Saving image...',
'finalizing': 'Finalizing...',
'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;
},
@@ -1058,8 +1099,9 @@ const Stegasoo = {
document.body.appendChild(modal);
}
// Reset progress
this.updateProgress(0, 'Initializing...');
// Reset progress tracking and start with indeterminate state
this.resetProgressTracking();
this.updateProgress(0, 'Initializing...', true);
// Show modal
const bsModal = new bootstrap.Modal(modal);
@@ -1078,16 +1120,450 @@ const Stegasoo = {
},
/**
* Update progress bar and text
* Track max progress to prevent backwards jumps
*/
updateProgress(percent, phase) {
_maxProgress: 0,
/**
* Reset progress tracking (call when starting new operation)
*/
resetProgressTracking() {
this._maxProgress = 0;
},
/**
* Update progress bar and text
* Supports indeterminate mode for initializing phase (barber pole at full width)
*/
updateProgress(percent, phase, indeterminate = false) {
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const phaseText = document.getElementById('progressPhase');
if (progressBar) progressBar.style.width = percent + '%';
if (progressText) progressText.textContent = Math.round(percent) + '%';
if (phaseText) phaseText.textContent = phase;
if (indeterminate) {
// Barber pole animation at full width, no percentage
if (progressBar) {
progressBar.style.width = '100%';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
if (progressText) progressText.textContent = '';
if (phaseText) phaseText.textContent = phase;
} else {
// Determinate progress - never go backwards
const safePercent = Math.max(percent, this._maxProgress);
this._maxProgress = safePercent;
if (progressBar) {
progressBar.style.width = safePercent + '%';
// Keep animation but show actual progress
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
if (progressText) progressText.textContent = Math.round(safePercent) + '%';
if (phaseText) phaseText.textContent = phase;
}
},
// ========================================================================
// ASYNC DECODE WITH PROGRESS (v4.1.5)
// ========================================================================
/**
* Submit decode form asynchronously with progress tracking
* @param {HTMLFormElement} form - The decode form
* @param {HTMLElement} btn - The submit button
*/
async submitDecodeAsync(form, btn) {
const formData = new FormData(form);
formData.append('async', 'true');
// Show progress modal
this.showProgressModal('Decoding');
try {
// Start decode job
const response = await fetch('/decode', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Failed to start decode');
}
const result = await response.json();
if (result.error) {
throw new Error(result.error);
}
const jobId = result.job_id;
// Poll for progress
await this.pollDecodeProgress(jobId);
} catch (error) {
this.hideProgressModal();
alert('Decode failed: ' + error.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-unlock-fill me-2"></i>Decode';
}
},
/**
* Poll decode progress until complete
* @param {string} jobId - The job ID
*/
async pollDecodeProgress(jobId) {
const poll = async () => {
try {
// Check status first
const statusResponse = await fetch(`/decode/status/${jobId}`);
const statusData = await statusResponse.json();
if (statusData.status === 'complete') {
// Done - redirect to result page
this.updateProgress(100, 'Complete!');
setTimeout(() => {
window.location.href = `/decode/result/${jobId}`;
}, 500);
return;
}
if (statusData.status === 'error') {
// Handle specific error types
const errorType = statusData.error_type;
let errorMsg = statusData.error || 'Decode failed';
if (errorType === 'DecryptionError' || errorMsg.toLowerCase().includes('decrypt')) {
errorMsg = 'Wrong credentials. Double-check your reference photo, passphrase, PIN, and channel key.';
}
throw new Error(errorMsg);
}
// Get progress
const progressResponse = await fetch(`/decode/progress/${jobId}`);
const progressData = await progressResponse.json();
const percent = progressData.percent || 0;
const phase = progressData.phase || 'processing';
// Use indeterminate mode for initializing/starting/loading phases
const isIndeterminate = (phase === 'initializing' || phase === 'starting' || phase === 'loading');
this.updateProgress(percent, this.formatDecodePhase(phase), isIndeterminate);
// Continue polling
setTimeout(poll, 500);
} catch (error) {
this.hideProgressModal();
alert(error.message);
}
};
await poll();
},
/**
* Format decode phase name for display
*/
formatDecodePhase(phase) {
const phases = {
'starting': 'Starting...',
'initializing': 'Deriving keys (may take a moment)...',
'loading': 'Deriving keys (may take a moment)...',
'reading': 'Reading image...',
'extracting': 'Extracting data...',
'decoding': 'Decoding data...',
'decrypting': 'Decrypting...',
'verifying': 'Verifying...',
'finalizing': 'Finalizing...',
'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;
},
// ========================================================================
// WEBCAM QR SCANNING (v4.1.5)
// ========================================================================
/**
* Active scanner instance
*/
_qrScanner: null,
_qrScannerModal: null,
_qrScannerCallback: null,
/**
* Show webcam QR scanner modal
* @param {Function} onSuccess - Callback with decoded QR text
* @param {string} title - Modal title
*/
showQrScanner(onSuccess, title = 'Scan QR Code') {
this._qrScannerCallback = onSuccess;
// Create modal if doesn't exist
let modal = document.getElementById('qrScannerModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'qrScannerModal';
modal.className = 'modal fade';
modal.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light">
<div class="modal-header border-secondary">
<h5 class="modal-title">
<i class="bi bi-camera-video me-2"></i>
<span id="qrScannerTitle">${title}</span>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="qrScannerReader" style="width: 100%;"></div>
<div id="qrScannerStatus" class="text-center py-3 text-muted">
<i class="bi bi-qr-code-scan me-2"></i>
Point camera at QR code
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-primary" id="qrCaptureBtn">
<i class="bi bi-camera me-1"></i>Capture
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Clean up scanner when modal hides
modal.addEventListener('hidden.bs.modal', () => {
this.stopQrScanner();
});
// Manual capture button
modal.querySelector('#qrCaptureBtn')?.addEventListener('click', () => {
this.captureQrFrame();
});
}
// Update title
const titleEl = modal.querySelector('#qrScannerTitle');
if (titleEl) titleEl.textContent = title;
// Reset status
const statusEl = modal.querySelector('#qrScannerStatus');
if (statusEl) {
statusEl.innerHTML = '<i class="bi bi-qr-code-scan me-2"></i>Point camera at QR code';
statusEl.className = 'text-center py-3 text-muted';
}
// Show modal
this._qrScannerModal = new bootstrap.Modal(modal);
this._qrScannerModal.show();
// Start scanner after modal is shown
modal.addEventListener('shown.bs.modal', () => {
this.startQrScanner();
}, { once: true });
},
/**
* Start the QR scanner
*/
startQrScanner() {
const readerEl = document.getElementById('qrScannerReader');
if (!readerEl) return;
// Check if Html5Qrcode is available
if (typeof Html5Qrcode === 'undefined') {
console.error('Html5Qrcode library not loaded');
const statusEl = document.getElementById('qrScannerStatus');
if (statusEl) {
statusEl.innerHTML = '<i class="bi bi-exclamation-triangle text-warning me-2"></i>QR scanner not available';
}
return;
}
this._qrScanner = new Html5Qrcode('qrScannerReader');
const config = {
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0,
};
this._qrScanner.start(
{ facingMode: 'environment' }, // Prefer back camera
config,
(decodedText, decodedResult) => {
// QR code detected
this.onQrCodeDetected(decodedText);
},
(errorMessage) => {
// Scan error (ignore, keep scanning)
}
).catch((err) => {
console.error('Failed to start scanner:', err);
const statusEl = document.getElementById('qrScannerStatus');
if (statusEl) {
if (err.toString().includes('Permission')) {
statusEl.innerHTML = '<i class="bi bi-camera-video-off text-danger me-2"></i>Camera permission denied';
} else {
statusEl.innerHTML = '<i class="bi bi-exclamation-triangle text-warning me-2"></i>Could not access camera';
}
statusEl.className = 'text-center py-3';
}
});
},
/**
* Capture a frame with countdown and try to decode
*/
captureQrFrame() {
const statusEl = document.getElementById('qrScannerStatus');
const captureBtn = document.getElementById('qrCaptureBtn');
if (!statusEl || !this._qrScanner) return;
// Disable button during countdown
if (captureBtn) captureBtn.disabled = true;
let count = 3;
const countdown = () => {
if (count > 0) {
statusEl.innerHTML = `<i class="bi bi-camera me-2"></i><span style="font-size: 1.5rem; font-weight: bold;">${count}</span>`;
statusEl.className = 'text-center py-3 text-warning';
count--;
setTimeout(countdown, 1000);
} else {
// Capture!
statusEl.innerHTML = '<i class="bi bi-hourglass-split me-2"></i>Analyzing...';
statusEl.className = 'text-center py-3 text-info';
// Get video element and capture frame
const video = document.querySelector('#qrScannerReader video');
if (video) {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
// Stop the scanner before file scan (prevents conflicts)
const scanner = this._qrScanner;
scanner.stop().then(() => {
canvas.toBlob((blob) => {
const file = new File([blob], 'capture.png', { type: 'image/png' });
scanner.scanFile(file, true)
.then((decodedText) => {
this.onQrCodeDetected(decodedText);
})
.catch((err) => {
statusEl.innerHTML = '<i class="bi bi-x-circle text-danger me-2"></i>No QR code found. Try again.';
statusEl.className = 'text-center py-3 text-danger';
if (captureBtn) captureBtn.disabled = false;
// Restart the scanner
this.startQrScanner();
});
}, 'image/png');
}).catch(() => {
statusEl.innerHTML = '<i class="bi bi-x-circle text-danger me-2"></i>Scanner error';
statusEl.className = 'text-center py-3 text-danger';
if (captureBtn) captureBtn.disabled = false;
});
} else {
statusEl.innerHTML = '<i class="bi bi-x-circle text-danger me-2"></i>Camera not ready';
statusEl.className = 'text-center py-3 text-danger';
if (captureBtn) captureBtn.disabled = false;
}
}
};
countdown();
},
/**
* Stop the QR scanner
*/
stopQrScanner() {
if (this._qrScanner) {
this._qrScanner.stop().then(() => {
this._qrScanner.clear();
this._qrScanner = null;
}).catch((err) => {
console.log('Scanner stop error:', err);
});
}
},
/**
* Handle detected QR code
* @param {string} text - Decoded QR text
*/
onQrCodeDetected(text) {
// Update status
const statusEl = document.getElementById('qrScannerStatus');
if (statusEl) {
statusEl.innerHTML = '<i class="bi bi-check-circle text-success me-2"></i>QR code detected!';
statusEl.className = 'text-center py-3 text-success';
}
// Close modal after brief delay
setTimeout(() => {
this._qrScannerModal?.hide();
// Call callback
if (this._qrScannerCallback) {
this._qrScannerCallback(text);
}
}, 500);
},
/**
* Add camera scan button to an input field
* @param {string} inputId - ID of the input field
* @param {string} title - Modal title
* @param {Function} validator - Optional validation function for scanned text
*/
addCameraScanButton(inputId, title = 'Scan QR Code', validator = null) {
const input = document.getElementById(inputId);
if (!input) return;
// Create button
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-outline-secondary';
btn.innerHTML = '<i class="bi bi-camera"></i>';
btn.title = 'Scan QR code with camera';
btn.addEventListener('click', () => {
this.showQrScanner((text) => {
// Validate if validator provided
if (validator && !validator(text)) {
alert('Invalid QR code format');
return;
}
// Set input value
input.value = text;
// Trigger input event for formatting
input.dispatchEvent(new Event('input', { bubbles: true }));
}, title);
});
// Wrap input in input-group if not already
const parent = input.parentElement;
if (!parent.classList.contains('input-group')) {
const wrapper = document.createElement('div');
wrapper.className = 'input-group';
parent.insertBefore(wrapper, input);
wrapper.appendChild(input);
wrapper.appendChild(btn);
} else {
parent.appendChild(btn);
}
},
// ========================================================================
@@ -1111,6 +1587,39 @@ const Stegasoo = {
generateBtnId: 'channelKeyGenerate'
});
// Webcam QR scanning for channel key (v4.1.5)
document.getElementById('channelKeyScan')?.addEventListener('click', () => {
this.showQrScanner((text) => {
const input = document.getElementById('channelKeyInput');
if (input) {
const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
input.value = clean.length === 32 ? clean.match(/.{4}/g).join('-') : text.toUpperCase();
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}, 'Scan Channel Key');
});
// Webcam QR scanning for RSA key (v4.1.5)
document.getElementById('rsaQrWebcam')?.addEventListener('click', () => {
this.showQrScanner((text) => {
// Check for raw PEM or compressed format (STEGASOO-Z: prefix)
const isRawPem = text.includes('-----BEGIN') && text.includes('KEY-----');
const isCompressed = text.startsWith('STEGASOO-Z:');
if (isRawPem || isCompressed) {
// Valid RSA key data scanned
document.getElementById('rsaKeyPem').value = text;
// Show success in drop zone
const dropZone = document.getElementById('qrDropZone');
const label = dropZone?.querySelector('.drop-zone-label');
if (label) {
label.innerHTML = '<i class="bi bi-check-circle text-success fs-4 d-block mb-1"></i><span class="text-success small">RSA Key scanned successfully</span>';
}
} else {
alert('QR code does not contain a valid RSA key');
}
}, 'Scan RSA Key QR');
});
// Form submission with async progress tracking (v4.1.2)
const form = document.getElementById('encodeForm');
const btn = document.getElementById('encodeBtn');
@@ -1136,7 +1645,7 @@ const Stegasoo = {
this.initRsaMethodToggle();
this.initDropZones();
this.initClipboardPaste(['input[name="stego_image"]', 'input[name="reference_photo"]']);
this.initQrCropAnimation('rsaKeyQrInput');
this.initQrCropAnimation('rsaQrInput');
this.initCollapseChevrons();
this.initPassphraseFontResize();
@@ -1148,28 +1657,56 @@ const Stegasoo = {
serverInfoId: 'channelServerInfoDec'
});
// Form submission with channel key validation and mode display
// Webcam QR scanning for channel key (v4.1.5)
document.getElementById('channelKeyScanDec')?.addEventListener('click', () => {
this.showQrScanner((text) => {
const input = document.getElementById('channelKeyInputDec');
if (input) {
const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
input.value = clean.length === 32 ? clean.match(/.{4}/g).join('-') : text.toUpperCase();
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}, 'Scan Channel Key');
});
// Webcam QR scanning for RSA key (v4.1.5)
document.getElementById('rsaQrWebcam')?.addEventListener('click', () => {
this.showQrScanner((text) => {
// Check for raw PEM or compressed format (STEGASOO-Z: prefix)
const isRawPem = text.includes('-----BEGIN') && text.includes('KEY-----');
const isCompressed = text.startsWith('STEGASOO-Z:');
if (isRawPem || isCompressed) {
// Valid RSA key data scanned
document.getElementById('rsaKeyPem').value = text;
// Show success in drop zone
const dropZone = document.getElementById('qrDropZone');
const label = dropZone?.querySelector('.drop-zone-label');
if (label) {
label.innerHTML = '<i class="bi bi-check-circle text-success fs-4 d-block mb-1"></i><span class="text-success small">RSA Key scanned successfully</span>';
}
} else {
alert('QR code does not contain a valid RSA key');
}
}, 'Scan RSA Key QR');
});
// Form submission with async progress tracking (v4.1.5)
const form = document.getElementById('decodeForm');
const btn = document.getElementById('decodeBtn');
form?.addEventListener('submit', (e) => {
e.preventDefault();
if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) {
e.preventDefault();
return false;
}
const selectedMode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'auto';
if (btn) {
btn.disabled = true;
const startTime = Date.now();
const updateTimer = () => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
const timeStr = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Decoding (${selectedMode.toUpperCase()})... ${timeStr}`;
};
updateTimer();
setInterval(updateTimer, 1000);
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting...';
}
// Use async submission with progress tracking
this.submitDecodeAsync(form, btn);
});
},

View File

@@ -16,7 +16,7 @@
--overlay-dark: rgba(0, 0, 0, 0.3);
--overlay-light: rgba(255, 255, 255, 0.05);
--day-highlight: #E3FF54; /* Bright yellow/green for day of week */
--header-gold: #fee862; /* Halfway between light straw and 24k gold */
--header-gold: #e5d058; /* Muted gold - less harsh on varied monitors */
}
/* ----------------------------------------------------------------------------
@@ -91,6 +91,56 @@
min-width: 0;
}
/* Compact inline mode buttons */
.mode-btn.mode-btn-sm {
padding: 0.35rem 0.6rem;
padding-left: 1.75rem;
font-size: 0.8rem;
border-radius: 0.375rem;
border-width: 1px;
}
.mode-btn.mode-btn-sm .form-check-input {
left: 8px;
width: 14px;
height: 14px;
}
.mode-btn.mode-btn-sm i {
font-size: 0.85rem;
}
/* Disabled button labels for btn-check groups */
.btn-check:disabled + .btn {
opacity: 0.4;
pointer-events: none;
}
/* ----------------------------------------------------------------------------
Form Labels - Gold
---------------------------------------------------------------------------- */
.card .form-label {
color: #d9c580;
font-weight: 400;
}
/* Dropdown selects - ensure chevron is visible in dark mode */
.form-select,
select.form-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23d9c580' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e") !important;
background-repeat: no-repeat !important;
background-position: right 0.75rem center !important;
background-size: 16px 12px !important;
padding-right: 2.25rem !important;
}
/* Payload type toggle - gold text when selected */
.btn-check:checked + .btn-outline-primary {
color: #d9c580 !important;
font-weight: 500;
}
/* ----------------------------------------------------------------------------
Security Factor Boxes - Matches drop-zone dashed border style
---------------------------------------------------------------------------- */
@@ -125,6 +175,122 @@ body {
.navbar {
background: var(--overlay-dark) !important;
backdrop-filter: blur(10px);
z-index: 1030; /* Above page content for dropdowns */
}
.navbar > .container {
padding-left: 0;
}
/* Ensure navbar dropdown appears above all page content */
.navbar .dropdown-menu {
z-index: 1031;
}
/* Left-align collapsed navbar menu on mobile */
@media (max-width: 991.98px) {
.navbar-collapse .navbar-nav {
align-items: flex-start !important;
}
}
/* ----------------------------------------------------------------------------
Nav Icons - Floating Label on Hover (label floats below, no layout shift)
---------------------------------------------------------------------------- */
.nav-icons {
gap: 0.25rem;
}
.nav-icons .nav-item {
position: relative;
}
.nav-expand {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem 0.75rem !important;
border-radius: 0.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: transparent;
position: relative;
}
.nav-expand i {
font-size: 1.15rem;
transition: all 0.3s ease;
}
/* Floating label - absolutely positioned below */
.nav-expand span {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%) translateY(-4px);
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
color: var(--header-gold);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
transition: opacity 0.2s ease,
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1040;
}
.nav-expand:hover {
background: linear-gradient(135deg, rgba(74, 40, 96, 0.25) 0%, rgba(85, 112, 212, 0.2) 100%);
box-shadow: 0 0 8px rgba(102, 126, 234, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.nav-expand:hover i {
color: var(--header-gold);
filter: drop-shadow(0 0 4px rgba(254, 232, 98, 0.4));
}
.nav-expand:hover span {
opacity: 1;
transform: translateX(-50%) translateY(2px);
}
/* Active state for current page */
.nav-expand.active,
.nav-item.active .nav-expand {
background: linear-gradient(135deg, rgba(74, 40, 96, 0.6) 0%, rgba(85, 112, 212, 0.5) 100%);
}
.nav-expand.active i,
.nav-item.active .nav-expand i {
color: var(--header-gold);
}
/* Mobile: Always show labels inline in collapsed menu */
@media (max-width: 991.98px) {
.nav-expand span {
position: static;
transform: none;
opacity: 1;
background: none;
box-shadow: none;
padding: 0;
margin-left: 0.5rem;
font-size: 0.9rem;
text-transform: none;
letter-spacing: normal;
pointer-events: auto;
}
.nav-expand:hover span {
transform: none;
}
.nav-expand:hover {
background: rgba(255, 255, 255, 0.1);
}
}
/* ----------------------------------------------------------------------------
@@ -893,36 +1059,36 @@ footer {
opacity: 0;
}
/* Color variants - 60% opacity */
/* Color variants - 70% opacity with tighter glow for thin lines */
.embed-trace.color-yellow {
background: rgba(212, 225, 87, 0.6);
box-shadow: 0 0 6px rgba(212, 225, 87, 0.5), 0 0 12px rgba(212, 225, 87, 0.3);
background: rgba(212, 225, 87, 0.7);
box-shadow: 0 0 3px rgba(212, 225, 87, 0.6), 0 0 6px rgba(212, 225, 87, 0.3);
}
.embed-trace.color-cyan {
background: rgba(0, 255, 170, 0.6);
box-shadow: 0 0 6px rgba(0, 255, 170, 0.5), 0 0 12px rgba(0, 255, 170, 0.3);
background: rgba(0, 255, 170, 0.7);
box-shadow: 0 0 3px rgba(0, 255, 170, 0.6), 0 0 6px rgba(0, 255, 170, 0.3);
}
.embed-trace.color-purple {
background: rgba(167, 139, 250, 0.6);
box-shadow: 0 0 6px rgba(167, 139, 250, 0.5), 0 0 12px rgba(167, 139, 250, 0.3);
background: rgba(167, 139, 250, 0.7);
box-shadow: 0 0 3px rgba(167, 139, 250, 0.6), 0 0 6px rgba(167, 139, 250, 0.3);
}
.embed-trace.color-blue {
background: rgba(102, 126, 234, 0.6);
box-shadow: 0 0 6px rgba(102, 126, 234, 0.5), 0 0 12px rgba(102, 126, 234, 0.3);
background: rgba(102, 126, 234, 0.7);
box-shadow: 0 0 3px rgba(102, 126, 234, 0.6), 0 0 6px rgba(102, 126, 234, 0.3);
}
/* Vertical segments shrink from top */
.embed-trace.v {
width: 2px;
width: 1px;
transform-origin: top center;
}
/* Horizontal segments shrink from left */
.embed-trace.h {
height: 2px;
height: 1px;
transform-origin: left center;
}
@@ -1094,7 +1260,8 @@ footer {
---------------------------------------------------------------------------- */
#rsaQrSection {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
}
#rsaQrSection .drop-zone {
@@ -1699,3 +1866,518 @@ footer {
font-size: 3rem;
}
}
/* ============================================================================
TOOLS PAGE - Office-style Ribbon + Two-Panel Layout
============================================================================ */
/* Icon Toolbar Ribbon - Purple/Blue Gradient Theme */
.tools-ribbon {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
border-bottom: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 0.5rem 0.5rem 0 0;
flex-wrap: wrap;
}
.tools-ribbon-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tools-ribbon-divider {
width: 2px;
height: 32px;
background: linear-gradient(180deg, rgba(102, 126, 234, 0.4) 0%, rgba(139, 92, 246, 0.4) 100%);
margin: 0 0.75rem;
border-radius: 1px;
}
/* Tool Icon Buttons */
.tool-icon-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 64px;
height: 52px;
padding: 0.25rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: all 0.2s ease;
}
.tool-icon-btn i {
font-size: 1.25rem;
margin-bottom: 2px;
}
.tool-icon-btn span {
font-size: 0.62rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
}
.tool-icon-btn:hover {
background: rgba(255, 230, 150, 0.1);
border-color: rgba(255, 230, 150, 0.3);
color: var(--header-gold);
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.33);
}
.tool-icon-btn.active {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.25) 0%, rgba(139, 92, 246, 0.25) 100%);
border-color: rgba(139, 92, 246, 0.5);
color: #c4b5fd;
box-shadow: 0 0 12px rgba(139, 92, 246, 0.2);
}
/* Two-Panel Layout */
.tools-panels {
display: flex;
min-height: 400px;
border-radius: 0 0 0.5rem 0.5rem;
}
/* Left Panel - Input/Dropzone */
.tools-panel-input {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.15);
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
/* Tool Mode Banner - bottom of input panel */
.tool-mode-banner {
margin-top: auto; /* Push to bottom */
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1.25rem;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(139, 92, 246, 0.2) 100%);
border-top: 1px solid rgba(139, 92, 246, 0.2);
font-size: 0.75rem;
}
.tool-mode-type {
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 3px;
background: rgba(139, 92, 246, 0.3);
color: #c4b5fd;
}
.tool-mode-banner.mode-analyze .tool-mode-type {
background: rgba(72, 187, 120, 0.3);
color: #9ae6b4;
}
.tool-mode-banner.mode-transform .tool-mode-type {
background: rgba(237, 181, 71, 0.3);
color: #fbd38d;
}
.tool-mode-name {
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
}
/* Right Panel - Results */
.tools-panel-results {
width: 280px;
min-width: 280px;
display: flex;
flex-direction: column;
padding: 1.25rem;
background: rgba(0, 0, 0, 0.25);
}
/* Tool Options Row */
.tool-options {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-wrap: wrap;
}
.tool-options:empty {
display: none;
}
.tool-options label {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 0;
}
.tool-options .form-select,
.tool-options .form-control {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.15);
font-size: 0.85rem;
padding: 0.4rem 0.75rem;
}
/* Tool Drop Zone */
.tool-dropzone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
border: 2px dashed rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
background: rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.tool-dropzone input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
z-index: 10;
}
.tool-dropzone-label {
text-align: center;
color: rgba(255, 255, 255, 0.5);
}
.tool-dropzone-label i {
font-size: 2.5rem;
margin-bottom: 0.75rem;
display: block;
opacity: 0.5;
}
.tool-dropzone.drag-over {
border-color: #63b3ed;
background: rgba(99, 179, 237, 0.1);
}
.tool-dropzone.drag-over .tool-dropzone-label i {
color: #63b3ed;
opacity: 1;
}
/* Dropzone with preview */
.tool-dropzone.has-file .tool-dropzone-label {
display: none;
}
.tool-dropzone-preview {
display: none;
width: 100%;
height: 100%;
padding: 1rem;
}
.tool-dropzone.has-file .tool-dropzone-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.tool-dropzone-preview img {
max-width: 100%;
max-height: 180px;
object-fit: contain;
border-radius: 0.375rem;
border: 2px solid rgba(99, 179, 237, 0.3);
}
/* Rotate preview - smooth transitions for size and transform */
#rotateThumb {
transition: transform 0.1s ease-out, width 0.1s ease-out, height 0.1s ease-out;
}
/* Rotate image container - fixed height to contain rotated images */
.rotate-img-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
}
/* Rotate file info - separate row below dropzone */
.rotate-file-info {
text-align: center;
padding: 0.5rem 0;
margin-top: 0.25rem;
}
.rotate-file-info .file-name {
font-size: 0.85rem;
color: #63b3ed;
font-weight: 500;
}
.rotate-file-info .file-meta {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.tool-dropzone-preview .file-name {
margin-top: 0.75rem;
font-size: 0.85rem;
color: #63b3ed;
font-weight: 500;
}
.tool-dropzone-preview .file-meta {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.tool-dropzone-clear {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 20;
opacity: 0.6;
}
.tool-dropzone-clear:hover {
opacity: 1;
}
/* Results Panel Content */
.tool-results-header {
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.tool-results-header h6 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #fff;
}
.tool-results-header small {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.tool-results-body {
flex: 1;
overflow-y: auto;
}
.tool-results-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: rgba(255, 255, 255, 0.3);
text-align: center;
}
.tool-results-empty i {
font-size: 2rem;
margin-bottom: 0.5rem;
}
/* Result Items */
.tool-result-item {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.tool-result-item:last-child {
border-bottom: none;
}
.tool-result-label {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.5);
}
.tool-result-value {
font-size: 0.9rem;
font-weight: 500;
color: #fff;
font-family: 'SF Mono', 'Consolas', monospace;
}
.tool-result-value.text-primary { color: #63b3ed !important; }
.tool-result-value.text-success { color: #48bb78 !important; }
.tool-result-value.text-warning { color: #edb547 !important; }
/* Results Actions */
.tool-results-actions {
margin-top: auto;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
gap: 0.5rem;
}
.tool-results-actions .btn {
flex: 1;
font-size: 0.85rem;
}
/* Tool Section Visibility */
.tool-section {
display: none;
width: 100%;
flex: 1;
padding: 0.5rem;
}
.tool-section.active {
display: flex;
flex-direction: column;
}
/* EXIF Grid Layout */
.exif-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.3rem;
max-height: 280px;
overflow-y: auto;
padding: 0.15rem;
}
.exif-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 4px;
padding: 0.25rem 0.4rem;
}
.exif-card:hover {
background: rgba(255, 255, 255, 0.06);
}
.exif-card-label {
font-size: 0.55rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.02em;
margin-bottom: 0.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.exif-card-value {
font-size: 0.7rem;
font-family: 'SF Mono', 'Consolas', monospace;
color: rgba(255, 255, 255, 0.85);
word-break: break-word;
line-height: 1.2;
}
.exif-card-value.truncated {
max-height: 2.4em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* Category headers */
.exif-category {
grid-column: 1 / -1;
font-size: 0.6rem;
font-weight: 600;
color: var(--bs-primary);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.35rem 0 0.15rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
margin-top: 0.15rem;
}
.exif-category:first-child {
margin-top: 0;
padding-top: 0;
}
/* Compact tool headers and actions */
.tool-results-header {
padding-bottom: 0.35rem;
margin-bottom: 0.35rem;
}
.tool-results-header h6 {
font-size: 0.8rem;
margin-bottom: 0;
}
.tool-results-header small {
font-size: 0.65rem;
}
.tool-results-actions {
padding-top: 0.35rem;
margin-top: 0.35rem;
}
/* Loading State */
.tool-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
z-index: 30;
border-radius: 0.5rem;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.tools-panels {
flex-direction: column;
}
.tools-panel-results {
width: 100%;
min-width: 100%;
border-right: none;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.tools-ribbon {
justify-content: center;
}
.tool-icon-btn {
width: 48px;
height: 44px;
}
.tool-icon-btn span {
display: none;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
Stegasoo Subprocess Worker (v4.0.0)
This script runs in a subprocess and handles encode/decode operations.
If it crashes due to jpegio/scipy issues, the parent Flask process survives.
If it crashes due to jpeglib/scipy issues, the parent Flask process survives.
CHANGES in v4.0.0:
- Added channel_key support for encode/decode operations
@@ -19,6 +19,8 @@ Usage:
import base64
import json
import logging
import os
import sys
import traceback
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))
# 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):
"""
@@ -73,6 +93,7 @@ def _get_channel_info(resolved_key):
def encode_operation(params: dict) -> dict:
"""Handle encode operation."""
logger.debug("encode_operation: mode=%s", params.get("embed_mode", "lsb"))
from stegasoo import FilePayload, encode
# Decode base64 inputs
@@ -136,14 +157,35 @@ def encode_operation(params: dict) -> dict:
}
def _write_decode_progress(progress_file: str | None, percent: int, phase: str) -> None:
"""Write decode progress to file."""
if not progress_file:
return
try:
import json
with open(progress_file, "w") as f:
json.dump({"percent": percent, "phase": phase}, f)
except Exception:
pass # Best effort
def decode_operation(params: dict) -> dict:
"""Handle decode operation."""
logger.debug("decode_operation: mode=%s", params.get("embed_mode", "auto"))
from stegasoo import decode
progress_file = params.get("progress_file")
# Progress: starting
_write_decode_progress(progress_file, 5, "reading")
# Decode base64 inputs
stego_data = base64.b64decode(params["stego_b64"])
reference_data = base64.b64decode(params["reference_b64"])
_write_decode_progress(progress_file, 15, "reading")
# Optional RSA key
rsa_key_data = None
if params.get("rsa_key_b64"):
@@ -152,6 +194,7 @@ def decode_operation(params: dict) -> dict:
# Resolve channel key (v4.0.0)
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
# Library handles progress internally via progress_file parameter
# Call decode with correct parameter names
result = decode(
stego_image=stego_data,
@@ -162,7 +205,9 @@ def decode_operation(params: dict) -> dict:
rsa_password=params.get("rsa_password"),
embed_mode=params.get("embed_mode", "auto"),
channel_key=resolved_channel_key, # v4.0.0
progress_file=progress_file, # v4.2.0: pass through for real-time progress
)
# Library writes 100% "complete" - no need for worker to write again
if result.is_file:
return {
@@ -211,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:
"""Handle channel status check (v4.0.0)."""
from stegasoo import get_channel_status
@@ -241,6 +425,7 @@ def main():
else:
params = json.loads(input_text)
operation = params.get("operation")
logger.info("Worker handling operation: %s", operation)
if operation == "encode":
output = encode_operation(params)
@@ -252,6 +437,13 @@ def main():
output = capacity_check_operation(params)
elif operation == "channel_status":
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:
output = {"success": False, "error": f"Unknown operation: {operation}"}

View File

@@ -115,6 +115,35 @@ class CapacityResult:
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
class ChannelStatusResult:
"""Result from channel status check (v4.0.0)."""
@@ -132,7 +161,7 @@ class SubprocessStego:
"""
Subprocess-isolated steganography operations.
All operations run in a separate Python process. If jpegio or scipy
All operations run in a separate Python process. If jpeglib or scipy
crashes, only the subprocess dies - Flask keeps running.
"""
@@ -314,6 +343,8 @@ class SubprocessStego:
# Channel key (v4.0.0)
channel_key: str | None = "auto",
timeout: int | None = None,
# Progress tracking (v4.1.5)
progress_file: str | None = None,
) -> DecodeResult:
"""
Decode a message or file from a stego image.
@@ -328,6 +359,7 @@ class SubprocessStego:
embed_mode: 'auto', 'lsb', or 'dct'
channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0)
timeout: Operation timeout in seconds
progress_file: Path to write progress updates (v4.1.5)
Returns:
DecodeResult with message or file_data on success
@@ -340,6 +372,7 @@ class SubprocessStego:
"pin": pin,
"embed_mode": embed_mode,
"channel_key": channel_key, # v4.0.0
"progress_file": progress_file, # v4.1.5
}
if rsa_key_data:
@@ -452,6 +485,201 @@ class SubprocessStego:
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(
self,
reveal: bool = False,

View File

@@ -14,8 +14,6 @@ It does NOT touch instance/ (auth database) or any other directories.
"""
import json
import os
import shutil
import time
from pathlib import Path
from threading import Lock

View File

@@ -271,8 +271,7 @@
<div class="card-body">
<p class="small mb-2">Uses server-configured key if available, otherwise public mode.</p>
<ul class="small mb-0">
<li>Set via <code>STEGASOO_CHANNEL_KEY</code> env var</li>
<li>Or <code>channel_key</code> in config file</li>
<li>Server admin configures the shared key</li>
<li>All users share the same channel</li>
</ul>
</div>
@@ -317,55 +316,18 @@
</div>
{% if channel_configured %}
<div class="alert alert-success mt-3 mb-3">
<div class="alert alert-success mt-3 mb-0">
<i class="bi bi-shield-lock me-2"></i>
<strong>This server has a channel key configured:</strong>
<code class="ms-2">{{ channel_fingerprint }}</code>
<span class="text-muted ms-2">({{ channel_source }})</span>
</div>
{% else %}
<div class="alert alert-info mt-3 mb-3">
<div class="alert alert-info mt-3 mb-0">
<i class="bi bi-info-circle me-2"></i>
This server is running in <strong>public mode</strong>.
Set <code>STEGASOO_CHANNEL_KEY</code> to enable server-wide channel isolation.
</div>
{% endif %}
<!-- Channel Key QR Generator -->
<div class="card bg-dark border-secondary">
<div class="card-header">
<i class="bi bi-qr-code me-2"></i>Share Channel Key via QR
</div>
<div class="card-body">
<p class="small text-muted mb-3">Generate a QR code to share a channel key with others.</p>
<div class="row g-2 align-items-end">
<div class="col-md-8">
<label class="form-label small">Channel Key</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="channelKeyQrInput"
placeholder="Enter or generate a key">
<button class="btn btn-outline-secondary" type="button" id="channelKeyQrGenerate"
title="Generate random key">
<i class="bi bi-shuffle"></i>
</button>
</div>
</div>
<div class="col-md-4">
<button class="btn btn-primary w-100" type="button" id="channelKeyQrShow">
<i class="bi bi-qr-code me-1"></i>Show QR
</button>
</div>
</div>
<div class="text-center mt-3 d-none" id="channelKeyQrContainer">
<canvas id="channelKeyQrCanvas" class="bg-white p-2 rounded"></canvas>
<div class="mt-2">
<button class="btn btn-sm btn-outline-secondary" type="button" id="channelKeyQrDownload">
<i class="bi bi-download me-1"></i>Download PNG
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -378,11 +340,13 @@
<!-- Current Version - Prominent -->
<div class="alert alert-success mb-4">
<div class="d-flex align-items-center">
<span class="badge bg-success fs-6 me-3">v4.1.2</span>
<span class="badge bg-success fs-6 me-3">v4.2.1</span>
<div>
<strong>Progress bars</strong> for encode operations,
<strong>mobile-responsive polish</strong>,
DCT decode bug fix, release validation script
<strong>Security & API improvements:</strong>
API key authentication,
TLS with self-signed certs,
CLI tools (compress, rotate, convert),
jpegtran lossless JPEG rotation
</div>
</div>
</div>
@@ -400,6 +364,10 @@
<div class="accordion-body p-0">
<table class="table table-dark table-sm small mb-0">
<tbody>
<tr>
<td width="80"><strong>4.1.7</strong></td>
<td>Progress bars for encode, mobile polish, release validation</td>
</tr>
<tr>
<td width="80"><strong>4.1.1</strong></td>
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
@@ -597,7 +565,7 @@
</tr>
<tr>
<td><i class="bi bi-clock me-2"></i>File expiry</td>
<td><strong>5 min</strong></td>
<td><strong>10 min</strong></td>
</tr>
<tr>
<td><i class="bi bi-key me-2"></i>PIN</td>
@@ -605,7 +573,7 @@
</tr>
<tr>
<td><i class="bi bi-shield me-2"></i>RSA keys</td>
<td><strong>2048, 3072, 4096 bit</strong></td>
<td><strong>2048, 3072 bit</strong></td>
</tr>
<tr>
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>
@@ -632,62 +600,3 @@
</div>
{% endblock %}
{% block scripts %}
<!-- QR Code library for channel key sharing -->
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const input = document.getElementById('channelKeyQrInput');
const generateBtn = document.getElementById('channelKeyQrGenerate');
const showBtn = document.getElementById('channelKeyQrShow');
const container = document.getElementById('channelKeyQrContainer');
const canvas = document.getElementById('channelKeyQrCanvas');
const downloadBtn = document.getElementById('channelKeyQrDownload');
// Generate random key
generateBtn?.addEventListener('click', function() {
if (input && typeof Stegasoo !== 'undefined') {
input.value = Stegasoo.generateChannelKey();
}
});
// Show QR code
showBtn?.addEventListener('click', function() {
const key = input?.value?.trim().replace(/-/g, '');
if (!key || key.length !== 32) {
alert('Please enter a valid 32-character channel key');
return;
}
// Format key with dashes for QR
const formatted = key.match(/.{4}/g)?.join('-') || key;
// Generate QR code
if (typeof QRCode !== 'undefined' && canvas) {
QRCode.toCanvas(canvas, formatted, {
width: 200,
margin: 2,
color: { dark: '#000', light: '#fff' }
}, function(error) {
if (error) {
console.error('QR generation error:', error);
return;
}
container?.classList.remove('d-none');
});
}
});
// Download QR as PNG
downloadBtn?.addEventListener('click', function() {
if (canvas) {
const link = document.createElement('a');
link.download = 'stegasoo-channel-key.png';
link.href = canvas.toDataURL('image/png');
link.click();
}
});
});
</script>
{% endblock %}

View File

@@ -140,6 +140,13 @@
{% endif %}
</div>
<div class="btn-group btn-group-sm">
{% if is_admin %}
<button type="button" class="btn btn-outline-info"
onclick="showKeyQr('{{ key.channel_key }}', '{{ key.name }}')"
title="Show QR Code">
<i class="bi bi-qr-code"></i>
</button>
{% endif %}
<button type="button" class="btn btn-outline-secondary"
onclick="renameKey({{ key.id }}, '{{ key.name }}')"
title="Rename">
@@ -170,9 +177,16 @@
placeholder="Key name" required maxlength="50">
</div>
<div class="col-7">
<input type="text" name="channel_key" class="form-control form-control-sm font-monospace"
placeholder="Channel key (32 hex chars)" required
pattern="[0-9a-fA-F\-]{32,39}" title="32 hex characters">
<div class="input-group input-group-sm">
<input type="text" name="channel_key" id="channelKeyInput"
class="form-control font-monospace"
placeholder="XXXX-XXXX-..." required
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}">
<button type="button" class="btn btn-outline-secondary" id="scanChannelKeyBtn"
title="Scan QR code with camera">
<i class="bi bi-camera"></i>
</button>
</div>
</div>
</div>
<button type="submit" class="btn btn-sm btn-outline-primary">
@@ -218,17 +232,209 @@
</div>
</div>
</div>
{% if is_admin %}
<!-- QR Code Modal (Admin only) -->
<div class="modal fade" id="qrModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title"><i class="bi bi-qr-code me-2"></i><span id="qrKeyName">Channel Key</span></h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<canvas id="qrCanvas" class="bg-white p-2 rounded"></canvas>
<div class="mt-2">
<code class="small" id="qrKeyDisplay"></code>
</div>
</div>
<div class="modal-footer justify-content-center">
<button type="button" class="btn btn-sm btn-outline-secondary" id="qrDownload">
<i class="bi bi-download me-1"></i>Download
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="qrPrint">
<i class="bi bi-printer me-1"></i>Print Sheet
</button>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
{% if is_admin %}
<script src="{{ url_for('static', filename='js/qrcode.min.js') }}"></script>
{% endif %}
<script>
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
// Webcam QR scanning for channel key input (v4.1.5)
document.getElementById('scanChannelKeyBtn')?.addEventListener('click', function() {
Stegasoo.showQrScanner((text) => {
const input = document.getElementById('channelKeyInput');
if (input) {
// Clean and format the key
const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
if (clean.length === 32) {
input.value = clean.match(/.{4}/g).join('-');
} else {
input.value = text.toUpperCase();
}
}
}, 'Scan Channel Key');
});
// Format channel key input as user types
document.getElementById('channelKeyInput')?.addEventListener('input', function() {
Stegasoo.formatChannelKeyInput(this);
});
function renameKey(keyId, currentName) {
document.getElementById('renameInput').value = currentName;
document.getElementById('renameForm').action = '/account/keys/' + keyId + '/rename';
new bootstrap.Modal(document.getElementById('renameModal')).show();
}
{% if is_admin %}
function showKeyQr(channelKey, keyName) {
// Format key with dashes if not already
const clean = channelKey.replace(/-/g, '').toUpperCase();
const formatted = clean.match(/.{4}/g)?.join('-') || clean;
// Update modal content
document.getElementById('qrKeyName').textContent = keyName;
document.getElementById('qrKeyDisplay').textContent = formatted;
// Generate QR code using QRious
const canvas = document.getElementById('qrCanvas');
if (typeof QRious !== 'undefined' && canvas) {
try {
new QRious({
element: canvas,
value: formatted,
size: 200,
level: 'M'
});
new bootstrap.Modal(document.getElementById('qrModal')).show();
} catch (error) {
console.error('QR generation error:', error);
}
}
}
// Download QR as PNG
document.getElementById('qrDownload')?.addEventListener('click', function() {
const canvas = document.getElementById('qrCanvas');
const keyName = document.getElementById('qrKeyName').textContent;
if (canvas) {
const link = document.createElement('a');
link.download = 'stegasoo-channel-key-' + keyName.toLowerCase().replace(/\s+/g, '-') + '.png';
link.href = canvas.toDataURL('image/png');
link.click();
}
});
// Print tiled QR sheet (US Letter)
document.getElementById('qrPrint')?.addEventListener('click', function() {
const canvas = document.getElementById('qrCanvas');
const keyText = document.getElementById('qrKeyDisplay').textContent;
const keyName = document.getElementById('qrKeyName').textContent;
if (canvas && keyText) {
printQrSheet(canvas, keyText, keyName);
}
});
// Print QR codes tiled on US Letter paper (8.5" x 11")
function printQrSheet(canvas, keyText, title) {
const qrDataUrl = canvas.toDataURL('image/png');
const printWindow = window.open('', '_blank');
if (!printWindow) {
alert('Please allow popups to print');
return;
}
// US Letter: 8.5" x 11" - create 4x5 grid of QR codes
const cols = 4;
const rows = 5;
// Split key into two lines (4 groups each)
const keyParts = keyText.split('-');
const keyLine1 = keyParts.slice(0, 4).join('-');
const keyLine2 = keyParts.slice(4).join('-');
let qrGrid = '';
for (let i = 0; i < rows * cols; i++) {
qrGrid += `
<div class="qr-tile">
<div class="key-text">${keyLine1}</div>
<img src="${qrDataUrl}" alt="QR">
<div class="key-text">${keyLine2}</div>
</div>
`;
}
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title></title>
<style>
@page {
size: letter;
margin: 0.2in;
margin-top: 0.1in;
margin-bottom: 0.1in;
}
@media print {
@page { margin: 0.15in; }
html, body { margin: 0; padding: 0; }
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Courier New', monospace;
background: white;
}
.grid {
display: grid;
grid-template-columns: repeat(${cols}, 1fr);
gap: 0;
margin-top: 0.09in;
}
.qr-tile {
border: 1px dashed #ccc;
padding: 0.04in;
text-align: center;
page-break-inside: avoid;
}
.qr-tile img {
width: 1.6in;
height: 1.6in;
}
.key-text {
font-size: 10pt;
font-weight: bold;
color: #333;
line-height: 1.2;
}
.footer {
display: none;
}
</style>
</head>
<body>
<div class="grid">${qrGrid}</div>
<div class="footer">Cut along dashed lines</div>
<script>
window.onload = function() { window.print(); };
<\/script>
</body>
</html>
`);
printWindow.document.close();
}
{% endif %}
</script>
{% endblock %}

View File

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

View File

@@ -5,44 +5,46 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Stegasoo{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
<link href="{{ url_for('static', filename='vendor/css/bootstrap.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='vendor/css/bootstrap-icons.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='style.css') }}" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="/">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="36" class="me-2">
<span style="position: relative; display: inline-block; margin-top: -14px;">
<span class="fw-bold title-gold">Stegasoo</span>
<span class="badge bg-success" style="position: absolute; font-size: 0.45rem; bottom: -8px; right: 6px;">v4.1</span>
</span>
<div class="container-fluid">
<a class="navbar-brand" href="/" style="padding-left: 6px; margin-right: 8px;">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="28">
</a>
{% if channel_configured %}
<span class="badge bg-success bg-opacity-25 small me-auto" style="padding-left: 0.35rem;" title="Private Channel: {{ channel_fingerprint }}">
<i class="bi bi-shield-lock me-2" style="color: #6ee7b7;"></i><code style="font-size: 0.7rem; font-weight: 300; color: #c9a860;">{{ channel_fingerprint[:4] }}-••••-{{ channel_fingerprint[-4:] }}</code>
</span>
{% else %}
<span class="badge bg-secondary bg-opacity-25 small text-muted me-auto" style="padding-left: 0.35rem;" title="Public Channel: No shared channel key configured. Messages use only passphrase and PIN for encryption.">
<i class="bi bi-globe me-1"></i>Public Channel
</span>
{% endif %}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<ul class="navbar-nav ms-auto nav-icons">
<li class="nav-item">
<a class="nav-link" href="/"><i class="bi bi-house me-1"></i> Home</a>
<a class="nav-link nav-expand" href="/"><i class="bi bi-house"></i><span>Home</span></a>
</li>
{% if not auth_enabled or is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="/encode"><i class="bi bi-lock me-1"></i> Encode</a>
<a class="nav-link nav-expand" href="/encode"><i class="bi bi-lock"></i><span>Encode</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="/decode"><i class="bi bi-unlock me-1"></i> Decode</a>
<a class="nav-link nav-expand" href="/decode"><i class="bi bi-unlock"></i><span>Decode</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="/generate"><i class="bi bi-key me-1"></i> Generate</a>
<a class="nav-link nav-expand" href="/generate"><i class="bi bi-key"></i><span>Generate</span></a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="/about"><i class="bi bi-info-circle me-1"></i> About</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/tools"><i class="bi bi-tools me-1"></i> Tools</a>
<a class="nav-link nav-expand" href="/tools"><i class="bi bi-tools"></i><span>Tools</span></a>
</li>
{% if auth_enabled %}
{% if is_authenticated %}
@@ -54,6 +56,7 @@
<li><a class="dropdown-item" href="/account"><i class="bi bi-gear me-2"></i>Account</a></li>
{% if is_admin %}
<li><a class="dropdown-item" href="/admin/users"><i class="bi bi-people me-2"></i>Users</a></li>
<li><a class="dropdown-item" href="/admin/settings"><i class="bi bi-sliders me-2"></i>System Settings</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li>
@@ -96,11 +99,15 @@
<small>
<img src="{{ url_for('static', filename='favicon.svg') }}" alt="" height="16" class="me-1" style="vertical-align: text-bottom;">
Stegasoo v{{ version }} — Steganography with Reference Photo + Passphrase + PIN/Key
<span class="mx-2">|</span>
<a href="/about" class="text-muted text-decoration-none"><i class="bi bi-info-circle me-1"></i>About</a>
</small>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='vendor/js/bootstrap.bundle.min.js') }}"></script>
<!-- QR Code scanning library (local) -->
<script src="{{ url_for('static', filename='vendor/js/html5-qrcode.min.js') }}"></script>
<script>
// Initialize toasts (auto-hide after delay)
document.querySelectorAll('.toast').forEach(el => new bootstrap.Toast(el));

File diff suppressed because it is too large Load Diff

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