14 Commits

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

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

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

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

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

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

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

Updated systemd service to use TLS.

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

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

Rotate tool supports flip-only operations without rotation.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 16:36:52 -05:00
32 changed files with 2214 additions and 125 deletions

View File

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

3
.gitignore vendored
View File

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

View File

@@ -1,3 +1,72 @@
## Stegasoo v4.2.1
### API Security
**API Key Authentication**
- All protected endpoints require `X-API-Key` header
- Keys stored hashed (SHA-256) in `~/.stegasoo/api_keys.json`
- Auth disabled when no keys configured (easy onboarding)
**TLS Support**
- Self-signed certificates auto-generated on first run
- Certs valid for localhost, all local IPs, hostname.local
- CLI: `stegasoo api tls generate` to pre-generate
### CLI Improvements
**New API Management Commands**
```bash
stegasoo api keys create NAME # Create new key
stegasoo api keys list # List API keys
stegasoo api tls generate # Generate TLS cert
stegasoo api serve # Start server with TLS
```
**New Image Tools**
```bash
stegasoo tools compress IMG -q 75 # JPEG compression
stegasoo tools rotate IMG -r 90 # Lossless rotation
stegasoo tools convert IMG -f png # Format conversion
```
### Bug Fixes
- **DCT rotation**: Portrait photos no longer export rotated 90°
- **jpegtran**: Removed `-trim` flag that destroyed DCT stego data
- **CLI encode**: Now outputs JPEG when carrier is JPEG (was always PNG)
- **Import paths**: Fixed for installed packages (AUR/pip)
### Installation
**AUR (Arch Linux)**
```bash
yay -S stegasoo-git # Full (Web + API + CLI)
yay -S stegasoo-cli-git # CLI only
```
**Docker**
```bash
docker-compose -f docker/docker-compose.yml up -d
```
**Raspberry Pi**
Flash `stegasoo-rpi-4.2.1.img.zst.zip` to SD card.
Default login: `admin` / `stegasoo`
### Requirements
- Python 3.11 - 3.14 (dropped 3.10 support)
### Release Assets
| File | Description |
|------|-------------|
| `stegasoo-rpi-4.2.1.img.zst.zip` | Raspberry Pi SD card image |
| `stegasoo-docker-base-4.2.1.tar.zst` | Docker base image |
| Source code (zip/tar.gz) | Auto-generated |
---
## Stegasoo v4.2.0 ## Stegasoo v4.2.0
### Performance Optimizations ### Performance Optimizations
@@ -55,32 +124,8 @@ Major performance improvements for Raspberry Pi and resource-constrained deploym
|--------|--------|--------|-------------| |--------|--------|--------|-------------|
| Decode (1MB) | ~2.6s | ~0.8s | **70% faster** | | Decode (1MB) | ~2.6s | ~0.8s | **70% faster** |
| Peak RAM | 211 MB | 107 MB | **50% less** | | Peak RAM | 211 MB | 107 MB | **50% less** |
| Concurrent API | No | Yes | | | Concurrent API | No | Yes | check |
| QR Compression | zlib | zstd | **~15% smaller** | | QR Compression | zlib | zstd | **~15% smaller** |
### Raspberry Pi Image
Download `stegasoo-rpi-4.2.0_final.img.zst` from Releases.
```bash
# Flash (auto-detects SD card)
sudo ./rpi/flash-image.sh stegasoo-rpi-4.2.0_final.img.zst
# Or manual
zstdcat stegasoo-rpi-4.2.0_final.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
```
Default login: `admin` / `stegasoo`
### Docker
```bash
# Build and run
docker build -f docker/Dockerfile.base -t stegasoo-base:latest .
docker-compose -f docker/docker-compose.yml up -d
# Or individual services
docker-compose -f docker/docker-compose.yml up -d web # Web UI on :5000
docker-compose -f docker/docker-compose.yml up -d api # REST API on :8000
```
### Full Changelog ### Full Changelog
See [CHANGELOG.md](CHANGELOG.md) for complete version history. See [CHANGELOG.md](CHANGELOG.md) for complete version history.

