57 Commits

Author SHA1 Message Date
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
76 changed files with 3672 additions and 657 deletions

View File

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

8
.gitignore vendored
View File

@@ -97,3 +97,11 @@ rpi/*.tar.zst.zip
rpi/*.img rpi/*.img
rpi/*.img.zst rpi/*.img.zst
rpi/*.img.zst.zip rpi/*.img.zst.zip
# AUR build artifacts
aur-upload/
aur/.SRCINFO
aur/*.pkg.tar.zst
# Docker pre-built images and deps (release assets, too large for git)
docker/*.tar.zst

4
CLI.md
View File

@@ -164,7 +164,7 @@ stegasoo generate [OPTIONS]
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN | | `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key | | `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
| `--pin-length` | | 6-9 | 6 | PIN length in digits | | `--pin-length` | | 6-9 | 6 | PIN length in digits |
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072, 4096) | | `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072) |
| `--words` | | 3-12 | 4 | Words in passphrase | | `--words` | | 3-12 | 4 | Words in passphrase |
| `--output` | `-o` | path | | Save RSA key to file | | `--output` | `-o` | path | | Save RSA key to file |
| `--password` | `-p` | string | | Password for RSA key file | | `--password` | `-p` | string | | Password for RSA key file |
@@ -180,7 +180,7 @@ stegasoo generate
stegasoo generate --words 6 stegasoo generate --words 6
# Generate with RSA key # Generate with RSA key
stegasoo generate --rsa --rsa-bits 4096 stegasoo generate --rsa --rsa-bits 3072
# Save RSA key to encrypted file # Save RSA key to encrypted file
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword" stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"

View File

@@ -1,52 +1,131 @@
## Stegasoo v4.1.7 ## Stegasoo v4.2.1
### Mobile UI Polish ### API Security
- **PIN Entry**: Shrunk digit boxes for 9-digit PIN support on mobile
- **Mode Selectors**: DCT/LSB buttons now use consistent button-group styling with icons
- **Navbar**: Left-aligned collapsed menu, shortened channel fingerprint display (`ABCD-••••-3456`)
- **Text Wrapping**: Fixed button text wrapping issues on narrow screens
### Docker Improvements **API Key Authentication**
- **Reorganized**: Docker files moved to `docker/` directory - All protected endpoints require `X-API-Key` header
- `docker/Dockerfile` - Keys stored hashed (SHA-256) in `~/.stegasoo/api_keys.json`
- `docker/Dockerfile.base` - Auth disabled when no keys configured (easy onboarding)
- `docker/docker-compose.yml`
- **DCT Fix**: Added Reed-Solomon (`reedsolo`) to Docker images - fixes DCT decode failures
- **Quick Start**: New `docs/DOCKER_QUICKSTART.md` guide
**TLS Support**
- Self-signed certificates auto-generated on first run
- Certs valid for localhost, all local IPs, hostname.local
- CLI: `stegasoo api tls generate` to pre-generate
### CLI Improvements
**New API Management Commands**
```bash
stegasoo api keys create NAME # Create new key
stegasoo api keys list # List API keys
stegasoo api tls generate # Generate TLS cert
stegasoo api serve # Start server with TLS
```
**New Image Tools**
```bash
stegasoo tools compress IMG -q 75 # JPEG compression
stegasoo tools rotate IMG -r 90 # Lossless rotation
stegasoo tools convert IMG -f png # Format conversion
```
### Bug Fixes
- **DCT rotation**: Portrait photos no longer export rotated 90°
- **jpegtran**: Removed `-trim` flag that destroyed DCT stego data
- **CLI encode**: Now outputs JPEG when carrier is JPEG (was always PNG)
- **Import paths**: Fixed for installed packages (AUR/pip)
### Installation
**AUR (Arch Linux)**
```bash
yay -S stegasoo-git # Full (Web + API + CLI)
yay -S stegasoo-cli-git # CLI only
```
**Docker**
```bash ```bash
# Build and run
docker build -f docker/Dockerfile.base -t stegasoo-base:latest .
docker-compose -f docker/docker-compose.yml up -d docker-compose -f docker/docker-compose.yml up -d
``` ```
### Raspberry Pi **Raspberry Pi**
- **First-Boot Wizard**: Can now load existing channel key (for joining team deployments) Flash `stegasoo-rpi-4.2.1.img.zst.zip` to SD card.
- **Project Cleanup**: Moved `pishrink.sh` to `rpi/tools/`
### UI Copy
- Changed "Undetectable" to "Covertly Embedded" on encode page (more accurate)
### Raspberry Pi Image
Download `stegasoo-rpi-4.1.7.img.zst.zip` from Releases.
```bash
# Flash (auto-detects SD card)
sudo ./rpi/flash-image.sh stegasoo-rpi-4.1.7.img.zst.zip
# Or manual
unzip -p stegasoo-rpi-4.1.7.img.zst.zip | zstdcat | sudo dd of=/dev/sdX bs=4M status=progress
```
Default login: `admin` / `stegasoo` Default login: `admin` / `stegasoo`
First boot runs the setup wizard for WiFi, HTTPS, and channel key configuration. ### Requirements
### Docker - Python 3.11 - 3.14 (dropped 3.10 support)
```bash
docker-compose -f docker/docker-compose.yml up -d web # Web UI on :5000 ### Release Assets
docker-compose -f docker/docker-compose.yml up -d api # REST API on :8000
``` | File | Description |
|------|-------------|
| `stegasoo-rpi-4.2.1.img.zst.zip` | Raspberry Pi SD card image |
| `stegasoo-docker-base-4.2.1.tar.zst` | Docker base image |
| Source code (zip/tar.gz) | Auto-generated |
---
## Stegasoo v4.2.0
### Performance Optimizations
Major performance improvements for Raspberry Pi and resource-constrained deployments.
#### DCT Vectorization (~14x faster)
- Batch DCT processing using `scipy.fft.dctn` with `axes=(1,2)`
- Processes 500 blocks at once instead of one-by-one
- Decode time reduced from ~2.6s to ~0.8s on 1MB images
#### Memory Optimization (50% reduction)
- Switched from `float64` to `float32` for all DCT operations
- Peak RAM: 211 MB → 107 MB for encode, 104 MB → 52 MB for decode
- Critical for Pi 3/4 avoiding swap thrashing
#### Progress Callbacks for Decode
- `progress_file` parameter added to `decode()` and extraction functions
- UI can now show decode progress (phases: loading, extracting, decoding, complete)
- JSON format: `{"current": 80, "total": 100, "percent": 80.0, "phase": "decoding"}`
#### Async API Endpoints
- Encode/decode operations now run in thread pool via `asyncio.to_thread()`
- API server can handle concurrent requests without blocking
- Essential for multi-user Pi deployments
### Compression
#### Zstd Default Compression
- `zstandard` is now a core dependency (always installed)
- Better compression ratio than zlib for QR code RSA keys
- New `STEGASOO-ZS:` prefix for zstd, backward compatible with `STEGASOO-Z:` (zlib)
### QR Code Generation
#### CLI Support
- `stegasoo generate --rsa --qr key.png` - save RSA key as QR image (PNG/JPG)
- `stegasoo generate --rsa --qr-ascii` - print ASCII QR to terminal
#### API Support
- `POST /generate-key-qr` - generate QR from RSA key
- Supports `png`, `jpg`, and `ascii` output formats
- Uses zstd compression by default
### Other Changes
- RSA key size capped at 3072 bits (4096 too large for QR codes)
- File auto-expire increased to 10 minutes
- Progress bar "candy cane" animation during Argon2 key derivation
- Optional API service in Pi setup (with security warning)
### Summary
| Metric | v4.1.7 | v4.2.0 | Improvement |
|--------|--------|--------|-------------|
| Decode (1MB) | ~2.6s | ~0.8s | **70% faster** |
| Peak RAM | 211 MB | 107 MB | **50% less** |
| Concurrent API | No | Yes | check |
| QR Compression | zlib | zstd | **~15% smaller** |
### Full Changelog ### Full Changelog
See [CHANGELOG.md](CHANGELOG.md) for complete version history. See [CHANGELOG.md](CHANGELOG.md) for complete version history.

54
TODO-4.2.1.md Normal file
View File

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

View File

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

23
aur-api/.SRCINFO Normal file
View File

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

109
aur-api/PKGBUILD Normal file
View File

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

View File

@@ -0,0 +1,48 @@
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 "Stegasoo API installed successfully!"
echo ""
echo "Quick Start:"
echo " 1. Create an API key:"
echo " stegasoo api keys create mykey"
echo ""
echo " 2. Start the API server:"
echo " sudo systemctl start stegasoo-api"
echo ""
echo " 3. Access the API:"
echo " curl -k -H 'X-API-Key: YOUR_KEY' https://localhost:8000/"
echo ""
echo "Management commands:"
echo " stegasoo api keys list # List API keys"
echo " stegasoo api keys create X # Create new key"
echo " stegasoo api tls info # Show certificate info"
echo " stegasoo api serve --help # Server options"
echo ""
echo "API docs available at: https://localhost:8000/docs"
echo ""
}
post_upgrade() {
post_install
}
pre_remove() {
# Stop service if running
systemctl stop stegasoo-api 2>/dev/null || true
}
post_remove() {
echo "Stegasoo API removed."
echo "User 'stegasoo' and config in /opt/stegasoo-api were not removed."
echo "To remove: userdel stegasoo && rm -rf /opt/stegasoo-api"
}

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

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

22
aur-cli/.SRCINFO Normal file
View File

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

69
aur-cli/PKGBUILD Normal file
View File

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

View File

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

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

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

120
aur/PKGBUILD Normal file
View File

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

79
aur/README.md Normal file
View File

@@ -0,0 +1,79 @@
# Stegasoo AUR Package
> **Note:** Uses Python 3.12 via `python312` AUR package (jpegio not yet compatible with 3.13)
## 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 3.12 venv with all dependencies
- `/usr/bin/stegasoo` - CLI symlink
- `/usr/lib/systemd/system/stegasoo-web.service` - Web UI service
- `/usr/lib/systemd/system/stegasoo-api.service` - REST API service
## Optional Dependencies
```bash
# QR code reading from webcam/images
sudo pacman -S zbar
```
All other dependencies are bundled in the venv.
## Usage
### CLI
```bash
stegasoo --help
stegasoo generate --rsa --qr-ascii
stegasoo encode -i carrier.jpg -r reference.jpg -m "secret" -P passphrase -p 123456
```
### Web UI (systemd)
```bash
# Create service user (first time)
sudo useradd -r -s /usr/bin/nologin stegasoo
# Start service
sudo systemctl enable --now stegasoo-web
# Access at http://localhost:5000
```
### REST API (systemd)
```bash
# Start service
sudo systemctl enable --now stegasoo-api
# Access at http://localhost:8000/docs
```
### Manual run (without systemd)
```bash
# Web UI
/opt/stegasoo/venv/bin/python -m gunicorn -b 0.0.0.0:5000 \
--chdir /opt/stegasoo/venv/lib/python3.12/site-packages/frontends/web app:app
# REST API
/opt/stegasoo/venv/bin/uvicorn \
--app-dir /opt/stegasoo/venv/lib/python3.12/site-packages/frontends/api \
main:app --host 0.0.0.0 --port 8000
```
## Maintainer
Aaron D. Lee

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

@@ -0,0 +1,40 @@
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 "Stegasoo installed successfully!"
echo ""
echo "CLI usage:"
echo " stegasoo --help"
echo ""
echo "To start the web UI:"
echo " sudo systemctl start stegasoo-web"
echo ""
echo "To start the REST API:"
echo " sudo systemctl start stegasoo-api"
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: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
data/WebUI_Recover.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
data/WebUI_Tools.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

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

0
frontends/__init__.py Normal file
View File

View File

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

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

View File

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

View File

View File

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

View File

View File

@@ -31,7 +31,7 @@ KEY PATTERNS
============ ============
1. SUBPROCESS ISOLATION 1. SUBPROCESS ISOLATION
Stegasoo's DCT mode uses scipy/jpegio which can crash on malformed input. Stegasoo's DCT mode uses scipy/jpeglib which can crash on malformed input.
We run encode/decode in subprocesses so crashes don't take down the server: We run encode/decode in subprocesses so crashes don't take down the server:
subprocess_stego = SubprocessStego(timeout=180) subprocess_stego = SubprocessStego(timeout=180)
@@ -213,7 +213,7 @@ except ImportError:
# #
# This is a critical reliability pattern. Here's the problem: # This is a critical reliability pattern. Here's the problem:
# #
# scipy's DCT and jpegio can crash (segfault) on: # scipy's DCT and jpeglib can crash (segfault) on:
# - Malformed JPEG files # - Malformed JPEG files
# - Very large images that exhaust memory # - Very large images that exhaust memory
# - Certain edge cases in coefficient manipulation # - Certain edge cases in coefficient manipulation
@@ -253,6 +253,7 @@ from stegasoo.qr_utils import (
detect_and_crop_qr, detect_and_crop_qr,
extract_key_from_qr, extract_key_from_qr,
generate_qr_code, generate_qr_code,
is_compressed,
) )
# Initialize subprocess wrapper (worker script must be in same directory) # Initialize subprocess wrapper (worker script must be in same directory)
@@ -1116,6 +1117,13 @@ def encode_page():
# Check if async mode requested # Check if async mode requested
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true" is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
def _error_response(msg):
"""Return error as JSON (async) or HTML flash (sync)."""
if is_async:
return jsonify({"error": msg}), 400
flash(msg, "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
try: try:
# Get files # Get files
ref_photo = request.files.get("reference_photo") ref_photo = request.files.get("reference_photo")
@@ -1124,12 +1132,10 @@ def encode_page():
payload_file = request.files.get("payload_file") payload_file = request.files.get("payload_file")
if not ref_photo or not carrier: if not ref_photo or not carrier:
flash("Both reference photo and carrier image are required", "error") return _error_response("Both reference photo and carrier image are required")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename): if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename):
flash("Invalid file type. Use PNG, JPG, or BMP", "error") return _error_response("Invalid file type. Use PNG, JPG, or BMP")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
# Get form data - v3.2.0: renamed from day_phrase to passphrase # Get form data - v3.2.0: renamed from day_phrase to passphrase
message = request.form.get("message", "") message = request.form.get("message", "")
@@ -1158,8 +1164,7 @@ def encode_page():
# Check DCT availability # Check DCT availability
if embed_mode == "dct" and not has_dct_support(): if embed_mode == "dct" and not has_dct_support():
flash("DCT mode requires scipy. Install with: pip install scipy", "error") return _error_response("DCT mode requires scipy. Install with: pip install scipy")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
# Determine payload # Determine payload
if payload_type == "file" and payload_file and payload_file.filename: if payload_type == "file" and payload_file and payload_file.filename:
@@ -1168,8 +1173,7 @@ def encode_page():
result = validate_file_payload(file_data, payload_file.filename) result = validate_file_payload(file_data, payload_file.filename)
if not result.is_valid: if not result.is_valid:
flash(result.error_message, "error") return _error_response(result.error_message)
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
mime_type, _ = mimetypes.guess_type(payload_file.filename) mime_type, _ = mimetypes.guess_type(payload_file.filename)
payload = FilePayload( payload = FilePayload(
@@ -1179,20 +1183,17 @@ def encode_page():
# Text message # Text message
result = validate_message(message) result = validate_message(message)
if not result.is_valid: if not result.is_valid:
flash(result.error_message, "error") return _error_response(result.error_message)
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
payload = message payload = message
# v3.2.0: Renamed from day_phrase # v3.2.0: Renamed from day_phrase
if not passphrase: if not passphrase:
flash("Passphrase is required", "error") return _error_response("Passphrase is required")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
# v3.2.0: Validate passphrase # v3.2.0: Validate passphrase
result = validate_passphrase(passphrase) result = validate_passphrase(passphrase)
if not result.is_valid: if not result.is_valid:
flash(result.error_message, "error") return _error_response(result.error_message)
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
# Show warning if passphrase is short # Show warning if passphrase is short
if result.warning: if result.warning:
@@ -1209,8 +1210,8 @@ def encode_page():
rsa_key_from_qr = False rsa_key_from_qr = False
if rsa_key_pem: if rsa_key_pem:
# Webcam-scanned PEM key (v4.1.5) - may be compressed # Webcam-scanned PEM key (v4.1.5+) - may be compressed (zlib or zstd)
if rsa_key_pem.startswith("STEGASOO-Z:"): if is_compressed(rsa_key_pem):
rsa_key_pem = decompress_data(rsa_key_pem) rsa_key_pem = decompress_data(rsa_key_pem)
rsa_key_data = rsa_key_pem.encode("utf-8") rsa_key_data = rsa_key_pem.encode("utf-8")
rsa_key_from_qr = True rsa_key_from_qr = True
@@ -1223,21 +1224,18 @@ def encode_page():
rsa_key_data = key_pem.encode("utf-8") rsa_key_data = key_pem.encode("utf-8")
rsa_key_from_qr = True rsa_key_from_qr = True
else: else:
flash("Could not extract RSA key from QR code image.", "error") return _error_response("Could not extract RSA key from QR code image.")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
# Validate security factors # Validate security factors
result = validate_security_factors(pin, rsa_key_data) result = validate_security_factors(pin, rsa_key_data)
if not result.is_valid: if not result.is_valid:
flash(result.error_message, "error") return _error_response(result.error_message)
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
# Validate PIN if provided # Validate PIN if provided
if pin: if pin:
result = validate_pin(pin) result = validate_pin(pin)
if not result.is_valid: if not result.is_valid:
flash(result.error_message, "error") return _error_response(result.error_message)
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
# Determine key password # Determine key password
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
@@ -1246,14 +1244,12 @@ def encode_page():
if rsa_key_data: if rsa_key_data:
result = validate_rsa_key(rsa_key_data, key_password) result = validate_rsa_key(rsa_key_data, key_password)
if not result.is_valid: if not result.is_valid:
flash(result.error_message, "error") return _error_response(result.error_message)
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
# Validate carrier image # Validate carrier image
result = validate_image(carrier_data, "Carrier image") result = validate_image(carrier_data, "Carrier image")
if not result.is_valid: if not result.is_valid:
flash(result.error_message, "error") return _error_response(result.error_message)
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
# Pre-check payload capacity BEFORE encode (fail fast) # Pre-check payload capacity BEFORE encode (fail fast)
from stegasoo.steganography import will_fit_by_mode from stegasoo.steganography import will_fit_by_mode
@@ -1273,8 +1269,7 @@ def encode_page():
alt_check = will_fit_by_mode(payload_size, carrier_data, embed_mode="lsb") alt_check = will_fit_by_mode(payload_size, carrier_data, embed_mode="lsb")
if alt_check.get("fits"): if alt_check.get("fits"):
error_msg += " - Try LSB mode instead." error_msg += " - Try LSB mode instead."
flash(error_msg, "error") return _error_response(error_msg)
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
# Build encode params for either sync or async # Build encode params for either sync or async
encode_params = { encode_params = {
@@ -1375,14 +1370,11 @@ def encode_page():
return redirect(url_for("encode_result", file_id=file_id)) return redirect(url_for("encode_result", file_id=file_id))
except CapacityError as e: except CapacityError as e:
flash(str(e), "error") return _error_response(str(e))
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
except StegasooError as e: except StegasooError as e:
flash(str(e), "error") return _error_response(str(e))
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
except Exception as e: except Exception as e:
flash(f"Error: {e}", "error") return _error_response(f"Error: {e}")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
@@ -1657,8 +1649,8 @@ def decode_page():
rsa_key_from_qr = False rsa_key_from_qr = False
if rsa_key_pem: if rsa_key_pem:
# Webcam-scanned PEM key (v4.1.5) - may be compressed # Webcam-scanned PEM key (v4.1.5+) - may be compressed (zlib or zstd)
if rsa_key_pem.startswith("STEGASOO-Z:"): if is_compressed(rsa_key_pem):
rsa_key_pem = decompress_data(rsa_key_pem) rsa_key_pem = decompress_data(rsa_key_pem)
rsa_key_data = rsa_key_pem.encode("utf-8") rsa_key_data = rsa_key_pem.encode("utf-8")
rsa_key_from_qr = True rsa_key_from_qr = True
@@ -2108,8 +2100,11 @@ def api_tools_exif_clear():
@app.route("/api/tools/rotate", methods=["POST"]) @app.route("/api/tools/rotate", methods=["POST"])
@login_required @login_required
def api_tools_rotate(): def api_tools_rotate():
"""Rotate and/or flip an image.""" """Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
from PIL import Image from PIL import Image
import shutil
import subprocess
import tempfile
image_file = request.files.get("image") image_file = request.files.get("image")
if not image_file: if not image_file:
@@ -2120,22 +2115,115 @@ def api_tools_rotate():
flip_v = request.form.get("flip_v", "false").lower() == "true" flip_v = request.form.get("flip_v", "false").lower() == "true"
try: try:
img = Image.open(io.BytesIO(image_file.read())) image_data = image_file.read()
img = Image.open(io.BytesIO(image_data))
original_format = img.format # JPEG, PNG, etc.
img.close()
# Apply rotation (PIL rotates counter-clockwise, so negate) # For JPEGs, use jpegtran for lossless rotation/flip (preserves DCT stego)
if rotation: has_jpegtran = shutil.which("jpegtran") is not None
img = img.rotate(-rotation, expand=True) use_jpegtran = original_format == "JPEG" and has_jpegtran and (rotation or flip_h or flip_v)
# Apply flips if use_jpegtran:
if flip_h: # Chain jpegtran operations for lossless transformation
img = img.transpose(Image.FLIP_LEFT_RIGHT) current_data = image_data
if flip_v:
img = img.transpose(Image.FLIP_TOP_BOTTOM)
# Output as PNG (lossless) # Apply rotation first
buffer = io.BytesIO() if rotation in (90, 180, 270):
img.save(buffer, format="PNG") with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
buffer.seek(0) f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-rotate", str(rotation), "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
# Apply horizontal flip
if flip_h:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-flip", "horizontal", "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
# Apply vertical flip
if flip_v:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-flip", "vertical", "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
buffer = io.BytesIO(current_data)
mimetype = "image/jpeg"
ext = "jpg"
else:
# Fallback to PIL for non-JPEGs or when jpegtran unavailable
img = Image.open(io.BytesIO(image_data))
# Apply rotation (PIL rotates counter-clockwise, so negate)
if rotation:
img = img.rotate(-rotation, expand=True)
# Apply flips
if flip_h:
img = img.transpose(Image.FLIP_LEFT_RIGHT)
if flip_v:
img = img.transpose(Image.FLIP_TOP_BOTTOM)
# Preserve original format
buffer = io.BytesIO()
if original_format == "JPEG":
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(buffer, format="JPEG", quality=95)
mimetype = "image/jpeg"
ext = "jpg"
else:
img.save(buffer, format="PNG")
mimetype = "image/png"
ext = "png"
buffer.seek(0)
stem = ( stem = (
image_file.filename.rsplit(".", 1)[0] image_file.filename.rsplit(".", 1)[0]
@@ -2144,9 +2232,9 @@ def api_tools_rotate():
) )
return send_file( return send_file(
buffer, buffer,
mimetype="image/png", mimetype=mimetype,
as_attachment=True, as_attachment=True,
download_name=f"{stem}_transformed.png", download_name=f"{stem}_transformed.{ext}",
) )
except Exception as e: except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500

View File

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

View File

@@ -2247,7 +2247,7 @@ footer {
display: none; display: none;
width: 100%; width: 100%;
flex: 1; flex: 1;
padding: 1.25rem; padding: 0.5rem;
} }
.tool-section.active { .tool-section.active {
@@ -2255,33 +2255,92 @@ footer {
flex-direction: column; flex-direction: column;
} }
/* EXIF Table in Results */ /* EXIF Grid Layout */
.tool-exif-table { .exif-grid {
font-size: 0.8rem; display: grid;
max-height: 250px; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.3rem;
max-height: 280px;
overflow-y: auto; overflow-y: auto;
padding: 0.15rem;
} }
.tool-exif-table table { .exif-card {
width: 100%; background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 4px;
padding: 0.25rem 0.4rem;
} }
.tool-exif-table th, .exif-card:hover {
.tool-exif-table td { background: rgba(255, 255, 255, 0.06);
padding: 0.35rem 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.tool-exif-table th { .exif-card-label {
font-size: 0.55rem;
font-weight: 500; font-weight: 500;
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.4);
text-align: left; text-transform: uppercase;
width: 40%; letter-spacing: 0.02em;
margin-bottom: 0.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.tool-exif-table td { .exif-card-value {
font-size: 0.7rem;
font-family: 'SF Mono', 'Consolas', monospace; font-family: 'SF Mono', 'Consolas', monospace;
word-break: break-all; color: rgba(255, 255, 255, 0.85);
word-break: break-word;
line-height: 1.2;
}
.exif-card-value.truncated {
max-height: 2.4em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* Category headers */
.exif-category {
grid-column: 1 / -1;
font-size: 0.6rem;
font-weight: 600;
color: var(--bs-primary);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.35rem 0 0.15rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
margin-top: 0.15rem;
}
.exif-category:first-child {
margin-top: 0;
padding-top: 0;
}
/* Compact tool headers and actions */
.tool-results-header {
padding-bottom: 0.35rem;
margin-bottom: 0.35rem;
}
.tool-results-header h6 {
font-size: 0.8rem;
margin-bottom: 0;
}
.tool-results-header small {
font-size: 0.65rem;
}
.tool-results-actions {
padding-top: 0.35rem;
margin-top: 0.35rem;
} }
/* Loading State */ /* Loading State */

View File

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

View File

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

View File

@@ -340,11 +340,13 @@
<!-- Current Version - Prominent --> <!-- Current Version - Prominent -->
<div class="alert alert-success mb-4"> <div class="alert alert-success mb-4">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="badge bg-success fs-6 me-3">v4.1.2</span> <span class="badge bg-success fs-6 me-3">v4.2.1</span>
<div> <div>
<strong>Progress bars</strong> for encode operations, <strong>Security & API improvements:</strong>
<strong>mobile-responsive polish</strong>, API key authentication,
DCT decode bug fix, release validation script TLS with self-signed certs,
CLI tools (compress, rotate, convert),
jpegtran lossless JPEG rotation
</div> </div>
</div> </div>
</div> </div>
@@ -362,6 +364,10 @@
<div class="accordion-body p-0"> <div class="accordion-body p-0">
<table class="table table-dark table-sm small mb-0"> <table class="table table-dark table-sm small mb-0">
<tbody> <tbody>
<tr>
<td width="80"><strong>4.1.7</strong></td>
<td>Progress bars for encode, mobile polish, release validation</td>
</tr>
<tr> <tr>
<td width="80"><strong>4.1.1</strong></td> <td width="80"><strong>4.1.1</strong></td>
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td> <td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
@@ -559,7 +565,7 @@
</tr> </tr>
<tr> <tr>
<td><i class="bi bi-clock me-2"></i>File expiry</td> <td><i class="bi bi-clock me-2"></i>File expiry</td>
<td><strong>5 min</strong></td> <td><strong>10 min</strong></td>
</tr> </tr>
<tr> <tr>
<td><i class="bi bi-key me-2"></i>PIN</td> <td><i class="bi bi-key me-2"></i>PIN</td>
@@ -567,7 +573,7 @@
</tr> </tr>
<tr> <tr>
<td><i class="bi bi-shield me-2"></i>RSA keys</td> <td><i class="bi bi-shield me-2"></i>RSA keys</td>
<td><strong>2048, 3072, 4096 bit</strong></td> <td><strong>2048, 3072 bit</strong></td>
</tr> </tr>
<tr> <tr>
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td> <td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>

View File

@@ -158,7 +158,7 @@
<div class="alert alert-warning small"> <div class="alert alert-warning small">
<i class="bi bi-clock me-1"></i> <i class="bi bi-clock me-1"></i>
<strong>File expires in 5 minutes.</strong> Download now. <strong>File expires in 10 minutes.</strong> Download now.
</div> </div>
<a href="/decode" class="btn btn-outline-light w-100"> <a href="/decode" class="btn btn-outline-light w-100">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,9 +80,9 @@ if [ -z "$1" ]; then
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip" echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
echo "" echo ""
echo "Examples:" echo "Examples:"
echo " $0 stegasoo-rpi-4.1.1.img.zst # auto-detect SD card" echo " $0 stegasoo-rpi-4.2.1.img.zst # auto-detect SD card"
echo " $0 stegasoo-rpi-4.1.1.img.zst.zip # from GitHub release" echo " $0 stegasoo-rpi-4.2.1.img.zst.zip # from GitHub release"
echo " $0 stegasoo-rpi-4.1.1.img.zst /dev/sdb # specify device" echo " $0 stegasoo-rpi-4.2.1.img.zst /dev/sdb # specify device"
exit 1 exit 1
fi fi

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
# Resizes rootfs to 16GB for consistent image size, then pulls # Resizes rootfs to 16GB for consistent image size, then pulls
# #
# Usage: ./pull-image.sh <device> <output.img.zst> # Usage: ./pull-image.sh <device> <output.img.zst>
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.1.5.img.zst # Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.2.1.img.zst
set -e set -e
@@ -15,7 +15,7 @@ NC='\033[0m'
if [ $# -ne 2 ]; then if [ $# -ne 2 ]; then
echo "Usage: $0 <device> <output.img.zst>" echo "Usage: $0 <device> <output.img.zst>"
echo "Example: $0 /dev/sdb stegasoo-rpi-4.1.5.img.zst" echo "Example: $0 /dev/sdb stegasoo-rpi-4.2.1.img.zst"
exit 1 exit 1
fi fi

View File

@@ -105,7 +105,7 @@ echo ""
echo -e "${GREEN}[4/6]${NC} Copying pre-built tarball to Pi..." echo -e "${GREEN}[4/6]${NC} Copying pre-built tarball to Pi..."
echo "" echo ""
TARBALL="$SCRIPT_DIR/stegasoo-rpi-runtime-env-arm64.tar.zst" TARBALL="$SCRIPT_DIR/stegasoo-rpi-venv-arm64.tar.zst"
if [[ -f "$TARBALL" ]]; then if [[ -f "$TARBALL" ]]; then
scp_to_pi "$TARBALL" "/opt/stegasoo/rpi/" scp_to_pi "$TARBALL" "/opt/stegasoo/rpi/"
echo -e " ${GREEN}${NC} Tarball copied" echo -e " ${GREEN}${NC} Tarball copied"

View File

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

View File

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

View File

@@ -68,20 +68,31 @@ case "${1:-fast}" in
echo -e "${GREEN}Cleaned!${NC}" echo -e "${GREEN}Cleaned!${NC}"
;; ;;
rebuild)
echo -e "${YELLOW}Full rebuild from scratch (no cache)...${NC}"
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" down --rmi local -v 2>/dev/null || true
$SUDO docker rmi stegasoo-base:latest 2>/dev/null || true
$SUDO docker build --no-cache -f "$DOCKER_DIR/Dockerfile.base" -t stegasoo-base:latest .
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" build --no-cache
echo -e "${GREEN}Done! Start with: $COMPOSE_CMD -f docker/docker-compose.yml up -d${NC}"
;;
*) *)
echo -e "${CYAN}Stegasoo Build Script${NC}" echo -e "${CYAN}Stegasoo Build Script${NC}"
echo "" echo ""
echo "Usage: $0 [command]" echo "Usage: $0 [command]"
echo "" echo ""
echo "Commands:" echo "Commands:"
echo " base Build the base image (one-time, 5-10 min)" echo " base Build the base image (one-time, 5-10 min)"
echo " fast Fast build using base image (default, ~10 sec)" echo " fast Fast build using base image (default, ~10 sec)"
echo " full Full rebuild from scratch (slow, no base needed)" echo " full Rebuild services without cache (uses existing base)"
echo " clean Remove all images and volumes" echo " rebuild Complete rebuild with no cache (base + services)"
echo " clean Remove all images and volumes"
echo "" echo ""
echo "Typical workflow:" echo "Typical workflow:"
echo " 1. First time: $0 base" echo " 1. First time: $0 base"
echo " 2. Daily dev: $0 fast" echo " 2. Daily dev: $0 fast"
echo " 3. Deps change: $0 base" echo " 3. Deps change: $0 base"
echo " 4. Nuclear: $0 rebuild"
;; ;;
esac esac

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,15 @@
""" """
Stegasoo Constants and Configuration (v4.0.2 - Web UI Authentication) Stegasoo Constants and Configuration (v4.2.0 - Performance & Compression)
Central location for all magic numbers, limits, and crypto parameters. Central location for all magic numbers, limits, and crypto parameters.
All version numbers, limits, and configuration values should be defined here. All version numbers, limits, and configuration values should be defined here.
CHANGES in v4.2.0:
- Added zstd compression for QR codes (better ratio than zlib)
- RSA key size capped at 3072 bits (4096 too large for QR codes)
- Progress bar improvements for encode/decode operations
- File auto-expire increased to 10 minutes
CHANGES in v4.0.2: CHANGES in v4.0.2:
- Added Web UI authentication with SQLite3 user storage - Added Web UI authentication with SQLite3 user storage
- Added optional HTTPS with auto-generated self-signed certificates - Added optional HTTPS with auto-generated self-signed certificates
@@ -25,7 +31,7 @@ from pathlib import Path
# VERSION # VERSION
# ============================================================================ # ============================================================================
__version__ = "4.1.5" __version__ = "4.2.1"
# ============================================================================ # ============================================================================
# FILE FORMAT # FILE FORMAT
@@ -98,7 +104,7 @@ DEFAULT_PHRASE_WORDS = DEFAULT_PASSPHRASE_WORDS
# RSA configuration # RSA configuration
MIN_RSA_BITS = 2048 MIN_RSA_BITS = 2048
VALID_RSA_SIZES = (2048, 3072, 4096) VALID_RSA_SIZES = (2048, 3072) # 4096 removed - too large for QR codes
DEFAULT_RSA_BITS = 2048 DEFAULT_RSA_BITS = 2048
MIN_KEY_PASSWORD_LENGTH = 8 MIN_KEY_PASSWORD_LENGTH = 8
@@ -108,8 +114,8 @@ MIN_KEY_PASSWORD_LENGTH = 8
# ============================================================================ # ============================================================================
# Temporary file storage # Temporary file storage
TEMP_FILE_EXPIRY = 300 # 5 minutes in seconds TEMP_FILE_EXPIRY = 600 # 10 minutes in seconds
TEMP_FILE_EXPIRY_MINUTES = 5 TEMP_FILE_EXPIRY_MINUTES = 10
# Thumbnail settings # Thumbnail settings
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnails THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnails

View File

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

View File

@@ -8,6 +8,7 @@ Changes in v4.0.0:
- Improved error messages for channel key mismatches - Improved error messages for channel key mismatches
""" """
import json
from pathlib import Path from pathlib import Path
from .constants import EMBED_MODE_AUTO from .constants import EMBED_MODE_AUTO
@@ -24,6 +25,22 @@ from .validation import (
) )
def _write_progress(progress_file: str | None, current: int, total: int, phase: str) -> None:
"""Write progress to file for UI polling."""
if progress_file is None:
return
try:
with open(progress_file, "w") as f:
json.dump({
"current": current,
"total": total,
"percent": (current / total * 100) if total > 0 else 0,
"phase": phase,
}, f)
except OSError:
pass
def decode( def decode(
stego_image: bytes, stego_image: bytes,
reference_photo: bytes, reference_photo: bytes,
@@ -33,6 +50,7 @@ def decode(
rsa_password: str | None = None, rsa_password: str | None = None,
embed_mode: str = EMBED_MODE_AUTO, embed_mode: str = EMBED_MODE_AUTO,
channel_key: str | bool | None = None, channel_key: str | bool | None = None,
progress_file: str | None = None,
) -> DecodeResult: ) -> DecodeResult:
""" """
Decode a message or file from a stego image. Decode a message or file from a stego image.
@@ -45,6 +63,7 @@ def decode(
rsa_key_data: Optional RSA key bytes (if used during encoding) rsa_key_data: Optional RSA key bytes (if used during encoding)
rsa_password: Optional RSA key password rsa_password: Optional RSA key password
embed_mode: 'auto' (default), 'lsb', or 'dct' embed_mode: 'auto' (default), 'lsb', or 'dct'
progress_file: Optional path to write progress JSON for UI polling
channel_key: Channel key for deployment/group isolation: channel_key: Channel key for deployment/group isolation:
- None or "auto": Use server's configured key - None or "auto": Use server's configured key
- str: Use this specific channel key - str: Use this specific channel key
@@ -91,16 +110,23 @@ def decode(
if rsa_key_data: if rsa_key_data:
require_valid_rsa_key(rsa_key_data, rsa_password) require_valid_rsa_key(rsa_key_data, rsa_password)
# Progress: starting key derivation (Argon2 - slow on Pi)
_write_progress(progress_file, 20, 100, "initializing")
# Derive pixel/coefficient selection key (with channel key) # Derive pixel/coefficient selection key (with channel key)
from .crypto import derive_pixel_key from .crypto import derive_pixel_key
pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key) pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key)
# Progress: key derivation done, starting extraction
_write_progress(progress_file, 25, 100, "extracting")
# Extract encrypted data # Extract encrypted data
encrypted = extract_from_image( encrypted = extract_from_image(
stego_image, stego_image,
pixel_key, pixel_key,
embed_mode=embed_mode, embed_mode=embed_mode,
progress_file=progress_file,
) )
if not encrypted: if not encrypted:
@@ -126,6 +152,7 @@ def decode_file(
rsa_password: str | None = None, rsa_password: str | None = None,
embed_mode: str = EMBED_MODE_AUTO, embed_mode: str = EMBED_MODE_AUTO,
channel_key: str | bool | None = None, channel_key: str | bool | None = None,
progress_file: str | None = None,
) -> Path: ) -> Path:
""" """
Decode a file from a stego image and save it. Decode a file from a stego image and save it.
@@ -140,6 +167,7 @@ def decode_file(
rsa_password: Optional RSA key password rsa_password: Optional RSA key password
embed_mode: 'auto', 'lsb', or 'dct' embed_mode: 'auto', 'lsb', or 'dct'
channel_key: Channel key parameter (see decode()) channel_key: Channel key parameter (see decode())
progress_file: Optional path to write progress JSON for UI polling
Returns: Returns:
Path where file was saved Path where file was saved
@@ -156,6 +184,7 @@ def decode_file(
rsa_password, rsa_password,
embed_mode, embed_mode,
channel_key, channel_key,
progress_file,
) )
if not result.is_file: if not result.is_file:
@@ -184,6 +213,7 @@ def decode_text(
rsa_password: str | None = None, rsa_password: str | None = None,
embed_mode: str = EMBED_MODE_AUTO, embed_mode: str = EMBED_MODE_AUTO,
channel_key: str | bool | None = None, channel_key: str | bool | None = None,
progress_file: str | None = None,
) -> str: ) -> str:
""" """
Decode a text message from a stego image. Decode a text message from a stego image.
@@ -199,6 +229,7 @@ def decode_text(
rsa_password: Optional RSA key password rsa_password: Optional RSA key password
embed_mode: 'auto', 'lsb', or 'dct' embed_mode: 'auto', 'lsb', or 'dct'
channel_key: Channel key parameter (see decode()) channel_key: Channel key parameter (see decode())
progress_file: Optional path to write progress JSON for UI polling
Returns: Returns:
Decoded message string Decoded message string
@@ -215,6 +246,7 @@ def decode_text(
rsa_password, rsa_password,
embed_mode, embed_mode,
channel_key, channel_key,
progress_file,
) )
if result.is_file: if result.is_file:

