Compare commits
64 Commits
v4.1.7
...
1630d044aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1630d044aa | ||
|
|
c2a0a731d7 | ||
|
|
89de839fd8 | ||
|
|
49566292ba | ||
|
|
9f0e0afeb6 | ||
|
|
398a359778 | ||
|
|
86aa5cbddf | ||
|
|
2f54f80214 | ||
|
|
1cd2656e60 | ||
|
|
ce728cec6e | ||
|
|
555735a4fd | ||
|
|
08b70043e4 | ||
|
|
d395e5731e | ||
|
|
110b160e68 | ||
|
|
b09f607d34 | ||
|
|
34ede3815f | ||
|
|
3b5ab41ce9 | ||
|
|
525bcec3c9 | ||
|
|
afc8c93923 | ||
|
|
38bef32750 | ||
|
|
4e3acfca20 | ||
|
|
2ebc42f2cd | ||
|
|
1e07630b49 | ||
|
|
67037ae196 | ||
|
|
5a68840725 | ||
|
|
ebc999b2b3 | ||
|
|
f46ef01f5f | ||
|
|
0d76780deb | ||
|
|
d34919e32f | ||
|
|
a4038589b0 | ||
|
|
db763f1464 | ||
|
|
27c5b08d41 | ||
|
|
28cb9bb9b3 | ||
|
|
889df881ba | ||
|
|
c058d116b8 | ||
|
|
fae86887e2 | ||
|
|
5e45b2c5c1 | ||
|
|
71088989f3 | ||
|
|
530e5debef | ||
|
|
3b062458e3 | ||
|
|
5e65035ca4 | ||
|
|
de9d1de881 | ||
|
|
8d90a888cf | ||
|
|
b0914778e3 | ||
|
|
7e5462ea6e | ||
|
|
e085a8ffe9 | ||
|
|
2d7fbd1e0d | ||
|
|
32842f6b73 | ||
|
|
3fd3204552 | ||
|
|
175362ce4c | ||
|
|
2ed108f3a0 | ||
|
|
167e1a6ff5 | ||
|
|
f2f3e2eefc | ||
|
|
5c685cba67 | ||
|
|
4e819b80cc | ||
|
|
ea86216648 | ||
|
|
8de5659fa6 | ||
|
|
de0bf2410d | ||
|
|
8b948d00a4 | ||
|
|
6d88453b69 | ||
|
|
ea57bdf302 | ||
|
|
55d54717f8 | ||
|
|
c0fe85ac83 | ||
|
|
e9e4d1aab9 |
3
.github/workflows/release.yml
vendored
@@ -37,7 +37,8 @@ jobs:
|
||||
publish:
|
||||
needs: test # Only run if tests pass
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
environment: pypi
|
||||
|
||||
# Required for PyPI trusted publishing (recommended)
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
2
.github/workflows/test.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false # Don't cancel other jobs if one fails
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
# 1. Get the code
|
||||
|
||||
8
.gitignore
vendored
@@ -97,3 +97,11 @@ rpi/*.tar.zst.zip
|
||||
rpi/*.img
|
||||
rpi/*.img.zst
|
||||
rpi/*.img.zst.zip
|
||||
|
||||
# AUR build artifacts
|
||||
aur-upload/
|
||||
aur/.SRCINFO
|
||||
aur/*.pkg.tar.zst
|
||||
|
||||
# Docker pre-built images and deps (release assets, too large for git)
|
||||
docker/*.tar.zst
|
||||
|
||||
4
CLI.md
@@ -164,7 +164,7 @@ stegasoo generate [OPTIONS]
|
||||
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
|
||||
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
|
||||
| `--pin-length` | | 6-9 | 6 | PIN length in digits |
|
||||
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072, 4096) |
|
||||
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072) |
|
||||
| `--words` | | 3-12 | 4 | Words in passphrase |
|
||||
| `--output` | `-o` | path | | Save RSA key to file |
|
||||
| `--password` | `-p` | string | | Password for RSA key file |
|
||||
@@ -180,7 +180,7 @@ stegasoo generate
|
||||
stegasoo generate --words 6
|
||||
|
||||
# Generate with RSA key
|
||||
stegasoo generate --rsa --rsa-bits 4096
|
||||
stegasoo generate --rsa --rsa-bits 3072
|
||||
|
||||
# Save RSA key to encrypted file
|
||||
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"
|
||||
|
||||
64
INSTALL.md
@@ -20,22 +20,23 @@ Complete installation instructions for all platforms and deployment methods.
|
||||
|
||||
## Requirements
|
||||
|
||||
### ⚠️ Python Version Requirements
|
||||
### Python Version Requirements
|
||||
|
||||
| Python Version | Status | Notes |
|
||||
|----------------|--------|-------|
|
||||
| 3.10 | ✅ Supported | |
|
||||
| 3.11 | ✅ Supported | Recommended |
|
||||
| 3.10 | ❌ Not Supported | Dropped in v4.2.1 |
|
||||
| 3.11 | ✅ Supported | Minimum version |
|
||||
| 3.12 | ✅ Supported | Recommended |
|
||||
| 3.13 | ❌ **Not Supported** | jpegio C extension incompatible |
|
||||
| 3.13 | ✅ Supported | |
|
||||
| 3.14 | ✅ Supported | Tested on Arch |
|
||||
|
||||
**Important:** Python 3.13 (released October 2024) is **not compatible** with jpegio due to C extension ABI changes. Use Python 3.12 or earlier.
|
||||
**Note:** v4.2.1 switched from `jpegio` to `jpeglib` for DCT steganography, enabling Python 3.11-3.14 support.
|
||||
|
||||
### Minimum Requirements
|
||||
|
||||
| Requirement | Value |
|
||||
|-------------|-------|
|
||||
| Python | 3.10-3.12 |
|
||||
| Python | 3.11-3.14 |
|
||||
| RAM | 512 MB minimum (256MB for Argon2) |
|
||||
| Disk | ~100 MB |
|
||||
|
||||
@@ -423,16 +424,61 @@ pip install jpegio
|
||||
|
||||
### Windows
|
||||
|
||||
1. Install Python 3.12 from [python.org](https://python.org) (NOT 3.13!)
|
||||
2. Install Visual Studio Build Tools
|
||||
Windows users have three options, listed from easiest to most complex:
|
||||
|
||||
#### Option 1: Docker Desktop (Recommended)
|
||||
|
||||
The easiest way to run Stegasoo on Windows. No Python installation needed.
|
||||
|
||||
1. Install [Docker Desktop](https://www.docker.com/products/docker-desktop/)
|
||||
2. Enable WSL2 backend when prompted
|
||||
3. Clone and run:
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo
|
||||
docker-compose -f docker/docker-compose.yml up -d web
|
||||
```
|
||||
|
||||
Access at http://localhost:5000
|
||||
|
||||
#### Option 2: WSL2 (Windows Subsystem for Linux)
|
||||
|
||||
Run the Linux version natively on Windows.
|
||||
|
||||
```powershell
|
||||
# Install WSL2 with Ubuntu
|
||||
wsl --install -d Ubuntu
|
||||
|
||||
# Open Ubuntu terminal, then follow Linux instructions:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3.12 python3.12-venv libzbar0 libjpeg-dev
|
||||
git clone https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -e ".[all]"
|
||||
stegasoo --version
|
||||
```
|
||||
|
||||
#### Option 3: Native Windows (Advanced)
|
||||
|
||||
Native Windows installation requires Visual Studio Build Tools for compiling C extensions.
|
||||
|
||||
1. Install Python 3.11 or 3.12 from [python.org](https://python.org)
|
||||
2. Install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with "Desktop development with C++"
|
||||
3. Install from pip:
|
||||
|
||||
```powershell
|
||||
python -m venv venv
|
||||
.\venv\Scripts\activate
|
||||
pip install stegasoo[all]
|
||||
pip install stegasoo[cli] # CLI only (easiest)
|
||||
# or
|
||||
pip install stegasoo[all] # Full install (may require additional setup)
|
||||
```
|
||||
|
||||
**Note:** Native Windows installation may have issues with `jpegio` (DCT mode). Docker or WSL2 is recommended for full functionality.
|
||||
|
||||
### Raspberry Pi
|
||||
|
||||
Stegasoo works on Raspberry Pi 4/5 (4GB+ RAM recommended for Web UI).
|
||||
|
||||
@@ -4,7 +4,7 @@ A secure steganography system for hiding encrypted messages in images using hybr
|
||||
|
||||
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml)
|
||||
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml)
|
||||

|
||||

|
||||
[](LICENSE)
|
||||

|
||||
|
||||
|
||||
157
RELEASE_NOTES.md
@@ -1,52 +1,131 @@
|
||||
## Stegasoo v4.1.7
|
||||
## Stegasoo v4.2.1
|
||||
|
||||
### Mobile UI Polish
|
||||
- **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
|
||||
### API Security
|
||||
|
||||
### Docker Improvements
|
||||
- **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
|
||||
**API Key Authentication**
|
||||
- All protected endpoints require `X-API-Key` header
|
||||
- Keys stored hashed (SHA-256) in `~/.stegasoo/api_keys.json`
|
||||
- Auth disabled when no keys configured (easy onboarding)
|
||||
|
||||
**TLS Support**
|
||||
- Self-signed certificates auto-generated on first run
|
||||
- Certs valid for localhost, all local IPs, hostname.local
|
||||
- CLI: `stegasoo api tls generate` to pre-generate
|
||||
|
||||
### CLI Improvements
|
||||
|
||||
**New API Management Commands**
|
||||
```bash
|
||||
stegasoo api keys create NAME # Create new key
|
||||
stegasoo api keys list # List API keys
|
||||
stegasoo api tls generate # Generate TLS cert
|
||||
stegasoo api serve # Start server with TLS
|
||||
```
|
||||
|
||||
**New Image Tools**
|
||||
```bash
|
||||
stegasoo tools compress IMG -q 75 # JPEG compression
|
||||
stegasoo tools rotate IMG -r 90 # Lossless rotation
|
||||
stegasoo tools convert IMG -f png # Format conversion
|
||||
```
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **DCT rotation**: Portrait photos no longer export rotated 90°
|
||||
- **jpegtran**: Removed `-trim` flag that destroyed DCT stego data
|
||||
- **CLI encode**: Now outputs JPEG when carrier is JPEG (was always PNG)
|
||||
- **Import paths**: Fixed for installed packages (AUR/pip)
|
||||
|
||||
### Installation
|
||||
|
||||
**AUR (Arch Linux)**
|
||||
```bash
|
||||
yay -S stegasoo-git # Full (Web + API + CLI)
|
||||
yay -S stegasoo-cli-git # CLI only
|
||||
```
|
||||
|
||||
**Docker**
|
||||
```bash
|
||||
# Build and run
|
||||
docker build -f docker/Dockerfile.base -t stegasoo-base:latest .
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
### Raspberry Pi
|
||||
- **First-Boot Wizard**: Can now load existing channel key (for joining team deployments)
|
||||
- **Project Cleanup**: Moved `pishrink.sh` to `rpi/tools/`
|
||||
|
||||
### UI Copy
|
||||
- Changed "Undetectable" to "Covertly Embedded" on encode page (more accurate)
|
||||
|
||||
### Raspberry Pi Image
|
||||
Download `stegasoo-rpi-4.1.7.img.zst.zip` from Releases.
|
||||
|
||||
```bash
|
||||
# Flash (auto-detects SD card)
|
||||
sudo ./rpi/flash-image.sh stegasoo-rpi-4.1.7.img.zst.zip
|
||||
|
||||
# Or manual
|
||||
unzip -p stegasoo-rpi-4.1.7.img.zst.zip | zstdcat | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
```
|
||||
|
||||
**Raspberry Pi**
|
||||
Flash `stegasoo-rpi-4.2.1.img.zst.zip` to SD card.
|
||||
Default login: `admin` / `stegasoo`
|
||||
|
||||
First boot runs the setup wizard for WiFi, HTTPS, and channel key configuration.
|
||||
### Requirements
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
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
|
||||
```
|
||||
- Python 3.11 - 3.14 (dropped 3.10 support)
|
||||
|
||||
### Release Assets
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `stegasoo-rpi-4.2.1.img.zst.zip` | Raspberry Pi SD card image |
|
||||
| `stegasoo-docker-base-4.2.1.tar.zst` | Docker base image |
|
||||
| Source code (zip/tar.gz) | Auto-generated |
|
||||
|
||||
---
|
||||
|
||||
## Stegasoo v4.2.0
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
Major performance improvements for Raspberry Pi and resource-constrained deployments.
|
||||
|
||||
#### DCT Vectorization (~14x faster)
|
||||
- Batch DCT processing using `scipy.fft.dctn` with `axes=(1,2)`
|
||||
- Processes 500 blocks at once instead of one-by-one
|
||||
- Decode time reduced from ~2.6s to ~0.8s on 1MB images
|
||||
|
||||
#### Memory Optimization (50% reduction)
|
||||
- Switched from `float64` to `float32` for all DCT operations
|
||||
- Peak RAM: 211 MB → 107 MB for encode, 104 MB → 52 MB for decode
|
||||
- Critical for Pi 3/4 avoiding swap thrashing
|
||||
|
||||
#### Progress Callbacks for Decode
|
||||
- `progress_file` parameter added to `decode()` and extraction functions
|
||||
- UI can now show decode progress (phases: loading, extracting, decoding, complete)
|
||||
- JSON format: `{"current": 80, "total": 100, "percent": 80.0, "phase": "decoding"}`
|
||||
|
||||
#### Async API Endpoints
|
||||
- Encode/decode operations now run in thread pool via `asyncio.to_thread()`
|
||||
- API server can handle concurrent requests without blocking
|
||||
- Essential for multi-user Pi deployments
|
||||
|
||||
### Compression
|
||||
|
||||
#### Zstd Default Compression
|
||||
- `zstandard` is now a core dependency (always installed)
|
||||
- Better compression ratio than zlib for QR code RSA keys
|
||||
- New `STEGASOO-ZS:` prefix for zstd, backward compatible with `STEGASOO-Z:` (zlib)
|
||||
|
||||
### QR Code Generation
|
||||
|
||||
#### CLI Support
|
||||
- `stegasoo generate --rsa --qr key.png` - save RSA key as QR image (PNG/JPG)
|
||||
- `stegasoo generate --rsa --qr-ascii` - print ASCII QR to terminal
|
||||
|
||||
#### API Support
|
||||
- `POST /generate-key-qr` - generate QR from RSA key
|
||||
- Supports `png`, `jpg`, and `ascii` output formats
|
||||
- Uses zstd compression by default
|
||||
|
||||
### Other Changes
|
||||
|
||||
- RSA key size capped at 3072 bits (4096 too large for QR codes)
|
||||
- File auto-expire increased to 10 minutes
|
||||
- Progress bar "candy cane" animation during Argon2 key derivation
|
||||
- Optional API service in Pi setup (with security warning)
|
||||
|
||||
### Summary
|
||||
|
||||
| Metric | v4.1.7 | v4.2.0 | Improvement |
|
||||
|--------|--------|--------|-------------|
|
||||
| Decode (1MB) | ~2.6s | ~0.8s | **70% faster** |
|
||||
| Peak RAM | 211 MB | 107 MB | **50% less** |
|
||||
| Concurrent API | No | Yes | check |
|
||||
| QR Compression | zlib | zstd | **~15% smaller** |
|
||||
|
||||
### Full Changelog
|
||||
See [CHANGELOG.md](CHANGELOG.md) for complete version history.
|
||||
|
||||
54
TODO-4.2.1.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Stegasoo 4.2.1 Plan
|
||||
|
||||
## Bugs
|
||||
- [x] Fix EXIF viewer panel not loading metadata in Web UI
|
||||
- Redesigned with card-based grid layout and categories
|
||||
- Compact styling for better space usage
|
||||
- [x] DCT mode: portrait photos export rotated 90° (EXIF orientation not handled)
|
||||
- Added `_apply_exif_orientation()` to apply EXIF rotation before embedding
|
||||
- [x] DCT mode: add rotation fallback (try as-is, rotate 90°, retry on failure)
|
||||
- Added rotation fallback in `extract_from_dct()` with quick header validation
|
||||
- [x] Rotate tool: use jpegtran for lossless JPEG rotation (preserves DCT stego!)
|
||||
- Web UI rotate tool now uses jpegtran for JPEGs
|
||||
- DCT decode rotation fallback now uses jpegtran for JPEGs
|
||||
- Dynamic UI shows "DCT Safe" for JPEGs, warning for other formats
|
||||
|
||||
## Tools Audit
|
||||
- [x] Web UI tools - full shakedown and fixes
|
||||
- Compress, Rotate, Strip, EXIF viewer all working
|
||||
- Rotate uses jpegtran for lossless JPEG rotation
|
||||
- Compact UI styling
|
||||
- [x] CLI tools - full shakedown and fixes
|
||||
- Fixed encode to output JPEG when carrier is JPEG (was always PNG)
|
||||
- Fixed jpegtran -trim flag destroying DCT stego data
|
||||
- Added compress, rotate, convert tools (matching Web UI)
|
||||
- Rotate uses jpegtran for JPEGs, supports flip-only operations
|
||||
|
||||
## AUR Packages
|
||||
- [x] `stegasoo-cli` - standalone CLI package (no web dependencies)
|
||||
- Created aur-cli/PKGBUILD with [cli,dct,compression] extras only
|
||||
- No flask/gunicorn/fastapi/uvicorn/pyzbar deps
|
||||
- 68MB vs 79MB for full package
|
||||
- [x] `stegasoo-api` - REST API package
|
||||
- Created aur-api/PKGBUILD with [api,cli,compression] extras
|
||||
- Has fastapi/uvicorn, no flask/gunicorn
|
||||
- 74MB package size
|
||||
- Includes systemd service with TLS
|
||||
|
||||
## API Auth Work
|
||||
- [x] API key authentication (simpler than OAuth2 for personal use)
|
||||
- `frontends/api/auth.py` - key generation, hashing, validation
|
||||
- Keys stored in `~/.stegasoo/api_keys.json` (hashed)
|
||||
- `X-API-Key` header for authentication
|
||||
- Auth disabled when no keys configured
|
||||
- [x] TLS with self-signed certificates
|
||||
- Auto-generates certs on first run
|
||||
- CLI: `stegasoo api tls generate`
|
||||
- Certs stored in `~/.stegasoo/certs/`
|
||||
- [x] CLI commands for API management
|
||||
- `stegasoo api keys list/create/delete`
|
||||
- `stegasoo api tls generate/info`
|
||||
- `stegasoo api serve` (starts with TLS by default)
|
||||
|
||||
## API Documentation
|
||||
- [ ] Postman collection (with environment templates)
|
||||
@@ -411,7 +411,7 @@ Create a new set of credentials for steganography operations.
|
||||
| Use PIN | on/off | on | Generate a numeric PIN |
|
||||
| PIN length | 6-9 | 6 | Digits in the PIN |
|
||||
| Use RSA Key | on/off | off | Generate an RSA key pair |
|
||||
| RSA key size | 2048/3072/4096 | 2048 | Key size in bits |
|
||||
| RSA key size | 2048/3072 | 2048 | Key size in bits |
|
||||
|
||||
#### Entropy Calculator
|
||||
|
||||
|
||||
23
aur-api/.SRCINFO
Normal file
@@ -0,0 +1,23 @@
|
||||
pkgbase = stegasoo-api-git
|
||||
pkgdesc = Stegasoo REST API with TLS and API key authentication
|
||||
pkgver = 4.2.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/adlee-was-taken/stegasoo
|
||||
install = stegasoo-api-git.install
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
makedepends = git
|
||||
makedepends = python
|
||||
makedepends = python-build
|
||||
makedepends = python-hatchling
|
||||
depends = python>=3.11
|
||||
depends = zbar
|
||||
optdepends = libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)
|
||||
provides = stegasoo-api
|
||||
conflicts = stegasoo-api
|
||||
conflicts = stegasoo
|
||||
conflicts = stegasoo-git
|
||||
source = stegasoo-api-git::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = stegasoo-api-git
|
||||
109
aur-api/PKGBUILD
Normal file
@@ -0,0 +1,109 @@
|
||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||
pkgname=stegasoo-api-git
|
||||
pkgver=4.2.1
|
||||
pkgrel=1
|
||||
pkgdesc="Stegasoo REST API with TLS and API key authentication"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/adlee-was-taken/stegasoo"
|
||||
license=('MIT')
|
||||
|
||||
# Python 3.11-3.14 supported
|
||||
depends=(
|
||||
'python>=3.11'
|
||||
'zbar' # QR code reading for RSA key extraction
|
||||
)
|
||||
makedepends=(
|
||||
'git'
|
||||
'python'
|
||||
'python-build'
|
||||
'python-hatchling'
|
||||
)
|
||||
optdepends=(
|
||||
'libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)'
|
||||
)
|
||||
provides=('stegasoo-api')
|
||||
conflicts=('stegasoo-api' 'stegasoo' 'stegasoo-git')
|
||||
install=stegasoo-api-git.install
|
||||
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$pkgname"
|
||||
python -m build --wheel --no-isolation
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
|
||||
# Detect Python version for site-packages path
|
||||
local pyver=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
|
||||
# Install to /opt/stegasoo-api with dedicated venv
|
||||
install -dm755 "$pkgdir/opt/stegasoo-api"
|
||||
|
||||
# Create fresh venv in package
|
||||
python -m venv "$pkgdir/opt/stegasoo-api/venv"
|
||||
|
||||
# Install the wheel with API + CLI + compression extras
|
||||
local wheel=$(ls dist/*.whl | head -1)
|
||||
"$pkgdir/opt/stegasoo-api/venv/bin/pip" install --no-cache-dir "${wheel}[api,cli,compression]"
|
||||
|
||||
# Install API frontend (not included in wheel by default)
|
||||
local site_packages="$pkgdir/opt/stegasoo-api/venv/lib/python${pyver}/site-packages"
|
||||
install -dm755 "$site_packages/frontends/api"
|
||||
cp -r frontends/api/*.py "$site_packages/frontends/api/"
|
||||
cp -r frontends/__init__.py "$site_packages/frontends/" 2>/dev/null || true
|
||||
|
||||
# Create temp directory for API
|
||||
install -dm755 "$site_packages/frontends/api/temp_files"
|
||||
|
||||
# Create config directories
|
||||
install -dm755 "$pkgdir/opt/stegasoo-api/config"
|
||||
install -dm700 "$pkgdir/opt/stegasoo-api/certs"
|
||||
|
||||
# Fix shebangs - replace build-time paths with installed paths
|
||||
find "$pkgdir/opt/stegasoo-api/venv/bin" -type f -exec \
|
||||
sed -i "s|$pkgdir/opt/stegasoo-api/venv|/opt/stegasoo-api/venv|g" {} \;
|
||||
|
||||
# Fix pyvenv.cfg
|
||||
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo-api/venv/pyvenv.cfg"
|
||||
|
||||
# Create symlink to /usr/bin
|
||||
install -dm755 "$pkgdir/usr/bin"
|
||||
ln -s /opt/stegasoo-api/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
|
||||
|
||||
# Install license
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
|
||||
# Install docs
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
|
||||
# Install systemd service
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo REST API (HTTPS)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=stegasoo
|
||||
WorkingDirectory=/opt/stegasoo-api/venv/lib/python${pyver}/site-packages/frontends/api
|
||||
Environment="PATH=/opt/stegasoo-api/venv/bin"
|
||||
Environment="HOME=/opt/stegasoo-api"
|
||||
# TLS enabled by default - certs auto-generated on first run
|
||||
# Use: stegasoo api tls generate (to pre-generate certs)
|
||||
# Use: stegasoo api keys create <name> (to create API keys)
|
||||
ExecStart=/opt/stegasoo-api/venv/bin/stegasoo api serve --host 127.0.0.1 --port 8000
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
102
aur-api/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Stegasoo API AUR Package
|
||||
|
||||
REST API server package for programmatic steganography operations. Includes HTTPS support and API key authentication.
|
||||
|
||||
## Installation
|
||||
|
||||
### From AUR (once published)
|
||||
```bash
|
||||
yay -S stegasoo-api-git
|
||||
# or
|
||||
paru -S stegasoo-api-git
|
||||
```
|
||||
|
||||
### Manual build
|
||||
```bash
|
||||
git clone https://aur.archlinux.org/stegasoo-api-git.git
|
||||
cd stegasoo-api-git
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## What Gets Installed
|
||||
|
||||
- `/opt/stegasoo-api/venv/` - Self-contained Python venv with API dependencies
|
||||
- `/opt/stegasoo-api/config/` - API key storage
|
||||
- `/opt/stegasoo-api/certs/` - TLS certificates
|
||||
- `/usr/bin/stegasoo` - CLI executable
|
||||
- `/usr/lib/systemd/system/stegasoo-api.service` - Systemd service
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Create an API key
|
||||
sudo -u stegasoo stegasoo api keys create mykey
|
||||
|
||||
# 2. Start the service
|
||||
sudo systemctl enable --now stegasoo-api
|
||||
|
||||
# 3. Test the API
|
||||
curl -k -H "X-API-Key: YOUR_KEY" https://localhost:8000/
|
||||
```
|
||||
|
||||
## Service Details
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Port | 8000 |
|
||||
| Protocol | HTTPS (self-signed cert auto-generated) |
|
||||
| API Docs | https://localhost:8000/docs |
|
||||
| OpenAPI | https://localhost:8000/openapi.json |
|
||||
|
||||
## API Key Management
|
||||
|
||||
```bash
|
||||
# List all keys
|
||||
stegasoo api keys list
|
||||
|
||||
# Create a new key
|
||||
sudo -u stegasoo stegasoo api keys create <name>
|
||||
|
||||
# Revoke a key
|
||||
sudo -u stegasoo stegasoo api keys revoke <name>
|
||||
```
|
||||
|
||||
## TLS Configuration
|
||||
|
||||
```bash
|
||||
# View current certificate info
|
||||
stegasoo api tls info
|
||||
|
||||
# Generate new self-signed certificate
|
||||
sudo -u stegasoo stegasoo api tls generate
|
||||
|
||||
# Use custom certificates (edit service)
|
||||
sudo systemctl edit stegasoo-api
|
||||
# Add:
|
||||
# [Service]
|
||||
# ExecStart=
|
||||
# ExecStart=/opt/stegasoo-api/venv/bin/stegasoo api serve \
|
||||
# --host 0.0.0.0 --port 8000 \
|
||||
# --cert /path/to/cert.pem --key /path/to/key.pem
|
||||
```
|
||||
|
||||
## Manual Run (without systemd)
|
||||
|
||||
```bash
|
||||
# Development mode (auto-reload)
|
||||
/opt/stegasoo-api/venv/bin/stegasoo api serve --reload
|
||||
|
||||
# Production mode
|
||||
/opt/stegasoo-api/venv/bin/stegasoo api serve --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## For Web UI
|
||||
|
||||
Install the full package instead:
|
||||
```bash
|
||||
yay -S stegasoo-git
|
||||
```
|
||||
|
||||
## Maintainer
|
||||
|
||||
Aaron D. Lee
|
||||
63
aur-api/stegasoo-api-git.install
Normal file
@@ -0,0 +1,63 @@
|
||||
post_install() {
|
||||
# Create stegasoo system user if it doesn't exist
|
||||
if ! getent passwd stegasoo >/dev/null; then
|
||||
useradd -r -s /usr/bin/nologin -d /opt/stegasoo-api stegasoo
|
||||
echo "Created system user 'stegasoo'"
|
||||
fi
|
||||
|
||||
# Set ownership of directories
|
||||
chown -R stegasoo:stegasoo /opt/stegasoo-api/config 2>/dev/null || true
|
||||
chown -R stegasoo:stegasoo /opt/stegasoo-api/certs 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo " Stegasoo API installed successfully!"
|
||||
echo "==============================================="
|
||||
echo ""
|
||||
echo "-----------------------------------------------"
|
||||
echo " Quick Start"
|
||||
echo "-----------------------------------------------"
|
||||
echo " 1. Create an API key:"
|
||||
echo " sudo -u stegasoo stegasoo api keys create mykey"
|
||||
echo ""
|
||||
echo " 2. Start the API server:"
|
||||
echo " sudo systemctl start stegasoo-api"
|
||||
echo " sudo systemctl enable stegasoo-api # auto-start"
|
||||
echo ""
|
||||
echo " 3. Access the API:"
|
||||
echo " curl -k -H 'X-API-Key: YOUR_KEY' https://localhost:8000/"
|
||||
echo ""
|
||||
echo "-----------------------------------------------"
|
||||
echo " Service Details"
|
||||
echo "-----------------------------------------------"
|
||||
echo " Port: 8000 (HTTPS by default)"
|
||||
echo " Docs: https://localhost:8000/docs"
|
||||
echo " Status: sudo systemctl status stegasoo-api"
|
||||
echo ""
|
||||
echo "-----------------------------------------------"
|
||||
echo " Management Commands"
|
||||
echo "-----------------------------------------------"
|
||||
echo " stegasoo api keys list # List API keys"
|
||||
echo " stegasoo api keys create X # Create new key"
|
||||
echo " stegasoo api tls generate # Generate TLS certs"
|
||||
echo " stegasoo api tls info # Show certificate info"
|
||||
echo " stegasoo api serve --help # Server options"
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo ""
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
post_install
|
||||
}
|
||||
|
||||
pre_remove() {
|
||||
# Stop service if running
|
||||
systemctl stop stegasoo-api 2>/dev/null || true
|
||||
}
|
||||
|
||||
post_remove() {
|
||||
echo "Stegasoo API removed."
|
||||
echo "User 'stegasoo' and config in /opt/stegasoo-api were not removed."
|
||||
echo "To remove: userdel stegasoo && rm -rf /opt/stegasoo-api"
|
||||
}
|
||||
22
aur-api/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Test build the AUR API package locally
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== Cleaning previous builds ==="
|
||||
rm -rf stegasoo-api-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
|
||||
|
||||
echo "=== Generating .SRCINFO ==="
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
echo "=== Building package ==="
|
||||
makepkg -sf
|
||||
|
||||
echo "=== Package built ==="
|
||||
ls -la *.pkg.tar.zst
|
||||
|
||||
echo ""
|
||||
echo "To install: sudo pacman -U stegasoo-api-git-*.pkg.tar.zst"
|
||||
echo "To test: makepkg -si"
|
||||
22
aur-cli/.SRCINFO
Normal file
@@ -0,0 +1,22 @@
|
||||
pkgbase = stegasoo-cli-git
|
||||
pkgdesc = Secure steganography CLI with hybrid photo + passphrase + PIN authentication
|
||||
pkgver = 4.2.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/adlee-was-taken/stegasoo
|
||||
install = stegasoo-cli-git.install
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
makedepends = git
|
||||
makedepends = python
|
||||
makedepends = python-build
|
||||
makedepends = python-hatchling
|
||||
depends = python>=3.11
|
||||
optdepends = libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)
|
||||
provides = stegasoo-cli
|
||||
conflicts = stegasoo-cli
|
||||
conflicts = stegasoo
|
||||
conflicts = stegasoo-git
|
||||
source = stegasoo-cli-git::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = stegasoo-cli-git
|
||||
69
aur-cli/PKGBUILD
Normal file
@@ -0,0 +1,69 @@
|
||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||
pkgname=stegasoo-cli-git
|
||||
pkgver=4.2.1
|
||||
pkgrel=1
|
||||
pkgdesc="Secure steganography CLI with hybrid photo + passphrase + PIN authentication"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/adlee-was-taken/stegasoo"
|
||||
license=('MIT')
|
||||
|
||||
# Python 3.11-3.14 supported (uses jpeglib for modern Python compatibility)
|
||||
depends=(
|
||||
'python>=3.11'
|
||||
)
|
||||
makedepends=(
|
||||
'git'
|
||||
'python'
|
||||
'python-build'
|
||||
'python-hatchling'
|
||||
)
|
||||
optdepends=(
|
||||
'libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)'
|
||||
)
|
||||
provides=('stegasoo-cli')
|
||||
conflicts=('stegasoo-cli' 'stegasoo' 'stegasoo-git')
|
||||
install=stegasoo-cli-git.install
|
||||
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$pkgname"
|
||||
python -m build --wheel --no-isolation
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
|
||||
# Install to /opt/stegasoo-cli with dedicated venv
|
||||
install -dm755 "$pkgdir/opt/stegasoo-cli"
|
||||
|
||||
# Create fresh venv in package
|
||||
python -m venv "$pkgdir/opt/stegasoo-cli/venv"
|
||||
|
||||
# Install the wheel with CLI + DCT + compression extras (no web/api)
|
||||
local wheel=$(ls dist/*.whl | head -1)
|
||||
"$pkgdir/opt/stegasoo-cli/venv/bin/pip" install --no-cache-dir "${wheel}[cli,dct,compression]"
|
||||
|
||||
# Fix shebangs - replace build-time paths with installed paths
|
||||
find "$pkgdir/opt/stegasoo-cli/venv/bin" -type f -exec \
|
||||
sed -i "s|$pkgdir/opt/stegasoo-cli/venv|/opt/stegasoo-cli/venv|g" {} \;
|
||||
|
||||
# Fix pyvenv.cfg
|
||||
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo-cli/venv/pyvenv.cfg"
|
||||
|
||||
# Create symlink to /usr/bin
|
||||
install -dm755 "$pkgdir/usr/bin"
|
||||
ln -s /opt/stegasoo-cli/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
|
||||
|
||||
# Install license
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
|
||||
# Install docs
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
}
|
||||
62
aur-cli/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Stegasoo CLI AUR Package
|
||||
|
||||
Lightweight CLI-only package for steganography operations. No web UI or API server.
|
||||
|
||||
## Installation
|
||||
|
||||
### From AUR (once published)
|
||||
```bash
|
||||
yay -S stegasoo-cli-git
|
||||
# or
|
||||
paru -S stegasoo-cli-git
|
||||
```
|
||||
|
||||
### Manual build
|
||||
```bash
|
||||
git clone https://aur.archlinux.org/stegasoo-cli-git.git
|
||||
cd stegasoo-cli-git
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## What Gets Installed
|
||||
|
||||
- `/opt/stegasoo-cli/venv/` - Self-contained Python venv with CLI dependencies only
|
||||
- `/usr/bin/stegasoo` - CLI executable
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Show all commands
|
||||
stegasoo --help
|
||||
|
||||
# Generate credentials (passphrase + PIN)
|
||||
stegasoo generate
|
||||
stegasoo generate --words 5 --pin-length 8
|
||||
|
||||
# Generate with RSA keys and QR codes
|
||||
stegasoo generate --rsa --qr-ascii
|
||||
|
||||
# Encode a message
|
||||
stegasoo encode -i carrier.jpg -r reference.jpg -m "secret message" \
|
||||
-P "word1 word2 word3 word4" -p 123456
|
||||
|
||||
# Decode a message
|
||||
stegasoo decode -i encoded.png -r reference.jpg \
|
||||
-P "word1 word2 word3 word4" -p 123456
|
||||
|
||||
# Image tools
|
||||
stegasoo tools --help
|
||||
stegasoo tools compress image.png
|
||||
stegasoo tools rotate image.jpg 90
|
||||
```
|
||||
|
||||
## For Web UI or REST API
|
||||
|
||||
Install the full package instead:
|
||||
```bash
|
||||
yay -S stegasoo-git
|
||||
```
|
||||
|
||||
## Maintainer
|
||||
|
||||
Aaron D. Lee
|
||||
20
aur-cli/stegasoo-cli-git.install
Normal file
@@ -0,0 +1,20 @@
|
||||
post_install() {
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo " Stegasoo CLI installed successfully!"
|
||||
echo "==============================================="
|
||||
echo ""
|
||||
echo "Usage:"
|
||||
echo " stegasoo --help # Show all commands"
|
||||
echo " stegasoo generate # Generate passphrase + PIN"
|
||||
echo " stegasoo encode ... # Hide data in an image"
|
||||
echo " stegasoo decode ... # Extract hidden data"
|
||||
echo " stegasoo tools --help # Image tools (compress, etc.)"
|
||||
echo ""
|
||||
echo "For web UI or REST API, install stegasoo-git instead."
|
||||
echo ""
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
post_install
|
||||
}
|
||||
22
aur-cli/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Test build the AUR CLI package locally
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== Cleaning previous builds ==="
|
||||
rm -rf stegasoo-cli-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
|
||||
|
||||
echo "=== Generating .SRCINFO ==="
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
echo "=== Building package ==="
|
||||
makepkg -sf
|
||||
|
||||
echo "=== Package built ==="
|
||||
ls -la *.pkg.tar.zst
|
||||
|
||||
echo ""
|
||||
echo "To install: sudo pacman -U stegasoo-cli-git-*.pkg.tar.zst"
|
||||
echo "To test: makepkg -si"
|
||||
120
aur/PKGBUILD
Normal file
@@ -0,0 +1,120 @@
|
||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||
pkgname=stegasoo-git
|
||||
pkgver=4.2.1
|
||||
pkgrel=1
|
||||
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/adlee-was-taken/stegasoo"
|
||||
license=('MIT')
|
||||
|
||||
# Python 3.11-3.14 supported (uses jpeglib for modern Python compatibility)
|
||||
depends=(
|
||||
'python>=3.11'
|
||||
'zbar' # QR code reading for Web UI
|
||||
)
|
||||
makedepends=(
|
||||
'git'
|
||||
'python'
|
||||
'python-build'
|
||||
'python-hatchling'
|
||||
)
|
||||
provides=('stegasoo')
|
||||
conflicts=('stegasoo')
|
||||
install=stegasoo-git.install
|
||||
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$pkgname"
|
||||
python -m build --wheel --no-isolation
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
|
||||
# Detect Python version for site-packages path
|
||||
local pyver=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
|
||||
# Install to /opt/stegasoo with dedicated venv
|
||||
install -dm755 "$pkgdir/opt/stegasoo"
|
||||
|
||||
# Create fresh venv in package
|
||||
python -m venv "$pkgdir/opt/stegasoo/venv"
|
||||
|
||||
# Install the wheel with all extras
|
||||
local wheel=$(ls dist/*.whl | head -1)
|
||||
"$pkgdir/opt/stegasoo/venv/bin/pip" install --no-cache-dir "${wheel}[all]"
|
||||
|
||||
# Install frontends (not included in wheel)
|
||||
local site_packages="$pkgdir/opt/stegasoo/venv/lib/python${pyver}/site-packages"
|
||||
cp -r frontends "$site_packages/"
|
||||
|
||||
# Create writable directories for stegasoo user
|
||||
install -dm755 "$pkgdir/opt/stegasoo/venv/var/app-instance"
|
||||
install -dm755 "$site_packages/frontends/web/temp_files"
|
||||
install -dm755 "$site_packages/frontends/api/temp_files"
|
||||
|
||||
# Fix shebangs - replace build-time paths with installed paths
|
||||
find "$pkgdir/opt/stegasoo/venv/bin" -type f -exec \
|
||||
sed -i "s|$pkgdir/opt/stegasoo/venv|/opt/stegasoo/venv|g" {} \;
|
||||
|
||||
# Fix pyvenv.cfg
|
||||
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo/venv/pyvenv.cfg"
|
||||
|
||||
# Create symlinks to /usr/bin
|
||||
install -dm755 "$pkgdir/usr/bin"
|
||||
ln -s /opt/stegasoo/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
|
||||
|
||||
# Install license
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
|
||||
# Install docs
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
|
||||
# Install systemd service files
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-web.service" <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo Web UI
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=stegasoo
|
||||
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/web
|
||||
Environment="PATH=/opt/stegasoo/venv/bin"
|
||||
ExecStart=/opt/stegasoo/venv/bin/gunicorn -b 127.0.0.1:5000 app:app
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo REST API (HTTPS)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=stegasoo
|
||||
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/api
|
||||
Environment="PATH=/opt/stegasoo/venv/bin"
|
||||
Environment="HOME=/opt/stegasoo"
|
||||
# TLS enabled by default - certs auto-generated on first run
|
||||
# Use stegasoo api tls generate to pre-generate certs
|
||||
# Use stegasoo api keys create <name> to create API keys
|
||||
ExecStart=/opt/stegasoo/venv/bin/stegasoo api serve --host 127.0.0.1 --port 8000
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
90
aur/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Stegasoo AUR Package
|
||||
|
||||
Full package with CLI, Web UI, and REST API. Supports Python 3.11-3.14.
|
||||
|
||||
## Installation
|
||||
|
||||
### From AUR (once published)
|
||||
```bash
|
||||
yay -S stegasoo-git
|
||||
# or
|
||||
paru -S stegasoo-git
|
||||
```
|
||||
|
||||
### Manual build
|
||||
```bash
|
||||
git clone https://aur.archlinux.org/stegasoo-git.git
|
||||
cd stegasoo-git
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## What Gets Installed
|
||||
|
||||
- `/opt/stegasoo/venv/` - Self-contained Python venv with all dependencies
|
||||
- `/usr/bin/stegasoo` - CLI symlink
|
||||
- `/usr/lib/systemd/system/stegasoo-web.service` - Web UI service (port 5000)
|
||||
- `/usr/lib/systemd/system/stegasoo-api.service` - REST API service (port 8000, HTTPS)
|
||||
|
||||
## Optional Dependencies
|
||||
|
||||
```bash
|
||||
# QR code reading from webcam/images (recommended)
|
||||
sudo pacman -S zbar
|
||||
```
|
||||
|
||||
All other dependencies are bundled in the venv.
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI
|
||||
```bash
|
||||
stegasoo --help
|
||||
stegasoo generate # Generate passphrase + PIN
|
||||
stegasoo generate --rsa --qr-ascii # With RSA keys and QR codes
|
||||
stegasoo encode -i carrier.jpg -r reference.jpg -m "secret" -P "word1 word2 word3 word4" -p 123456
|
||||
stegasoo decode -i encoded.png -r reference.jpg -P "word1 word2 word3 word4" -p 123456
|
||||
```
|
||||
|
||||
### Web UI
|
||||
```bash
|
||||
# Start service (user created automatically on install)
|
||||
sudo systemctl enable --now stegasoo-web
|
||||
|
||||
# Access at http://localhost:5000
|
||||
```
|
||||
|
||||
### REST API
|
||||
```bash
|
||||
# Create an API key first
|
||||
sudo -u stegasoo stegasoo api keys create mykey
|
||||
|
||||
# Start service (HTTPS with auto-generated self-signed cert)
|
||||
sudo systemctl enable --now stegasoo-api
|
||||
|
||||
# Access docs at https://localhost:8000/docs
|
||||
curl -k -H "X-API-Key: YOUR_KEY" https://localhost:8000/
|
||||
```
|
||||
|
||||
### HTTPS Configuration
|
||||
|
||||
The API uses HTTPS by default with auto-generated self-signed certificates.
|
||||
|
||||
```bash
|
||||
# View certificate info
|
||||
stegasoo api tls info
|
||||
|
||||
# Generate new self-signed cert
|
||||
sudo -u stegasoo stegasoo api tls generate
|
||||
|
||||
# Use custom certs (edit service file)
|
||||
sudo systemctl edit stegasoo-api
|
||||
```
|
||||
|
||||
## Alternative Packages
|
||||
|
||||
- `stegasoo-cli-git` - CLI only, minimal dependencies
|
||||
- `stegasoo-api-git` - CLI + REST API, no web UI
|
||||
|
||||
## Maintainer
|
||||
|
||||
Aaron D. Lee
|
||||
75
aur/stegasoo-git.install
Normal file
@@ -0,0 +1,75 @@
|
||||
post_install() {
|
||||
# Create stegasoo system user if it doesn't exist
|
||||
if ! getent passwd stegasoo >/dev/null; then
|
||||
useradd -r -s /usr/bin/nologin -d /opt/stegasoo stegasoo
|
||||
echo "Created system user 'stegasoo'"
|
||||
fi
|
||||
|
||||
# Set ownership of instance directory for Flask
|
||||
chown -R stegasoo:stegasoo /opt/stegasoo/venv/var/app-instance 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo " Stegasoo installed successfully!"
|
||||
echo "==============================================="
|
||||
echo ""
|
||||
echo "CLI usage:"
|
||||
echo " stegasoo --help"
|
||||
echo " stegasoo generate # Generate credentials"
|
||||
echo " stegasoo encode # Encode a message"
|
||||
echo " stegasoo decode # Decode a message"
|
||||
echo ""
|
||||
echo "-----------------------------------------------"
|
||||
echo " Web UI Service"
|
||||
echo "-----------------------------------------------"
|
||||
echo " Port: 5000 (HTTP)"
|
||||
echo " Start: sudo systemctl start stegasoo-web"
|
||||
echo " Enable: sudo systemctl enable stegasoo-web"
|
||||
echo " Status: sudo systemctl status stegasoo-web"
|
||||
echo " Access: http://localhost:5000"
|
||||
echo ""
|
||||
echo "-----------------------------------------------"
|
||||
echo " REST API Service"
|
||||
echo "-----------------------------------------------"
|
||||
echo " Port: 8000 (HTTPS by default)"
|
||||
echo " Start: sudo systemctl start stegasoo-api"
|
||||
echo " Enable: sudo systemctl enable stegasoo-api"
|
||||
echo " Status: sudo systemctl status stegasoo-api"
|
||||
echo " Access: https://localhost:8000"
|
||||
echo ""
|
||||
echo "-----------------------------------------------"
|
||||
echo " HTTPS Configuration"
|
||||
echo "-----------------------------------------------"
|
||||
echo " The API generates self-signed certs on first run."
|
||||
echo " To pre-generate or use custom certificates:"
|
||||
echo ""
|
||||
echo " # Generate self-signed certs"
|
||||
echo " sudo -u stegasoo stegasoo api tls generate"
|
||||
echo ""
|
||||
echo " # Use custom certs (edit the service file)"
|
||||
echo " sudo systemctl edit stegasoo-api"
|
||||
echo " # Add: ExecStart= with --cert and --key flags"
|
||||
echo ""
|
||||
echo " # Create API keys for authentication"
|
||||
echo " sudo -u stegasoo stegasoo api keys create <name>"
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo ""
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
post_install
|
||||
}
|
||||
|
||||
pre_remove() {
|
||||
# Stop services if running
|
||||
systemctl stop stegasoo-web 2>/dev/null || true
|
||||
systemctl stop stegasoo-api 2>/dev/null || true
|
||||
}
|
||||
|
||||
post_remove() {
|
||||
# Optionally remove the stegasoo user
|
||||
# userdel stegasoo 2>/dev/null || true
|
||||
echo "Stegasoo removed. User 'stegasoo' was not removed."
|
||||
echo "To remove: userdel stegasoo"
|
||||
}
|
||||
22
aur/test-build.sh
Executable file
@@ -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"
|
||||
BIN
data/WebUI.webp
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 16 KiB |
BIN
data/WebUI_Recover.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 16 KiB |
BIN
data/WebUI_Tools.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
@@ -33,7 +33,8 @@ RUN pip install --no-cache-dir \
|
||||
argon2-cffi>=23.0.0 \
|
||||
pillow>=10.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)
|
||||
RUN pip install --no-cache-dir \
|
||||
@@ -48,9 +49,9 @@ RUN pip install --no-cache-dir \
|
||||
lz4>=4.0.0
|
||||
|
||||
# Verify key packages work
|
||||
RUN python -c "import jpegio; import scipy; import numpy; print('jpegio + scipy + numpy OK')"
|
||||
RUN python -c "import jpegio; import scipy; import numpy; import zstandard; print('jpegio + scipy + numpy + zstd OK')"
|
||||
|
||||
# Label for tracking
|
||||
LABEL org.opencontainers.image.title="Stegasoo Base"
|
||||
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
|
||||
LABEL org.opencontainers.image.version="4.0.0"
|
||||
LABEL org.opencontainers.image.version="4.2.1"
|
||||
|
||||
@@ -126,7 +126,7 @@ Quick reference for all Jinja2 templates in `frontends/web/templates/`.
|
||||
- `use_pin` - checkbox
|
||||
- `pin_length` - PIN digits (6-9)
|
||||
- `use_rsa` - checkbox
|
||||
- `rsa_bits` - key size (2048/3072/4096)
|
||||
- `rsa_bits` - key size (2048/3072)
|
||||
|
||||
**Output panels:**
|
||||
- Passphrase display
|
||||
|
||||
0
frontends/__init__.py
Normal file
0
frontends/api/__init__.py
Normal file
256
frontends/api/auth.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
API Key Authentication for Stegasoo REST API.
|
||||
|
||||
Provides simple API key authentication with hashed key storage.
|
||||
Keys can be stored in user config (~/.stegasoo/) or project config (./config/).
|
||||
|
||||
Usage:
|
||||
from .auth import require_api_key, get_api_key_status
|
||||
|
||||
@app.get("/protected")
|
||||
async def protected_endpoint(api_key: str = Depends(require_api_key)):
|
||||
return {"status": "authenticated"}
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
# API key header name
|
||||
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
# Config locations
|
||||
USER_CONFIG_DIR = Path.home() / ".stegasoo"
|
||||
PROJECT_CONFIG_DIR = Path("./config")
|
||||
|
||||
# Key file name
|
||||
API_KEYS_FILE = "api_keys.json"
|
||||
|
||||
# Environment variable for API key (alternative to file)
|
||||
API_KEY_ENV_VAR = "STEGASOO_API_KEY"
|
||||
|
||||
|
||||
def _hash_key(key: str) -> str:
|
||||
"""Hash an API key for storage."""
|
||||
return hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
|
||||
def _get_keys_file(location: str = "user") -> Path:
|
||||
"""Get path to API keys file."""
|
||||
if location == "project":
|
||||
return PROJECT_CONFIG_DIR / API_KEYS_FILE
|
||||
return USER_CONFIG_DIR / API_KEYS_FILE
|
||||
|
||||
|
||||
def _load_keys(location: str = "user") -> dict:
|
||||
"""Load API keys from config file."""
|
||||
keys_file = _get_keys_file(location)
|
||||
if keys_file.exists():
|
||||
try:
|
||||
with open(keys_file) as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {"keys": [], "enabled": True}
|
||||
return {"keys": [], "enabled": True}
|
||||
|
||||
|
||||
def _save_keys(data: dict, location: str = "user") -> None:
|
||||
"""Save API keys to config file."""
|
||||
keys_file = _get_keys_file(location)
|
||||
keys_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(keys_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
# Secure permissions (owner read/write only)
|
||||
os.chmod(keys_file, 0o600)
|
||||
|
||||
|
||||
def generate_api_key() -> str:
|
||||
"""Generate a new API key."""
|
||||
# Format: stegasoo_XXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
# 32 bytes = 256 bits of entropy
|
||||
random_part = secrets.token_hex(16)
|
||||
return f"stegasoo_{random_part[:4]}_{random_part[4:]}"
|
||||
|
||||
|
||||
def add_api_key(name: str, location: str = "user") -> str:
|
||||
"""
|
||||
Generate and store a new API key.
|
||||
|
||||
Args:
|
||||
name: Descriptive name for the key (e.g., "laptop", "automation")
|
||||
location: "user" or "project"
|
||||
|
||||
Returns:
|
||||
The generated API key (only shown once!)
|
||||
"""
|
||||
key = generate_api_key()
|
||||
key_hash = _hash_key(key)
|
||||
|
||||
data = _load_keys(location)
|
||||
|
||||
# Check for duplicate name
|
||||
for existing in data["keys"]:
|
||||
if existing["name"] == name:
|
||||
raise ValueError(f"Key with name '{name}' already exists")
|
||||
|
||||
data["keys"].append({
|
||||
"name": name,
|
||||
"hash": key_hash,
|
||||
"created": __import__("datetime").datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
_save_keys(data, location)
|
||||
|
||||
return key
|
||||
|
||||
|
||||
def remove_api_key(name: str, location: str = "user") -> bool:
|
||||
"""
|
||||
Remove an API key by name.
|
||||
|
||||
Returns:
|
||||
True if key was found and removed, False otherwise
|
||||
"""
|
||||
data = _load_keys(location)
|
||||
original_count = len(data["keys"])
|
||||
|
||||
data["keys"] = [k for k in data["keys"] if k["name"] != name]
|
||||
|
||||
if len(data["keys"]) < original_count:
|
||||
_save_keys(data, location)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def list_api_keys(location: str = "user") -> list[dict]:
|
||||
"""
|
||||
List all API keys (names and creation dates, not actual keys).
|
||||
"""
|
||||
data = _load_keys(location)
|
||||
return [{"name": k["name"], "created": k.get("created", "unknown")} for k in data["keys"]]
|
||||
|
||||
|
||||
def set_auth_enabled(enabled: bool, location: str = "user") -> None:
|
||||
"""Enable or disable API key authentication."""
|
||||
data = _load_keys(location)
|
||||
data["enabled"] = enabled
|
||||
_save_keys(data, location)
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
"""Check if API key authentication is enabled."""
|
||||
# Check project config first, then user config
|
||||
for location in ["project", "user"]:
|
||||
data = _load_keys(location)
|
||||
if "enabled" in data:
|
||||
return data["enabled"]
|
||||
|
||||
# Default: enabled if any keys exist
|
||||
return bool(get_all_key_hashes())
|
||||
|
||||
|
||||
def get_all_key_hashes() -> set[str]:
|
||||
"""Get all valid API key hashes from all sources."""
|
||||
hashes = set()
|
||||
|
||||
# Check environment variable first
|
||||
env_key = os.environ.get(API_KEY_ENV_VAR)
|
||||
if env_key:
|
||||
hashes.add(_hash_key(env_key))
|
||||
|
||||
# Check project and user configs
|
||||
for location in ["project", "user"]:
|
||||
data = _load_keys(location)
|
||||
for key_entry in data.get("keys", []):
|
||||
if "hash" in key_entry:
|
||||
hashes.add(key_entry["hash"])
|
||||
|
||||
return hashes
|
||||
|
||||
|
||||
def validate_api_key(key: str) -> bool:
|
||||
"""Validate an API key against stored hashes."""
|
||||
if not key:
|
||||
return False
|
||||
|
||||
key_hash = _hash_key(key)
|
||||
valid_hashes = get_all_key_hashes()
|
||||
|
||||
return key_hash in valid_hashes
|
||||
|
||||
|
||||
def get_api_key_status() -> dict:
|
||||
"""Get current API key authentication status."""
|
||||
user_keys = list_api_keys("user")
|
||||
project_keys = list_api_keys("project")
|
||||
env_configured = bool(os.environ.get(API_KEY_ENV_VAR))
|
||||
|
||||
total_keys = len(user_keys) + len(project_keys) + (1 if env_configured else 0)
|
||||
|
||||
return {
|
||||
"enabled": is_auth_enabled(),
|
||||
"total_keys": total_keys,
|
||||
"user_keys": len(user_keys),
|
||||
"project_keys": len(project_keys),
|
||||
"env_configured": env_configured,
|
||||
"keys": {
|
||||
"user": user_keys,
|
||||
"project": project_keys,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# FastAPI dependency for API key authentication
|
||||
async def require_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> str:
|
||||
"""
|
||||
FastAPI dependency that requires a valid API key.
|
||||
|
||||
Usage:
|
||||
@app.get("/protected")
|
||||
async def endpoint(key: str = Depends(require_api_key)):
|
||||
...
|
||||
"""
|
||||
# Check if auth is enabled
|
||||
if not is_auth_enabled():
|
||||
return "auth_disabled"
|
||||
|
||||
# No keys configured = auth disabled
|
||||
if not get_all_key_hashes():
|
||||
return "no_keys_configured"
|
||||
|
||||
# Validate the provided key
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="API key required. Provide X-API-Key header.",
|
||||
headers={"WWW-Authenticate": "ApiKey"},
|
||||
)
|
||||
|
||||
if not validate_api_key(api_key):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Invalid API key.",
|
||||
)
|
||||
|
||||
return api_key
|
||||
|
||||
|
||||
async def optional_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> Optional[str]:
|
||||
"""
|
||||
FastAPI dependency that optionally validates API key.
|
||||
|
||||
Returns the key if valid, None if not provided or invalid.
|
||||
Doesn't raise exceptions - useful for endpoints that work
|
||||
with or without auth.
|
||||
"""
|
||||
if api_key and validate_api_key(api_key):
|
||||
return api_key
|
||||
return None
|
||||
@@ -1,10 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stegasoo REST API (v4.0.0)
|
||||
Stegasoo REST API (v4.2.1)
|
||||
|
||||
FastAPI-based REST API for steganography operations.
|
||||
Supports both text messages and file embedding.
|
||||
|
||||
CHANGES in v4.2.1:
|
||||
- API key authentication (X-API-Key header)
|
||||
- TLS support with self-signed certificates
|
||||
- /auth/* endpoints for key management
|
||||
|
||||
CHANGES in v4.2.0:
|
||||
- Async encode/decode operations (run in thread pool)
|
||||
- Server can handle concurrent requests without blocking
|
||||
|
||||
CHANGES in v4.0.0:
|
||||
- Added channel key support for deployment/group isolation
|
||||
- New /channel endpoints for key management
|
||||
@@ -21,15 +30,38 @@ NEW in v3.0: LSB and DCT embedding modes.
|
||||
NEW in v3.0.1: DCT color mode and JPEG output format.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import sys
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# API Key Authentication
|
||||
try:
|
||||
from .auth import (
|
||||
require_api_key,
|
||||
get_api_key_status,
|
||||
add_api_key,
|
||||
remove_api_key,
|
||||
list_api_keys,
|
||||
is_auth_enabled,
|
||||
)
|
||||
except ImportError:
|
||||
# When running directly (not as package)
|
||||
from auth import (
|
||||
require_api_key,
|
||||
get_api_key_status,
|
||||
add_api_key,
|
||||
remove_api_key,
|
||||
list_api_keys,
|
||||
is_auth_enabled,
|
||||
)
|
||||
|
||||
# Add parent to path for development
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
@@ -68,13 +100,20 @@ from stegasoo.constants import (
|
||||
try:
|
||||
from stegasoo.qr_utils import (
|
||||
extract_key_from_qr,
|
||||
generate_qr_ascii,
|
||||
generate_qr_code,
|
||||
has_qr_read,
|
||||
has_qr_write,
|
||||
)
|
||||
|
||||
HAS_QR_READ = has_qr_read()
|
||||
HAS_QR_WRITE = has_qr_write()
|
||||
except ImportError:
|
||||
HAS_QR_READ = False
|
||||
HAS_QR_WRITE = False
|
||||
extract_key_from_qr = None
|
||||
generate_qr_code = None
|
||||
generate_qr_ascii = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -344,6 +383,23 @@ class ChannelSetRequest(BaseModel):
|
||||
location: str = Field(default="user", description="'user' or 'project'")
|
||||
|
||||
|
||||
class AuthStatusResponse(BaseModel):
|
||||
"""Response for API key authentication status."""
|
||||
|
||||
enabled: bool = Field(description="Whether API key auth is enabled")
|
||||
total_keys: int = Field(description="Total number of configured API keys")
|
||||
user_keys: int = Field(description="Keys in user config")
|
||||
project_keys: int = Field(description="Keys in project config")
|
||||
env_configured: bool = Field(description="Whether env var key is set")
|
||||
|
||||
|
||||
class AuthKeyInfo(BaseModel):
|
||||
"""Info about a single API key (not the actual key)."""
|
||||
|
||||
name: str
|
||||
created: str
|
||||
|
||||
|
||||
class ModesResponse(BaseModel):
|
||||
"""Response showing available embedding modes."""
|
||||
|
||||
@@ -357,6 +413,7 @@ class StatusResponse(BaseModel):
|
||||
version: str
|
||||
has_argon2: bool
|
||||
has_qrcode_read: bool
|
||||
has_qrcode_write: bool # v4.2.0: QR generation capability
|
||||
has_dct: bool
|
||||
max_payload_kb: int
|
||||
available_modes: list[str]
|
||||
@@ -372,6 +429,32 @@ class QrExtractResponse(BaseModel):
|
||||
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):
|
||||
"""Request to check if payload will fit."""
|
||||
|
||||
@@ -436,6 +519,27 @@ def _get_channel_info(channel_key: str | None) -> tuple[str, str | None]:
|
||||
return info["mode"], info.get("fingerprint")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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
|
||||
# ============================================================================
|
||||
@@ -469,6 +573,7 @@ async def root():
|
||||
version=__version__,
|
||||
has_argon2=has_argon2(),
|
||||
has_qrcode_read=HAS_QR_READ,
|
||||
has_qrcode_write=HAS_QR_WRITE,
|
||||
has_dct=has_dct_support(),
|
||||
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
|
||||
available_modes=available_modes,
|
||||
@@ -552,6 +657,7 @@ async def api_channel_status(
|
||||
|
||||
@app.post("/channel/generate", response_model=ChannelGenerateResponse)
|
||||
async def api_channel_generate(
|
||||
_: str = Depends(require_api_key),
|
||||
save: bool = Query(False, description="Save to user config"),
|
||||
save_project: bool = Query(False, description="Save to project config"),
|
||||
):
|
||||
@@ -590,7 +696,7 @@ async def api_channel_generate(
|
||||
|
||||
|
||||
@app.post("/channel/set")
|
||||
async def api_channel_set(request: ChannelSetRequest):
|
||||
async def api_channel_set(request: ChannelSetRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Set/save a channel key to config.
|
||||
|
||||
@@ -616,6 +722,7 @@ async def api_channel_set(request: ChannelSetRequest):
|
||||
|
||||
@app.delete("/channel")
|
||||
async def api_channel_clear(
|
||||
_: str = Depends(require_api_key),
|
||||
location: str = Query("user", description="'user', 'project', or 'all'")
|
||||
):
|
||||
"""
|
||||
@@ -642,8 +749,98 @@ async def api_channel_clear(
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ROUTES - AUTHENTICATION (v4.2.1)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@app.get("/auth/status", response_model=AuthStatusResponse)
|
||||
async def api_auth_status():
|
||||
"""
|
||||
Get API key authentication status.
|
||||
|
||||
v4.2.1: New endpoint for auth status.
|
||||
Returns whether auth is enabled and key counts.
|
||||
"""
|
||||
status = get_api_key_status()
|
||||
return AuthStatusResponse(
|
||||
enabled=status["enabled"],
|
||||
total_keys=status["total_keys"],
|
||||
user_keys=status["user_keys"],
|
||||
project_keys=status["project_keys"],
|
||||
env_configured=status["env_configured"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/auth/keys", response_model=list[AuthKeyInfo])
|
||||
async def api_auth_list_keys(
|
||||
location: str = Query("user", description="'user' or 'project'"),
|
||||
_: str = Depends(require_api_key),
|
||||
):
|
||||
"""
|
||||
List configured API keys (names only, not actual keys).
|
||||
|
||||
v4.2.1: New endpoint for auth management.
|
||||
Requires authentication.
|
||||
"""
|
||||
if location not in ("user", "project"):
|
||||
raise HTTPException(400, "location must be 'user' or 'project'")
|
||||
|
||||
keys = list_api_keys(location)
|
||||
return [AuthKeyInfo(name=k["name"], created=k["created"]) for k in keys]
|
||||
|
||||
|
||||
@app.post("/auth/keys")
|
||||
async def api_auth_create_key(
|
||||
name: str = Query(..., description="Name for the new API key"),
|
||||
location: str = Query("user", description="'user' or 'project'"),
|
||||
_: str = Depends(require_api_key),
|
||||
):
|
||||
"""
|
||||
Create a new API key.
|
||||
|
||||
v4.2.1: New endpoint for auth management.
|
||||
Returns the key ONCE - it cannot be retrieved again!
|
||||
Requires authentication (or no keys configured yet).
|
||||
"""
|
||||
if location not in ("user", "project"):
|
||||
raise HTTPException(400, "location must be 'user' or 'project'")
|
||||
|
||||
try:
|
||||
key = add_api_key(name, location)
|
||||
return {
|
||||
"success": True,
|
||||
"name": name,
|
||||
"key": key,
|
||||
"warning": "Save this key now! It cannot be retrieved again.",
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@app.delete("/auth/keys")
|
||||
async def api_auth_delete_key(
|
||||
name: str = Query(..., description="Name of key to delete"),
|
||||
location: str = Query("user", description="'user' or 'project'"),
|
||||
_: str = Depends(require_api_key),
|
||||
):
|
||||
"""
|
||||
Delete an API key by name.
|
||||
|
||||
v4.2.1: New endpoint for auth management.
|
||||
Requires authentication.
|
||||
"""
|
||||
if location not in ("user", "project"):
|
||||
raise HTTPException(400, "location must be 'user' or 'project'")
|
||||
|
||||
if remove_api_key(name, location):
|
||||
return {"success": True, "deleted": name}
|
||||
else:
|
||||
raise HTTPException(404, f"Key '{name}' not found in {location} config")
|
||||
|
||||
|
||||
@app.post("/compare", response_model=CompareModesResponse)
|
||||
async def api_compare_modes(request: CompareModesRequest):
|
||||
async def api_compare_modes(request: CompareModesRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Compare LSB and DCT embedding modes for a carrier image.
|
||||
|
||||
@@ -701,7 +898,7 @@ async def api_compare_modes(request: CompareModesRequest):
|
||||
|
||||
|
||||
@app.post("/will-fit", response_model=WillFitResponse)
|
||||
async def api_will_fit(request: WillFitRequest):
|
||||
async def api_will_fit(request: WillFitRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Check if a payload of given size will fit in the carrier image.
|
||||
|
||||
@@ -737,6 +934,7 @@ async def api_will_fit(request: WillFitRequest):
|
||||
|
||||
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
|
||||
async def api_extract_key_from_qr(
|
||||
_: str = Depends(require_api_key),
|
||||
qr_image: UploadFile = File(..., description="QR code image containing RSA key")
|
||||
):
|
||||
"""
|
||||
@@ -760,13 +958,58 @@ async def api_extract_key_from_qr(
|
||||
return QrExtractResponse(success=False, error=str(e))
|
||||
|
||||
|
||||
@app.post("/generate-key-qr", response_model=QrGenerateResponse)
|
||||
async def api_generate_key_qr(request: QrGenerateRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Generate QR code from an RSA private key.
|
||||
|
||||
Supports PNG, JPG, and ASCII output formats.
|
||||
Uses zstd compression by default for better QR code density.
|
||||
"""
|
||||
if not HAS_QR_WRITE:
|
||||
raise HTTPException(501, "QR code generation not available. Install qrcode library.")
|
||||
|
||||
try:
|
||||
fmt = request.output_format.lower()
|
||||
|
||||
if fmt == "ascii":
|
||||
ascii_qr = generate_qr_ascii(
|
||||
request.key_pem,
|
||||
compress=request.compress,
|
||||
invert=False,
|
||||
)
|
||||
return QrGenerateResponse(success=True, format="ascii", qr_data=ascii_qr)
|
||||
|
||||
elif fmt in ("png", "jpg", "jpeg"):
|
||||
import base64
|
||||
|
||||
qr_bytes = generate_qr_code(
|
||||
request.key_pem,
|
||||
compress=request.compress,
|
||||
output_format=fmt,
|
||||
)
|
||||
qr_b64 = base64.b64encode(qr_bytes).decode("ascii")
|
||||
return QrGenerateResponse(success=True, format=fmt, qr_data=qr_b64)
|
||||
|
||||
else:
|
||||
return QrGenerateResponse(
|
||||
success=False,
|
||||
error=f"Unsupported format: {fmt}. Use 'png', 'jpg', or 'ascii'",
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
return QrGenerateResponse(success=False, error=str(e))
|
||||
except Exception as e:
|
||||
return QrGenerateResponse(success=False, error=f"QR generation failed: {e}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ROUTES - GENERATE
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@app.post("/generate", response_model=GenerateResponse)
|
||||
async def api_generate(request: GenerateRequest):
|
||||
async def api_generate(request: GenerateRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Generate credentials for encoding/decoding.
|
||||
|
||||
@@ -848,7 +1091,7 @@ def _get_output_info(embed_mode: str, dct_output_format: str, dct_color_mode: st
|
||||
|
||||
|
||||
@app.post("/encode", response_model=EncodeResponse)
|
||||
async def api_encode(request: EncodeRequest):
|
||||
async def api_encode(request: EncodeRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Encode a text message into an image.
|
||||
|
||||
@@ -874,8 +1117,9 @@ async def api_encode(request: EncodeRequest):
|
||||
request.embed_mode, request.dct_output_format, request.dct_color_mode
|
||||
)
|
||||
|
||||
# v4.0.0: Include channel_key
|
||||
result = encode(
|
||||
# v4.2.0: Run CPU-bound encode in thread pool
|
||||
result = await run_in_thread(
|
||||
encode,
|
||||
message=request.message,
|
||||
reference_photo=ref_photo,
|
||||
carrier_image=carrier,
|
||||
@@ -919,7 +1163,7 @@ async def api_encode(request: EncodeRequest):
|
||||
|
||||
|
||||
@app.post("/encode/file", response_model=EncodeResponse)
|
||||
async def api_encode_file(request: EncodeFileRequest):
|
||||
async def api_encode_file(request: EncodeFileRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Encode a file into an image (JSON with base64).
|
||||
|
||||
@@ -950,8 +1194,9 @@ async def api_encode_file(request: EncodeFileRequest):
|
||||
request.embed_mode, request.dct_output_format, request.dct_color_mode
|
||||
)
|
||||
|
||||
# v4.0.0: Include channel_key
|
||||
result = encode(
|
||||
# v4.2.0: Run CPU-bound encode in thread pool
|
||||
result = await run_in_thread(
|
||||
encode,
|
||||
message=payload,
|
||||
reference_photo=ref_photo,
|
||||
carrier_image=carrier,
|
||||
@@ -1000,7 +1245,7 @@ async def api_encode_file(request: EncodeFileRequest):
|
||||
|
||||
|
||||
@app.post("/decode", response_model=DecodeResponse)
|
||||
async def api_decode(request: DecodeRequest):
|
||||
async def api_decode(request: DecodeRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Decode a message or file from a stego image.
|
||||
|
||||
@@ -1021,8 +1266,9 @@ async def api_decode(request: DecodeRequest):
|
||||
ref_photo = base64.b64decode(request.reference_photo_base64)
|
||||
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
||||
|
||||
# v4.0.0: Include channel_key
|
||||
result = decode(
|
||||
# v4.2.0: Run CPU-bound decode in thread pool
|
||||
result = await run_in_thread(
|
||||
decode,
|
||||
stego_image=stego,
|
||||
reference_photo=ref_photo,
|
||||
passphrase=request.passphrase,
|
||||
@@ -1062,6 +1308,7 @@ async def api_decode(request: DecodeRequest):
|
||||
|
||||
@app.post("/encode/multipart")
|
||||
async def api_encode_multipart(
|
||||
_: str = Depends(require_api_key),
|
||||
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
|
||||
reference_photo: UploadFile = File(...),
|
||||
carrier: UploadFile = File(...),
|
||||
@@ -1150,8 +1397,9 @@ async def api_encode_multipart(
|
||||
# Get DCT parameters
|
||||
dct_params = _get_dct_params(embed_mode, dct_output_format, dct_color_mode)
|
||||
|
||||
# v4.0.0: Include channel_key
|
||||
result = encode(
|
||||
# v4.2.0: Run CPU-bound encode in thread pool
|
||||
result = await run_in_thread(
|
||||
encode,
|
||||
message=payload,
|
||||
reference_photo=ref_data,
|
||||
carrier_image=carrier_data,
|
||||
@@ -1202,6 +1450,7 @@ async def api_encode_multipart(
|
||||
|
||||
@app.post("/decode/multipart", response_model=DecodeResponse)
|
||||
async def api_decode_multipart(
|
||||
_: str = Depends(require_api_key),
|
||||
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
|
||||
reference_photo: UploadFile = File(...),
|
||||
stego_image: UploadFile = File(...),
|
||||
@@ -1264,8 +1513,9 @@ async def api_decode_multipart(
|
||||
# QR code keys are never password-protected
|
||||
effective_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
||||
|
||||
# v4.0.0: Include channel_key
|
||||
result = decode(
|
||||
# v4.2.0: Run CPU-bound decode in thread pool
|
||||
result = await run_in_thread(
|
||||
decode,
|
||||
stego_image=stego_data,
|
||||
reference_photo=ref_data,
|
||||
passphrase=passphrase,
|
||||
@@ -1306,6 +1556,7 @@ async def api_decode_multipart(
|
||||
|
||||
@app.post("/image/info", response_model=ImageInfoResponse)
|
||||
async def api_image_info(
|
||||
_: str = Depends(require_api_key),
|
||||
image: UploadFile = File(...),
|
||||
include_modes: bool = Query(True, description="Include capacity by mode (v3.0+)"),
|
||||
):
|
||||
|
||||
0
frontends/cli/__init__.py
Normal file
@@ -120,6 +120,7 @@ try:
|
||||
from stegasoo.qr_utils import ( # noqa: F401
|
||||
can_fit_in_qr,
|
||||
extract_key_from_qr_file,
|
||||
generate_qr_ascii,
|
||||
generate_qr_code,
|
||||
has_qr_read,
|
||||
has_qr_write,
|
||||
@@ -136,6 +137,9 @@ except ImportError:
|
||||
def has_qr_write() -> bool:
|
||||
return False
|
||||
|
||||
def generate_qr_ascii(*args, **kwargs):
|
||||
raise RuntimeError("QR code generation not available")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CLI SETUP
|
||||
@@ -236,7 +240,7 @@ def format_channel_status_line(quiet: bool = False) -> str | None:
|
||||
help=f"PIN length (6-9, default: {DEFAULT_PIN_LENGTH})",
|
||||
)
|
||||
@click.option(
|
||||
"--rsa-bits", type=click.Choice(["2048", "3072", "4096"]), default="2048", help="RSA key size"
|
||||
"--rsa-bits", type=click.Choice(["2048", "3072"]), default="2048", help="RSA key size"
|
||||
)
|
||||
@click.option(
|
||||
"--words",
|
||||
@@ -247,7 +251,13 @@ def format_channel_status_line(quiet: bool = False) -> str | None:
|
||||
@click.option("--output", "-o", type=click.Path(), help="Save RSA key to file (requires password)")
|
||||
@click.option("--password", "-p", help="Password for RSA key file")
|
||||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||||
def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
||||
@click.option(
|
||||
"--qr",
|
||||
type=click.Path(),
|
||||
help="Save RSA key QR code to file (png/jpg, uses zstd compression)",
|
||||
)
|
||||
@click.option("--qr-ascii", is_flag=True, help="Print RSA key as ASCII QR code to terminal")
|
||||
def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json, qr, qr_ascii):
|
||||
"""
|
||||
Generate credentials for encoding/decoding.
|
||||
|
||||
@@ -261,13 +271,18 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
||||
Examples:
|
||||
stegasoo generate
|
||||
stegasoo generate --words 5
|
||||
stegasoo generate --rsa --rsa-bits 4096
|
||||
stegasoo generate --rsa --rsa-bits 3072
|
||||
stegasoo generate --rsa -o mykey.pem -p "secretpassword"
|
||||
stegasoo generate --rsa --qr key.png
|
||||
stegasoo generate --rsa --qr-ascii
|
||||
stegasoo generate --no-pin --rsa
|
||||
"""
|
||||
if not pin and not rsa:
|
||||
raise click.UsageError("Must enable at least one of --pin or --rsa")
|
||||
|
||||
if (qr or qr_ascii) and not rsa:
|
||||
raise click.UsageError("QR output requires --rsa to generate an RSA key")
|
||||
|
||||
if output and not password:
|
||||
raise click.UsageError("--password is required when saving RSA key to file")
|
||||
|
||||
@@ -334,6 +349,33 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
||||
click.echo(creds.rsa_key_pem)
|
||||
click.echo()
|
||||
|
||||
# QR code output (v4.2.0)
|
||||
if qr:
|
||||
if not HAS_QR:
|
||||
click.secho(" ⚠️ QR code library not available", fg="yellow")
|
||||
else:
|
||||
# Determine format from extension
|
||||
qr_path = Path(qr)
|
||||
ext = qr_path.suffix.lower()
|
||||
fmt = "jpeg" if ext in (".jpg", ".jpeg") else "png"
|
||||
|
||||
qr_bytes = generate_qr_code(creds.rsa_key_pem, compress=True, output_format=fmt)
|
||||
qr_path.write_bytes(qr_bytes)
|
||||
click.secho(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.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)")
|
||||
if creds.pin:
|
||||
|
||||
0
frontends/web/__init__.py
Normal file
@@ -31,7 +31,7 @@ KEY PATTERNS
|
||||
============
|
||||
|
||||
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:
|
||||
|
||||
subprocess_stego = SubprocessStego(timeout=180)
|
||||
@@ -213,7 +213,7 @@ except ImportError:
|
||||
#
|
||||
# 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
|
||||
# - Very large images that exhaust memory
|
||||
# - Certain edge cases in coefficient manipulation
|
||||
@@ -253,6 +253,7 @@ from stegasoo.qr_utils import (
|
||||
detect_and_crop_qr,
|
||||
extract_key_from_qr,
|
||||
generate_qr_code,
|
||||
is_compressed,
|
||||
)
|
||||
|
||||
# Initialize subprocess wrapper (worker script must be in same directory)
|
||||
@@ -1116,6 +1117,13 @@ def encode_page():
|
||||
# Check if async mode requested
|
||||
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:
|
||||
# Get files
|
||||
ref_photo = request.files.get("reference_photo")
|
||||
@@ -1124,12 +1132,10 @@ def encode_page():
|
||||
payload_file = request.files.get("payload_file")
|
||||
|
||||
if not ref_photo or not carrier:
|
||||
flash("Both reference photo and carrier image are required", "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response("Both reference photo and carrier image are required")
|
||||
|
||||
if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename):
|
||||
flash("Invalid file type. Use PNG, JPG, or BMP", "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response("Invalid file type. Use PNG, JPG, or BMP")
|
||||
|
||||
# Get form data - v3.2.0: renamed from day_phrase to passphrase
|
||||
message = request.form.get("message", "")
|
||||
@@ -1158,8 +1164,7 @@ def encode_page():
|
||||
|
||||
# Check DCT availability
|
||||
if embed_mode == "dct" and not has_dct_support():
|
||||
flash("DCT mode requires scipy. Install with: pip install scipy", "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response("DCT mode requires scipy. Install with: pip install scipy")
|
||||
|
||||
# Determine payload
|
||||
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)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response(result.error_message)
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(payload_file.filename)
|
||||
payload = FilePayload(
|
||||
@@ -1179,20 +1183,17 @@ def encode_page():
|
||||
# Text message
|
||||
result = validate_message(message)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response(result.error_message)
|
||||
payload = message
|
||||
|
||||
# v3.2.0: Renamed from day_phrase
|
||||
if not passphrase:
|
||||
flash("Passphrase is required", "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response("Passphrase is required")
|
||||
|
||||
# v3.2.0: Validate passphrase
|
||||
result = validate_passphrase(passphrase)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response(result.error_message)
|
||||
|
||||
# Show warning if passphrase is short
|
||||
if result.warning:
|
||||
@@ -1209,8 +1210,8 @@ def encode_page():
|
||||
rsa_key_from_qr = False
|
||||
|
||||
if rsa_key_pem:
|
||||
# Webcam-scanned PEM key (v4.1.5) - may be compressed
|
||||
if rsa_key_pem.startswith("STEGASOO-Z:"):
|
||||
# Webcam-scanned PEM key (v4.1.5+) - may be compressed (zlib or zstd)
|
||||
if is_compressed(rsa_key_pem):
|
||||
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||
rsa_key_from_qr = True
|
||||
@@ -1223,21 +1224,18 @@ def encode_page():
|
||||
rsa_key_data = key_pem.encode("utf-8")
|
||||
rsa_key_from_qr = True
|
||||
else:
|
||||
flash("Could not extract RSA key from QR code image.", "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response("Could not extract RSA key from QR code image.")
|
||||
|
||||
# Validate security factors
|
||||
result = validate_security_factors(pin, rsa_key_data)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response(result.error_message)
|
||||
|
||||
# Validate PIN if provided
|
||||
if pin:
|
||||
result = validate_pin(pin)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response(result.error_message)
|
||||
|
||||
# Determine key password
|
||||
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:
|
||||
result = validate_rsa_key(rsa_key_data, key_password)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response(result.error_message)
|
||||
|
||||
# Validate carrier image
|
||||
result = validate_image(carrier_data, "Carrier image")
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response(result.error_message)
|
||||
|
||||
# Pre-check payload capacity BEFORE encode (fail fast)
|
||||
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")
|
||||
if alt_check.get("fits"):
|
||||
error_msg += " - Try LSB mode instead."
|
||||
flash(error_msg, "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response(error_msg)
|
||||
|
||||
# Build encode params for either sync or async
|
||||
encode_params = {
|
||||
@@ -1375,14 +1370,11 @@ def encode_page():
|
||||
return redirect(url_for("encode_result", file_id=file_id))
|
||||
|
||||
except CapacityError as e:
|
||||
flash(str(e), "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response(str(e))
|
||||
except StegasooError as e:
|
||||
flash(str(e), "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response(str(e))
|
||||
except Exception as e:
|
||||
flash(f"Error: {e}", "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
return _error_response(f"Error: {e}")
|
||||
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
@@ -1657,8 +1649,8 @@ def decode_page():
|
||||
rsa_key_from_qr = False
|
||||
|
||||
if rsa_key_pem:
|
||||
# Webcam-scanned PEM key (v4.1.5) - may be compressed
|
||||
if rsa_key_pem.startswith("STEGASOO-Z:"):
|
||||
# Webcam-scanned PEM key (v4.1.5+) - may be compressed (zlib or zstd)
|
||||
if is_compressed(rsa_key_pem):
|
||||
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||
rsa_key_from_qr = True
|
||||
@@ -2108,8 +2100,11 @@ def api_tools_exif_clear():
|
||||
@app.route("/api/tools/rotate", methods=["POST"])
|
||||
@login_required
|
||||
def api_tools_rotate():
|
||||
"""Rotate and/or flip an image."""
|
||||
"""Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
|
||||
from PIL import Image
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
image_file = request.files.get("image")
|
||||
if not image_file:
|
||||
@@ -2120,22 +2115,115 @@ def api_tools_rotate():
|
||||
flip_v = request.form.get("flip_v", "false").lower() == "true"
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(image_file.read()))
|
||||
image_data = image_file.read()
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
original_format = img.format # JPEG, PNG, etc.
|
||||
img.close()
|
||||
|
||||
# Apply rotation (PIL rotates counter-clockwise, so negate)
|
||||
if rotation:
|
||||
img = img.rotate(-rotation, expand=True)
|
||||
# For JPEGs, use jpegtran for lossless rotation/flip (preserves DCT stego)
|
||||
has_jpegtran = shutil.which("jpegtran") is not None
|
||||
use_jpegtran = original_format == "JPEG" and has_jpegtran and (rotation or flip_h or flip_v)
|
||||
|
||||
# Apply flips
|
||||
if flip_h:
|
||||
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
||||
if flip_v:
|
||||
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
||||
if use_jpegtran:
|
||||
# Chain jpegtran operations for lossless transformation
|
||||
current_data = image_data
|
||||
|
||||
# Output as PNG (lossless)
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
# Apply rotation first
|
||||
if rotation in (90, 180, 270):
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||
f.write(current_data)
|
||||
input_path = f.name
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-rotate", str(rotation), "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True, timeout=30
|
||||
)
|
||||
if result.returncode == 0:
|
||||
with open(output_path, "rb") as f:
|
||||
current_data = f.read()
|
||||
finally:
|
||||
for p in [input_path, output_path]:
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Apply horizontal flip
|
||||
if flip_h:
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||
f.write(current_data)
|
||||
input_path = f.name
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-flip", "horizontal", "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True, timeout=30
|
||||
)
|
||||
if result.returncode == 0:
|
||||
with open(output_path, "rb") as f:
|
||||
current_data = f.read()
|
||||
finally:
|
||||
for p in [input_path, output_path]:
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Apply vertical flip
|
||||
if flip_v:
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||
f.write(current_data)
|
||||
input_path = f.name
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-flip", "vertical", "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True, timeout=30
|
||||
)
|
||||
if result.returncode == 0:
|
||||
with open(output_path, "rb") as f:
|
||||
current_data = f.read()
|
||||
finally:
|
||||
for p in [input_path, output_path]:
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
buffer = io.BytesIO(current_data)
|
||||
mimetype = "image/jpeg"
|
||||
ext = "jpg"
|
||||
else:
|
||||
# Fallback to PIL for non-JPEGs or when jpegtran unavailable
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# Apply rotation (PIL rotates counter-clockwise, so negate)
|
||||
if rotation:
|
||||
img = img.rotate(-rotation, expand=True)
|
||||
|
||||
# Apply flips
|
||||
if flip_h:
|
||||
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
||||
if flip_v:
|
||||
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
||||
|
||||
# Preserve original format
|
||||
buffer = io.BytesIO()
|
||||
if original_format == "JPEG":
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
img.save(buffer, format="JPEG", quality=95)
|
||||
mimetype = "image/jpeg"
|
||||
ext = "jpg"
|
||||
else:
|
||||
img.save(buffer, format="PNG")
|
||||
mimetype = "image/png"
|
||||
ext = "png"
|
||||
buffer.seek(0)
|
||||
|
||||
stem = (
|
||||
image_file.filename.rsplit(".", 1)[0]
|
||||
@@ -2144,9 +2232,9 @@ def api_tools_rotate():
|
||||
)
|
||||
return send_file(
|
||||
buffer,
|
||||
mimetype="image/png",
|
||||
mimetype=mimetype,
|
||||
as_attachment=True,
|
||||
download_name=f"{stem}_transformed.png",
|
||||
download_name=f"{stem}_transformed.{ext}",
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@@ -1009,7 +1009,9 @@ const Stegasoo = {
|
||||
const percent = progressData.percent || 0;
|
||||
const phase = progressData.phase || 'processing';
|
||||
|
||||
this.updateProgress(percent, this.formatPhase(phase));
|
||||
// Use indeterminate mode for initializing/starting phases
|
||||
const isIndeterminate = (phase === 'initializing' || phase === 'starting');
|
||||
this.updateProgress(percent, this.formatPhase(phase), isIndeterminate);
|
||||
|
||||
// Continue polling
|
||||
setTimeout(poll, 500);
|
||||
@@ -1029,7 +1031,7 @@ const Stegasoo = {
|
||||
formatPhase(phase) {
|
||||
const phases = {
|
||||
'starting': 'Starting...',
|
||||
'initializing': 'Initializing...',
|
||||
'initializing': 'Deriving keys (may take a moment)...',
|
||||
'embedding': 'Embedding data...',
|
||||
'saving': 'Saving image...',
|
||||
'finalizing': 'Finalizing...',
|
||||
@@ -1070,8 +1072,9 @@ const Stegasoo = {
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Reset progress
|
||||
this.updateProgress(0, 'Initializing...');
|
||||
// Reset progress tracking and start with indeterminate state
|
||||
this.resetProgressTracking();
|
||||
this.updateProgress(0, 'Initializing...', true);
|
||||
|
||||
// Show modal
|
||||
const bsModal = new bootstrap.Modal(modal);
|
||||
@@ -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 progressText = document.getElementById('progressText');
|
||||
const phaseText = document.getElementById('progressPhase');
|
||||
|
||||
if (progressBar) progressBar.style.width = percent + '%';
|
||||
if (progressText) progressText.textContent = Math.round(percent) + '%';
|
||||
if (phaseText) phaseText.textContent = phase;
|
||||
if (indeterminate) {
|
||||
// Barber pole animation at full width, no percentage
|
||||
if (progressBar) {
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
}
|
||||
if (progressText) progressText.textContent = '';
|
||||
if (phaseText) phaseText.textContent = phase;
|
||||
} else {
|
||||
// Determinate progress - never go backwards
|
||||
const safePercent = Math.max(percent, this._maxProgress);
|
||||
this._maxProgress = safePercent;
|
||||
|
||||
if (progressBar) {
|
||||
progressBar.style.width = safePercent + '%';
|
||||
// Keep animation but show actual progress
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
}
|
||||
if (progressText) progressText.textContent = Math.round(safePercent) + '%';
|
||||
if (phaseText) phaseText.textContent = phase;
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
@@ -1187,7 +1221,9 @@ const Stegasoo = {
|
||||
const percent = progressData.percent || 0;
|
||||
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
|
||||
setTimeout(poll, 500);
|
||||
@@ -1207,8 +1243,11 @@ const Stegasoo = {
|
||||
formatDecodePhase(phase) {
|
||||
const phases = {
|
||||
'starting': 'Starting...',
|
||||
'initializing': 'Deriving keys (may take a moment)...',
|
||||
'loading': 'Deriving keys (may take a moment)...',
|
||||
'reading': 'Reading image...',
|
||||
'extracting': 'Extracting data...',
|
||||
'decoding': 'Decoding data...',
|
||||
'decrypting': 'Decrypting...',
|
||||
'verifying': 'Verifying...',
|
||||
'finalizing': 'Finalizing...',
|
||||
|
||||
@@ -2247,7 +2247,7 @@ footer {
|
||||
display: none;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
padding: 1.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-section.active {
|
||||
@@ -2255,33 +2255,92 @@ footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* EXIF Table in Results */
|
||||
.tool-exif-table {
|
||||
font-size: 0.8rem;
|
||||
max-height: 250px;
|
||||
/* EXIF Grid Layout */
|
||||
.exif-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.3rem;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 0.15rem;
|
||||
}
|
||||
|
||||
.tool-exif-table table {
|
||||
width: 100%;
|
||||
.exif-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.4rem;
|
||||
}
|
||||
|
||||
.tool-exif-table th,
|
||||
.tool-exif-table td {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
.exif-card:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.tool-exif-table th {
|
||||
.exif-card-label {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: left;
|
||||
width: 40%;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 0.1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tool-exif-table td {
|
||||
.exif-card-value {
|
||||
font-size: 0.7rem;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
word-break: break-all;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
word-break: break-word;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.exif-card-value.truncated {
|
||||
max-height: 2.4em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* Category headers */
|
||||
.exif-category {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.35rem 0 0.15rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.exif-category:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* Compact tool headers and actions */
|
||||
.tool-results-header {
|
||||
padding-bottom: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.tool-results-header h6 {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tool-results-header small {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.tool-results-actions {
|
||||
padding-top: 0.35rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Stegasoo Subprocess Worker (v4.0.0)
|
||||
|
||||
This script runs in a subprocess and handles encode/decode operations.
|
||||
If it crashes due to jpegio/scipy issues, the parent Flask process survives.
|
||||
If it crashes due to jpeglib/scipy issues, the parent Flask process survives.
|
||||
|
||||
CHANGES in v4.0.0:
|
||||
- Added channel_key support for encode/decode operations
|
||||
@@ -171,8 +171,7 @@ def decode_operation(params: dict) -> dict:
|
||||
# Resolve channel key (v4.0.0)
|
||||
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
|
||||
result = decode(
|
||||
stego_image=stego_data,
|
||||
@@ -183,9 +182,9 @@ def decode_operation(params: dict) -> dict:
|
||||
rsa_password=params.get("rsa_password"),
|
||||
embed_mode=params.get("embed_mode", "auto"),
|
||||
channel_key=resolved_channel_key, # v4.0.0
|
||||
progress_file=progress_file, # v4.2.0: pass through for real-time progress
|
||||
)
|
||||
|
||||
_write_decode_progress(progress_file, 90, "finalizing")
|
||||
# Library writes 100% "complete" - no need for worker to write again
|
||||
|
||||
if result.is_file:
|
||||
return {
|
||||
|
||||
@@ -132,7 +132,7 @@ class SubprocessStego:
|
||||
"""
|
||||
Subprocess-isolated steganography operations.
|
||||
|
||||
All operations run in a separate Python process. If jpegio or scipy
|
||||
All operations run in a separate Python process. If jpeglib or scipy
|
||||
crashes, only the subprocess dies - Flask keeps running.
|
||||
"""
|
||||
|
||||
|
||||
@@ -340,11 +340,13 @@
|
||||
<!-- Current Version - Prominent -->
|
||||
<div class="alert alert-success mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-success fs-6 me-3">v4.1.2</span>
|
||||
<span class="badge bg-success fs-6 me-3">v4.2.1</span>
|
||||
<div>
|
||||
<strong>Progress bars</strong> for encode operations,
|
||||
<strong>mobile-responsive polish</strong>,
|
||||
DCT decode bug fix, release validation script
|
||||
<strong>Security & API improvements:</strong>
|
||||
API key authentication,
|
||||
TLS with self-signed certs,
|
||||
CLI tools (compress, rotate, convert),
|
||||
jpegtran lossless JPEG rotation
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,6 +364,10 @@
|
||||
<div class="accordion-body p-0">
|
||||
<table class="table table-dark table-sm small mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="80"><strong>4.1.7</strong></td>
|
||||
<td>Progress bars for encode, mobile polish, release validation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="80"><strong>4.1.1</strong></td>
|
||||
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
|
||||
@@ -559,7 +565,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-clock me-2"></i>File expiry</td>
|
||||
<td><strong>5 min</strong></td>
|
||||
<td><strong>10 min</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-key me-2"></i>PIN</td>
|
||||
@@ -567,7 +573,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-shield me-2"></i>RSA keys</td>
|
||||
<td><strong>2048, 3072, 4096 bit</strong></td>
|
||||
<td><strong>2048, 3072 bit</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="28">
|
||||
</a>
|
||||
{% if channel_configured %}
|
||||
<span class="badge bg-success bg-opacity-25 small" style="padding-left: 0.35rem;" title="Private Channel: {{ channel_fingerprint }}">
|
||||
<span class="badge bg-success bg-opacity-25 small me-auto" style="padding-left: 0.35rem;" title="Private Channel: {{ channel_fingerprint }}">
|
||||
<i class="bi bi-shield-lock me-2" style="color: #6ee7b7;"></i><code style="font-size: 0.7rem; font-weight: 300; color: #c9a860;">{{ channel_fingerprint[:4] }}-••••-{{ channel_fingerprint[-4:] }}</code>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary bg-opacity-25 small text-muted" style="padding-left: 0.35rem;" title="Public Channel: No shared channel key configured. Messages use only passphrase and PIN for encryption.">
|
||||
<span class="badge bg-secondary bg-opacity-25 small text-muted me-auto" style="padding-left: 0.35rem;" title="Public Channel: No shared channel key configured. Messages use only passphrase and PIN for encryption.">
|
||||
<i class="bi bi-globe me-1"></i>Public Channel
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
|
||||
<div class="alert alert-warning small">
|
||||
<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>
|
||||
|
||||
<a href="/decode" class="btn btn-outline-light w-100">
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Important:</strong>
|
||||
<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>
|
||||
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
|
||||
<li>JPEG format is lossy - avoid re-saving or editing</li>
|
||||
|
||||
@@ -65,11 +65,7 @@
|
||||
<select name="rsa_bits" class="form-select form-select-sm" id="rsaBitsSelect">
|
||||
<option value="2048" selected>2048 bits (~128 bits entropy)</option>
|
||||
<option value="3072">3072 bits (~128 bits entropy)</option>
|
||||
<option value="4096">4096 bits (~128 bits entropy)</option>
|
||||
</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 >3072 bits
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -286,12 +282,6 @@
|
||||
<i class="bi bi-shield-exclamation me-1"></i>
|
||||
<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.
|
||||
{% 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>
|
||||
|
||||
@@ -22,17 +22,17 @@
|
||||
<div class="tools-ribbon-divider"></div>
|
||||
|
||||
<div class="tools-ribbon-group">
|
||||
<button class="tool-icon-btn" data-tool="strip" title="Strip Metadata">
|
||||
<i class="bi bi-eraser"></i>
|
||||
<span>Strip</span>
|
||||
<button class="tool-icon-btn" data-tool="compress" title="JPEG Compression">
|
||||
<i class="bi bi-file-zip"></i>
|
||||
<span>Compress</span>
|
||||
</button>
|
||||
<button class="tool-icon-btn" data-tool="rotate" title="Rotate / Flip">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
<span>Rotate</span>
|
||||
</button>
|
||||
<button class="tool-icon-btn" data-tool="compress" title="JPEG Compression">
|
||||
<i class="bi bi-file-zip"></i>
|
||||
<span>Compress</span>
|
||||
<button class="tool-icon-btn" data-tool="strip" title="Strip Metadata">
|
||||
<i class="bi bi-eraser"></i>
|
||||
<span>Strip</span>
|
||||
</button>
|
||||
<button class="tool-icon-btn" data-tool="convert" title="Format Convert">
|
||||
<i class="bi bi-arrow-left-right"></i>
|
||||
@@ -283,10 +283,8 @@
|
||||
<span>Drop an image to view metadata</span>
|
||||
</div>
|
||||
<div id="exifData" class="d-none">
|
||||
<div class="tool-exif-table">
|
||||
<table>
|
||||
<tbody id="exifTable"></tbody>
|
||||
</table>
|
||||
<div class="exif-grid" id="exifGrid">
|
||||
<!-- Cards populated by JS -->
|
||||
</div>
|
||||
<div id="exifNoData" class="text-muted text-center py-3 d-none">
|
||||
<i class="bi bi-inbox d-block mb-2"></i>
|
||||
@@ -368,6 +366,14 @@
|
||||
<span class="tool-result-label">Flipped</span>
|
||||
<span class="tool-result-value" id="rotateFlip">None</span>
|
||||
</div>
|
||||
<div class="alert alert-success small mt-3 mb-0" id="rotateJpegSafe" style="display: none;">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
<strong>DCT Safe:</strong> Uses jpegtran for lossless JPEG rotation. Your stego data will be preserved.
|
||||
</div>
|
||||
<div class="alert alert-warning small mt-3 mb-0" id="rotateNonJpegWarn" style="display: none;">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Note:</strong> Non-JPEG images are re-encoded during rotation.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-results-actions d-none" id="rotateActions">
|
||||
@@ -634,30 +640,104 @@ setupDropZone('exifZone', 'exifFile', async (file) => {
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tools/exif', { method: 'POST', body: formData });
|
||||
|
||||
// Check for auth redirect or non-JSON response
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
console.error('EXIF API returned non-JSON:', res.status, contentType);
|
||||
document.getElementById('exifNoData').classList.remove('d-none');
|
||||
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-exclamation-triangle d-block mb-2"></i>Session expired - please refresh';
|
||||
document.getElementById('exifEmpty').classList.add('d-none');
|
||||
document.getElementById('exifData').classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
const tbody = document.getElementById('exifTable');
|
||||
const entries = Object.entries(data.exif).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
const grid = document.getElementById('exifGrid');
|
||||
const entries = Object.entries(data.exif);
|
||||
|
||||
if (entries.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
grid.innerHTML = '';
|
||||
document.getElementById('exifNoData').classList.remove('d-none');
|
||||
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-inbox d-block mb-2"></i>No metadata found';
|
||||
} else {
|
||||
document.getElementById('exifNoData').classList.add('d-none');
|
||||
tbody.innerHTML = entries.map(([key, value]) => {
|
||||
|
||||
// Categorize EXIF fields
|
||||
const categories = {
|
||||
'Camera': ['Make', 'Model', 'Software', 'LensMake', 'LensModel', 'BodySerialNumber'],
|
||||
'Image': ['ImageWidth', 'ImageLength', 'Orientation', 'ResolutionUnit', 'XResolution', 'YResolution', 'ColorSpace', 'ExifImageWidth', 'ExifImageHeight'],
|
||||
'Date/Time': ['DateTime', 'DateTimeOriginal', 'DateTimeDigitized', 'SubsecTime', 'SubsecTimeOriginal', 'SubsecTimeDigitized', 'OffsetTime', 'OffsetTimeOriginal'],
|
||||
'Exposure': ['ExposureTime', 'FNumber', 'ExposureProgram', 'ISOSpeedRatings', 'ExposureBiasValue', 'MaxApertureValue', 'MeteringMode', 'Flash', 'FocalLength', 'FocalLengthIn35mmFilm', 'WhiteBalance', 'ExposureMode', 'DigitalZoomRatio', 'SceneCaptureType', 'Contrast', 'Saturation', 'Sharpness'],
|
||||
'GPS': ['GPSInfo', 'GPSLatitude', 'GPSLatitudeRef', 'GPSLongitude', 'GPSLongitudeRef', 'GPSAltitude', 'GPSAltitudeRef', 'GPSTimeStamp', 'GPSDateStamp'],
|
||||
};
|
||||
|
||||
const categorized = {};
|
||||
const other = [];
|
||||
const allCategoryFields = new Set(Object.values(categories).flat());
|
||||
|
||||
entries.forEach(([key, value]) => {
|
||||
let found = false;
|
||||
for (const [cat, fields] of Object.entries(categories)) {
|
||||
if (fields.includes(key)) {
|
||||
if (!categorized[cat]) categorized[cat] = [];
|
||||
categorized[cat].push([key, value]);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) other.push([key, value]);
|
||||
});
|
||||
|
||||
// Render cards
|
||||
let html = '';
|
||||
const renderCard = ([key, value]) => {
|
||||
let displayVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
if (displayVal.length > 40) displayVal = displayVal.substring(0, 37) + '...';
|
||||
return `<tr><th>${key}</th><td title="${String(value)}">${displayVal}</td></tr>`;
|
||||
}).join('');
|
||||
const needsTruncate = displayVal.length > 60;
|
||||
if (needsTruncate) displayVal = displayVal.substring(0, 57) + '...';
|
||||
const fullVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
return `<div class="exif-card" title="${fullVal.replace(/"/g, '"')}">
|
||||
<div class="exif-card-label">${key}</div>
|
||||
<div class="exif-card-value${needsTruncate ? ' truncated' : ''}">${displayVal}</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// Render each category
|
||||
for (const [cat, fields] of Object.entries(categories)) {
|
||||
if (categorized[cat] && categorized[cat].length > 0) {
|
||||
html += `<div class="exif-category"><i class="bi bi-${cat === 'Camera' ? 'camera' : cat === 'Image' ? 'image' : cat === 'Date/Time' ? 'clock' : cat === 'Exposure' ? 'aperture' : cat === 'GPS' ? 'geo-alt' : 'tag'} me-1"></i>${cat}</div>`;
|
||||
html += categorized[cat].map(renderCard).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Render other fields
|
||||
if (other.length > 0) {
|
||||
html += `<div class="exif-category"><i class="bi bi-three-dots me-1"></i>Other</div>`;
|
||||
html += other.map(renderCard).join('');
|
||||
}
|
||||
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
document.getElementById('exifEmpty').classList.add('d-none');
|
||||
document.getElementById('exifData').classList.remove('d-none');
|
||||
document.getElementById('exifActions').classList.remove('d-none');
|
||||
} else {
|
||||
// API returned success: false
|
||||
console.error('EXIF API error:', data.error);
|
||||
document.getElementById('exifNoData').classList.remove('d-none');
|
||||
document.getElementById('exifNoData').innerHTML = `<i class="bi bi-exclamation-triangle d-block mb-2"></i>${data.error || 'Error reading metadata'}`;
|
||||
document.getElementById('exifEmpty').classList.add('d-none');
|
||||
document.getElementById('exifData').classList.remove('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
console.error('EXIF fetch error:', err);
|
||||
document.getElementById('exifNoData').classList.remove('d-none');
|
||||
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-exclamation-triangle d-block mb-2"></i>Error loading metadata';
|
||||
document.getElementById('exifEmpty').classList.add('d-none');
|
||||
document.getElementById('exifData').classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -796,6 +876,11 @@ setupDropZone('rotateZone', 'rotateFile', async (file) => {
|
||||
document.getElementById('rotateData').classList.remove('d-none');
|
||||
document.getElementById('rotateActions').classList.remove('d-none');
|
||||
|
||||
// Show appropriate DCT warning based on file type
|
||||
const isJpeg = file.type === 'image/jpeg' || file.name.toLowerCase().match(/\.jpe?g$/);
|
||||
document.getElementById('rotateJpegSafe').style.display = isJpeg ? 'block' : 'none';
|
||||
document.getElementById('rotateNonJpegWarn').style.display = isJpeg ? 'none' : 'block';
|
||||
|
||||
// Load image to get dimensions, then show preview
|
||||
const thumb = document.getElementById('rotateThumb');
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
@@ -889,6 +974,8 @@ function clearRotate() {
|
||||
document.getElementById('rotateData').classList.add('d-none');
|
||||
document.getElementById('rotateActions').classList.add('d-none');
|
||||
document.getElementById('rotateFileInfo').classList.add('d-none');
|
||||
document.getElementById('rotateJpegSafe').style.display = 'none';
|
||||
document.getElementById('rotateNonJpegWarn').style.display = 'none';
|
||||
const thumb = document.getElementById('rotateThumb');
|
||||
thumb.style.transform = '';
|
||||
thumb.style.width = '';
|
||||
@@ -920,8 +1007,7 @@ document.getElementById('rotateDownload')?.addEventListener('click', async funct
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const baseName = rotateCurrentFile?.name?.replace(/\.[^.]+$/, '') || 'rotated';
|
||||
a.download = `${baseName}_transformed.png`;
|
||||
a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || 'rotated.jpg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "stegasoo"
|
||||
version = "4.1.5"
|
||||
version = "4.2.1"
|
||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
{ name = "Aaron D. Lee" }
|
||||
]
|
||||
@@ -29,9 +29,10 @@ classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Topic :: Security :: Cryptography",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
]
|
||||
@@ -40,6 +41,7 @@ dependencies = [
|
||||
"pillow>=10.0.0",
|
||||
"cryptography>=41.0.0",
|
||||
"argon2-cffi>=23.0.0",
|
||||
"zstandard>=0.22.0", # v4.2.0: Default compression algorithm
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -47,7 +49,7 @@ dependencies = [
|
||||
dct = [
|
||||
"numpy>=2.0.0",
|
||||
"scipy>=1.10.0",
|
||||
"jpegio>=0.2.0",
|
||||
"jpeglib>=1.0.0",
|
||||
"reedsolo>=1.7.0",
|
||||
]
|
||||
cli = [
|
||||
@@ -57,7 +59,7 @@ cli = [
|
||||
"rich>=13.0.0",
|
||||
]
|
||||
compression = [
|
||||
"lz4>=4.0.0",
|
||||
"lz4>=4.0.0", # Optional: faster but slightly worse ratio than zstd
|
||||
]
|
||||
web = [
|
||||
"flask>=3.0.0",
|
||||
@@ -68,7 +70,7 @@ web = [
|
||||
# Include DCT support for web UI
|
||||
"numpy>=2.0.0",
|
||||
"scipy>=1.10.0",
|
||||
"jpegio>=0.2.0",
|
||||
"jpeglib>=1.0.0",
|
||||
"reedsolo>=1.7.0",
|
||||
]
|
||||
api = [
|
||||
@@ -80,7 +82,7 @@ api = [
|
||||
# Include DCT support for API
|
||||
"numpy>=2.0.0",
|
||||
"scipy>=1.10.0",
|
||||
"jpegio>=0.2.0",
|
||||
"jpeglib>=1.0.0",
|
||||
"reedsolo>=1.7.0",
|
||||
]
|
||||
all = [
|
||||
@@ -110,7 +112,14 @@ include = [
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/stegasoo"]
|
||||
packages = ["src/stegasoo", "frontends"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.sources]
|
||||
"src" = ""
|
||||
|
||||
# Include data files in the wheel
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"src/stegasoo/data/bip39-words.txt" = "stegasoo/data/bip39-words.txt"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -119,7 +128,7 @@ addopts = "-v --cov=stegasoo --cov-report=term-missing"
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py310", "py311", "py312"]
|
||||
target-version = ["py311", "py312", "py313"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
@@ -136,7 +145,7 @@ ignore = ["E501"]
|
||||
"src/stegasoo/__init__.py" = ["E402"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
python_version = "3.11"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
@@ -26,51 +26,50 @@ ssh admin@stegasoo.local
|
||||
## Step 3: Pre-Setup
|
||||
|
||||
```bash
|
||||
# Take ownership of /opt (for pyenv, jpegio builds)
|
||||
# Take ownership of /opt
|
||||
sudo chown admin:admin /opt
|
||||
|
||||
# Install git and zstd (not included in Lite image)
|
||||
sudo apt-get update && sudo apt-get install -y git zstd jq
|
||||
# Install git (not included in Lite image)
|
||||
sudo apt-get update && sudo apt-get install -y git
|
||||
```
|
||||
|
||||
## Step 4: Clone Repo
|
||||
|
||||
```bash
|
||||
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)
|
||||
|
||||
> **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
|
||||
## Step 5: Run Setup
|
||||
|
||||
```bash
|
||||
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
|
||||
./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
|
||||
sudo systemctl start stegasoo
|
||||
@@ -78,7 +77,7 @@ curl -k https://localhost:5000
|
||||
# Should return HTML
|
||||
```
|
||||
|
||||
## Step 8: Sanitize for Distribution
|
||||
## Step 7: Sanitize for Distribution
|
||||
|
||||
```bash
|
||||
# Full sanitize (for final image - removes WiFi, shuts down)
|
||||
@@ -98,7 +97,7 @@ This removes:
|
||||
|
||||
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:
|
||||
|
||||
@@ -107,12 +106,12 @@ Remove SD card, insert into your Linux machine:
|
||||
lsblk
|
||||
|
||||
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
|
||||
```
|
||||
|
||||
The script automatically resizes rootfs to 16GB (for smaller download), preserves auto-expand, and compresses.
|
||||
|
||||
## Step 10: Distribute
|
||||
## Step 9: Distribute
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
# On the Pi after successful setup:
|
||||
cd ~
|
||||
cd /opt/stegasoo
|
||||
|
||||
# Strip caches and tests from venv (295MB → 208MB)
|
||||
find /opt/stegasoo/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 /opt/stegasoo/venv/ -type d -name 'test' -exec rm -rf {} + 2>/dev/null
|
||||
# Strip caches and tests from venv (saves ~100MB)
|
||||
find venv/ -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
|
||||
find venv/ -type d -name 'tests' -exec rm -rf {} + 2>/dev/null
|
||||
find venv/ -type d -name 'test' -exec rm -rf {} + 2>/dev/null
|
||||
|
||||
# Create venv tarball
|
||||
cd /opt/stegasoo
|
||||
tar -cf - venv/ | zstd -19 -T0 > ~/stegasoo-venv.tar.zst
|
||||
tar -cf - venv/ | zstd -19 -T0 > /tmp/stegasoo-rpi-venv-arm64.tar.zst
|
||||
|
||||
# Create combined tarball (pyenv + venv pointer)
|
||||
cd ~
|
||||
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
|
||||
# Check size (should be ~40-50MB)
|
||||
ls -lh /tmp/stegasoo-rpi-venv-arm64.tar.zst
|
||||
```
|
||||
|
||||
Pull to host and upload to GitHub releases:
|
||||
```bash
|
||||
# On host:
|
||||
scp admin@stegasoo.local:/tmp/stegasoo-rpi-runtime-env-arm64.tar.zst ./
|
||||
# Upload to GitHub releases as 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-venv-arm64.tar.zst
|
||||
```
|
||||
|
||||
---
|
||||
@@ -169,18 +163,15 @@ scp admin@stegasoo.local:/tmp/stegasoo-rpi-runtime-env-arm64.tar.zst ./
|
||||
```bash
|
||||
# On Pi (after SSH):
|
||||
sudo chown admin:admin /opt
|
||||
sudo apt-get update && sudo apt-get install -y git zstd jq
|
||||
cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
sudo apt-get update && sudo apt-get install -y git
|
||||
cd /opt && git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
|
||||
# On host (copy tarball):
|
||||
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
||||
|
||||
# On Pi (run setup):
|
||||
# Run setup:
|
||||
cd /opt/stegasoo && ./rpi/setup.sh
|
||||
sudo systemctl start stegasoo
|
||||
curl -k https://localhost:5000
|
||||
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
||||
|
||||
# On host (pull image - auto-resizes to 16GB):
|
||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@ Scripts and resources for deploying Stegasoo on Raspberry Pi.
|
||||
|
||||
## Quick Install
|
||||
|
||||
On a fresh Raspberry Pi OS Lite (64-bit) installation:
|
||||
On a fresh Raspberry Pi OS (64-bit) installation:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
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
|
||||
./rpi/setup.sh
|
||||
```
|
||||
|
||||
## What the Setup Script Does
|
||||
|
||||
1. **Installs system dependencies** - build tools, libraries
|
||||
2. **Installs Python 3.12** - via pyenv (Pi OS ships with 3.13 which is incompatible)
|
||||
3. **Builds jpegio for ARM** - patches x86-specific flags
|
||||
1. **Verifies Python 3.11+** - uses system Python (no pyenv needed)
|
||||
2. **Installs system dependencies** - build tools, libraries
|
||||
3. **Installs jpeglib** - DCT steganography (Python 3.11-3.14 compatible)
|
||||
4. **Installs Stegasoo** - with web UI and all dependencies
|
||||
5. **Creates systemd service** - auto-starts on boot
|
||||
6. **Enables the service** - ready to start
|
||||
@@ -30,11 +30,18 @@ cd stegasoo
|
||||
## Requirements
|
||||
|
||||
- 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)
|
||||
- 16GB+ SD card (pre-built images are 16GB)
|
||||
- Internet connection
|
||||
|
||||
### Python Compatibility
|
||||
|
||||
| Raspberry Pi OS | Python | Supported |
|
||||
|-----------------|--------|-----------|
|
||||
| Bookworm | 3.11 | Yes |
|
||||
| Trixie | 3.13 | Yes |
|
||||
|
||||
### 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).
|
||||
@@ -159,7 +166,7 @@ sudo apt-get update && sudo apt-get install -y git
|
||||
|
||||
# Clone and run setup
|
||||
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
|
||||
./rpi/setup.sh
|
||||
```
|
||||
@@ -200,7 +207,7 @@ After Pi shuts down, remove SD card and on another Linux machine:
|
||||
lsblk
|
||||
|
||||
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
|
||||
```
|
||||
|
||||
The `pull-image.sh` script automatically:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Build Stegasoo Pi Runtime Environment Tarball
|
||||
# Build Stegasoo Pi venv Tarball
|
||||
# Run this ON THE PI after a successful from-source build
|
||||
#
|
||||
# Creates: stegasoo-rpi-runtime-env-arm64.tar.zst (~50-60MB)
|
||||
# Contains: pyenv + Python 3.12 + venv with all dependencies
|
||||
# Creates: stegasoo-rpi-venv-arm64.tar.zst (~40-50MB)
|
||||
# Contains: venv with all dependencies (uses system Python 3.11+)
|
||||
#
|
||||
|
||||
set -e
|
||||
@@ -16,11 +16,10 @@ YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
|
||||
OUTPUT_DIR="${OUTPUT_DIR:-/tmp}"
|
||||
OUTPUT_FILE="$OUTPUT_DIR/stegasoo-rpi-runtime-env-arm64.tar.zst"
|
||||
OUTPUT_FILE="${1:-$HOME/stegasoo-rpi-venv-arm64.tar.zst}"
|
||||
|
||||
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 ""
|
||||
|
||||
@@ -32,13 +31,6 @@ if [[ "$ARCH" != "aarch64" ]]; then
|
||||
exit 1
|
||||
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
|
||||
if [[ ! -d "$INSTALL_DIR/venv" ]]; then
|
||||
echo -e "${RED}Error: venv not found at $INSTALL_DIR/venv${NC}"
|
||||
@@ -47,33 +39,22 @@ if [[ ! -d "$INSTALL_DIR/venv" ]]; then
|
||||
fi
|
||||
|
||||
# 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)
|
||||
find "$INSTALL_DIR/venv/" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$INSTALL_DIR/venv/" -type d -name 'tests' -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$INSTALL_DIR/venv/" -type d -name 'test' -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$INSTALL_DIR/venv/" -type f -name '*.pyc' -delete 2>/dev/null || true
|
||||
VENV_SIZE_AFTER=$(du -sh "$INSTALL_DIR/venv" | cut -f1)
|
||||
echo " venv: $VENV_SIZE_BEFORE → $VENV_SIZE_AFTER"
|
||||
echo " venv: $VENV_SIZE_BEFORE -> $VENV_SIZE_AFTER"
|
||||
|
||||
# Step 2: Create venv tarball
|
||||
echo -e "${GREEN}[2/4]${NC} Creating venv tarball..."
|
||||
# Step 2: Create tarball
|
||||
echo -e "${GREEN}[2/2]${NC} Creating tarball..."
|
||||
cd "$INSTALL_DIR"
|
||||
tar -cf - venv/ | zstd -19 -T0 > "$HOME/stegasoo-venv.tar.zst"
|
||||
VENV_TAR_SIZE=$(ls -lh "$HOME/stegasoo-venv.tar.zst" | awk '{print $5}')
|
||||
echo " Created: ~/stegasoo-venv.tar.zst ($VENV_TAR_SIZE)"
|
||||
tar -cf - venv/ | zstd -19 -T0 > "$OUTPUT_FILE"
|
||||
|
||||
# Step 3: Create combined tarball
|
||||
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
|
||||
# Summary
|
||||
FINAL_SIZE=$(ls -lh "$OUTPUT_FILE" | awk '{print $5}')
|
||||
echo -e "${GREEN}[4/4]${NC} Done!"
|
||||
echo ""
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e " Output: ${YELLOW}$OUTPUT_FILE${NC}"
|
||||
@@ -83,7 +64,7 @@ echo ""
|
||||
echo "To pull to your host machine:"
|
||||
echo " scp $(whoami)@$(hostname).local:$OUTPUT_FILE ./"
|
||||
echo ""
|
||||
echo "To use in setup.sh, copy to:"
|
||||
echo " rpi/stegasoo-rpi-runtime-env-arm64.tar.zst"
|
||||
echo "To use in setup.sh, place at:"
|
||||
echo " rpi/stegasoo-rpi-venv-arm64.tar.zst"
|
||||
echo ""
|
||||
echo "Or upload to GitHub releases for automatic download."
|
||||
|
||||
@@ -80,9 +80,9 @@ if [ -z "$1" ]; then
|
||||
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 stegasoo-rpi-4.1.1.img.zst # auto-detect SD card"
|
||||
echo " $0 stegasoo-rpi-4.1.1.img.zst.zip # from GitHub release"
|
||||
echo " $0 stegasoo-rpi-4.1.1.img.zst /dev/sdb # specify device"
|
||||
echo " $0 stegasoo-rpi-4.2.1.img.zst # auto-detect SD card"
|
||||
echo " $0 stegasoo-rpi-4.2.1.img.zst.zip # from GitHub release"
|
||||
echo " $0 stegasoo-rpi-4.2.1.img.zst /dev/sdb # specify device"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -2,56 +2,39 @@
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
patches/
|
||||
<package>/
|
||||
arm64.patch # Standard unified diff patch file
|
||||
apply-patch.sh # Script with fallback strategies
|
||||
jpegio/ # Legacy (v4.1) - not used in v4.2+
|
||||
arm64.patch
|
||||
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:
|
||||
|
||||
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
|
||||
If a new dependency needs ARM64 patches:
|
||||
|
||||
1. Create a directory: `patches/<package>/`
|
||||
2. Create the patch file: `git diff > arm64.patch`
|
||||
3. Create `apply-patch.sh` with appropriate fallback logic
|
||||
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
|
||||
2. Add patch files or helper scripts
|
||||
3. Update `setup.sh` to apply the patch during installation
|
||||
|
||||
57
rpi/patches/jpeglib/install-jpeglib-arm64.sh
Executable 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."
|
||||
@@ -3,7 +3,7 @@
|
||||
# Resizes rootfs to 16GB for consistent image size, then pulls
|
||||
#
|
||||
# Usage: ./pull-image.sh <device> <output.img.zst>
|
||||
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.1.5.img.zst
|
||||
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.2.1.img.zst
|
||||
|
||||
set -e
|
||||
|
||||
@@ -15,7 +15,7 @@ NC='\033[0m'
|
||||
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "Usage: $0 <device> <output.img.zst>"
|
||||
echo "Example: $0 /dev/sdb stegasoo-rpi-4.1.5.img.zst"
|
||||
echo "Example: $0 /dev/sdb stegasoo-rpi-4.2.1.img.zst"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ echo ""
|
||||
echo -e "${GREEN}[4/6]${NC} Copying pre-built tarball to Pi..."
|
||||
echo ""
|
||||
|
||||
TARBALL="$SCRIPT_DIR/stegasoo-rpi-runtime-env-arm64.tar.zst"
|
||||
TARBALL="$SCRIPT_DIR/stegasoo-rpi-venv-arm64.tar.zst"
|
||||
if [[ -f "$TARBALL" ]]; then
|
||||
scp_to_pi "$TARBALL" "/opt/stegasoo/rpi/"
|
||||
echo -e " ${GREEN}✓${NC} Tarball copied"
|
||||
|
||||
@@ -264,49 +264,25 @@ if [ -n "$STEGASOO_DIR" ] && [ -d "$STEGASOO_DIR/venv" ]; then
|
||||
echo " Venv broken or stegasoo not installed, rebuilding..."
|
||||
rm -rf "$STEGASOO_DIR/venv"
|
||||
|
||||
# Find Python 3.12 (prefer pyenv, fall back to system)
|
||||
USER_HOME=$(eval echo "~$STEGASOO_USER")
|
||||
PYENV_PYTHON="$USER_HOME/.pyenv/versions/3.12*/bin/python"
|
||||
if compgen -G "$PYENV_PYTHON" > /dev/null 2>&1; then
|
||||
PYTHON_BIN=$(ls $PYENV_PYTHON 2>/dev/null | head -1)
|
||||
echo " Using pyenv Python: $PYTHON_BIN"
|
||||
elif command -v python3.12 &>/dev/null; then
|
||||
PYTHON_BIN="python3.12"
|
||||
echo " Using system Python 3.12"
|
||||
else
|
||||
PYTHON_BIN="python3"
|
||||
echo " Warning: Python 3.12 not found, using $($PYTHON_BIN --version)"
|
||||
fi
|
||||
|
||||
sudo -u "$STEGASOO_USER" "$PYTHON_BIN" -m venv "$STEGASOO_DIR/venv"
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet --upgrade pip setuptools wheel
|
||||
|
||||
# On ARM64, jpegio needs patching before install
|
||||
ARCH=$(uname -m)
|
||||
if [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then
|
||||
echo " Building jpegio for ARM64 (this may take a minute)..."
|
||||
# Install build deps
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet cython numpy
|
||||
JPEGIO_DIR="/tmp/jpegio-build-$$"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
if git clone https://github.com/dwgoon/jpegio.git "$JPEGIO_DIR" 2>/dev/null; then
|
||||
# Apply patch to remove -m64 flag
|
||||
if [ -f "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
|
||||
bash "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
|
||||
else
|
||||
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
|
||||
fi
|
||||
# Change ownership so user can build
|
||||
chown -R "$STEGASOO_USER:$STEGASOO_USER" "$JPEGIO_DIR"
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install "$JPEGIO_DIR"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
else
|
||||
echo " Warning: Failed to clone jpegio, DCT mode may not work"
|
||||
# Find system Python 3.11+ (no pyenv needed)
|
||||
PYTHON_BIN=""
|
||||
for py in python3.14 python3.13 python3.12 python3.11 python3; do
|
||||
if command -v "$py" &>/dev/null; then
|
||||
PYTHON_BIN=$(command -v "$py")
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]"
|
||||
echo " Venv rebuilt and stegasoo installed"
|
||||
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" "$STEGASOO_DIR/venv/bin/pip" install --quiet --upgrade pip setuptools wheel
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]"
|
||||
echo " Venv rebuilt and stegasoo installed"
|
||||
fi
|
||||
else
|
||||
echo " Venv OK"
|
||||
fi
|
||||
|
||||
351
rpi/setup.sh
@@ -4,14 +4,14 @@
|
||||
# Tested on: Raspberry Pi 4/5 with Raspberry Pi OS (64-bit)
|
||||
#
|
||||
# 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
|
||||
# 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:
|
||||
# 1. Installs system dependencies
|
||||
# 2. Installs Python 3.12 via pyenv (Pi OS ships with 3.13 which is incompatible)
|
||||
# 3. Patches and builds jpegio for ARM
|
||||
# 2. Verifies Python 3.11+ (uses system Python)
|
||||
# 3. Installs jpeglib for DCT steganography (Python 3.11-3.14 compatible)
|
||||
# 4. Installs Stegasoo with web UI
|
||||
# 5. Creates systemd service for auto-start
|
||||
# 6. Enables the service
|
||||
@@ -75,9 +75,8 @@ show_help() {
|
||||
echo ""
|
||||
echo " Available variables:"
|
||||
echo " INSTALL_DIR Install location (default: /opt/stegasoo)"
|
||||
echo " PYTHON_VERSION Python version (default: 3.12)"
|
||||
echo " STEGASOO_REPO Git repo URL"
|
||||
echo " STEGASOO_BRANCH Git branch (default: 4.1)"
|
||||
echo " STEGASOO_BRANCH Git branch (default: 4.2)"
|
||||
echo ""
|
||||
echo " Example:"
|
||||
echo " export INSTALL_DIR=\"/home/pi/stegasoo\""
|
||||
@@ -95,10 +94,8 @@ done
|
||||
|
||||
# Default configuration
|
||||
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_BRANCH="${STEGASOO_BRANCH:-4.1}"
|
||||
JPEGIO_REPO="https://github.com/dwgoon/jpegio.git"
|
||||
STEGASOO_BRANCH="${STEGASOO_BRANCH:-4.2}"
|
||||
|
||||
# Load config files (system, then user - user overrides system)
|
||||
for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf"; do
|
||||
@@ -112,7 +109,7 @@ clear
|
||||
print_banner "Raspberry Pi Setup"
|
||||
echo ""
|
||||
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 ""
|
||||
|
||||
# Check if running on ARM
|
||||
@@ -123,6 +120,63 @@ if [[ "$ARCH" != "aarch64" && "$ARCH" != "arm64" ]]; then
|
||||
exit 1
|
||||
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
|
||||
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
|
||||
if [ "$TOTAL_MEM" -lt 2000 ]; then
|
||||
@@ -136,8 +190,11 @@ if [ "$TOTAL_MEM" -lt 2000 ]; then
|
||||
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
|
||||
sudo mkdir -p "$INSTALL_DIR"
|
||||
sudo chown "$USER:$USER" "$INSTALL_DIR"
|
||||
@@ -148,7 +205,7 @@ else
|
||||
echo " $INSTALL_DIR exists, updated ownership"
|
||||
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 install -y \
|
||||
build-essential \
|
||||
@@ -170,9 +227,11 @@ sudo apt-get install -y \
|
||||
libzbar0 \
|
||||
libjpeg-dev \
|
||||
python3-dev \
|
||||
python3-venv \
|
||||
python3-pip \
|
||||
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
|
||||
if ! command -v gum &>/dev/null; then
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
@@ -198,7 +257,7 @@ else
|
||||
echo " mkcert already installed"
|
||||
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)
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
@@ -212,17 +271,16 @@ else
|
||||
cd "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Pre-built environment tarball (skips 20+ min compile time)
|
||||
# Includes both pyenv Python 3.12 AND venv with all dependencies
|
||||
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.1.5/stegasoo-rpi-runtime-env-arm64.tar.zst}"
|
||||
# Pre-built venv tarball (skips pip compile time)
|
||||
PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-rpi-venv-arm64.tar.zst"
|
||||
PREBUILT_URL="${PREBUILT_URL:-https://github.com/adlee-was-taken/stegasoo/releases/download/v4.2.1/stegasoo-rpi-venv-arm64.tar.zst}"
|
||||
USE_PREBUILT=true
|
||||
|
||||
# Use local tarball if present, otherwise will download
|
||||
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
|
||||
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
|
||||
|
||||
# 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}"
|
||||
fi
|
||||
|
||||
# Fast path: use pre-built environment if available
|
||||
echo -e "${GREEN}[5/9]${NC} Setting up Python environment..."
|
||||
|
||||
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
|
||||
if [ ! -f "$PREBUILT_TARBALL" ]; then
|
||||
echo " Downloading pre-built environment (~50MB)..."
|
||||
echo " Downloading pre-built venv (~50MB)..."
|
||||
curl -L -o "$PREBUILT_TARBALL" "$PREBUILT_URL"
|
||||
fi
|
||||
|
||||
# Extract pre-built environment (includes pyenv Python + venv)
|
||||
echo " Extracting pre-built environment..."
|
||||
zstd -d "$PREBUILT_TARBALL" --stdout | tar -xf - -C "$HOME"
|
||||
# Extract pre-built venv
|
||||
echo " Extracting pre-built venv..."
|
||||
zstd -d "$PREBUILT_TARBALL" --stdout | tar -xf - -C "$INSTALL_DIR"
|
||||
|
||||
# Setup pyenv in current shell
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
eval "$(pyenv init -)"
|
||||
pyenv global $PYTHON_VERSION
|
||||
# Fix venv Python symlinks to point to system Python
|
||||
echo " Updating venv to use system Python..."
|
||||
rm -f "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/venv/bin/python3"
|
||||
ln -s "$SYSTEM_PYTHON" "$INSTALL_DIR/venv/bin/python"
|
||||
ln -s "$SYSTEM_PYTHON" "$INSTALL_DIR/venv/bin/python3"
|
||||
|
||||
# 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
|
||||
|
||||
# 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"
|
||||
# Update pip shebang if needed
|
||||
if [ -f "$INSTALL_DIR/venv/bin/pip" ]; then
|
||||
sed -i "1s|^#!.*|#!$INSTALL_DIR/venv/bin/python|" "$INSTALL_DIR/venv/bin/pip" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Activate and verify
|
||||
@@ -277,105 +321,87 @@ if [ "$USE_PREBUILT" = true ]; then
|
||||
echo -e " ${GREEN}✓${NC} venv Python: $VENV_PY"
|
||||
|
||||
# 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
|
||||
|
||||
# Adjust step numbers for rest of script
|
||||
STEP_OFFSET=-4
|
||||
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
|
||||
if [ ! -d "$HOME/.pyenv" ]; then
|
||||
curl https://pyenv.run | bash
|
||||
|
||||
# Add pyenv to current shell
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
eval "$(pyenv init -)"
|
||||
|
||||
# Add to .bashrc if not already there
|
||||
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
|
||||
echo '' >> ~/.bashrc
|
||||
echo '# pyenv' >> ~/.bashrc
|
||||
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
||||
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
||||
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
|
||||
fi
|
||||
else
|
||||
echo " pyenv already installed"
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
eval "$(pyenv init -)"
|
||||
# Create venv with system Python
|
||||
if [ ! -d "$INSTALL_DIR/venv" ]; then
|
||||
"$SYSTEM_PYTHON" -m venv "$INSTALL_DIR/venv"
|
||||
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
|
||||
source "$INSTALL_DIR/venv/bin/activate"
|
||||
|
||||
# Verify we're using the right Python
|
||||
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
||||
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
|
||||
JPEGIO_DIR="/tmp/jpegio-build"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
git clone "$JPEGIO_REPO" "$JPEGIO_DIR"
|
||||
# Install jpeglib (no ARM64 wheel, PyPI tarball missing headers - use GitHub)
|
||||
echo " Installing jpeglib for ARM64..."
|
||||
JPEGLIB_WORKDIR=$(mktemp -d)
|
||||
cd "$JPEGLIB_WORKDIR"
|
||||
|
||||
# Apply ARM64 patch
|
||||
if [ -f "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
|
||||
bash "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
|
||||
else
|
||||
echo " Applying inline ARM64 patch..."
|
||||
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
|
||||
fi
|
||||
# Clone from GitHub (PyPI source tarball is missing .h files)
|
||||
echo " Cloning jpeglib from GitHub..."
|
||||
git clone --depth 1 --branch 1.0.2 https://github.com/martinbenes1996/jpeglib.git
|
||||
cd jpeglib
|
||||
CJPEGLIB="src/jpeglib/cjpeglib"
|
||||
|
||||
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
|
||||
pip install --upgrade pip setuptools wheel cython numpy
|
||||
# Download libjpeg headers (not included in repo either)
|
||||
# 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 .
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
rm -rf "$JPEGLIB_WORKDIR"
|
||||
|
||||
echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..."
|
||||
|
||||
# Install dependencies (jpegio already in venv, won't re-download)
|
||||
# Install remaining dependencies
|
||||
echo " Installing remaining dependencies..."
|
||||
pip install -e ".[web]"
|
||||
|
||||
STEP_OFFSET=0
|
||||
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
|
||||
[Unit]
|
||||
Description=Stegasoo Web UI
|
||||
@@ -397,12 +423,53 @@ RestartSec=5
|
||||
WantedBy=multi-user.target
|
||||
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 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
|
||||
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)"
|
||||
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
|
||||
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 " Status: sudo systemctl status stegasoo"
|
||||
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 ""
|
||||
|
||||
# Offer to start now
|
||||
@@ -734,9 +809,12 @@ read -p "Start Stegasoo now? [Y/n] " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
sudo systemctl start stegasoo
|
||||
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
|
||||
sudo systemctl start stegasoo-api
|
||||
fi
|
||||
sleep 2
|
||||
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 [ "$USE_PORT_443" = "true" ]; then
|
||||
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
|
||||
echo -e " Create admin: ${YELLOW}http://$PI_HOST.local:5000/setup${NC} or ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
||||
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
|
||||
echo -e "${RED}✗ Failed to start. Check logs:${NC} journalctl -u stegasoo -f"
|
||||
fi
|
||||
|
||||
@@ -68,20 +68,31 @@ case "${1:-fast}" in
|
||||
echo -e "${GREEN}Cleaned!${NC}"
|
||||
;;
|
||||
|
||||
rebuild)
|
||||
echo -e "${YELLOW}Full rebuild from scratch (no cache)...${NC}"
|
||||
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" down --rmi local -v 2>/dev/null || true
|
||||
$SUDO docker rmi stegasoo-base:latest 2>/dev/null || true
|
||||
$SUDO docker build --no-cache -f "$DOCKER_DIR/Dockerfile.base" -t stegasoo-base:latest .
|
||||
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" build --no-cache
|
||||
echo -e "${GREEN}Done! Start with: $COMPOSE_CMD -f docker/docker-compose.yml up -d${NC}"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo -e "${CYAN}Stegasoo Build Script${NC}"
|
||||
echo ""
|
||||
echo "Usage: $0 [command]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " base Build the base image (one-time, 5-10 min)"
|
||||
echo " fast Fast build using base image (default, ~10 sec)"
|
||||
echo " full Full rebuild from scratch (slow, no base needed)"
|
||||
echo " clean Remove all images and volumes"
|
||||
echo " base Build the base image (one-time, 5-10 min)"
|
||||
echo " fast Fast build using base image (default, ~10 sec)"
|
||||
echo " full Rebuild services without cache (uses existing base)"
|
||||
echo " rebuild Complete rebuild with no cache (base + services)"
|
||||
echo " clean Remove all images and volumes"
|
||||
echo ""
|
||||
echo "Typical workflow:"
|
||||
echo " 1. First time: $0 base"
|
||||
echo " 2. Daily dev: $0 fast"
|
||||
echo " 3. Deps change: $0 base"
|
||||
echo " 1. First time: $0 base"
|
||||
echo " 2. Daily dev: $0 fast"
|
||||
echo " 3. Deps change: $0 base"
|
||||
echo " 4. Nuclear: $0 rebuild"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -33,8 +33,8 @@ for cmd in chromium magick curl; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Check if server is running
|
||||
if ! curl -s "$BASE_URL" > /dev/null 2>&1; then
|
||||
# Check if server is running (-k for self-signed certs)
|
||||
if ! curl -sk "$BASE_URL" > /dev/null 2>&1; then
|
||||
echo "Error: Server not responding at $BASE_URL"
|
||||
echo "Start with: STEGASOO_AUTH_ENABLED=false python frontends/web/app.py"
|
||||
exit 1
|
||||
@@ -49,7 +49,7 @@ capture() {
|
||||
printf " %-20s <- %s\n" "$name" "$route"
|
||||
chromium --headless --screenshot="$OUTPUT_DIR/$name.png" \
|
||||
--window-size="$WINDOW_SIZE" --hide-scrollbars \
|
||||
--disable-gpu --no-sandbox \
|
||||
--disable-gpu --no-sandbox --ignore-certificate-errors \
|
||||
"$url" 2>/dev/null
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Changes in v4.0.0:
|
||||
- encode() and decode() now accept channel_key parameter
|
||||
"""
|
||||
|
||||
__version__ = "4.1.7"
|
||||
__version__ = "4.2.1"
|
||||
|
||||
# Core functionality
|
||||
# Channel key management (v4.0.0)
|
||||
|
||||
@@ -241,8 +241,20 @@ def encode(
|
||||
with open(carrier, "rb") as f:
|
||||
carrier_data = f.read()
|
||||
|
||||
# Determine output path
|
||||
output = output or f"{Path(carrier).stem}_encoded.png"
|
||||
# Determine output path and format
|
||||
# Default to JPEG for JPEG carriers (preserves DCT mode benefits)
|
||||
carrier_ext = Path(carrier).suffix.lower()
|
||||
if not output:
|
||||
if carrier_ext in ('.jpg', '.jpeg'):
|
||||
output = f"{Path(carrier).stem}_encoded.jpg"
|
||||
else:
|
||||
output = f"{Path(carrier).stem}_encoded.png"
|
||||
|
||||
# Detect output format from extension
|
||||
output_ext = Path(output).suffix.lower()
|
||||
use_dct = output_ext in ('.jpg', '.jpeg')
|
||||
|
||||
from .steganography import EMBED_MODE_DCT, EMBED_MODE_LSB
|
||||
|
||||
try:
|
||||
if file_payload:
|
||||
@@ -253,6 +265,8 @@ def encode(
|
||||
carrier_image=carrier_data,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
embed_mode=EMBED_MODE_DCT if use_dct else EMBED_MODE_LSB,
|
||||
dct_output_format="jpeg" if use_dct else "png",
|
||||
)
|
||||
else:
|
||||
# Encode message
|
||||
@@ -262,6 +276,8 @@ def encode(
|
||||
carrier_image=carrier_data,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
embed_mode=EMBED_MODE_DCT if use_dct else EMBED_MODE_LSB,
|
||||
dct_output_format="jpeg" if use_dct else "png",
|
||||
)
|
||||
|
||||
# Write output
|
||||
@@ -1297,6 +1313,203 @@ def tools_exif(image, clear, set_fields, output, as_json):
|
||||
raise click.UsageError(str(e))
|
||||
|
||||
|
||||
@tools.command("compress")
|
||||
@click.argument("image", type=click.Path(exists=True))
|
||||
@click.option("-q", "--quality", type=int, default=75, help="JPEG quality (1-100, default: 75)")
|
||||
@click.option("-o", "--output", type=click.Path(), help="Output file (default: <name>_q<quality>.jpg)")
|
||||
def tools_compress(image, quality, output):
|
||||
"""Compress a JPEG image.
|
||||
|
||||
DCT steganography survives JPEG compression! Use this to reduce file size
|
||||
while preserving hidden data.
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo tools compress photo.jpg -q 60
|
||||
stegasoo tools compress photo.jpg -q 80 -o smaller.jpg
|
||||
"""
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
if not 1 <= quality <= 100:
|
||||
raise click.UsageError("Quality must be between 1 and 100")
|
||||
|
||||
with open(image, "rb") as f:
|
||||
image_data = f.read()
|
||||
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# Convert to RGB if needed (JPEG doesn't support alpha)
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="JPEG", quality=quality)
|
||||
compressed_data = buffer.getvalue()
|
||||
|
||||
if not output:
|
||||
stem = Path(image).stem
|
||||
output = f"{stem}_q{quality}.jpg"
|
||||
|
||||
with open(output, "wb") as f:
|
||||
f.write(compressed_data)
|
||||
|
||||
orig_size = len(image_data)
|
||||
new_size = len(compressed_data)
|
||||
reduction = (1 - new_size / orig_size) * 100
|
||||
|
||||
click.echo(f"Compressed to: {output}")
|
||||
click.echo(f" Original: {orig_size:,} bytes")
|
||||
click.echo(f" Compressed: {new_size:,} bytes ({reduction:.1f}% smaller)")
|
||||
|
||||
|
||||
@tools.command("rotate")
|
||||
@click.argument("image", type=click.Path(exists=True))
|
||||
@click.option("-r", "--rotation", type=click.Choice(["90", "180", "270"]), help="Rotation degrees clockwise")
|
||||
@click.option("--flip-h", is_flag=True, help="Flip horizontally")
|
||||
@click.option("--flip-v", is_flag=True, help="Flip vertically")
|
||||
@click.option("-o", "--output", type=click.Path(), help="Output file")
|
||||
def tools_rotate(image, rotation, flip_h, flip_v, output):
|
||||
"""Rotate and/or flip an image.
|
||||
|
||||
For JPEGs, uses lossless jpegtran rotation which preserves DCT steganography.
|
||||
For other formats, uses PIL (re-encodes the image).
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo tools rotate photo.jpg -r 90
|
||||
stegasoo tools rotate photo.jpg -r 180 --flip-h -o rotated.jpg
|
||||
"""
|
||||
from PIL import Image
|
||||
import io
|
||||
import shutil
|
||||
|
||||
with open(image, "rb") as f:
|
||||
image_data = f.read()
|
||||
|
||||
# Must have rotation or flip
|
||||
if not rotation and not flip_h and not flip_v:
|
||||
raise click.UsageError("Must specify at least one of -r/--rotation, --flip-h, or --flip-v")
|
||||
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
is_jpeg = img.format == "JPEG"
|
||||
img.close()
|
||||
|
||||
rotation_deg = int(rotation) if rotation else 0
|
||||
|
||||
# For JPEGs, use lossless jpegtran
|
||||
if is_jpeg and shutil.which("jpegtran"):
|
||||
from .dct_steganography import _jpegtran_rotate
|
||||
|
||||
result_data = image_data
|
||||
|
||||
# Apply rotation
|
||||
if rotation_deg in (90, 180, 270):
|
||||
result_data = _jpegtran_rotate(result_data, rotation_deg)
|
||||
|
||||
# Apply flips using jpegtran
|
||||
if flip_h or flip_v:
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
for flip_type in (["horizontal"] if flip_h else []) + (["vertical"] if flip_v else []):
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||
f.write(result_data)
|
||||
input_path = f.name
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
try:
|
||||
subprocess.run(
|
||||
["jpegtran", "-flip", flip_type, "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True, timeout=30, check=True
|
||||
)
|
||||
with open(output_path, "rb") as f:
|
||||
result_data = f.read()
|
||||
finally:
|
||||
for p in [input_path, output_path]:
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
ext = "jpg"
|
||||
click.echo(" (Used lossless jpegtran - DCT stego preserved)")
|
||||
else:
|
||||
# Use PIL for non-JPEGs
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# PIL rotation is counter-clockwise, we want clockwise
|
||||
if rotation_deg:
|
||||
pil_rotation = {90: 270, 180: 180, 270: 90}[rotation_deg]
|
||||
img = img.rotate(pil_rotation, expand=True)
|
||||
|
||||
if flip_h:
|
||||
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
||||
if flip_v:
|
||||
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
result_data = buffer.getvalue()
|
||||
ext = "png"
|
||||
|
||||
if not output:
|
||||
stem = Path(image).stem
|
||||
suffix = "rotated" if rotation_deg else "flipped"
|
||||
output = f"{stem}_{suffix}.{ext}"
|
||||
|
||||
with open(output, "wb") as f:
|
||||
f.write(result_data)
|
||||
|
||||
click.echo(f"Saved to: {output}")
|
||||
|
||||
|
||||
@tools.command("convert")
|
||||
@click.argument("image", type=click.Path(exists=True))
|
||||
@click.option("-f", "--format", "fmt", type=click.Choice(["png", "jpg", "bmp", "webp"]), required=True, help="Output format")
|
||||
@click.option("-q", "--quality", type=int, default=95, help="Quality for lossy formats (default: 95)")
|
||||
@click.option("-o", "--output", type=click.Path(), help="Output file")
|
||||
def tools_convert(image, fmt, quality, output):
|
||||
"""Convert image to a different format.
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo tools convert photo.png -f jpg
|
||||
stegasoo tools convert photo.jpg -f png -o lossless.png
|
||||
"""
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
with open(image, "rb") as f:
|
||||
image_data = f.read()
|
||||
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# Handle format-specific conversions
|
||||
save_format = {"jpg": "JPEG", "png": "PNG", "bmp": "BMP", "webp": "WEBP"}[fmt]
|
||||
|
||||
if save_format == "JPEG" and img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
buffer = io.BytesIO()
|
||||
if save_format in ("JPEG", "WEBP"):
|
||||
img.save(buffer, format=save_format, quality=quality)
|
||||
else:
|
||||
img.save(buffer, format=save_format)
|
||||
|
||||
result_data = buffer.getvalue()
|
||||
|
||||
if not output:
|
||||
stem = Path(image).stem
|
||||
output = f"{stem}.{fmt}"
|
||||
|
||||
with open(output, "wb") as f:
|
||||
f.write(result_data)
|
||||
|
||||
click.echo(f"Converted to: {output}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ADMIN COMMANDS (Web UI administration)
|
||||
# =============================================================================
|
||||
@@ -1455,6 +1668,301 @@ def admin_generate_key(show_qr):
|
||||
click.echo("go to Account > Recovery Key > Regenerate")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API COMMANDS (REST API management)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _setup_frontends_path():
|
||||
"""Add frontends directory to sys.path for importing API/web modules."""
|
||||
import sys
|
||||
|
||||
# Try multiple possible locations
|
||||
possible_paths = [
|
||||
# Development: stegasoo/frontends
|
||||
Path(__file__).parent.parent.parent / "frontends",
|
||||
# Installed package: site-packages/frontends
|
||||
Path(__file__).parent.parent / "frontends",
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if path.exists() and str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@cli.group()
|
||||
@click.pass_context
|
||||
def api(ctx):
|
||||
"""REST API management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@api.group("keys")
|
||||
def api_keys():
|
||||
"""Manage API keys for authentication."""
|
||||
pass
|
||||
|
||||
|
||||
@api_keys.command("list")
|
||||
@click.option("--location", type=click.Choice(["user", "project", "all"]), default="all",
|
||||
help="Config location to list keys from")
|
||||
def api_keys_list(location):
|
||||
"""List configured API keys.
|
||||
|
||||
Shows key names and creation dates (not actual keys).
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo api keys list
|
||||
stegasoo api keys list --location user
|
||||
"""
|
||||
_setup_frontends_path()
|
||||
|
||||
try:
|
||||
from api.auth import list_api_keys, get_api_key_status
|
||||
except ImportError:
|
||||
raise click.ClickException("API frontend not available")
|
||||
|
||||
status = get_api_key_status()
|
||||
|
||||
click.echo(f"\nAPI Key Authentication: {'Enabled' if status['enabled'] else 'Disabled'}")
|
||||
click.echo(f"Total keys: {status['total_keys']}")
|
||||
click.echo(f"Environment variable: {'Set' if status['env_configured'] else 'Not set'}")
|
||||
|
||||
locations = ["user", "project"] if location == "all" else [location]
|
||||
|
||||
for loc in locations:
|
||||
keys = list_api_keys(loc)
|
||||
click.echo(f"\n{loc.title()} keys ({len(keys)}):")
|
||||
if keys:
|
||||
for k in keys:
|
||||
click.echo(f" - {k['name']} (created: {k['created'][:10]})")
|
||||
else:
|
||||
click.echo(" (none)")
|
||||
|
||||
|
||||
@api_keys.command("create")
|
||||
@click.argument("name")
|
||||
@click.option("--location", type=click.Choice(["user", "project"]), default="user",
|
||||
help="Where to store the key")
|
||||
def api_keys_create(name, location):
|
||||
"""Create a new API key.
|
||||
|
||||
The key is shown ONCE and cannot be retrieved again.
|
||||
Save it immediately!
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo api keys create laptop
|
||||
stegasoo api keys create automation --location project
|
||||
"""
|
||||
_setup_frontends_path()
|
||||
|
||||
try:
|
||||
from api.auth import add_api_key
|
||||
except ImportError:
|
||||
raise click.ClickException("API frontend not available")
|
||||
|
||||
try:
|
||||
key = add_api_key(name, location)
|
||||
click.echo(f"\nAPI Key created: {name}")
|
||||
click.echo("─" * 60)
|
||||
click.echo(f" {key}")
|
||||
click.echo("─" * 60)
|
||||
click.echo("\nSave this key NOW! It cannot be retrieved again.")
|
||||
click.echo(f"Stored in: {location} config")
|
||||
except ValueError as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
|
||||
@api_keys.command("delete")
|
||||
@click.argument("name")
|
||||
@click.option("--location", type=click.Choice(["user", "project"]), default="user",
|
||||
help="Config location")
|
||||
def api_keys_delete(name, location):
|
||||
"""Delete an API key by name.
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo api keys delete laptop
|
||||
stegasoo api keys delete automation --location project
|
||||
"""
|
||||
_setup_frontends_path()
|
||||
|
||||
try:
|
||||
from api.auth import remove_api_key
|
||||
except ImportError:
|
||||
raise click.ClickException("API frontend not available")
|
||||
|
||||
if remove_api_key(name, location):
|
||||
click.echo(f"Deleted API key: {name}")
|
||||
else:
|
||||
raise click.ClickException(f"Key '{name}' not found in {location} config")
|
||||
|
||||
|
||||
@api.group("tls")
|
||||
def api_tls():
|
||||
"""Manage TLS certificates for HTTPS."""
|
||||
pass
|
||||
|
||||
|
||||
@api_tls.command("generate")
|
||||
@click.option("--hostname", default="localhost", help="Server hostname for certificate")
|
||||
@click.option("--days", default=365, help="Certificate validity in days")
|
||||
@click.option("--output", "-o", type=click.Path(), help="Output directory (default: ~/.stegasoo/certs)")
|
||||
def api_tls_generate(hostname, days, output):
|
||||
"""Generate self-signed TLS certificate.
|
||||
|
||||
Creates a certificate valid for:
|
||||
- The specified hostname
|
||||
- localhost / 127.0.0.1
|
||||
- hostname.local (for mDNS)
|
||||
- All detected local network IPs
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo api tls generate
|
||||
stegasoo api tls generate --hostname myserver --days 730
|
||||
stegasoo api tls generate -o /etc/stegasoo/certs
|
||||
"""
|
||||
_setup_frontends_path()
|
||||
|
||||
try:
|
||||
from web.ssl_utils import generate_self_signed_cert, get_cert_paths
|
||||
except ImportError:
|
||||
raise click.ClickException("Web frontend not available (ssl_utils required)")
|
||||
|
||||
if output:
|
||||
base_dir = Path(output)
|
||||
else:
|
||||
base_dir = Path.home() / ".stegasoo"
|
||||
|
||||
click.echo(f"Generating TLS certificate for: {hostname}")
|
||||
click.echo(f"Validity: {days} days")
|
||||
|
||||
cert_path, key_path = generate_self_signed_cert(base_dir, hostname, days)
|
||||
|
||||
click.echo(f"\nCertificate: {cert_path}")
|
||||
click.echo(f"Private Key: {key_path}")
|
||||
click.echo("\nTo use with the API:")
|
||||
click.echo(f" uvicorn main:app --ssl-certfile {cert_path} --ssl-keyfile {key_path}")
|
||||
|
||||
|
||||
@api_tls.command("info")
|
||||
@click.option("--cert", "-c", type=click.Path(exists=True), help="Certificate file (default: ~/.stegasoo/certs/server.crt)")
|
||||
def api_tls_info(cert):
|
||||
"""Show information about a TLS certificate.
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo api tls info
|
||||
stegasoo api tls info --cert /path/to/server.crt
|
||||
"""
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
if not cert:
|
||||
cert = Path.home() / ".stegasoo" / "certs" / "server.crt"
|
||||
if not cert.exists():
|
||||
raise click.ClickException(f"No certificate found at {cert}. Generate one with: stegasoo api tls generate")
|
||||
|
||||
cert_data = Path(cert).read_bytes()
|
||||
certificate = x509.load_pem_x509_certificate(cert_data)
|
||||
|
||||
click.echo(f"\nCertificate: {cert}")
|
||||
click.echo("─" * 50)
|
||||
click.echo(f"Subject: {certificate.subject.rfc4514_string()}")
|
||||
click.echo(f"Issuer: {certificate.issuer.rfc4514_string()}")
|
||||
click.echo(f"Serial: {certificate.serial_number}")
|
||||
click.echo(f"Valid from: {certificate.not_valid_before_utc}")
|
||||
click.echo(f"Valid until: {certificate.not_valid_after_utc}")
|
||||
|
||||
# Check expiry
|
||||
import datetime
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
if certificate.not_valid_after_utc < now:
|
||||
click.echo("\nStatus: EXPIRED")
|
||||
elif certificate.not_valid_after_utc < now + datetime.timedelta(days=30):
|
||||
days_left = (certificate.not_valid_after_utc - now).days
|
||||
click.echo(f"\nStatus: Expires in {days_left} days (consider renewal)")
|
||||
else:
|
||||
days_left = (certificate.not_valid_after_utc - now).days
|
||||
click.echo(f"\nStatus: Valid ({days_left} days remaining)")
|
||||
|
||||
# Show SANs
|
||||
try:
|
||||
san_ext = certificate.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
click.echo("\nSubject Alternative Names:")
|
||||
for name in san_ext.value:
|
||||
click.echo(f" - {name.value}")
|
||||
except x509.ExtensionNotFound:
|
||||
pass
|
||||
|
||||
|
||||
@api.command("serve")
|
||||
@click.option("--host", default="127.0.0.1", help="Host to bind to")
|
||||
@click.option("--port", default=8000, help="Port to bind to")
|
||||
@click.option("--ssl/--no-ssl", default=True, help="Enable/disable TLS")
|
||||
@click.option("--cert", type=click.Path(exists=True), help="TLS certificate file")
|
||||
@click.option("--key", type=click.Path(exists=True), help="TLS private key file")
|
||||
@click.option("--reload", "do_reload", is_flag=True, help="Enable auto-reload for development")
|
||||
def api_serve(host, port, ssl, cert, key, do_reload):
|
||||
"""Start the REST API server.
|
||||
|
||||
By default starts with TLS using certificates from ~/.stegasoo/certs/.
|
||||
If no certificates exist, they are generated automatically.
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo api serve
|
||||
stegasoo api serve --host 0.0.0.0 --port 8443
|
||||
stegasoo api serve --no-ssl
|
||||
stegasoo api serve --cert /path/to/cert.pem --key /path/to/key.pem
|
||||
"""
|
||||
_setup_frontends_path()
|
||||
|
||||
# Determine cert paths
|
||||
if ssl:
|
||||
if cert and key:
|
||||
cert_path, key_path = cert, key
|
||||
else:
|
||||
try:
|
||||
from web.ssl_utils import ensure_certs
|
||||
base_dir = Path.home() / ".stegasoo"
|
||||
cert_path, key_path = ensure_certs(base_dir, host if host != "0.0.0.0" else "localhost")
|
||||
except ImportError:
|
||||
raise click.ClickException("ssl_utils not available")
|
||||
|
||||
click.echo(f"Starting API server with TLS on https://{host}:{port}")
|
||||
click.echo(f"Certificate: {cert_path}")
|
||||
else:
|
||||
cert_path = key_path = None
|
||||
click.echo(f"Starting API server on http://{host}:{port}")
|
||||
click.echo("WARNING: TLS disabled - connections are not encrypted!")
|
||||
|
||||
# Import and run uvicorn
|
||||
try:
|
||||
import uvicorn
|
||||
except ImportError:
|
||||
raise click.ClickException("uvicorn not installed. Install with: pip install uvicorn")
|
||||
|
||||
uvicorn_kwargs = {
|
||||
"app": "api.main:app",
|
||||
"host": host,
|
||||
"port": port,
|
||||
"reload": do_reload,
|
||||
}
|
||||
|
||||
if ssl and cert_path and key_path:
|
||||
uvicorn_kwargs["ssl_certfile"] = str(cert_path)
|
||||
uvicorn_kwargs["ssl_keyfile"] = str(key_path)
|
||||
|
||||
uvicorn.run(**uvicorn_kwargs)
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for CLI."""
|
||||
cli(obj={})
|
||||
|
||||
@@ -17,6 +17,14 @@ try:
|
||||
except ImportError:
|
||||
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):
|
||||
"""Supported compression algorithms."""
|
||||
@@ -24,6 +32,7 @@ class CompressionAlgorithm(IntEnum):
|
||||
NONE = 0
|
||||
ZLIB = 1
|
||||
LZ4 = 2
|
||||
ZSTD = 3 # v4.2.0: Best ratio, fast compression
|
||||
|
||||
|
||||
# Magic bytes for compressed payloads
|
||||
@@ -72,6 +81,15 @@ def compress(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm
|
||||
algorithm = CompressionAlgorithm.ZLIB
|
||||
else:
|
||||
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:
|
||||
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
|
||||
|
||||
@@ -123,6 +141,15 @@ def decompress(data: bytes) -> bytes:
|
||||
result = lz4.frame.decompress(compressed_data)
|
||||
except Exception as 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:
|
||||
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
|
||||
|
||||
@@ -181,6 +208,9 @@ def estimate_compressed_size(
|
||||
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
|
||||
elif algorithm == CompressionAlgorithm.LZ4 and HAS_LZ4:
|
||||
compressed_sample = lz4.frame.compress(sample)
|
||||
elif algorithm == CompressionAlgorithm.ZSTD and HAS_ZSTD:
|
||||
cctx = zstd.ZstdCompressor(level=19)
|
||||
compressed_sample = cctx.compress(sample)
|
||||
else:
|
||||
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
|
||||
|
||||
@@ -195,14 +225,24 @@ def get_available_algorithms() -> list[CompressionAlgorithm]:
|
||||
algorithms = [CompressionAlgorithm.NONE, CompressionAlgorithm.ZLIB]
|
||||
if HAS_LZ4:
|
||||
algorithms.append(CompressionAlgorithm.LZ4)
|
||||
if HAS_ZSTD:
|
||||
algorithms.append(CompressionAlgorithm.ZSTD)
|
||||
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:
|
||||
"""Get human-readable algorithm name."""
|
||||
names = {
|
||||
CompressionAlgorithm.NONE: "None",
|
||||
CompressionAlgorithm.ZLIB: "Zlib (deflate)",
|
||||
CompressionAlgorithm.LZ4: "LZ4 (fast)",
|
||||
CompressionAlgorithm.ZSTD: "Zstd (best)",
|
||||
}
|
||||
return names.get(algo, "Unknown")
|
||||
|
||||
@@ -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.
|
||||
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:
|
||||
- Added Web UI authentication with SQLite3 user storage
|
||||
- Added optional HTTPS with auto-generated self-signed certificates
|
||||
@@ -19,13 +25,14 @@ BREAKING CHANGES in v3.2.0:
|
||||
- Renamed day_phrase → passphrase throughout codebase
|
||||
"""
|
||||
|
||||
import importlib.resources
|
||||
from pathlib import Path
|
||||
|
||||
# ============================================================================
|
||||
# VERSION
|
||||
# ============================================================================
|
||||
|
||||
__version__ = "4.1.5"
|
||||
__version__ = "4.2.1"
|
||||
|
||||
# ============================================================================
|
||||
# FILE FORMAT
|
||||
@@ -98,7 +105,7 @@ DEFAULT_PHRASE_WORDS = DEFAULT_PASSPHRASE_WORDS
|
||||
|
||||
# RSA configuration
|
||||
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
|
||||
|
||||
MIN_KEY_PASSWORD_LENGTH = 8
|
||||
@@ -108,8 +115,8 @@ MIN_KEY_PASSWORD_LENGTH = 8
|
||||
# ============================================================================
|
||||
|
||||
# Temporary file storage
|
||||
TEMP_FILE_EXPIRY = 300 # 5 minutes in seconds
|
||||
TEMP_FILE_EXPIRY_MINUTES = 5
|
||||
TEMP_FILE_EXPIRY = 600 # 10 minutes in seconds
|
||||
TEMP_FILE_EXPIRY_MINUTES = 10
|
||||
|
||||
# Thumbnail settings
|
||||
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnails
|
||||
@@ -171,15 +178,32 @@ BATCH_OUTPUT_SUFFIX = "_encoded"
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
"""Get the data directory path."""
|
||||
# Check multiple locations
|
||||
"""Get the data directory path.
|
||||
|
||||
Checks locations in order:
|
||||
1. Package data (installed via pip/wheel) using importlib.resources
|
||||
2. Development layout (src/stegasoo -> project root/data)
|
||||
3. Docker container (/app/data)
|
||||
4. Current working directory fallbacks
|
||||
"""
|
||||
# Try package data first (works when installed via pip)
|
||||
try:
|
||||
pkg_data = importlib.resources.files("stegasoo.data")
|
||||
# Check if the package data directory exists and has our files
|
||||
if (pkg_data / "bip39-words.txt").is_file():
|
||||
# Return as Path - importlib.resources.files returns a Traversable
|
||||
return Path(str(pkg_data))
|
||||
except (ModuleNotFoundError, TypeError):
|
||||
pass
|
||||
|
||||
# Fallback to file-based locations
|
||||
# From src/stegasoo/constants.py:
|
||||
# .parent = src/stegasoo/
|
||||
# .parent.parent = src/
|
||||
# .parent.parent.parent = project root (where data/ lives)
|
||||
candidates = [
|
||||
Path(__file__).parent / "data", # Installed package (stegasoo/data/)
|
||||
Path(__file__).parent.parent.parent / "data", # Development: src/stegasoo -> project root
|
||||
Path(__file__).parent / "data", # Installed package
|
||||
Path("/app/data"), # Docker
|
||||
Path.cwd() / "data", # Current directory
|
||||
Path.cwd().parent / "data", # One level up from cwd
|
||||
@@ -190,8 +214,8 @@ def get_data_dir() -> Path:
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
# Default to first candidate
|
||||
return candidates[0]
|
||||
# Default to package data path for clearer error messages
|
||||
return Path(__file__).parent / "data"
|
||||
|
||||
|
||||
def get_bip39_words() -> list[str]:
|
||||
|
||||
1
src/stegasoo/data/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Package data directory for stegasoo
|
||||
2048
src/stegasoo/data/bip39-words.txt
Normal file
@@ -12,7 +12,7 @@ Why is this cool?
|
||||
|
||||
Two approaches depending on what you want:
|
||||
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:
|
||||
- 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
|
||||
- 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
|
||||
@@ -35,32 +35,35 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
# Check for scipy availability (for PNG/DCT mode)
|
||||
# Prefer scipy.fft (newer, more stable) over scipy.fftpack
|
||||
try:
|
||||
from scipy.fft import dct, idct
|
||||
from scipy.fft import dct, idct, dctn, idctn
|
||||
|
||||
HAS_SCIPY = True
|
||||
except ImportError:
|
||||
try:
|
||||
from scipy.fftpack import dct, idct
|
||||
from scipy.fftpack import dct, idct, dctn, idctn
|
||||
|
||||
HAS_SCIPY = True
|
||||
except ImportError:
|
||||
HAS_SCIPY = False
|
||||
dct = 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:
|
||||
import jpegio as jio
|
||||
import jpeglib
|
||||
|
||||
HAS_JPEGIO = True
|
||||
HAS_JPEGIO = True # Keep variable name for compatibility
|
||||
except ImportError:
|
||||
HAS_JPEGIO = False
|
||||
jio = None
|
||||
jpeglib = None
|
||||
|
||||
# Import custom exceptions
|
||||
from .exceptions import InvalidMagicBytesError
|
||||
@@ -403,31 +406,72 @@ def _safe_idct2(block: np.ndarray) -> np.ndarray:
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _apply_exif_orientation(image_data: bytes) -> bytes:
|
||||
"""
|
||||
Apply EXIF orientation to image and return corrected bytes.
|
||||
|
||||
Portrait photos from cameras often have EXIF orientation metadata that
|
||||
tells viewers to rotate the image for display. However, the raw pixel
|
||||
data is stored in landscape orientation. This function applies that
|
||||
rotation to the pixel data so the output matches what users expect.
|
||||
|
||||
Without this, a portrait photo encoded with DCT would come out rotated
|
||||
90 degrees because we'd embed in the raw (landscape) orientation.
|
||||
"""
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
original_format = img.format or "JPEG"
|
||||
|
||||
# Apply EXIF orientation (rotates/flips pixels to match EXIF tag)
|
||||
# This also removes the EXIF orientation tag since it's now baked in
|
||||
corrected = ImageOps.exif_transpose(img)
|
||||
|
||||
# If no change was needed, return original data unchanged
|
||||
if corrected is img:
|
||||
img.close()
|
||||
return image_data
|
||||
|
||||
# Save corrected image back to bytes
|
||||
output = io.BytesIO()
|
||||
if original_format == "JPEG":
|
||||
if corrected.mode in ("RGBA", "P"):
|
||||
corrected = corrected.convert("RGB")
|
||||
corrected.save(output, format="JPEG", quality=95)
|
||||
else:
|
||||
corrected.save(output, format="PNG")
|
||||
|
||||
img.close()
|
||||
corrected.close()
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _to_grayscale(image_data: bytes) -> np.ndarray:
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
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:
|
||||
"""Extract Y (luminance) channel - float32 for memory efficiency."""
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
if img.mode != "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]
|
||||
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]]:
|
||||
"""Pad image to block boundaries - uses float32 for memory efficiency."""
|
||||
h, w = image.shape
|
||||
new_h = ((h + 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:
|
||||
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
|
||||
|
||||
# Simple edge replication for padding
|
||||
@@ -444,8 +488,9 @@ def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]:
|
||||
|
||||
|
||||
def _unpad_image(image: np.ndarray, original_size: tuple[int, int]) -> np.ndarray:
|
||||
"""Remove padding - uses float32 for memory efficiency."""
|
||||
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:
|
||||
@@ -543,20 +588,23 @@ def _rgb_to_ycbcr(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
- Cb/Cr are often subsampled (4:2:0) so Y has more capacity anyway
|
||||
|
||||
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)
|
||||
G = rgb[:, :, 1].astype(np.float64)
|
||||
B = rgb[:, :, 2].astype(np.float64)
|
||||
# Use float32 - sufficient precision for 8-bit images, halves memory
|
||||
R = rgb[:, :, 0].astype(np.float32)
|
||||
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 = 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 = 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 = 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
|
||||
@@ -569,11 +617,12 @@ def _ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray) -> np.ndarray:
|
||||
After embedding in the Y channel, we need to reconstruct RGB for display.
|
||||
The Cb/Cr channels are unchanged - we only touched luminance.
|
||||
"""
|
||||
# Use float32 for memory efficiency
|
||||
R = Y + 1.402 * (Cr - 128)
|
||||
G = Y - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 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[:, :, 1] = G
|
||||
rgb[:, :, 2] = B
|
||||
@@ -733,7 +782,7 @@ def estimate_capacity_comparison(image_data: bytes) -> dict:
|
||||
},
|
||||
"jpeg_native": {
|
||||
"available": HAS_JPEGIO,
|
||||
"note": "Uses jpegio for proper JPEG coefficient embedding",
|
||||
"note": "Uses jpeglib for proper JPEG coefficient embedding",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -753,6 +802,10 @@ def embed_in_dct(
|
||||
if color_mode not in ("color", "grayscale"):
|
||||
color_mode = "color"
|
||||
|
||||
# Apply EXIF orientation to carrier image before embedding
|
||||
# This ensures portrait photos are embedded in their correct visual orientation
|
||||
carrier_image = _apply_exif_orientation(carrier_image)
|
||||
|
||||
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
|
||||
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
|
||||
|
||||
@@ -818,8 +871,8 @@ def _embed_scipy_dct_safe(
|
||||
if img.mode == "RGBA":
|
||||
img = img.convert("RGB")
|
||||
|
||||
# Process color image
|
||||
rgb = np.array(img, dtype=np.float64, copy=True, order="C")
|
||||
# Process color image (float32 for memory efficiency)
|
||||
rgb = np.array(img, dtype=np.float32, copy=True, order="C")
|
||||
img.close()
|
||||
|
||||
Y, Cb, Cr = _rgb_to_ycbcr(rgb)
|
||||
@@ -891,61 +944,105 @@ def _embed_in_channel_safe(
|
||||
progress_file: str | None = None,
|
||||
) -> 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
|
||||
|
||||
# Create result with explicit new memory
|
||||
result = np.array(channel, dtype=np.float64, copy=True, order="C")
|
||||
# Create result with explicit new memory (float32 for memory efficiency)
|
||||
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
|
||||
total_blocks = len(block_order)
|
||||
block_idx = 0
|
||||
|
||||
for block_idx, block_num in enumerate(block_order):
|
||||
if bit_idx >= len(bits):
|
||||
break
|
||||
while block_idx < blocks_to_process and bit_idx < total_bits:
|
||||
# Determine batch size
|
||||
batch_end = min(block_idx + BATCH_SIZE, blocks_to_process)
|
||||
batch_order = block_order[block_idx:batch_end]
|
||||
batch_count = len(batch_order)
|
||||
|
||||
by = (block_num // blocks_x) * BLOCK_SIZE
|
||||
bx = (block_num % blocks_x) * BLOCK_SIZE
|
||||
# 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
|
||||
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
|
||||
block = np.array(
|
||||
result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE],
|
||||
dtype=np.float64,
|
||||
copy=True,
|
||||
order="C",
|
||||
)
|
||||
# Vectorized 2D DCT on all blocks at once
|
||||
dct_blocks = dctn(blocks, axes=(1, 2), norm="ortho")
|
||||
|
||||
# Apply safe DCT (row-by-row)
|
||||
dct_block = _safe_dct2(block)
|
||||
|
||||
# Embed bits
|
||||
for pos in DEFAULT_EMBED_POSITIONS:
|
||||
if bit_idx >= len(bits):
|
||||
# Embed bits in each block (vectorized where possible)
|
||||
for i in range(batch_count):
|
||||
if bit_idx >= total_bits:
|
||||
break
|
||||
dct_block[pos[0], pos[1]] = _embed_bit_in_coeff(
|
||||
float(dct_block[pos[0], pos[1]]), bits[bit_idx]
|
||||
)
|
||||
bit_idx += 1
|
||||
|
||||
# Apply safe inverse DCT
|
||||
modified_block = _safe_idct2(dct_block)
|
||||
# Get bits for this block
|
||||
block_bits = bits[bit_idx : bit_idx + bits_per_block]
|
||||
num_bits = len(block_bits)
|
||||
|
||||
# Copy back
|
||||
result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE] = modified_block
|
||||
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
|
||||
)
|
||||
|
||||
# Clean up this iteration
|
||||
del block, dct_block, modified_block
|
||||
bit_idx += num_bits
|
||||
|
||||
# Vectorized inverse DCT
|
||||
modified_blocks = idctn(dct_blocks, axes=(1, 2), norm="ortho")
|
||||
|
||||
# Copy modified blocks back to result
|
||||
for i, (by, bx) in enumerate(block_positions):
|
||||
result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE] = modified_blocks[i]
|
||||
|
||||
# Cleanup
|
||||
del blocks, dct_blocks, modified_blocks
|
||||
block_idx = batch_end
|
||||
|
||||
# Report progress periodically
|
||||
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
|
||||
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
|
||||
gc.collect()
|
||||
@@ -1029,7 +1126,7 @@ def _embed_jpegio(
|
||||
flags = FLAG_COLOR_MODE if color_mode == "color" else 0
|
||||
|
||||
try:
|
||||
jpeg = jio.read(input_path)
|
||||
jpeg = jpeglib.to_jpegio(jpeglib.read_dct(input_path))
|
||||
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
|
||||
|
||||
all_positions = _jpegio_get_usable_positions(coef_array)
|
||||
@@ -1064,6 +1161,10 @@ def _embed_jpegio(
|
||||
total_bits = len(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):
|
||||
if bit_idx >= len(bits):
|
||||
break
|
||||
@@ -1087,7 +1188,7 @@ def _embed_jpegio(
|
||||
if progress_file:
|
||||
_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:
|
||||
stego_bytes = f.read()
|
||||
@@ -1115,24 +1216,261 @@ def _embed_jpegio(
|
||||
pass
|
||||
|
||||
|
||||
def extract_from_dct(stego_image: bytes, seed: bytes) -> bytes:
|
||||
"""Extract data from DCT stego image."""
|
||||
img = Image.open(io.BytesIO(stego_image))
|
||||
fmt = img.format
|
||||
def _jpegtran_available() -> bool:
|
||||
"""Check if jpegtran is available on the system."""
|
||||
import shutil
|
||||
return shutil.which("jpegtran") is not None
|
||||
|
||||
|
||||
def _jpegtran_rotate(image_data: bytes, rotation: int) -> bytes:
|
||||
"""
|
||||
Losslessly rotate a JPEG using jpegtran.
|
||||
|
||||
This preserves DCT coefficients by rearranging blocks rather than
|
||||
re-encoding. Essential for rotating stego images without destroying
|
||||
the hidden data.
|
||||
|
||||
Args:
|
||||
image_data: JPEG image bytes
|
||||
rotation: Degrees clockwise (90, 180, or 270)
|
||||
|
||||
Returns:
|
||||
Rotated JPEG bytes with DCT coefficients preserved
|
||||
"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
if rotation not in (90, 180, 270):
|
||||
raise ValueError(f"Invalid rotation: {rotation}")
|
||||
|
||||
# Write input to temp file
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||
f.write(image_data)
|
||||
input_path = f.name
|
||||
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
|
||||
try:
|
||||
# jpegtran -rotate 90|180|270 -copy all
|
||||
# -copy all: preserve all metadata
|
||||
# NOTE: Don't use -trim as it drops edge blocks and destroys stego data
|
||||
# NOTE: Don't use -perfect as it fails on images with non-MCU-aligned edges
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-rotate", str(rotation), "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"jpegtran failed: {result.stderr.decode()}")
|
||||
|
||||
with open(output_path, "rb") as f:
|
||||
return f.read()
|
||||
finally:
|
||||
for path in [input_path, output_path]:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _rotate_image_bytes(image_data: bytes, rotation: int, lossless: bool = True) -> bytes:
|
||||
"""
|
||||
Rotate image by 90, 180, or 270 degrees and return as bytes.
|
||||
|
||||
For JPEGs with lossless=True (default), uses jpegtran to preserve DCT
|
||||
coefficients. This is essential for rotating stego images.
|
||||
|
||||
For PNGs or when jpegtran is unavailable, uses PIL (which re-encodes
|
||||
but PNGs are lossless anyway).
|
||||
"""
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
original_format = img.format or "PNG"
|
||||
img.close()
|
||||
|
||||
if fmt == "JPEG" and HAS_JPEGIO:
|
||||
# Use jpegtran for lossless JPEG rotation
|
||||
if lossless and original_format == "JPEG" and _jpegtran_available():
|
||||
return _jpegtran_rotate(image_data, rotation)
|
||||
|
||||
# Fallback to PIL for PNGs or when jpegtran unavailable
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# PIL rotation is counter-clockwise, we want clockwise
|
||||
# 90 CW = 270 CCW, 180 = 180, 270 CW = 90 CCW
|
||||
pil_rotation = {90: 270, 180: 180, 270: 90}[rotation]
|
||||
rotated = img.rotate(pil_rotation, expand=True)
|
||||
|
||||
output = io.BytesIO()
|
||||
# Save in original format if possible, fallback to PNG
|
||||
save_format = original_format if original_format in ("JPEG", "PNG") else "PNG"
|
||||
if save_format == "JPEG":
|
||||
rotated.save(output, format="JPEG", quality=95)
|
||||
else:
|
||||
rotated.save(output, format="PNG")
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _quick_validate_dct_header(image_data: bytes, seed: bytes) -> bool:
|
||||
"""
|
||||
Quick validation that only extracts enough DCT data to check magic bytes.
|
||||
Returns True if header looks valid, False otherwise.
|
||||
|
||||
This is much faster than full extraction - only processes first ~8 blocks.
|
||||
"""
|
||||
try:
|
||||
# Convert to grayscale for quick check
|
||||
gray = _to_grayscale(image_data)
|
||||
height, width = gray.shape
|
||||
padded, _ = _pad_to_blocks(gray)
|
||||
padded_h, padded_w = padded.shape
|
||||
blocks_x = padded_w // BLOCK_SIZE
|
||||
num_blocks = (padded_h // BLOCK_SIZE) * blocks_x
|
||||
|
||||
# Generate block order
|
||||
block_order = _generate_block_order(num_blocks, seed)
|
||||
|
||||
# Only extract first 8 blocks (enough for RS length prefix + header)
|
||||
# 8 blocks * 16 bits/block = 128 bits = 16 bytes (covers RS prefix)
|
||||
blocks_needed = min(8, len(block_order))
|
||||
|
||||
all_bits = []
|
||||
for block_num in block_order[:blocks_needed]:
|
||||
by = (block_num // blocks_x) * BLOCK_SIZE
|
||||
bx = (block_num % blocks_x) * BLOCK_SIZE
|
||||
block = padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE].astype(np.float32)
|
||||
|
||||
dct_block = dctn(block, norm="ortho")
|
||||
|
||||
for row, col in EMBED_POSITIONS:
|
||||
coef = dct_block[row, col]
|
||||
bit = _extract_bit_from_coeff(coef)
|
||||
all_bits.append(bit)
|
||||
|
||||
# Check RS format first (3 copies of 8-byte length header)
|
||||
if len(all_bits) >= RS_LENGTH_PREFIX_SIZE * 8:
|
||||
length_prefix_bits = all_bits[: RS_LENGTH_PREFIX_SIZE * 8]
|
||||
length_prefix_bytes = bytes(
|
||||
[
|
||||
sum(length_prefix_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
|
||||
for i in range(RS_LENGTH_PREFIX_SIZE)
|
||||
]
|
||||
)
|
||||
|
||||
# Check if 2+ copies match (indicates valid RS format)
|
||||
copies = []
|
||||
for i in range(RS_LENGTH_COPIES):
|
||||
start = i * RS_LENGTH_HEADER_SIZE
|
||||
end = start + RS_LENGTH_HEADER_SIZE
|
||||
copies.append(length_prefix_bytes[start:end])
|
||||
|
||||
from collections import Counter
|
||||
counter = Counter(copies)
|
||||
_, count = counter.most_common(1)[0]
|
||||
|
||||
if count >= 2:
|
||||
return True # Looks like valid RS format
|
||||
|
||||
# Check legacy format (magic bytes in first 10 bytes)
|
||||
if len(all_bits) >= HEADER_SIZE * 8:
|
||||
try:
|
||||
_parse_header(all_bits[: HEADER_SIZE * 8])
|
||||
return True # Magic bytes matched
|
||||
except (ValueError, InvalidMagicBytesError):
|
||||
pass
|
||||
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def extract_from_dct(
|
||||
stego_image: bytes,
|
||||
seed: bytes,
|
||||
progress_file: str | None = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Extract data from DCT stego image.
|
||||
|
||||
If extraction fails with InvalidMagicBytesError, automatically tries
|
||||
90°, 180°, and 270° rotations to handle images that were rotated after
|
||||
encoding (e.g., by external tools or EXIF orientation changes).
|
||||
|
||||
Uses quick header validation to skip obviously invalid rotations.
|
||||
"""
|
||||
rotations_to_try = [0, 90, 180, 270]
|
||||
last_error = None
|
||||
valid_rotations = []
|
||||
|
||||
# Phase 1: Quick validation to find candidate rotations
|
||||
for rotation in rotations_to_try:
|
||||
if rotation == 0:
|
||||
image_to_check = stego_image
|
||||
else:
|
||||
image_to_check = _rotate_image_bytes(stego_image, rotation)
|
||||
|
||||
if _quick_validate_dct_header(image_to_check, seed):
|
||||
valid_rotations.append((rotation, image_to_check))
|
||||
|
||||
# If no rotations pass quick check, try all anyway (fallback)
|
||||
if not valid_rotations:
|
||||
# Must try all rotations - quick validation might have failed due to
|
||||
# scipy vs jpegio differences or other edge cases
|
||||
for rotation in rotations_to_try:
|
||||
if rotation == 0:
|
||||
valid_rotations.append((0, stego_image))
|
||||
else:
|
||||
valid_rotations.append((rotation, _rotate_image_bytes(stego_image, rotation)))
|
||||
|
||||
# Phase 2: Full extraction on valid candidates
|
||||
for rotation, image_to_decode in valid_rotations:
|
||||
try:
|
||||
return _extract_jpegio(stego_image, seed)
|
||||
except ValueError:
|
||||
pass
|
||||
img = Image.open(io.BytesIO(image_to_decode))
|
||||
fmt = img.format
|
||||
img.close()
|
||||
|
||||
_check_scipy()
|
||||
return _extract_scipy_dct_safe(stego_image, seed)
|
||||
if fmt == "JPEG" and HAS_JPEGIO:
|
||||
try:
|
||||
result = _extract_jpegio(image_to_decode, seed, progress_file)
|
||||
if rotation != 0:
|
||||
try:
|
||||
from . import debug
|
||||
debug.print(f"DCT decode succeeded after {rotation}° rotation")
|
||||
except Exception:
|
||||
pass # Don't let debug logging break extraction
|
||||
return result
|
||||
except (ValueError, InvalidMagicBytesError) as e:
|
||||
last_error = e if isinstance(e, InvalidMagicBytesError) else last_error
|
||||
continue
|
||||
|
||||
_check_scipy()
|
||||
result = _extract_scipy_dct_safe(image_to_decode, seed, progress_file)
|
||||
if rotation != 0:
|
||||
try:
|
||||
from . import debug
|
||||
debug.print(f"DCT decode succeeded after {rotation}° rotation")
|
||||
except Exception:
|
||||
pass # Don't let debug logging break extraction
|
||||
return result
|
||||
|
||||
except InvalidMagicBytesError as e:
|
||||
last_error = e
|
||||
continue
|
||||
|
||||
# All rotations failed
|
||||
raise last_error or InvalidMagicBytesError("Not a Stegasoo image (tried all rotations)")
|
||||
|
||||
|
||||
def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
||||
"""Extract using safe DCT operations."""
|
||||
def _extract_scipy_dct_safe(
|
||||
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))
|
||||
width, height = img.size
|
||||
mode = img.mode
|
||||
@@ -1156,26 +1494,54 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
||||
|
||||
block_order = _generate_block_order(num_blocks, seed)
|
||||
|
||||
# Vectorized extraction: process blocks in batches for ~10x speedup
|
||||
# Batch size balances memory usage vs. parallelization benefit
|
||||
BATCH_SIZE = 500
|
||||
all_bits = []
|
||||
|
||||
for block_num in block_order:
|
||||
by = (block_num // blocks_x) * BLOCK_SIZE
|
||||
bx = (block_num % blocks_x) * BLOCK_SIZE
|
||||
# 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])
|
||||
|
||||
block = np.array(
|
||||
padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE],
|
||||
dtype=np.float64,
|
||||
copy=True,
|
||||
order="C",
|
||||
)
|
||||
dct_block = _safe_dct2(block)
|
||||
# Progress reporting interval - report frequently for responsive UI
|
||||
PROGRESS_INTERVAL = 500 # Report every N blocks (matches BATCH_SIZE)
|
||||
|
||||
for pos in DEFAULT_EMBED_POSITIONS:
|
||||
bit = _extract_bit_from_coeff(float(dct_block[pos[0], pos[1]]))
|
||||
all_bits.append(bit)
|
||||
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)
|
||||
|
||||
del block, dct_block
|
||||
# Extract blocks into 3D array (batch_count, 8, 8) - float32 for memory efficiency
|
||||
blocks = np.zeros((batch_count, BLOCK_SIZE, BLOCK_SIZE), dtype=np.float32)
|
||||
for i, block_num in enumerate(batch_order):
|
||||
by = (block_num // blocks_x) * BLOCK_SIZE
|
||||
bx = (block_num % blocks_x) * BLOCK_SIZE
|
||||
blocks[i] = padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE]
|
||||
|
||||
# Vectorized 2D DCT on all blocks at once (~10-15x faster than sequential)
|
||||
dct_blocks = dctn(blocks, axes=(1, 2), norm="ortho")
|
||||
|
||||
# Extract bits from embed positions (vectorized)
|
||||
# Shape: (batch_count, num_positions)
|
||||
coeffs = dct_blocks[:, embed_rows, embed_cols]
|
||||
|
||||
# Quantize and extract bits (vectorized)
|
||||
quantized = np.round(coeffs / QUANT_STEP).astype(int)
|
||||
bits = (quantized % 2).flatten().tolist()
|
||||
all_bits.extend(bits)
|
||||
|
||||
del blocks, dct_blocks, coeffs, quantized
|
||||
block_idx = batch_end
|
||||
|
||||
# Report progress (scale to 25-70% range, RS decode gets 70-100%)
|
||||
# Starts at 25% because decode.py writes 25% before calling extraction
|
||||
if progress_file and block_idx % PROGRESS_INTERVAL < BATCH_SIZE:
|
||||
extract_pct = 25 + int(45 * block_idx / num_blocks)
|
||||
_write_progress(progress_file, extract_pct, 100, "extracting")
|
||||
|
||||
# Check if we have enough bits (early exit)
|
||||
if len(all_bits) >= HEADER_SIZE * 8:
|
||||
try:
|
||||
_, flags, data_length = _parse_header(all_bits[: HEADER_SIZE * 8])
|
||||
@@ -1188,6 +1554,9 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
||||
del padded
|
||||
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)
|
||||
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)
|
||||
@@ -1240,10 +1609,16 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
||||
]
|
||||
)
|
||||
|
||||
# 75% - bits converted, starting RS decode (slow part)
|
||||
_write_progress(progress_file, 75, 100, "decoding")
|
||||
|
||||
try:
|
||||
# RS decode to get header + data
|
||||
raw_payload = _rs_decode(rs_encoded)
|
||||
|
||||
# 95% - RS decode done
|
||||
_write_progress(progress_file, 95, 100, "decoding")
|
||||
|
||||
# Parse header from decoded payload
|
||||
_, flags, data_length = _parse_header(
|
||||
[((raw_payload[i // 8] >> (7 - i % 8)) & 1) for i in range(HEADER_SIZE * 8)]
|
||||
@@ -1251,6 +1626,7 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
||||
|
||||
# Extract data
|
||||
data = raw_payload[HEADER_SIZE : HEADER_SIZE + data_length]
|
||||
_write_progress(progress_file, 100, 100, "complete")
|
||||
return data
|
||||
except (ValueError, struct.error):
|
||||
pass # Fall through to legacy format
|
||||
@@ -1266,13 +1642,20 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
||||
]
|
||||
)
|
||||
|
||||
_write_progress(progress_file, 100, 100, "complete")
|
||||
return data
|
||||
|
||||
|
||||
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."""
|
||||
import os
|
||||
|
||||
# Progress starts at 25% (decode.py writes 20% for Argon2, 25% before extraction)
|
||||
|
||||
# Normalize JPEG to avoid crashes with quality=100 images
|
||||
# (shouldn't happen with stego images, but be defensive)
|
||||
stego_image = _normalize_jpeg_for_jpegio(stego_image)
|
||||
@@ -1280,12 +1663,14 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
||||
temp_path = _jpegio_bytes_to_file(stego_image, suffix=".jpg")
|
||||
|
||||
try:
|
||||
jpeg = jio.read(temp_path)
|
||||
jpeg = jpeglib.to_jpegio(jpeglib.read_dct(temp_path))
|
||||
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
|
||||
|
||||
all_positions = _jpegio_get_usable_positions(coef_array)
|
||||
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)
|
||||
if HAS_REEDSOLO and len(all_positions) >= RS_LENGTH_PREFIX_SIZE * 8:
|
||||
# Extract length prefix (24 bytes: 3 copies of 8-byte header)
|
||||
@@ -1349,9 +1734,12 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
||||
)
|
||||
|
||||
try:
|
||||
_write_progress(progress_file, 75, 100, "decoding")
|
||||
raw_payload = _rs_decode(rs_encoded)
|
||||
_write_progress(progress_file, 95, 100, "decoding")
|
||||
_, flags, data_length = _jpegio_parse_header(raw_payload[:HEADER_SIZE])
|
||||
data = raw_payload[HEADER_SIZE : HEADER_SIZE + data_length]
|
||||
_write_progress(progress_file, 100, 100, "complete")
|
||||
return data
|
||||
except (ValueError, struct.error):
|
||||
pass # Fall through to legacy format
|
||||
@@ -1389,6 +1777,7 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
||||
]
|
||||
)
|
||||
|
||||
_write_progress(progress_file, 100, 100, "complete")
|
||||
return data
|
||||
|
||||
finally:
|
||||
|
||||
@@ -8,6 +8,7 @@ Changes in v4.0.0:
|
||||
- Improved error messages for channel key mismatches
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
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(
|
||||
stego_image: bytes,
|
||||
reference_photo: bytes,
|
||||
@@ -33,6 +50,7 @@ def decode(
|
||||
rsa_password: str | None = None,
|
||||
embed_mode: str = EMBED_MODE_AUTO,
|
||||
channel_key: str | bool | None = None,
|
||||
progress_file: str | None = None,
|
||||
) -> DecodeResult:
|
||||
"""
|
||||
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_password: Optional RSA key password
|
||||
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:
|
||||
- None or "auto": Use server's configured key
|
||||
- str: Use this specific channel key
|
||||
@@ -91,16 +110,23 @@ def decode(
|
||||
if rsa_key_data:
|
||||
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)
|
||||
from .crypto import derive_pixel_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
|
||||
encrypted = extract_from_image(
|
||||
stego_image,
|
||||
pixel_key,
|
||||
embed_mode=embed_mode,
|
||||
progress_file=progress_file,
|
||||
)
|
||||
|
||||
if not encrypted:
|
||||
@@ -126,6 +152,7 @@ def decode_file(
|
||||
rsa_password: str | None = None,
|
||||
embed_mode: str = EMBED_MODE_AUTO,
|
||||
channel_key: str | bool | None = None,
|
||||
progress_file: str | None = None,
|
||||
) -> Path:
|
||||
"""
|
||||
Decode a file from a stego image and save it.
|
||||
@@ -140,6 +167,7 @@ def decode_file(
|
||||
rsa_password: Optional RSA key password
|
||||
embed_mode: 'auto', 'lsb', or 'dct'
|
||||
channel_key: Channel key parameter (see decode())
|
||||
progress_file: Optional path to write progress JSON for UI polling
|
||||
|
||||
Returns:
|
||||
Path where file was saved
|
||||
@@ -156,6 +184,7 @@ def decode_file(
|
||||
rsa_password,
|
||||
embed_mode,
|
||||
channel_key,
|
||||
progress_file,
|
||||
)
|
||||
|
||||
if not result.is_file:
|
||||
@@ -184,6 +213,7 @@ def decode_text(
|
||||
rsa_password: str | None = None,
|
||||
embed_mode: str = EMBED_MODE_AUTO,
|
||||
channel_key: str | bool | None = None,
|
||||
progress_file: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Decode a text message from a stego image.
|
||||
@@ -199,6 +229,7 @@ def decode_text(
|
||||
rsa_password: Optional RSA key password
|
||||
embed_mode: 'auto', 'lsb', or 'dct'
|
||||
channel_key: Channel key parameter (see decode())
|
||||
progress_file: Optional path to write progress JSON for UI polling
|
||||
|
||||
Returns:
|
||||
Decoded message string
|
||||
@@ -215,6 +246,7 @@ def decode_text(
|
||||
rsa_password,
|
||||
embed_mode,
|
||||
channel_key,
|
||||
progress_file,
|
||||
)
|
||||
|
||||
if result.is_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.
|
||||
|
||||
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
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -136,7 +136,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey:
|
||||
Generate an RSA private key.
|
||||
|
||||
Args:
|
||||
bits: Key size (2048, 3072, or 4096)
|
||||
bits: Key size (2048 or 3072)
|
||||
|
||||
Returns:
|
||||
RSA private key object
|
||||
|
||||
@@ -8,6 +8,7 @@ IMPROVEMENTS IN THIS VERSION:
|
||||
- Much more robust PEM normalization
|
||||
- Better handling of QR code extraction edge cases
|
||||
- Improved error messages
|
||||
- v4.2.0: Added zstd compression (better ratio than zlib)
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -16,6 +17,14 @@ import zlib
|
||||
|
||||
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
|
||||
try:
|
||||
import qrcode
|
||||
@@ -42,30 +51,46 @@ from .constants import (
|
||||
)
|
||||
|
||||
# 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:
|
||||
"""
|
||||
Compress string data for QR code storage.
|
||||
|
||||
Uses zstd if available (better ratio), falls back to zlib.
|
||||
|
||||
Args:
|
||||
data: String to compress
|
||||
|
||||
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)
|
||||
encoded = base64.b64encode(compressed).decode("ascii")
|
||||
return COMPRESSION_PREFIX + encoded
|
||||
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")
|
||||
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:
|
||||
"""
|
||||
Decompress data from QR code.
|
||||
|
||||
Supports both zstd (STEGASOO-ZS:) and zlib (STEGASOO-Z:) formats.
|
||||
|
||||
Args:
|
||||
data: Compressed string with STEGASOO-Z: prefix
|
||||
data: Compressed string with STEGASOO-ZS: or STEGASOO-Z: prefix
|
||||
|
||||
Returns:
|
||||
Original uncompressed string
|
||||
@@ -73,12 +98,26 @@ def decompress_data(data: str) -> str:
|
||||
Raises:
|
||||
ValueError: If data is not valid compressed format
|
||||
"""
|
||||
if not data.startswith(COMPRESSION_PREFIX):
|
||||
raise ValueError("Data is not in compressed format")
|
||||
if data.startswith(COMPRESSION_PREFIX_ZSTD):
|
||||
# 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) :]
|
||||
compressed = base64.b64decode(encoded)
|
||||
return zlib.decompress(compressed).decode("utf-8")
|
||||
elif data.startswith(COMPRESSION_PREFIX_ZLIB):
|
||||
# Legacy zlib compression
|
||||
encoded = data[len(COMPRESSION_PREFIX_ZLIB):]
|
||||
compressed = base64.b64decode(encoded)
|
||||
return zlib.decompress(compressed).decode("utf-8")
|
||||
|
||||
else:
|
||||
raise ValueError("Data is not in compressed format")
|
||||
|
||||
|
||||
def normalize_pem(pem_data: str) -> str:
|
||||
@@ -166,8 +205,8 @@ def normalize_pem(pem_data: str) -> str:
|
||||
|
||||
|
||||
def is_compressed(data: str) -> bool:
|
||||
"""Check if data has compression prefix."""
|
||||
return data.startswith(COMPRESSION_PREFIX)
|
||||
"""Check if data has compression prefix (zstd or zlib)."""
|
||||
return data.startswith(COMPRESSION_PREFIX_ZSTD) or data.startswith(COMPRESSION_PREFIX_ZLIB)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
data: String data to encode
|
||||
compress: Whether to compress data first
|
||||
error_correction: QR error correction level (default: auto)
|
||||
output_format: Image format - 'png' or 'jpg'/'jpeg'
|
||||
|
||||
Returns:
|
||||
PNG image bytes
|
||||
Image bytes in requested format
|
||||
|
||||
Raises:
|
||||
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")
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
fmt = output_format.lower()
|
||||
if fmt in ("jpg", "jpeg"):
|
||||
# Convert to RGB for JPEG (no alpha channel)
|
||||
img = img.convert("RGB")
|
||||
img.save(buf, format="JPEG", quality=95)
|
||||
else:
|
||||
img.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
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:
|
||||
"""
|
||||
Read QR code from image data.
|
||||
|
||||
@@ -156,7 +156,7 @@ def has_dct_support() -> bool:
|
||||
dct_mod = _get_dct_module()
|
||||
return dct_mod.has_dct_support()
|
||||
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
|
||||
|
||||
|
||||
@@ -746,6 +746,10 @@ def _embed_lsb(
|
||||
modified_pixels = 0
|
||||
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):
|
||||
if bit_idx >= len(binary_data):
|
||||
break
|
||||
@@ -839,6 +843,7 @@ def extract_from_image(
|
||||
pixel_key: bytes,
|
||||
bits_per_channel: int = 1,
|
||||
embed_mode: str = EMBED_MODE_AUTO,
|
||||
progress_file: str | None = None,
|
||||
) -> bytes | None:
|
||||
"""
|
||||
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)
|
||||
bits_per_channel: Bits per channel (LSB mode only)
|
||||
embed_mode: 'auto' (try both), 'lsb', or 'dct'
|
||||
progress_file: Optional path to write progress JSON for UI polling
|
||||
|
||||
Returns:
|
||||
Extracted data bytes, or None if extraction fails
|
||||
@@ -863,7 +869,7 @@ def extract_from_image(
|
||||
|
||||
if has_dct_support():
|
||||
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:
|
||||
debug.print("Auto-detect: DCT extraction succeeded")
|
||||
return result
|
||||
@@ -875,18 +881,22 @@ def extract_from_image(
|
||||
elif embed_mode == EMBED_MODE_DCT:
|
||||
if not has_dct_support():
|
||||
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
|
||||
else:
|
||||
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."""
|
||||
try:
|
||||
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:
|
||||
debug.print(f"DCT extraction failed: {e}")
|
||||
return None
|
||||
@@ -1087,7 +1097,7 @@ def peek_image(image_data: bytes) -> dict:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try DCT extraction (requires scipy/jpegio)
|
||||
# Try DCT extraction (requires scipy/jpeglib)
|
||||
try:
|
||||
from .dct_steganography import HAS_JPEGIO, HAS_SCIPY
|
||||
|
||||
|
||||
@@ -66,9 +66,15 @@ def read_image_exif(image_data: bytes) -> dict:
|
||||
# Convert bytes to string if possible
|
||||
elif isinstance(value, bytes):
|
||||
try:
|
||||
result[tag] = value.decode("utf-8", errors="replace").strip("\x00")
|
||||
except Exception:
|
||||
result[tag] = f"<{len(value)} bytes>"
|
||||
# Try to decode as ASCII/UTF-8 text
|
||||
decoded = value.decode("utf-8", errors="strict").strip("\x00")
|
||||
# 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
|
||||
elif isinstance(value, tuple) and value and hasattr(value[0], "numerator"):
|
||||
result[tag] = [float(v) for v in value]
|
||||
|
||||
107
test-aur-build.sh
Normal file
@@ -0,0 +1,107 @@
|
||||
#!/bin/bash
|
||||
# Test AUR package builds in a clean Arch container
|
||||
#
|
||||
# Usage: sudo ./test-aur-build.sh [package]
|
||||
# package: all (default), full, cli, api
|
||||
|
||||
set -e
|
||||
|
||||
PACKAGE="${1:-all}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "=== Stegasoo AUR Build Test ==="
|
||||
echo "Package: $PACKAGE"
|
||||
echo ""
|
||||
|
||||
# Create a test script to run inside container
|
||||
cat > /tmp/aur-build-test.sh << 'INNERSCRIPT'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Update system
|
||||
pacman -Syu --noconfirm
|
||||
|
||||
# Install build dependencies
|
||||
pacman -S --noconfirm --needed \
|
||||
base-devel git python python-build python-hatchling \
|
||||
zbar
|
||||
|
||||
# Create build user (makepkg won't run as root)
|
||||
useradd -m builder
|
||||
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||
|
||||
# Copy source to build location
|
||||
cp -r /src /home/builder/stegasoo
|
||||
chown -R builder:builder /home/builder/stegasoo
|
||||
|
||||
build_package() {
|
||||
local pkg_dir="$1"
|
||||
local pkg_name="$2"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Building: $pkg_name"
|
||||
echo "=========================================="
|
||||
|
||||
cd "/home/builder/stegasoo/$pkg_dir"
|
||||
|
||||
# Build as non-root user
|
||||
sudo -u builder makepkg -sf --noconfirm
|
||||
|
||||
# Show result
|
||||
ls -lh *.pkg.tar.zst
|
||||
|
||||
# Test install
|
||||
echo "Installing $pkg_name..."
|
||||
pacman -U --noconfirm *.pkg.tar.zst
|
||||
|
||||
# Quick test
|
||||
echo "Testing $pkg_name..."
|
||||
stegasoo --version
|
||||
stegasoo --help | head -20
|
||||
|
||||
# Uninstall for next test
|
||||
pacman -R --noconfirm "${pkg_name%-git}" 2>/dev/null || pacman -R --noconfirm "$pkg_name" 2>/dev/null || true
|
||||
|
||||
echo "$pkg_name: SUCCESS"
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
full)
|
||||
build_package "aur" "stegasoo-git"
|
||||
;;
|
||||
cli)
|
||||
build_package "aur-cli" "stegasoo-cli-git"
|
||||
;;
|
||||
api)
|
||||
build_package "aur-api" "stegasoo-api-git"
|
||||
;;
|
||||
all)
|
||||
build_package "aur" "stegasoo-git"
|
||||
build_package "aur-cli" "stegasoo-cli-git"
|
||||
build_package "aur-api" "stegasoo-api-git"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown package: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "All builds completed successfully!"
|
||||
echo "=========================================="
|
||||
INNERSCRIPT
|
||||
|
||||
chmod +x /tmp/aur-build-test.sh
|
||||
|
||||
# Run in Arch container
|
||||
echo "Starting Arch container..."
|
||||
docker run --rm -it \
|
||||
-v "$SCRIPT_DIR:/src:ro" \
|
||||
-v "/tmp/aur-build-test.sh:/build.sh:ro" \
|
||||
archlinux:latest \
|
||||
/bin/bash -c "chmod +x /build.sh && /build.sh $PACKAGE"
|
||||
|
||||
echo ""
|
||||
echo "=== Build test complete ==="
|
||||
130
test-aur-nspawn.sh
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/bin/bash
|
||||
# Test AUR package builds using systemd-nspawn
|
||||
#
|
||||
# Usage: sudo ./test-aur-nspawn.sh [package]
|
||||
# package: all (default), full, cli, api
|
||||
#
|
||||
# First run creates Arch root at /tmp/arch-build-root
|
||||
|
||||
set -e
|
||||
|
||||
PACKAGE="${1:-all}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ARCH_ROOT="/tmp/arch-build-root"
|
||||
|
||||
echo "=== Stegasoo AUR Build Test (nspawn) ==="
|
||||
echo "Package: $PACKAGE"
|
||||
echo "Arch root: $ARCH_ROOT"
|
||||
echo ""
|
||||
|
||||
# Check for root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root (sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create Arch root if it doesn't exist
|
||||
if [ ! -d "$ARCH_ROOT/usr" ]; then
|
||||
echo "Creating Arch root (first time setup)..."
|
||||
mkdir -p "$ARCH_ROOT"
|
||||
pacstrap -c "$ARCH_ROOT" base base-devel git python python-build python-hatchling zbar
|
||||
echo "Arch root created."
|
||||
else
|
||||
echo "Using existing Arch root."
|
||||
# Update packages
|
||||
arch-chroot "$ARCH_ROOT" pacman -Syu --noconfirm
|
||||
fi
|
||||
|
||||
# Create build user if needed
|
||||
if ! arch-chroot "$ARCH_ROOT" id builder &>/dev/null; then
|
||||
arch-chroot "$ARCH_ROOT" useradd -m builder
|
||||
echo "builder ALL=(ALL) NOPASSWD: ALL" >> "$ARCH_ROOT/etc/sudoers"
|
||||
fi
|
||||
|
||||
# Copy source
|
||||
rm -rf "$ARCH_ROOT/home/builder/stegasoo"
|
||||
cp -r "$SCRIPT_DIR" "$ARCH_ROOT/home/builder/stegasoo"
|
||||
arch-chroot "$ARCH_ROOT" chown -R builder:builder /home/builder/stegasoo
|
||||
|
||||
# Create build script
|
||||
cat > "$ARCH_ROOT/tmp/build.sh" << 'BUILDSCRIPT'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
build_package() {
|
||||
local pkg_dir="$1"
|
||||
local pkg_name="$2"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Building: $pkg_name"
|
||||
echo "=========================================="
|
||||
|
||||
cd "/home/builder/stegasoo/$pkg_dir"
|
||||
|
||||
# Clean previous builds
|
||||
rm -rf src pkg *.pkg.tar.zst "${pkg_name}" 2>/dev/null || true
|
||||
|
||||
# Build as non-root user
|
||||
sudo -u builder makepkg -sf --noconfirm
|
||||
|
||||
# Show result
|
||||
ls -lh *.pkg.tar.zst
|
||||
|
||||
# Test install
|
||||
echo "Installing $pkg_name..."
|
||||
pacman -U --noconfirm *.pkg.tar.zst
|
||||
|
||||
# Quick test
|
||||
echo "Testing $pkg_name..."
|
||||
/usr/bin/stegasoo --version
|
||||
|
||||
# More tests for API package
|
||||
if [[ "$pkg_name" == *"api"* ]]; then
|
||||
/usr/bin/stegasoo api --help
|
||||
/usr/bin/stegasoo api keys list
|
||||
fi
|
||||
|
||||
# Uninstall for next test
|
||||
pacman -Rns --noconfirm $(pacman -Qq | grep stegasoo) 2>/dev/null || true
|
||||
|
||||
echo "$pkg_name: SUCCESS"
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
full)
|
||||
build_package "aur" "stegasoo-git"
|
||||
;;
|
||||
cli)
|
||||
build_package "aur-cli" "stegasoo-cli-git"
|
||||
;;
|
||||
api)
|
||||
build_package "aur-api" "stegasoo-api-git"
|
||||
;;
|
||||
all)
|
||||
build_package "aur-cli" "stegasoo-cli-git"
|
||||
build_package "aur-api" "stegasoo-api-git"
|
||||
build_package "aur" "stegasoo-git"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown package: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "All builds completed successfully!"
|
||||
echo "=========================================="
|
||||
BUILDSCRIPT
|
||||
|
||||
chmod +x "$ARCH_ROOT/tmp/build.sh"
|
||||
|
||||
# Run build in nspawn container
|
||||
echo "Starting nspawn container..."
|
||||
systemd-nspawn -D "$ARCH_ROOT" --bind-ro="$SCRIPT_DIR:/home/builder/stegasoo" /tmp/build.sh "$PACKAGE"
|
||||
|
||||
echo ""
|
||||
echo "=== Build test complete ==="
|
||||
echo "Arch root preserved at: $ARCH_ROOT"
|
||||
echo "To clean up: sudo rm -rf $ARCH_ROOT"
|
||||
BIN
test_data/carrier3.JPG
Executable file
|
After Width: | Height: | Size: 12 MiB |
30
test_data/phonebooth.pem
Normal 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
|
After Width: | Height: | Size: 422 KiB |