54
TODO-4.2.1.md Normal file
View File

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

23
aur-api/.SRCINFO Normal file
View File

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

109
aur-api/PKGBUILD Normal file
View File

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

View File

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

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

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

22
aur-cli/.SRCINFO Normal file
View File

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

69
aur-cli/PKGBUILD Normal file
View File

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

View File

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

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

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

View File

@@ -1,6 +1,6 @@
# Maintainer: Aaron D. Lee <your-email@example.com> # Maintainer: Aaron D. Lee <your-email@example.com>
pkgname=stegasoo-git pkgname=stegasoo-git
pkgver=4.2.0.r0.g530e5de pkgver=4.2.1
pkgrel=1 pkgrel=1
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication" pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
arch=('x86_64') arch=('x86_64')
@@ -27,7 +27,7 @@ sha256sums=('SKIP')
pkgver() { pkgver() {
cd "$pkgname" cd "$pkgname"
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \ git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
printf "%s.r%s.g%s" "4.2.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
} }
build() { build() {
@@ -98,7 +98,7 @@ EOF
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
[Unit] [Unit]
Description=Stegasoo REST API Description=Stegasoo REST API (HTTPS)
After=network.target After=network.target
[Service] [Service]
@@ -106,7 +106,11 @@ Type=simple
User=stegasoo User=stegasoo
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/api WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/api
Environment="PATH=/opt/stegasoo/venv/bin" Environment="PATH=/opt/stegasoo/venv/bin"
ExecStart=/opt/stegasoo/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 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 Restart=on-failure
RestartSec=5 RestartSec=5

View File

@@ -54,4 +54,4 @@ RUN python -c "import jpegio; import scipy; import numpy; import zstandard; prin
# Label for tracking # Label for tracking
LABEL org.opencontainers.image.title="Stegasoo Base" LABEL org.opencontainers.image.title="Stegasoo Base"
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo" LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
LABEL org.opencontainers.image.version="4.2.0" LABEL org.opencontainers.image.version="4.2.1"

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

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

View File

@@ -1,10 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Stegasoo REST API (v4.2.0) Stegasoo REST API (v4.2.1)
FastAPI-based REST API for steganography operations. FastAPI-based REST API for steganography operations.
Supports both text messages and file embedding. Supports both text messages and file embedding.
CHANGES in v4.2.1:
- API key authentication (X-API-Key header)
- TLS support with self-signed certificates
- /auth/* endpoints for key management
CHANGES in v4.2.0: CHANGES in v4.2.0:
- Async encode/decode operations (run in thread pool) - Async encode/decode operations (run in thread pool)
- Server can handle concurrent requests without blocking - Server can handle concurrent requests without blocking
@@ -32,10 +37,31 @@ from functools import partial
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import JSONResponse, Response from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# API Key Authentication
try:
from .auth import (
require_api_key,
get_api_key_status,
add_api_key,
remove_api_key,
list_api_keys,
is_auth_enabled,
)
except ImportError:
# When running directly (not as package)
from auth import (
require_api_key,
get_api_key_status,
add_api_key,
remove_api_key,
list_api_keys,
is_auth_enabled,
)
# Add parent to path for development # Add parent to path for development
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
@@ -357,6 +383,23 @@ class ChannelSetRequest(BaseModel):
location: str = Field(default="user", description="'user' or 'project'") location: str = Field(default="user", description="'user' or 'project'")
class AuthStatusResponse(BaseModel):
"""Response for API key authentication status."""
enabled: bool = Field(description="Whether API key auth is enabled")
total_keys: int = Field(description="Total number of configured API keys")
user_keys: int = Field(description="Keys in user config")
project_keys: int = Field(description="Keys in project config")
env_configured: bool = Field(description="Whether env var key is set")
class AuthKeyInfo(BaseModel):
"""Info about a single API key (not the actual key)."""
name: str
created: str
class ModesResponse(BaseModel): class ModesResponse(BaseModel):
"""Response showing available embedding modes.""" """Response showing available embedding modes."""
@@ -614,6 +657,7 @@ async def api_channel_status(
@app.post("/channel/generate", response_model=ChannelGenerateResponse) @app.post("/channel/generate", response_model=ChannelGenerateResponse)
async def api_channel_generate( async def api_channel_generate(
_: str = Depends(require_api_key),
save: bool = Query(False, description="Save to user config"), save: bool = Query(False, description="Save to user config"),
save_project: bool = Query(False, description="Save to project config"), save_project: bool = Query(False, description="Save to project config"),
): ):
@@ -652,7 +696,7 @@ async def api_channel_generate(
@app.post("/channel/set") @app.post("/channel/set")
async def api_channel_set(request: ChannelSetRequest): async def api_channel_set(request: ChannelSetRequest, _: str = Depends(require_api_key)):
""" """
Set/save a channel key to config. Set/save a channel key to config.
@@ -678,6 +722,7 @@ async def api_channel_set(request: ChannelSetRequest):
@app.delete("/channel") @app.delete("/channel")
async def api_channel_clear( async def api_channel_clear(
_: str = Depends(require_api_key),
location: str = Query("user", description="'user', 'project', or 'all'") location: str = Query("user", description="'user', 'project', or 'all'")
): ):
""" """
@@ -704,8 +749,98 @@ async def api_channel_clear(
} }
# ============================================================================
# ROUTES - AUTHENTICATION (v4.2.1)
# ============================================================================
@app.get("/auth/status", response_model=AuthStatusResponse)
async def api_auth_status():
"""
Get API key authentication status.
v4.2.1: New endpoint for auth status.
Returns whether auth is enabled and key counts.
"""
status = get_api_key_status()
return AuthStatusResponse(
enabled=status["enabled"],
total_keys=status["total_keys"],
user_keys=status["user_keys"],
project_keys=status["project_keys"],
env_configured=status["env_configured"],
)
@app.get("/auth/keys", response_model=list[AuthKeyInfo])
async def api_auth_list_keys(
location: str = Query("user", description="'user' or 'project'"),
_: str = Depends(require_api_key),
):
"""
List configured API keys (names only, not actual keys).
v4.2.1: New endpoint for auth management.
Requires authentication.
"""
if location not in ("user", "project"):
raise HTTPException(400, "location must be 'user' or 'project'")
keys = list_api_keys(location)
return [AuthKeyInfo(name=k["name"], created=k["created"]) for k in keys]
@app.post("/auth/keys")
async def api_auth_create_key(
name: str = Query(..., description="Name for the new API key"),
location: str = Query("user", description="'user' or 'project'"),
_: str = Depends(require_api_key),
):
"""
Create a new API key.
v4.2.1: New endpoint for auth management.
Returns the key ONCE - it cannot be retrieved again!
Requires authentication (or no keys configured yet).
"""
if location not in ("user", "project"):
raise HTTPException(400, "location must be 'user' or 'project'")
try:
key = add_api_key(name, location)
return {
"success": True,
"name": name,
"key": key,
"warning": "Save this key now! It cannot be retrieved again.",
}
except ValueError as e:
raise HTTPException(400, str(e))
@app.delete("/auth/keys")
async def api_auth_delete_key(
name: str = Query(..., description="Name of key to delete"),
location: str = Query("user", description="'user' or 'project'"),
_: str = Depends(require_api_key),
):
"""
Delete an API key by name.
v4.2.1: New endpoint for auth management.
Requires authentication.
"""
if location not in ("user", "project"):
raise HTTPException(400, "location must be 'user' or 'project'")
if remove_api_key(name, location):
return {"success": True, "deleted": name}
else:
raise HTTPException(404, f"Key '{name}' not found in {location} config")
@app.post("/compare", response_model=CompareModesResponse) @app.post("/compare", response_model=CompareModesResponse)
async def api_compare_modes(request: CompareModesRequest): async def api_compare_modes(request: CompareModesRequest, _: str = Depends(require_api_key)):
""" """
Compare LSB and DCT embedding modes for a carrier image. Compare LSB and DCT embedding modes for a carrier image.
@@ -763,7 +898,7 @@ async def api_compare_modes(request: CompareModesRequest):
@app.post("/will-fit", response_model=WillFitResponse) @app.post("/will-fit", response_model=WillFitResponse)
async def api_will_fit(request: WillFitRequest): async def api_will_fit(request: WillFitRequest, _: str = Depends(require_api_key)):
""" """
Check if a payload of given size will fit in the carrier image. Check if a payload of given size will fit in the carrier image.
@@ -799,6 +934,7 @@ async def api_will_fit(request: WillFitRequest):
@app.post("/extract-key-from-qr", response_model=QrExtractResponse) @app.post("/extract-key-from-qr", response_model=QrExtractResponse)
async def api_extract_key_from_qr( async def api_extract_key_from_qr(
_: str = Depends(require_api_key),
qr_image: UploadFile = File(..., description="QR code image containing RSA key") qr_image: UploadFile = File(..., description="QR code image containing RSA key")
): ):
""" """
@@ -823,7 +959,7 @@ async def api_extract_key_from_qr(
@app.post("/generate-key-qr", response_model=QrGenerateResponse) @app.post("/generate-key-qr", response_model=QrGenerateResponse)
async def api_generate_key_qr(request: QrGenerateRequest): async def api_generate_key_qr(request: QrGenerateRequest, _: str = Depends(require_api_key)):
""" """
Generate QR code from an RSA private key. Generate QR code from an RSA private key.
@@ -873,7 +1009,7 @@ async def api_generate_key_qr(request: QrGenerateRequest):
@app.post("/generate", response_model=GenerateResponse) @app.post("/generate", response_model=GenerateResponse)
async def api_generate(request: GenerateRequest): async def api_generate(request: GenerateRequest, _: str = Depends(require_api_key)):
""" """
Generate credentials for encoding/decoding. Generate credentials for encoding/decoding.
@@ -955,7 +1091,7 @@ def _get_output_info(embed_mode: str, dct_output_format: str, dct_color_mode: st
@app.post("/encode", response_model=EncodeResponse) @app.post("/encode", response_model=EncodeResponse)
async def api_encode(request: EncodeRequest): async def api_encode(request: EncodeRequest, _: str = Depends(require_api_key)):
""" """
Encode a text message into an image. Encode a text message into an image.
@@ -1027,7 +1163,7 @@ async def api_encode(request: EncodeRequest):
@app.post("/encode/file", response_model=EncodeResponse) @app.post("/encode/file", response_model=EncodeResponse)
async def api_encode_file(request: EncodeFileRequest): async def api_encode_file(request: EncodeFileRequest, _: str = Depends(require_api_key)):
""" """
Encode a file into an image (JSON with base64). Encode a file into an image (JSON with base64).
@@ -1109,7 +1245,7 @@ async def api_encode_file(request: EncodeFileRequest):
@app.post("/decode", response_model=DecodeResponse) @app.post("/decode", response_model=DecodeResponse)
async def api_decode(request: DecodeRequest): async def api_decode(request: DecodeRequest, _: str = Depends(require_api_key)):
""" """
Decode a message or file from a stego image. Decode a message or file from a stego image.
@@ -1172,6 +1308,7 @@ async def api_decode(request: DecodeRequest):
@app.post("/encode/multipart") @app.post("/encode/multipart")
async def api_encode_multipart( async def api_encode_multipart(
_: str = Depends(require_api_key),
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"), passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
reference_photo: UploadFile = File(...), reference_photo: UploadFile = File(...),
carrier: UploadFile = File(...), carrier: UploadFile = File(...),
@@ -1313,6 +1450,7 @@ async def api_encode_multipart(
@app.post("/decode/multipart", response_model=DecodeResponse) @app.post("/decode/multipart", response_model=DecodeResponse)
async def api_decode_multipart( async def api_decode_multipart(
_: str = Depends(require_api_key),
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"), passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
reference_photo: UploadFile = File(...), reference_photo: UploadFile = File(...),
stego_image: UploadFile = File(...), stego_image: UploadFile = File(...),
@@ -1418,6 +1556,7 @@ async def api_decode_multipart(
@app.post("/image/info", response_model=ImageInfoResponse) @app.post("/image/info", response_model=ImageInfoResponse)
async def api_image_info( async def api_image_info(
_: str = Depends(require_api_key),
image: UploadFile = File(...), image: UploadFile = File(...),
include_modes: bool = Query(True, description="Include capacity by mode (v3.0+)"), include_modes: bool = Query(True, description="Include capacity by mode (v3.0+)"),
): ):

View File

@@ -2100,8 +2100,11 @@ def api_tools_exif_clear():
@app.route("/api/tools/rotate", methods=["POST"]) @app.route("/api/tools/rotate", methods=["POST"])
@login_required @login_required
def api_tools_rotate(): def api_tools_rotate():
"""Rotate and/or flip an image.""" """Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
from PIL import Image from PIL import Image
import shutil
import subprocess
import tempfile
image_file = request.files.get("image") image_file = request.files.get("image")
if not image_file: if not image_file:
@@ -2112,22 +2115,115 @@ def api_tools_rotate():
flip_v = request.form.get("flip_v", "false").lower() == "true" flip_v = request.form.get("flip_v", "false").lower() == "true"
try: try:
img = Image.open(io.BytesIO(image_file.read())) image_data = image_file.read()
img = Image.open(io.BytesIO(image_data))
original_format = img.format # JPEG, PNG, etc.
img.close()
# Apply rotation (PIL rotates counter-clockwise, so negate) # For JPEGs, use jpegtran for lossless rotation/flip (preserves DCT stego)
if rotation: has_jpegtran = shutil.which("jpegtran") is not None
img = img.rotate(-rotation, expand=True) use_jpegtran = original_format == "JPEG" and has_jpegtran and (rotation or flip_h or flip_v)
# Apply flips if use_jpegtran:
if flip_h: # Chain jpegtran operations for lossless transformation
img = img.transpose(Image.FLIP_LEFT_RIGHT) current_data = image_data
if flip_v:
img = img.transpose(Image.FLIP_TOP_BOTTOM)
# Output as PNG (lossless) # Apply rotation first
buffer = io.BytesIO() if rotation in (90, 180, 270):
img.save(buffer, format="PNG") with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
buffer.seek(0) f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-rotate", str(rotation), "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
# Apply horizontal flip
if flip_h:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-flip", "horizontal", "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
# Apply vertical flip
if flip_v:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-flip", "vertical", "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
buffer = io.BytesIO(current_data)
mimetype = "image/jpeg"
ext = "jpg"
else:
# Fallback to PIL for non-JPEGs or when jpegtran unavailable
img = Image.open(io.BytesIO(image_data))
# Apply rotation (PIL rotates counter-clockwise, so negate)
if rotation:
img = img.rotate(-rotation, expand=True)
# Apply flips
if flip_h:
img = img.transpose(Image.FLIP_LEFT_RIGHT)
if flip_v:
img = img.transpose(Image.FLIP_TOP_BOTTOM)
# Preserve original format
buffer = io.BytesIO()
if original_format == "JPEG":
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(buffer, format="JPEG", quality=95)
mimetype = "image/jpeg"
ext = "jpg"
else:
img.save(buffer, format="PNG")
mimetype = "image/png"
ext = "png"
buffer.seek(0)
stem = ( stem = (
image_file.filename.rsplit(".", 1)[0] image_file.filename.rsplit(".", 1)[0]
@@ -2136,9 +2232,9 @@ def api_tools_rotate():
) )
return send_file( return send_file(
buffer, buffer,
mimetype="image/png", mimetype=mimetype,
as_attachment=True, as_attachment=True,
download_name=f"{stem}_transformed.png", download_name=f"{stem}_transformed.{ext}",
) )
except Exception as e: except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500

View File

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

View File

@@ -340,13 +340,13 @@
<!-- Current Version - Prominent --> <!-- Current Version - Prominent -->
<div class="alert alert-success mb-4"> <div class="alert alert-success mb-4">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="badge bg-success fs-6 me-3">v4.2.0</span> <span class="badge bg-success fs-6 me-3">v4.2.1</span>
<div> <div>
<strong>Performance optimizations:</strong> <strong>Security & API improvements:</strong>
~70% faster decode (vectorized DCT), API key authentication,
50% less RAM (float32), TLS with self-signed certs,
async API endpoints, CLI tools (compress, rotate, convert),
decode progress callbacks jpegtran lossless JPEG rotation
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "stegasoo" name = "stegasoo"
version = "4.2.0" version = "4.2.1"
description = "Secure steganography with hybrid photo + passphrase + PIN authentication" description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"

View File

@@ -106,7 +106,7 @@ Remove SD card, insert into your Linux machine:
lsblk lsblk
# Pull image (auto-resizes to 16GB, compresses with zstd) # Pull image (auto-resizes to 16GB, compresses with zstd)
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.0.img.zst sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
``` ```
The script automatically resizes rootfs to 16GB (for smaller download), preserves auto-expand, and compresses. The script automatically resizes rootfs to 16GB (for smaller download), preserves auto-expand, and compresses.
@@ -173,5 +173,5 @@ curl -k https://localhost:5000
sudo /opt/stegasoo/rpi/sanitize-for-image.sh sudo /opt/stegasoo/rpi/sanitize-for-image.sh
# On host (pull image - auto-resizes to 16GB): # On host (pull image - auto-resizes to 16GB):
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.0.img.zst sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
``` ```

View File

@@ -207,7 +207,7 @@ After Pi shuts down, remove SD card and on another Linux machine:
lsblk lsblk
# Pull image (auto-resizes to 16GB, compresses with zstd) # Pull image (auto-resizes to 16GB, compresses with zstd)
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.0.img.zst sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
``` ```
The `pull-image.sh` script automatically: The `pull-image.sh` script automatically:

View File

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

View File

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

View File

@@ -273,7 +273,7 @@ fi
# Pre-built venv tarball (skips pip compile time) # Pre-built venv tarball (skips pip compile time)
PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-rpi-venv-arm64.tar.zst" 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.0/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_PREBUILT=true
# Use local tarball if present, otherwise will download # Use local tarball if present, otherwise will download

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ from pathlib import Path
# VERSION # VERSION
# ============================================================================ # ============================================================================
__version__ = "4.2.0" __version__ = "4.2.1"
# ============================================================================ # ============================================================================
# FILE FORMAT # FILE FORMAT

View File

@@ -35,7 +35,7 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
import numpy as np import numpy as np
from PIL import Image from PIL import Image, ImageOps
# Check for scipy availability (for PNG/DCT mode) # Check for scipy availability (for PNG/DCT mode)
# Prefer scipy.fft (newer, more stable) over scipy.fftpack # Prefer scipy.fft (newer, more stable) over scipy.fftpack
@@ -406,6 +406,45 @@ def _safe_idct2(block: np.ndarray) -> np.ndarray:
# ============================================================================ # ============================================================================
def _apply_exif_orientation(image_data: bytes) -> bytes:
"""
Apply EXIF orientation to image and return corrected bytes.
Portrait photos from cameras often have EXIF orientation metadata that
tells viewers to rotate the image for display. However, the raw pixel
data is stored in landscape orientation. This function applies that
rotation to the pixel data so the output matches what users expect.
Without this, a portrait photo encoded with DCT would come out rotated
90 degrees because we'd embed in the raw (landscape) orientation.
"""
img = Image.open(io.BytesIO(image_data))
original_format = img.format or "JPEG"
# Apply EXIF orientation (rotates/flips pixels to match EXIF tag)
# This also removes the EXIF orientation tag since it's now baked in
corrected = ImageOps.exif_transpose(img)
# If no change was needed, return original data unchanged
if corrected is img:
img.close()
return image_data
# Save corrected image back to bytes
output = io.BytesIO()
if original_format == "JPEG":
if corrected.mode in ("RGBA", "P"):
corrected = corrected.convert("RGB")
corrected.save(output, format="JPEG", quality=95)
else:
corrected.save(output, format="PNG")
img.close()
corrected.close()
output.seek(0)
return output.getvalue()
def _to_grayscale(image_data: bytes) -> np.ndarray: def _to_grayscale(image_data: bytes) -> np.ndarray:
img = Image.open(io.BytesIO(image_data)) img = Image.open(io.BytesIO(image_data))
gray = img.convert("L") gray = img.convert("L")
@@ -763,6 +802,10 @@ def embed_in_dct(
if color_mode not in ("color", "grayscale"): if color_mode not in ("color", "grayscale"):
color_mode = "color" color_mode = "color"
# Apply EXIF orientation to carrier image before embedding
# This ensures portrait photos are embedded in their correct visual orientation
carrier_image = _apply_exif_orientation(carrier_image)
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO: if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file) return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
@@ -1173,24 +1216,251 @@ def _embed_jpegio(
pass pass
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()
# 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( def extract_from_dct(
stego_image: bytes, stego_image: bytes,
seed: bytes, seed: bytes,
progress_file: str | None = None, progress_file: str | None = None,
) -> bytes: ) -> bytes:
"""Extract data from DCT stego image.""" """
img = Image.open(io.BytesIO(stego_image)) Extract data from DCT stego image.
fmt = img.format
img.close()
if fmt == "JPEG" and HAS_JPEGIO: If extraction fails with InvalidMagicBytesError, automatically tries
90°, 180°, and 270° rotations to handle images that were rotated after
encoding (e.g., by external tools or EXIF orientation changes).
Uses quick header validation to skip obviously invalid rotations.
"""
rotations_to_try = [0, 90, 180, 270]
last_error = None
valid_rotations = []
# Phase 1: Quick validation to find candidate rotations
for rotation in rotations_to_try:
if rotation == 0:
image_to_check = stego_image
else:
image_to_check = _rotate_image_bytes(stego_image, rotation)
if _quick_validate_dct_header(image_to_check, seed):
valid_rotations.append((rotation, image_to_check))
# If no rotations pass quick check, try all anyway (fallback)
if not valid_rotations:
# Must try all rotations - quick validation might have failed due to
# scipy vs jpegio differences or other edge cases
for rotation in rotations_to_try:
if rotation == 0:
valid_rotations.append((0, stego_image))
else:
valid_rotations.append((rotation, _rotate_image_bytes(stego_image, rotation)))
# Phase 2: Full extraction on valid candidates
for rotation, image_to_decode in valid_rotations:
try: try:
return _extract_jpegio(stego_image, seed, progress_file) img = Image.open(io.BytesIO(image_to_decode))
except ValueError: fmt = img.format
pass img.close()
_check_scipy() if fmt == "JPEG" and HAS_JPEGIO:
return _extract_scipy_dct_safe(stego_image, seed, progress_file) 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( def _extract_scipy_dct_safe(

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

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

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

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