View File

@@ -82,7 +82,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS, password: str | None = None)
Generate an RSA private key in PEM format. Generate an RSA private key in PEM format.
Args: Args:
bits: Key size (2048, 3072, or 4096, default 2048) bits: Key size (2048 or 3072, default 2048)
password: Optional password to encrypt the key password: Optional password to encrypt the key
Returns: Returns:

View File

@@ -136,7 +136,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey:
Generate an RSA private key. Generate an RSA private key.
Args: Args:
bits: Key size (2048, 3072, or 4096) bits: Key size (2048 or 3072)
Returns: Returns:
RSA private key object RSA private key object

View File

@@ -8,6 +8,7 @@ IMPROVEMENTS IN THIS VERSION:
- Much more robust PEM normalization - Much more robust PEM normalization
- Better handling of QR code extraction edge cases - Better handling of QR code extraction edge cases
- Improved error messages - Improved error messages
- v4.2.0: Added zstd compression (better ratio than zlib)
""" """
import base64 import base64
@@ -16,6 +17,14 @@ import zlib
from PIL import Image from PIL import Image
# Optional ZSTD support (better compression ratio)
try:
import zstandard as zstd
HAS_ZSTD = True
except ImportError:
HAS_ZSTD = False
# QR code generation # QR code generation
try: try:
import qrcode import qrcode
@@ -42,30 +51,46 @@ from .constants import (
) )
# Constants # Constants
COMPRESSION_PREFIX = "STEGASOO-Z:" COMPRESSION_PREFIX_ZLIB = "STEGASOO-Z:" # Legacy zlib compression
COMPRESSION_PREFIX_ZSTD = "STEGASOO-ZS:" # v4.2.0: New zstd compression (better ratio)
COMPRESSION_PREFIX = COMPRESSION_PREFIX_ZSTD if HAS_ZSTD else COMPRESSION_PREFIX_ZLIB
def compress_data(data: str) -> str: def compress_data(data: str) -> str:
""" """
Compress string data for QR code storage. Compress string data for QR code storage.
Uses zstd if available (better ratio), falls back to zlib.
Args: Args:
data: String to compress data: String to compress
Returns: Returns:
Compressed string with STEGASOO-Z: prefix Compressed string with STEGASOO-ZS: (zstd) or STEGASOO-Z: (zlib) prefix
""" """
compressed = zlib.compress(data.encode("utf-8"), level=9) data_bytes = data.encode("utf-8")
encoded = base64.b64encode(compressed).decode("ascii")
return COMPRESSION_PREFIX + encoded if HAS_ZSTD:
# Use zstd (better compression ratio)
cctx = zstd.ZstdCompressor(level=19)
compressed = cctx.compress(data_bytes)
encoded = base64.b64encode(compressed).decode("ascii")
return COMPRESSION_PREFIX_ZSTD + encoded
else:
# Fall back to zlib
compressed = zlib.compress(data_bytes, level=9)
encoded = base64.b64encode(compressed).decode("ascii")
return COMPRESSION_PREFIX_ZLIB + encoded
def decompress_data(data: str) -> str: def decompress_data(data: str) -> str:
""" """
Decompress data from QR code. Decompress data from QR code.
Supports both zstd (STEGASOO-ZS:) and zlib (STEGASOO-Z:) formats.
Args: Args:
data: Compressed string with STEGASOO-Z: prefix data: Compressed string with STEGASOO-ZS: or STEGASOO-Z: prefix
Returns: Returns:
Original uncompressed string Original uncompressed string
@@ -73,12 +98,26 @@ def decompress_data(data: str) -> str:
Raises: Raises:
ValueError: If data is not valid compressed format ValueError: If data is not valid compressed format
""" """
if not data.startswith(COMPRESSION_PREFIX): if data.startswith(COMPRESSION_PREFIX_ZSTD):
raise ValueError("Data is not in compressed format") # v4.2.0: ZSTD compression
if not HAS_ZSTD:
raise ValueError(
"Data compressed with zstd but zstandard package not installed. "
"Run: pip install zstandard"
)
encoded = data[len(COMPRESSION_PREFIX_ZSTD):]
compressed = base64.b64decode(encoded)
dctx = zstd.ZstdDecompressor()
return dctx.decompress(compressed).decode("utf-8")
encoded = data[len(COMPRESSION_PREFIX) :] elif data.startswith(COMPRESSION_PREFIX_ZLIB):
compressed = base64.b64decode(encoded) # Legacy zlib compression
return zlib.decompress(compressed).decode("utf-8") encoded = data[len(COMPRESSION_PREFIX_ZLIB):]
compressed = base64.b64decode(encoded)
return zlib.decompress(compressed).decode("utf-8")
else:
raise ValueError("Data is not in compressed format")
def normalize_pem(pem_data: str) -> str: def normalize_pem(pem_data: str) -> str:
@@ -166,8 +205,8 @@ def normalize_pem(pem_data: str) -> str:
def is_compressed(data: str) -> bool: def is_compressed(data: str) -> bool:
"""Check if data has compression prefix.""" """Check if data has compression prefix (zstd or zlib)."""
return data.startswith(COMPRESSION_PREFIX) return data.startswith(COMPRESSION_PREFIX_ZSTD) or data.startswith(COMPRESSION_PREFIX_ZLIB)
def auto_decompress(data: str) -> str: def auto_decompress(data: str) -> str:
@@ -213,17 +252,23 @@ def needs_compression(data: str) -> bool:
return not can_fit_in_qr(data, compress=False) and can_fit_in_qr(data, compress=True) return not can_fit_in_qr(data, compress=False) and can_fit_in_qr(data, compress=True)
def generate_qr_code(data: str, compress: bool = False, error_correction=None) -> bytes: def generate_qr_code(
data: str,
compress: bool = False,
error_correction=None,
output_format: str = "png",
) -> bytes:
""" """
Generate a QR code PNG from string data. Generate a QR code image from string data.
Args: Args:
data: String data to encode data: String data to encode
compress: Whether to compress data first compress: Whether to compress data first
error_correction: QR error correction level (default: auto) error_correction: QR error correction level (default: auto)
output_format: Image format - 'png' or 'jpg'/'jpeg'
Returns: Returns:
PNG image bytes Image bytes in requested format
Raises: Raises:
RuntimeError: If qrcode library not available RuntimeError: If qrcode library not available
@@ -260,11 +305,79 @@ def generate_qr_code(data: str, compress: bool = False, error_correction=None) -
img = qr.make_image(fill_color="black", back_color="white") img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO() buf = io.BytesIO()
img.save(buf, format="PNG") fmt = output_format.lower()
if fmt in ("jpg", "jpeg"):
# Convert to RGB for JPEG (no alpha channel)
img = img.convert("RGB")
img.save(buf, format="JPEG", quality=95)
else:
img.save(buf, format="PNG")
buf.seek(0) buf.seek(0)
return buf.getvalue() return buf.getvalue()
def generate_qr_ascii(
data: str,
compress: bool = False,
invert: bool = False,
) -> str:
"""
Generate an ASCII representation of a QR code.
Uses Unicode block characters for compact display.
Args:
data: String data to encode
compress: Whether to compress data first
invert: Invert colors (white on black for dark terminals)
Returns:
ASCII string representation of QR code
Raises:
RuntimeError: If qrcode library not available
ValueError: If data too large for QR code
"""
if not HAS_QRCODE_WRITE:
raise RuntimeError("qrcode library not installed. Run: pip install qrcode[pil]")
qr_data = data
# Compress if requested
if compress:
qr_data = compress_data(data)
# Check size
if len(qr_data.encode("utf-8")) > QR_MAX_BINARY:
raise ValueError(
f"Data too large for QR code ({len(qr_data)} bytes). " f"Maximum: {QR_MAX_BINARY} bytes"
)
qr = qrcode.QRCode(
version=None,
error_correction=ERROR_CORRECT_L,
box_size=1,
border=2,
)
qr.add_data(qr_data)
qr.make(fit=True)
# Get the QR matrix
# Use print_ascii to a StringIO to capture output
import sys
from io import StringIO
old_stdout = sys.stdout
sys.stdout = StringIO()
try:
qr.print_ascii(invert=invert)
ascii_qr = sys.stdout.getvalue()
finally:
sys.stdout = old_stdout
return ascii_qr
def read_qr_code(image_data: bytes) -> str | None: def read_qr_code(image_data: bytes) -> str | None:
""" """
Read QR code from image data. Read QR code from image data.

