43 Commits

Author SHA1 Message Date
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
60 changed files with 1500 additions and 574 deletions

5
.gitignore vendored
View File

@@ -97,3 +97,8 @@ 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

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,49 +1,83 @@
## Stegasoo v4.1.7 ## Stegasoo v4.2.0
### Mobile UI Polish ### Performance Optimizations
- **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 Major performance improvements for Raspberry Pi and resource-constrained deployments.
- **Reorganized**: Docker files moved to `docker/` directory
- `docker/Dockerfile`
- `docker/Dockerfile.base`
- `docker/docker-compose.yml`
- **DCT Fix**: Added Reed-Solomon (`reedsolo`) to Docker images - fixes DCT decode failures
- **Quick Start**: New `docs/DOCKER_QUICKSTART.md` guide
```bash #### DCT Vectorization (~14x faster)
# Build and run - Batch DCT processing using `scipy.fft.dctn` with `axes=(1,2)`
docker build -f docker/Dockerfile.base -t stegasoo-base:latest . - Processes 500 blocks at once instead of one-by-one
docker-compose -f docker/docker-compose.yml up -d - Decode time reduced from ~2.6s to ~0.8s on 1MB images
```
### Raspberry Pi #### Memory Optimization (50% reduction)
- **First-Boot Wizard**: Can now load existing channel key (for joining team deployments) - Switched from `float64` to `float32` for all DCT operations
- **Project Cleanup**: Moved `pishrink.sh` to `rpi/tools/` - Peak RAM: 211 MB → 107 MB for encode, 104 MB → 52 MB for decode
- Critical for Pi 3/4 avoiding swap thrashing
### UI Copy #### Progress Callbacks for Decode
- Changed "Undetectable" to "Covertly Embedded" on encode page (more accurate) - `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 | ✓ |
| QR Compression | zlib | zstd | **~15% smaller** |
### Raspberry Pi Image ### Raspberry Pi Image
Download `stegasoo-rpi-4.1.7.img.zst.zip` from Releases. Download `stegasoo-rpi-4.2.0_final.img.zst` from Releases.
```bash ```bash
# Flash (auto-detects SD card) # Flash (auto-detects SD card)
sudo ./rpi/flash-image.sh stegasoo-rpi-4.1.7.img.zst.zip sudo ./rpi/flash-image.sh stegasoo-rpi-4.2.0_final.img.zst
# Or manual # Or manual
unzip -p stegasoo-rpi-4.1.7.img.zst.zip | zstdcat | sudo dd of=/dev/sdX bs=4M status=progress zstdcat stegasoo-rpi-4.2.0_final.img.zst | 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.
### Docker ### 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
# Or individual services
docker-compose -f docker/docker-compose.yml up -d web # Web UI on :5000 docker-compose -f docker/docker-compose.yml up -d web # Web UI on :5000
docker-compose -f docker/docker-compose.yml up -d api # REST API on :8000 docker-compose -f docker/docker-compose.yml up -d api # REST API on :8000
``` ```

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

116
aur/PKGBUILD Normal file
View File

@@ -0,0 +1,116 @@
# Maintainer: Aaron D. Lee <your-email@example.com>
pkgname=stegasoo-git
pkgver=4.2.0.r0.g530e5de
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.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
build() {
cd "$pkgname"
python -m build --wheel --no-isolation
}
package() {
cd "$pkgname"
# Detect Python version for site-packages path
local pyver=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
# Install to /opt/stegasoo with dedicated venv
install -dm755 "$pkgdir/opt/stegasoo"
# Create fresh venv in package
python -m venv "$pkgdir/opt/stegasoo/venv"
# Install the wheel with all extras
local wheel=$(ls dist/*.whl | head -1)
"$pkgdir/opt/stegasoo/venv/bin/pip" install --no-cache-dir "${wheel}[all]"
# Install frontends (not included in wheel)
local site_packages="$pkgdir/opt/stegasoo/venv/lib/python${pyver}/site-packages"
cp -r frontends "$site_packages/"
# Create writable directories for stegasoo user
install -dm755 "$pkgdir/opt/stegasoo/venv/var/app-instance"
install -dm755 "$site_packages/frontends/web/temp_files"
install -dm755 "$site_packages/frontends/api/temp_files"
# Fix shebangs - replace build-time paths with installed paths
find "$pkgdir/opt/stegasoo/venv/bin" -type f -exec \
sed -i "s|$pkgdir/opt/stegasoo/venv|/opt/stegasoo/venv|g" {} \;
# Fix pyvenv.cfg
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo/venv/pyvenv.cfg"
# Create symlinks to /usr/bin
install -dm755 "$pkgdir/usr/bin"
ln -s /opt/stegasoo/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
# Install license
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
# Install docs
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
# Install systemd service files
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-web.service" <<EOF
[Unit]
Description=Stegasoo Web UI
After=network.target
[Service]
Type=simple
User=stegasoo
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/web
Environment="PATH=/opt/stegasoo/venv/bin"
ExecStart=/opt/stegasoo/venv/bin/gunicorn -b 127.0.0.1:5000 app:app
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
[Unit]
Description=Stegasoo REST API
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"
ExecStart=/opt/stegasoo/venv/bin/uvicorn main:app --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.0"

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

View File

@@ -1,10 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Stegasoo REST API (v4.0.0) Stegasoo REST API (v4.2.0)
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.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,8 +25,10 @@ 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
@@ -68,13 +74,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
# ============================================================================ # ============================================================================
@@ -357,6 +370,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 +386,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 +476,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 +530,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,
@@ -760,6 +822,51 @@ 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):
"""
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
# ============================================================================ # ============================================================================
@@ -874,8 +981,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,
@@ -950,8 +1058,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,
@@ -1021,8 +1130,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,
@@ -1150,8 +1260,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,
@@ -1264,8 +1375,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,

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

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 (progressBar) {
progressBar.style.width = '100%';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
if (progressText) progressText.textContent = '';
if (phaseText) phaseText.textContent = phase; 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

@@ -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.0</span>
<div> <div>
<strong>Progress bars</strong> for encode operations, <strong>Performance optimizations:</strong>
<strong>mobile-responsive polish</strong>, ~70% faster decode (vectorized DCT),
DCT decode bug fix, release validation script 50% less RAM (float32),
async API endpoints,
decode progress callbacks
</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

@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
[project] [project]
name = "stegasoo" name = "stegasoo"
version = "4.1.5" version = "4.2.0"
description = "Secure steganography with hybrid photo + passphrase + PIN authentication" description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
requires-python = ">=3.10" requires-python = ">=3.11"
authors = [ authors = [
{ name = "Aaron D. Lee" } { name = "Aaron D. Lee" }
] ]
@@ -29,9 +29,10 @@ classifiers = [
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Security :: Cryptography", "Topic :: Security :: Cryptography",
"Topic :: Multimedia :: Graphics", "Topic :: Multimedia :: Graphics",
] ]
@@ -40,6 +41,7 @@ dependencies = [
"pillow>=10.0.0", "pillow>=10.0.0",
"cryptography>=41.0.0", "cryptography>=41.0.0",
"argon2-cffi>=23.0.0", "argon2-cffi>=23.0.0",
"zstandard>=0.22.0", # v4.2.0: Default compression algorithm
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -47,7 +49,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.0.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.0.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.0.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.0.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.0.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.0.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.0.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.0.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 fi
done
if [ -z "$PYTHON_BIN" ]; then
echo " Error: Python 3.11+ not found"
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
else
echo " Using: $PYTHON_BIN ($($PYTHON_BIN --version 2>&1))"
sudo -u "$STEGASOO_USER" "$PYTHON_BIN" -m venv "$STEGASOO_DIR/venv" sudo -u "$STEGASOO_USER" "$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 --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
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]" sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]"
echo " Venv rebuilt and stegasoo installed" 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.0/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 fi
else source "$INSTALL_DIR/venv/bin/activate"
echo " pyenv already installed"
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
fi
# Install Python 3.12 if not present
if ! pyenv versions | grep -q "$PYTHON_VERSION"; then
echo " Building Python $PYTHON_VERSION (this takes ~10 minutes)..."
pyenv install $PYTHON_VERSION
else
echo " Python $PYTHON_VERSION already installed"
fi
pyenv global $PYTHON_VERSION
# Verify Python version
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
if [ "$INSTALLED_PY" != "$PYTHON_VERSION" ]; then
echo -e "${RED}Error: Python $PYTHON_VERSION not active. Got: $INSTALLED_PY${NC}"
exit 1
fi
echo -e "${GREEN}[6/12]${NC} Creating Python virtual environment..."
echo -e " ${YELLOW}Note: No pre-built venv found. Building from source (20+ min)${NC}"
echo -e " ${YELLOW}To speed up future installs, add stegasoo-venv-pi-arm64.tar.gz to rpi/${NC}"
# Create venv with pyenv Python (not system Python)
# Use pyenv which to get actual path (handles 3.12 -> 3.12.12 mapping)
PYENV_PYTHON=$(pyenv which python)
echo " Using Python: $PYENV_PYTHON"
if [ ! -d "venv" ]; then
"$PYENV_PYTHON" -m venv venv
fi
source venv/bin/activate
# 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,6 +68,15 @@ 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 ""
@@ -76,12 +85,14 @@ case "${1:-fast}" in
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 " rebuild Complete rebuild with no cache (base + services)"
echo " clean Remove all images and volumes" 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.0"
# Core functionality # Core functionality
# Channel key management (v4.0.0) # Channel key management (v4.0.0)

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.0"
# ============================================================================ # ============================================================================
# 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
@@ -40,27 +40,30 @@ from PIL import Image
# 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
@@ -406,28 +409,30 @@ def _safe_idct2(block: np.ndarray) -> np.ndarray:
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 +449,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 +549,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 +578,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 +743,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",
}, },
} }
@@ -818,8 +828,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 +901,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)
# Extract blocks into 3D array (float32 for memory efficiency)
blocks = np.zeros((batch_count, BLOCK_SIZE, BLOCK_SIZE), dtype=np.float32)
block_positions = []
for i, block_num in enumerate(batch_order):
by = (block_num // blocks_x) * BLOCK_SIZE by = (block_num // blocks_x) * BLOCK_SIZE
bx = (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] # Get bits for this block
block_bits = bits[bit_idx : bit_idx + bits_per_block]
num_bits = len(block_bits)
if num_bits == bits_per_block:
# Full block - vectorized embedding
coeffs = dct_blocks[i, embed_rows, embed_cols]
bit_array = np.array(block_bits)
# QIM embedding: round to grid, adjust for bit
quantized = np.round(coeffs / QUANT_STEP).astype(int)
# If quantized % 2 != bit, nudge coefficient
needs_adjust = (quantized % 2) != bit_array
# Determine direction to nudge
dct_blocks[i, embed_rows[needs_adjust], embed_cols[needs_adjust]] = (
(quantized[needs_adjust] + (1 - 2 * (quantized[needs_adjust] % 2 == 1))) * QUANT_STEP
).astype(np.float64)
# For bits that already match, just quantize
dct_blocks[i, embed_rows[~needs_adjust], embed_cols[~needs_adjust]] = (
quantized[~needs_adjust] * QUANT_STEP
).astype(np.float64)
else:
# Partial block - process remaining bits individually
for j, bit in enumerate(block_bits):
row, col = embed_rows[j], embed_cols[j]
dct_blocks[i, row, col] = _embed_bit_in_coeff(
float(dct_blocks[i, row, col]), bit
) )
bit_idx += 1
# Apply safe inverse DCT bit_idx += num_bits
modified_block = _safe_idct2(dct_block)
# Copy back # Vectorized inverse DCT
result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE] = modified_block modified_blocks = idctn(dct_blocks, axes=(1, 2), norm="ortho")
# Clean up this iteration # Copy modified blocks back to result
del block, dct_block, modified_block 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 +1083,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 +1118,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 +1145,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,7 +1173,11 @@ def _embed_jpegio(
pass pass
def extract_from_dct(stego_image: bytes, seed: bytes) -> bytes: def extract_from_dct(
stego_image: bytes,
seed: bytes,
progress_file: str | None = None,
) -> bytes:
"""Extract data from DCT stego image.""" """Extract data from DCT stego image."""
img = Image.open(io.BytesIO(stego_image)) img = Image.open(io.BytesIO(stego_image))
fmt = img.format fmt = img.format
@@ -1123,16 +1185,22 @@ def extract_from_dct(stego_image: bytes, seed: bytes) -> bytes:
if fmt == "JPEG" and HAS_JPEGIO: if fmt == "JPEG" and HAS_JPEGIO:
try: try:
return _extract_jpegio(stego_image, seed) return _extract_jpegio(stego_image, seed, progress_file)
except ValueError: except ValueError:
pass pass
_check_scipy() _check_scipy()
return _extract_scipy_dct_safe(stego_image, seed) return _extract_scipy_dct_safe(stego_image, seed, progress_file)
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 +1224,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
embed_rows = np.array([pos[0] for pos in DEFAULT_EMBED_POSITIONS])
embed_cols = np.array([pos[1] for pos in DEFAULT_EMBED_POSITIONS])
# Progress reporting interval - report frequently for responsive UI
PROGRESS_INTERVAL = 500 # Report every N blocks (matches BATCH_SIZE)
block_idx = 0
while block_idx < len(block_order):
# Determine batch size (may be smaller at end)
batch_end = min(block_idx + BATCH_SIZE, len(block_order))
batch_order = block_order[block_idx:batch_end]
batch_count = len(batch_order)
# 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 by = (block_num // blocks_x) * BLOCK_SIZE
bx = (block_num % blocks_x) * BLOCK_SIZE bx = (block_num % blocks_x) * BLOCK_SIZE
blocks[i] = padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE]
block = np.array( # Vectorized 2D DCT on all blocks at once (~10-15x faster than sequential)
padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE], dct_blocks = dctn(blocks, axes=(1, 2), norm="ortho")
dtype=np.float64,
copy=True,
order="C",
)
dct_block = _safe_dct2(block)
for pos in DEFAULT_EMBED_POSITIONS: # Extract bits from embed positions (vectorized)
bit = _extract_bit_from_coeff(float(dct_block[pos[0], pos[1]])) # Shape: (batch_count, num_positions)
all_bits.append(bit) coeffs = dct_blocks[:, embed_rows, embed_cols]
del block, dct_block # 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 +1284,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 +1339,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 +1356,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 +1372,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 +1393,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 +1464,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 +1507,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")
if HAS_ZSTD:
# Use zstd (better compression ratio)
cctx = zstd.ZstdCompressor(level=19)
compressed = cctx.compress(data_bytes)
encoded = base64.b64encode(compressed).decode("ascii") encoded = base64.b64encode(compressed).decode("ascii")
return COMPRESSION_PREFIX + encoded 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,13 +98,27 @@ 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):
# Legacy zlib compression
encoded = data[len(COMPRESSION_PREFIX_ZLIB):]
compressed = base64.b64decode(encoded) compressed = base64.b64decode(encoded)
return zlib.decompress(compressed).decode("utf-8") 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()
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") 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]

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