View File

@@ -156,7 +156,7 @@ def has_dct_support() -> bool:
dct_mod = _get_dct_module() dct_mod = _get_dct_module()
return dct_mod.has_dct_support() return dct_mod.has_dct_support()
except (ImportError, ValueError): except (ImportError, ValueError):
# ValueError: numpy binary incompatibility (e.g., jpegio built against numpy 2.x) # ValueError: numpy binary incompatibility (e.g., jpeglib built against numpy 2.x)
return False return False
@@ -746,6 +746,10 @@ def _embed_lsb(
modified_pixels = 0 modified_pixels = 0
total_pixels_to_process = len(selected_indices) total_pixels_to_process = len(selected_indices)
# Initial progress write - signals prep is done, embedding starting
if progress_file:
_write_progress(progress_file, 5, 100, "embedding")
for progress_idx, pixel_idx in enumerate(selected_indices): for progress_idx, pixel_idx in enumerate(selected_indices):
if bit_idx >= len(binary_data): if bit_idx >= len(binary_data):
break break
@@ -839,6 +843,7 @@ def extract_from_image(
pixel_key: bytes, pixel_key: bytes,
bits_per_channel: int = 1, bits_per_channel: int = 1,
embed_mode: str = EMBED_MODE_AUTO, embed_mode: str = EMBED_MODE_AUTO,
progress_file: str | None = None,
) -> bytes | None: ) -> bytes | None:
""" """
Extract hidden data from a stego image. Extract hidden data from a stego image.
@@ -848,6 +853,7 @@ def extract_from_image(
pixel_key: Key for pixel/coefficient selection (must match encoding) pixel_key: Key for pixel/coefficient selection (must match encoding)
bits_per_channel: Bits per channel (LSB mode only) bits_per_channel: Bits per channel (LSB mode only)
embed_mode: 'auto' (try both), 'lsb', or 'dct' embed_mode: 'auto' (try both), 'lsb', or 'dct'
progress_file: Optional path to write progress JSON for UI polling
Returns: Returns:
Extracted data bytes, or None if extraction fails Extracted data bytes, or None if extraction fails
@@ -863,7 +869,7 @@ def extract_from_image(
if has_dct_support(): if has_dct_support():
debug.print("Auto-detect: LSB failed, trying DCT") debug.print("Auto-detect: LSB failed, trying DCT")
result = _extract_dct(image_data, pixel_key) result = _extract_dct(image_data, pixel_key, progress_file)
if result is not None: if result is not None:
debug.print("Auto-detect: DCT extraction succeeded") debug.print("Auto-detect: DCT extraction succeeded")
return result return result
@@ -875,18 +881,22 @@ def extract_from_image(
elif embed_mode == EMBED_MODE_DCT: elif embed_mode == EMBED_MODE_DCT:
if not has_dct_support(): if not has_dct_support():
raise ImportError("scipy required for DCT mode") raise ImportError("scipy required for DCT mode")
return _extract_dct(image_data, pixel_key) return _extract_dct(image_data, pixel_key, progress_file)
# EXPLICIT LSB MODE # EXPLICIT LSB MODE
else: else:
return _extract_lsb(image_data, pixel_key, bits_per_channel) return _extract_lsb(image_data, pixel_key, bits_per_channel)
def _extract_dct(image_data: bytes, pixel_key: bytes) -> bytes | None: def _extract_dct(
image_data: bytes,
pixel_key: bytes,
progress_file: str | None = None,
) -> bytes | None:
"""Extract using DCT mode.""" """Extract using DCT mode."""
try: try:
dct_mod = _get_dct_module() dct_mod = _get_dct_module()
return dct_mod.extract_from_dct(image_data, pixel_key) return dct_mod.extract_from_dct(image_data, pixel_key, progress_file)
except Exception as e: except Exception as e:
debug.print(f"DCT extraction failed: {e}") debug.print(f"DCT extraction failed: {e}")
return None return None
@@ -1087,7 +1097,7 @@ def peek_image(image_data: bytes) -> dict:
except Exception: except Exception:
pass pass
# Try DCT extraction (requires scipy/jpegio) # Try DCT extraction (requires scipy/jpeglib)
try: try:
from .dct_steganography import HAS_JPEGIO, HAS_SCIPY from .dct_steganography import HAS_JPEGIO, HAS_SCIPY

View File

@@ -66,9 +66,15 @@ def read_image_exif(image_data: bytes) -> dict:
# Convert bytes to string if possible # Convert bytes to string if possible
elif isinstance(value, bytes): elif isinstance(value, bytes):
try: try:
result[tag] = value.decode("utf-8", errors="replace").strip("\x00") # Try to decode as ASCII/UTF-8 text
except Exception: decoded = value.decode("utf-8", errors="strict").strip("\x00")
result[tag] = f"<{len(value)} bytes>" # Only keep if it looks like printable text
if decoded.isprintable() or all(c.isspace() or c.isprintable() for c in decoded):
result[tag] = decoded
else:
result[tag] = f"<{len(value)} bytes binary>"
except (UnicodeDecodeError, Exception):
result[tag] = f"<{len(value)} bytes binary>"
# Handle tuples of IFDRational # Handle tuples of IFDRational
elif isinstance(value, tuple) and value and hasattr(value[0], "numerator"): elif isinstance(value, tuple) and value and hasattr(value[0], "numerator"):
result[tag] = [float(v) for v in value] result[tag] = [float(v) for v in value]

107
test-aur-build.sh Normal file
View File

@@ -0,0 +1,107 @@
#!/bin/bash
# Test AUR package builds in a clean Arch container
#
# Usage: sudo ./test-aur-build.sh [package]
# package: all (default), full, cli, api
set -e
PACKAGE="${1:-all}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== Stegasoo AUR Build Test ==="
echo "Package: $PACKAGE"
echo ""
# Create a test script to run inside container
cat > /tmp/aur-build-test.sh << 'INNERSCRIPT'
#!/bin/bash
set -e
# Update system
pacman -Syu --noconfirm
# Install build dependencies
pacman -S --noconfirm --needed \
base-devel git python python-build python-hatchling \
zbar
# Create build user (makepkg won't run as root)
useradd -m builder
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
# Copy source to build location
cp -r /src /home/builder/stegasoo
chown -R builder:builder /home/builder/stegasoo
build_package() {
local pkg_dir="$1"
local pkg_name="$2"
echo ""
echo "=========================================="
echo "Building: $pkg_name"
echo "=========================================="
cd "/home/builder/stegasoo/$pkg_dir"
# Build as non-root user
sudo -u builder makepkg -sf --noconfirm
# Show result
ls -lh *.pkg.tar.zst
# Test install
echo "Installing $pkg_name..."
pacman -U --noconfirm *.pkg.tar.zst
# Quick test
echo "Testing $pkg_name..."
stegasoo --version
stegasoo --help | head -20
# Uninstall for next test
pacman -R --noconfirm "${pkg_name%-git}" 2>/dev/null || pacman -R --noconfirm "$pkg_name" 2>/dev/null || true
echo "$pkg_name: SUCCESS"
}
case "$1" in
full)
build_package "aur" "stegasoo-git"
;;
cli)
build_package "aur-cli" "stegasoo-cli-git"
;;
api)
build_package "aur-api" "stegasoo-api-git"
;;
all)
build_package "aur" "stegasoo-git"
build_package "aur-cli" "stegasoo-cli-git"
build_package "aur-api" "stegasoo-api-git"
;;
*)
echo "Unknown package: $1"
exit 1
;;
esac
echo ""
echo "=========================================="
echo "All builds completed successfully!"
echo "=========================================="
INNERSCRIPT
chmod +x /tmp/aur-build-test.sh
# Run in Arch container
echo "Starting Arch container..."
docker run --rm -it \
-v "$SCRIPT_DIR:/src:ro" \
-v "/tmp/aur-build-test.sh:/build.sh:ro" \
archlinux:latest \
/bin/bash -c "chmod +x /build.sh && /build.sh $PACKAGE"
echo ""
echo "=== Build test complete ==="

130
test-aur-nspawn.sh Normal file
View File

@@ -0,0 +1,130 @@
#!/bin/bash
# Test AUR package builds using systemd-nspawn
#
# Usage: sudo ./test-aur-nspawn.sh [package]
# package: all (default), full, cli, api
#
# First run creates Arch root at /tmp/arch-build-root
set -e
PACKAGE="${1:-all}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ARCH_ROOT="/tmp/arch-build-root"
echo "=== Stegasoo AUR Build Test (nspawn) ==="
echo "Package: $PACKAGE"
echo "Arch root: $ARCH_ROOT"
echo ""
# Check for root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (sudo)"
exit 1
fi
# Create Arch root if it doesn't exist
if [ ! -d "$ARCH_ROOT/usr" ]; then
echo "Creating Arch root (first time setup)..."
mkdir -p "$ARCH_ROOT"
pacstrap -c "$ARCH_ROOT" base base-devel git python python-build python-hatchling zbar
echo "Arch root created."
else
echo "Using existing Arch root."
# Update packages
arch-chroot "$ARCH_ROOT" pacman -Syu --noconfirm
fi
# Create build user if needed
if ! arch-chroot "$ARCH_ROOT" id builder &>/dev/null; then
arch-chroot "$ARCH_ROOT" useradd -m builder
echo "builder ALL=(ALL) NOPASSWD: ALL" >> "$ARCH_ROOT/etc/sudoers"
fi
# Copy source
rm -rf "$ARCH_ROOT/home/builder/stegasoo"
cp -r "$SCRIPT_DIR" "$ARCH_ROOT/home/builder/stegasoo"
arch-chroot "$ARCH_ROOT" chown -R builder:builder /home/builder/stegasoo
# Create build script
cat > "$ARCH_ROOT/tmp/build.sh" << 'BUILDSCRIPT'
#!/bin/bash
set -e
build_package() {
local pkg_dir="$1"
local pkg_name="$2"
echo ""
echo "=========================================="
echo "Building: $pkg_name"
echo "=========================================="
cd "/home/builder/stegasoo/$pkg_dir"
# Clean previous builds
rm -rf src pkg *.pkg.tar.zst "${pkg_name}" 2>/dev/null || true
# Build as non-root user
sudo -u builder makepkg -sf --noconfirm
# Show result
ls -lh *.pkg.tar.zst
# Test install
echo "Installing $pkg_name..."
pacman -U --noconfirm *.pkg.tar.zst
# Quick test
echo "Testing $pkg_name..."
/usr/bin/stegasoo --version
# More tests for API package
if [[ "$pkg_name" == *"api"* ]]; then
/usr/bin/stegasoo api --help
/usr/bin/stegasoo api keys list
fi
# Uninstall for next test
pacman -Rns --noconfirm $(pacman -Qq | grep stegasoo) 2>/dev/null || true
echo "$pkg_name: SUCCESS"
}
case "$1" in
full)
build_package "aur" "stegasoo-git"
;;
cli)
build_package "aur-cli" "stegasoo-cli-git"
;;
api)
build_package "aur-api" "stegasoo-api-git"
;;
all)
build_package "aur-cli" "stegasoo-cli-git"
build_package "aur-api" "stegasoo-api-git"
build_package "aur" "stegasoo-git"
;;
*)
echo "Unknown package: $1"
exit 1
;;
esac
echo ""
echo "=========================================="
echo "All builds completed successfully!"
echo "=========================================="
BUILDSCRIPT
chmod +x "$ARCH_ROOT/tmp/build.sh"
# Run build in nspawn container
echo "Starting nspawn container..."
systemd-nspawn -D "$ARCH_ROOT" --bind-ro="$SCRIPT_DIR:/home/builder/stegasoo" /tmp/build.sh "$PACKAGE"
echo ""
echo "=== Build test complete ==="
echo "Arch root preserved at: $ARCH_ROOT"
echo "To clean up: sudo rm -rf $ARCH_ROOT"

BIN
test_data/carrier3.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

30
test_data/phonebooth.pem Normal file
View File

@@ -0,0 +1,30 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFJTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQZA5S460JEEzHr4Gv
6SHaxwICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEIUT3kxmLKusysd+
g2eLYzwEggTAjVjjUGenOSvsc9jyPzq+bvpkml1OXxbPh/014rge+wpSd8Q937eK
6CCfkhp7gGpcK2/Myt9RzATHRFj3Y0t2HNrLXHhBsuQrhO6Nd4RIMhRLWbZL7eyV
hjrACXDTNOJIMHaMj17qu2bWDhoQK9khtYFKTiGnXJgw/qheaq+XoV/dcDXIC3/m
3wlveYLxRB+907u9Ddjqjhyz+58IWZozxaEjCcX7UIdJLul0RvBhAT0RSBGzA1Zr
kvuIya/rx37vtHu4VDBijZyxlieMAXp7oEsi4vC6rEWMBO+mupf9scTuxiO6UJJp
+kh1aH0zBep5X5pseHfsZmtjF+ExfXQDEDDBKIXJteoyozaT3cwXw+0f3+ba2fGl
4gI+SiZeprhOLRAuh6z1HSshSe3+SHubfVQiaZWrrusQOlE/CbxXF7MC6p7YBuw7
UIl4shjqERe9mSj4bRtCw7DBqnKbCxQjqgAN2P1ELuiH6f+z8kd//AFBMp0IBtwR
AlmIl0yT8x209Kd8ztpqRpoO87FJNOVfmTKIIZqVQls5jglPoeL6xgNdruTydMr8
4fTqW+O7V69F7hASe4Zxu6VZYDqb9Qg2DEwbIsgERL9t/7bO6Lhpfsk7J4YLgaqu
Tq+BcP62J73aq9lo4VJlA7NaSOzH3Sqi78JCYq4ZrttGbmOqSAKVxDsXq7sI6sJA
va97f5pxhU+g4o0iu1rkaygGA08Ajs/8AzJ9Oyj65zxNONOfBRDWYvfbia1xKBMl
QGnHuyBFAvOvSFwq2qJ7+yUB7PMkXar/Gx2dQrW7a/2ahqjhO4+ssUKbeOpup4K7
BIXob8guks3s1i3dl0wap8GtwCgPLduEXSvQ2ORiU/avpYdCAA8iqUaxXalZ/lhe
nfTy8Uz/BBXpunTpHJ4A1ruDrdigfoYiI3vnVB1DglX37XillmysO/gu5gwYECHz
OTZSUevcWw88rVVRbUelIs3FwmywCT+NWXJDtfgm1PCXchlJmQx2zjJMBwez3syn
u+SY84ntrB0hyAWmwaHtGbwe4Z9u1FnZ7j+0Y8vTAD4LeWJls34RkboXhzNlJYn5
s4zp619MY+l+YPgQubhFEsCr6yzPOXQEdg1pk/liZFO9sh2tFR1teg3bM4JKn0w1
8qpdUmeY3tTU/+Vk9UUZSqhMk8No59a/8//26KN9AOOUUv7j8yLrjsonUkuvkadX
EnsJHVlOnwe0dt+4ll23Hf5+Ka8KjNYAjdeyMrtS5XVnz0zOC6KLnWori+DbuB4n
jezwLC1cHU5KbVDRCnssEN7di0i1UlFFi3oujvC8DOD0k57+rmwpK26gj61tCiwn
TcIvzIvtSNeFgCjrIVldFt2rd36nvgVK6I6NyK4EAdLdVjqV0gVZ5WVhV9x50ZNi
ADoaidbHoxVTBt3ZkKMXjxJss4YtTDerUS3xD1bHMMtSQKMYhe1u/n1ecwkyGaAv
9s9ldUUwmGU6wbHpIixXTlDeRT/w3DVHLlEjHRnqv1o88wJV4kALZxUCfgLaaiQo
SpBl6v1Q70MXd22N+ywJTPS/mScEMb4NiemlNFSVGpT6EioY0lofHB7YNaB4UZES
mOcTA23IguMFuU/jGYp04cGT+gE4X+7CzA==
-----END ENCRYPTED PRIVATE KEY-----

BIN
test_data/ref2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB