Compare commits
70 Commits
v4.1.7
...
pre-monore
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70b941d55a | ||
|
|
14fce4d3ed | ||
|
|
05382c4081 | ||
|
|
ef5a9ce9cb | ||
|
|
0248bec813 | ||
|
|
7aeb26e003 | ||
|
|
1630d044aa | ||
|
|
c2a0a731d7 | ||
|
|
89de839fd8 | ||
|
|
49566292ba | ||
|
|
9f0e0afeb6 | ||
|
|
398a359778 | ||
|
|
86aa5cbddf | ||
|
|
2f54f80214 | ||
|
|
1cd2656e60 | ||
|
|
ce728cec6e | ||
|
|
555735a4fd | ||
|
|
08b70043e4 | ||
|
|
d395e5731e | ||
|
|
110b160e68 | ||
|
|
b09f607d34 | ||
|
|
34ede3815f | ||
|
|
3b5ab41ce9 | ||
|
|
525bcec3c9 | ||
|
|
afc8c93923 | ||
|
|
38bef32750 | ||
|
|
4e3acfca20 | ||
|
|
2ebc42f2cd | ||
|
|
1e07630b49 | ||
|
|
67037ae196 | ||
|
|
5a68840725 | ||
|
|
ebc999b2b3 | ||
|
|
f46ef01f5f | ||
|
|
0d76780deb | ||
|
|
d34919e32f | ||
|
|
a4038589b0 | ||
|
|
db763f1464 | ||
|
|
27c5b08d41 | ||
|
|
28cb9bb9b3 | ||
|
|
889df881ba | ||
|
|
c058d116b8 | ||
|
|
fae86887e2 | ||
|
|
5e45b2c5c1 | ||
|
|
71088989f3 | ||
|
|
530e5debef | ||
|
|
3b062458e3 | ||
|
|
5e65035ca4 | ||
|
|
de9d1de881 | ||
|
|
8d90a888cf | ||
|
|
b0914778e3 | ||
|
|
7e5462ea6e | ||
|
|
e085a8ffe9 | ||
|
|
2d7fbd1e0d | ||
|
|
32842f6b73 | ||
|
|
3fd3204552 | ||
|
|
175362ce4c | ||
|
|
2ed108f3a0 | ||
|
|
167e1a6ff5 | ||
|
|
f2f3e2eefc | ||
|
|
5c685cba67 | ||
|
|
4e819b80cc | ||
|
|
ea86216648 | ||
|
|
8de5659fa6 | ||
|
|
de0bf2410d | ||
|
|
8b948d00a4 | ||
|
|
6d88453b69 | ||
|
|
ea57bdf302 | ||
|
|
55d54717f8 | ||
|
|
c0fe85ac83 | ||
|
|
e9e4d1aab9 |
3
.github/workflows/release.yml
vendored
@@ -37,7 +37,8 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
needs: test # Only run if tests pass
|
needs: test # Only run if tests pass
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
environment: pypi
|
||||||
|
|
||||||
# Required for PyPI trusted publishing (recommended)
|
# Required for PyPI trusted publishing (recommended)
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|||||||
2
.github/workflows/test.yml
vendored
@@ -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
|
||||||
|
|||||||
11
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
|
# Embedded repos (AUR packaging)
|
||||||
|
aur-cli-upload/
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
@@ -97,3 +100,11 @@ rpi/*.tar.zst.zip
|
|||||||
rpi/*.img
|
rpi/*.img
|
||||||
rpi/*.img.zst
|
rpi/*.img.zst
|
||||||
rpi/*.img.zst.zip
|
rpi/*.img.zst.zip
|
||||||
|
|
||||||
|
# AUR build artifacts
|
||||||
|
aur-upload/
|
||||||
|
aur/.SRCINFO
|
||||||
|
aur/*.pkg.tar.zst
|
||||||
|
|
||||||
|
# Docker pre-built images and deps (release assets, too large for git)
|
||||||
|
docker/*.tar.zst
|
||||||
|
|||||||
20
CHANGELOG.md
@@ -5,6 +5,25 @@ All notable changes to Stegasoo will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org).
|
and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [4.3.0] - 2026-02-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Audio Steganography** — Hide messages in audio files (WAV, FLAC, MP3, OGG, AAC, M4A)
|
||||||
|
- LSB mode: Direct least-significant-bit embedding in audio samples
|
||||||
|
- Spread Spectrum mode: Noise-resistant encoding using pseudo-random spreading
|
||||||
|
- Automatic format transcoding to WAV for embedding
|
||||||
|
- Full CLI support: `stegasoo audio-encode`, `audio-decode`, `audio-info`
|
||||||
|
- REST API endpoints: `/audio/encode`, `/audio/decode`, `/audio/info`
|
||||||
|
- Web UI: Unified encode/decode pages with carrier type selector (Image/Audio)
|
||||||
|
- New `AudioCapacityInfo`, `AudioEmbedStats`, `AudioInfo` model classes
|
||||||
|
- Audio-specific exceptions: `AudioError`, `AudioValidationError`, `AudioCapacityError`, `AudioExtractionError`, `AudioTranscodeError`, `UnsupportedAudioFormatError`
|
||||||
|
- Subprocess isolation for audio operations (crash protection)
|
||||||
|
- `debug.py` module for structured logging across all steganography operations
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Encode/Decode web pages now have a "Carrier Type" step to switch between Image and Audio
|
||||||
|
- Version bumped to 4.3.0
|
||||||
|
|
||||||
## [4.1.5] - 2026-01-07
|
## [4.1.5] - 2026-01-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -201,6 +220,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
|
|||||||
- CLI interface
|
- CLI interface
|
||||||
- Basic PIN authentication
|
- Basic PIN authentication
|
||||||
|
|
||||||
|
[4.3.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.2.1...v4.3.0
|
||||||
[4.1.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5
|
[4.1.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5
|
||||||
[4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3
|
[4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3
|
||||||
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0
|
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0
|
||||||
|
|||||||
114
CLAUDE.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Stegasoo — Claude Code Project Guide
|
||||||
|
|
||||||
|
Stegasoo is a secure steganography toolkit with hybrid photo + passphrase + PIN authentication.
|
||||||
|
Version 4.3.0 · Python >=3.11 · MIT License
|
||||||
|
|
||||||
|
## Quick commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]" # Install for development (includes all extras)
|
||||||
|
pytest # Run tests (coverage reported automatically)
|
||||||
|
black src/ tests/ frontends/ # Format code
|
||||||
|
ruff check src/ tests/ frontends/ --fix # Lint (auto-fix)
|
||||||
|
mypy src/ # Type check
|
||||||
|
pre-commit run --all-files # Run all pre-commit hooks
|
||||||
|
PYTHONPATH=src python -m stegasoo.cli # Run CLI directly without install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/stegasoo/ Core library
|
||||||
|
crypto.py Argon2id / PBKDF2 key derivation + AES-256-GCM encryption
|
||||||
|
steganography.py LSB spatial embedding
|
||||||
|
dct_steganography.py DCT domain embedding (JPEG-safe, needs [dct] extras)
|
||||||
|
validation.py Input validation for all security factors
|
||||||
|
constants.py All magic numbers, crypto params, limits
|
||||||
|
models.py Dataclasses (EncodeResult, DecodeResult, etc.)
|
||||||
|
encode.py / decode.py High-level encode/decode orchestration
|
||||||
|
channel.py Channel key management (v4.0+)
|
||||||
|
audio_steganography.py LSB audio embedding/extraction (v4.3.0)
|
||||||
|
spread_steganography.py Spread spectrum audio embedding (v4.3.0)
|
||||||
|
audio_utils.py Audio format detection, validation, transcoding (v4.3.0)
|
||||||
|
debug.py Structured logging for operations (v4.3.0)
|
||||||
|
compression.py Zstandard / zlib / lz4 payload compression
|
||||||
|
cli.py Click CLI entry point
|
||||||
|
generate.py Credential generation (passphrase, PIN, RSA keys)
|
||||||
|
exceptions.py Exception hierarchy (all inherit StegasooError)
|
||||||
|
__init__.py Public API surface (__all__)
|
||||||
|
|
||||||
|
frontends/web/ Flask web UI (entry: app.py)
|
||||||
|
frontends/api/ FastAPI REST API (entry: main.py)
|
||||||
|
frontends/cli/ CLI extras
|
||||||
|
|
||||||
|
tests/ Pytest suite
|
||||||
|
test_stegasoo.py Single test file covering core library
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entry points
|
||||||
|
|
||||||
|
| Interface | Entry point | Install extra |
|
||||||
|
|-----------|-------------|---------------|
|
||||||
|
| CLI | `stegasoo.cli:main` (`stegasoo` command) | `[cli]` |
|
||||||
|
| Web UI | `frontends/web/app.py` | `[web]` |
|
||||||
|
| REST API | `frontends/api/main.py` | `[api]` |
|
||||||
|
|
||||||
|
## Code conventions
|
||||||
|
|
||||||
|
- **Formatter**: Black, 100-char line length
|
||||||
|
- **Linter**: Ruff — rules E, F, I, N, W, UP (E501 ignored). N803/N806 suppressed in `dct_steganography.py` for colorspace variable names
|
||||||
|
- **Type hints**: Required on all new code. `mypy` with `ignore_missing_imports = true`
|
||||||
|
- **Pre-commit hooks**: ruff, ruff-format, trailing-whitespace, end-of-file-fixer, check-yaml, check-toml, check-added-large-files (1MB), check-merge-conflict, debug-statements, bandit (excludes tests/)
|
||||||
|
- **Branch naming**: `feature/`, `fix/`, `docs/`, `refactor/`
|
||||||
|
- **Commits**: Imperative mood, clear subject line. Include what + why
|
||||||
|
|
||||||
|
## Security-critical modules
|
||||||
|
|
||||||
|
These files implement the cryptographic and steganographic core. Changes require extra care, thorough test coverage, and careful review:
|
||||||
|
|
||||||
|
- **`crypto.py`** — Argon2id KDF (256 MB / 4 iterations / 4 parallelism) + PBKDF2 fallback (600K iterations) → AES-256-GCM authenticated encryption
|
||||||
|
- **`steganography.py`** — LSB spatial embedding/extraction
|
||||||
|
- **`dct_steganography.py`** — DCT domain embedding with Reed-Solomon error correction
|
||||||
|
- **`validation.py`** — Input validation for all security factors (PIN, passphrase, image, RSA key, channel key)
|
||||||
|
- **`constants.py`** — Crypto parameters (salt sizes, iteration counts, Argon2 memory cost, format versions). Do not change these casually — they affect backward compatibility and security margins
|
||||||
|
|
||||||
|
## Public API
|
||||||
|
|
||||||
|
`src/stegasoo/__init__.py` defines the full public API surface via `__all__`. Any new public function must be:
|
||||||
|
1. Imported in `__init__.py`
|
||||||
|
2. Added to the `__all__` list
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Single test file: `tests/test_stegasoo.py`
|
||||||
|
- Requires `pip install -e ".[dev]"` (includes DCT dependencies)
|
||||||
|
- Coverage is reported automatically via pytest config (`--cov=stegasoo --cov-report=term-missing`)
|
||||||
|
- Run: `pytest` (no extra flags needed)
|
||||||
|
|
||||||
|
## Worktree workflow
|
||||||
|
|
||||||
|
When working on features or fixes that touch multiple files, prefer using a git worktree for isolation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Claude Code can create worktrees automatically via /worktree or EnterWorktree
|
||||||
|
# Manual creation:
|
||||||
|
git worktree add .claude/worktrees/<name> -b <branch-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Guidelines for worktree usage
|
||||||
|
|
||||||
|
- **Use worktrees for**: multi-file refactors, experimental changes, anything that might need to be discarded
|
||||||
|
- **Worktree location**: `.claude/worktrees/` (gitignored by Claude Code)
|
||||||
|
- **Branch from**: always branch from `main` unless working on a version branch (e.g., `4.2`)
|
||||||
|
- **Naming**: use the same conventions as branches — `feature/description`, `fix/description`, etc.
|
||||||
|
- **Cleanup**: worktrees in `.claude/worktrees/` are ephemeral. Remove with `git worktree remove <path>` when done
|
||||||
|
- **Testing in worktrees**: run `pip install -e ".[dev]"` inside the worktree before running tests, since the editable install points to the worktree's source
|
||||||
|
- **Merging back**: create a PR from the worktree branch, or merge locally into `main`
|
||||||
|
|
||||||
|
## Useful context
|
||||||
|
|
||||||
|
- BIP-39 wordlist lives at `src/stegasoo/data/bip39-words.txt` (used for passphrase generation)
|
||||||
|
- Docker support: `src/stegasoo/Dockerfile` + `docs/DOCKER_QUICKSTART.md`
|
||||||
|
- Raspberry Pi builds: `rpi/` directory
|
||||||
|
- AUR packages: `aur/`, `aur-cli/`, `aur-api/`
|
||||||
|
- Version is defined in both `pyproject.toml` and `src/stegasoo/__init__.py` — keep them in sync
|
||||||
4
CLI.md
@@ -164,7 +164,7 @@ stegasoo generate [OPTIONS]
|
|||||||
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
|
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
|
||||||
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
|
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
|
||||||
| `--pin-length` | | 6-9 | 6 | PIN length in digits |
|
| `--pin-length` | | 6-9 | 6 | PIN length in digits |
|
||||||
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072, 4096) |
|
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072) |
|
||||||
| `--words` | | 3-12 | 4 | Words in passphrase |
|
| `--words` | | 3-12 | 4 | Words in passphrase |
|
||||||
| `--output` | `-o` | path | | Save RSA key to file |
|
| `--output` | `-o` | path | | Save RSA key to file |
|
||||||
| `--password` | `-p` | string | | Password for RSA key file |
|
| `--password` | `-p` | string | | Password for RSA key file |
|
||||||
@@ -180,7 +180,7 @@ stegasoo generate
|
|||||||
stegasoo generate --words 6
|
stegasoo generate --words 6
|
||||||
|
|
||||||
# Generate with RSA key
|
# Generate with RSA key
|
||||||
stegasoo generate --rsa --rsa-bits 4096
|
stegasoo generate --rsa --rsa-bits 3072
|
||||||
|
|
||||||
# Save RSA key to encrypted file
|
# Save RSA key to encrypted file
|
||||||
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"
|
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"
|
||||||
|
|||||||
64
INSTALL.md
@@ -20,22 +20,23 @@ Complete installation instructions for all platforms and deployment methods.
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### ⚠️ Python Version Requirements
|
### Python Version Requirements
|
||||||
|
|
||||||
| Python Version | Status | Notes |
|
| Python Version | Status | Notes |
|
||||||
|----------------|--------|-------|
|
|----------------|--------|-------|
|
||||||
| 3.10 | ✅ Supported | |
|
| 3.10 | ❌ Not Supported | Dropped in v4.2.1 |
|
||||||
| 3.11 | ✅ Supported | Recommended |
|
| 3.11 | ✅ Supported | Minimum version |
|
||||||
| 3.12 | ✅ Supported | Recommended |
|
| 3.12 | ✅ Supported | Recommended |
|
||||||
| 3.13 | ❌ **Not Supported** | jpegio C extension incompatible |
|
| 3.13 | ✅ Supported | |
|
||||||
|
| 3.14 | ✅ Supported | Tested on Arch |
|
||||||
|
|
||||||
**Important:** Python 3.13 (released October 2024) is **not compatible** with jpegio due to C extension ABI changes. Use Python 3.12 or earlier.
|
**Note:** v4.2.1 switched from `jpegio` to `jpeglib` for DCT steganography, enabling Python 3.11-3.14 support.
|
||||||
|
|
||||||
### Minimum Requirements
|
### Minimum Requirements
|
||||||
|
|
||||||
| Requirement | Value |
|
| Requirement | Value |
|
||||||
|-------------|-------|
|
|-------------|-------|
|
||||||
| Python | 3.10-3.12 |
|
| Python | 3.11-3.14 |
|
||||||
| RAM | 512 MB minimum (256MB for Argon2) |
|
| RAM | 512 MB minimum (256MB for Argon2) |
|
||||||
| Disk | ~100 MB |
|
| Disk | ~100 MB |
|
||||||
|
|
||||||
@@ -423,16 +424,61 @@ pip install jpegio
|
|||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
1. Install Python 3.12 from [python.org](https://python.org) (NOT 3.13!)
|
Windows users have three options, listed from easiest to most complex:
|
||||||
2. Install Visual Studio Build Tools
|
|
||||||
|
#### Option 1: Docker Desktop (Recommended)
|
||||||
|
|
||||||
|
The easiest way to run Stegasoo on Windows. No Python installation needed.
|
||||||
|
|
||||||
|
1. Install [Docker Desktop](https://www.docker.com/products/docker-desktop/)
|
||||||
|
2. Enable WSL2 backend when prompted
|
||||||
|
3. Clone and run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/adlee-was-taken/stegasoo.git
|
||||||
|
cd stegasoo
|
||||||
|
docker-compose -f docker/docker-compose.yml up -d web
|
||||||
|
```
|
||||||
|
|
||||||
|
Access at http://localhost:5000
|
||||||
|
|
||||||
|
#### Option 2: WSL2 (Windows Subsystem for Linux)
|
||||||
|
|
||||||
|
Run the Linux version natively on Windows.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Install WSL2 with Ubuntu
|
||||||
|
wsl --install -d Ubuntu
|
||||||
|
|
||||||
|
# Open Ubuntu terminal, then follow Linux instructions:
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y python3.12 python3.12-venv libzbar0 libjpeg-dev
|
||||||
|
git clone https://github.com/adlee-was-taken/stegasoo.git
|
||||||
|
cd stegasoo
|
||||||
|
python3.12 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -e ".[all]"
|
||||||
|
stegasoo --version
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option 3: Native Windows (Advanced)
|
||||||
|
|
||||||
|
Native Windows installation requires Visual Studio Build Tools for compiling C extensions.
|
||||||
|
|
||||||
|
1. Install Python 3.11 or 3.12 from [python.org](https://python.org)
|
||||||
|
2. Install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with "Desktop development with C++"
|
||||||
3. Install from pip:
|
3. Install from pip:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
.\venv\Scripts\activate
|
.\venv\Scripts\activate
|
||||||
pip install stegasoo[all]
|
pip install stegasoo[cli] # CLI only (easiest)
|
||||||
|
# or
|
||||||
|
pip install stegasoo[all] # Full install (may require additional setup)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note:** Native Windows installation may have issues with `jpegio` (DCT mode). Docker or WSL2 is recommended for full functionality.
|
||||||
|
|
||||||
### Raspberry Pi
|
### Raspberry Pi
|
||||||
|
|
||||||
Stegasoo works on Raspberry Pi 4/5 (4GB+ RAM recommended for Web UI).
|
Stegasoo works on Raspberry Pi 4/5 (4GB+ RAM recommended for Web UI).
|
||||||
|
|||||||
294
IdeasScout_PLANS_20260324.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# Stegasoo Ideas Scout — Implementation Plans (2026-03-24)
|
||||||
|
|
||||||
|
Baseline: v4.3.0, Python >=3.11, FORMAT_VERSION 5, no existing users (no backward compat constraints).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 1 — Quick Wins
|
||||||
|
|
||||||
|
### 1. Platform-Calibrated DCT Presets
|
||||||
|
|
||||||
|
**Description**: `--platform telegram|discord|signal|whatsapp` flag for DCT encode. Bakes in each platform's known recompression parameters. Pre-verifies payload survives before outputting.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- New file `src/stegasoo/platform_presets.py` — `PlatformPreset` dataclass + `PRESETS` dict mapping platform → tuned `quant_step`, `jpeg_quality`, `embed_positions`, `max_dimension`, `recompress_quality`
|
||||||
|
- `dct_steganography.py`: `_embed_scipy_dct_safe()` / `_embed_jpegio()` accept optional preset overrides for `QUANT_STEP`, `DEFAULT_EMBED_POSITIONS`, output quality
|
||||||
|
- New `pre_verify_survival()` function: encode → re-save at platform quality → extract → pass/fail
|
||||||
|
- Thread `platform` param through `encode.py` → `steganography.py` → DCT functions
|
||||||
|
- `cli.py`: add `--platform` as `click.Choice` + `--verify/--no-verify` (pre-verification doubles encode time)
|
||||||
|
- LSB + `--platform` should error early — LSB data is destroyed by any JPEG recompression
|
||||||
|
|
||||||
|
**Known platform params** (from research):
|
||||||
|
| Platform | Quality | Max Dimension | Notes |
|
||||||
|
|----------|---------|---------------|-------|
|
||||||
|
| Telegram | ~82 | 2560×2560 | ~81KB embeddable |
|
||||||
|
| Discord | ~85 | Varies (Nitro) | |
|
||||||
|
| Signal | ~80 | Aggressive | |
|
||||||
|
| WhatsApp | ~70 | 1600×1600 | Most lossy |
|
||||||
|
|
||||||
|
**Go/No-Go metrics**:
|
||||||
|
- >95% payload survival rate per platform at 1KB message size in automated tests
|
||||||
|
- Pre-verification correctly predicts real platform behavior (manual validation per platform at least once)
|
||||||
|
|
||||||
|
**Complexity**: **M** — new file + parameter threading through 4-5 functions
|
||||||
|
|
||||||
|
**Risks**: Platform params change without notice. Add version/date stamps to presets and a `stegasoo tools verify-platform` test command.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Steganalysis Self-Check (`stegasoo check`)
|
||||||
|
|
||||||
|
**Description**: New CLI command running chi-square and RS (Regular-Singular) statistical analysis on stego images. Outputs detectability risk level (low/medium/high).
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- New file `src/stegasoo/steganalysis.py`:
|
||||||
|
- `chi_square_analysis(image_data) -> float` — chi-square statistic on LSB distribution per channel
|
||||||
|
- `rs_analysis(image_data) -> float` — Regular-Singular groups analysis (requires numpy)
|
||||||
|
- `assess_risk(chi_p, rs_estimate) -> str` — maps to "low"/"medium"/"high"
|
||||||
|
- `check_image(image_data) -> dict` — orchestrator
|
||||||
|
- `cli.py`: new `@cli.command("check")` with `IMAGE` arg, `--json`, `--mode lsb|dct|auto`
|
||||||
|
- `constants.py`: threshold constants for chi-square p-value and RS boundaries
|
||||||
|
- `__init__.py`: export `check_image` in `__all__`
|
||||||
|
- Start LSB-only; DCT steganalysis (calibration attack) deferred
|
||||||
|
|
||||||
|
**Go/No-Go metrics**:
|
||||||
|
- Clean images → consistently "low risk"
|
||||||
|
- Naive sequential LSB → "high risk"
|
||||||
|
- Stegasoo LSB at <50% capacity → "low" or "medium"
|
||||||
|
|
||||||
|
**Complexity**: **M** — ~150 lines numpy per test, straightforward CLI integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Python 3.13 DCT Cleanup
|
||||||
|
|
||||||
|
**Description**: The `jpegio` → `jpeglib` migration is already done in code. Remaining work: rename stale `jpegio` references and verify on 3.13.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- `dct_steganography.py`: rename `HAS_JPEGIO` → `HAS_JPEGLIB`, `_jpegio_*` functions → `_jpeglib_*`, update constant names (`JPEGIO_MAGIC` → `JPEGLIB_MAGIC`, etc.)
|
||||||
|
- Verify `jpeglib.to_jpegio()` compatibility shim — if jpeglib plans to deprecate it, migrate to native API
|
||||||
|
- Run full test suite on Python 3.13
|
||||||
|
|
||||||
|
**Go/No-Go metrics**:
|
||||||
|
- All DCT tests pass on Python 3.13
|
||||||
|
- No deprecation warnings from jpeglib
|
||||||
|
|
||||||
|
**Complexity**: **S** — renaming and verification only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 2 — Strategic
|
||||||
|
|
||||||
|
### 4. Content-Adaptive Embedding (S-UNIWARD/WOW-inspired)
|
||||||
|
|
||||||
|
**Description**: Replace uniform-random pixel selection with texture-weighted cost functions. Embed preferentially in busy/textured regions where changes are least detectable. 3-5x harder to detect statistically.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- New file `src/stegasoo/adaptive_cost.py`:
|
||||||
|
- `compute_cost_map(image_data) -> np.ndarray` — per-pixel distortion cost via directional high-pass filters (Daubechets wavelet bank / KB filter)
|
||||||
|
- `select_pixels_by_cost(cost_map, pixel_key, num_needed) -> list[int]` — weighted sampling, still ChaCha20-seeded for determinism
|
||||||
|
- `steganography.py`:
|
||||||
|
- `generate_pixel_indices()`: add `cost_map` param, use weighted sampling when provided
|
||||||
|
- `_embed_lsb()`: compute cost map when adaptive mode enabled
|
||||||
|
- `_extract_lsb()`: must compute identical cost map to find same pixels
|
||||||
|
- `dct_steganography.py`: adapt `DEFAULT_EMBED_POSITIONS` per-block based on block texture energy
|
||||||
|
- Thread `adaptive: bool` through `encode.py`/`decode.py`
|
||||||
|
- `constants.py`: add `EMBED_MODE_ADAPTIVE_LSB`, filter kernels, cost thresholds
|
||||||
|
|
||||||
|
**Go/No-Go metrics**:
|
||||||
|
- Chi-square test (Feature 2) shows measurable improvement vs uniform-random
|
||||||
|
- **Critical**: cost map computation is deterministic across platforms (quantize to fixed-point integers)
|
||||||
|
- Round-trip decode succeeds on Linux x86, Linux ARM, macOS
|
||||||
|
|
||||||
|
**Complexity**: **L** — novel algorithm, cross-platform determinism requirement, touches core embedding
|
||||||
|
|
||||||
|
**Risks**: Floating-point differences in wavelet computation could break extraction. Mitigate with integer quantization. Increases encode/decode time ~2-3x.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Per-Message Forward Secrecy via HKDF
|
||||||
|
|
||||||
|
**Description**: Derive ephemeral per-message encryption keys using HKDF expansion from the Argon2id root key + random nonce. Compromising one message doesn't reveal others.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- `crypto.py`:
|
||||||
|
- Add `from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand`
|
||||||
|
- `derive_message_key(root_key, nonce) -> bytes` — HKDF-Expand with SHA-256
|
||||||
|
- `encrypt_message()`: generate 16-byte random nonce, derive per-message key, embed nonce in header
|
||||||
|
- `decrypt_message()`: extract nonce, derive same key
|
||||||
|
- Also derive pixel selection key via HKDF with different `info` param
|
||||||
|
- `constants.py`:
|
||||||
|
- Bump `FORMAT_VERSION` to 6
|
||||||
|
- `HKDF_INFO_ENCRYPTION = b"stegasoo-v6-encrypt"`, `HKDF_INFO_PIXEL = b"stegasoo-v6-pixel"`
|
||||||
|
- `MESSAGE_NONCE_SIZE = 16`
|
||||||
|
- Header grows from 66 → 82 bytes: add `message_nonce(16)` field
|
||||||
|
- Update `HEADER_OVERHEAD` / `ENCRYPTION_OVERHEAD` in `steganography.py`
|
||||||
|
|
||||||
|
**Go/No-Go metrics**:
|
||||||
|
- Two messages with identical credentials produce different ciphertexts and different pixel locations
|
||||||
|
- `cryptography` library HKDF works with existing Argon2id output
|
||||||
|
|
||||||
|
**Complexity**: **M** — well-defined crypto change, touches security-critical header format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. PWA Mobile Interface
|
||||||
|
|
||||||
|
**Description**: Convert Flask Web UI to Progressive Web App. Mobile-optimized, installable, offline-capable static pages.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- New files in `frontends/web/static/`: `manifest.json`, `sw.js`, icon set (192×192, 512×512)
|
||||||
|
- Base template: add manifest link, theme-color meta, viewport meta, service worker registration
|
||||||
|
- `app.py`: serve manifest with correct MIME, add cache headers for static assets
|
||||||
|
- Responsive CSS for encode/decode accordion forms
|
||||||
|
- Camera capture: `<input type="file" accept="image/*" capture="environment">` for reference photo
|
||||||
|
- Service worker caches static assets only — NOT encode/decode API endpoints
|
||||||
|
|
||||||
|
**Go/No-Go metrics**:
|
||||||
|
- Lighthouse PWA score >= 90
|
||||||
|
- Installable on Android Chrome and iOS Safari
|
||||||
|
- Offline: static pages load, encode/decode shows graceful "offline" message
|
||||||
|
|
||||||
|
**Complexity**: **M** — frontend only, no core library changes
|
||||||
|
|
||||||
|
**Risks**: Camera capture requires HTTPS (already supported via `ssl_utils.py`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 3 — Moonshot
|
||||||
|
|
||||||
|
### 7. Plausible Deniability / Dual-Payload Mode
|
||||||
|
|
||||||
|
**Description**: Two independent encrypted payloads in one carrier, each with different credentials. Reveal decoy under coercion; real payload stays hidden.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- New file `src/stegasoo/dual_payload.py`:
|
||||||
|
- `encode_dual(message_a, message_b, carrier, creds_a, creds_b)`
|
||||||
|
- Partition available pixels into two disjoint pools using different seeds
|
||||||
|
- **Critical**: ALL images (single or dual) must fill unused pixel pool with random data so single-payload and dual-payload images are indistinguishable
|
||||||
|
- `steganography.py`: `generate_pixel_indices()` gets `exclude_indices` param
|
||||||
|
- `decode.py`: each credential set finds a different valid payload; wrong credentials produce garbage
|
||||||
|
- CLI + Web UI: dual-payload encode workflow
|
||||||
|
|
||||||
|
**Go/No-Go metrics**:
|
||||||
|
- Single-payload and dual-payload images are statistically indistinguishable (chi-square can't differentiate)
|
||||||
|
- Each payload decodes independently
|
||||||
|
- Wrong credentials for one payload don't reveal other payload's existence
|
||||||
|
|
||||||
|
**Complexity**: **XL** — novel design, halves capacity per payload, challenging UX, needs rigorous security analysis
|
||||||
|
|
||||||
|
**Dependencies**: Feature 2 (validation), Feature 4 (detectability reduction)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Improvements
|
||||||
|
|
||||||
|
### 8. EmbeddingBackend Protocol
|
||||||
|
|
||||||
|
**Description**: Typed plugin interface for all embedding algorithms. Replace if/elif dispatch in `steganography.py` with a registry.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- New package `src/stegasoo/backends/`:
|
||||||
|
- `protocol.py` — `EmbeddingBackend(Protocol)` with `embed()`, `extract()`, `calculate_capacity()`, `is_available()`
|
||||||
|
- `lsb.py`, `dct.py` — wrap existing functions
|
||||||
|
- `registry.py` — `BackendRegistry` mapping mode strings to backends
|
||||||
|
- `steganography.py`: `embed_in_image()` / `extract_from_image()` dispatch via registry
|
||||||
|
- `__init__.py`: export protocol and `register_backend()`
|
||||||
|
|
||||||
|
**Complexity**: **M** — implement before Features 4 and 7 (they become new backends)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. HKDF Key Separation
|
||||||
|
|
||||||
|
Subsumed by Feature 5. The HKDF expansion provides:
|
||||||
|
- Encryption key: `HKDF-Expand(root_key, info="stegasoo-encrypt", nonce)`
|
||||||
|
- Pixel selection key: `HKDF-Expand(root_key, info="stegasoo-pixel", nonce)`
|
||||||
|
- Future: MAC key, padding key, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. `[core]` Extra with Minimal Deps
|
||||||
|
|
||||||
|
**Description**: Move Pillow to `[image]` extra, base deps = `cryptography` + `argon2-cffi` + `zstandard` only.
|
||||||
|
|
||||||
|
**Complexity**: **S** — but Pillow is used in `crypto.py` for photo hashing (core to security model). Only worth it with a concrete headless use case. **Low priority.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ecosystem Features
|
||||||
|
|
||||||
|
### 11. Aletheia Integration
|
||||||
|
|
||||||
|
Optional `--engine aletheia` backend for Feature 2's `stegasoo check`. BSD-licensed, provides SPA/RS/WS attacks + ML classifiers. **Complexity: S** (after Feature 2). **Depends on**: Feature 2.
|
||||||
|
|
||||||
|
### 12. C2PA/AI Provenance Watermarking
|
||||||
|
|
||||||
|
Embed C2PA metadata alongside stego payloads. **Complexity: L** — C2PA is a complex standard. Potentially conflicts with stego goals (adds detectable metadata). Research-heavy.
|
||||||
|
|
||||||
|
### 13. Signal/Matrix Bot
|
||||||
|
|
||||||
|
Bot that decodes stego images in a channel using configured channel key. **Complexity: M** — integration work, uses existing `decode()` API.
|
||||||
|
|
||||||
|
### 14. Homebrew Tap + Nix Flake
|
||||||
|
|
||||||
|
Package distribution for macOS/NixOS. **Complexity: S** — packaging only, no code changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Table
|
||||||
|
|
||||||
|
| # | Feature | Tier | Size | Dependencies | Primary Files |
|
||||||
|
|---|---------|------|------|-------------|---------------|
|
||||||
|
| 1 | Platform DCT Presets | T1 | M | — | new `platform_presets.py`, `dct_steganography.py`, `encode.py`, `cli.py` |
|
||||||
|
| 2 | Steganalysis Self-Check | T1 | M | — | new `steganalysis.py`, `cli.py`, `constants.py` |
|
||||||
|
| 3 | Python 3.13 DCT Cleanup | T1 | S | — | `dct_steganography.py` |
|
||||||
|
| 4 | Content-Adaptive Embedding | T2 | L | numpy, #2 | new `adaptive_cost.py`, `steganography.py`, `constants.py` |
|
||||||
|
| 5 | HKDF Forward Secrecy | T2 | M | — | `crypto.py`, `constants.py`, `steganography.py` |
|
||||||
|
| 6 | PWA Mobile Interface | T2 | M | — | `frontends/web/` templates + static |
|
||||||
|
| 7 | Dual-Payload Mode | T3 | XL | #2, #4 | new `dual_payload.py`, `steganography.py`, `cli.py` |
|
||||||
|
| 8 | EmbeddingBackend Protocol | Arch | M | — | new `backends/` package, `steganography.py` |
|
||||||
|
| 9 | HKDF Key Separation | Arch | — | Included in #5 | `crypto.py` |
|
||||||
|
| 10 | `[core]` Extra | Arch | S | — | `pyproject.toml` |
|
||||||
|
| 11 | Aletheia Integration | Eco | S | #2 | `steganalysis.py` |
|
||||||
|
| 12 | C2PA Watermarking | Eco | L | — | new module |
|
||||||
|
| 13 | Signal/Matrix Bot | Eco | M | — | new `bots/` package |
|
||||||
|
| 14 | Homebrew + Nix | Eco | S | — | packaging files only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested Roadmap
|
||||||
|
|
||||||
|
### Phase 1 — Foundations (v4.4.0)
|
||||||
|
|
||||||
|
1. **#3** Python 3.13 DCT Cleanup (S) — unblocks CI on 3.13
|
||||||
|
2. **#8** EmbeddingBackend Protocol (M) — architectural cleanup before new embedding work
|
||||||
|
3. **#2** Steganalysis Self-Check (M) — validation tooling for everything that follows
|
||||||
|
|
||||||
|
### Phase 2 — Security & Robustness (v4.5.0)
|
||||||
|
|
||||||
|
4. **#5** HKDF Forward Secrecy (M) — FORMAT_VERSION bump to 6, improved crypto
|
||||||
|
5. **#1** Platform-Calibrated DCT Presets (M) — high user value for social media
|
||||||
|
6. **#14** Homebrew + Nix (S) — distribution expansion
|
||||||
|
|
||||||
|
### Phase 3 — Advanced Steganography (v5.0.0)
|
||||||
|
|
||||||
|
7. **#4** Content-Adaptive Embedding (L) — major security improvement
|
||||||
|
8. **#6** PWA Mobile Interface (M) — parallel frontend work stream
|
||||||
|
|
||||||
|
### Phase 4 — Moonshot (v5.x+)
|
||||||
|
|
||||||
|
9. **#7** Dual-Payload Mode (XL) — after #2 and #4 are solid
|
||||||
|
10. **#12** C2PA Watermarking (L) — research-heavy
|
||||||
|
11. **#13** Signal/Matrix Bot (M) — community-driven
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Ideas (Backlog)
|
||||||
|
|
||||||
|
- **Animated GIF steganography** — LSB in GIF frames, natural multi-media extension
|
||||||
|
- **PDF steganography** — whitespace/font metric/embedded image payloads
|
||||||
|
- **Batch encode** — `stegasoo batch-encode --dir /photos/` with auto carrier selection (BATCH_* constants suggest this was planned)
|
||||||
|
- **Stego identification** — `stegasoo identify image.png` probes for known stego signatures
|
||||||
|
- **Per-device credential sync via QR** — channel key as stego image of reference photo
|
||||||
|
- **`stegasoo verify`** — decode + confirm message matches expected hash without revealing contents
|
||||||
14
README.md
@@ -1,10 +1,10 @@
|
|||||||
# Stegasoo
|
# Stegasoo
|
||||||
|
|
||||||
A secure steganography system for hiding encrypted messages in images using hybrid authentication.
|
A secure steganography system for hiding encrypted messages in images and audio using hybrid authentication.
|
||||||
|
|
||||||
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml)
|
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml)
|
||||||
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml)
|
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml)
|
||||||

|

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

|

|
||||||
|
|
||||||
@@ -17,15 +17,25 @@ A secure steganography system for hiding encrypted messages in images using hybr
|
|||||||
- **Multiple interfaces**: CLI, Web UI, REST API
|
- **Multiple interfaces**: CLI, Web UI, REST API
|
||||||
- **File embedding**: Hide any file type (PDF, ZIP, documents)
|
- **File embedding**: Hide any file type (PDF, ZIP, documents)
|
||||||
- **DCT steganography**: JPEG-resilient embedding for social media
|
- **DCT steganography**: JPEG-resilient embedding for social media
|
||||||
|
- **Audio steganography**: Hide messages in WAV, FLAC, MP3, OGG, AAC, M4A files (LSB and Spread Spectrum modes)
|
||||||
- **Channel keys**: Private group communication channels
|
- **Channel keys**: Private group communication channels
|
||||||
|
|
||||||
## Embedding Modes
|
## Embedding Modes
|
||||||
|
|
||||||
|
### Image Modes
|
||||||
|
|
||||||
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|
||||||
|------|------------------|----------------|----------|
|
|------|------------------|----------------|----------|
|
||||||
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
|
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
|
||||||
| **LSB** | ~750 KB | No | Email, direct file transfer |
|
| **LSB** | ~750 KB | No | Email, direct file transfer |
|
||||||
|
|
||||||
|
### Audio Modes
|
||||||
|
|
||||||
|
| Mode | Capacity (5 min WAV) | Noise Resistant | Best For |
|
||||||
|
|------|---------------------|-----------------|----------|
|
||||||
|
| **LSB** | ~1.3 MB | No | Direct file transfer |
|
||||||
|
| **Spread Spectrum** | ~160 KB | Yes | Shared files, light processing |
|
||||||
|
|
||||||
## Web UI
|
## Web UI
|
||||||
|
|
||||||
| Home | Encode | Decode | Generate |
|
| Home | Encode | Decode | Generate |
|
||||||
|
|||||||
199
RELEASE_NOTES.md
@@ -1,52 +1,173 @@
|
|||||||
## Stegasoo v4.1.7
|
# v4.3.0 — Audio Steganography
|
||||||
|
|
||||||
### Mobile UI Polish
|
**Release Date:** 2026-02-27
|
||||||
- **PIN Entry**: Shrunk digit boxes for 9-digit PIN support on mobile
|
|
||||||
- **Mode Selectors**: DCT/LSB buttons now use consistent button-group styling with icons
|
|
||||||
- **Navbar**: Left-aligned collapsed menu, shortened channel fingerprint display (`ABCD-••••-3456`)
|
|
||||||
- **Text Wrapping**: Fixed button text wrapping issues on narrow screens
|
|
||||||
|
|
||||||
### Docker Improvements
|
## Highlights
|
||||||
- **Reorganized**: Docker files moved to `docker/` directory
|
|
||||||
- `docker/Dockerfile`
|
|
||||||
- `docker/Dockerfile.base`
|
|
||||||
- `docker/docker-compose.yml`
|
|
||||||
- **DCT Fix**: Added Reed-Solomon (`reedsolo`) to Docker images - fixes DCT decode failures
|
|
||||||
- **Quick Start**: New `docs/DOCKER_QUICKSTART.md` guide
|
|
||||||
|
|
||||||
|
Stegasoo can now hide messages in audio files! This release adds full audio steganography support with two embedding modes:
|
||||||
|
|
||||||
|
- **LSB (Least Significant Bit)**: Embeds data directly in audio sample LSBs. High capacity, best for direct file transfers.
|
||||||
|
- **Spread Spectrum**: Spreads data across audio frequencies using pseudo-random sequences. Lower capacity but more resistant to noise and light processing.
|
||||||
|
|
||||||
|
## What's New
|
||||||
|
|
||||||
|
### Audio Steganography
|
||||||
|
- Support for WAV, FLAC, MP3, OGG, AAC, and M4A input formats
|
||||||
|
- Automatic transcoding to WAV (16-bit PCM) for embedding
|
||||||
|
- Same security model: reference photo + passphrase + PIN/RSA + channel key
|
||||||
|
- Full CLI, REST API, and Web UI support
|
||||||
|
|
||||||
|
### Unified Web UI
|
||||||
|
- Encode and Decode pages now feature a "Carrier Type" selector
|
||||||
|
- Switch between Image and Audio modes without leaving the page
|
||||||
|
- Audio capacity display shows LSB and Spread Spectrum capacities
|
||||||
|
- Audio preview player on encode result page
|
||||||
|
|
||||||
|
### New Modules
|
||||||
|
- `audio_steganography.py` — LSB audio embedding/extraction
|
||||||
|
- `spread_steganography.py` — Spread spectrum embedding/extraction
|
||||||
|
- `audio_utils.py` — Audio format detection, validation, transcoding
|
||||||
|
- `debug.py` — Structured logging for all operations
|
||||||
|
|
||||||
|
## Upgrade Notes
|
||||||
|
|
||||||
|
Audio steganography requires `numpy` and `soundfile` packages. Install with:
|
||||||
|
```bash
|
||||||
|
pip install stegasoo[audio]
|
||||||
|
```
|
||||||
|
|
||||||
|
For full audio format support (MP3, AAC, etc.), install FFmpeg on your system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stegasoo v4.2.1
|
||||||
|
|
||||||
|
### API Security
|
||||||
|
|
||||||
|
**API Key Authentication**
|
||||||
|
- All protected endpoints require `X-API-Key` header
|
||||||
|
- Keys stored hashed (SHA-256) in `~/.stegasoo/api_keys.json`
|
||||||
|
- Auth disabled when no keys configured (easy onboarding)
|
||||||
|
|
||||||
|
**TLS Support**
|
||||||
|
- Self-signed certificates auto-generated on first run
|
||||||
|
- Certs valid for localhost, all local IPs, hostname.local
|
||||||
|
- CLI: `stegasoo api tls generate` to pre-generate
|
||||||
|
|
||||||
|
### CLI Improvements
|
||||||
|
|
||||||
|
**New API Management Commands**
|
||||||
|
```bash
|
||||||
|
stegasoo api keys create NAME # Create new key
|
||||||
|
stegasoo api keys list # List API keys
|
||||||
|
stegasoo api tls generate # Generate TLS cert
|
||||||
|
stegasoo api serve # Start server with TLS
|
||||||
|
```
|
||||||
|
|
||||||
|
**New Image Tools**
|
||||||
|
```bash
|
||||||
|
stegasoo tools compress IMG -q 75 # JPEG compression
|
||||||
|
stegasoo tools rotate IMG -r 90 # Lossless rotation
|
||||||
|
stegasoo tools convert IMG -f png # Format conversion
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **DCT rotation**: Portrait photos no longer export rotated 90°
|
||||||
|
- **jpegtran**: Removed `-trim` flag that destroyed DCT stego data
|
||||||
|
- **CLI encode**: Now outputs JPEG when carrier is JPEG (was always PNG)
|
||||||
|
- **Import paths**: Fixed for installed packages (AUR/pip)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
**AUR (Arch Linux)**
|
||||||
|
```bash
|
||||||
|
yay -S stegasoo-git # Full (Web + API + CLI)
|
||||||
|
yay -S stegasoo-cli-git # CLI only
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker**
|
||||||
```bash
|
```bash
|
||||||
# Build and run
|
|
||||||
docker build -f docker/Dockerfile.base -t stegasoo-base:latest .
|
|
||||||
docker-compose -f docker/docker-compose.yml up -d
|
docker-compose -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Raspberry Pi
|
**Raspberry Pi**
|
||||||
- **First-Boot Wizard**: Can now load existing channel key (for joining team deployments)
|
Flash `stegasoo-rpi-4.2.1.img.zst.zip` to SD card.
|
||||||
- **Project Cleanup**: Moved `pishrink.sh` to `rpi/tools/`
|
|
||||||
|
|
||||||
### UI Copy
|
|
||||||
- Changed "Undetectable" to "Covertly Embedded" on encode page (more accurate)
|
|
||||||
|
|
||||||
### Raspberry Pi Image
|
|
||||||
Download `stegasoo-rpi-4.1.7.img.zst.zip` from Releases.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Flash (auto-detects SD card)
|
|
||||||
sudo ./rpi/flash-image.sh stegasoo-rpi-4.1.7.img.zst.zip
|
|
||||||
|
|
||||||
# Or manual
|
|
||||||
unzip -p stegasoo-rpi-4.1.7.img.zst.zip | zstdcat | sudo dd of=/dev/sdX bs=4M status=progress
|
|
||||||
```
|
|
||||||
|
|
||||||
Default login: `admin` / `stegasoo`
|
Default login: `admin` / `stegasoo`
|
||||||
|
|
||||||
First boot runs the setup wizard for WiFi, HTTPS, and channel key configuration.
|
### Requirements
|
||||||
|
|
||||||
### Docker
|
- Python 3.11 - 3.14 (dropped 3.10 support)
|
||||||
```bash
|
|
||||||
docker-compose -f docker/docker-compose.yml up -d web # Web UI on :5000
|
### Release Assets
|
||||||
docker-compose -f docker/docker-compose.yml up -d api # REST API on :8000
|
|
||||||
```
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `stegasoo-rpi-4.2.1.img.zst.zip` | Raspberry Pi SD card image |
|
||||||
|
| `stegasoo-docker-base-4.2.1.tar.zst` | Docker base image |
|
||||||
|
| Source code (zip/tar.gz) | Auto-generated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stegasoo v4.2.0
|
||||||
|
|
||||||
|
### Performance Optimizations
|
||||||
|
|
||||||
|
Major performance improvements for Raspberry Pi and resource-constrained deployments.
|
||||||
|
|
||||||
|
#### DCT Vectorization (~14x faster)
|
||||||
|
- Batch DCT processing using `scipy.fft.dctn` with `axes=(1,2)`
|
||||||
|
- Processes 500 blocks at once instead of one-by-one
|
||||||
|
- Decode time reduced from ~2.6s to ~0.8s on 1MB images
|
||||||
|
|
||||||
|
#### Memory Optimization (50% reduction)
|
||||||
|
- Switched from `float64` to `float32` for all DCT operations
|
||||||
|
- Peak RAM: 211 MB → 107 MB for encode, 104 MB → 52 MB for decode
|
||||||
|
- Critical for Pi 3/4 avoiding swap thrashing
|
||||||
|
|
||||||
|
#### Progress Callbacks for Decode
|
||||||
|
- `progress_file` parameter added to `decode()` and extraction functions
|
||||||
|
- UI can now show decode progress (phases: loading, extracting, decoding, complete)
|
||||||
|
- JSON format: `{"current": 80, "total": 100, "percent": 80.0, "phase": "decoding"}`
|
||||||
|
|
||||||
|
#### Async API Endpoints
|
||||||
|
- Encode/decode operations now run in thread pool via `asyncio.to_thread()`
|
||||||
|
- API server can handle concurrent requests without blocking
|
||||||
|
- Essential for multi-user Pi deployments
|
||||||
|
|
||||||
|
### Compression
|
||||||
|
|
||||||
|
#### Zstd Default Compression
|
||||||
|
- `zstandard` is now a core dependency (always installed)
|
||||||
|
- Better compression ratio than zlib for QR code RSA keys
|
||||||
|
- New `STEGASOO-ZS:` prefix for zstd, backward compatible with `STEGASOO-Z:` (zlib)
|
||||||
|
|
||||||
|
### QR Code Generation
|
||||||
|
|
||||||
|
#### CLI Support
|
||||||
|
- `stegasoo generate --rsa --qr key.png` - save RSA key as QR image (PNG/JPG)
|
||||||
|
- `stegasoo generate --rsa --qr-ascii` - print ASCII QR to terminal
|
||||||
|
|
||||||
|
#### API Support
|
||||||
|
- `POST /generate-key-qr` - generate QR from RSA key
|
||||||
|
- Supports `png`, `jpg`, and `ascii` output formats
|
||||||
|
- Uses zstd compression by default
|
||||||
|
|
||||||
|
### Other Changes
|
||||||
|
|
||||||
|
- RSA key size capped at 3072 bits (4096 too large for QR codes)
|
||||||
|
- File auto-expire increased to 10 minutes
|
||||||
|
- Progress bar "candy cane" animation during Argon2 key derivation
|
||||||
|
- Optional API service in Pi setup (with security warning)
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
| Metric | v4.1.7 | v4.2.0 | Improvement |
|
||||||
|
|--------|--------|--------|-------------|
|
||||||
|
| Decode (1MB) | ~2.6s | ~0.8s | **70% faster** |
|
||||||
|
| Peak RAM | 211 MB | 107 MB | **50% less** |
|
||||||
|
| Concurrent API | No | Yes | check |
|
||||||
|
| QR Compression | zlib | zstd | **~15% smaller** |
|
||||||
|
|
||||||
### Full Changelog
|
### Full Changelog
|
||||||
See [CHANGELOG.md](CHANGELOG.md) for complete version history.
|
See [CHANGELOG.md](CHANGELOG.md) for complete version history.
|
||||||
|
|||||||
54
TODO-4.2.1.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Stegasoo 4.2.1 Plan
|
||||||
|
|
||||||
|
## Bugs
|
||||||
|
- [x] Fix EXIF viewer panel not loading metadata in Web UI
|
||||||
|
- Redesigned with card-based grid layout and categories
|
||||||
|
- Compact styling for better space usage
|
||||||
|
- [x] DCT mode: portrait photos export rotated 90° (EXIF orientation not handled)
|
||||||
|
- Added `_apply_exif_orientation()` to apply EXIF rotation before embedding
|
||||||
|
- [x] DCT mode: add rotation fallback (try as-is, rotate 90°, retry on failure)
|
||||||
|
- Added rotation fallback in `extract_from_dct()` with quick header validation
|
||||||
|
- [x] Rotate tool: use jpegtran for lossless JPEG rotation (preserves DCT stego!)
|
||||||
|
- Web UI rotate tool now uses jpegtran for JPEGs
|
||||||
|
- DCT decode rotation fallback now uses jpegtran for JPEGs
|
||||||
|
- Dynamic UI shows "DCT Safe" for JPEGs, warning for other formats
|
||||||
|
|
||||||
|
## Tools Audit
|
||||||
|
- [x] Web UI tools - full shakedown and fixes
|
||||||
|
- Compress, Rotate, Strip, EXIF viewer all working
|
||||||
|
- Rotate uses jpegtran for lossless JPEG rotation
|
||||||
|
- Compact UI styling
|
||||||
|
- [x] CLI tools - full shakedown and fixes
|
||||||
|
- Fixed encode to output JPEG when carrier is JPEG (was always PNG)
|
||||||
|
- Fixed jpegtran -trim flag destroying DCT stego data
|
||||||
|
- Added compress, rotate, convert tools (matching Web UI)
|
||||||
|
- Rotate uses jpegtran for JPEGs, supports flip-only operations
|
||||||
|
|
||||||
|
## AUR Packages
|
||||||
|
- [x] `stegasoo-cli` - standalone CLI package (no web dependencies)
|
||||||
|
- Created aur-cli/PKGBUILD with [cli,dct,compression] extras only
|
||||||
|
- No flask/gunicorn/fastapi/uvicorn/pyzbar deps
|
||||||
|
- 68MB vs 79MB for full package
|
||||||
|
- [x] `stegasoo-api` - REST API package
|
||||||
|
- Created aur-api/PKGBUILD with [api,cli,compression] extras
|
||||||
|
- Has fastapi/uvicorn, no flask/gunicorn
|
||||||
|
- 74MB package size
|
||||||
|
- Includes systemd service with TLS
|
||||||
|
|
||||||
|
## API Auth Work
|
||||||
|
- [x] API key authentication (simpler than OAuth2 for personal use)
|
||||||
|
- `frontends/api/auth.py` - key generation, hashing, validation
|
||||||
|
- Keys stored in `~/.stegasoo/api_keys.json` (hashed)
|
||||||
|
- `X-API-Key` header for authentication
|
||||||
|
- Auth disabled when no keys configured
|
||||||
|
- [x] TLS with self-signed certificates
|
||||||
|
- Auto-generates certs on first run
|
||||||
|
- CLI: `stegasoo api tls generate`
|
||||||
|
- Certs stored in `~/.stegasoo/certs/`
|
||||||
|
- [x] CLI commands for API management
|
||||||
|
- `stegasoo api keys list/create/delete`
|
||||||
|
- `stegasoo api tls generate/info`
|
||||||
|
- `stegasoo api serve` (starts with TLS by default)
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
- [ ] Postman collection (with environment templates)
|
||||||
@@ -411,7 +411,7 @@ Create a new set of credentials for steganography operations.
|
|||||||
| Use PIN | on/off | on | Generate a numeric PIN |
|
| Use PIN | on/off | on | Generate a numeric PIN |
|
||||||
| PIN length | 6-9 | 6 | Digits in the PIN |
|
| PIN length | 6-9 | 6 | Digits in the PIN |
|
||||||
| Use RSA Key | on/off | off | Generate an RSA key pair |
|
| Use RSA Key | on/off | off | Generate an RSA key pair |
|
||||||
| RSA key size | 2048/3072/4096 | 2048 | Key size in bits |
|
| RSA key size | 2048/3072 | 2048 | Key size in bits |
|
||||||
|
|
||||||
#### Entropy Calculator
|
#### Entropy Calculator
|
||||||
|
|
||||||
|
|||||||
23
aur-api/.SRCINFO
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
pkgbase = stegasoo-api-git
|
||||||
|
pkgdesc = Stegasoo REST API with TLS and API key authentication
|
||||||
|
pkgver = 4.2.1
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/adlee-was-taken/stegasoo
|
||||||
|
install = stegasoo-api-git.install
|
||||||
|
arch = x86_64
|
||||||
|
license = MIT
|
||||||
|
makedepends = git
|
||||||
|
makedepends = python
|
||||||
|
makedepends = python-build
|
||||||
|
makedepends = python-hatchling
|
||||||
|
depends = python>=3.11
|
||||||
|
depends = zbar
|
||||||
|
optdepends = libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)
|
||||||
|
provides = stegasoo-api
|
||||||
|
conflicts = stegasoo-api
|
||||||
|
conflicts = stegasoo
|
||||||
|
conflicts = stegasoo-git
|
||||||
|
source = stegasoo-api-git::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main
|
||||||
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
pkgname = stegasoo-api-git
|
||||||
109
aur-api/PKGBUILD
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||||
|
pkgname=stegasoo-api-git
|
||||||
|
pkgver=4.3.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Stegasoo REST API with TLS and API key authentication"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/adlee-was-taken/stegasoo"
|
||||||
|
license=('MIT')
|
||||||
|
|
||||||
|
# Python 3.11-3.14 supported
|
||||||
|
depends=(
|
||||||
|
'python>=3.11'
|
||||||
|
'zbar' # QR code reading for RSA key extraction
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'git'
|
||||||
|
'python'
|
||||||
|
'python-build'
|
||||||
|
'python-hatchling'
|
||||||
|
)
|
||||||
|
optdepends=(
|
||||||
|
'libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)'
|
||||||
|
)
|
||||||
|
provides=('stegasoo-api')
|
||||||
|
conflicts=('stegasoo-api' 'stegasoo' 'stegasoo-git')
|
||||||
|
install=stegasoo-api-git.install
|
||||||
|
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
pkgver() {
|
||||||
|
cd "$pkgname"
|
||||||
|
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||||
|
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname"
|
||||||
|
python -m build --wheel --no-isolation
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname"
|
||||||
|
|
||||||
|
# Detect Python version for site-packages path
|
||||||
|
local pyver=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||||
|
|
||||||
|
# Install to /opt/stegasoo-api with dedicated venv
|
||||||
|
install -dm755 "$pkgdir/opt/stegasoo-api"
|
||||||
|
|
||||||
|
# Create fresh venv in package
|
||||||
|
python -m venv "$pkgdir/opt/stegasoo-api/venv"
|
||||||
|
|
||||||
|
# Install the wheel with API + CLI + compression extras
|
||||||
|
local wheel=$(ls dist/*.whl | head -1)
|
||||||
|
"$pkgdir/opt/stegasoo-api/venv/bin/pip" install --no-cache-dir "${wheel}[api,cli,compression]"
|
||||||
|
|
||||||
|
# Install API frontend (not included in wheel by default)
|
||||||
|
local site_packages="$pkgdir/opt/stegasoo-api/venv/lib/python${pyver}/site-packages"
|
||||||
|
install -dm755 "$site_packages/frontends/api"
|
||||||
|
cp -r frontends/api/*.py "$site_packages/frontends/api/"
|
||||||
|
cp -r frontends/__init__.py "$site_packages/frontends/" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create temp directory for API
|
||||||
|
install -dm755 "$site_packages/frontends/api/temp_files"
|
||||||
|
|
||||||
|
# Create config directories
|
||||||
|
install -dm755 "$pkgdir/opt/stegasoo-api/config"
|
||||||
|
install -dm700 "$pkgdir/opt/stegasoo-api/certs"
|
||||||
|
|
||||||
|
# Fix shebangs - replace build-time paths with installed paths
|
||||||
|
find "$pkgdir/opt/stegasoo-api/venv/bin" -type f -exec \
|
||||||
|
sed -i "s|$pkgdir/opt/stegasoo-api/venv|/opt/stegasoo-api/venv|g" {} \;
|
||||||
|
|
||||||
|
# Fix pyvenv.cfg
|
||||||
|
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo-api/venv/pyvenv.cfg"
|
||||||
|
|
||||||
|
# Create symlink to /usr/bin
|
||||||
|
install -dm755 "$pkgdir/usr/bin"
|
||||||
|
ln -s /opt/stegasoo-api/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
|
||||||
|
|
||||||
|
# Install license
|
||||||
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
|
||||||
|
# Install docs
|
||||||
|
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
|
||||||
|
# Install systemd service
|
||||||
|
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stegasoo REST API (HTTPS)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=stegasoo
|
||||||
|
WorkingDirectory=/opt/stegasoo-api/venv/lib/python${pyver}/site-packages/frontends/api
|
||||||
|
Environment="PATH=/opt/stegasoo-api/venv/bin"
|
||||||
|
Environment="HOME=/opt/stegasoo-api"
|
||||||
|
# TLS enabled by default - certs auto-generated on first run
|
||||||
|
# Use: stegasoo api tls generate (to pre-generate certs)
|
||||||
|
# Use: stegasoo api keys create <name> (to create API keys)
|
||||||
|
ExecStart=/opt/stegasoo-api/venv/bin/stegasoo api serve --host 127.0.0.1 --port 8000
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
}
|
||||||
102
aur-api/README.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Stegasoo API AUR Package
|
||||||
|
|
||||||
|
REST API server package for programmatic steganography operations. Includes HTTPS support and API key authentication.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From AUR (once published)
|
||||||
|
```bash
|
||||||
|
yay -S stegasoo-api-git
|
||||||
|
# or
|
||||||
|
paru -S stegasoo-api-git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual build
|
||||||
|
```bash
|
||||||
|
git clone https://aur.archlinux.org/stegasoo-api-git.git
|
||||||
|
cd stegasoo-api-git
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Gets Installed
|
||||||
|
|
||||||
|
- `/opt/stegasoo-api/venv/` - Self-contained Python venv with API dependencies
|
||||||
|
- `/opt/stegasoo-api/config/` - API key storage
|
||||||
|
- `/opt/stegasoo-api/certs/` - TLS certificates
|
||||||
|
- `/usr/bin/stegasoo` - CLI executable
|
||||||
|
- `/usr/lib/systemd/system/stegasoo-api.service` - Systemd service
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create an API key
|
||||||
|
sudo -u stegasoo stegasoo api keys create mykey
|
||||||
|
|
||||||
|
# 2. Start the service
|
||||||
|
sudo systemctl enable --now stegasoo-api
|
||||||
|
|
||||||
|
# 3. Test the API
|
||||||
|
curl -k -H "X-API-Key: YOUR_KEY" https://localhost:8000/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service Details
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Port | 8000 |
|
||||||
|
| Protocol | HTTPS (self-signed cert auto-generated) |
|
||||||
|
| API Docs | https://localhost:8000/docs |
|
||||||
|
| OpenAPI | https://localhost:8000/openapi.json |
|
||||||
|
|
||||||
|
## API Key Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all keys
|
||||||
|
stegasoo api keys list
|
||||||
|
|
||||||
|
# Create a new key
|
||||||
|
sudo -u stegasoo stegasoo api keys create <name>
|
||||||
|
|
||||||
|
# Revoke a key
|
||||||
|
sudo -u stegasoo stegasoo api keys revoke <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
## TLS Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View current certificate info
|
||||||
|
stegasoo api tls info
|
||||||
|
|
||||||
|
# Generate new self-signed certificate
|
||||||
|
sudo -u stegasoo stegasoo api tls generate
|
||||||
|
|
||||||
|
# Use custom certificates (edit service)
|
||||||
|
sudo systemctl edit stegasoo-api
|
||||||
|
# Add:
|
||||||
|
# [Service]
|
||||||
|
# ExecStart=
|
||||||
|
# ExecStart=/opt/stegasoo-api/venv/bin/stegasoo api serve \
|
||||||
|
# --host 0.0.0.0 --port 8000 \
|
||||||
|
# --cert /path/to/cert.pem --key /path/to/key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Run (without systemd)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development mode (auto-reload)
|
||||||
|
/opt/stegasoo-api/venv/bin/stegasoo api serve --reload
|
||||||
|
|
||||||
|
# Production mode
|
||||||
|
/opt/stegasoo-api/venv/bin/stegasoo api serve --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## For Web UI
|
||||||
|
|
||||||
|
Install the full package instead:
|
||||||
|
```bash
|
||||||
|
yay -S stegasoo-git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintainer
|
||||||
|
|
||||||
|
Aaron D. Lee
|
||||||
63
aur-api/stegasoo-api-git.install
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
post_install() {
|
||||||
|
# Create stegasoo system user if it doesn't exist
|
||||||
|
if ! getent passwd stegasoo >/dev/null; then
|
||||||
|
useradd -r -s /usr/bin/nologin -d /opt/stegasoo-api stegasoo
|
||||||
|
echo "Created system user 'stegasoo'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set ownership of directories
|
||||||
|
chown -R stegasoo:stegasoo /opt/stegasoo-api/config 2>/dev/null || true
|
||||||
|
chown -R stegasoo:stegasoo /opt/stegasoo-api/certs 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==============================================="
|
||||||
|
echo " Stegasoo API installed successfully!"
|
||||||
|
echo "==============================================="
|
||||||
|
echo ""
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo " Quick Start"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo " 1. Create an API key:"
|
||||||
|
echo " sudo -u stegasoo stegasoo api keys create mykey"
|
||||||
|
echo ""
|
||||||
|
echo " 2. Start the API server:"
|
||||||
|
echo " sudo systemctl start stegasoo-api"
|
||||||
|
echo " sudo systemctl enable stegasoo-api # auto-start"
|
||||||
|
echo ""
|
||||||
|
echo " 3. Access the API:"
|
||||||
|
echo " curl -k -H 'X-API-Key: YOUR_KEY' https://localhost:8000/"
|
||||||
|
echo ""
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo " Service Details"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo " Port: 8000 (HTTPS by default)"
|
||||||
|
echo " Docs: https://localhost:8000/docs"
|
||||||
|
echo " Status: sudo systemctl status stegasoo-api"
|
||||||
|
echo ""
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo " Management Commands"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo " stegasoo api keys list # List API keys"
|
||||||
|
echo " stegasoo api keys create X # Create new key"
|
||||||
|
echo " stegasoo api tls generate # Generate TLS certs"
|
||||||
|
echo " stegasoo api tls info # Show certificate info"
|
||||||
|
echo " stegasoo api serve --help # Server options"
|
||||||
|
echo ""
|
||||||
|
echo "==============================================="
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
post_upgrade() {
|
||||||
|
post_install
|
||||||
|
}
|
||||||
|
|
||||||
|
pre_remove() {
|
||||||
|
# Stop service if running
|
||||||
|
systemctl stop stegasoo-api 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
post_remove() {
|
||||||
|
echo "Stegasoo API removed."
|
||||||
|
echo "User 'stegasoo' and config in /opt/stegasoo-api were not removed."
|
||||||
|
echo "To remove: userdel stegasoo && rm -rf /opt/stegasoo-api"
|
||||||
|
}
|
||||||
22
aur-api/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test build the AUR API package locally
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "=== Cleaning previous builds ==="
|
||||||
|
rm -rf stegasoo-api-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== Generating .SRCINFO ==="
|
||||||
|
makepkg --printsrcinfo > .SRCINFO
|
||||||
|
|
||||||
|
echo "=== Building package ==="
|
||||||
|
makepkg -sf
|
||||||
|
|
||||||
|
echo "=== Package built ==="
|
||||||
|
ls -la *.pkg.tar.zst
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "To install: sudo pacman -U stegasoo-api-git-*.pkg.tar.zst"
|
||||||
|
echo "To test: makepkg -si"
|
||||||
22
aur-cli/.SRCINFO
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
pkgbase = stegasoo-cli-git
|
||||||
|
pkgdesc = Secure steganography CLI with hybrid photo + passphrase + PIN authentication
|
||||||
|
pkgver = 4.2.1
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/adlee-was-taken/stegasoo
|
||||||
|
install = stegasoo-cli-git.install
|
||||||
|
arch = x86_64
|
||||||
|
license = MIT
|
||||||
|
makedepends = git
|
||||||
|
makedepends = python
|
||||||
|
makedepends = python-build
|
||||||
|
makedepends = python-hatchling
|
||||||
|
depends = python>=3.11
|
||||||
|
optdepends = libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)
|
||||||
|
provides = stegasoo-cli
|
||||||
|
conflicts = stegasoo-cli
|
||||||
|
conflicts = stegasoo
|
||||||
|
conflicts = stegasoo-git
|
||||||
|
source = stegasoo-cli-git::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main
|
||||||
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
pkgname = stegasoo-cli-git
|
||||||
69
aur-cli/PKGBUILD
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||||
|
pkgname=stegasoo-cli-git
|
||||||
|
pkgver=4.3.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Secure steganography CLI with hybrid photo + passphrase + PIN authentication"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/adlee-was-taken/stegasoo"
|
||||||
|
license=('MIT')
|
||||||
|
|
||||||
|
# Python 3.11-3.14 supported (uses jpeglib for modern Python compatibility)
|
||||||
|
depends=(
|
||||||
|
'python>=3.11'
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'git'
|
||||||
|
'python'
|
||||||
|
'python-build'
|
||||||
|
'python-hatchling'
|
||||||
|
)
|
||||||
|
optdepends=(
|
||||||
|
'libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)'
|
||||||
|
)
|
||||||
|
provides=('stegasoo-cli')
|
||||||
|
conflicts=('stegasoo-cli' 'stegasoo' 'stegasoo-git')
|
||||||
|
install=stegasoo-cli-git.install
|
||||||
|
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
pkgver() {
|
||||||
|
cd "$pkgname"
|
||||||
|
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||||
|
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname"
|
||||||
|
python -m build --wheel --no-isolation
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname"
|
||||||
|
|
||||||
|
# Install to /opt/stegasoo-cli with dedicated venv
|
||||||
|
install -dm755 "$pkgdir/opt/stegasoo-cli"
|
||||||
|
|
||||||
|
# Create fresh venv in package
|
||||||
|
python -m venv "$pkgdir/opt/stegasoo-cli/venv"
|
||||||
|
|
||||||
|
# Install the wheel with CLI + DCT + compression extras (no web/api)
|
||||||
|
local wheel=$(ls dist/*.whl | head -1)
|
||||||
|
"$pkgdir/opt/stegasoo-cli/venv/bin/pip" install --no-cache-dir "${wheel}[cli,dct,compression]"
|
||||||
|
|
||||||
|
# Fix shebangs - replace build-time paths with installed paths
|
||||||
|
find "$pkgdir/opt/stegasoo-cli/venv/bin" -type f -exec \
|
||||||
|
sed -i "s|$pkgdir/opt/stegasoo-cli/venv|/opt/stegasoo-cli/venv|g" {} \;
|
||||||
|
|
||||||
|
# Fix pyvenv.cfg
|
||||||
|
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo-cli/venv/pyvenv.cfg"
|
||||||
|
|
||||||
|
# Create symlink to /usr/bin
|
||||||
|
install -dm755 "$pkgdir/usr/bin"
|
||||||
|
ln -s /opt/stegasoo-cli/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
|
||||||
|
|
||||||
|
# Install license
|
||||||
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
|
||||||
|
# Install docs
|
||||||
|
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
}
|
||||||
62
aur-cli/README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Stegasoo CLI AUR Package
|
||||||
|
|
||||||
|
Lightweight CLI-only package for steganography operations. No web UI or API server.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From AUR (once published)
|
||||||
|
```bash
|
||||||
|
yay -S stegasoo-cli-git
|
||||||
|
# or
|
||||||
|
paru -S stegasoo-cli-git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual build
|
||||||
|
```bash
|
||||||
|
git clone https://aur.archlinux.org/stegasoo-cli-git.git
|
||||||
|
cd stegasoo-cli-git
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Gets Installed
|
||||||
|
|
||||||
|
- `/opt/stegasoo-cli/venv/` - Self-contained Python venv with CLI dependencies only
|
||||||
|
- `/usr/bin/stegasoo` - CLI executable
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show all commands
|
||||||
|
stegasoo --help
|
||||||
|
|
||||||
|
# Generate credentials (passphrase + PIN)
|
||||||
|
stegasoo generate
|
||||||
|
stegasoo generate --words 5 --pin-length 8
|
||||||
|
|
||||||
|
# Generate with RSA keys and QR codes
|
||||||
|
stegasoo generate --rsa --qr-ascii
|
||||||
|
|
||||||
|
# Encode a message
|
||||||
|
stegasoo encode -i carrier.jpg -r reference.jpg -m "secret message" \
|
||||||
|
-P "word1 word2 word3 word4" -p 123456
|
||||||
|
|
||||||
|
# Decode a message
|
||||||
|
stegasoo decode -i encoded.png -r reference.jpg \
|
||||||
|
-P "word1 word2 word3 word4" -p 123456
|
||||||
|
|
||||||
|
# Image tools
|
||||||
|
stegasoo tools --help
|
||||||
|
stegasoo tools compress image.png
|
||||||
|
stegasoo tools rotate image.jpg 90
|
||||||
|
```
|
||||||
|
|
||||||
|
## For Web UI or REST API
|
||||||
|
|
||||||
|
Install the full package instead:
|
||||||
|
```bash
|
||||||
|
yay -S stegasoo-git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintainer
|
||||||
|
|
||||||
|
Aaron D. Lee
|
||||||
20
aur-cli/stegasoo-cli-git.install
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
post_install() {
|
||||||
|
echo ""
|
||||||
|
echo "==============================================="
|
||||||
|
echo " Stegasoo CLI installed successfully!"
|
||||||
|
echo "==============================================="
|
||||||
|
echo ""
|
||||||
|
echo "Usage:"
|
||||||
|
echo " stegasoo --help # Show all commands"
|
||||||
|
echo " stegasoo generate # Generate passphrase + PIN"
|
||||||
|
echo " stegasoo encode ... # Hide data in an image"
|
||||||
|
echo " stegasoo decode ... # Extract hidden data"
|
||||||
|
echo " stegasoo tools --help # Image tools (compress, etc.)"
|
||||||
|
echo ""
|
||||||
|
echo "For web UI or REST API, install stegasoo-git instead."
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
post_upgrade() {
|
||||||
|
post_install
|
||||||
|
}
|
||||||
22
aur-cli/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test build the AUR CLI package locally
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "=== Cleaning previous builds ==="
|
||||||
|
rm -rf stegasoo-cli-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== Generating .SRCINFO ==="
|
||||||
|
makepkg --printsrcinfo > .SRCINFO
|
||||||
|
|
||||||
|
echo "=== Building package ==="
|
||||||
|
makepkg -sf
|
||||||
|
|
||||||
|
echo "=== Package built ==="
|
||||||
|
ls -la *.pkg.tar.zst
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "To install: sudo pacman -U stegasoo-cli-git-*.pkg.tar.zst"
|
||||||
|
echo "To test: makepkg -si"
|
||||||
120
aur/PKGBUILD
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||||
|
pkgname=stegasoo-git
|
||||||
|
pkgver=4.3.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/adlee-was-taken/stegasoo"
|
||||||
|
license=('MIT')
|
||||||
|
|
||||||
|
# Python 3.11-3.14 supported (uses jpeglib for modern Python compatibility)
|
||||||
|
depends=(
|
||||||
|
'python>=3.11'
|
||||||
|
'zbar' # QR code reading for Web UI
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'git'
|
||||||
|
'python'
|
||||||
|
'python-build'
|
||||||
|
'python-hatchling'
|
||||||
|
)
|
||||||
|
provides=('stegasoo')
|
||||||
|
conflicts=('stegasoo')
|
||||||
|
install=stegasoo-git.install
|
||||||
|
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
pkgver() {
|
||||||
|
cd "$pkgname"
|
||||||
|
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||||
|
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname"
|
||||||
|
python -m build --wheel --no-isolation
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname"
|
||||||
|
|
||||||
|
# Detect Python version for site-packages path
|
||||||
|
local pyver=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||||
|
|
||||||
|
# Install to /opt/stegasoo with dedicated venv
|
||||||
|
install -dm755 "$pkgdir/opt/stegasoo"
|
||||||
|
|
||||||
|
# Create fresh venv in package
|
||||||
|
python -m venv "$pkgdir/opt/stegasoo/venv"
|
||||||
|
|
||||||
|
# Install the wheel with all extras
|
||||||
|
local wheel=$(ls dist/*.whl | head -1)
|
||||||
|
"$pkgdir/opt/stegasoo/venv/bin/pip" install --no-cache-dir "${wheel}[all]"
|
||||||
|
|
||||||
|
# Install frontends (not included in wheel)
|
||||||
|
local site_packages="$pkgdir/opt/stegasoo/venv/lib/python${pyver}/site-packages"
|
||||||
|
cp -r frontends "$site_packages/"
|
||||||
|
|
||||||
|
# Create writable directories for stegasoo user
|
||||||
|
install -dm755 "$pkgdir/opt/stegasoo/venv/var/app-instance"
|
||||||
|
install -dm755 "$site_packages/frontends/web/temp_files"
|
||||||
|
install -dm755 "$site_packages/frontends/api/temp_files"
|
||||||
|
|
||||||
|
# Fix shebangs - replace build-time paths with installed paths
|
||||||
|
find "$pkgdir/opt/stegasoo/venv/bin" -type f -exec \
|
||||||
|
sed -i "s|$pkgdir/opt/stegasoo/venv|/opt/stegasoo/venv|g" {} \;
|
||||||
|
|
||||||
|
# Fix pyvenv.cfg
|
||||||
|
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo/venv/pyvenv.cfg"
|
||||||
|
|
||||||
|
# Create symlinks to /usr/bin
|
||||||
|
install -dm755 "$pkgdir/usr/bin"
|
||||||
|
ln -s /opt/stegasoo/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
|
||||||
|
|
||||||
|
# Install license
|
||||||
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
|
||||||
|
# Install docs
|
||||||
|
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
|
||||||
|
# Install systemd service files
|
||||||
|
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-web.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stegasoo Web UI
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=stegasoo
|
||||||
|
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/web
|
||||||
|
Environment="PATH=/opt/stegasoo/venv/bin"
|
||||||
|
ExecStart=/opt/stegasoo/venv/bin/gunicorn -b 127.0.0.1:5000 app:app
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stegasoo REST API (HTTPS)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=stegasoo
|
||||||
|
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/api
|
||||||
|
Environment="PATH=/opt/stegasoo/venv/bin"
|
||||||
|
Environment="HOME=/opt/stegasoo"
|
||||||
|
# TLS enabled by default - certs auto-generated on first run
|
||||||
|
# Use stegasoo api tls generate to pre-generate certs
|
||||||
|
# Use stegasoo api keys create <name> to create API keys
|
||||||
|
ExecStart=/opt/stegasoo/venv/bin/stegasoo api serve --host 127.0.0.1 --port 8000
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
}
|
||||||
90
aur/README.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Stegasoo AUR Package
|
||||||
|
|
||||||
|
Full package with CLI, Web UI, and REST API. Supports Python 3.11-3.14.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From AUR (once published)
|
||||||
|
```bash
|
||||||
|
yay -S stegasoo-git
|
||||||
|
# or
|
||||||
|
paru -S stegasoo-git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual build
|
||||||
|
```bash
|
||||||
|
git clone https://aur.archlinux.org/stegasoo-git.git
|
||||||
|
cd stegasoo-git
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Gets Installed
|
||||||
|
|
||||||
|
- `/opt/stegasoo/venv/` - Self-contained Python venv with all dependencies
|
||||||
|
- `/usr/bin/stegasoo` - CLI symlink
|
||||||
|
- `/usr/lib/systemd/system/stegasoo-web.service` - Web UI service (port 5000)
|
||||||
|
- `/usr/lib/systemd/system/stegasoo-api.service` - REST API service (port 8000, HTTPS)
|
||||||
|
|
||||||
|
## Optional Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# QR code reading from webcam/images (recommended)
|
||||||
|
sudo pacman -S zbar
|
||||||
|
```
|
||||||
|
|
||||||
|
All other dependencies are bundled in the venv.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
```bash
|
||||||
|
stegasoo --help
|
||||||
|
stegasoo generate # Generate passphrase + PIN
|
||||||
|
stegasoo generate --rsa --qr-ascii # With RSA keys and QR codes
|
||||||
|
stegasoo encode -i carrier.jpg -r reference.jpg -m "secret" -P "word1 word2 word3 word4" -p 123456
|
||||||
|
stegasoo decode -i encoded.png -r reference.jpg -P "word1 word2 word3 word4" -p 123456
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
```bash
|
||||||
|
# Start service (user created automatically on install)
|
||||||
|
sudo systemctl enable --now stegasoo-web
|
||||||
|
|
||||||
|
# Access at http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
```bash
|
||||||
|
# Create an API key first
|
||||||
|
sudo -u stegasoo stegasoo api keys create mykey
|
||||||
|
|
||||||
|
# Start service (HTTPS with auto-generated self-signed cert)
|
||||||
|
sudo systemctl enable --now stegasoo-api
|
||||||
|
|
||||||
|
# Access docs at https://localhost:8000/docs
|
||||||
|
curl -k -H "X-API-Key: YOUR_KEY" https://localhost:8000/
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTPS Configuration
|
||||||
|
|
||||||
|
The API uses HTTPS by default with auto-generated self-signed certificates.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View certificate info
|
||||||
|
stegasoo api tls info
|
||||||
|
|
||||||
|
# Generate new self-signed cert
|
||||||
|
sudo -u stegasoo stegasoo api tls generate
|
||||||
|
|
||||||
|
# Use custom certs (edit service file)
|
||||||
|
sudo systemctl edit stegasoo-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternative Packages
|
||||||
|
|
||||||
|
- `stegasoo-cli-git` - CLI only, minimal dependencies
|
||||||
|
- `stegasoo-api-git` - CLI + REST API, no web UI
|
||||||
|
|
||||||
|
## Maintainer
|
||||||
|
|
||||||
|
Aaron D. Lee
|
||||||
75
aur/stegasoo-git.install
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
post_install() {
|
||||||
|
# Create stegasoo system user if it doesn't exist
|
||||||
|
if ! getent passwd stegasoo >/dev/null; then
|
||||||
|
useradd -r -s /usr/bin/nologin -d /opt/stegasoo stegasoo
|
||||||
|
echo "Created system user 'stegasoo'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set ownership of instance directory for Flask
|
||||||
|
chown -R stegasoo:stegasoo /opt/stegasoo/venv/var/app-instance 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==============================================="
|
||||||
|
echo " Stegasoo installed successfully!"
|
||||||
|
echo "==============================================="
|
||||||
|
echo ""
|
||||||
|
echo "CLI usage:"
|
||||||
|
echo " stegasoo --help"
|
||||||
|
echo " stegasoo generate # Generate credentials"
|
||||||
|
echo " stegasoo encode # Encode a message"
|
||||||
|
echo " stegasoo decode # Decode a message"
|
||||||
|
echo ""
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo " Web UI Service"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo " Port: 5000 (HTTP)"
|
||||||
|
echo " Start: sudo systemctl start stegasoo-web"
|
||||||
|
echo " Enable: sudo systemctl enable stegasoo-web"
|
||||||
|
echo " Status: sudo systemctl status stegasoo-web"
|
||||||
|
echo " Access: http://localhost:5000"
|
||||||
|
echo ""
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo " REST API Service"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo " Port: 8000 (HTTPS by default)"
|
||||||
|
echo " Start: sudo systemctl start stegasoo-api"
|
||||||
|
echo " Enable: sudo systemctl enable stegasoo-api"
|
||||||
|
echo " Status: sudo systemctl status stegasoo-api"
|
||||||
|
echo " Access: https://localhost:8000"
|
||||||
|
echo ""
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo " HTTPS Configuration"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo " The API generates self-signed certs on first run."
|
||||||
|
echo " To pre-generate or use custom certificates:"
|
||||||
|
echo ""
|
||||||
|
echo " # Generate self-signed certs"
|
||||||
|
echo " sudo -u stegasoo stegasoo api tls generate"
|
||||||
|
echo ""
|
||||||
|
echo " # Use custom certs (edit the service file)"
|
||||||
|
echo " sudo systemctl edit stegasoo-api"
|
||||||
|
echo " # Add: ExecStart= with --cert and --key flags"
|
||||||
|
echo ""
|
||||||
|
echo " # Create API keys for authentication"
|
||||||
|
echo " sudo -u stegasoo stegasoo api keys create <name>"
|
||||||
|
echo ""
|
||||||
|
echo "==============================================="
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
post_upgrade() {
|
||||||
|
post_install
|
||||||
|
}
|
||||||
|
|
||||||
|
pre_remove() {
|
||||||
|
# Stop services if running
|
||||||
|
systemctl stop stegasoo-web 2>/dev/null || true
|
||||||
|
systemctl stop stegasoo-api 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
post_remove() {
|
||||||
|
# Optionally remove the stegasoo user
|
||||||
|
# userdel stegasoo 2>/dev/null || true
|
||||||
|
echo "Stegasoo removed. User 'stegasoo' was not removed."
|
||||||
|
echo "To remove: userdel stegasoo"
|
||||||
|
}
|
||||||
22
aur/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test build the AUR package locally
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "=== Cleaning previous builds ==="
|
||||||
|
rm -rf stegasoo-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== Generating .SRCINFO ==="
|
||||||
|
makepkg --printsrcinfo > .SRCINFO
|
||||||
|
|
||||||
|
echo "=== Building package ==="
|
||||||
|
makepkg -sf
|
||||||
|
|
||||||
|
echo "=== Package built ==="
|
||||||
|
ls -la *.pkg.tar.zst
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "To install: sudo pacman -U stegasoo-git-*.pkg.tar.zst"
|
||||||
|
echo "To test: makepkg -si"
|
||||||
BIN
data/WebUI.webp
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 16 KiB |
BIN
data/WebUI_Recover.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 16 KiB |
BIN
data/WebUI_Tools.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
@@ -33,7 +33,8 @@ RUN pip install --no-cache-dir \
|
|||||||
argon2-cffi>=23.0.0 \
|
argon2-cffi>=23.0.0 \
|
||||||
pillow>=10.0.0 \
|
pillow>=10.0.0 \
|
||||||
cryptography>=41.0.0 \
|
cryptography>=41.0.0 \
|
||||||
reedsolo>=1.7.0
|
reedsolo>=1.7.0 \
|
||||||
|
zstandard>=0.22.0
|
||||||
|
|
||||||
# Install web/api framework packages (also stable)
|
# Install web/api framework packages (also stable)
|
||||||
RUN pip install --no-cache-dir \
|
RUN pip install --no-cache-dir \
|
||||||
@@ -48,9 +49,9 @@ RUN pip install --no-cache-dir \
|
|||||||
lz4>=4.0.0
|
lz4>=4.0.0
|
||||||
|
|
||||||
# Verify key packages work
|
# Verify key packages work
|
||||||
RUN python -c "import jpegio; import scipy; import numpy; print('jpegio + scipy + numpy OK')"
|
RUN python -c "import jpegio; import scipy; import numpy; import zstandard; print('jpegio + scipy + numpy + zstd OK')"
|
||||||
|
|
||||||
# Label for tracking
|
# Label for tracking
|
||||||
LABEL org.opencontainers.image.title="Stegasoo Base"
|
LABEL org.opencontainers.image.title="Stegasoo Base"
|
||||||
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
|
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
|
||||||
LABEL org.opencontainers.image.version="4.0.0"
|
LABEL org.opencontainers.image.version="4.2.1"
|
||||||
|
|||||||
224
docs/CLAUDE_WORKTREES.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# Using Claude Code with Git Worktrees — A Stegasoo Guide
|
||||||
|
|
||||||
|
## What is a worktree?
|
||||||
|
|
||||||
|
A git worktree is a second (or third, or fourth...) copy of your repo that shares the same `.git` history but lives in its own folder with its own branch. Think of it like opening the same project in a parallel universe — you can hack on a feature in one worktree while keeping `main` pristine in another.
|
||||||
|
|
||||||
|
Claude Code has built-in worktree support, so you don't need to memorize any git commands.
|
||||||
|
|
||||||
|
## Why bother?
|
||||||
|
|
||||||
|
- **Safety net**: Your `main` branch stays untouched. If Claude's changes go sideways, just delete the worktree — zero damage.
|
||||||
|
- **Easy A/B comparison**: Keep the original code open in one editor tab, Claude's changes in another.
|
||||||
|
- **Parallel work**: You can keep working in `main` while Claude tinkers in a worktree.
|
||||||
|
- **Clean PRs**: The worktree branch becomes your PR branch with no stray changes mixed in.
|
||||||
|
|
||||||
|
## The 30-second version
|
||||||
|
|
||||||
|
1. Ask Claude to work in a worktree
|
||||||
|
2. Claude creates an isolated copy and works there
|
||||||
|
3. When done, you either merge or throw it away
|
||||||
|
|
||||||
|
That's it. Everything below is details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to start a worktree session
|
||||||
|
|
||||||
|
### Option A: Ask Claude directly
|
||||||
|
|
||||||
|
Just tell Claude you want to work in a worktree:
|
||||||
|
|
||||||
|
```
|
||||||
|
> Let's work in a worktree for this
|
||||||
|
> Start a worktree called "dct-refactor"
|
||||||
|
> Can you make these changes in an isolated worktree?
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude will use `EnterWorktree` behind the scenes and switch into it automatically.
|
||||||
|
|
||||||
|
### Option B: Use the slash command
|
||||||
|
|
||||||
|
```
|
||||||
|
> /worktree
|
||||||
|
```
|
||||||
|
|
||||||
|
This drops you into a fresh worktree immediately.
|
||||||
|
|
||||||
|
### Option C: Tell Claude to launch an agent in a worktree
|
||||||
|
|
||||||
|
If you want Claude to do something in the background without touching your working directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
> Run the tests in a worktree so we don't mess up my local state
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude can spin up a sub-agent with `isolation: "worktree"` — it gets its own copy and reports back.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where do worktrees live?
|
||||||
|
|
||||||
|
Claude puts them in:
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude/worktrees/<name>/
|
||||||
|
```
|
||||||
|
|
||||||
|
This directory is inside your repo but ignored by git, so it won't pollute your commits.
|
||||||
|
|
||||||
|
## What happens inside a worktree?
|
||||||
|
|
||||||
|
The worktree is a full checkout of your repo on a new branch. Claude's working directory switches to it, so all file reads, edits, and commands happen there — not in your main checkout.
|
||||||
|
|
||||||
|
**Important for Stegasoo**: The first thing you (or Claude) should do in a fresh worktree is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
This points your editable install at the worktree's source code instead of your main checkout. Without this, `pytest` will test the wrong copy of the code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real-world examples
|
||||||
|
|
||||||
|
### Example 1: Feature work
|
||||||
|
|
||||||
|
```
|
||||||
|
You: I want to add lz4 as a default compression option. Let's use a worktree.
|
||||||
|
Claude: *creates worktree, switches to it*
|
||||||
|
Claude: *installs dev deps, makes changes, runs tests*
|
||||||
|
Claude: All tests pass. Ready to merge or open a PR.
|
||||||
|
You: Looks good, make a PR.
|
||||||
|
Claude: *pushes branch, creates PR*
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Risky refactor
|
||||||
|
|
||||||
|
```
|
||||||
|
You: Refactor the crypto module to split KDF logic into its own file.
|
||||||
|
Do it in a worktree so I can review before touching main.
|
||||||
|
Claude: *creates worktree "refactor/split-kdf"*
|
||||||
|
Claude: *does the refactor, runs tests*
|
||||||
|
You: Hmm, I don't love the approach. Throw it away.
|
||||||
|
Claude: *removes worktree — main is untouched*
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Investigate a bug without side effects
|
||||||
|
|
||||||
|
```
|
||||||
|
You: Something's wrong with DCT encoding on large images.
|
||||||
|
Can you investigate in a worktree? I've got uncommitted work here.
|
||||||
|
Claude: *creates worktree, adds debug logging, runs tests*
|
||||||
|
Claude: Found it — the block size calculation overflows at >16MP.
|
||||||
|
Here's the fix. Want me to apply it to main?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to use a worktree vs. just editing in place
|
||||||
|
|
||||||
|
| Situation | Worktree? | Why |
|
||||||
|
|-----------|-----------|-----|
|
||||||
|
| Quick one-file fix | No | Overkill — just edit directly |
|
||||||
|
| Multi-file refactor | Yes | Easy to discard if it goes wrong |
|
||||||
|
| Touching security-critical code (`crypto.py`, `steganography.py`, etc.) | Yes | Extra safety for sensitive changes |
|
||||||
|
| Experimental / "let's try this" work | Yes | Zero-cost throwaway |
|
||||||
|
| You have uncommitted changes you don't want to stash | Yes | Worktree won't touch your working tree |
|
||||||
|
| Adding a single test | No | Low risk, just do it |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cleaning up
|
||||||
|
|
||||||
|
### If you merged or created a PR
|
||||||
|
|
||||||
|
The worktree served its purpose. Clean up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git worktree remove .claude/worktrees/<name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or ask Claude:
|
||||||
|
|
||||||
|
```
|
||||||
|
> Clean up the worktree
|
||||||
|
```
|
||||||
|
|
||||||
|
### If you want to throw everything away
|
||||||
|
|
||||||
|
Same command — removing the worktree deletes the directory and its branch reference. Your `main` branch is completely unaffected.
|
||||||
|
|
||||||
|
### If Claude's session ends
|
||||||
|
|
||||||
|
When a Claude Code session ends while in a worktree, you'll be prompted to keep or remove it. If you keep it, you can resume later:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd .claude/worktrees/<name>
|
||||||
|
# pick up where you left off
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch naming in worktrees
|
||||||
|
|
||||||
|
Follow the same conventions as the rest of the project:
|
||||||
|
|
||||||
|
| Type | Branch name | Example |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| Feature | `feature/description` | `feature/batch-progress-bars` |
|
||||||
|
| Bug fix | `fix/description` | `fix/dct-overflow-large-images` |
|
||||||
|
| Docs | `docs/description` | `docs/api-examples` |
|
||||||
|
| Refactor | `refactor/description` | `refactor/split-crypto-module` |
|
||||||
|
|
||||||
|
When Claude creates a worktree automatically, it generates a random branch name. You can rename it before pushing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git branch -m <old-name> feature/my-better-name
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "I ran pytest but it's testing the old code"
|
||||||
|
|
||||||
|
You forgot to install in the worktree:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### "I can't find my worktree"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git worktree list
|
||||||
|
```
|
||||||
|
|
||||||
|
This shows all worktrees and their paths.
|
||||||
|
|
||||||
|
### "I accidentally deleted the worktree folder without removing it from git"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git worktree prune
|
||||||
|
```
|
||||||
|
|
||||||
|
This cleans up stale worktree references.
|
||||||
|
|
||||||
|
### "I want to switch back to my main checkout"
|
||||||
|
|
||||||
|
If you're in a Claude Code session that entered a worktree, the session stays in the worktree until it ends. Start a new session to go back to your main checkout, or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/stegasoo
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
1. Say "use a worktree" when asking Claude to make changes
|
||||||
|
2. Claude works in an isolated copy — your `main` is safe
|
||||||
|
3. Merge the good stuff, trash the bad stuff
|
||||||
|
4. Never think about it again until next time
|
||||||
@@ -126,7 +126,7 @@ Quick reference for all Jinja2 templates in `frontends/web/templates/`.
|
|||||||
- `use_pin` - checkbox
|
- `use_pin` - checkbox
|
||||||
- `pin_length` - PIN digits (6-9)
|
- `pin_length` - PIN digits (6-9)
|
||||||
- `use_rsa` - checkbox
|
- `use_rsa` - checkbox
|
||||||
- `rsa_bits` - key size (2048/3072/4096)
|
- `rsa_bits` - key size (2048/3072)
|
||||||
|
|
||||||
**Output panels:**
|
**Output panels:**
|
||||||
- Passphrase display
|
- Passphrase display
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.\" Stegasoo man page
|
.\" Stegasoo man page
|
||||||
.\" Generate with: groff -man -Tascii stegasoo.1
|
.\" Generate with: groff -man -Tascii stegasoo.1
|
||||||
.TH STEGASOO 1 "January 2026" "Stegasoo 4.1.7" "User Commands"
|
.TH STEGASOO 1 "February 2026" "Stegasoo 4.3.0" "User Commands"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
stegasoo \- steganography with hybrid authentication
|
stegasoo \- steganography with hybrid authentication
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -12,9 +12,10 @@ stegasoo \- steganography with hybrid authentication
|
|||||||
[\fIargs\fR]
|
[\fIargs\fR]
|
||||||
.SH DESCRIPTION
|
.SH DESCRIPTION
|
||||||
.B stegasoo
|
.B stegasoo
|
||||||
hides messages and files in images using PIN + passphrase security.
|
hides messages and files in images and audio using PIN + passphrase security.
|
||||||
It uses LSB (Least Significant Bit) steganography with optional DCT
|
It uses LSB (Least Significant Bit) steganography with optional DCT
|
||||||
(Discrete Cosine Transform) encoding for JPEG resilience.
|
(Discrete Cosine Transform) encoding for JPEG resilience, and supports
|
||||||
|
audio steganography with LSB and Spread Spectrum modes.
|
||||||
.PP
|
.PP
|
||||||
Messages are encrypted using a hybrid authentication scheme that combines
|
Messages are encrypted using a hybrid authentication scheme that combines
|
||||||
a reference photo (shared secret), passphrase, and PIN code.
|
a reference photo (shared secret), passphrase, and PIN code.
|
||||||
@@ -221,6 +222,83 @@ Reset admin password using recovery key.
|
|||||||
.PP
|
.PP
|
||||||
Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR.
|
Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR.
|
||||||
.RE
|
.RE
|
||||||
|
.SS audio\-encode
|
||||||
|
Encode a message or file into an audio file.
|
||||||
|
.PP
|
||||||
|
.B stegasoo audio\-encode
|
||||||
|
.I audio
|
||||||
|
.B \-r
|
||||||
|
.I reference
|
||||||
|
[\fB\-m\fR \fImessage\fR | \fB\-f\fR \fIfile\fR]
|
||||||
|
[\fIoptions\fR]
|
||||||
|
.TP
|
||||||
|
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||||
|
Reference photo (shared secret). Required.
|
||||||
|
.TP
|
||||||
|
.BR \-m ", " \-\-message " " \fITEXT\fR
|
||||||
|
Message to encode.
|
||||||
|
.TP
|
||||||
|
.BR \-f ", " \-\-file " " \fIPATH\fR
|
||||||
|
File to embed instead of message.
|
||||||
|
.TP
|
||||||
|
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||||
|
Output audio path.
|
||||||
|
.TP
|
||||||
|
.B \-\-passphrase " " \fITEXT\fR
|
||||||
|
Passphrase (recommend 4+ words). Prompts if not provided.
|
||||||
|
.TP
|
||||||
|
.B \-\-pin " " \fITEXT\fR
|
||||||
|
PIN code. Prompts if not provided.
|
||||||
|
.TP
|
||||||
|
.B \-\-mode " " [\fIlsb\fR|\fIspread\fR]
|
||||||
|
Embedding mode: lsb (default) or spread (spread spectrum).
|
||||||
|
.PP
|
||||||
|
.B Examples:
|
||||||
|
.nf
|
||||||
|
stegasoo audio-encode song.wav -r ref.jpg -m "Secret" --passphrase --pin
|
||||||
|
stegasoo audio-encode podcast.mp3 -r ref.jpg -f doc.pdf --mode spread
|
||||||
|
.fi
|
||||||
|
.SS audio\-decode
|
||||||
|
Decode a message or file from a stego audio file.
|
||||||
|
.PP
|
||||||
|
.B stegasoo audio\-decode
|
||||||
|
.I audio
|
||||||
|
.B \-r
|
||||||
|
.I reference
|
||||||
|
[\fIoptions\fR]
|
||||||
|
.TP
|
||||||
|
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||||
|
Reference photo (shared secret). Required.
|
||||||
|
.TP
|
||||||
|
.B \-\-passphrase " " \fITEXT\fR
|
||||||
|
Passphrase. Prompts if not provided.
|
||||||
|
.TP
|
||||||
|
.B \-\-pin " " \fITEXT\fR
|
||||||
|
PIN code. Prompts if not provided.
|
||||||
|
.TP
|
||||||
|
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||||
|
Output path for file payloads.
|
||||||
|
.PP
|
||||||
|
.B Examples:
|
||||||
|
.nf
|
||||||
|
stegasoo audio-decode stego.wav -r ref.jpg --passphrase --pin
|
||||||
|
stegasoo audio-decode stego.wav -r ref.jpg -o ./extracted/
|
||||||
|
.fi
|
||||||
|
.SS audio\-info
|
||||||
|
Display audio file information and steganographic capacity.
|
||||||
|
.PP
|
||||||
|
.B stegasoo audio\-info
|
||||||
|
.I audio
|
||||||
|
[\fB\-\-json\fR]
|
||||||
|
.PP
|
||||||
|
Shows format, sample rate, channels, bit depth, duration, and embedding
|
||||||
|
capacity for both LSB and Spread Spectrum modes.
|
||||||
|
.PP
|
||||||
|
.B Examples:
|
||||||
|
.nf
|
||||||
|
stegasoo audio-info song.wav
|
||||||
|
stegasoo audio-info podcast.mp3 --json
|
||||||
|
.fi
|
||||||
.SS tools
|
.SS tools
|
||||||
Image security tools.
|
Image security tools.
|
||||||
.PP
|
.PP
|
||||||
|
|||||||
0
frontends/__init__.py
Normal file
0
frontends/api/__init__.py
Normal file
257
frontends/api/auth.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"""
|
||||||
|
API Key Authentication for Stegasoo REST API.
|
||||||
|
|
||||||
|
Provides simple API key authentication with hashed key storage.
|
||||||
|
Keys can be stored in user config (~/.stegasoo/) or project config (./config/).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from .auth import require_api_key, get_api_key_status
|
||||||
|
|
||||||
|
@app.get("/protected")
|
||||||
|
async def protected_endpoint(api_key: str = Depends(require_api_key)):
|
||||||
|
return {"status": "authenticated"}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Security
|
||||||
|
from fastapi.security import APIKeyHeader
|
||||||
|
|
||||||
|
# API key header name
|
||||||
|
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||||
|
|
||||||
|
# Config locations
|
||||||
|
USER_CONFIG_DIR = Path.home() / ".stegasoo"
|
||||||
|
PROJECT_CONFIG_DIR = Path("./config")
|
||||||
|
|
||||||
|
# Key file name
|
||||||
|
API_KEYS_FILE = "api_keys.json"
|
||||||
|
|
||||||
|
# Environment variable for API key (alternative to file)
|
||||||
|
API_KEY_ENV_VAR = "STEGASOO_API_KEY"
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_key(key: str) -> str:
|
||||||
|
"""Hash an API key for storage."""
|
||||||
|
return hashlib.sha256(key.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_keys_file(location: str = "user") -> Path:
|
||||||
|
"""Get path to API keys file."""
|
||||||
|
if location == "project":
|
||||||
|
return PROJECT_CONFIG_DIR / API_KEYS_FILE
|
||||||
|
return USER_CONFIG_DIR / API_KEYS_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def _load_keys(location: str = "user") -> dict:
|
||||||
|
"""Load API keys from config file."""
|
||||||
|
keys_file = _get_keys_file(location)
|
||||||
|
if keys_file.exists():
|
||||||
|
try:
|
||||||
|
with open(keys_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {"keys": [], "enabled": True}
|
||||||
|
return {"keys": [], "enabled": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_keys(data: dict, location: str = "user") -> None:
|
||||||
|
"""Save API keys to config file."""
|
||||||
|
keys_file = _get_keys_file(location)
|
||||||
|
keys_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(keys_file, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
# Secure permissions (owner read/write only)
|
||||||
|
os.chmod(keys_file, 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_api_key() -> str:
|
||||||
|
"""Generate a new API key."""
|
||||||
|
# Format: stegasoo_XXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
|
# 32 bytes = 256 bits of entropy
|
||||||
|
random_part = secrets.token_hex(16)
|
||||||
|
return f"stegasoo_{random_part[:4]}_{random_part[4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def add_api_key(name: str, location: str = "user") -> str:
|
||||||
|
"""
|
||||||
|
Generate and store a new API key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Descriptive name for the key (e.g., "laptop", "automation")
|
||||||
|
location: "user" or "project"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The generated API key (only shown once!)
|
||||||
|
"""
|
||||||
|
key = generate_api_key()
|
||||||
|
key_hash = _hash_key(key)
|
||||||
|
|
||||||
|
data = _load_keys(location)
|
||||||
|
|
||||||
|
# Check for duplicate name
|
||||||
|
for existing in data["keys"]:
|
||||||
|
if existing["name"] == name:
|
||||||
|
raise ValueError(f"Key with name '{name}' already exists")
|
||||||
|
|
||||||
|
data["keys"].append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"hash": key_hash,
|
||||||
|
"created": __import__("datetime").datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_save_keys(data, location)
|
||||||
|
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def remove_api_key(name: str, location: str = "user") -> bool:
|
||||||
|
"""
|
||||||
|
Remove an API key by name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if key was found and removed, False otherwise
|
||||||
|
"""
|
||||||
|
data = _load_keys(location)
|
||||||
|
original_count = len(data["keys"])
|
||||||
|
|
||||||
|
data["keys"] = [k for k in data["keys"] if k["name"] != name]
|
||||||
|
|
||||||
|
if len(data["keys"]) < original_count:
|
||||||
|
_save_keys(data, location)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def list_api_keys(location: str = "user") -> list[dict]:
|
||||||
|
"""
|
||||||
|
List all API keys (names and creation dates, not actual keys).
|
||||||
|
"""
|
||||||
|
data = _load_keys(location)
|
||||||
|
return [{"name": k["name"], "created": k.get("created", "unknown")} for k in data["keys"]]
|
||||||
|
|
||||||
|
|
||||||
|
def set_auth_enabled(enabled: bool, location: str = "user") -> None:
|
||||||
|
"""Enable or disable API key authentication."""
|
||||||
|
data = _load_keys(location)
|
||||||
|
data["enabled"] = enabled
|
||||||
|
_save_keys(data, location)
|
||||||
|
|
||||||
|
|
||||||
|
def is_auth_enabled() -> bool:
|
||||||
|
"""Check if API key authentication is enabled."""
|
||||||
|
# Check project config first, then user config
|
||||||
|
for location in ["project", "user"]:
|
||||||
|
data = _load_keys(location)
|
||||||
|
if "enabled" in data:
|
||||||
|
return data["enabled"]
|
||||||
|
|
||||||
|
# Default: enabled if any keys exist
|
||||||
|
return bool(get_all_key_hashes())
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_key_hashes() -> set[str]:
|
||||||
|
"""Get all valid API key hashes from all sources."""
|
||||||
|
hashes = set()
|
||||||
|
|
||||||
|
# Check environment variable first
|
||||||
|
env_key = os.environ.get(API_KEY_ENV_VAR)
|
||||||
|
if env_key:
|
||||||
|
hashes.add(_hash_key(env_key))
|
||||||
|
|
||||||
|
# Check project and user configs
|
||||||
|
for location in ["project", "user"]:
|
||||||
|
data = _load_keys(location)
|
||||||
|
for key_entry in data.get("keys", []):
|
||||||
|
if "hash" in key_entry:
|
||||||
|
hashes.add(key_entry["hash"])
|
||||||
|
|
||||||
|
return hashes
|
||||||
|
|
||||||
|
|
||||||
|
def validate_api_key(key: str) -> bool:
|
||||||
|
"""Validate an API key against stored hashes."""
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
key_hash = _hash_key(key)
|
||||||
|
valid_hashes = get_all_key_hashes()
|
||||||
|
|
||||||
|
return key_hash in valid_hashes
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_key_status() -> dict:
|
||||||
|
"""Get current API key authentication status."""
|
||||||
|
user_keys = list_api_keys("user")
|
||||||
|
project_keys = list_api_keys("project")
|
||||||
|
env_configured = bool(os.environ.get(API_KEY_ENV_VAR))
|
||||||
|
|
||||||
|
total_keys = len(user_keys) + len(project_keys) + (1 if env_configured else 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": is_auth_enabled(),
|
||||||
|
"total_keys": total_keys,
|
||||||
|
"user_keys": len(user_keys),
|
||||||
|
"project_keys": len(project_keys),
|
||||||
|
"env_configured": env_configured,
|
||||||
|
"keys": {
|
||||||
|
"user": user_keys,
|
||||||
|
"project": project_keys,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# FastAPI dependency for API key authentication
|
||||||
|
async def require_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str:
|
||||||
|
"""
|
||||||
|
FastAPI dependency that requires a valid API key.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@app.get("/protected")
|
||||||
|
async def endpoint(key: str = Depends(require_api_key)):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
# Check if auth is enabled
|
||||||
|
if not is_auth_enabled():
|
||||||
|
return "auth_disabled"
|
||||||
|
|
||||||
|
# No keys configured = auth disabled
|
||||||
|
if not get_all_key_hashes():
|
||||||
|
return "no_keys_configured"
|
||||||
|
|
||||||
|
# Validate the provided key
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="API key required. Provide X-API-Key header.",
|
||||||
|
headers={"WWW-Authenticate": "ApiKey"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not validate_api_key(api_key):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Invalid API key.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
|
async def optional_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str | None:
|
||||||
|
"""
|
||||||
|
FastAPI dependency that optionally validates API key.
|
||||||
|
|
||||||
|
Returns the key if valid, None if not provided or invalid.
|
||||||
|
Doesn't raise exceptions - useful for endpoints that work
|
||||||
|
with or without auth.
|
||||||
|
"""
|
||||||
|
if api_key and validate_api_key(api_key):
|
||||||
|
return api_key
|
||||||
|
return None
|
||||||
0
frontends/cli/__init__.py
Normal file
@@ -120,6 +120,7 @@ try:
|
|||||||
from stegasoo.qr_utils import ( # noqa: F401
|
from stegasoo.qr_utils import ( # noqa: F401
|
||||||
can_fit_in_qr,
|
can_fit_in_qr,
|
||||||
extract_key_from_qr_file,
|
extract_key_from_qr_file,
|
||||||
|
generate_qr_ascii,
|
||||||
generate_qr_code,
|
generate_qr_code,
|
||||||
has_qr_read,
|
has_qr_read,
|
||||||
has_qr_write,
|
has_qr_write,
|
||||||
@@ -136,6 +137,9 @@ except ImportError:
|
|||||||
def has_qr_write() -> bool:
|
def has_qr_write() -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def generate_qr_ascii(*args, **kwargs):
|
||||||
|
raise RuntimeError("QR code generation not available")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CLI SETUP
|
# CLI SETUP
|
||||||
@@ -236,7 +240,7 @@ def format_channel_status_line(quiet: bool = False) -> str | None:
|
|||||||
help=f"PIN length (6-9, default: {DEFAULT_PIN_LENGTH})",
|
help=f"PIN length (6-9, default: {DEFAULT_PIN_LENGTH})",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--rsa-bits", type=click.Choice(["2048", "3072", "4096"]), default="2048", help="RSA key size"
|
"--rsa-bits", type=click.Choice(["2048", "3072"]), default="2048", help="RSA key size"
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--words",
|
"--words",
|
||||||
@@ -247,7 +251,13 @@ def format_channel_status_line(quiet: bool = False) -> str | None:
|
|||||||
@click.option("--output", "-o", type=click.Path(), help="Save RSA key to file (requires password)")
|
@click.option("--output", "-o", type=click.Path(), help="Save RSA key to file (requires password)")
|
||||||
@click.option("--password", "-p", help="Password for RSA key file")
|
@click.option("--password", "-p", help="Password for RSA key file")
|
||||||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||||||
def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
@click.option(
|
||||||
|
"--qr",
|
||||||
|
type=click.Path(),
|
||||||
|
help="Save RSA key QR code to file (png/jpg, uses zstd compression)",
|
||||||
|
)
|
||||||
|
@click.option("--qr-ascii", is_flag=True, help="Print RSA key as ASCII QR code to terminal")
|
||||||
|
def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json, qr, qr_ascii):
|
||||||
"""
|
"""
|
||||||
Generate credentials for encoding/decoding.
|
Generate credentials for encoding/decoding.
|
||||||
|
|
||||||
@@ -261,13 +271,18 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
|||||||
Examples:
|
Examples:
|
||||||
stegasoo generate
|
stegasoo generate
|
||||||
stegasoo generate --words 5
|
stegasoo generate --words 5
|
||||||
stegasoo generate --rsa --rsa-bits 4096
|
stegasoo generate --rsa --rsa-bits 3072
|
||||||
stegasoo generate --rsa -o mykey.pem -p "secretpassword"
|
stegasoo generate --rsa -o mykey.pem -p "secretpassword"
|
||||||
|
stegasoo generate --rsa --qr key.png
|
||||||
|
stegasoo generate --rsa --qr-ascii
|
||||||
stegasoo generate --no-pin --rsa
|
stegasoo generate --no-pin --rsa
|
||||||
"""
|
"""
|
||||||
if not pin and not rsa:
|
if not pin and not rsa:
|
||||||
raise click.UsageError("Must enable at least one of --pin or --rsa")
|
raise click.UsageError("Must enable at least one of --pin or --rsa")
|
||||||
|
|
||||||
|
if (qr or qr_ascii) and not rsa:
|
||||||
|
raise click.UsageError("QR output requires --rsa to generate an RSA key")
|
||||||
|
|
||||||
if output and not password:
|
if output and not password:
|
||||||
raise click.UsageError("--password is required when saving RSA key to file")
|
raise click.UsageError("--password is required when saving RSA key to file")
|
||||||
|
|
||||||
@@ -334,6 +349,33 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
|||||||
click.echo(creds.rsa_key_pem)
|
click.echo(creds.rsa_key_pem)
|
||||||
click.echo()
|
click.echo()
|
||||||
|
|
||||||
|
# QR code output (v4.2.0)
|
||||||
|
if qr:
|
||||||
|
if not HAS_QR:
|
||||||
|
click.secho(" ⚠️ QR code library not available", fg="yellow")
|
||||||
|
else:
|
||||||
|
# Determine format from extension
|
||||||
|
qr_path = Path(qr)
|
||||||
|
ext = qr_path.suffix.lower()
|
||||||
|
fmt = "jpeg" if ext in (".jpg", ".jpeg") else "png"
|
||||||
|
|
||||||
|
qr_bytes = generate_qr_code(creds.rsa_key_pem, compress=True, output_format=fmt)
|
||||||
|
qr_path.write_bytes(qr_bytes)
|
||||||
|
click.secho("─── RSA KEY QR CODE ───", fg="green")
|
||||||
|
click.secho(f" Saved to: {qr}", fg="bright_white")
|
||||||
|
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
if qr_ascii:
|
||||||
|
if not HAS_QR:
|
||||||
|
click.secho(" ⚠️ QR code library not available", fg="yellow")
|
||||||
|
else:
|
||||||
|
click.secho("─── RSA KEY QR CODE (ASCII) ───", fg="green")
|
||||||
|
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
|
||||||
|
click.echo()
|
||||||
|
ascii_qr = generate_qr_ascii(creds.rsa_key_pem, compress=True, invert=True)
|
||||||
|
click.echo(ascii_qr)
|
||||||
|
|
||||||
click.secho("─── SECURITY ───", fg="green")
|
click.secho("─── SECURITY ───", fg="green")
|
||||||
click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)")
|
click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)")
|
||||||
if creds.pin:
|
if creds.pin:
|
||||||
|
|||||||
0
frontends/web/__init__.py
Normal file
@@ -77,14 +77,10 @@ def init_db():
|
|||||||
db = get_db()
|
db = get_db()
|
||||||
|
|
||||||
# Check if we need to migrate from old single-user schema
|
# Check if we need to migrate from old single-user schema
|
||||||
cursor = db.execute(
|
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'")
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'"
|
|
||||||
)
|
|
||||||
has_old_table = cursor.fetchone() is not None
|
has_old_table = cursor.fetchone() is not None
|
||||||
|
|
||||||
cursor = db.execute(
|
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
|
||||||
)
|
|
||||||
has_new_table = cursor.fetchone() is not None
|
has_new_table = cursor.fetchone() is not None
|
||||||
|
|
||||||
if has_old_table and not has_new_table:
|
if has_old_table and not has_new_table:
|
||||||
@@ -189,9 +185,7 @@ def _ensure_channel_keys_table(db: sqlite3.Connection):
|
|||||||
|
|
||||||
def _ensure_app_settings_table(db: sqlite3.Connection):
|
def _ensure_app_settings_table(db: sqlite3.Connection):
|
||||||
"""Ensure app_settings table exists (v4.1.0 migration)."""
|
"""Ensure app_settings table exists (v4.1.0 migration)."""
|
||||||
cursor = db.execute(
|
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'")
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
|
|
||||||
)
|
|
||||||
if cursor.fetchone() is None:
|
if cursor.fetchone() is None:
|
||||||
db.executescript("""
|
db.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS app_settings (
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
@@ -212,9 +206,7 @@ def _ensure_app_settings_table(db: sqlite3.Connection):
|
|||||||
def get_app_setting(key: str) -> str | None:
|
def get_app_setting(key: str) -> str | None:
|
||||||
"""Get an app-level setting value."""
|
"""Get an app-level setting value."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
row = db.execute(
|
row = db.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone()
|
||||||
"SELECT value FROM app_settings WHERE key = ?", (key,)
|
|
||||||
).fetchone()
|
|
||||||
return row["value"] if row else None
|
return row["value"] if row else None
|
||||||
|
|
||||||
|
|
||||||
@@ -384,12 +376,10 @@ def get_user_by_username(username: str) -> User | None:
|
|||||||
def get_all_users() -> list[User]:
|
def get_all_users() -> list[User]:
|
||||||
"""Get all users, admins first, then by creation date."""
|
"""Get all users, admins first, then by creation date."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
rows = db.execute(
|
rows = db.execute("""
|
||||||
"""
|
|
||||||
SELECT id, username, role, created_at FROM users
|
SELECT id, username, role, created_at FROM users
|
||||||
ORDER BY role = 'admin' DESC, created_at ASC
|
ORDER BY role = 'admin' DESC, created_at ASC
|
||||||
"""
|
""").fetchall()
|
||||||
).fetchall()
|
|
||||||
return [
|
return [
|
||||||
User(
|
User(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
@@ -596,9 +586,7 @@ def create_admin_user(username: str, password: str) -> tuple[bool, str]:
|
|||||||
return success, msg
|
return success, msg
|
||||||
|
|
||||||
|
|
||||||
def change_password(
|
def change_password(user_id: int, current_password: str, new_password: str) -> tuple[bool, str]:
|
||||||
user_id: int, current_password: str, new_password: str
|
|
||||||
) -> tuple[bool, str]:
|
|
||||||
"""Change a user's password (requires current password)."""
|
"""Change a user's password (requires current password)."""
|
||||||
user = get_user_by_id(user_id)
|
user = get_user_by_id(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -667,9 +655,7 @@ def delete_user(user_id: int, current_user_id: int) -> tuple[bool, str]:
|
|||||||
# Check if this is the last admin
|
# Check if this is the last admin
|
||||||
if user.role == ROLE_ADMIN:
|
if user.role == ROLE_ADMIN:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
admin_count = db.execute(
|
admin_count = db.execute("SELECT COUNT(*) FROM users WHERE role = 'admin'").fetchone()[0]
|
||||||
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
|
|
||||||
).fetchone()[0]
|
|
||||||
if admin_count <= 1:
|
if admin_count <= 1:
|
||||||
return False, "Cannot delete the last admin"
|
return False, "Cannot delete the last admin"
|
||||||
|
|
||||||
@@ -848,9 +834,7 @@ def save_channel_key(
|
|||||||
return False, "This channel key is already saved", None
|
return False, "This channel key is already saved", None
|
||||||
|
|
||||||
|
|
||||||
def update_channel_key_name(
|
def update_channel_key_name(key_id: int, user_id: int, new_name: str) -> tuple[bool, str]:
|
||||||
key_id: int, user_id: int, new_name: str
|
|
||||||
) -> tuple[bool, str]:
|
|
||||||
"""Update the name of a saved channel key."""
|
"""Update the name of a saved channel key."""
|
||||||
new_name = new_name.strip()
|
new_name = new_name.strip()
|
||||||
if not new_name:
|
if not new_name:
|
||||||
|
|||||||
@@ -81,10 +81,12 @@ def generate_self_signed_cert(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create certificate
|
# Create certificate
|
||||||
subject = issuer = x509.Name([
|
subject = issuer = x509.Name(
|
||||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
|
[
|
||||||
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
|
||||||
])
|
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# Subject Alternative Names
|
# Subject Alternative Names
|
||||||
san_list = [
|
san_list = [
|
||||||
@@ -112,7 +114,7 @@ def generate_self_signed_cert(
|
|||||||
except (ipaddress.AddressValueError, ValueError):
|
except (ipaddress.AddressValueError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
now = datetime.datetime.now(datetime.timezone.utc)
|
now = datetime.datetime.now(datetime.UTC)
|
||||||
cert = (
|
cert = (
|
||||||
x509.CertificateBuilder()
|
x509.CertificateBuilder()
|
||||||
.subject_name(subject)
|
.subject_name(subject)
|
||||||
|
|||||||
@@ -95,7 +95,16 @@ const Stegasoo = {
|
|||||||
if (!isPayloadZone && !isQrZone) {
|
if (!isPayloadZone && !isQrZone) {
|
||||||
input.addEventListener('change', function() {
|
input.addEventListener('change', function() {
|
||||||
if (this.files && this.files[0]) {
|
if (this.files && this.files[0]) {
|
||||||
Stegasoo.showImagePreview(this.files[0], preview, label, zone);
|
const file = this.files[0];
|
||||||
|
if (file.type.startsWith('image/') && preview) {
|
||||||
|
Stegasoo.showImagePreview(file, preview, label, zone);
|
||||||
|
} else if (file.type.startsWith('audio/') || !file.type.startsWith('image/')) {
|
||||||
|
// Audio or non-image files: show file info instead of image preview
|
||||||
|
Stegasoo.showAudioFileInfo(file, zone);
|
||||||
|
if (label) {
|
||||||
|
label.classList.add('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -153,7 +162,21 @@ const Stegasoo = {
|
|||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format audio file info for display in drop zones (v4.3.0)
|
||||||
|
*/
|
||||||
|
showAudioFileInfo(file, zone) {
|
||||||
|
const filenameEl = zone.querySelector('.pixel-data-filename span, .scan-data-filename span');
|
||||||
|
const sizeEl = zone.querySelector('.pixel-data-value, .scan-data-value');
|
||||||
|
if (filenameEl) filenameEl.textContent = file.name;
|
||||||
|
if (sizeEl) {
|
||||||
|
const kb = file.size / 1024;
|
||||||
|
sizeEl.textContent = kb >= 1024 ? (kb / 1024).toFixed(1) + ' MB' : kb.toFixed(1) + ' KB';
|
||||||
|
}
|
||||||
|
zone.classList.add('has-file');
|
||||||
|
},
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// REFERENCE PHOTO SCAN ANIMATION
|
// REFERENCE PHOTO SCAN ANIMATION
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -1009,7 +1032,9 @@ const Stegasoo = {
|
|||||||
const percent = progressData.percent || 0;
|
const percent = progressData.percent || 0;
|
||||||
const phase = progressData.phase || 'processing';
|
const phase = progressData.phase || 'processing';
|
||||||
|
|
||||||
this.updateProgress(percent, this.formatPhase(phase));
|
// Use indeterminate mode for initializing/starting phases
|
||||||
|
const isIndeterminate = (phase === 'initializing' || phase === 'starting');
|
||||||
|
this.updateProgress(percent, this.formatPhase(phase), isIndeterminate);
|
||||||
|
|
||||||
// Continue polling
|
// Continue polling
|
||||||
setTimeout(poll, 500);
|
setTimeout(poll, 500);
|
||||||
@@ -1029,11 +1054,15 @@ const Stegasoo = {
|
|||||||
formatPhase(phase) {
|
formatPhase(phase) {
|
||||||
const phases = {
|
const phases = {
|
||||||
'starting': 'Starting...',
|
'starting': 'Starting...',
|
||||||
'initializing': 'Initializing...',
|
'initializing': 'Deriving keys (may take a moment)...',
|
||||||
'embedding': 'Embedding data...',
|
'embedding': 'Embedding data...',
|
||||||
'saving': 'Saving image...',
|
'saving': 'Saving image...',
|
||||||
'finalizing': 'Finalizing...',
|
'finalizing': 'Finalizing...',
|
||||||
'complete': 'Complete!',
|
'complete': 'Complete!',
|
||||||
|
// Audio encode phases (v4.3.0)
|
||||||
|
'audio_transcoding': 'Transcoding audio...',
|
||||||
|
'audio_embedding': 'Embedding in audio...',
|
||||||
|
'spread_embedding': 'Spread spectrum embedding...',
|
||||||
};
|
};
|
||||||
return phases[phase] || phase;
|
return phases[phase] || phase;
|
||||||
},
|
},
|
||||||
@@ -1070,8 +1099,9 @@ const Stegasoo = {
|
|||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset progress
|
// Reset progress tracking and start with indeterminate state
|
||||||
this.updateProgress(0, 'Initializing...');
|
this.resetProgressTracking();
|
||||||
|
this.updateProgress(0, 'Initializing...', true);
|
||||||
|
|
||||||
// Show modal
|
// Show modal
|
||||||
const bsModal = new bootstrap.Modal(modal);
|
const bsModal = new bootstrap.Modal(modal);
|
||||||
@@ -1090,16 +1120,47 @@ const Stegasoo = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update progress bar and text
|
* Track max progress to prevent backwards jumps
|
||||||
*/
|
*/
|
||||||
updateProgress(percent, phase) {
|
_maxProgress: 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset progress tracking (call when starting new operation)
|
||||||
|
*/
|
||||||
|
resetProgressTracking() {
|
||||||
|
this._maxProgress = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update progress bar and text
|
||||||
|
* Supports indeterminate mode for initializing phase (barber pole at full width)
|
||||||
|
*/
|
||||||
|
updateProgress(percent, phase, indeterminate = false) {
|
||||||
const progressBar = document.getElementById('progressBar');
|
const progressBar = document.getElementById('progressBar');
|
||||||
const progressText = document.getElementById('progressText');
|
const progressText = document.getElementById('progressText');
|
||||||
const phaseText = document.getElementById('progressPhase');
|
const phaseText = document.getElementById('progressPhase');
|
||||||
|
|
||||||
if (progressBar) progressBar.style.width = percent + '%';
|
if (indeterminate) {
|
||||||
if (progressText) progressText.textContent = Math.round(percent) + '%';
|
// Barber pole animation at full width, no percentage
|
||||||
if (phaseText) phaseText.textContent = phase;
|
if (progressBar) {
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||||
|
}
|
||||||
|
if (progressText) progressText.textContent = '';
|
||||||
|
if (phaseText) phaseText.textContent = phase;
|
||||||
|
} else {
|
||||||
|
// Determinate progress - never go backwards
|
||||||
|
const safePercent = Math.max(percent, this._maxProgress);
|
||||||
|
this._maxProgress = safePercent;
|
||||||
|
|
||||||
|
if (progressBar) {
|
||||||
|
progressBar.style.width = safePercent + '%';
|
||||||
|
// Keep animation but show actual progress
|
||||||
|
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||||
|
}
|
||||||
|
if (progressText) progressText.textContent = Math.round(safePercent) + '%';
|
||||||
|
if (phaseText) phaseText.textContent = phase;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -1187,7 +1248,9 @@ const Stegasoo = {
|
|||||||
const percent = progressData.percent || 0;
|
const percent = progressData.percent || 0;
|
||||||
const phase = progressData.phase || 'processing';
|
const phase = progressData.phase || 'processing';
|
||||||
|
|
||||||
this.updateProgress(percent, this.formatDecodePhase(phase));
|
// Use indeterminate mode for initializing/starting/loading phases
|
||||||
|
const isIndeterminate = (phase === 'initializing' || phase === 'starting' || phase === 'loading');
|
||||||
|
this.updateProgress(percent, this.formatDecodePhase(phase), isIndeterminate);
|
||||||
|
|
||||||
// Continue polling
|
// Continue polling
|
||||||
setTimeout(poll, 500);
|
setTimeout(poll, 500);
|
||||||
@@ -1207,12 +1270,19 @@ const Stegasoo = {
|
|||||||
formatDecodePhase(phase) {
|
formatDecodePhase(phase) {
|
||||||
const phases = {
|
const phases = {
|
||||||
'starting': 'Starting...',
|
'starting': 'Starting...',
|
||||||
|
'initializing': 'Deriving keys (may take a moment)...',
|
||||||
|
'loading': 'Deriving keys (may take a moment)...',
|
||||||
'reading': 'Reading image...',
|
'reading': 'Reading image...',
|
||||||
'extracting': 'Extracting data...',
|
'extracting': 'Extracting data...',
|
||||||
|
'decoding': 'Decoding data...',
|
||||||
'decrypting': 'Decrypting...',
|
'decrypting': 'Decrypting...',
|
||||||
'verifying': 'Verifying...',
|
'verifying': 'Verifying...',
|
||||||
'finalizing': 'Finalizing...',
|
'finalizing': 'Finalizing...',
|
||||||
'complete': 'Complete!',
|
'complete': 'Complete!',
|
||||||
|
// Audio decode phases (v4.3.0)
|
||||||
|
'audio_transcoding': 'Transcoding audio...',
|
||||||
|
'audio_extracting': 'Extracting from audio...',
|
||||||
|
'spread_extracting': 'Spread spectrum extracting...',
|
||||||
};
|
};
|
||||||
return phases[phase] || phase;
|
return phases[phase] || phase;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Stegasoo Subprocess Worker (v4.0.0)
|
Stegasoo Subprocess Worker (v4.0.0)
|
||||||
|
|
||||||
This script runs in a subprocess and handles encode/decode operations.
|
This script runs in a subprocess and handles encode/decode operations.
|
||||||
If it crashes due to jpegio/scipy issues, the parent Flask process survives.
|
If it crashes due to jpeglib/scipy issues, the parent Flask process survives.
|
||||||
|
|
||||||
CHANGES in v4.0.0:
|
CHANGES in v4.0.0:
|
||||||
- Added channel_key support for encode/decode operations
|
- Added channel_key support for encode/decode operations
|
||||||
@@ -19,6 +19,8 @@ Usage:
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -27,6 +29,24 @@ from pathlib import Path
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
# Configure logging for worker subprocess
|
||||||
|
_log_level = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
|
||||||
|
if _log_level and hasattr(logging, _log_level):
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, _log_level),
|
||||||
|
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
stream=sys.stderr,
|
||||||
|
)
|
||||||
|
elif os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
stream=sys.stderr,
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("stegasoo.worker")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_channel_key(channel_key_param):
|
def _resolve_channel_key(channel_key_param):
|
||||||
"""
|
"""
|
||||||
@@ -73,6 +93,7 @@ def _get_channel_info(resolved_key):
|
|||||||
|
|
||||||
def encode_operation(params: dict) -> dict:
|
def encode_operation(params: dict) -> dict:
|
||||||
"""Handle encode operation."""
|
"""Handle encode operation."""
|
||||||
|
logger.debug("encode_operation: mode=%s", params.get("embed_mode", "lsb"))
|
||||||
from stegasoo import FilePayload, encode
|
from stegasoo import FilePayload, encode
|
||||||
|
|
||||||
# Decode base64 inputs
|
# Decode base64 inputs
|
||||||
@@ -142,6 +163,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
|
|
||||||
with open(progress_file, "w") as f:
|
with open(progress_file, "w") as f:
|
||||||
json.dump({"percent": percent, "phase": phase}, f)
|
json.dump({"percent": percent, "phase": phase}, f)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -150,6 +172,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
|
|||||||
|
|
||||||
def decode_operation(params: dict) -> dict:
|
def decode_operation(params: dict) -> dict:
|
||||||
"""Handle decode operation."""
|
"""Handle decode operation."""
|
||||||
|
logger.debug("decode_operation: mode=%s", params.get("embed_mode", "auto"))
|
||||||
from stegasoo import decode
|
from stegasoo import decode
|
||||||
|
|
||||||
progress_file = params.get("progress_file")
|
progress_file = params.get("progress_file")
|
||||||
@@ -171,8 +194,7 @@ def decode_operation(params: dict) -> dict:
|
|||||||
# Resolve channel key (v4.0.0)
|
# Resolve channel key (v4.0.0)
|
||||||
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
||||||
|
|
||||||
_write_decode_progress(progress_file, 25, "extracting")
|
# Library handles progress internally via progress_file parameter
|
||||||
|
|
||||||
# Call decode with correct parameter names
|
# Call decode with correct parameter names
|
||||||
result = decode(
|
result = decode(
|
||||||
stego_image=stego_data,
|
stego_image=stego_data,
|
||||||
@@ -183,9 +205,9 @@ def decode_operation(params: dict) -> dict:
|
|||||||
rsa_password=params.get("rsa_password"),
|
rsa_password=params.get("rsa_password"),
|
||||||
embed_mode=params.get("embed_mode", "auto"),
|
embed_mode=params.get("embed_mode", "auto"),
|
||||||
channel_key=resolved_channel_key, # v4.0.0
|
channel_key=resolved_channel_key, # v4.0.0
|
||||||
|
progress_file=progress_file, # v4.2.0: pass through for real-time progress
|
||||||
)
|
)
|
||||||
|
# Library writes 100% "complete" - no need for worker to write again
|
||||||
_write_decode_progress(progress_file, 90, "finalizing")
|
|
||||||
|
|
||||||
if result.is_file:
|
if result.is_file:
|
||||||
return {
|
return {
|
||||||
@@ -234,6 +256,145 @@ def capacity_check_operation(params: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def encode_audio_operation(params: dict) -> dict:
|
||||||
|
"""Handle audio encode operation (v4.3.0)."""
|
||||||
|
logger.debug("encode_audio_operation: mode=%s", params.get("embed_mode", "audio_lsb"))
|
||||||
|
from stegasoo import FilePayload, encode_audio
|
||||||
|
|
||||||
|
carrier_data = base64.b64decode(params["carrier_b64"])
|
||||||
|
reference_data = base64.b64decode(params["reference_b64"])
|
||||||
|
|
||||||
|
# Optional RSA key
|
||||||
|
rsa_key_data = None
|
||||||
|
if params.get("rsa_key_b64"):
|
||||||
|
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
|
||||||
|
|
||||||
|
# Determine payload type
|
||||||
|
if params.get("file_b64"):
|
||||||
|
file_data = base64.b64decode(params["file_b64"])
|
||||||
|
payload = FilePayload(
|
||||||
|
data=file_data,
|
||||||
|
filename=params.get("file_name", "file"),
|
||||||
|
mime_type=params.get("file_mime", "application/octet-stream"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
payload = params.get("message", "")
|
||||||
|
|
||||||
|
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
||||||
|
|
||||||
|
# Resolve chip_tier from params (None means use default)
|
||||||
|
chip_tier_val = params.get("chip_tier")
|
||||||
|
if chip_tier_val is not None:
|
||||||
|
chip_tier_val = int(chip_tier_val)
|
||||||
|
|
||||||
|
stego_audio, stats = encode_audio(
|
||||||
|
message=payload,
|
||||||
|
reference_photo=reference_data,
|
||||||
|
carrier_audio=carrier_data,
|
||||||
|
passphrase=params.get("passphrase", ""),
|
||||||
|
pin=params.get("pin"),
|
||||||
|
rsa_key_data=rsa_key_data,
|
||||||
|
rsa_password=params.get("rsa_password"),
|
||||||
|
embed_mode=params.get("embed_mode", "audio_lsb"),
|
||||||
|
channel_key=resolved_channel_key,
|
||||||
|
progress_file=params.get("progress_file"),
|
||||||
|
chip_tier=chip_tier_val,
|
||||||
|
)
|
||||||
|
|
||||||
|
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"stego_b64": base64.b64encode(stego_audio).decode("ascii"),
|
||||||
|
"stats": {
|
||||||
|
"samples_modified": stats.samples_modified,
|
||||||
|
"total_samples": stats.total_samples,
|
||||||
|
"capacity_used": stats.capacity_used,
|
||||||
|
"bytes_embedded": stats.bytes_embedded,
|
||||||
|
"sample_rate": stats.sample_rate,
|
||||||
|
"channels": stats.channels,
|
||||||
|
"duration_seconds": stats.duration_seconds,
|
||||||
|
"embed_mode": stats.embed_mode,
|
||||||
|
},
|
||||||
|
"channel_mode": channel_mode,
|
||||||
|
"channel_fingerprint": channel_fingerprint,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_audio_operation(params: dict) -> dict:
|
||||||
|
"""Handle audio decode operation (v4.3.0)."""
|
||||||
|
logger.debug("decode_audio_operation: mode=%s", params.get("embed_mode", "audio_auto"))
|
||||||
|
from stegasoo import decode_audio
|
||||||
|
|
||||||
|
progress_file = params.get("progress_file")
|
||||||
|
_write_decode_progress(progress_file, 5, "reading")
|
||||||
|
|
||||||
|
stego_data = base64.b64decode(params["stego_b64"])
|
||||||
|
reference_data = base64.b64decode(params["reference_b64"])
|
||||||
|
|
||||||
|
_write_decode_progress(progress_file, 15, "reading")
|
||||||
|
|
||||||
|
rsa_key_data = None
|
||||||
|
if params.get("rsa_key_b64"):
|
||||||
|
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
|
||||||
|
|
||||||
|
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
||||||
|
|
||||||
|
result = decode_audio(
|
||||||
|
stego_audio=stego_data,
|
||||||
|
reference_photo=reference_data,
|
||||||
|
passphrase=params.get("passphrase", ""),
|
||||||
|
pin=params.get("pin"),
|
||||||
|
rsa_key_data=rsa_key_data,
|
||||||
|
rsa_password=params.get("rsa_password"),
|
||||||
|
embed_mode=params.get("embed_mode", "audio_auto"),
|
||||||
|
channel_key=resolved_channel_key,
|
||||||
|
progress_file=progress_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.is_file:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"is_file": True,
|
||||||
|
"file_b64": base64.b64encode(result.file_data).decode("ascii"),
|
||||||
|
"filename": result.filename,
|
||||||
|
"mime_type": result.mime_type,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"is_file": False,
|
||||||
|
"message": result.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def audio_info_operation(params: dict) -> dict:
|
||||||
|
"""Handle audio info operation (v4.3.0)."""
|
||||||
|
from stegasoo import get_audio_info
|
||||||
|
from stegasoo.audio_steganography import calculate_audio_lsb_capacity
|
||||||
|
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
||||||
|
|
||||||
|
audio_data = base64.b64decode(params["audio_b64"])
|
||||||
|
|
||||||
|
info = get_audio_info(audio_data)
|
||||||
|
lsb_capacity = calculate_audio_lsb_capacity(audio_data)
|
||||||
|
spread_capacity = calculate_audio_spread_capacity(audio_data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"info": {
|
||||||
|
"sample_rate": info.sample_rate,
|
||||||
|
"channels": info.channels,
|
||||||
|
"duration_seconds": round(info.duration_seconds, 2),
|
||||||
|
"num_samples": info.num_samples,
|
||||||
|
"format": info.format,
|
||||||
|
"bit_depth": info.bit_depth,
|
||||||
|
"capacity_lsb": lsb_capacity,
|
||||||
|
"capacity_spread": spread_capacity.usable_capacity_bytes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def channel_status_operation(params: dict) -> dict:
|
def channel_status_operation(params: dict) -> dict:
|
||||||
"""Handle channel status check (v4.0.0)."""
|
"""Handle channel status check (v4.0.0)."""
|
||||||
from stegasoo import get_channel_status
|
from stegasoo import get_channel_status
|
||||||
@@ -264,6 +425,7 @@ def main():
|
|||||||
else:
|
else:
|
||||||
params = json.loads(input_text)
|
params = json.loads(input_text)
|
||||||
operation = params.get("operation")
|
operation = params.get("operation")
|
||||||
|
logger.info("Worker handling operation: %s", operation)
|
||||||
|
|
||||||
if operation == "encode":
|
if operation == "encode":
|
||||||
output = encode_operation(params)
|
output = encode_operation(params)
|
||||||
@@ -275,6 +437,13 @@ def main():
|
|||||||
output = capacity_check_operation(params)
|
output = capacity_check_operation(params)
|
||||||
elif operation == "channel_status":
|
elif operation == "channel_status":
|
||||||
output = channel_status_operation(params)
|
output = channel_status_operation(params)
|
||||||
|
# Audio operations (v4.3.0)
|
||||||
|
elif operation == "encode_audio":
|
||||||
|
output = encode_audio_operation(params)
|
||||||
|
elif operation == "decode_audio":
|
||||||
|
output = decode_audio_operation(params)
|
||||||
|
elif operation == "audio_info":
|
||||||
|
output = audio_info_operation(params)
|
||||||
else:
|
else:
|
||||||
output = {"success": False, "error": f"Unknown operation: {operation}"}
|
output = {"success": False, "error": f"Unknown operation: {operation}"}
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,35 @@ class CapacityResult:
|
|||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioEncodeResult:
|
||||||
|
"""Result from audio encode operation (v4.3.0)."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
stego_data: bytes | None = None
|
||||||
|
stats: dict[str, Any] | None = None
|
||||||
|
channel_mode: str | None = None
|
||||||
|
channel_fingerprint: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
error_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioInfoResult:
|
||||||
|
"""Result from audio info operation (v4.3.0)."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
sample_rate: int = 0
|
||||||
|
channels: int = 0
|
||||||
|
duration_seconds: float = 0.0
|
||||||
|
num_samples: int = 0
|
||||||
|
format: str = ""
|
||||||
|
bit_depth: int | None = None
|
||||||
|
capacity_lsb: int = 0
|
||||||
|
capacity_spread: int = 0
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ChannelStatusResult:
|
class ChannelStatusResult:
|
||||||
"""Result from channel status check (v4.0.0)."""
|
"""Result from channel status check (v4.0.0)."""
|
||||||
@@ -132,7 +161,7 @@ class SubprocessStego:
|
|||||||
"""
|
"""
|
||||||
Subprocess-isolated steganography operations.
|
Subprocess-isolated steganography operations.
|
||||||
|
|
||||||
All operations run in a separate Python process. If jpegio or scipy
|
All operations run in a separate Python process. If jpeglib or scipy
|
||||||
crashes, only the subprocess dies - Flask keeps running.
|
crashes, only the subprocess dies - Flask keeps running.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -456,6 +485,201 @@ class SubprocessStego:
|
|||||||
error=result.get("error", "Unknown error"),
|
error=result.get("error", "Unknown error"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Audio Steganography (v4.3.0)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def encode_audio(
|
||||||
|
self,
|
||||||
|
carrier_data: bytes,
|
||||||
|
reference_data: bytes,
|
||||||
|
message: str | None = None,
|
||||||
|
file_data: bytes | None = None,
|
||||||
|
file_name: str | None = None,
|
||||||
|
file_mime: str | None = None,
|
||||||
|
passphrase: str = "",
|
||||||
|
pin: str | None = None,
|
||||||
|
rsa_key_data: bytes | None = None,
|
||||||
|
rsa_password: str | None = None,
|
||||||
|
embed_mode: str = "audio_lsb",
|
||||||
|
channel_key: str | None = "auto",
|
||||||
|
timeout: int | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
chip_tier: int | None = None,
|
||||||
|
) -> AudioEncodeResult:
|
||||||
|
"""
|
||||||
|
Encode a message or file into an audio carrier.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
carrier_data: Carrier audio bytes (WAV, FLAC, MP3, etc.)
|
||||||
|
reference_data: Reference photo bytes
|
||||||
|
message: Text message to encode (if not file)
|
||||||
|
file_data: File bytes to encode (if not message)
|
||||||
|
file_name: Original filename (for file payload)
|
||||||
|
file_mime: MIME type (for file payload)
|
||||||
|
passphrase: Encryption passphrase
|
||||||
|
pin: Optional PIN
|
||||||
|
rsa_key_data: Optional RSA key PEM bytes
|
||||||
|
rsa_password: RSA key password if encrypted
|
||||||
|
embed_mode: 'audio_lsb' or 'audio_spread'
|
||||||
|
channel_key: 'auto', 'none', or explicit key
|
||||||
|
timeout: Operation timeout (default 300s for audio)
|
||||||
|
progress_file: Path to write progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AudioEncodeResult with stego audio data on success
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"operation": "encode_audio",
|
||||||
|
"carrier_b64": base64.b64encode(carrier_data).decode("ascii"),
|
||||||
|
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
|
||||||
|
"message": message,
|
||||||
|
"passphrase": passphrase,
|
||||||
|
"pin": pin,
|
||||||
|
"embed_mode": embed_mode,
|
||||||
|
"channel_key": channel_key,
|
||||||
|
"progress_file": progress_file,
|
||||||
|
"chip_tier": chip_tier,
|
||||||
|
}
|
||||||
|
|
||||||
|
if file_data:
|
||||||
|
params["file_b64"] = base64.b64encode(file_data).decode("ascii")
|
||||||
|
params["file_name"] = file_name
|
||||||
|
params["file_mime"] = file_mime
|
||||||
|
|
||||||
|
if rsa_key_data:
|
||||||
|
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
|
||||||
|
params["rsa_password"] = rsa_password
|
||||||
|
|
||||||
|
# Audio operations can be slower (especially spread spectrum)
|
||||||
|
result = self._run_worker(params, timeout or 300)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
return AudioEncodeResult(
|
||||||
|
success=True,
|
||||||
|
stego_data=base64.b64decode(result["stego_b64"]),
|
||||||
|
stats=result.get("stats"),
|
||||||
|
channel_mode=result.get("channel_mode"),
|
||||||
|
channel_fingerprint=result.get("channel_fingerprint"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return AudioEncodeResult(
|
||||||
|
success=False,
|
||||||
|
error=result.get("error", "Unknown error"),
|
||||||
|
error_type=result.get("error_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def decode_audio(
|
||||||
|
self,
|
||||||
|
stego_data: bytes,
|
||||||
|
reference_data: bytes,
|
||||||
|
passphrase: str = "",
|
||||||
|
pin: str | None = None,
|
||||||
|
rsa_key_data: bytes | None = None,
|
||||||
|
rsa_password: str | None = None,
|
||||||
|
embed_mode: str = "audio_auto",
|
||||||
|
channel_key: str | None = "auto",
|
||||||
|
timeout: int | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
) -> DecodeResult:
|
||||||
|
"""
|
||||||
|
Decode a message or file from stego audio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stego_data: Stego audio bytes
|
||||||
|
reference_data: Reference photo bytes
|
||||||
|
passphrase: Decryption passphrase
|
||||||
|
pin: Optional PIN
|
||||||
|
rsa_key_data: Optional RSA key PEM bytes
|
||||||
|
rsa_password: RSA key password if encrypted
|
||||||
|
embed_mode: 'audio_auto', 'audio_lsb', or 'audio_spread'
|
||||||
|
channel_key: 'auto', 'none', or explicit key
|
||||||
|
timeout: Operation timeout (default 300s for audio)
|
||||||
|
progress_file: Path to write progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DecodeResult with message or file_data on success
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"operation": "decode_audio",
|
||||||
|
"stego_b64": base64.b64encode(stego_data).decode("ascii"),
|
||||||
|
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
|
||||||
|
"passphrase": passphrase,
|
||||||
|
"pin": pin,
|
||||||
|
"embed_mode": embed_mode,
|
||||||
|
"channel_key": channel_key,
|
||||||
|
"progress_file": progress_file,
|
||||||
|
}
|
||||||
|
|
||||||
|
if rsa_key_data:
|
||||||
|
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
|
||||||
|
params["rsa_password"] = rsa_password
|
||||||
|
|
||||||
|
result = self._run_worker(params, timeout or 300)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
if result.get("is_file"):
|
||||||
|
return DecodeResult(
|
||||||
|
success=True,
|
||||||
|
is_file=True,
|
||||||
|
file_data=base64.b64decode(result["file_b64"]),
|
||||||
|
filename=result.get("filename"),
|
||||||
|
mime_type=result.get("mime_type"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return DecodeResult(
|
||||||
|
success=True,
|
||||||
|
is_file=False,
|
||||||
|
message=result.get("message"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return DecodeResult(
|
||||||
|
success=False,
|
||||||
|
error=result.get("error", "Unknown error"),
|
||||||
|
error_type=result.get("error_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def audio_info(
|
||||||
|
self,
|
||||||
|
audio_data: bytes,
|
||||||
|
timeout: int | None = None,
|
||||||
|
) -> AudioInfoResult:
|
||||||
|
"""
|
||||||
|
Get audio file information and steganographic capacity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Audio file bytes
|
||||||
|
timeout: Operation timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AudioInfoResult with metadata and capacity info
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"operation": "audio_info",
|
||||||
|
"audio_b64": base64.b64encode(audio_data).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = self._run_worker(params, timeout)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
info = result.get("info", {})
|
||||||
|
return AudioInfoResult(
|
||||||
|
success=True,
|
||||||
|
sample_rate=info.get("sample_rate", 0),
|
||||||
|
channels=info.get("channels", 0),
|
||||||
|
duration_seconds=info.get("duration_seconds", 0.0),
|
||||||
|
num_samples=info.get("num_samples", 0),
|
||||||
|
format=info.get("format", ""),
|
||||||
|
bit_depth=info.get("bit_depth"),
|
||||||
|
capacity_lsb=info.get("capacity_lsb", 0),
|
||||||
|
capacity_spread=info.get("capacity_spread", 0),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return AudioInfoResult(
|
||||||
|
success=False,
|
||||||
|
error=result.get("error", "Unknown error"),
|
||||||
|
)
|
||||||
|
|
||||||
def get_channel_status(
|
def get_channel_status(
|
||||||
self,
|
self,
|
||||||
reveal: bool = False,
|
reveal: bool = False,
|
||||||
|
|||||||
@@ -340,11 +340,13 @@
|
|||||||
<!-- Current Version - Prominent -->
|
<!-- Current Version - Prominent -->
|
||||||
<div class="alert alert-success mb-4">
|
<div class="alert alert-success mb-4">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<span class="badge bg-success fs-6 me-3">v4.1.2</span>
|
<span class="badge bg-success fs-6 me-3">v4.2.1</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Progress bars</strong> for encode operations,
|
<strong>Security & API improvements:</strong>
|
||||||
<strong>mobile-responsive polish</strong>,
|
API key authentication,
|
||||||
DCT decode bug fix, release validation script
|
TLS with self-signed certs,
|
||||||
|
CLI tools (compress, rotate, convert),
|
||||||
|
jpegtran lossless JPEG rotation
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,6 +364,10 @@
|
|||||||
<div class="accordion-body p-0">
|
<div class="accordion-body p-0">
|
||||||
<table class="table table-dark table-sm small mb-0">
|
<table class="table table-dark table-sm small mb-0">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td width="80"><strong>4.1.7</strong></td>
|
||||||
|
<td>Progress bars for encode, mobile polish, release validation</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="80"><strong>4.1.1</strong></td>
|
<td width="80"><strong>4.1.1</strong></td>
|
||||||
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
|
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
|
||||||
@@ -559,7 +565,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><i class="bi bi-clock me-2"></i>File expiry</td>
|
<td><i class="bi bi-clock me-2"></i>File expiry</td>
|
||||||
<td><strong>5 min</strong></td>
|
<td><strong>10 min</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><i class="bi bi-key me-2"></i>PIN</td>
|
<td><i class="bi bi-key me-2"></i>PIN</td>
|
||||||
@@ -567,7 +573,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><i class="bi bi-shield me-2"></i>RSA keys</td>
|
<td><i class="bi bi-shield me-2"></i>RSA keys</td>
|
||||||
<td><strong>2048, 3072, 4096 bit</strong></td>
|
<td><strong>2048, 3072 bit</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>
|
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>
|
||||||
|
|||||||
@@ -16,11 +16,11 @@
|
|||||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="28">
|
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="28">
|
||||||
</a>
|
</a>
|
||||||
{% if channel_configured %}
|
{% if channel_configured %}
|
||||||
<span class="badge bg-success bg-opacity-25 small" style="padding-left: 0.35rem;" title="Private Channel: {{ channel_fingerprint }}">
|
<span class="badge bg-success bg-opacity-25 small me-auto" style="padding-left: 0.35rem;" title="Private Channel: {{ channel_fingerprint }}">
|
||||||
<i class="bi bi-shield-lock me-2" style="color: #6ee7b7;"></i><code style="font-size: 0.7rem; font-weight: 300; color: #c9a860;">{{ channel_fingerprint[:4] }}-••••-{{ channel_fingerprint[-4:] }}</code>
|
<i class="bi bi-shield-lock me-2" style="color: #6ee7b7;"></i><code style="font-size: 0.7rem; font-weight: 300; color: #c9a860;">{{ channel_fingerprint[:4] }}-••••-{{ channel_fingerprint[-4:] }}</code>
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary bg-opacity-25 small text-muted" style="padding-left: 0.35rem;" title="Public Channel: No shared channel key configured. Messages use only passphrase and PIN for encryption.">
|
<span class="badge bg-secondary bg-opacity-25 small text-muted me-auto" style="padding-left: 0.35rem;" title="Public Channel: No shared channel key configured. Messages use only passphrase and PIN for encryption.">
|
||||||
<i class="bi bi-globe me-1"></i>Public Channel
|
<i class="bi bi-globe me-1"></i>Public Channel
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -24,7 +24,11 @@
|
|||||||
border-left: 3px solid #ffe699;
|
border-left: 3px solid #ffe699;
|
||||||
}
|
}
|
||||||
.step-accordion .accordion-button::after {
|
.step-accordion .accordion-button::after {
|
||||||
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
|
filter: brightness(0) invert(1);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.step-accordion .accordion-button:not(.collapsed)::after {
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.step-accordion .accordion-body {
|
.step-accordion .accordion-body {
|
||||||
background: rgba(30, 40, 50, 0.4);
|
background: rgba(30, 40, 50, 0.4);
|
||||||
@@ -158,7 +162,7 @@
|
|||||||
|
|
||||||
<div class="alert alert-warning small">
|
<div class="alert alert-warning small">
|
||||||
<i class="bi bi-clock me-1"></i>
|
<i class="bi bi-clock me-1"></i>
|
||||||
<strong>File expires in 5 minutes.</strong> Download now.
|
<strong>File expires in 10 minutes.</strong> Download now.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="/decode" class="btn btn-outline-light w-100">
|
<a href="/decode" class="btn btn-outline-light w-100">
|
||||||
@@ -172,19 +176,51 @@
|
|||||||
<div class="accordion step-accordion" id="decodeAccordion">
|
<div class="accordion step-accordion" id="decodeAccordion">
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
STEP 1: IMAGES & MODE
|
STEP 1: CARRIER TYPE (v4.3.0)
|
||||||
|
================================================================ -->
|
||||||
|
<div class="accordion-item" id="carrierTypeStep">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepCarrierType">
|
||||||
|
<span class="step-title">
|
||||||
|
<span class="step-number" id="stepCarrierTypeNumber">1</span>
|
||||||
|
<i class="bi bi-collection me-1"></i> Carrier Type
|
||||||
|
</span>
|
||||||
|
<span class="step-summary" id="stepCarrierTypeSummary"></span>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="stepCarrierType" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<input type="hidden" name="carrier_type" id="carrierTypeInput" value="image">
|
||||||
|
<div class="btn-group w-100" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="carrier_type_select" id="typeImage" value="image" checked>
|
||||||
|
<label class="btn btn-outline-secondary" for="typeImage">
|
||||||
|
<i class="bi bi-image me-1"></i> Image
|
||||||
|
</label>
|
||||||
|
<input type="radio" class="btn-check" name="carrier_type_select" id="typeAudio" value="audio"
|
||||||
|
{% if not has_audio %}disabled{% endif %}>
|
||||||
|
<label class="btn btn-outline-secondary {% if not has_audio %}disabled text-muted{% endif %}" for="typeAudio">
|
||||||
|
<i class="bi bi-music-note-beamed me-1"></i> Audio
|
||||||
|
{% if not has_audio %}<small class="d-block" style="font-size: 0.65rem;">(not available)</small>{% endif %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
STEP 2: IMAGES & MODE
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
||||||
<span class="step-title">
|
<span class="step-title">
|
||||||
<span class="step-number" id="stepImagesNumber">1</span>
|
<span class="step-number" id="stepImagesNumber">2</span>
|
||||||
<i class="bi bi-images me-1"></i> Images & Mode
|
<i class="bi bi-images me-1"></i> Reference, Carrier, Mode
|
||||||
</span>
|
</span>
|
||||||
<span class="step-summary" id="stepImagesSummary">Select reference & stego</span>
|
<span class="step-summary" id="stepImagesSummary">Select reference & stego</span>
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
|
<div id="stepImages" class="accordion-collapse collapse" data-bs-parent="#decodeAccordion">
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -213,41 +249,74 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">
|
<div id="imageStegoSection">
|
||||||
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
<label class="form-label">
|
||||||
</label>
|
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
||||||
<div class="drop-zone pixel-container" id="stegoDropZone">
|
</label>
|
||||||
<input type="file" name="stego_image" accept="image/*" required id="stegoInput">
|
<div class="drop-zone pixel-container" id="stegoDropZone">
|
||||||
<div class="drop-zone-label">
|
<input type="file" name="stego_image" accept="image/*" required id="stegoInput">
|
||||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
<div class="drop-zone-label">
|
||||||
<span class="text-muted">Drop image or click</span>
|
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||||
</div>
|
<span class="text-muted">Drop image or click</span>
|
||||||
<img class="drop-zone-preview d-none" id="stegoPreview">
|
</div>
|
||||||
<div class="pixel-blocks"></div>
|
<img class="drop-zone-preview d-none" id="stegoPreview">
|
||||||
<div class="pixel-scan-line"></div>
|
<div class="pixel-blocks"></div>
|
||||||
<div class="pixel-corners">
|
<div class="pixel-scan-line"></div>
|
||||||
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
|
<div class="pixel-corners">
|
||||||
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
|
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
|
||||||
</div>
|
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
|
||||||
<div class="pixel-data-panel">
|
</div>
|
||||||
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="stegoFileName">image.png</span></div>
|
<div class="pixel-data-panel">
|
||||||
<div class="pixel-data-row"><span class="pixel-status-badge">Stego Loaded</span><span class="pixel-data-value" id="stegoFileSize">--</span></div>
|
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="stegoFileName">image.png</span></div>
|
||||||
<div class="pixel-dimensions" id="stegoDims">-- x -- px</div>
|
<div class="pixel-data-row"><span class="pixel-status-badge">Stego Loaded</span><span class="pixel-data-value" id="stegoFileSize">--</span></div>
|
||||||
|
<div class="pixel-dimensions" id="stegoDims">-- x -- px</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-text">Image containing the hidden message</div>
|
||||||
|
</div>
|
||||||
|
<!-- Audio Stego (hidden by default) -->
|
||||||
|
<div class="d-none" id="audioStegoSection">
|
||||||
|
<label class="form-label">
|
||||||
|
<i class="bi bi-file-earmark-music me-1"></i> Stego Audio
|
||||||
|
</label>
|
||||||
|
<div class="drop-zone pixel-container" id="audioStegoDropZone">
|
||||||
|
<input type="file" name="stego_image" accept="audio/*" id="audioStegoInput">
|
||||||
|
<div class="drop-zone-label">
|
||||||
|
<i class="bi bi-music-note-beamed fs-3 d-block mb-2 text-muted"></i>
|
||||||
|
<span class="text-muted">Drop audio or click</span>
|
||||||
|
</div>
|
||||||
|
<div class="pixel-data-panel">
|
||||||
|
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="audioStegoFileName">audio.wav</span></div>
|
||||||
|
<div class="pixel-data-row"><span class="pixel-status-badge">Audio Loaded</span><span class="pixel-data-value" id="audioStegoFileSize">--</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Audio file containing the hidden message</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Image containing the hidden message</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Extraction Mode -->
|
<!-- Extraction Mode -->
|
||||||
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
||||||
<div class="btn-group" role="group">
|
<div id="imageModeGroup">
|
||||||
<input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
|
<div class="btn-group" role="group">
|
||||||
<label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label>
|
<input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
|
||||||
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb">
|
<label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label>
|
||||||
<label class="btn btn-outline-secondary text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb">
|
||||||
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
|
<label class="btn btn-outline-secondary text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||||
<label class="btn btn-outline-secondary text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
|
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
|
||||||
|
<label class="btn btn-outline-secondary text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Audio Extraction Modes (hidden by default) -->
|
||||||
|
<div class="d-none" id="audioModeGroup">
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioAuto" value="audio_auto">
|
||||||
|
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioAuto"><i class="bi bi-magic me-1"></i>Auto</label>
|
||||||
|
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioLsb" value="audio_lsb">
|
||||||
|
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||||
|
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioSpread" value="audio_spread">
|
||||||
|
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioSpread"><i class="bi bi-broadcast me-1"></i>Spread</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text" id="modeHint">
|
<div class="form-text" id="modeHint">
|
||||||
@@ -259,13 +328,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
STEP 2: SECURITY
|
STEP 3: SECURITY
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
|
||||||
<span class="step-title">
|
<span class="step-title">
|
||||||
<span class="step-number" id="stepSecurityNumber">2</span>
|
<span class="step-number" id="stepSecurityNumber">3</span>
|
||||||
<i class="bi bi-shield-lock me-1"></i> Security
|
<i class="bi bi-shield-lock me-1"></i> Security
|
||||||
</span>
|
</span>
|
||||||
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
|
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
|
||||||
@@ -425,7 +494,10 @@
|
|||||||
const modeHints = {
|
const modeHints = {
|
||||||
auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' },
|
auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' },
|
||||||
lsb: { icon: 'hdd', text: 'For email and direct transfers' },
|
lsb: { icon: 'hdd', text: 'For email and direct transfers' },
|
||||||
dct: { icon: 'phone', text: 'For social media images' }
|
dct: { icon: 'phone', text: 'For social media images' },
|
||||||
|
audio_auto: { icon: 'lightning', text: 'Tries LSB first, then Spread Spectrum' },
|
||||||
|
audio_lsb: { icon: 'grid-3x3-gap', text: 'Direct bit embedding in audio samples' },
|
||||||
|
audio_spread: { icon: 'broadcast', text: 'Noise-resistant spread spectrum encoding' }
|
||||||
};
|
};
|
||||||
|
|
||||||
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
||||||
@@ -442,9 +514,14 @@ document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
|||||||
// ACCORDION SUMMARY UPDATES
|
// ACCORDION SUMMARY UPDATES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
const carrierTypeInput = document.getElementById('carrierTypeInput');
|
||||||
|
|
||||||
function updateImagesSummary() {
|
function updateImagesSummary() {
|
||||||
const ref = document.getElementById('refPhotoInput')?.files[0];
|
const ref = document.getElementById('refPhotoInput')?.files[0];
|
||||||
const stego = document.getElementById('stegoInput')?.files[0];
|
const isAudio = carrierTypeInput?.value === 'audio';
|
||||||
|
const stego = isAudio
|
||||||
|
? document.getElementById('audioStegoInput')?.files[0]
|
||||||
|
: document.getElementById('stegoInput')?.files[0];
|
||||||
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'AUTO';
|
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'AUTO';
|
||||||
const summary = document.getElementById('stepImagesSummary');
|
const summary = document.getElementById('stepImagesSummary');
|
||||||
const stepNum = document.getElementById('stepImagesNumber');
|
const stepNum = document.getElementById('stepImagesNumber');
|
||||||
@@ -460,12 +537,12 @@ function updateImagesSummary() {
|
|||||||
summary.textContent = ref ? ref.name.slice(0, 15) : stego.name.slice(0, 15);
|
summary.textContent = ref ? ref.name.slice(0, 15) : stego.name.slice(0, 15);
|
||||||
summary.classList.remove('has-content');
|
summary.classList.remove('has-content');
|
||||||
stepNum.classList.remove('complete');
|
stepNum.classList.remove('complete');
|
||||||
stepNum.textContent = '1';
|
stepNum.textContent = '2';
|
||||||
} else {
|
} else {
|
||||||
summary.textContent = 'Select reference & stego';
|
summary.textContent = isAudio ? 'Select reference & audio' : 'Select reference & stego';
|
||||||
summary.classList.remove('has-content');
|
summary.classList.remove('has-content');
|
||||||
stepNum.classList.remove('complete');
|
stepNum.classList.remove('complete');
|
||||||
stepNum.textContent = '1';
|
stepNum.textContent = '2';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,19 +570,99 @@ function updateSecuritySummary() {
|
|||||||
summary.textContent = 'Passphrase & keys';
|
summary.textContent = 'Passphrase & keys';
|
||||||
summary.classList.remove('has-content');
|
summary.classList.remove('has-content');
|
||||||
stepNum.classList.remove('complete');
|
stepNum.classList.remove('complete');
|
||||||
stepNum.textContent = '2';
|
stepNum.textContent = '3';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach listeners
|
// Attach listeners
|
||||||
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
|
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
|
||||||
document.getElementById('stegoInput')?.addEventListener('change', updateImagesSummary);
|
document.getElementById('stegoInput')?.addEventListener('change', updateImagesSummary);
|
||||||
|
document.getElementById('audioStegoInput')?.addEventListener('change', updateImagesSummary);
|
||||||
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||||
|
document.querySelectorAll('#audioModeGroup input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||||
|
|
||||||
document.getElementById('passphraseInput')?.addEventListener('input', updateSecuritySummary);
|
document.getElementById('passphraseInput')?.addEventListener('input', updateSecuritySummary);
|
||||||
document.getElementById('pinInput')?.addEventListener('input', updateSecuritySummary);
|
document.getElementById('pinInput')?.addEventListener('input', updateSecuritySummary);
|
||||||
document.querySelector('input[name="rsa_key"]')?.addEventListener('change', updateSecuritySummary);
|
document.querySelector('input[name="rsa_key"]')?.addEventListener('change', updateSecuritySummary);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CARRIER TYPE TOGGLE (v4.3.0)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const carrierTypeRadios = document.querySelectorAll('input[name="carrier_type_select"]');
|
||||||
|
const imageStegoSection = document.getElementById('imageStegoSection');
|
||||||
|
const audioStegoSection = document.getElementById('audioStegoSection');
|
||||||
|
const imageModeGroup = document.getElementById('imageModeGroup');
|
||||||
|
const audioModeGroup = document.getElementById('audioModeGroup');
|
||||||
|
const stepCarrierTypeSummary = document.getElementById('stepCarrierTypeSummary');
|
||||||
|
|
||||||
|
carrierTypeRadios.forEach(radio => {
|
||||||
|
radio.addEventListener('change', function() {
|
||||||
|
const isAudio = this.value === 'audio';
|
||||||
|
carrierTypeInput.value = this.value;
|
||||||
|
|
||||||
|
// Toggle stego sections
|
||||||
|
if (imageStegoSection) imageStegoSection.classList.toggle('d-none', isAudio);
|
||||||
|
if (audioStegoSection) audioStegoSection.classList.toggle('d-none', !isAudio);
|
||||||
|
|
||||||
|
// Toggle required attribute so hidden inputs don't block form submission
|
||||||
|
const imgStego = document.getElementById('stegoInput');
|
||||||
|
const audStego = document.getElementById('audioStegoInput');
|
||||||
|
if (imgStego) { if (isAudio) imgStego.removeAttribute('required'); else imgStego.setAttribute('required', ''); }
|
||||||
|
if (audStego) { if (isAudio) audStego.setAttribute('required', ''); else audStego.removeAttribute('required'); }
|
||||||
|
|
||||||
|
// Toggle mode groups
|
||||||
|
if (imageModeGroup) imageModeGroup.classList.toggle('d-none', isAudio);
|
||||||
|
if (audioModeGroup) audioModeGroup.classList.toggle('d-none', !isAudio);
|
||||||
|
|
||||||
|
// Update summary
|
||||||
|
if (stepCarrierTypeSummary) {
|
||||||
|
stepCarrierTypeSummary.textContent = isAudio ? 'Audio' : 'Image';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select default mode
|
||||||
|
if (isAudio) {
|
||||||
|
const audioAuto = document.getElementById('modeAudioAuto');
|
||||||
|
if (audioAuto) audioAuto.checked = true;
|
||||||
|
} else {
|
||||||
|
const autoMode = document.getElementById('modeAuto');
|
||||||
|
if (autoMode) autoMode.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear stego file selections
|
||||||
|
const stegoInput = document.getElementById('stegoInput');
|
||||||
|
const audioStegoInput = document.getElementById('audioStegoInput');
|
||||||
|
if (stegoInput) stegoInput.value = '';
|
||||||
|
if (audioStegoInput) audioStegoInput.value = '';
|
||||||
|
|
||||||
|
// Reset previews
|
||||||
|
document.getElementById('stegoPreview')?.classList.add('d-none');
|
||||||
|
|
||||||
|
// Update mode hint
|
||||||
|
const hint = document.getElementById('modeHint');
|
||||||
|
if (hint) {
|
||||||
|
if (isAudio) {
|
||||||
|
hint.innerHTML = '<i class="bi bi-lightning me-1"></i>Tries LSB first, then Spread Spectrum';
|
||||||
|
} else {
|
||||||
|
hint.innerHTML = '<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImagesSummary();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audio stego file info display
|
||||||
|
const audioStegoInput = document.getElementById('audioStegoInput');
|
||||||
|
audioStegoInput?.addEventListener('change', function() {
|
||||||
|
if (this.files && this.files[0]) {
|
||||||
|
const file = this.files[0];
|
||||||
|
document.getElementById('audioStegoFileName').textContent = file.name;
|
||||||
|
document.getElementById('audioStegoFileSize').textContent = (file.size / 1024).toFixed(1) + ' KB';
|
||||||
|
updateImagesSummary();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MODE SWITCHING
|
// MODE SWITCHING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -24,7 +24,11 @@
|
|||||||
border-left: 3px solid #ffe699;
|
border-left: 3px solid #ffe699;
|
||||||
}
|
}
|
||||||
.step-accordion .accordion-button::after {
|
.step-accordion .accordion-button::after {
|
||||||
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
|
filter: brightness(0) invert(1);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.step-accordion .accordion-button:not(.collapsed)::after {
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.step-accordion .accordion-body {
|
.step-accordion .accordion-body {
|
||||||
background: rgba(30, 40, 50, 0.4);
|
background: rgba(30, 40, 50, 0.4);
|
||||||
@@ -126,14 +130,14 @@
|
|||||||
<div class="accordion step-accordion" id="encodeAccordion">
|
<div class="accordion step-accordion" id="encodeAccordion">
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
STEP 1: IMAGES
|
STEP 1: CARRIER & MODE
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
||||||
<span class="step-title">
|
<span class="step-title">
|
||||||
<span class="step-number" id="stepImagesNumber">1</span>
|
<span class="step-number" id="stepImagesNumber">1</span>
|
||||||
<i class="bi bi-images me-1"></i> Images & Mode
|
<i class="bi bi-images me-1"></i> Carrier & Mode
|
||||||
</span>
|
</span>
|
||||||
<span class="step-summary" id="stepImagesSummary">Select reference & carrier</span>
|
<span class="step-summary" id="stepImagesSummary">Select reference & carrier</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -141,6 +145,8 @@
|
|||||||
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#encodeAccordion">
|
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#encodeAccordion">
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
|
|
||||||
|
<input type="hidden" name="carrier_type" id="carrierTypeInput" value="image">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
@@ -168,28 +174,47 @@
|
|||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
<i class="bi bi-file-earmark-image me-1"></i> Carrier Image
|
<i class="bi bi-file-earmark me-1"></i> Carrier File
|
||||||
</label>
|
</label>
|
||||||
<div class="drop-zone pixel-container" id="carrierDropZone">
|
<div id="imageCarrierSection">
|
||||||
<input type="file" name="carrier" accept="image/*" required id="carrierInput">
|
<div class="drop-zone pixel-container" id="carrierDropZone">
|
||||||
<div class="drop-zone-label">
|
<input type="file" name="carrier" accept="image/*" required id="carrierInput">
|
||||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
<div class="drop-zone-label">
|
||||||
<span class="text-muted">Drop image or click</span>
|
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||||
</div>
|
<span class="text-muted">Drop image or click</span>
|
||||||
<img class="drop-zone-preview d-none" id="carrierPreview">
|
</div>
|
||||||
<div class="pixel-blocks"></div>
|
<img class="drop-zone-preview d-none" id="carrierPreview">
|
||||||
<div class="pixel-scan-line"></div>
|
<div class="pixel-blocks"></div>
|
||||||
<div class="pixel-corners">
|
<div class="pixel-scan-line"></div>
|
||||||
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
|
<div class="pixel-corners">
|
||||||
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
|
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
|
||||||
</div>
|
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
|
||||||
<div class="pixel-data-panel">
|
</div>
|
||||||
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="carrierFileName">image.jpg</span></div>
|
<div class="pixel-data-panel">
|
||||||
<div class="pixel-data-row"><span class="pixel-status-badge">Carrier Loaded</span><span class="pixel-data-value" id="carrierFileSize">--</span></div>
|
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="carrierFileName">image.jpg</span></div>
|
||||||
<div class="pixel-dimensions" id="carrierDims">-- x -- px</div>
|
<div class="pixel-data-row"><span class="pixel-status-badge">Carrier Loaded</span><span class="pixel-data-value" id="carrierFileSize">--</span></div>
|
||||||
|
<div class="pixel-dimensions" id="carrierDims">-- x -- px</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-text" id="imageCarrierHint">Image to hide your message in</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio Carrier (hidden by default, shown when audio type selected) -->
|
||||||
|
<div class="d-none" id="audioCarrierSection">
|
||||||
|
<div class="drop-zone pixel-container" id="audioCarrierDropZone">
|
||||||
|
<input type="file" name="audio_carrier" accept="audio/*" id="audioCarrierInput">
|
||||||
|
<div class="drop-zone-label">
|
||||||
|
<i class="bi bi-music-note-beamed fs-3 d-block mb-2 text-muted"></i>
|
||||||
|
<span class="text-muted">Drop audio or click</span>
|
||||||
|
</div>
|
||||||
|
<div class="pixel-data-panel">
|
||||||
|
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="audioCarrierFileName">audio.wav</span></div>
|
||||||
|
<div class="pixel-data-row"><span class="pixel-status-badge">Audio Loaded</span><span class="pixel-data-value" id="audioCarrierFileSize">--</span></div>
|
||||||
|
<div class="pixel-dimensions" id="audioCarrierDuration">--:-- duration</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text" id="audioCarrierHint">Audio file to hide your message in</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Image to hide your message in</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -204,32 +229,71 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Embedding Mode (compact inline) -->
|
<!-- Audio Capacity Info (v4.3.0) -->
|
||||||
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
<div class="alert alert-info small d-none mb-3" id="audioCapacityPanel">
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
|
<span><i class="bi bi-music-note-beamed me-1"></i><span id="audioInfo">-</span></span>
|
||||||
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
|
<span>
|
||||||
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb" {% if not has_dct %}checked{% endif %}>
|
<span class="badge bg-primary me-1" id="lsbAudioCapacityBadge">LSB: -</span>
|
||||||
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
<span class="badge bg-warning text-dark" id="spreadCapacityBadge">Spread: -</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-muted d-none d-sm-inline">|</span>
|
|
||||||
<span class="d-flex gap-2 align-items-center" id="outputOptions">
|
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
|
||||||
<input type="radio" class="btn-check" name="dct_color_mode" id="colorMode" value="color" checked>
|
|
||||||
<label class="btn btn-outline-secondary btn-sm" for="colorMode">Color</label>
|
|
||||||
<input type="radio" class="btn-check" name="dct_color_mode" id="grayMode" value="grayscale">
|
|
||||||
<label class="btn btn-outline-secondary btn-sm" for="grayMode" id="grayModeLabel">Gray</label>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
|
||||||
<input type="radio" class="btn-check" name="dct_output_format" id="jpegFormat" value="jpeg" checked>
|
|
||||||
<label class="btn btn-outline-secondary btn-sm" for="jpegFormat" id="jpegFormatLabel">JPEG</label>
|
|
||||||
<input type="radio" class="btn-check" name="dct_output_format" id="pngFormat" value="png">
|
|
||||||
<label class="btn btn-outline-secondary btn-sm" for="pngFormat">PNG</label>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text" id="modeHint">
|
|
||||||
<i class="bi bi-{% if has_dct %}phone{% else %}hdd{% endif %} me-1"></i>{% if has_dct %}Survives social media compression{% else %}Higher capacity for direct transfers{% endif %}
|
<!-- Mode & Carrier Type toggles (aligned row) -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div id="imageModeGroup">
|
||||||
|
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
|
||||||
|
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
|
||||||
|
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb" {% if not has_dct %}checked{% endif %}>
|
||||||
|
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted d-none d-sm-inline">|</span>
|
||||||
|
<span class="d-flex gap-2 align-items-center" id="outputOptions">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="dct_color_mode" id="colorMode" value="color" checked>
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="colorMode">Color</label>
|
||||||
|
<input type="radio" class="btn-check" name="dct_color_mode" id="grayMode" value="grayscale">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="grayMode" id="grayModeLabel">Gray</label>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="dct_output_format" id="jpegFormat" value="jpeg" checked>
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="jpegFormat" id="jpegFormatLabel">JPEG</label>
|
||||||
|
<input type="radio" class="btn-check" name="dct_output_format" id="pngFormat" value="png">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="pngFormat">PNG</label>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Audio Modes (hidden by default) -->
|
||||||
|
<div class="d-none" id="audioModeGroup">
|
||||||
|
<div class="btn-group btn-group-sm mb-2" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioLsb" value="audio_lsb">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeAudioLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||||
|
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioSpread" value="audio_spread">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeAudioSpread"><i class="bi bi-broadcast me-1"></i>Spread</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text" id="modeHint">
|
||||||
|
<i class="bi bi-{% if has_dct %}phone{% else %}hdd{% endif %} me-1"></i>{% if has_dct %}Survives social media compression{% else %}Higher capacity for direct transfers{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="carrier_type_select" id="typeImage" value="image" checked>
|
||||||
|
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="typeImage"><i class="bi bi-image me-1"></i>Image</label>
|
||||||
|
<input type="radio" class="btn-check" name="carrier_type_select" id="typeAudio" value="audio" {% if not has_audio %}disabled{% endif %}>
|
||||||
|
<label class="btn btn-outline-secondary btn-sm text-nowrap {% if not has_audio %}disabled text-muted{% endif %}" for="typeAudio"><i class="bi bi-music-note-beamed me-1"></i>Audio</label>
|
||||||
|
</div>
|
||||||
|
{% if not has_audio %}
|
||||||
|
<span class="form-text text-warning mb-0" style="font-size: 0.7rem;"><i class="bi bi-exclamation-triangle me-1"></i>Requires numpy + soundfile</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -449,7 +513,9 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
const modeHints = {
|
const modeHints = {
|
||||||
dct: { icon: 'phone', text: 'Survives social media compression' },
|
dct: { icon: 'phone', text: 'Survives social media compression' },
|
||||||
lsb: { icon: 'hdd', text: 'Higher capacity, outputs Color PNG' }
|
lsb: { icon: 'hdd', text: 'Higher capacity, outputs Color PNG' },
|
||||||
|
audio_lsb: { icon: 'soundwave', text: 'Highest capacity, lossless carriers only (WAV/FLAC)' },
|
||||||
|
audio_spread: { icon: 'broadcast', text: 'Lower capacity, survives lossy conversion (MP3/AAC)' }
|
||||||
};
|
};
|
||||||
|
|
||||||
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
||||||
@@ -462,13 +528,125 @@ document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CARRIER TYPE TOGGLE (v4.3.0)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const carrierTypeRadios = document.querySelectorAll('input[name="carrier_type_select"]');
|
||||||
|
const carrierTypeInput = document.getElementById('carrierTypeInput');
|
||||||
|
const imageCarrierSection = document.getElementById('imageCarrierSection');
|
||||||
|
const audioCarrierSection = document.getElementById('audioCarrierSection');
|
||||||
|
const imageModeGroup = document.getElementById('imageModeGroup');
|
||||||
|
const audioModeGroup = document.getElementById('audioModeGroup');
|
||||||
|
const capacityPanel = document.getElementById('capacityPanel');
|
||||||
|
const audioCapacityPanel = document.getElementById('audioCapacityPanel');
|
||||||
|
|
||||||
|
carrierTypeRadios.forEach(radio => {
|
||||||
|
radio.addEventListener('change', function() {
|
||||||
|
const isAudio = this.value === 'audio';
|
||||||
|
carrierTypeInput.value = this.value;
|
||||||
|
|
||||||
|
// Toggle carrier sections
|
||||||
|
if (imageCarrierSection) imageCarrierSection.classList.toggle('d-none', isAudio);
|
||||||
|
if (audioCarrierSection) audioCarrierSection.classList.toggle('d-none', !isAudio);
|
||||||
|
|
||||||
|
// Toggle required attribute so hidden inputs don't block form submission
|
||||||
|
const imgCarrier = document.getElementById('carrierInput');
|
||||||
|
const audCarrier = document.getElementById('audioCarrierInput');
|
||||||
|
if (imgCarrier) { if (isAudio) imgCarrier.removeAttribute('required'); else imgCarrier.setAttribute('required', ''); }
|
||||||
|
if (audCarrier) { if (isAudio) audCarrier.setAttribute('required', ''); else audCarrier.removeAttribute('required'); }
|
||||||
|
|
||||||
|
// Toggle mode groups
|
||||||
|
if (imageModeGroup) imageModeGroup.classList.toggle('d-none', isAudio);
|
||||||
|
if (audioModeGroup) audioModeGroup.classList.toggle('d-none', !isAudio);
|
||||||
|
|
||||||
|
// Toggle capacity panels
|
||||||
|
if (capacityPanel) capacityPanel.classList.add('d-none');
|
||||||
|
if (audioCapacityPanel) audioCapacityPanel.classList.add('d-none');
|
||||||
|
|
||||||
|
// Select default mode for the active type and update hint
|
||||||
|
if (isAudio) {
|
||||||
|
const audioLsb = document.getElementById('modeAudioLsb');
|
||||||
|
if (audioLsb) { audioLsb.checked = true; audioLsb.dispatchEvent(new Event('change')); }
|
||||||
|
} else {
|
||||||
|
// Reset to DCT if available, else LSB
|
||||||
|
const dctRadio = document.getElementById('modeDct');
|
||||||
|
const lsbRadio = document.getElementById('modeLsb');
|
||||||
|
if (dctRadio && !dctRadio.disabled) {
|
||||||
|
dctRadio.checked = true; dctRadio.dispatchEvent(new Event('change'));
|
||||||
|
} else if (lsbRadio) {
|
||||||
|
lsbRadio.checked = true; lsbRadio.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear carrier file selections
|
||||||
|
const carrierInput = document.getElementById('carrierInput');
|
||||||
|
const audioCarrierInput = document.getElementById('audioCarrierInput');
|
||||||
|
if (carrierInput) carrierInput.value = '';
|
||||||
|
if (audioCarrierInput) audioCarrierInput.value = '';
|
||||||
|
|
||||||
|
// Reset previews
|
||||||
|
document.getElementById('carrierPreview')?.classList.add('d-none');
|
||||||
|
|
||||||
|
// Update step title
|
||||||
|
const stepImagesTitle = document.querySelector('#stepImages')?.closest('.accordion-item')?.querySelector('.accordion-button .step-title');
|
||||||
|
if (stepImagesTitle) {
|
||||||
|
const icon = stepImagesTitle.querySelector('i:not(.step-number i)');
|
||||||
|
const textNode = stepImagesTitle.childNodes[stepImagesTitle.childNodes.length - 1];
|
||||||
|
if (icon) {
|
||||||
|
icon.className = isAudio ? 'bi bi-music-note-beamed me-1' : 'bi bi-images me-1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImagesSummary();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audio carrier file change handler
|
||||||
|
const audioCarrierInput = document.getElementById('audioCarrierInput');
|
||||||
|
audioCarrierInput?.addEventListener('change', function() {
|
||||||
|
if (this.files && this.files[0]) {
|
||||||
|
const file = this.files[0];
|
||||||
|
document.getElementById('audioCarrierFileName').textContent = file.name;
|
||||||
|
document.getElementById('audioCarrierFileSize').textContent = (file.size / 1024).toFixed(1) + ' KB';
|
||||||
|
|
||||||
|
// Fetch audio capacity
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('carrier', file);
|
||||||
|
fetch('/api/audio-capacity', { method: 'POST', body: formData })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) return;
|
||||||
|
const info = `${data.format || 'Audio'} · ${data.sample_rate}Hz · ${data.channels}ch · ${data.duration}s`;
|
||||||
|
document.getElementById('audioInfo').textContent = info;
|
||||||
|
document.getElementById('lsbAudioCapacityBadge').textContent = `LSB: ${(data.lsb_capacity / 1024).toFixed(1)} KB`;
|
||||||
|
document.getElementById('spreadCapacityBadge').textContent = `Spread: ${(data.spread_capacity / 1024).toFixed(1)} KB`;
|
||||||
|
document.getElementById('audioCapacityPanel')?.classList.remove('d-none');
|
||||||
|
if (data.duration) {
|
||||||
|
document.getElementById('audioCarrierDuration').textContent = data.duration + 's duration';
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Trigger the drop zone animation
|
||||||
|
const dropZone = document.getElementById('audioCarrierDropZone');
|
||||||
|
if (dropZone) {
|
||||||
|
dropZone.classList.add('has-file');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImagesSummary();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ACCORDION SUMMARY UPDATES
|
// ACCORDION SUMMARY UPDATES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function updateImagesSummary() {
|
function updateImagesSummary() {
|
||||||
const ref = document.getElementById('refPhotoInput')?.files[0];
|
const ref = document.getElementById('refPhotoInput')?.files[0];
|
||||||
const carrier = document.getElementById('carrierInput')?.files[0];
|
const isAudio = carrierTypeInput?.value === 'audio';
|
||||||
|
const carrier = isAudio
|
||||||
|
? document.getElementById('audioCarrierInput')?.files[0]
|
||||||
|
: document.getElementById('carrierInput')?.files[0];
|
||||||
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'LSB';
|
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'LSB';
|
||||||
const summary = document.getElementById('stepImagesSummary');
|
const summary = document.getElementById('stepImagesSummary');
|
||||||
const stepNum = document.getElementById('stepImagesNumber');
|
const stepNum = document.getElementById('stepImagesNumber');
|
||||||
@@ -486,7 +664,7 @@ function updateImagesSummary() {
|
|||||||
stepNum.classList.remove('complete');
|
stepNum.classList.remove('complete');
|
||||||
stepNum.textContent = '1';
|
stepNum.textContent = '1';
|
||||||
} else {
|
} else {
|
||||||
summary.textContent = 'Select reference & carrier';
|
summary.textContent = isAudio ? 'Select reference & audio' : 'Select reference & carrier';
|
||||||
summary.classList.remove('has-content');
|
summary.classList.remove('has-content');
|
||||||
stepNum.classList.remove('complete');
|
stepNum.classList.remove('complete');
|
||||||
stepNum.textContent = '1';
|
stepNum.textContent = '1';
|
||||||
@@ -550,7 +728,9 @@ function updateSecuritySummary() {
|
|||||||
// Attach listeners
|
// Attach listeners
|
||||||
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
|
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
|
||||||
document.getElementById('carrierInput')?.addEventListener('change', updateImagesSummary);
|
document.getElementById('carrierInput')?.addEventListener('change', updateImagesSummary);
|
||||||
|
document.getElementById('audioCarrierInput')?.addEventListener('change', updateImagesSummary);
|
||||||
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||||
|
document.querySelectorAll('#audioModeGroup input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||||
|
|
||||||
document.getElementById('messageInput')?.addEventListener('input', updatePayloadSummary);
|
document.getElementById('messageInput')?.addEventListener('input', updatePayloadSummary);
|
||||||
document.getElementById('payloadFileInput')?.addEventListener('change', updatePayloadSummary);
|
document.getElementById('payloadFileInput')?.addEventListener('change', updatePayloadSummary);
|
||||||
|
|||||||
@@ -12,12 +12,26 @@
|
|||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
|
{% if carrier_type == 'audio' %}
|
||||||
|
<!-- Audio Preview -->
|
||||||
|
<div class="my-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="bi bi-music-note-beamed text-success" style="font-size: 4rem;"></i>
|
||||||
|
<div class="mt-2">
|
||||||
|
<audio controls src="{{ url_for('encode_file_route', file_id=file_id) }}" class="w-100" style="max-width: 400px;"></audio>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 small text-muted">
|
||||||
|
<i class="bi bi-music-note-beamed me-1"></i>Encoded Audio Preview
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
<div class="my-4">
|
<div class="my-4">
|
||||||
{% if thumbnail_url %}
|
{% if thumbnail_url %}
|
||||||
<!-- Thumbnail of the actual encoded image -->
|
<!-- Thumbnail of the actual encoded image -->
|
||||||
<div class="encoded-image-thumbnail">
|
<div class="encoded-image-thumbnail">
|
||||||
<img src="{{ thumbnail_url }}"
|
<img src="{{ thumbnail_url }}"
|
||||||
alt="Encoded image thumbnail"
|
alt="Encoded image thumbnail"
|
||||||
class="img-thumbnail rounded"
|
class="img-thumbnail rounded"
|
||||||
style="max-width: 250px; max-height: 250px; object-fit: contain;">
|
style="max-width: 250px; max-height: 250px; object-fit: contain;">
|
||||||
<div class="mt-2 small text-muted">
|
<div class="mt-2 small text-muted">
|
||||||
@@ -29,8 +43,9 @@
|
|||||||
<i class="bi bi-file-earmark-image text-success" style="font-size: 4rem;"></i>
|
<i class="bi bi-file-earmark-image text-success" style="font-size: 4rem;"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<p class="lead mb-4">Your secret has been hidden in the image.</p>
|
<p class="lead mb-4">Your secret has been hidden in the {{ 'audio file' if carrier_type == 'audio' else 'image' }}.</p>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<code class="fs-5">{{ filename }}</code>
|
<code class="fs-5">{{ filename }}</code>
|
||||||
@@ -38,11 +53,32 @@
|
|||||||
|
|
||||||
<!-- Mode and format badges -->
|
<!-- Mode and format badges -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
{% if embed_mode == 'dct' %}
|
{% if carrier_type == 'audio' %}
|
||||||
|
<!-- Audio mode badges -->
|
||||||
|
{% if embed_mode == 'audio_spread' %}
|
||||||
|
<span class="badge bg-warning text-dark fs-6">
|
||||||
|
<i class="bi bi-broadcast me-1"></i>Spread Spectrum
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-primary fs-6">
|
||||||
|
<i class="bi bi-grid-3x3-gap me-1"></i>Audio LSB
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="badge bg-info fs-6 ms-1">
|
||||||
|
<i class="bi bi-file-earmark-music me-1"></i>WAV
|
||||||
|
</span>
|
||||||
|
<div class="small text-muted mt-2">
|
||||||
|
{% if embed_mode == 'audio_spread' %}
|
||||||
|
Spread spectrum embedding in audio samples
|
||||||
|
{% else %}
|
||||||
|
LSB embedding in audio samples, WAV output
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif embed_mode == 'dct' %}
|
||||||
<span class="badge bg-info fs-6">
|
<span class="badge bg-info fs-6">
|
||||||
<i class="bi bi-soundwave me-1"></i>DCT Mode
|
<i class="bi bi-soundwave me-1"></i>DCT Mode
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Color mode badge (v3.0.1) -->
|
<!-- Color mode badge (v3.0.1) -->
|
||||||
{% if color_mode == 'color' %}
|
{% if color_mode == 'color' %}
|
||||||
<span class="badge bg-success fs-6 ms-1">
|
<span class="badge bg-success fs-6 ms-1">
|
||||||
@@ -53,7 +89,7 @@
|
|||||||
<i class="bi bi-circle-half me-1"></i>Grayscale
|
<i class="bi bi-circle-half me-1"></i>Grayscale
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Output format badge -->
|
<!-- Output format badge -->
|
||||||
{% if output_format == 'jpeg' %}
|
{% if output_format == 'jpeg' %}
|
||||||
<span class="badge bg-warning text-dark fs-6 ms-1">
|
<span class="badge bg-warning text-dark fs-6 ms-1">
|
||||||
@@ -78,7 +114,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-primary fs-6">
|
<span class="badge bg-primary fs-6">
|
||||||
<i class="bi bi-grid-3x3-gap me-1"></i>LSB Mode
|
<i class="bi bi-grid-3x3-gap me-1"></i>LSB Mode
|
||||||
@@ -114,7 +150,7 @@
|
|||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<a href="{{ url_for('encode_download', file_id=file_id) }}"
|
<a href="{{ url_for('encode_download', file_id=file_id) }}"
|
||||||
class="btn btn-primary btn-lg" id="downloadBtn">
|
class="btn btn-primary btn-lg" id="downloadBtn">
|
||||||
<i class="bi bi-download me-2"></i>Download Image
|
<i class="bi bi-download me-2"></i>Download {{ 'Audio' if carrier_type == 'audio' else 'Image' }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button type="button" class="btn btn-outline-primary" id="shareBtn" style="display: none;">
|
<button type="button" class="btn btn-outline-primary" id="shareBtn" style="display: none;">
|
||||||
@@ -128,7 +164,12 @@
|
|||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
<strong>Important:</strong>
|
<strong>Important:</strong>
|
||||||
<ul class="mb-0 mt-2">
|
<ul class="mb-0 mt-2">
|
||||||
<li>This file expires in <strong>5 minutes</strong></li>
|
<li>This file expires in <strong>10 minutes</strong></li>
|
||||||
|
{% if carrier_type == 'audio' %}
|
||||||
|
<li>Do <strong>not</strong> re-encode or convert the audio file</li>
|
||||||
|
<li>WAV format preserves your hidden data losslessly</li>
|
||||||
|
<li>Sharing via platforms that re-encode audio will destroy the hidden data</li>
|
||||||
|
{% else %}
|
||||||
<li>Do <strong>not</strong> resize or recompress the image</li>
|
<li>Do <strong>not</strong> resize or recompress the image</li>
|
||||||
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
|
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
|
||||||
<li>JPEG format is lossy - avoid re-saving or editing</li>
|
<li>JPEG format is lossy - avoid re-saving or editing</li>
|
||||||
@@ -141,6 +182,7 @@
|
|||||||
<li>Color preserved - extraction works on both color and grayscale</li>
|
<li>Color preserved - extraction works on both color and grayscale</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% if channel_mode == 'private' %}
|
{% if channel_mode == 'private' %}
|
||||||
<li><i class="bi bi-shield-lock text-warning me-1"></i>Recipient needs the <strong>same channel key</strong> to decode</li>
|
<li><i class="bi bi-shield-lock text-warning me-1"></i>Recipient needs the <strong>same channel key</strong> to decode</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -148,7 +190,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="{{ url_for('encode_page') }}" class="btn btn-outline-secondary">
|
<a href="{{ url_for('encode_page') }}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-repeat me-2"></i>Encode Another Message
|
<i class="bi bi-arrow-repeat me-2"></i>Encode Another
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,7 +204,7 @@
|
|||||||
const shareBtn = document.getElementById('shareBtn');
|
const shareBtn = document.getElementById('shareBtn');
|
||||||
const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}";
|
const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}";
|
||||||
const fileName = "{{ filename }}";
|
const fileName = "{{ filename }}";
|
||||||
const mimeType = "{{ 'image/jpeg' if embed_mode == 'dct' and output_format == 'jpeg' else 'image/png' }}";
|
const mimeType = "{{ 'audio/wav' if carrier_type == 'audio' else ('image/jpeg' if embed_mode == 'dct' and output_format == 'jpeg' else 'image/png') }}";
|
||||||
|
|
||||||
if (navigator.share && navigator.canShare) {
|
if (navigator.share && navigator.canShare) {
|
||||||
// Check if we can share files
|
// Check if we can share files
|
||||||
|
|||||||
@@ -65,11 +65,7 @@
|
|||||||
<select name="rsa_bits" class="form-select form-select-sm" id="rsaBitsSelect">
|
<select name="rsa_bits" class="form-select form-select-sm" id="rsaBitsSelect">
|
||||||
<option value="2048" selected>2048 bits (~128 bits entropy)</option>
|
<option value="2048" selected>2048 bits (~128 bits entropy)</option>
|
||||||
<option value="3072">3072 bits (~128 bits entropy)</option>
|
<option value="3072">3072 bits (~128 bits entropy)</option>
|
||||||
<option value="4096">4096 bits (~128 bits entropy)</option>
|
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text text-warning d-none" id="rsaQrWarning">
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>QR code unavailable for keys >3072 bits
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,12 +282,6 @@
|
|||||||
<i class="bi bi-shield-exclamation me-1"></i>
|
<i class="bi bi-shield-exclamation me-1"></i>
|
||||||
<strong>Security note:</strong> The QR code contains your unencrypted private key.
|
<strong>Security note:</strong> The QR code contains your unencrypted private key.
|
||||||
Only scan in a secure environment. Consider using the password-protected download instead.
|
Only scan in a secure environment. Consider using the password-protected download instead.
|
||||||
{% if rsa_bits >= 4096 %}
|
|
||||||
<br><br>
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
||||||
<strong>4096-bit keys</strong> produce very dense QR codes. If scanning fails,
|
|
||||||
use the PEM text or download options instead.
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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, '"')}">
|
||||||
|
<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);
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "stegasoo"
|
name = "stegasoo"
|
||||||
version = "4.1.5"
|
version = "4.3.0"
|
||||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.11"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Aaron D. Lee" }
|
{ name = "Aaron D. Lee" }
|
||||||
]
|
]
|
||||||
@@ -29,9 +29,10 @@ classifiers = [
|
|||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
"Topic :: Security :: Cryptography",
|
"Topic :: Security :: Cryptography",
|
||||||
"Topic :: Multimedia :: Graphics",
|
"Topic :: Multimedia :: Graphics",
|
||||||
]
|
]
|
||||||
@@ -40,6 +41,7 @@ dependencies = [
|
|||||||
"pillow>=10.0.0",
|
"pillow>=10.0.0",
|
||||||
"cryptography>=41.0.0",
|
"cryptography>=41.0.0",
|
||||||
"argon2-cffi>=23.0.0",
|
"argon2-cffi>=23.0.0",
|
||||||
|
"zstandard>=0.22.0", # v4.2.0: Default compression algorithm
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -47,7 +49,14 @@ dependencies = [
|
|||||||
dct = [
|
dct = [
|
||||||
"numpy>=2.0.0",
|
"numpy>=2.0.0",
|
||||||
"scipy>=1.10.0",
|
"scipy>=1.10.0",
|
||||||
"jpegio>=0.2.0",
|
"jpeglib>=1.0.0",
|
||||||
|
"reedsolo>=1.7.0",
|
||||||
|
]
|
||||||
|
audio = [
|
||||||
|
"pydub>=0.25.0",
|
||||||
|
"numpy>=2.0.0",
|
||||||
|
"scipy>=1.10.0",
|
||||||
|
"soundfile>=0.12.0",
|
||||||
"reedsolo>=1.7.0",
|
"reedsolo>=1.7.0",
|
||||||
]
|
]
|
||||||
cli = [
|
cli = [
|
||||||
@@ -57,7 +66,7 @@ cli = [
|
|||||||
"rich>=13.0.0",
|
"rich>=13.0.0",
|
||||||
]
|
]
|
||||||
compression = [
|
compression = [
|
||||||
"lz4>=4.0.0",
|
"lz4>=4.0.0", # Optional: faster but slightly worse ratio than zstd
|
||||||
]
|
]
|
||||||
web = [
|
web = [
|
||||||
"flask>=3.0.0",
|
"flask>=3.0.0",
|
||||||
@@ -68,7 +77,7 @@ web = [
|
|||||||
# Include DCT support for web UI
|
# Include DCT support for web UI
|
||||||
"numpy>=2.0.0",
|
"numpy>=2.0.0",
|
||||||
"scipy>=1.10.0",
|
"scipy>=1.10.0",
|
||||||
"jpegio>=0.2.0",
|
"jpeglib>=1.0.0",
|
||||||
"reedsolo>=1.7.0",
|
"reedsolo>=1.7.0",
|
||||||
]
|
]
|
||||||
api = [
|
api = [
|
||||||
@@ -80,11 +89,11 @@ api = [
|
|||||||
# Include DCT support for API
|
# Include DCT support for API
|
||||||
"numpy>=2.0.0",
|
"numpy>=2.0.0",
|
||||||
"scipy>=1.10.0",
|
"scipy>=1.10.0",
|
||||||
"jpegio>=0.2.0",
|
"jpeglib>=1.0.0",
|
||||||
"reedsolo>=1.7.0",
|
"reedsolo>=1.7.0",
|
||||||
]
|
]
|
||||||
all = [
|
all = [
|
||||||
"stegasoo[cli,web,api,dct,compression]",
|
"stegasoo[cli,web,api,dct,audio,compression]",
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"stegasoo[all]",
|
"stegasoo[all]",
|
||||||
@@ -110,7 +119,14 @@ include = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/stegasoo"]
|
packages = ["src/stegasoo", "frontends"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel.sources]
|
||||||
|
"src" = ""
|
||||||
|
|
||||||
|
# Include data files in the wheel
|
||||||
|
[tool.hatch.build.targets.wheel.force-include]
|
||||||
|
"src/stegasoo/data/bip39-words.txt" = "stegasoo/data/bip39-words.txt"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
@@ -119,7 +135,7 @@ addopts = "-v --cov=stegasoo --cov-report=term-missing"
|
|||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = ["py310", "py311", "py312"]
|
target-version = ["py311", "py312", "py313"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
@@ -132,11 +148,13 @@ ignore = ["E501"]
|
|||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
# YCbCr colorspace variables (R, G, B, Y, Cb, Cr) are standard names
|
# YCbCr colorspace variables (R, G, B, Y, Cb, Cr) are standard names
|
||||||
"src/stegasoo/dct_steganography.py" = ["N803", "N806"]
|
"src/stegasoo/dct_steganography.py" = ["N803", "N806"]
|
||||||
|
# MDCT transform variables (N, X) are standard mathematical names
|
||||||
|
"src/stegasoo/spread_steganography.py" = ["N803", "N806"]
|
||||||
# Package __init__.py has imports after try/except and aliases - intentional structure
|
# Package __init__.py has imports after try/except and aliases - intentional structure
|
||||||
"src/stegasoo/__init__.py" = ["E402"]
|
"src/stegasoo/__init__.py" = ["E402"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.10"
|
python_version = "3.11"
|
||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unused_configs = true
|
warn_unused_configs = true
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|||||||
@@ -26,51 +26,50 @@ ssh admin@stegasoo.local
|
|||||||
## Step 3: Pre-Setup
|
## Step 3: Pre-Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Take ownership of /opt (for pyenv, jpegio builds)
|
# Take ownership of /opt
|
||||||
sudo chown admin:admin /opt
|
sudo chown admin:admin /opt
|
||||||
|
|
||||||
# Install git and zstd (not included in Lite image)
|
# Install git (not included in Lite image)
|
||||||
sudo apt-get update && sudo apt-get install -y git zstd jq
|
sudo apt-get update && sudo apt-get install -y git
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 4: Clone Repo
|
## Step 4: Clone Repo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt
|
cd /opt
|
||||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 5: Copy Pre-built Tarball (from host)
|
## Step 5: Run Setup
|
||||||
|
|
||||||
> **Dev-only asset:** This tarball is for building Pi images, not for end users.
|
|
||||||
> It's available on [Releases](https://github.com/adlee-was-taken/stegasoo/releases) for image builders.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# On your host machine:
|
|
||||||
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
|
||||||
```
|
|
||||||
|
|
||||||
This tarball contains:
|
|
||||||
- pyenv with Python 3.12 (pre-compiled for ARM64)
|
|
||||||
- venv with all dependencies (jpegio, scipy, etc.)
|
|
||||||
|
|
||||||
Install time: **~2 minutes** (vs 20+ min from source)
|
|
||||||
|
|
||||||
## Step 6: Run Setup
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/stegasoo
|
cd /opt/stegasoo
|
||||||
./rpi/setup.sh # Detects local tarball, skips download
|
./rpi/setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### From-Source Build (optional)
|
The setup script:
|
||||||
|
- Verifies Python 3.11+ (system Python, no pyenv needed)
|
||||||
|
- Installs dependencies via apt and pip
|
||||||
|
- jpeglib installs cleanly (no ARM patching like jpegio)
|
||||||
|
- Creates and enables systemd service
|
||||||
|
|
||||||
|
Install time: **5-10 minutes** (from source)
|
||||||
|
|
||||||
|
### Pre-built Venv (optional)
|
||||||
|
|
||||||
|
For faster installs, you can provide a pre-built venv tarball:
|
||||||
|
|
||||||
To build without the pre-built tarball:
|
|
||||||
```bash
|
```bash
|
||||||
./rpi/setup.sh --no-prebuilt # Takes 15-20 minutes
|
# On your host machine:
|
||||||
|
scp rpi/stegasoo-rpi-venv-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
||||||
|
|
||||||
|
# Then on Pi:
|
||||||
|
cd /opt/stegasoo && ./rpi/setup.sh # Detects local tarball, skips pip build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 7: Test It Works
|
Install time with pre-built: **~2 minutes**
|
||||||
|
|
||||||
|
## Step 6: Test It Works
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl start stegasoo
|
sudo systemctl start stegasoo
|
||||||
@@ -78,7 +77,7 @@ curl -k https://localhost:5000
|
|||||||
# Should return HTML
|
# Should return HTML
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 8: Sanitize for Distribution
|
## Step 7: Sanitize for Distribution
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Full sanitize (for final image - removes WiFi, shuts down)
|
# Full sanitize (for final image - removes WiFi, shuts down)
|
||||||
@@ -98,7 +97,7 @@ This removes:
|
|||||||
|
|
||||||
The script validates all cleanup steps before finishing.
|
The script validates all cleanup steps before finishing.
|
||||||
|
|
||||||
## Step 9: Pull the Image
|
## Step 8: Pull the Image
|
||||||
|
|
||||||
Remove SD card, insert into your Linux machine:
|
Remove SD card, insert into your Linux machine:
|
||||||
|
|
||||||
@@ -107,12 +106,12 @@ Remove SD card, insert into your Linux machine:
|
|||||||
lsblk
|
lsblk
|
||||||
|
|
||||||
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
The script automatically resizes rootfs to 16GB (for smaller download), preserves auto-expand, and compresses.
|
The script automatically resizes rootfs to 16GB (for smaller download), preserves auto-expand, and compresses.
|
||||||
|
|
||||||
## Step 10: Distribute
|
## Step 9: Distribute
|
||||||
|
|
||||||
Upload `.img.zst` to GitHub Releases.
|
Upload `.img.zst` to GitHub Releases.
|
||||||
|
|
||||||
@@ -130,36 +129,31 @@ zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Creating the Pre-built Tarball
|
## Creating the Pre-built Venv Tarball
|
||||||
|
|
||||||
After a successful from-source build, create the pre-built tarball for future installs:
|
After a successful from-source build, create the pre-built tarball for future installs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On the Pi after successful setup:
|
# On the Pi after successful setup:
|
||||||
cd ~
|
cd /opt/stegasoo
|
||||||
|
|
||||||
# Strip caches and tests from venv (295MB → 208MB)
|
# Strip caches and tests from venv (saves ~100MB)
|
||||||
find /opt/stegasoo/venv/ -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
|
find venv/ -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
|
||||||
find /opt/stegasoo/venv/ -type d -name 'tests' -exec rm -rf {} + 2>/dev/null
|
find venv/ -type d -name 'tests' -exec rm -rf {} + 2>/dev/null
|
||||||
find /opt/stegasoo/venv/ -type d -name 'test' -exec rm -rf {} + 2>/dev/null
|
find venv/ -type d -name 'test' -exec rm -rf {} + 2>/dev/null
|
||||||
|
|
||||||
# Create venv tarball
|
# Create venv tarball
|
||||||
cd /opt/stegasoo
|
tar -cf - venv/ | zstd -19 -T0 > /tmp/stegasoo-rpi-venv-arm64.tar.zst
|
||||||
tar -cf - venv/ | zstd -19 -T0 > ~/stegasoo-venv.tar.zst
|
|
||||||
|
|
||||||
# Create combined tarball (pyenv + venv pointer)
|
# Check size (should be ~40-50MB)
|
||||||
cd ~
|
ls -lh /tmp/stegasoo-rpi-venv-arm64.tar.zst
|
||||||
tar -cf - .pyenv stegasoo-venv.tar.zst | zstd -19 -T0 > /tmp/stegasoo-rpi-runtime-env-arm64.tar.zst
|
|
||||||
|
|
||||||
# Check size (should be ~50-60MB)
|
|
||||||
ls -lh /tmp/stegasoo-rpi-runtime-env-arm64.tar.zst
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Pull to host and upload to GitHub releases:
|
Pull to host and upload to GitHub releases:
|
||||||
```bash
|
```bash
|
||||||
# On host:
|
# On host:
|
||||||
scp admin@stegasoo.local:/tmp/stegasoo-rpi-runtime-env-arm64.tar.zst ./
|
scp admin@stegasoo.local:/tmp/stegasoo-rpi-venv-arm64.tar.zst ./rpi/
|
||||||
# Upload to GitHub releases as stegasoo-rpi-runtime-env-arm64.tar.zst
|
# Upload to GitHub releases as stegasoo-rpi-venv-arm64.tar.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -169,18 +163,15 @@ scp admin@stegasoo.local:/tmp/stegasoo-rpi-runtime-env-arm64.tar.zst ./
|
|||||||
```bash
|
```bash
|
||||||
# On Pi (after SSH):
|
# On Pi (after SSH):
|
||||||
sudo chown admin:admin /opt
|
sudo chown admin:admin /opt
|
||||||
sudo apt-get update && sudo apt-get install -y git zstd jq
|
sudo apt-get update && sudo apt-get install -y git
|
||||||
cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
cd /opt && git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||||
|
|
||||||
# On host (copy tarball):
|
# Run setup:
|
||||||
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
|
||||||
|
|
||||||
# On Pi (run setup):
|
|
||||||
cd /opt/stegasoo && ./rpi/setup.sh
|
cd /opt/stegasoo && ./rpi/setup.sh
|
||||||
sudo systemctl start stegasoo
|
sudo systemctl start stegasoo
|
||||||
curl -k https://localhost:5000
|
curl -k https://localhost:5000
|
||||||
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
||||||
|
|
||||||
# On host (pull image - auto-resizes to 16GB):
|
# On host (pull image - auto-resizes to 16GB):
|
||||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Scripts and resources for deploying Stegasoo on Raspberry Pi.
|
|||||||
|
|
||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
On a fresh Raspberry Pi OS Lite (64-bit) installation:
|
On a fresh Raspberry Pi OS (64-bit) installation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pre-setup (git not included in Lite image)
|
# Pre-setup (git not included in Lite image)
|
||||||
@@ -13,16 +13,16 @@ sudo apt-get update && sudo apt-get install -y git
|
|||||||
|
|
||||||
# Clone and run setup
|
# Clone and run setup
|
||||||
cd /opt
|
cd /opt
|
||||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||||
cd stegasoo
|
cd stegasoo
|
||||||
./rpi/setup.sh
|
./rpi/setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## What the Setup Script Does
|
## What the Setup Script Does
|
||||||
|
|
||||||
1. **Installs system dependencies** - build tools, libraries
|
1. **Verifies Python 3.11+** - uses system Python (no pyenv needed)
|
||||||
2. **Installs Python 3.12** - via pyenv (Pi OS ships with 3.13 which is incompatible)
|
2. **Installs system dependencies** - build tools, libraries
|
||||||
3. **Builds jpegio for ARM** - patches x86-specific flags
|
3. **Installs jpeglib** - DCT steganography (Python 3.11-3.14 compatible)
|
||||||
4. **Installs Stegasoo** - with web UI and all dependencies
|
4. **Installs Stegasoo** - with web UI and all dependencies
|
||||||
5. **Creates systemd service** - auto-starts on boot
|
5. **Creates systemd service** - auto-starts on boot
|
||||||
6. **Enables the service** - ready to start
|
6. **Enables the service** - ready to start
|
||||||
@@ -30,11 +30,18 @@ cd stegasoo
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Raspberry Pi 4 or 5
|
- Raspberry Pi 4 or 5
|
||||||
- Raspberry Pi OS Lite (64-bit) - Bookworm or later
|
- Raspberry Pi OS (64-bit) - Bookworm (Python 3.11) or Trixie (Python 3.13)
|
||||||
- 4GB+ RAM recommended (2GB minimum)
|
- 4GB+ RAM recommended (2GB minimum)
|
||||||
- 16GB+ SD card (pre-built images are 16GB)
|
- 16GB+ SD card (pre-built images are 16GB)
|
||||||
- Internet connection
|
- Internet connection
|
||||||
|
|
||||||
|
### Python Compatibility
|
||||||
|
|
||||||
|
| Raspberry Pi OS | Python | Supported |
|
||||||
|
|-----------------|--------|-----------|
|
||||||
|
| Bookworm | 3.11 | Yes |
|
||||||
|
| Trixie | 3.13 | Yes |
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
On a Pi 4 at 2GHz with USB 3.0 NVMe, expect ~60 seconds to encode/decode a 10MB JPEG with full encryption (passphrase + PIN + reference photo).
|
On a Pi 4 at 2GHz with USB 3.0 NVMe, expect ~60 seconds to encode/decode a 10MB JPEG with full encryption (passphrase + PIN + reference photo).
|
||||||
@@ -159,7 +166,7 @@ sudo apt-get update && sudo apt-get install -y git
|
|||||||
|
|
||||||
# Clone and run setup
|
# Clone and run setup
|
||||||
cd /opt
|
cd /opt
|
||||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||||
cd stegasoo
|
cd stegasoo
|
||||||
./rpi/setup.sh
|
./rpi/setup.sh
|
||||||
```
|
```
|
||||||
@@ -200,7 +207,7 @@ After Pi shuts down, remove SD card and on another Linux machine:
|
|||||||
lsblk
|
lsblk
|
||||||
|
|
||||||
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
The `pull-image.sh` script automatically:
|
The `pull-image.sh` script automatically:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
#
|
||||||
# Build Stegasoo Pi Runtime Environment Tarball
|
# Build Stegasoo Pi venv Tarball
|
||||||
# Run this ON THE PI after a successful from-source build
|
# Run this ON THE PI after a successful from-source build
|
||||||
#
|
#
|
||||||
# Creates: stegasoo-rpi-runtime-env-arm64.tar.zst (~50-60MB)
|
# Creates: stegasoo-rpi-venv-arm64.tar.zst (~40-50MB)
|
||||||
# Contains: pyenv + Python 3.12 + venv with all dependencies
|
# Contains: venv with all dependencies (uses system Python 3.11+)
|
||||||
#
|
#
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -16,11 +16,10 @@ YELLOW='\033[1;33m'
|
|||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
|
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
|
||||||
OUTPUT_DIR="${OUTPUT_DIR:-/tmp}"
|
OUTPUT_FILE="${1:-$HOME/stegasoo-rpi-venv-arm64.tar.zst}"
|
||||||
OUTPUT_FILE="$OUTPUT_DIR/stegasoo-rpi-runtime-env-arm64.tar.zst"
|
|
||||||
|
|
||||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||||
echo -e "${GREEN}║ Stegasoo Pi Runtime Tarball Builder ║${NC}"
|
echo -e "${GREEN}║ Stegasoo Pi venv Tarball Builder ║${NC}"
|
||||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -32,13 +31,6 @@ if [[ "$ARCH" != "aarch64" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verify pyenv exists
|
|
||||||
if [[ ! -d "$HOME/.pyenv" ]]; then
|
|
||||||
echo -e "${RED}Error: pyenv not found at ~/.pyenv${NC}"
|
|
||||||
echo "Run a from-source build first: ./rpi/setup.sh --no-prebuilt"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify venv exists
|
# Verify venv exists
|
||||||
if [[ ! -d "$INSTALL_DIR/venv" ]]; then
|
if [[ ! -d "$INSTALL_DIR/venv" ]]; then
|
||||||
echo -e "${RED}Error: venv not found at $INSTALL_DIR/venv${NC}"
|
echo -e "${RED}Error: venv not found at $INSTALL_DIR/venv${NC}"
|
||||||
@@ -47,33 +39,22 @@ if [[ ! -d "$INSTALL_DIR/venv" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 1: Clean caches from venv
|
# Step 1: Clean caches from venv
|
||||||
echo -e "${GREEN}[1/4]${NC} Cleaning caches from venv..."
|
echo -e "${GREEN}[1/2]${NC} Cleaning caches from venv..."
|
||||||
VENV_SIZE_BEFORE=$(du -sh "$INSTALL_DIR/venv" | cut -f1)
|
VENV_SIZE_BEFORE=$(du -sh "$INSTALL_DIR/venv" | cut -f1)
|
||||||
find "$INSTALL_DIR/venv/" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
|
find "$INSTALL_DIR/venv/" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
|
||||||
find "$INSTALL_DIR/venv/" -type d -name 'tests' -exec rm -rf {} + 2>/dev/null || true
|
find "$INSTALL_DIR/venv/" -type d -name 'tests' -exec rm -rf {} + 2>/dev/null || true
|
||||||
find "$INSTALL_DIR/venv/" -type d -name 'test' -exec rm -rf {} + 2>/dev/null || true
|
find "$INSTALL_DIR/venv/" -type d -name 'test' -exec rm -rf {} + 2>/dev/null || true
|
||||||
find "$INSTALL_DIR/venv/" -type f -name '*.pyc' -delete 2>/dev/null || true
|
find "$INSTALL_DIR/venv/" -type f -name '*.pyc' -delete 2>/dev/null || true
|
||||||
VENV_SIZE_AFTER=$(du -sh "$INSTALL_DIR/venv" | cut -f1)
|
VENV_SIZE_AFTER=$(du -sh "$INSTALL_DIR/venv" | cut -f1)
|
||||||
echo " venv: $VENV_SIZE_BEFORE → $VENV_SIZE_AFTER"
|
echo " venv: $VENV_SIZE_BEFORE -> $VENV_SIZE_AFTER"
|
||||||
|
|
||||||
# Step 2: Create venv tarball
|
# Step 2: Create tarball
|
||||||
echo -e "${GREEN}[2/4]${NC} Creating venv tarball..."
|
echo -e "${GREEN}[2/2]${NC} Creating tarball..."
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
tar -cf - venv/ | zstd -19 -T0 > "$HOME/stegasoo-venv.tar.zst"
|
tar -cf - venv/ | zstd -19 -T0 > "$OUTPUT_FILE"
|
||||||
VENV_TAR_SIZE=$(ls -lh "$HOME/stegasoo-venv.tar.zst" | awk '{print $5}')
|
|
||||||
echo " Created: ~/stegasoo-venv.tar.zst ($VENV_TAR_SIZE)"
|
|
||||||
|
|
||||||
# Step 3: Create combined tarball
|
# Summary
|
||||||
echo -e "${GREEN}[3/4]${NC} Creating combined runtime tarball..."
|
|
||||||
cd "$HOME"
|
|
||||||
tar -cf - .pyenv stegasoo-venv.tar.zst | zstd -19 -T0 > "$OUTPUT_FILE"
|
|
||||||
|
|
||||||
# Cleanup intermediate file
|
|
||||||
rm "$HOME/stegasoo-venv.tar.zst"
|
|
||||||
|
|
||||||
# Step 4: Summary
|
|
||||||
FINAL_SIZE=$(ls -lh "$OUTPUT_FILE" | awk '{print $5}')
|
FINAL_SIZE=$(ls -lh "$OUTPUT_FILE" | awk '{print $5}')
|
||||||
echo -e "${GREEN}[4/4]${NC} Done!"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
||||||
echo -e " Output: ${YELLOW}$OUTPUT_FILE${NC}"
|
echo -e " Output: ${YELLOW}$OUTPUT_FILE${NC}"
|
||||||
@@ -83,7 +64,7 @@ echo ""
|
|||||||
echo "To pull to your host machine:"
|
echo "To pull to your host machine:"
|
||||||
echo " scp $(whoami)@$(hostname).local:$OUTPUT_FILE ./"
|
echo " scp $(whoami)@$(hostname).local:$OUTPUT_FILE ./"
|
||||||
echo ""
|
echo ""
|
||||||
echo "To use in setup.sh, copy to:"
|
echo "To use in setup.sh, place at:"
|
||||||
echo " rpi/stegasoo-rpi-runtime-env-arm64.tar.zst"
|
echo " rpi/stegasoo-rpi-venv-arm64.tar.zst"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Or upload to GitHub releases for automatic download."
|
echo "Or upload to GitHub releases for automatic download."
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ if [ -z "$1" ]; then
|
|||||||
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
|
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Examples:"
|
echo "Examples:"
|
||||||
echo " $0 stegasoo-rpi-4.1.1.img.zst # auto-detect SD card"
|
echo " $0 stegasoo-rpi-4.2.1.img.zst # auto-detect SD card"
|
||||||
echo " $0 stegasoo-rpi-4.1.1.img.zst.zip # from GitHub release"
|
echo " $0 stegasoo-rpi-4.2.1.img.zst.zip # from GitHub release"
|
||||||
echo " $0 stegasoo-rpi-4.1.1.img.zst /dev/sdb # specify device"
|
echo " $0 stegasoo-rpi-4.2.1.img.zst /dev/sdb # specify device"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Flash Raspberry Pi image with headless config (Trixie/Bookworm compatible)
|
# Flash Raspberry Pi image with headless config (Trixie/Bookworm compatible)
|
||||||
# Usage: ./flash-stock-img.sh <image.img.xz> <device>
|
# Usage: ./flash-stock-img.sh [-c config.json] <image.img.xz> <device>
|
||||||
# Reads settings from config.json in same directory
|
# Reads settings from config.json in same directory (or specify with -c)
|
||||||
#
|
#
|
||||||
# Uses the same firstrun.sh approach as rpi-imager for compatibility
|
# Uses the same firstrun.sh approach as rpi-imager for compatibility
|
||||||
|
|
||||||
@@ -10,11 +10,31 @@ set -e
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
CONFIG_FILE="$SCRIPT_DIR/config.json"
|
CONFIG_FILE="$SCRIPT_DIR/config.json"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Parse options
|
||||||
|
# ============================================================================
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 [-c config.json] <image.img.xz> <device>"
|
||||||
|
echo " -c FILE Use alternate config file (default: config.json in script dir)"
|
||||||
|
echo "Example: $0 2025-12-04-raspios-trixie-arm64-lite.img.xz /dev/sdb"
|
||||||
|
echo "Example: $0 -c myconfig.json raspios.img.xz /dev/sdb"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while getopts "c:h" opt; do
|
||||||
|
case $opt in
|
||||||
|
c) CONFIG_FILE="$OPTARG" ;;
|
||||||
|
h) usage ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
shift $((OPTIND - 1))
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Load config
|
# Load config
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
if [ ! -f "$CONFIG_FILE" ]; then
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
echo "Error: config.json not found at $CONFIG_FILE"
|
echo "Error: config file not found at $CONFIG_FILE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -38,9 +58,7 @@ echo
|
|||||||
# Validate args
|
# Validate args
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
if [ $# -ne 2 ]; then
|
if [ $# -ne 2 ]; then
|
||||||
echo "Usage: $0 <image.img.xz> <device>"
|
usage
|
||||||
echo "Example: $0 2025-12-04-raspios-trixie-arm64-lite.img.xz /dev/sdb"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
IMAGE="$1"
|
IMAGE="$1"
|
||||||
|
|||||||
@@ -2,56 +2,39 @@
|
|||||||
|
|
||||||
This directory contains patches for dependencies that need modifications to build on ARM64.
|
This directory contains patches for dependencies that need modifications to build on ARM64.
|
||||||
|
|
||||||
|
## Current Status (v4.2+)
|
||||||
|
|
||||||
|
As of Stegasoo 4.2, we use **jpeglib** instead of jpegio. The jpeglib build process is handled inline in `setup.sh` and includes:
|
||||||
|
|
||||||
|
- Cloning from GitHub (PyPI tarball missing headers)
|
||||||
|
- Downloading libjpeg headers for each version (6b through 9f)
|
||||||
|
- Patching setup.py to skip turbo/mozjpeg (need cmake-generated headers)
|
||||||
|
|
||||||
|
See `setup.sh` for the full implementation.
|
||||||
|
|
||||||
|
## Legacy: jpegio Patches (v4.1 and earlier)
|
||||||
|
|
||||||
|
The `jpegio/` directory contains patches for the old jpegio dependency, which required removing x86-specific `-m64` compiler flags. These are no longer used but kept for reference.
|
||||||
|
|
||||||
|
## jpeglib Helper Script
|
||||||
|
|
||||||
|
The `jpeglib/install-jpeglib-arm64.sh` script is a standalone version of the jpeglib build process. It's not used by setup.sh (which has the logic inline) but can be useful for manual testing or debugging.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
patches/
|
patches/
|
||||||
<package>/
|
jpegio/ # Legacy (v4.1) - not used in v4.2+
|
||||||
arm64.patch # Standard unified diff patch file
|
arm64.patch
|
||||||
apply-patch.sh # Script with fallback strategies
|
apply-patch.sh
|
||||||
|
jpeglib/ # Reference script for manual builds
|
||||||
|
install-jpeglib-arm64.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## How It Works
|
## Adding New Patches
|
||||||
|
|
||||||
The `apply-patch.sh` script tries multiple strategies in order:
|
If a new dependency needs ARM64 patches:
|
||||||
|
|
||||||
1. **Patch file** - Apply the `.patch` file using `patch -p1`
|
|
||||||
2. **Sed fallback** - Use sed for simple string replacements
|
|
||||||
3. **Python fallback** - Use regex for flexible pattern matching
|
|
||||||
|
|
||||||
This layered approach handles:
|
|
||||||
- Exact matches (patch file works)
|
|
||||||
- Minor upstream changes (sed catches variations)
|
|
||||||
- Significant changes (Python regex is most flexible)
|
|
||||||
- Already patched files (detected and skipped)
|
|
||||||
|
|
||||||
## Adding a New Patch
|
|
||||||
|
|
||||||
1. Create a directory: `patches/<package>/`
|
1. Create a directory: `patches/<package>/`
|
||||||
2. Create the patch file: `git diff > arm64.patch`
|
2. Add patch files or helper scripts
|
||||||
3. Create `apply-patch.sh` with appropriate fallback logic
|
3. Update `setup.sh` to apply the patch during installation
|
||||||
4. Update `setup.sh` to call the patch script
|
|
||||||
|
|
||||||
## jpegio Patch
|
|
||||||
|
|
||||||
The jpegio library includes x86-specific `-m64` compiler flags that fail on ARM64.
|
|
||||||
The patch removes these flags by replacing:
|
|
||||||
|
|
||||||
```python
|
|
||||||
cargs.append('-m64')
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
pass # ARM64: removed x86-specific -m64 flag
|
|
||||||
```
|
|
||||||
|
|
||||||
## Updating Patches
|
|
||||||
|
|
||||||
When upstream changes break a patch:
|
|
||||||
|
|
||||||
1. Clone the new version
|
|
||||||
2. Make the necessary modifications
|
|
||||||
3. Generate a new patch: `diff -u original modified > arm64.patch`
|
|
||||||
4. Test on a fresh Pi install
|
|
||||||
|
|||||||
57
rpi/patches/jpeglib/install-jpeglib-arm64.sh
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Install jpeglib on ARM64 Linux (Raspberry Pi)
|
||||||
|
# Works around missing headers in the source tarball
|
||||||
|
#
|
||||||
|
# Usage: ./install-jpeglib-arm64.sh
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Installing jpeglib for ARM64..."
|
||||||
|
|
||||||
|
# Create temp directory
|
||||||
|
WORKDIR=$(mktemp -d)
|
||||||
|
cd "$WORKDIR"
|
||||||
|
|
||||||
|
# Download jpeglib source
|
||||||
|
echo " Downloading jpeglib source..."
|
||||||
|
pip download jpeglib==1.0.2 --no-binary :all: --no-deps -d . -q
|
||||||
|
tar -xzf jpeglib-1.0.2.tar.gz
|
||||||
|
cd jpeglib-1.0.2
|
||||||
|
|
||||||
|
# Download official libjpeg sources and copy headers
|
||||||
|
echo " Downloading libjpeg headers..."
|
||||||
|
CJPEGLIB="src/jpeglib/cjpeglib"
|
||||||
|
|
||||||
|
# libjpeg 6b
|
||||||
|
curl -sL "https://www.ijg.org/files/jpegsrc.v6b.tar.gz" | tar -xzf -
|
||||||
|
cp jpeg-6b/*.h "$CJPEGLIB/6b/"
|
||||||
|
|
||||||
|
# libjpeg 7-9f (all use similar headers from 9e)
|
||||||
|
curl -sL "https://www.ijg.org/files/jpegsrc.v9f.tar.gz" | tar -xzf -
|
||||||
|
for v in 7 8 8a 8b 8c 8d 9 9a 9b 9c 9d 9e 9f; do
|
||||||
|
cp jpeg-9f/*.h "$CJPEGLIB/$v/"
|
||||||
|
done
|
||||||
|
|
||||||
|
# libjpeg-turbo versions
|
||||||
|
curl -sL "https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/2.1.0.tar.gz" | tar -xzf -
|
||||||
|
for v in turbo120 turbo130 turbo140 turbo150 turbo200 turbo210; do
|
||||||
|
cp libjpeg-turbo-2.1.0/*.h "$CJPEGLIB/$v/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# mozjpeg versions
|
||||||
|
curl -sL "https://github.com/mozilla/mozjpeg/archive/refs/tags/v4.0.3.tar.gz" | tar -xzf -
|
||||||
|
for v in mozjpeg101 mozjpeg201 mozjpeg300 mozjpeg403; do
|
||||||
|
cp mozjpeg-4.0.3/*.h "$CJPEGLIB/$v/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Build and install
|
||||||
|
echo " Building jpeglib..."
|
||||||
|
pip install . -q
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
cd /
|
||||||
|
rm -rf "$WORKDIR"
|
||||||
|
|
||||||
|
echo " Done! jpeglib installed successfully."
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# Resizes rootfs to 16GB for consistent image size, then pulls
|
# Resizes rootfs to 16GB for consistent image size, then pulls
|
||||||
#
|
#
|
||||||
# Usage: ./pull-image.sh <device> <output.img.zst>
|
# Usage: ./pull-image.sh <device> <output.img.zst>
|
||||||
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.1.5.img.zst
|
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.2.1.img.zst
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -14,9 +14,9 @@ BOLD='\033[1m'
|
|||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
if [ $# -ne 2 ]; then
|
if [ $# -ne 2 ]; then
|
||||||
echo "Usage: $0 <device> <output.img.zst>"
|
echo "Usage: $0 <device> <output.img.zst>"
|
||||||
echo "Example: $0 /dev/sdb stegasoo-rpi-4.1.5.img.zst"
|
echo "Example: $0 /dev/sdb stegasoo-rpi-4.2.1.img.zst"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
DEVICE="$1"
|
DEVICE="$1"
|
||||||
@@ -24,13 +24,13 @@ OUTPUT="$2"
|
|||||||
|
|
||||||
# Check for root
|
# Check for root
|
||||||
if [ "$EUID" -ne 0 ]; then
|
if [ "$EUID" -ne 0 ]; then
|
||||||
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -b "$DEVICE" ]; then
|
if [ ! -b "$DEVICE" ]; then
|
||||||
echo -e "${RED}Error: Device not found: $DEVICE${NC}"
|
echo -e "${RED}Error: Device not found: $DEVICE${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${BOLD}Device info:${NC}"
|
echo -e "${BOLD}Device info:${NC}"
|
||||||
@@ -39,14 +39,14 @@ echo
|
|||||||
|
|
||||||
# Find partitions
|
# Find partitions
|
||||||
if [ -b "${DEVICE}1" ]; then
|
if [ -b "${DEVICE}1" ]; then
|
||||||
BOOT_PART="${DEVICE}1"
|
BOOT_PART="${DEVICE}1"
|
||||||
ROOT_PART="${DEVICE}2"
|
ROOT_PART="${DEVICE}2"
|
||||||
elif [ -b "${DEVICE}p1" ]; then
|
elif [ -b "${DEVICE}p1" ]; then
|
||||||
BOOT_PART="${DEVICE}p1"
|
BOOT_PART="${DEVICE}p1"
|
||||||
ROOT_PART="${DEVICE}p2"
|
ROOT_PART="${DEVICE}p2"
|
||||||
else
|
else
|
||||||
echo -e "${RED}Error: Could not find partitions${NC}"
|
echo -e "${RED}Error: Could not find partitions${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Unmount any mounted partitions
|
# Unmount any mounted partitions
|
||||||
@@ -62,65 +62,65 @@ echo -e "${BOLD}Checking partition size...${NC}"
|
|||||||
|
|
||||||
# Get current partition size in bytes
|
# Get current partition size in bytes
|
||||||
CURRENT_SIZE=$(blockdev --getsize64 "$ROOT_PART")
|
CURRENT_SIZE=$(blockdev --getsize64 "$ROOT_PART")
|
||||||
TARGET_BYTES=$((16 * 1024 * 1024 * 1024)) # 16GB in bytes
|
TARGET_BYTES=$((16 * 1024 * 1024 * 1024)) # 16GB in bytes
|
||||||
CURRENT_GB=$(echo "scale=2; $CURRENT_SIZE / 1073741824" | bc)
|
CURRENT_GB=$(echo "scale=2; $CURRENT_SIZE / 1073741824" | bc)
|
||||||
|
|
||||||
echo " Current rootfs size: ${CURRENT_GB}GB"
|
echo " Current rootfs size: ${CURRENT_GB}GB"
|
||||||
|
|
||||||
if [ "$CURRENT_SIZE" -gt "$TARGET_BYTES" ]; then
|
if [ "$CURRENT_SIZE" -gt "$TARGET_BYTES" ]; then
|
||||||
echo -e "${YELLOW}Resizing rootfs to 16GB...${NC}"
|
echo -e "${YELLOW}Resizing rootfs to 16GB...${NC}"
|
||||||
|
|
||||||
# Get boot partition end in sectors
|
# Get boot partition end in sectors
|
||||||
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||||
|
|
||||||
# Calculate 16GB in sectors (512 byte sectors)
|
# Calculate 16GB in sectors (512 byte sectors)
|
||||||
ROOT_SIZE_SECTORS=33554432
|
ROOT_SIZE_SECTORS=33554432
|
||||||
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||||
|
|
||||||
# SHRINKING: filesystem first, then partition
|
# SHRINKING: filesystem first, then partition
|
||||||
echo " Checking filesystem..."
|
echo " Checking filesystem..."
|
||||||
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||||
|
|
||||||
# Shrink filesystem to 15.5GB (leave room for partition overhead)
|
# Shrink filesystem to 15.5GB (leave room for partition overhead)
|
||||||
echo " Shrinking filesystem to 15500M..."
|
echo " Shrinking filesystem to 15500M..."
|
||||||
resize2fs "$ROOT_PART" 15500M
|
resize2fs "$ROOT_PART" 15500M
|
||||||
|
|
||||||
# Delete and recreate partition 2 with 16GB size
|
# Delete and recreate partition 2 with 16GB size
|
||||||
echo " Shrinking partition to 16GB..."
|
echo " Shrinking partition to 16GB..."
|
||||||
parted -s "$DEVICE" rm 2
|
parted -s "$DEVICE" rm 2
|
||||||
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||||
|
|
||||||
# Refresh partition table
|
# Refresh partition table
|
||||||
partprobe "$DEVICE"
|
partprobe "$DEVICE"
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
# Expand filesystem to fill the partition exactly
|
# Expand filesystem to fill the partition exactly
|
||||||
echo " Expanding filesystem to fill partition..."
|
echo " Expanding filesystem to fill partition..."
|
||||||
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||||
resize2fs "$ROOT_PART"
|
resize2fs "$ROOT_PART"
|
||||||
|
|
||||||
echo -e "${GREEN} Rootfs resized to 16GB${NC}"
|
echo -e "${GREEN} Rootfs resized to 16GB${NC}"
|
||||||
elif [ "$CURRENT_SIZE" -lt "$TARGET_BYTES" ]; then
|
elif [ "$CURRENT_SIZE" -lt "$TARGET_BYTES" ]; then
|
||||||
echo -e "${YELLOW} Rootfs is smaller than 16GB - expanding...${NC}"
|
echo -e "${YELLOW} Rootfs is smaller than 16GB - expanding...${NC}"
|
||||||
|
|
||||||
# Get boot partition end in sectors
|
# Get boot partition end in sectors
|
||||||
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||||
ROOT_SIZE_SECTORS=33554432
|
ROOT_SIZE_SECTORS=33554432
|
||||||
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||||
|
|
||||||
# EXPANDING: partition first, then filesystem
|
# EXPANDING: partition first, then filesystem
|
||||||
parted -s "$DEVICE" rm 2
|
parted -s "$DEVICE" rm 2
|
||||||
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||||
|
|
||||||
partprobe "$DEVICE"
|
partprobe "$DEVICE"
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||||
resize2fs "$ROOT_PART"
|
resize2fs "$ROOT_PART"
|
||||||
|
|
||||||
echo -e "${GREEN} Rootfs expanded to 16GB${NC}"
|
echo -e "${GREEN} Rootfs expanded to 16GB${NC}"
|
||||||
else
|
else
|
||||||
echo -e "${GREEN} Rootfs already ~16GB${NC}"
|
echo -e "${GREEN} Rootfs already ~16GB${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -135,8 +135,8 @@ echo
|
|||||||
END_SECTOR=$(parted -s "$DEVICE" unit s print | grep "^ 2" | awk '{print $3}' | tr -d 's')
|
END_SECTOR=$(parted -s "$DEVICE" unit s print | grep "^ 2" | awk '{print $3}' | tr -d 's')
|
||||||
|
|
||||||
if [ -z "$END_SECTOR" ]; then
|
if [ -z "$END_SECTOR" ]; then
|
||||||
echo -e "${RED}Error: Could not determine partition 2 end sector${NC}"
|
echo -e "${RED}Error: Could not determine partition 2 end sector${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Add a small buffer (1MB = 2048 sectors) for safety
|
# Add a small buffer (1MB = 2048 sectors) for safety
|
||||||
@@ -150,8 +150,8 @@ echo
|
|||||||
|
|
||||||
read -p "Proceed with image pull? [Y/n] " confirm
|
read -p "Proceed with image pull? [Y/n] " confirm
|
||||||
if [[ "$confirm" =~ ^[Nn]$ ]]; then
|
if [[ "$confirm" =~ ^[Nn]$ ]]; then
|
||||||
echo "Aborted."
|
echo "Aborted."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
@@ -159,13 +159,13 @@ echo -e "${GREEN}Pulling image...${NC}"
|
|||||||
echo
|
echo
|
||||||
|
|
||||||
# Use pv if available for progress, otherwise fallback to dd status
|
# Use pv if available for progress, otherwise fallback to dd status
|
||||||
if command -v pv &> /dev/null; then
|
if command -v pv &>/dev/null; then
|
||||||
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS 2>/dev/null | \
|
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS 2>/dev/null |
|
||||||
pv -s $TOTAL_BYTES | \
|
pv -s $TOTAL_BYTES |
|
||||||
zstd -T0 -3 > "$OUTPUT"
|
zstd -T0 -19 --ultra >"$OUTPUT"
|
||||||
else
|
else
|
||||||
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS status=progress | \
|
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS status=progress |
|
||||||
zstd -T0 -3 > "$OUTPUT"
|
zstd -T0 -19 --ultra >"$OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
@@ -178,16 +178,16 @@ ls -lh "$OUTPUT"
|
|||||||
echo
|
echo
|
||||||
read -p "Create .zst.zip wrapper for GitHub? [y/N] " zip_confirm
|
read -p "Create .zst.zip wrapper for GitHub? [y/N] " zip_confirm
|
||||||
if [[ "$zip_confirm" =~ ^[Yy]$ ]]; then
|
if [[ "$zip_confirm" =~ ^[Yy]$ ]]; then
|
||||||
ZIP_OUTPUT="${OUTPUT}.zip"
|
ZIP_OUTPUT="${OUTPUT}.zip"
|
||||||
echo -e "${YELLOW}Creating zip wrapper (store mode, no compression)...${NC}"
|
echo -e "${YELLOW}Creating zip wrapper (store mode, no compression)...${NC}"
|
||||||
zip -0 "$ZIP_OUTPUT" "$OUTPUT"
|
zip -0 "$ZIP_OUTPUT" "$OUTPUT"
|
||||||
echo -e "${GREEN}Done!${NC} Upload this to GitHub Releases:"
|
echo -e "${GREEN}Done!${NC} Upload this to GitHub Releases:"
|
||||||
ls -lh "$ZIP_OUTPUT"
|
ls -lh "$ZIP_OUTPUT"
|
||||||
echo
|
echo
|
||||||
echo "Users can flash with:"
|
echo "Users can flash with:"
|
||||||
echo " sudo ./rpi/flash-image.sh $ZIP_OUTPUT"
|
echo " sudo ./rpi/flash-image.sh $ZIP_OUTPUT"
|
||||||
else
|
else
|
||||||
echo
|
echo
|
||||||
echo "To verify:"
|
echo "To verify:"
|
||||||
echo " zstdcat $OUTPUT | fdisk -l /dev/stdin"
|
echo " zstdcat $OUTPUT | fdisk -l /dev/stdin"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ echo ""
|
|||||||
echo -e "${GREEN}[4/6]${NC} Copying pre-built tarball to Pi..."
|
echo -e "${GREEN}[4/6]${NC} Copying pre-built tarball to Pi..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
TARBALL="$SCRIPT_DIR/stegasoo-rpi-runtime-env-arm64.tar.zst"
|
TARBALL="$SCRIPT_DIR/stegasoo-rpi-venv-arm64.tar.zst"
|
||||||
if [[ -f "$TARBALL" ]]; then
|
if [[ -f "$TARBALL" ]]; then
|
||||||
scp_to_pi "$TARBALL" "/opt/stegasoo/rpi/"
|
scp_to_pi "$TARBALL" "/opt/stegasoo/rpi/"
|
||||||
echo -e " ${GREEN}✓${NC} Tarball copied"
|
echo -e " ${GREEN}✓${NC} Tarball copied"
|
||||||
|
|||||||
@@ -264,49 +264,25 @@ if [ -n "$STEGASOO_DIR" ] && [ -d "$STEGASOO_DIR/venv" ]; then
|
|||||||
echo " Venv broken or stegasoo not installed, rebuilding..."
|
echo " Venv broken or stegasoo not installed, rebuilding..."
|
||||||
rm -rf "$STEGASOO_DIR/venv"
|
rm -rf "$STEGASOO_DIR/venv"
|
||||||
|
|
||||||
# Find Python 3.12 (prefer pyenv, fall back to system)
|
# Find system Python 3.11+ (no pyenv needed)
|
||||||
USER_HOME=$(eval echo "~$STEGASOO_USER")
|
PYTHON_BIN=""
|
||||||
PYENV_PYTHON="$USER_HOME/.pyenv/versions/3.12*/bin/python"
|
for py in python3.14 python3.13 python3.12 python3.11 python3; do
|
||||||
if compgen -G "$PYENV_PYTHON" > /dev/null 2>&1; then
|
if command -v "$py" &>/dev/null; then
|
||||||
PYTHON_BIN=$(ls $PYENV_PYTHON 2>/dev/null | head -1)
|
PYTHON_BIN=$(command -v "$py")
|
||||||
echo " Using pyenv Python: $PYTHON_BIN"
|
break
|
||||||
elif command -v python3.12 &>/dev/null; then
|
|
||||||
PYTHON_BIN="python3.12"
|
|
||||||
echo " Using system Python 3.12"
|
|
||||||
else
|
|
||||||
PYTHON_BIN="python3"
|
|
||||||
echo " Warning: Python 3.12 not found, using $($PYTHON_BIN --version)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
sudo -u "$STEGASOO_USER" "$PYTHON_BIN" -m venv "$STEGASOO_DIR/venv"
|
|
||||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet --upgrade pip setuptools wheel
|
|
||||||
|
|
||||||
# On ARM64, jpegio needs patching before install
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
if [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then
|
|
||||||
echo " Building jpegio for ARM64 (this may take a minute)..."
|
|
||||||
# Install build deps
|
|
||||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet cython numpy
|
|
||||||
JPEGIO_DIR="/tmp/jpegio-build-$$"
|
|
||||||
rm -rf "$JPEGIO_DIR"
|
|
||||||
if git clone https://github.com/dwgoon/jpegio.git "$JPEGIO_DIR" 2>/dev/null; then
|
|
||||||
# Apply patch to remove -m64 flag
|
|
||||||
if [ -f "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
|
|
||||||
bash "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
|
|
||||||
else
|
|
||||||
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
|
|
||||||
fi
|
|
||||||
# Change ownership so user can build
|
|
||||||
chown -R "$STEGASOO_USER:$STEGASOO_USER" "$JPEGIO_DIR"
|
|
||||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install "$JPEGIO_DIR"
|
|
||||||
rm -rf "$JPEGIO_DIR"
|
|
||||||
else
|
|
||||||
echo " Warning: Failed to clone jpegio, DCT mode may not work"
|
|
||||||
fi
|
fi
|
||||||
fi
|
done
|
||||||
|
|
||||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]"
|
if [ -z "$PYTHON_BIN" ]; then
|
||||||
echo " Venv rebuilt and stegasoo installed"
|
echo " Error: Python 3.11+ not found"
|
||||||
|
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo " Using: $PYTHON_BIN ($($PYTHON_BIN --version 2>&1))"
|
||||||
|
sudo -u "$STEGASOO_USER" "$PYTHON_BIN" -m venv "$STEGASOO_DIR/venv"
|
||||||
|
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet --upgrade pip setuptools wheel
|
||||||
|
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]"
|
||||||
|
echo " Venv rebuilt and stegasoo installed"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo " Venv OK"
|
echo " Venv OK"
|
||||||
fi
|
fi
|
||||||
|
|||||||
351
rpi/setup.sh
@@ -4,14 +4,14 @@
|
|||||||
# Tested on: Raspberry Pi 4/5 with Raspberry Pi OS (64-bit)
|
# Tested on: Raspberry Pi 4/5 with Raspberry Pi OS (64-bit)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# curl -sSL https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.1/rpi/setup.sh | bash
|
# curl -sSL https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.2/rpi/setup.sh | bash
|
||||||
# # or
|
# # or
|
||||||
# wget -qO- https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.1/rpi/setup.sh | bash
|
# wget -qO- https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.2/rpi/setup.sh | bash
|
||||||
#
|
#
|
||||||
# What this script does:
|
# What this script does:
|
||||||
# 1. Installs system dependencies
|
# 1. Installs system dependencies
|
||||||
# 2. Installs Python 3.12 via pyenv (Pi OS ships with 3.13 which is incompatible)
|
# 2. Verifies Python 3.11+ (uses system Python)
|
||||||
# 3. Patches and builds jpegio for ARM
|
# 3. Installs jpeglib for DCT steganography (Python 3.11-3.14 compatible)
|
||||||
# 4. Installs Stegasoo with web UI
|
# 4. Installs Stegasoo with web UI
|
||||||
# 5. Creates systemd service for auto-start
|
# 5. Creates systemd service for auto-start
|
||||||
# 6. Enables the service
|
# 6. Enables the service
|
||||||
@@ -75,9 +75,8 @@ show_help() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo " Available variables:"
|
echo " Available variables:"
|
||||||
echo " INSTALL_DIR Install location (default: /opt/stegasoo)"
|
echo " INSTALL_DIR Install location (default: /opt/stegasoo)"
|
||||||
echo " PYTHON_VERSION Python version (default: 3.12)"
|
|
||||||
echo " STEGASOO_REPO Git repo URL"
|
echo " STEGASOO_REPO Git repo URL"
|
||||||
echo " STEGASOO_BRANCH Git branch (default: 4.1)"
|
echo " STEGASOO_BRANCH Git branch (default: 4.2)"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Example:"
|
echo " Example:"
|
||||||
echo " export INSTALL_DIR=\"/home/pi/stegasoo\""
|
echo " export INSTALL_DIR=\"/home/pi/stegasoo\""
|
||||||
@@ -95,10 +94,8 @@ done
|
|||||||
|
|
||||||
# Default configuration
|
# Default configuration
|
||||||
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
|
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
|
||||||
PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
|
|
||||||
STEGASOO_REPO="${STEGASOO_REPO:-https://github.com/adlee-was-taken/stegasoo.git}"
|
STEGASOO_REPO="${STEGASOO_REPO:-https://github.com/adlee-was-taken/stegasoo.git}"
|
||||||
STEGASOO_BRANCH="${STEGASOO_BRANCH:-4.1}"
|
STEGASOO_BRANCH="${STEGASOO_BRANCH:-4.2}"
|
||||||
JPEGIO_REPO="https://github.com/dwgoon/jpegio.git"
|
|
||||||
|
|
||||||
# Load config files (system, then user - user overrides system)
|
# Load config files (system, then user - user overrides system)
|
||||||
for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf"; do
|
for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf"; do
|
||||||
@@ -112,7 +109,7 @@ clear
|
|||||||
print_banner "Raspberry Pi Setup"
|
print_banner "Raspberry Pi Setup"
|
||||||
echo ""
|
echo ""
|
||||||
echo " This will install Stegasoo with full DCT support"
|
echo " This will install Stegasoo with full DCT support"
|
||||||
echo " Estimated time: ~2 minutes (pre-built) or 15-20 min (from source)"
|
echo " Estimated time: ~2 minutes (pre-built) or 5-10 min (from source)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Check if running on ARM
|
# Check if running on ARM
|
||||||
@@ -123,6 +120,63 @@ if [[ "$ARCH" != "aarch64" && "$ARCH" != "arm64" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Python Version Check
|
||||||
|
# =============================================================================
|
||||||
|
echo -e "${GREEN}Checking Python version...${NC}"
|
||||||
|
|
||||||
|
# Find system Python
|
||||||
|
SYSTEM_PYTHON=""
|
||||||
|
for py in python3.14 python3.13 python3.12 python3.11 python3; do
|
||||||
|
if command -v "$py" &>/dev/null; then
|
||||||
|
SYSTEM_PYTHON=$(command -v "$py")
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$SYSTEM_PYTHON" ]; then
|
||||||
|
echo -e "${RED}Error: Python 3 not found.${NC}"
|
||||||
|
echo "Please install Python 3.11 or later:"
|
||||||
|
echo " sudo apt-get install python3"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get version numbers
|
||||||
|
PY_VERSION=$("$SYSTEM_PYTHON" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||||
|
PY_MAJOR=$("$SYSTEM_PYTHON" -c 'import sys; print(sys.version_info.major)')
|
||||||
|
PY_MINOR=$("$SYSTEM_PYTHON" -c 'import sys; print(sys.version_info.minor)')
|
||||||
|
|
||||||
|
echo " Found: $SYSTEM_PYTHON (Python $PY_VERSION)"
|
||||||
|
|
||||||
|
# Check version range (3.11 <= version <= 3.14)
|
||||||
|
if [ "$PY_MAJOR" -ne 3 ]; then
|
||||||
|
echo -e "${RED}Error: Python 3 required, found Python $PY_MAJOR${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$PY_MINOR" -lt 11 ]; then
|
||||||
|
echo -e "${RED}Error: Python 3.11+ required, found Python $PY_VERSION${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Raspberry Pi OS Bookworm ships with Python 3.11."
|
||||||
|
echo "Raspberry Pi OS Trixie ships with Python 3.13."
|
||||||
|
echo ""
|
||||||
|
echo "Please upgrade your Raspberry Pi OS or install Python 3.11+."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$PY_MINOR" -gt 14 ]; then
|
||||||
|
echo -e "${YELLOW}Warning: Python $PY_VERSION detected.${NC}"
|
||||||
|
echo "Stegasoo is tested with Python 3.11-3.14."
|
||||||
|
echo "Newer versions may work but are not officially supported."
|
||||||
|
read -p "Continue anyway? [y/N] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓${NC} Python $PY_VERSION supported"
|
||||||
|
|
||||||
# Check available memory
|
# Check available memory
|
||||||
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
|
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
|
||||||
if [ "$TOTAL_MEM" -lt 2000 ]; then
|
if [ "$TOTAL_MEM" -lt 2000 ]; then
|
||||||
@@ -136,8 +190,11 @@ if [ "$TOTAL_MEM" -lt 2000 ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create /opt/stegasoo with proper permissions
|
# =============================================================================
|
||||||
echo -e "${GREEN}[1/12]${NC} Setting up install directory..."
|
# Installation
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo -e "${GREEN}[1/9]${NC} Setting up install directory..."
|
||||||
if [ ! -d "$INSTALL_DIR" ]; then
|
if [ ! -d "$INSTALL_DIR" ]; then
|
||||||
sudo mkdir -p "$INSTALL_DIR"
|
sudo mkdir -p "$INSTALL_DIR"
|
||||||
sudo chown "$USER:$USER" "$INSTALL_DIR"
|
sudo chown "$USER:$USER" "$INSTALL_DIR"
|
||||||
@@ -148,7 +205,7 @@ else
|
|||||||
echo " $INSTALL_DIR exists, updated ownership"
|
echo " $INSTALL_DIR exists, updated ownership"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}[2/12]${NC} Installing system dependencies..."
|
echo -e "${GREEN}[2/9]${NC} Installing system dependencies..."
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
@@ -170,9 +227,11 @@ sudo apt-get install -y \
|
|||||||
libzbar0 \
|
libzbar0 \
|
||||||
libjpeg-dev \
|
libjpeg-dev \
|
||||||
python3-dev \
|
python3-dev \
|
||||||
|
python3-venv \
|
||||||
|
python3-pip \
|
||||||
btop
|
btop
|
||||||
|
|
||||||
echo -e "${GREEN}[3/12]${NC} Installing gum (TUI toolkit)..."
|
echo -e "${GREEN}[3/9]${NC} Installing gum (TUI toolkit)..."
|
||||||
# Add Charm repo for gum
|
# Add Charm repo for gum
|
||||||
if ! command -v gum &>/dev/null; then
|
if ! command -v gum &>/dev/null; then
|
||||||
sudo mkdir -p /etc/apt/keyrings
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
@@ -198,7 +257,7 @@ else
|
|||||||
echo " mkcert already installed"
|
echo " mkcert already installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}[4/12]${NC} Cloning Stegasoo..."
|
echo -e "${GREEN}[4/9]${NC} Cloning Stegasoo..."
|
||||||
|
|
||||||
# Clone Stegasoo first (needed to check for pre-built tarball)
|
# Clone Stegasoo first (needed to check for pre-built tarball)
|
||||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||||
@@ -212,17 +271,16 @@ else
|
|||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pre-built environment tarball (skips 20+ min compile time)
|
# Pre-built venv tarball (skips pip compile time)
|
||||||
# Includes both pyenv Python 3.12 AND venv with all dependencies
|
PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-rpi-venv-arm64.tar.zst"
|
||||||
PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-rpi-runtime-env-arm64.tar.zst"
|
PREBUILT_URL="${PREBUILT_URL:-https://github.com/adlee-was-taken/stegasoo/releases/download/v4.2.1/stegasoo-rpi-venv-arm64.tar.zst}"
|
||||||
PREBUILT_URL="${PREBUILT_URL:-https://github.com/adlee-was-taken/stegasoo/releases/download/v4.1.5/stegasoo-rpi-runtime-env-arm64.tar.zst}"
|
|
||||||
USE_PREBUILT=true
|
USE_PREBUILT=true
|
||||||
|
|
||||||
# Use local tarball if present, otherwise will download
|
# Use local tarball if present, otherwise will download
|
||||||
if [ -f "$PREBUILT_TARBALL" ]; then
|
if [ -f "$PREBUILT_TARBALL" ]; then
|
||||||
echo -e "${GREEN}Found local pre-built environment - fast install mode${NC}"
|
echo -e "${GREEN}Found local pre-built venv - fast install mode${NC}"
|
||||||
else
|
else
|
||||||
echo -e "${GREEN}Will download pre-built environment - fast install mode${NC}"
|
echo -e "${GREEN}Will download pre-built venv - fast install mode${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Allow --no-prebuilt flag to force from-source build
|
# Allow --no-prebuilt flag to force from-source build
|
||||||
@@ -231,44 +289,30 @@ if [[ " $* " =~ " --no-prebuilt " ]] || [[ " $* " =~ " --from-source " ]]; then
|
|||||||
echo -e "${YELLOW}Building from source (--no-prebuilt specified)${NC}"
|
echo -e "${YELLOW}Building from source (--no-prebuilt specified)${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fast path: use pre-built environment if available
|
echo -e "${GREEN}[5/9]${NC} Setting up Python environment..."
|
||||||
|
|
||||||
if [ "$USE_PREBUILT" = true ]; then
|
if [ "$USE_PREBUILT" = true ]; then
|
||||||
echo -e "${GREEN}[5/8]${NC} Installing pre-built Python environment..."
|
# Fast path: use pre-built venv
|
||||||
|
|
||||||
# Download if local file doesn't exist
|
# Download if local file doesn't exist
|
||||||
if [ ! -f "$PREBUILT_TARBALL" ]; then
|
if [ ! -f "$PREBUILT_TARBALL" ]; then
|
||||||
echo " Downloading pre-built environment (~50MB)..."
|
echo " Downloading pre-built venv (~50MB)..."
|
||||||
curl -L -o "$PREBUILT_TARBALL" "$PREBUILT_URL"
|
curl -L -o "$PREBUILT_TARBALL" "$PREBUILT_URL"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Extract pre-built environment (includes pyenv Python + venv)
|
# Extract pre-built venv
|
||||||
echo " Extracting pre-built environment..."
|
echo " Extracting pre-built venv..."
|
||||||
zstd -d "$PREBUILT_TARBALL" --stdout | tar -xf - -C "$HOME"
|
zstd -d "$PREBUILT_TARBALL" --stdout | tar -xf - -C "$INSTALL_DIR"
|
||||||
|
|
||||||
# Setup pyenv in current shell
|
# Fix venv Python symlinks to point to system Python
|
||||||
export PYENV_ROOT="$HOME/.pyenv"
|
echo " Updating venv to use system Python..."
|
||||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
rm -f "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/venv/bin/python3"
|
||||||
eval "$(pyenv init -)"
|
ln -s "$SYSTEM_PYTHON" "$INSTALL_DIR/venv/bin/python"
|
||||||
pyenv global $PYTHON_VERSION
|
ln -s "$SYSTEM_PYTHON" "$INSTALL_DIR/venv/bin/python3"
|
||||||
|
|
||||||
# Add to .bashrc if not already there
|
# Update pip shebang if needed
|
||||||
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
|
if [ -f "$INSTALL_DIR/venv/bin/pip" ]; then
|
||||||
echo '' >> ~/.bashrc
|
sed -i "1s|^#!.*|#!$INSTALL_DIR/venv/bin/python|" "$INSTALL_DIR/venv/bin/pip" 2>/dev/null || true
|
||||||
echo '# pyenv' >> ~/.bashrc
|
|
||||||
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
|
||||||
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
|
||||||
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify Python
|
|
||||||
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
|
||||||
echo -e " ${GREEN}✓${NC} Python: $INSTALLED_PY"
|
|
||||||
|
|
||||||
# Extract venv to install dir
|
|
||||||
echo -e "${GREEN}[6/8]${NC} Setting up virtual environment..."
|
|
||||||
if [ -f "$HOME/stegasoo-venv.tar.zst" ]; then
|
|
||||||
zstd -d "$HOME/stegasoo-venv.tar.zst" --stdout | tar -xf - -C "$INSTALL_DIR"
|
|
||||||
rm "$HOME/stegasoo-venv.tar.zst"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Activate and verify
|
# Activate and verify
|
||||||
@@ -277,105 +321,87 @@ if [ "$USE_PREBUILT" = true ]; then
|
|||||||
echo -e " ${GREEN}✓${NC} venv Python: $VENV_PY"
|
echo -e " ${GREEN}✓${NC} venv Python: $VENV_PY"
|
||||||
|
|
||||||
# Install stegasoo package in editable mode (quick, no compile)
|
# Install stegasoo package in editable mode (quick, no compile)
|
||||||
echo -e "${GREEN}[7/8]${NC} Installing Stegasoo package..."
|
echo " Installing Stegasoo package..."
|
||||||
pip install -e "." --quiet
|
pip install -e "." --quiet
|
||||||
|
|
||||||
# Adjust step numbers for rest of script
|
|
||||||
STEP_OFFSET=-4
|
|
||||||
else
|
else
|
||||||
echo -e "${GREEN}[5/12]${NC} Installing pyenv and Python $PYTHON_VERSION..."
|
# Build from source
|
||||||
|
echo -e " ${YELLOW}Building from source (this takes 5-10 minutes)${NC}"
|
||||||
|
|
||||||
# Install pyenv if not present
|
# Create venv with system Python
|
||||||
if [ ! -d "$HOME/.pyenv" ]; then
|
if [ ! -d "$INSTALL_DIR/venv" ]; then
|
||||||
curl https://pyenv.run | bash
|
"$SYSTEM_PYTHON" -m venv "$INSTALL_DIR/venv"
|
||||||
|
|
||||||
# Add pyenv to current shell
|
|
||||||
export PYENV_ROOT="$HOME/.pyenv"
|
|
||||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
|
||||||
eval "$(pyenv init -)"
|
|
||||||
|
|
||||||
# Add to .bashrc if not already there
|
|
||||||
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
|
|
||||||
echo '' >> ~/.bashrc
|
|
||||||
echo '# pyenv' >> ~/.bashrc
|
|
||||||
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
|
||||||
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
|
||||||
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " pyenv already installed"
|
|
||||||
export PYENV_ROOT="$HOME/.pyenv"
|
|
||||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
|
||||||
eval "$(pyenv init -)"
|
|
||||||
fi
|
fi
|
||||||
|
source "$INSTALL_DIR/venv/bin/activate"
|
||||||
# Install Python 3.12 if not present
|
|
||||||
if ! pyenv versions | grep -q "$PYTHON_VERSION"; then
|
|
||||||
echo " Building Python $PYTHON_VERSION (this takes ~10 minutes)..."
|
|
||||||
pyenv install $PYTHON_VERSION
|
|
||||||
else
|
|
||||||
echo " Python $PYTHON_VERSION already installed"
|
|
||||||
fi
|
|
||||||
pyenv global $PYTHON_VERSION
|
|
||||||
|
|
||||||
# Verify Python version
|
|
||||||
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
|
||||||
if [ "$INSTALLED_PY" != "$PYTHON_VERSION" ]; then
|
|
||||||
echo -e "${RED}Error: Python $PYTHON_VERSION not active. Got: $INSTALLED_PY${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo -e "${GREEN}[6/12]${NC} Creating Python virtual environment..."
|
|
||||||
echo -e " ${YELLOW}Note: No pre-built venv found. Building from source (20+ min)${NC}"
|
|
||||||
echo -e " ${YELLOW}To speed up future installs, add stegasoo-venv-pi-arm64.tar.gz to rpi/${NC}"
|
|
||||||
|
|
||||||
# Create venv with pyenv Python (not system Python)
|
|
||||||
# Use pyenv which to get actual path (handles 3.12 -> 3.12.12 mapping)
|
|
||||||
PYENV_PYTHON=$(pyenv which python)
|
|
||||||
echo " Using Python: $PYENV_PYTHON"
|
|
||||||
if [ ! -d "venv" ]; then
|
|
||||||
"$PYENV_PYTHON" -m venv venv
|
|
||||||
fi
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
# Verify we're using the right Python
|
# Verify we're using the right Python
|
||||||
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
||||||
echo " venv Python: $VENV_PY"
|
echo " venv Python: $VENV_PY"
|
||||||
|
|
||||||
echo -e "${GREEN}[7/12]${NC} Building jpegio for ARM..."
|
# Upgrade pip and install build tools
|
||||||
|
pip install --upgrade pip setuptools wheel
|
||||||
|
|
||||||
# Clone jpegio
|
# Install jpeglib (no ARM64 wheel, PyPI tarball missing headers - use GitHub)
|
||||||
JPEGIO_DIR="/tmp/jpegio-build"
|
echo " Installing jpeglib for ARM64..."
|
||||||
rm -rf "$JPEGIO_DIR"
|
JPEGLIB_WORKDIR=$(mktemp -d)
|
||||||
git clone "$JPEGIO_REPO" "$JPEGIO_DIR"
|
cd "$JPEGLIB_WORKDIR"
|
||||||
|
|
||||||
# Apply ARM64 patch
|
# Clone from GitHub (PyPI source tarball is missing .h files)
|
||||||
if [ -f "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
|
echo " Cloning jpeglib from GitHub..."
|
||||||
bash "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
|
git clone --depth 1 --branch 1.0.2 https://github.com/martinbenes1996/jpeglib.git
|
||||||
else
|
cd jpeglib
|
||||||
echo " Applying inline ARM64 patch..."
|
CJPEGLIB="src/jpeglib/cjpeglib"
|
||||||
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$JPEGIO_DIR"
|
# Fix broken include paths in setup.py (uses jpeglib/ but files are in src/jpeglib/)
|
||||||
|
ln -s src/jpeglib jpeglib
|
||||||
|
|
||||||
# Build jpegio into venv
|
# Download libjpeg headers (not included in repo either)
|
||||||
pip install --upgrade pip setuptools wheel cython numpy
|
# Each version needs EXACT matching headers (APIs differ between versions)
|
||||||
|
echo " Downloading libjpeg headers (all versions)..."
|
||||||
|
|
||||||
|
# Download each version separately (APIs are incompatible between versions)
|
||||||
|
for v in 6b 7 8 8a 8b 8c 8d 9 9a 9b 9c 9d 9e 9f; do
|
||||||
|
echo " libjpeg $v..."
|
||||||
|
curl -sL "https://www.ijg.org/files/jpegsrc.v${v}.tar.gz" | tar -xzf -
|
||||||
|
cp jpeg-${v}/*.h "$CJPEGLIB/$v/" 2>/dev/null || cp jpeg-${v//.}/*.h "$CJPEGLIB/$v/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Skip turbo/mozjpeg - they need cmake-generated headers
|
||||||
|
# Only remove dict entries (lines 49-59), keep if blocks (they're safe when is_turbo=False)
|
||||||
|
echo " Patching setup.py to skip turbo/mozjpeg (need cmake)..."
|
||||||
|
python3 << 'PYPATCH'
|
||||||
|
with open('setup.py', 'r') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
filtered = []
|
||||||
|
for line in lines:
|
||||||
|
# Only skip dict entries like "'turbo120': ..." or "'mozjpeg101': ..."
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("'turbo") and ':' in stripped:
|
||||||
|
continue
|
||||||
|
if stripped.startswith("'mozjpeg") and ':' in stripped:
|
||||||
|
continue
|
||||||
|
if stripped.startswith("# 'turbo"): # commented turbo line
|
||||||
|
continue
|
||||||
|
filtered.append(line)
|
||||||
|
with open('setup.py', 'w') as f:
|
||||||
|
f.writelines(filtered)
|
||||||
|
PYPATCH
|
||||||
|
|
||||||
|
# Build and install
|
||||||
|
echo " Building jpeglib (this takes a few minutes)..."
|
||||||
pip install .
|
pip install .
|
||||||
|
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
rm -rf "$JPEGIO_DIR"
|
rm -rf "$JPEGLIB_WORKDIR"
|
||||||
|
|
||||||
echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..."
|
# Install remaining dependencies
|
||||||
|
echo " Installing remaining dependencies..."
|
||||||
# Install dependencies (jpegio already in venv, won't re-download)
|
|
||||||
pip install -e ".[web]"
|
pip install -e ".[web]"
|
||||||
|
|
||||||
STEP_OFFSET=0
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}[9/12]${NC} Creating systemd service..."
|
echo -e " ${GREEN}✓${NC} Stegasoo installed"
|
||||||
|
|
||||||
# Create systemd service file
|
echo -e "${GREEN}[6/9]${NC} Creating systemd services..."
|
||||||
|
|
||||||
|
# Create systemd service file for Web UI
|
||||||
sudo tee /etc/systemd/system/stegasoo.service > /dev/null <<EOF
|
sudo tee /etc/systemd/system/stegasoo.service > /dev/null <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Stegasoo Web UI
|
Description=Stegasoo Web UI
|
||||||
@@ -397,12 +423,53 @@ RestartSec=5
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo -e "${GREEN}[10/12]${NC} Enabling service..."
|
# Create systemd service file for REST API (optional)
|
||||||
|
sudo tee /etc/systemd/system/stegasoo-api.service > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stegasoo REST API
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$USER
|
||||||
|
WorkingDirectory=$INSTALL_DIR/frontends/api
|
||||||
|
Environment="PATH=$INSTALL_DIR/venv/bin:/usr/bin"
|
||||||
|
Environment="PYTHONPATH=$INSTALL_DIR/src"
|
||||||
|
ExecStart=$INSTALL_DIR/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo -e "${GREEN}[7/9]${NC} Enabling services..."
|
||||||
|
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable stegasoo.service
|
sudo systemctl enable stegasoo.service
|
||||||
|
|
||||||
echo -e "${GREEN}[11/12]${NC} Setting up user environment..."
|
# Prompt for REST API service (optional, with security warning)
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}Would you like to enable the REST API service? (port 8000)${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${RED}⚠ WARNING: The REST API has NO AUTHENTICATION${NC}"
|
||||||
|
echo " Anyone on your network can use it to encode/decode messages."
|
||||||
|
echo " Only enable if you understand the security implications."
|
||||||
|
echo ""
|
||||||
|
echo " The Web UI (port 5000) has authentication and works independently."
|
||||||
|
echo ""
|
||||||
|
read -p "Enable REST API (no auth)? [y/N]: " ENABLE_API
|
||||||
|
if [[ "$ENABLE_API" =~ ^[Yy]$ ]]; then
|
||||||
|
sudo systemctl enable stegasoo-api.service
|
||||||
|
STEGASOO_API_ENABLED=true
|
||||||
|
echo -e " ${YELLOW}⚠${NC} REST API enabled on port 8000 ${RED}(no authentication)${NC}"
|
||||||
|
else
|
||||||
|
STEGASOO_API_ENABLED=false
|
||||||
|
echo -e " ${GREEN}✓${NC} REST API not enabled (recommended)"
|
||||||
|
echo " Can enable later with: sudo systemctl enable --now stegasoo-api"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}[8/9]${NC} Setting up user environment..."
|
||||||
|
|
||||||
# Add stegasoo venv and rpi scripts to PATH for all users
|
# Add stegasoo venv and rpi scripts to PATH for all users
|
||||||
sudo tee /etc/profile.d/stegasoo-path.sh > /dev/null <<'PATHEOF'
|
sudo tee /etc/profile.d/stegasoo-path.sh > /dev/null <<'PATHEOF'
|
||||||
@@ -436,7 +503,7 @@ if [ -f "$INSTALL_DIR/docs/stegasoo.1" ]; then
|
|||||||
echo " Installed man page (man stegasoo)"
|
echo " Installed man page (man stegasoo)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}[12/12]${NC} Setting up login banner..."
|
echo -e "${GREEN}[9/9]${NC} Setting up login banner..."
|
||||||
|
|
||||||
# Create dynamic MOTD script
|
# Create dynamic MOTD script
|
||||||
sudo tee /etc/profile.d/stegasoo-motd.sh > /dev/null <<'MOTDEOF'
|
sudo tee /etc/profile.d/stegasoo-motd.sh > /dev/null <<'MOTDEOF'
|
||||||
@@ -727,6 +794,14 @@ echo " Start: sudo systemctl start stegasoo"
|
|||||||
echo " Stop: sudo systemctl stop stegasoo"
|
echo " Stop: sudo systemctl stop stegasoo"
|
||||||
echo " Status: sudo systemctl status stegasoo"
|
echo " Status: sudo systemctl status stegasoo"
|
||||||
echo " Logs: journalctl -u stegasoo -f"
|
echo " Logs: journalctl -u stegasoo -f"
|
||||||
|
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}REST API Commands:${NC}"
|
||||||
|
echo " Start: sudo systemctl start stegasoo-api"
|
||||||
|
echo " Stop: sudo systemctl stop stegasoo-api"
|
||||||
|
echo " Status: sudo systemctl status stegasoo-api"
|
||||||
|
echo " Logs: journalctl -u stegasoo-api -f"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Offer to start now
|
# Offer to start now
|
||||||
@@ -734,9 +809,12 @@ read -p "Start Stegasoo now? [Y/n] " -n 1 -r
|
|||||||
echo
|
echo
|
||||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||||
sudo systemctl start stegasoo
|
sudo systemctl start stegasoo
|
||||||
|
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
|
||||||
|
sudo systemctl start stegasoo-api
|
||||||
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
if systemctl is-active --quiet stegasoo; then
|
if systemctl is-active --quiet stegasoo; then
|
||||||
echo -e "${GREEN}✓ Stegasoo is running!${NC}"
|
echo -e "${GREEN}✓ Stegasoo Web UI is running!${NC}"
|
||||||
if [ "$ENABLE_HTTPS" = "true" ]; then
|
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||||
if [ "$USE_PORT_443" = "true" ]; then
|
if [ "$USE_PORT_443" = "true" ]; then
|
||||||
echo -e " Create admin: ${YELLOW}https://$PI_HOST.local/setup${NC} or ${YELLOW}https://$PI_IP/setup${NC}"
|
echo -e " Create admin: ${YELLOW}https://$PI_HOST.local/setup${NC} or ${YELLOW}https://$PI_IP/setup${NC}"
|
||||||
@@ -746,6 +824,13 @@ if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
|||||||
else
|
else
|
||||||
echo -e " Create admin: ${YELLOW}http://$PI_HOST.local:5000/setup${NC} or ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
echo -e " Create admin: ${YELLOW}http://$PI_HOST.local:5000/setup${NC} or ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
||||||
fi
|
fi
|
||||||
|
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
|
||||||
|
if systemctl is-active --quiet stegasoo-api; then
|
||||||
|
echo -e "${GREEN}✓ Stegasoo REST API is running on port 8000${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ REST API failed to start. Check logs:${NC} journalctl -u stegasoo-api -f"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${RED}✗ Failed to start. Check logs:${NC} journalctl -u stegasoo -f"
|
echo -e "${RED}✗ Failed to start. Check logs:${NC} journalctl -u stegasoo -f"
|
||||||
fi
|
fi
|
||||||
|
|||||||
13
rpi/train_proj.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"hostname": "running_trains",
|
||||||
|
"username": "admin",
|
||||||
|
"password": "runthemtrains",
|
||||||
|
"wifiSSID": "WitchHazelWrecked",
|
||||||
|
"wifiPassword": "BeefPigsMoo",
|
||||||
|
"wifiCountry": "US",
|
||||||
|
"locale": "en_US.UTF-8",
|
||||||
|
"keyboardLayout": "us",
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"enableSSH": true
|
||||||
|
}
|
||||||
|
|
||||||
@@ -68,20 +68,31 @@ case "${1:-fast}" in
|
|||||||
echo -e "${GREEN}Cleaned!${NC}"
|
echo -e "${GREEN}Cleaned!${NC}"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
rebuild)
|
||||||
|
echo -e "${YELLOW}Full rebuild from scratch (no cache)...${NC}"
|
||||||
|
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" down --rmi local -v 2>/dev/null || true
|
||||||
|
$SUDO docker rmi stegasoo-base:latest 2>/dev/null || true
|
||||||
|
$SUDO docker build --no-cache -f "$DOCKER_DIR/Dockerfile.base" -t stegasoo-base:latest .
|
||||||
|
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" build --no-cache
|
||||||
|
echo -e "${GREEN}Done! Start with: $COMPOSE_CMD -f docker/docker-compose.yml up -d${NC}"
|
||||||
|
;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
echo -e "${CYAN}Stegasoo Build Script${NC}"
|
echo -e "${CYAN}Stegasoo Build Script${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Usage: $0 [command]"
|
echo "Usage: $0 [command]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Commands:"
|
echo "Commands:"
|
||||||
echo " base Build the base image (one-time, 5-10 min)"
|
echo " base Build the base image (one-time, 5-10 min)"
|
||||||
echo " fast Fast build using base image (default, ~10 sec)"
|
echo " fast Fast build using base image (default, ~10 sec)"
|
||||||
echo " full Full rebuild from scratch (slow, no base needed)"
|
echo " full Rebuild services without cache (uses existing base)"
|
||||||
echo " clean Remove all images and volumes"
|
echo " rebuild Complete rebuild with no cache (base + services)"
|
||||||
|
echo " clean Remove all images and volumes"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Typical workflow:"
|
echo "Typical workflow:"
|
||||||
echo " 1. First time: $0 base"
|
echo " 1. First time: $0 base"
|
||||||
echo " 2. Daily dev: $0 fast"
|
echo " 2. Daily dev: $0 fast"
|
||||||
echo " 3. Deps change: $0 base"
|
echo " 3. Deps change: $0 base"
|
||||||
|
echo " 4. Nuclear: $0 rebuild"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ for cmd in chromium magick curl; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Check if server is running
|
# Check if server is running (-k for self-signed certs)
|
||||||
if ! curl -s "$BASE_URL" > /dev/null 2>&1; then
|
if ! curl -sk "$BASE_URL" > /dev/null 2>&1; then
|
||||||
echo "Error: Server not responding at $BASE_URL"
|
echo "Error: Server not responding at $BASE_URL"
|
||||||
echo "Start with: STEGASOO_AUTH_ENABLED=false python frontends/web/app.py"
|
echo "Start with: STEGASOO_AUTH_ENABLED=false python frontends/web/app.py"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -49,7 +49,7 @@ capture() {
|
|||||||
printf " %-20s <- %s\n" "$name" "$route"
|
printf " %-20s <- %s\n" "$name" "$route"
|
||||||
chromium --headless --screenshot="$OUTPUT_DIR/$name.png" \
|
chromium --headless --screenshot="$OUTPUT_DIR/$name.png" \
|
||||||
--window-size="$WINDOW_SIZE" --hide-scrollbars \
|
--window-size="$WINDOW_SIZE" --hide-scrollbars \
|
||||||
--disable-gpu --no-sandbox \
|
--disable-gpu --no-sandbox --ignore-certificate-errors \
|
||||||
"$url" 2>/dev/null
|
"$url" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Changes in v4.0.0:
|
|||||||
- encode() and decode() now accept channel_key parameter
|
- encode() and decode() now accept channel_key parameter
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "4.1.7"
|
__version__ = "4.3.0"
|
||||||
|
|
||||||
# Core functionality
|
# Core functionality
|
||||||
# Channel key management (v4.0.0)
|
# Channel key management (v4.0.0)
|
||||||
@@ -22,6 +22,9 @@ from .channel import (
|
|||||||
validate_channel_key,
|
validate_channel_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Audio support — gated by STEGASOO_AUDIO env var and dependency availability
|
||||||
|
from .constants import AUDIO_ENABLED, VIDEO_ENABLED
|
||||||
|
|
||||||
# Crypto functions
|
# Crypto functions
|
||||||
from .crypto import get_active_channel_key, get_channel_fingerprint, has_argon2
|
from .crypto import get_active_channel_key, get_channel_fingerprint, has_argon2
|
||||||
from .decode import decode, decode_file, decode_text
|
from .decode import decode, decode_file, decode_text
|
||||||
@@ -43,6 +46,16 @@ from .image_utils import (
|
|||||||
get_image_info,
|
get_image_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Backend registry
|
||||||
|
from .backends import EmbeddingBackend, registry as backend_registry
|
||||||
|
|
||||||
|
# Platform presets
|
||||||
|
from .platform_presets import PLATFORMS, get_preset
|
||||||
|
|
||||||
|
# Steganalysis
|
||||||
|
from .steganalysis import check_image
|
||||||
|
from .backends.registry import BackendNotFoundError
|
||||||
|
|
||||||
# Steganography functions
|
# Steganography functions
|
||||||
from .steganography import (
|
from .steganography import (
|
||||||
calculate_capacity_by_mode,
|
calculate_capacity_by_mode,
|
||||||
@@ -54,6 +67,44 @@ from .steganography import (
|
|||||||
# Utilities
|
# Utilities
|
||||||
from .utils import generate_filename
|
from .utils import generate_filename
|
||||||
|
|
||||||
|
HAS_AUDIO_SUPPORT = AUDIO_ENABLED
|
||||||
|
HAS_VIDEO_SUPPORT = VIDEO_ENABLED
|
||||||
|
|
||||||
|
if AUDIO_ENABLED:
|
||||||
|
from .audio_utils import (
|
||||||
|
detect_audio_format,
|
||||||
|
get_audio_info,
|
||||||
|
has_ffmpeg_support,
|
||||||
|
validate_audio,
|
||||||
|
)
|
||||||
|
from .decode import decode_audio
|
||||||
|
from .encode import encode_audio
|
||||||
|
else:
|
||||||
|
detect_audio_format = None
|
||||||
|
get_audio_info = None
|
||||||
|
has_ffmpeg_support = None
|
||||||
|
validate_audio = None
|
||||||
|
encode_audio = None
|
||||||
|
decode_audio = None
|
||||||
|
|
||||||
|
# Video support — gated by STEGASOO_VIDEO env var and ffmpeg + audio deps
|
||||||
|
if VIDEO_ENABLED:
|
||||||
|
from .decode import decode_video
|
||||||
|
from .encode import encode_video
|
||||||
|
from .video_utils import (
|
||||||
|
calculate_video_capacity,
|
||||||
|
detect_video_format,
|
||||||
|
get_video_info,
|
||||||
|
validate_video,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
detect_video_format = None
|
||||||
|
get_video_info = None
|
||||||
|
validate_video = None
|
||||||
|
calculate_video_capacity = None
|
||||||
|
encode_video = None
|
||||||
|
decode_video = None
|
||||||
|
|
||||||
# QR Code utilities - optional, may not be available
|
# QR Code utilities - optional, may not be available
|
||||||
try:
|
try:
|
||||||
from .qr_utils import (
|
from .qr_utils import (
|
||||||
@@ -88,9 +139,14 @@ validate_carrier = validate_image
|
|||||||
# Constants
|
# Constants
|
||||||
from .constants import (
|
from .constants import (
|
||||||
DEFAULT_PASSPHRASE_WORDS,
|
DEFAULT_PASSPHRASE_WORDS,
|
||||||
|
EMBED_MODE_AUDIO_AUTO,
|
||||||
|
EMBED_MODE_AUDIO_LSB,
|
||||||
|
EMBED_MODE_AUDIO_SPREAD,
|
||||||
EMBED_MODE_AUTO,
|
EMBED_MODE_AUTO,
|
||||||
EMBED_MODE_DCT,
|
EMBED_MODE_DCT,
|
||||||
EMBED_MODE_LSB,
|
EMBED_MODE_LSB,
|
||||||
|
EMBED_MODE_VIDEO_AUTO,
|
||||||
|
EMBED_MODE_VIDEO_LSB,
|
||||||
FORMAT_VERSION,
|
FORMAT_VERSION,
|
||||||
LOSSLESS_FORMATS,
|
LOSSLESS_FORMATS,
|
||||||
MAX_FILE_PAYLOAD_SIZE,
|
MAX_FILE_PAYLOAD_SIZE,
|
||||||
@@ -106,6 +162,11 @@ from .constants import (
|
|||||||
|
|
||||||
# Exceptions
|
# Exceptions
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
|
AudioCapacityError,
|
||||||
|
AudioError,
|
||||||
|
AudioExtractionError,
|
||||||
|
AudioTranscodeError,
|
||||||
|
AudioValidationError,
|
||||||
CapacityError,
|
CapacityError,
|
||||||
CryptoError,
|
CryptoError,
|
||||||
DecryptionError,
|
DecryptionError,
|
||||||
@@ -127,11 +188,21 @@ from .exceptions import (
|
|||||||
SecurityFactorError,
|
SecurityFactorError,
|
||||||
SteganographyError,
|
SteganographyError,
|
||||||
StegasooError,
|
StegasooError,
|
||||||
|
UnsupportedAudioFormatError,
|
||||||
|
UnsupportedVideoFormatError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
|
VideoCapacityError,
|
||||||
|
VideoError,
|
||||||
|
VideoExtractionError,
|
||||||
|
VideoTranscodeError,
|
||||||
|
VideoValidationError,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Models
|
# Models
|
||||||
from .models import (
|
from .models import (
|
||||||
|
AudioCapacityInfo,
|
||||||
|
AudioEmbedStats,
|
||||||
|
AudioInfo,
|
||||||
CapacityComparison,
|
CapacityComparison,
|
||||||
Credentials,
|
Credentials,
|
||||||
DecodeResult,
|
DecodeResult,
|
||||||
@@ -140,8 +211,13 @@ from .models import (
|
|||||||
GenerateResult,
|
GenerateResult,
|
||||||
ImageInfo,
|
ImageInfo,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
|
VideoCapacityInfo,
|
||||||
|
VideoEmbedStats,
|
||||||
|
VideoInfo,
|
||||||
)
|
)
|
||||||
from .validation import (
|
from .validation import (
|
||||||
|
validate_audio_embed_mode,
|
||||||
|
validate_audio_file,
|
||||||
validate_dct_color_mode,
|
validate_dct_color_mode,
|
||||||
validate_dct_output_format,
|
validate_dct_output_format,
|
||||||
validate_embed_mode,
|
validate_embed_mode,
|
||||||
@@ -164,6 +240,24 @@ __all__ = [
|
|||||||
"decode",
|
"decode",
|
||||||
"decode_file",
|
"decode_file",
|
||||||
"decode_text",
|
"decode_text",
|
||||||
|
# Audio (v4.3.0)
|
||||||
|
"encode_audio",
|
||||||
|
"decode_audio",
|
||||||
|
"detect_audio_format",
|
||||||
|
"get_audio_info",
|
||||||
|
"has_ffmpeg_support",
|
||||||
|
"validate_audio",
|
||||||
|
"HAS_AUDIO_SUPPORT",
|
||||||
|
"HAS_VIDEO_SUPPORT",
|
||||||
|
"validate_audio_embed_mode",
|
||||||
|
"validate_audio_file",
|
||||||
|
# Video (v4.4.0)
|
||||||
|
"encode_video",
|
||||||
|
"decode_video",
|
||||||
|
"detect_video_format",
|
||||||
|
"get_video_info",
|
||||||
|
"validate_video",
|
||||||
|
"calculate_video_capacity",
|
||||||
# Generation
|
# Generation
|
||||||
"generate_pin",
|
"generate_pin",
|
||||||
"generate_passphrase",
|
"generate_passphrase",
|
||||||
@@ -189,6 +283,15 @@ __all__ = [
|
|||||||
"generate_filename",
|
"generate_filename",
|
||||||
# Crypto
|
# Crypto
|
||||||
"has_argon2",
|
"has_argon2",
|
||||||
|
# Backends
|
||||||
|
"EmbeddingBackend",
|
||||||
|
"backend_registry",
|
||||||
|
"BackendNotFoundError",
|
||||||
|
# Platform presets
|
||||||
|
"get_preset",
|
||||||
|
"PLATFORMS",
|
||||||
|
# Steganalysis
|
||||||
|
"check_image",
|
||||||
# Steganography
|
# Steganography
|
||||||
"has_dct_support",
|
"has_dct_support",
|
||||||
"calculate_capacity_by_mode",
|
"calculate_capacity_by_mode",
|
||||||
@@ -221,6 +324,14 @@ __all__ = [
|
|||||||
"FilePayload",
|
"FilePayload",
|
||||||
"Credentials",
|
"Credentials",
|
||||||
"ValidationResult",
|
"ValidationResult",
|
||||||
|
# Audio models
|
||||||
|
"AudioEmbedStats",
|
||||||
|
"AudioInfo",
|
||||||
|
"AudioCapacityInfo",
|
||||||
|
# Video models
|
||||||
|
"VideoEmbedStats",
|
||||||
|
"VideoInfo",
|
||||||
|
"VideoCapacityInfo",
|
||||||
# Exceptions
|
# Exceptions
|
||||||
"StegasooError",
|
"StegasooError",
|
||||||
"ValidationError",
|
"ValidationError",
|
||||||
@@ -244,6 +355,20 @@ __all__ = [
|
|||||||
"ReedSolomonError",
|
"ReedSolomonError",
|
||||||
"NoDataFoundError",
|
"NoDataFoundError",
|
||||||
"ModeMismatchError",
|
"ModeMismatchError",
|
||||||
|
# Audio exceptions
|
||||||
|
"AudioError",
|
||||||
|
"AudioValidationError",
|
||||||
|
"AudioCapacityError",
|
||||||
|
"AudioExtractionError",
|
||||||
|
"AudioTranscodeError",
|
||||||
|
"UnsupportedAudioFormatError",
|
||||||
|
# Video exceptions
|
||||||
|
"VideoError",
|
||||||
|
"VideoValidationError",
|
||||||
|
"VideoCapacityError",
|
||||||
|
"VideoExtractionError",
|
||||||
|
"VideoTranscodeError",
|
||||||
|
"UnsupportedVideoFormatError",
|
||||||
# Constants
|
# Constants
|
||||||
"FORMAT_VERSION",
|
"FORMAT_VERSION",
|
||||||
"MIN_PASSPHRASE_WORDS",
|
"MIN_PASSPHRASE_WORDS",
|
||||||
@@ -266,4 +391,11 @@ __all__ = [
|
|||||||
"EMBED_MODE_LSB",
|
"EMBED_MODE_LSB",
|
||||||
"EMBED_MODE_DCT",
|
"EMBED_MODE_DCT",
|
||||||
"EMBED_MODE_AUTO",
|
"EMBED_MODE_AUTO",
|
||||||
|
# Audio constants
|
||||||
|
"EMBED_MODE_AUDIO_LSB",
|
||||||
|
"EMBED_MODE_AUDIO_SPREAD",
|
||||||
|
"EMBED_MODE_AUDIO_AUTO",
|
||||||
|
# Video constants
|
||||||
|
"EMBED_MODE_VIDEO_LSB",
|
||||||
|
"EMBED_MODE_VIDEO_AUTO",
|
||||||
]
|
]
|
||||||
|
|||||||
510
src/stegasoo/audio_steganography.py
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
"""
|
||||||
|
Stegasoo Audio Steganography — LSB Embedding/Extraction (v4.3.0)
|
||||||
|
|
||||||
|
LSB (Least Significant Bit) embedding for PCM audio samples.
|
||||||
|
|
||||||
|
Hides data in the least significant bit(s) of audio samples, analogous to
|
||||||
|
how steganography.py hides data in pixel LSBs. The carrier audio must be
|
||||||
|
lossless (WAV or FLAC) — lossy codecs (MP3, OGG, AAC) destroy LSBs.
|
||||||
|
|
||||||
|
Uses ChaCha20 as a CSPRNG for pseudo-random sample index selection,
|
||||||
|
ensuring that without the key an attacker cannot determine which samples
|
||||||
|
were modified.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- 16-bit PCM (int16 samples)
|
||||||
|
- 24-bit PCM (int32 samples from soundfile)
|
||||||
|
- Float audio (converted to int16 before embedding)
|
||||||
|
- 1 or 2 bits per sample embedding depth
|
||||||
|
- Mono and multi-channel audio (flattened for embedding)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
AUDIO_MAGIC_LSB,
|
||||||
|
EMBED_MODE_AUDIO_LSB,
|
||||||
|
)
|
||||||
|
from .debug import debug
|
||||||
|
from .exceptions import AudioCapacityError, AudioError
|
||||||
|
from .models import AudioEmbedStats
|
||||||
|
from .steganography import ENCRYPTION_OVERHEAD
|
||||||
|
|
||||||
|
# Progress reporting interval — write every N samples
|
||||||
|
PROGRESS_INTERVAL = 5000
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PROGRESS REPORTING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _write_progress(progress_file: str | None, current: int, total: int, phase: str = "embedding"):
|
||||||
|
"""Write progress to file for frontend polling."""
|
||||||
|
if progress_file is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
with open(progress_file, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"current": current,
|
||||||
|
"total": total,
|
||||||
|
"percent": round((current / total) * 100, 1) if total > 0 else 0,
|
||||||
|
"phase": phase,
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Don't let progress writing break encoding
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CAPACITY
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_audio_lsb_capacity(
|
||||||
|
audio_data: bytes,
|
||||||
|
bits_per_sample: int = 1,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Calculate the maximum bytes that can be embedded in a WAV/FLAC file via LSB.
|
||||||
|
|
||||||
|
Reads the carrier audio with soundfile, counts the total number of individual
|
||||||
|
sample values (num_frames * channels), and computes how many payload bytes
|
||||||
|
can be hidden at the given bit depth, minus the fixed encryption overhead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Raw bytes of a WAV or FLAC file.
|
||||||
|
bits_per_sample: Number of LSBs to use per sample (1 or 2).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Maximum embeddable payload size in bytes (after subtracting overhead).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AudioError: If the audio cannot be read or is in an unsupported format.
|
||||||
|
"""
|
||||||
|
debug.validate(
|
||||||
|
bits_per_sample in (1, 2), f"bits_per_sample must be 1 or 2, got {bits_per_sample}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = sf.info(io.BytesIO(audio_data))
|
||||||
|
except Exception as e:
|
||||||
|
raise AudioError(f"Failed to read audio file: {e}") from e
|
||||||
|
|
||||||
|
num_samples = info.frames * info.channels
|
||||||
|
total_bits = num_samples * bits_per_sample
|
||||||
|
max_bytes = total_bits // 8
|
||||||
|
|
||||||
|
capacity = max(0, max_bytes - ENCRYPTION_OVERHEAD)
|
||||||
|
debug.print(
|
||||||
|
f"Audio LSB capacity: {capacity} bytes "
|
||||||
|
f"({num_samples} samples, {bits_per_sample} bit(s)/sample, "
|
||||||
|
f"{info.samplerate} Hz, {info.channels} ch)"
|
||||||
|
)
|
||||||
|
return capacity
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SAMPLE INDEX GENERATION (ChaCha20 CSPRNG)
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Identical strategy to generate_pixel_indices in steganography.py:
|
||||||
|
# - >= 50% capacity utilisation: full Fisher-Yates shuffle, take first N
|
||||||
|
# - < 50%: direct random sampling with collision handling
|
||||||
|
#
|
||||||
|
# The key MUST be 32 bytes (same derivation path as the pixel key).
|
||||||
|
|
||||||
|
|
||||||
|
@debug.time
|
||||||
|
def generate_sample_indices(key: bytes, num_samples: int, num_needed: int) -> list[int]:
|
||||||
|
"""
|
||||||
|
Generate pseudo-random sample indices using ChaCha20 as a CSPRNG.
|
||||||
|
|
||||||
|
Produces a deterministic sequence of unique sample indices so that
|
||||||
|
the same key always yields the same embedding locations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 32-byte key for the ChaCha20 cipher.
|
||||||
|
num_samples: Total number of samples in the carrier audio.
|
||||||
|
num_needed: How many unique sample indices are required.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ``num_needed`` unique indices in [0, num_samples).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AssertionError (via debug.validate): On invalid arguments.
|
||||||
|
"""
|
||||||
|
debug.validate(len(key) == 32, f"Sample key must be 32 bytes, got {len(key)}")
|
||||||
|
debug.validate(num_samples > 0, f"Number of samples must be positive, got {num_samples}")
|
||||||
|
debug.validate(num_needed > 0, f"Number needed must be positive, got {num_needed}")
|
||||||
|
debug.validate(
|
||||||
|
num_needed <= num_samples,
|
||||||
|
f"Cannot select {num_needed} samples from {num_samples} available",
|
||||||
|
)
|
||||||
|
|
||||||
|
debug.print(f"Generating {num_needed} sample indices from {num_samples} total samples")
|
||||||
|
|
||||||
|
# Strategy 1: Full Fisher-Yates shuffle when we need many indices
|
||||||
|
if num_needed >= num_samples // 2:
|
||||||
|
debug.print(f"Using full shuffle (needed {num_needed}/{num_samples} samples)")
|
||||||
|
nonce = b"\x00" * 16
|
||||||
|
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
|
||||||
|
encryptor = cipher.encryptor()
|
||||||
|
|
||||||
|
indices = list(range(num_samples))
|
||||||
|
random_bytes = encryptor.update(b"\x00" * (num_samples * 4))
|
||||||
|
|
||||||
|
for i in range(num_samples - 1, 0, -1):
|
||||||
|
j_bytes = random_bytes[(num_samples - 1 - i) * 4 : (num_samples - i) * 4]
|
||||||
|
j = int.from_bytes(j_bytes, "big") % (i + 1)
|
||||||
|
indices[i], indices[j] = indices[j], indices[i]
|
||||||
|
|
||||||
|
selected = indices[:num_needed]
|
||||||
|
debug.print(f"Generated {len(selected)} indices via shuffle")
|
||||||
|
return selected
|
||||||
|
|
||||||
|
# Strategy 2: Direct sampling for lower utilisation
|
||||||
|
debug.print(f"Using optimized selection (needed {num_needed}/{num_samples} samples)")
|
||||||
|
selected: list[int] = []
|
||||||
|
used: set[int] = set()
|
||||||
|
|
||||||
|
nonce = b"\x00" * 16
|
||||||
|
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
|
||||||
|
encryptor = cipher.encryptor()
|
||||||
|
|
||||||
|
# Pre-generate 2x bytes to handle expected collisions
|
||||||
|
bytes_needed = (num_needed * 2) * 4
|
||||||
|
random_bytes = encryptor.update(b"\x00" * bytes_needed)
|
||||||
|
|
||||||
|
byte_offset = 0
|
||||||
|
collisions = 0
|
||||||
|
while len(selected) < num_needed and byte_offset < len(random_bytes) - 4:
|
||||||
|
idx = int.from_bytes(random_bytes[byte_offset : byte_offset + 4], "big") % num_samples
|
||||||
|
byte_offset += 4
|
||||||
|
|
||||||
|
if idx not in used:
|
||||||
|
used.add(idx)
|
||||||
|
selected.append(idx)
|
||||||
|
else:
|
||||||
|
collisions += 1
|
||||||
|
|
||||||
|
# Edge case: ran out of pre-generated bytes (very high collision rate)
|
||||||
|
if len(selected) < num_needed:
|
||||||
|
debug.print(f"Need {num_needed - len(selected)} more indices, generating...")
|
||||||
|
extra_needed = num_needed - len(selected)
|
||||||
|
for _ in range(extra_needed * 2):
|
||||||
|
extra_bytes = encryptor.update(b"\x00" * 4)
|
||||||
|
idx = int.from_bytes(extra_bytes, "big") % num_samples
|
||||||
|
if idx not in used:
|
||||||
|
used.add(idx)
|
||||||
|
selected.append(idx)
|
||||||
|
if len(selected) == num_needed:
|
||||||
|
break
|
||||||
|
|
||||||
|
debug.print(f"Generated {len(selected)} indices with {collisions} collisions")
|
||||||
|
debug.validate(
|
||||||
|
len(selected) == num_needed,
|
||||||
|
f"Failed to generate enough indices: {len(selected)}/{num_needed}",
|
||||||
|
)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EMBEDDING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@debug.time
|
||||||
|
def embed_in_audio_lsb(
|
||||||
|
data: bytes,
|
||||||
|
carrier_audio: bytes,
|
||||||
|
sample_key: bytes,
|
||||||
|
bits_per_sample: int = 1,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
) -> tuple[bytes, AudioEmbedStats]:
|
||||||
|
"""
|
||||||
|
Embed data into PCM audio samples using LSB steganography.
|
||||||
|
|
||||||
|
The payload is prepended with a 4-byte magic header (``AUDIO_MAGIC_LSB``)
|
||||||
|
and a 4-byte big-endian length prefix, then converted to a binary string.
|
||||||
|
Pseudo-random sample indices are generated from ``sample_key`` and the
|
||||||
|
corresponding sample LSBs are overwritten.
|
||||||
|
|
||||||
|
The modified audio is written back as a 16-bit PCM WAV file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Encrypted payload bytes to embed.
|
||||||
|
carrier_audio: Raw bytes of the carrier WAV/FLAC file.
|
||||||
|
sample_key: 32-byte key for sample index generation.
|
||||||
|
bits_per_sample: LSBs to use per sample (1 or 2).
|
||||||
|
progress_file: Optional path for progress JSON (frontend polling).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (stego WAV bytes, AudioEmbedStats).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AudioCapacityError: If the payload is too large for the carrier.
|
||||||
|
AudioError: On any other embedding failure.
|
||||||
|
"""
|
||||||
|
debug.print(f"Audio LSB embedding {len(data)} bytes")
|
||||||
|
debug.data(sample_key, "Sample key for embedding")
|
||||||
|
debug.validate(
|
||||||
|
bits_per_sample in (1, 2), f"bits_per_sample must be 1 or 2, got {bits_per_sample}"
|
||||||
|
)
|
||||||
|
debug.validate(len(sample_key) == 32, f"Sample key must be 32 bytes, got {len(sample_key)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Read carrier audio
|
||||||
|
samples, samplerate = sf.read(io.BytesIO(carrier_audio), dtype="int16", always_2d=True)
|
||||||
|
# samples shape: (num_frames, channels)
|
||||||
|
original_shape = samples.shape
|
||||||
|
channels = original_shape[1]
|
||||||
|
duration = original_shape[0] / samplerate
|
||||||
|
|
||||||
|
debug.print(
|
||||||
|
f"Carrier audio: {samplerate} Hz, {channels} ch, "
|
||||||
|
f"{original_shape[0]} frames, {duration:.2f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flatten to 1D for embedding
|
||||||
|
flat_samples = samples.flatten().copy()
|
||||||
|
num_samples = len(flat_samples)
|
||||||
|
|
||||||
|
# 2. Prepend magic + length prefix
|
||||||
|
header = AUDIO_MAGIC_LSB + struct.pack(">I", len(data))
|
||||||
|
payload = header + data
|
||||||
|
debug.print(
|
||||||
|
f"Payload with header: {len(payload)} bytes (magic 4 + len 4 + data {len(data)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Check capacity
|
||||||
|
max_bytes = (num_samples * bits_per_sample) // 8
|
||||||
|
if len(payload) > max_bytes:
|
||||||
|
debug.print(f"Capacity error: need {len(payload)}, have {max_bytes}")
|
||||||
|
raise AudioCapacityError(len(payload), max_bytes)
|
||||||
|
|
||||||
|
debug.print(
|
||||||
|
f"Capacity usage: {len(payload)}/{max_bytes} bytes "
|
||||||
|
f"({len(payload) / max_bytes * 100:.1f}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Convert payload to binary string
|
||||||
|
binary_data = "".join(format(b, "08b") for b in payload)
|
||||||
|
samples_needed = (len(binary_data) + bits_per_sample - 1) // bits_per_sample
|
||||||
|
|
||||||
|
debug.print(f"Need {samples_needed} samples to embed {len(binary_data)} bits")
|
||||||
|
|
||||||
|
# 5. Generate pseudo-random sample indices
|
||||||
|
selected_indices = generate_sample_indices(sample_key, num_samples, samples_needed)
|
||||||
|
|
||||||
|
# 6. Modify LSBs of selected samples
|
||||||
|
lsb_mask = (1 << bits_per_sample) - 1
|
||||||
|
bit_idx = 0
|
||||||
|
modified_count = 0
|
||||||
|
total_to_process = len(selected_indices)
|
||||||
|
|
||||||
|
# Initial progress
|
||||||
|
if progress_file:
|
||||||
|
_write_progress(progress_file, 5, 100, "embedding")
|
||||||
|
|
||||||
|
for progress_idx, sample_idx in enumerate(selected_indices):
|
||||||
|
if bit_idx >= len(binary_data):
|
||||||
|
break
|
||||||
|
|
||||||
|
bits = binary_data[bit_idx : bit_idx + bits_per_sample].ljust(bits_per_sample, "0")
|
||||||
|
bit_val = int(bits, 2)
|
||||||
|
|
||||||
|
sample_val = flat_samples[sample_idx]
|
||||||
|
# Work in unsigned 16-bit space to avoid overflow
|
||||||
|
unsigned_val = int(sample_val) & 0xFFFF
|
||||||
|
new_unsigned = (unsigned_val & ~lsb_mask) | bit_val
|
||||||
|
# Convert back to signed int16
|
||||||
|
new_val = np.int16(new_unsigned if new_unsigned < 32768 else new_unsigned - 65536)
|
||||||
|
|
||||||
|
if sample_val != new_val:
|
||||||
|
flat_samples[sample_idx] = new_val
|
||||||
|
modified_count += 1
|
||||||
|
|
||||||
|
bit_idx += bits_per_sample
|
||||||
|
|
||||||
|
# Report progress periodically
|
||||||
|
if progress_file and progress_idx % PROGRESS_INTERVAL == 0:
|
||||||
|
_write_progress(progress_file, progress_idx, total_to_process, "embedding")
|
||||||
|
|
||||||
|
# Final progress before save
|
||||||
|
if progress_file:
|
||||||
|
_write_progress(progress_file, total_to_process, total_to_process, "saving")
|
||||||
|
|
||||||
|
debug.print(f"Modified {modified_count} samples (out of {samples_needed} selected)")
|
||||||
|
|
||||||
|
# 7. Reshape and write back as WAV
|
||||||
|
stego_samples = flat_samples.reshape(original_shape)
|
||||||
|
|
||||||
|
output_buf = io.BytesIO()
|
||||||
|
sf.write(output_buf, stego_samples, samplerate, format="WAV", subtype="PCM_16")
|
||||||
|
output_buf.seek(0)
|
||||||
|
stego_bytes = output_buf.getvalue()
|
||||||
|
|
||||||
|
stats = AudioEmbedStats(
|
||||||
|
samples_modified=modified_count,
|
||||||
|
total_samples=num_samples,
|
||||||
|
capacity_used=len(payload) / max_bytes,
|
||||||
|
bytes_embedded=len(payload),
|
||||||
|
sample_rate=samplerate,
|
||||||
|
channels=channels,
|
||||||
|
duration_seconds=duration,
|
||||||
|
embed_mode=EMBED_MODE_AUDIO_LSB,
|
||||||
|
)
|
||||||
|
|
||||||
|
debug.print(f"Audio LSB embedding complete: {len(stego_bytes)} byte WAV")
|
||||||
|
return stego_bytes, stats
|
||||||
|
|
||||||
|
except AudioCapacityError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
debug.exception(e, "embed_in_audio_lsb")
|
||||||
|
raise AudioError(f"Failed to embed data in audio: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EXTRACTION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@debug.time
|
||||||
|
def extract_from_audio_lsb(
|
||||||
|
audio_data: bytes,
|
||||||
|
sample_key: bytes,
|
||||||
|
bits_per_sample: int = 1,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
) -> bytes | None:
|
||||||
|
"""
|
||||||
|
Extract hidden data from audio using LSB steganography.
|
||||||
|
|
||||||
|
Reads the stego audio, generates the same pseudo-random sample indices
|
||||||
|
from ``sample_key``, extracts the LSBs, and reconstructs the payload.
|
||||||
|
Verifies the ``AUDIO_MAGIC_LSB`` header before returning.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Raw bytes of the stego WAV file.
|
||||||
|
sample_key: 32-byte key (must match the one used for embedding).
|
||||||
|
bits_per_sample: LSBs per sample (must match embedding).
|
||||||
|
progress_file: Optional path for progress JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Extracted payload bytes (without magic/length prefix), or ``None``
|
||||||
|
if extraction fails (wrong key, no data, corrupted).
|
||||||
|
"""
|
||||||
|
debug.print(f"Audio LSB extracting from {len(audio_data)} byte audio")
|
||||||
|
debug.data(sample_key, "Sample key for extraction")
|
||||||
|
debug.validate(
|
||||||
|
bits_per_sample in (1, 2), f"bits_per_sample must be 1 or 2, got {bits_per_sample}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Read audio
|
||||||
|
samples, samplerate = sf.read(io.BytesIO(audio_data), dtype="int16", always_2d=True)
|
||||||
|
flat_samples = samples.flatten()
|
||||||
|
num_samples = len(flat_samples)
|
||||||
|
|
||||||
|
debug.print(f"Audio: {samplerate} Hz, {samples.shape[1]} ch, {num_samples} total samples")
|
||||||
|
|
||||||
|
# 2. Extract initial samples to find magic bytes + length (8 bytes = 64 bits)
|
||||||
|
header_bits_needed = 64 # 4 bytes magic + 4 bytes length
|
||||||
|
header_samples_needed = (header_bits_needed + bits_per_sample - 1) // bits_per_sample + 10
|
||||||
|
|
||||||
|
if header_samples_needed > num_samples:
|
||||||
|
debug.print("Audio too small to contain header")
|
||||||
|
return None
|
||||||
|
|
||||||
|
initial_indices = generate_sample_indices(sample_key, num_samples, header_samples_needed)
|
||||||
|
|
||||||
|
binary_data = ""
|
||||||
|
for sample_idx in initial_indices:
|
||||||
|
val = int(flat_samples[sample_idx]) & 0xFFFF
|
||||||
|
for bit_pos in range(bits_per_sample - 1, -1, -1):
|
||||||
|
binary_data += str((val >> bit_pos) & 1)
|
||||||
|
|
||||||
|
# 3. Verify magic bytes
|
||||||
|
if len(binary_data) < 64:
|
||||||
|
debug.print(f"Not enough bits for header: {len(binary_data)}/64")
|
||||||
|
return None
|
||||||
|
|
||||||
|
magic_bits = binary_data[:32]
|
||||||
|
magic_bytes = int(magic_bits, 2).to_bytes(4, "big")
|
||||||
|
|
||||||
|
if magic_bytes != AUDIO_MAGIC_LSB:
|
||||||
|
debug.print(f"Magic mismatch: got {magic_bytes!r}, expected {AUDIO_MAGIC_LSB!r}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
debug.print("Magic bytes verified: AUDL")
|
||||||
|
|
||||||
|
# 4. Parse length
|
||||||
|
length_bits = binary_data[32:64]
|
||||||
|
data_length = struct.unpack(">I", int(length_bits, 2).to_bytes(4, "big"))[0]
|
||||||
|
debug.print(f"Extracted length: {data_length} bytes")
|
||||||
|
|
||||||
|
# Sanity check length
|
||||||
|
max_possible = (num_samples * bits_per_sample) // 8 - 8 # minus header
|
||||||
|
if data_length > max_possible or data_length < 1:
|
||||||
|
debug.print(f"Invalid data length: {data_length} (max possible: {max_possible})")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 5. Extract full payload
|
||||||
|
total_bits = (8 + data_length) * 8 # header (8 bytes) + payload
|
||||||
|
total_samples_needed = (total_bits + bits_per_sample - 1) // bits_per_sample
|
||||||
|
|
||||||
|
if total_samples_needed > num_samples:
|
||||||
|
debug.print(f"Need {total_samples_needed} samples but only {num_samples} available")
|
||||||
|
return None
|
||||||
|
|
||||||
|
debug.print(f"Need {total_samples_needed} samples to extract {data_length} bytes")
|
||||||
|
|
||||||
|
selected_indices = generate_sample_indices(sample_key, num_samples, total_samples_needed)
|
||||||
|
|
||||||
|
# Initial progress
|
||||||
|
if progress_file:
|
||||||
|
_write_progress(progress_file, 5, 100, "extracting")
|
||||||
|
|
||||||
|
binary_data = ""
|
||||||
|
for progress_idx, sample_idx in enumerate(selected_indices):
|
||||||
|
val = int(flat_samples[sample_idx]) & 0xFFFF
|
||||||
|
for bit_pos in range(bits_per_sample - 1, -1, -1):
|
||||||
|
binary_data += str((val >> bit_pos) & 1)
|
||||||
|
|
||||||
|
if progress_file and progress_idx % PROGRESS_INTERVAL == 0:
|
||||||
|
_write_progress(progress_file, progress_idx, total_samples_needed, "extracting")
|
||||||
|
|
||||||
|
if progress_file:
|
||||||
|
_write_progress(progress_file, total_samples_needed, total_samples_needed, "extracting")
|
||||||
|
|
||||||
|
# Skip the 8-byte header (magic + length) = 64 bits
|
||||||
|
data_bits = binary_data[64 : 64 + (data_length * 8)]
|
||||||
|
|
||||||
|
if len(data_bits) < data_length * 8:
|
||||||
|
debug.print(f"Insufficient bits: {len(data_bits)} < {data_length * 8}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Convert bits back to bytes
|
||||||
|
data_bytes = bytearray()
|
||||||
|
for i in range(0, len(data_bits), 8):
|
||||||
|
byte_bits = data_bits[i : i + 8]
|
||||||
|
if len(byte_bits) == 8:
|
||||||
|
data_bytes.append(int(byte_bits, 2))
|
||||||
|
|
||||||
|
debug.print(f"Audio LSB successfully extracted {len(data_bytes)} bytes")
|
||||||
|
return bytes(data_bytes)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
debug.exception(e, "extract_from_audio_lsb")
|
||||||
|
return None
|
||||||
540
src/stegasoo/audio_utils.py
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
"""
|
||||||
|
Stegasoo Audio Utilities (v4.3.0)
|
||||||
|
|
||||||
|
Audio format detection, transcoding, and metadata extraction for audio steganography.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
- soundfile (sf): Fast WAV/FLAC reading without ffmpeg
|
||||||
|
- pydub: MP3/OGG/AAC transcoding (wraps ffmpeg)
|
||||||
|
|
||||||
|
Both are optional — functions degrade gracefully when unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
EMBED_MODE_AUDIO_AUTO,
|
||||||
|
MAX_AUDIO_DURATION,
|
||||||
|
MAX_AUDIO_FILE_SIZE,
|
||||||
|
MAX_AUDIO_SAMPLE_RATE,
|
||||||
|
MIN_AUDIO_SAMPLE_RATE,
|
||||||
|
VALID_AUDIO_EMBED_MODES,
|
||||||
|
)
|
||||||
|
from .debug import get_logger
|
||||||
|
from .exceptions import AudioTranscodeError, AudioValidationError, UnsupportedAudioFormatError
|
||||||
|
from .models import AudioInfo, ValidationResult
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FFMPEG AVAILABILITY
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def has_ffmpeg_support() -> bool:
|
||||||
|
"""Check if ffmpeg is available on the system.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if ffmpeg is found on PATH, False otherwise.
|
||||||
|
"""
|
||||||
|
return shutil.which("ffmpeg") is not None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FORMAT DETECTION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def detect_audio_format(audio_data: bytes) -> str:
|
||||||
|
"""Detect audio format from magic bytes.
|
||||||
|
|
||||||
|
Examines the first bytes of audio data to identify the container format.
|
||||||
|
|
||||||
|
Magic byte signatures:
|
||||||
|
- WAV: b"RIFF" at offset 0 + b"WAVE" at offset 8
|
||||||
|
- FLAC: b"fLaC" at offset 0
|
||||||
|
- MP3: b"\\xff\\xfb", b"\\xff\\xf3", b"\\xff\\xf2" (sync bytes) or b"ID3" (ID3 tag)
|
||||||
|
- OGG (Vorbis/Opus): b"OggS" at offset 0
|
||||||
|
- AAC: b"\\xff\\xf1" or b"\\xff\\xf9" (ADTS header)
|
||||||
|
- M4A/MP4: b"ftyp" at offset 4
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Raw audio file bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Format string: "wav", "flac", "mp3", "ogg", "aac", "m4a", or "unknown".
|
||||||
|
"""
|
||||||
|
if len(audio_data) < 12:
|
||||||
|
logger.debug("detect_audio_format: data too short (%d bytes)", len(audio_data))
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
# WAV: RIFF....WAVE
|
||||||
|
if audio_data[:4] == b"RIFF" and audio_data[8:12] == b"WAVE":
|
||||||
|
logger.debug("Detected WAV format (%d bytes)", len(audio_data))
|
||||||
|
return "wav"
|
||||||
|
|
||||||
|
# FLAC
|
||||||
|
if audio_data[:4] == b"fLaC":
|
||||||
|
return "flac"
|
||||||
|
|
||||||
|
# OGG (Vorbis or Opus)
|
||||||
|
if audio_data[:4] == b"OggS":
|
||||||
|
return "ogg"
|
||||||
|
|
||||||
|
# MP3 with ID3 tag
|
||||||
|
if audio_data[:3] == b"ID3":
|
||||||
|
return "mp3"
|
||||||
|
|
||||||
|
# MP3 sync bytes (MPEG audio frame header)
|
||||||
|
if len(audio_data) >= 2 and audio_data[:2] in (b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"):
|
||||||
|
return "mp3"
|
||||||
|
|
||||||
|
# M4A/MP4 container: "ftyp" at offset 4
|
||||||
|
if audio_data[4:8] == b"ftyp":
|
||||||
|
return "m4a"
|
||||||
|
|
||||||
|
# AAC ADTS header
|
||||||
|
if len(audio_data) >= 2 and audio_data[:2] in (b"\xff\xf1", b"\xff\xf9"):
|
||||||
|
return "aac"
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TRANSCODING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def transcode_to_wav(audio_data: bytes) -> bytes:
|
||||||
|
"""Transcode any supported audio format to WAV PCM format.
|
||||||
|
|
||||||
|
Uses soundfile directly for WAV/FLAC (no ffmpeg needed).
|
||||||
|
Uses pydub (wraps ffmpeg) for lossy formats (MP3, OGG, AAC, M4A).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Raw audio file bytes in any supported format.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WAV PCM file bytes (16-bit, original sample rate).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AudioTranscodeError: If transcoding fails.
|
||||||
|
UnsupportedAudioFormatError: If the format cannot be detected.
|
||||||
|
"""
|
||||||
|
fmt = detect_audio_format(audio_data)
|
||||||
|
logger.info("transcode_to_wav: input format=%s, size=%d bytes", fmt, len(audio_data))
|
||||||
|
|
||||||
|
if fmt == "unknown":
|
||||||
|
raise UnsupportedAudioFormatError(
|
||||||
|
"Cannot detect audio format. Supported: WAV, FLAC, MP3, OGG, AAC, M4A."
|
||||||
|
)
|
||||||
|
|
||||||
|
# WAV files: validate with soundfile but return as-is if already PCM
|
||||||
|
if fmt == "wav":
|
||||||
|
try:
|
||||||
|
import soundfile as sf
|
||||||
|
|
||||||
|
buf = io.BytesIO(audio_data)
|
||||||
|
info = sf.info(buf)
|
||||||
|
if info.subtype in ("PCM_16", "PCM_24", "PCM_32", "FLOAT", "DOUBLE"):
|
||||||
|
# Re-encode to ensure consistent PCM_16 output
|
||||||
|
buf.seek(0)
|
||||||
|
data, samplerate = sf.read(buf, dtype="int16")
|
||||||
|
out = io.BytesIO()
|
||||||
|
sf.write(out, data, samplerate, format="WAV", subtype="PCM_16")
|
||||||
|
return out.getvalue()
|
||||||
|
except ImportError:
|
||||||
|
raise AudioTranscodeError("soundfile package is required for WAV processing")
|
||||||
|
except Exception as e:
|
||||||
|
raise AudioTranscodeError(f"Failed to process WAV: {e}")
|
||||||
|
|
||||||
|
# FLAC: use soundfile (fast, no ffmpeg)
|
||||||
|
if fmt == "flac":
|
||||||
|
try:
|
||||||
|
import soundfile as sf
|
||||||
|
|
||||||
|
buf = io.BytesIO(audio_data)
|
||||||
|
data, samplerate = sf.read(buf, dtype="int16")
|
||||||
|
out = io.BytesIO()
|
||||||
|
sf.write(out, data, samplerate, format="WAV", subtype="PCM_16")
|
||||||
|
return out.getvalue()
|
||||||
|
except ImportError:
|
||||||
|
raise AudioTranscodeError("soundfile package is required for FLAC processing")
|
||||||
|
except Exception as e:
|
||||||
|
raise AudioTranscodeError(f"Failed to transcode FLAC to WAV: {e}")
|
||||||
|
|
||||||
|
# Lossy formats (MP3, OGG, AAC, M4A): use pydub + ffmpeg
|
||||||
|
return _transcode_with_pydub(audio_data, fmt, "wav")
|
||||||
|
|
||||||
|
|
||||||
|
def transcode_to_mp3(audio_data: bytes, bitrate: str = "256k") -> bytes:
|
||||||
|
"""Transcode audio to MP3 format.
|
||||||
|
|
||||||
|
Uses pydub (wraps ffmpeg) for transcoding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Raw audio file bytes in any supported format.
|
||||||
|
bitrate: Target MP3 bitrate (e.g., "128k", "192k", "256k", "320k").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MP3 file bytes.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AudioTranscodeError: If transcoding fails or pydub/ffmpeg unavailable.
|
||||||
|
"""
|
||||||
|
fmt = detect_audio_format(audio_data)
|
||||||
|
|
||||||
|
if fmt == "unknown":
|
||||||
|
raise UnsupportedAudioFormatError(
|
||||||
|
"Cannot detect audio format. Supported: WAV, FLAC, MP3, OGG, AAC, M4A."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pydub import AudioSegment
|
||||||
|
except ImportError:
|
||||||
|
raise AudioTranscodeError(
|
||||||
|
"pydub package is required for MP3 transcoding. Install with: pip install pydub"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_ffmpeg_support():
|
||||||
|
raise AudioTranscodeError(
|
||||||
|
"ffmpeg is required for MP3 transcoding. Install ffmpeg on your system."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Map our format names to pydub format names
|
||||||
|
pydub_fmt = _pydub_format(fmt)
|
||||||
|
buf = io.BytesIO(audio_data)
|
||||||
|
audio = AudioSegment.from_file(buf, format=pydub_fmt)
|
||||||
|
|
||||||
|
out = io.BytesIO()
|
||||||
|
audio.export(out, format="mp3", bitrate=bitrate)
|
||||||
|
return out.getvalue()
|
||||||
|
except Exception as e:
|
||||||
|
raise AudioTranscodeError(f"Failed to transcode to MP3: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _transcode_with_pydub(audio_data: bytes, src_fmt: str, dst_fmt: str) -> bytes:
|
||||||
|
"""Transcode audio using pydub (requires ffmpeg).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Raw audio bytes.
|
||||||
|
src_fmt: Source format string (our naming).
|
||||||
|
dst_fmt: Destination format string ("wav" or "mp3").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transcoded audio bytes.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AudioTranscodeError: If transcoding fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from pydub import AudioSegment
|
||||||
|
except ImportError:
|
||||||
|
raise AudioTranscodeError(
|
||||||
|
"pydub package is required for audio transcoding. Install with: pip install pydub"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_ffmpeg_support():
|
||||||
|
raise AudioTranscodeError(
|
||||||
|
"ffmpeg is required for audio transcoding. Install ffmpeg on your system."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pydub_fmt = _pydub_format(src_fmt)
|
||||||
|
buf = io.BytesIO(audio_data)
|
||||||
|
audio = AudioSegment.from_file(buf, format=pydub_fmt)
|
||||||
|
|
||||||
|
out = io.BytesIO()
|
||||||
|
if dst_fmt == "wav":
|
||||||
|
audio.export(out, format="wav")
|
||||||
|
else:
|
||||||
|
audio.export(out, format=dst_fmt)
|
||||||
|
return out.getvalue()
|
||||||
|
except Exception as e:
|
||||||
|
raise AudioTranscodeError(f"Failed to transcode {src_fmt} to {dst_fmt}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _pydub_format(fmt: str) -> str:
|
||||||
|
"""Map our format names to pydub/ffmpeg format names.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fmt: Our internal format name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
pydub-compatible format string.
|
||||||
|
"""
|
||||||
|
mapping = {
|
||||||
|
"wav": "wav",
|
||||||
|
"flac": "flac",
|
||||||
|
"mp3": "mp3",
|
||||||
|
"ogg": "ogg",
|
||||||
|
"aac": "aac",
|
||||||
|
"m4a": "m4a",
|
||||||
|
}
|
||||||
|
return mapping.get(fmt, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# METADATA EXTRACTION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_info(audio_data: bytes) -> AudioInfo:
|
||||||
|
"""Extract audio metadata from raw audio bytes.
|
||||||
|
|
||||||
|
Uses soundfile for WAV/FLAC (fast, no ffmpeg dependency).
|
||||||
|
Falls back to pydub for other formats (requires ffmpeg).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Raw audio file bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AudioInfo dataclass with sample rate, channels, duration, etc.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
UnsupportedAudioFormatError: If the format cannot be detected.
|
||||||
|
AudioTranscodeError: If metadata extraction fails.
|
||||||
|
"""
|
||||||
|
fmt = detect_audio_format(audio_data)
|
||||||
|
|
||||||
|
if fmt == "unknown":
|
||||||
|
raise UnsupportedAudioFormatError(
|
||||||
|
"Cannot detect audio format. Supported: WAV, FLAC, MP3, OGG, AAC, M4A."
|
||||||
|
)
|
||||||
|
|
||||||
|
# WAV and FLAC: use soundfile (fast)
|
||||||
|
if fmt in ("wav", "flac"):
|
||||||
|
return _get_info_soundfile(audio_data, fmt)
|
||||||
|
|
||||||
|
# Lossy formats: use pydub
|
||||||
|
return _get_info_pydub(audio_data, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_info_soundfile(audio_data: bytes, fmt: str) -> AudioInfo:
|
||||||
|
"""Extract audio info using soundfile (WAV/FLAC).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Raw audio bytes.
|
||||||
|
fmt: Format string ("wav" or "flac").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AudioInfo with metadata.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import soundfile as sf
|
||||||
|
except ImportError:
|
||||||
|
raise AudioTranscodeError(
|
||||||
|
"soundfile package is required. Install with: pip install soundfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
buf = io.BytesIO(audio_data)
|
||||||
|
info = sf.info(buf)
|
||||||
|
|
||||||
|
# Determine bit depth from subtype
|
||||||
|
bit_depth = _bit_depth_from_subtype(info.subtype)
|
||||||
|
|
||||||
|
return AudioInfo(
|
||||||
|
sample_rate=info.samplerate,
|
||||||
|
channels=info.channels,
|
||||||
|
duration_seconds=info.duration,
|
||||||
|
num_samples=info.frames,
|
||||||
|
format=fmt,
|
||||||
|
bitrate=None,
|
||||||
|
bit_depth=bit_depth,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise AudioTranscodeError(f"Failed to read {fmt.upper()} metadata: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _bit_depth_from_subtype(subtype: str) -> int | None:
|
||||||
|
"""Determine bit depth from soundfile subtype string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subtype: Soundfile subtype (e.g., "PCM_16", "PCM_24", "FLOAT").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Bit depth as integer, or None if unknown.
|
||||||
|
"""
|
||||||
|
subtype_map = {
|
||||||
|
"PCM_S8": 8,
|
||||||
|
"PCM_U8": 8,
|
||||||
|
"PCM_16": 16,
|
||||||
|
"PCM_24": 24,
|
||||||
|
"PCM_32": 32,
|
||||||
|
"FLOAT": 32,
|
||||||
|
"DOUBLE": 64,
|
||||||
|
}
|
||||||
|
return subtype_map.get(subtype)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_info_pydub(audio_data: bytes, fmt: str) -> AudioInfo:
|
||||||
|
"""Extract audio info using pydub (lossy formats).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Raw audio bytes.
|
||||||
|
fmt: Format string ("mp3", "ogg", "aac", "m4a").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AudioInfo with metadata.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from pydub import AudioSegment
|
||||||
|
except ImportError:
|
||||||
|
raise AudioTranscodeError(
|
||||||
|
"pydub package is required for audio metadata. Install with: pip install pydub"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_ffmpeg_support():
|
||||||
|
raise AudioTranscodeError(
|
||||||
|
"ffmpeg is required for audio metadata extraction. Install ffmpeg on your system."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pydub_fmt = _pydub_format(fmt)
|
||||||
|
buf = io.BytesIO(audio_data)
|
||||||
|
audio = AudioSegment.from_file(buf, format=pydub_fmt)
|
||||||
|
|
||||||
|
num_samples = int(audio.frame_count())
|
||||||
|
duration = audio.duration_seconds
|
||||||
|
sample_rate = audio.frame_rate
|
||||||
|
channels = audio.channels
|
||||||
|
|
||||||
|
# Estimate bitrate from file size and duration
|
||||||
|
bitrate = None
|
||||||
|
if duration > 0:
|
||||||
|
bitrate = int((len(audio_data) * 8) / duration)
|
||||||
|
|
||||||
|
return AudioInfo(
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
channels=channels,
|
||||||
|
duration_seconds=duration,
|
||||||
|
num_samples=num_samples,
|
||||||
|
format=fmt,
|
||||||
|
bitrate=bitrate,
|
||||||
|
bit_depth=audio.sample_width * 8 if audio.sample_width else None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise AudioTranscodeError(f"Failed to read {fmt.upper()} metadata: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# VALIDATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def validate_audio(
|
||||||
|
audio_data: bytes,
|
||||||
|
name: str = "Audio",
|
||||||
|
check_duration: bool = True,
|
||||||
|
) -> ValidationResult:
|
||||||
|
"""Validate audio data for steganography.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- Not empty
|
||||||
|
- Not too large (MAX_AUDIO_FILE_SIZE)
|
||||||
|
- Valid audio format (detectable via magic bytes)
|
||||||
|
- Duration within limits (MAX_AUDIO_DURATION) if check_duration=True
|
||||||
|
- Sample rate within limits (MIN_AUDIO_SAMPLE_RATE to MAX_AUDIO_SAMPLE_RATE)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Raw audio file bytes.
|
||||||
|
name: Descriptive name for error messages (default: "Audio").
|
||||||
|
check_duration: Whether to enforce duration limit (default: True).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationResult with audio info in details (sample_rate, channels,
|
||||||
|
duration, num_samples, format) on success.
|
||||||
|
"""
|
||||||
|
if not audio_data:
|
||||||
|
return ValidationResult.error(f"{name} is required")
|
||||||
|
|
||||||
|
if len(audio_data) > MAX_AUDIO_FILE_SIZE:
|
||||||
|
size_mb = len(audio_data) / (1024 * 1024)
|
||||||
|
max_mb = MAX_AUDIO_FILE_SIZE / (1024 * 1024)
|
||||||
|
return ValidationResult.error(
|
||||||
|
f"{name} too large ({size_mb:.1f} MB). Maximum: {max_mb:.0f} MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Detect format
|
||||||
|
fmt = detect_audio_format(audio_data)
|
||||||
|
if fmt == "unknown":
|
||||||
|
return ValidationResult.error(
|
||||||
|
f"Could not detect {name} format. " "Supported formats: WAV, FLAC, MP3, OGG, AAC, M4A."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract metadata for further validation
|
||||||
|
try:
|
||||||
|
info = get_audio_info(audio_data)
|
||||||
|
except (AudioTranscodeError, UnsupportedAudioFormatError) as e:
|
||||||
|
return ValidationResult.error(f"Could not read {name}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
return ValidationResult.error(f"Could not read {name}: {e}")
|
||||||
|
|
||||||
|
# Check duration
|
||||||
|
if check_duration and info.duration_seconds > MAX_AUDIO_DURATION:
|
||||||
|
return ValidationResult.error(
|
||||||
|
f"{name} too long ({info.duration_seconds:.1f}s). "
|
||||||
|
f"Maximum: {MAX_AUDIO_DURATION}s ({MAX_AUDIO_DURATION // 60} minutes)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check sample rate
|
||||||
|
if info.sample_rate < MIN_AUDIO_SAMPLE_RATE:
|
||||||
|
return ValidationResult.error(
|
||||||
|
f"{name} sample rate too low ({info.sample_rate} Hz). "
|
||||||
|
f"Minimum: {MIN_AUDIO_SAMPLE_RATE} Hz"
|
||||||
|
)
|
||||||
|
|
||||||
|
if info.sample_rate > MAX_AUDIO_SAMPLE_RATE:
|
||||||
|
return ValidationResult.error(
|
||||||
|
f"{name} sample rate too high ({info.sample_rate} Hz). "
|
||||||
|
f"Maximum: {MAX_AUDIO_SAMPLE_RATE} Hz"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ValidationResult.ok(
|
||||||
|
sample_rate=info.sample_rate,
|
||||||
|
channels=info.channels,
|
||||||
|
duration=info.duration_seconds,
|
||||||
|
num_samples=info.num_samples,
|
||||||
|
format=info.format,
|
||||||
|
bitrate=info.bitrate,
|
||||||
|
bit_depth=info.bit_depth,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_valid_audio(audio_data: bytes, name: str = "Audio") -> None:
|
||||||
|
"""Validate audio, raising AudioValidationError on failure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Raw audio file bytes.
|
||||||
|
name: Descriptive name for error messages.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AudioValidationError: If validation fails.
|
||||||
|
"""
|
||||||
|
result = validate_audio(audio_data, name)
|
||||||
|
if not result.is_valid:
|
||||||
|
raise AudioValidationError(result.error_message)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_audio_embed_mode(mode: str) -> ValidationResult:
|
||||||
|
"""Validate audio embedding mode string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Embedding mode to validate (e.g., "audio_lsb", "audio_mdct", "audio_auto").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationResult with mode in details on success.
|
||||||
|
"""
|
||||||
|
valid_modes = VALID_AUDIO_EMBED_MODES | {EMBED_MODE_AUDIO_AUTO}
|
||||||
|
if mode not in valid_modes:
|
||||||
|
return ValidationResult.error(
|
||||||
|
f"Invalid audio embed_mode: '{mode}'. "
|
||||||
|
f"Valid options: {', '.join(sorted(valid_modes))}"
|
||||||
|
)
|
||||||
|
return ValidationResult.ok(mode=mode)
|
||||||
31
src/stegasoo/backends/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Stegasoo embedding backends.
|
||||||
|
|
||||||
|
Provides a typed plugin interface for all embedding algorithms.
|
||||||
|
Backends register with the module-level ``registry`` on import.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from stegasoo.backends import registry
|
||||||
|
|
||||||
|
backend = registry.get("lsb")
|
||||||
|
stego, stats = backend.embed(data, carrier, key)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .dct import DCTBackend
|
||||||
|
from .lsb import LSBBackend
|
||||||
|
from .protocol import EmbeddingBackend
|
||||||
|
from .registry import BackendNotFoundError, BackendRegistry, registry
|
||||||
|
|
||||||
|
# Auto-register built-in backends
|
||||||
|
registry.register(LSBBackend())
|
||||||
|
registry.register(DCTBackend())
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EmbeddingBackend",
|
||||||
|
"BackendRegistry",
|
||||||
|
"BackendNotFoundError",
|
||||||
|
"registry",
|
||||||
|
"LSBBackend",
|
||||||
|
"DCTBackend",
|
||||||
|
]
|
||||||
69
src/stegasoo/backends/dct.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
DCT (Discrete Cosine Transform) image embedding backend.
|
||||||
|
|
||||||
|
Wraps the existing frequency-domain DCT functions in dct_steganography.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class DCTBackend:
|
||||||
|
"""Frequency-domain DCT embedding for JPEG-resilient steganography."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mode(self) -> str:
|
||||||
|
return "dct"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def carrier_type(self) -> str:
|
||||||
|
return "image"
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
from ..dct_steganography import HAS_SCIPY
|
||||||
|
|
||||||
|
return HAS_SCIPY
|
||||||
|
|
||||||
|
def embed(
|
||||||
|
self,
|
||||||
|
data: bytes,
|
||||||
|
carrier: bytes,
|
||||||
|
key: bytes,
|
||||||
|
*,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
**options: Any,
|
||||||
|
) -> tuple[bytes, Any]:
|
||||||
|
from ..dct_steganography import embed_in_dct
|
||||||
|
|
||||||
|
output_format = options.get("dct_output_format", "png")
|
||||||
|
color_mode = options.get("dct_color_mode", "color")
|
||||||
|
quant_step = options.get("quant_step")
|
||||||
|
jpeg_quality = options.get("jpeg_quality")
|
||||||
|
max_dimension = options.get("max_dimension")
|
||||||
|
return embed_in_dct(
|
||||||
|
data, carrier, key, output_format, color_mode, progress_file,
|
||||||
|
quant_step=quant_step, jpeg_quality=jpeg_quality, max_dimension=max_dimension,
|
||||||
|
)
|
||||||
|
|
||||||
|
def extract(
|
||||||
|
self,
|
||||||
|
carrier: bytes,
|
||||||
|
key: bytes,
|
||||||
|
*,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
**options: Any,
|
||||||
|
) -> bytes | None:
|
||||||
|
from ..dct_steganography import extract_from_dct
|
||||||
|
|
||||||
|
quant_step = options.get("quant_step")
|
||||||
|
try:
|
||||||
|
return extract_from_dct(carrier, key, progress_file, quant_step=quant_step)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def calculate_capacity(self, carrier: bytes, **options: Any) -> int:
|
||||||
|
from ..dct_steganography import calculate_dct_capacity
|
||||||
|
|
||||||
|
info = calculate_dct_capacity(carrier)
|
||||||
|
return info.usable_capacity_bytes
|
||||||
63
src/stegasoo/backends/lsb.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
LSB (Least Significant Bit) image embedding backend.
|
||||||
|
|
||||||
|
Wraps the existing spatial-domain LSB functions in steganography.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class LSBBackend:
|
||||||
|
"""Spatial-domain LSB embedding for lossless image formats."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mode(self) -> str:
|
||||||
|
return "lsb"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def carrier_type(self) -> str:
|
||||||
|
return "image"
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True # Only needs Pillow, which is always present
|
||||||
|
|
||||||
|
def embed(
|
||||||
|
self,
|
||||||
|
data: bytes,
|
||||||
|
carrier: bytes,
|
||||||
|
key: bytes,
|
||||||
|
*,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
**options: Any,
|
||||||
|
) -> tuple[bytes, Any]:
|
||||||
|
from ..steganography import _embed_lsb
|
||||||
|
|
||||||
|
bits_per_channel = options.get("bits_per_channel", 1)
|
||||||
|
output_format = options.get("output_format", None)
|
||||||
|
stego_bytes, stats, ext = _embed_lsb(
|
||||||
|
data, carrier, key, bits_per_channel, output_format, progress_file
|
||||||
|
)
|
||||||
|
# Attach output extension to stats for callers that need it
|
||||||
|
stats.output_extension = ext # type: ignore[attr-defined]
|
||||||
|
return stego_bytes, stats
|
||||||
|
|
||||||
|
def extract(
|
||||||
|
self,
|
||||||
|
carrier: bytes,
|
||||||
|
key: bytes,
|
||||||
|
*,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
**options: Any,
|
||||||
|
) -> bytes | None:
|
||||||
|
from ..steganography import _extract_lsb
|
||||||
|
|
||||||
|
bits_per_channel = options.get("bits_per_channel", 1)
|
||||||
|
return _extract_lsb(carrier, key, bits_per_channel)
|
||||||
|
|
||||||
|
def calculate_capacity(self, carrier: bytes, **options: Any) -> int:
|
||||||
|
from ..steganography import calculate_capacity
|
||||||
|
|
||||||
|
bits_per_channel = options.get("bits_per_channel", 1)
|
||||||
|
return calculate_capacity(carrier, bits_per_channel)
|
||||||
91
src/stegasoo/backends/protocol.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
Embedding backend protocol definition.
|
||||||
|
|
||||||
|
All embedding backends (LSB, DCT, audio, video, etc.) implement this protocol,
|
||||||
|
enabling registry-based dispatch instead of if/elif chains.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class EmbeddingBackend(Protocol):
|
||||||
|
"""Protocol that all embedding backends must satisfy.
|
||||||
|
|
||||||
|
Each backend handles a specific embedding mode (e.g. 'lsb', 'dct',
|
||||||
|
'audio_lsb', 'audio_spread') for a specific carrier type ('image',
|
||||||
|
'audio', 'video').
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mode(self) -> str:
|
||||||
|
"""The embedding mode identifier (e.g. 'lsb', 'dct')."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def carrier_type(self) -> str:
|
||||||
|
"""The carrier media type: 'image', 'audio', or 'video'."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Whether this backend's dependencies are installed."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def embed(
|
||||||
|
self,
|
||||||
|
data: bytes,
|
||||||
|
carrier: bytes,
|
||||||
|
key: bytes,
|
||||||
|
*,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
**options: Any,
|
||||||
|
) -> tuple[bytes, Any]:
|
||||||
|
"""Embed data into a carrier.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Encrypted payload bytes.
|
||||||
|
carrier: Raw carrier file bytes (image, audio, etc.).
|
||||||
|
key: Derived key for pixel/sample selection.
|
||||||
|
progress_file: Optional progress file path.
|
||||||
|
**options: Backend-specific options (bits_per_channel,
|
||||||
|
output_format, color_mode, chip_tier, etc.).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (stego carrier bytes, embed stats).
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def extract(
|
||||||
|
self,
|
||||||
|
carrier: bytes,
|
||||||
|
key: bytes,
|
||||||
|
*,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
**options: Any,
|
||||||
|
) -> bytes | None:
|
||||||
|
"""Extract data from a carrier.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
carrier: Stego carrier file bytes.
|
||||||
|
key: Derived key for pixel/sample selection.
|
||||||
|
progress_file: Optional progress file path.
|
||||||
|
**options: Backend-specific options.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Extracted payload bytes, or None if no payload found.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def calculate_capacity(self, carrier: bytes, **options: Any) -> int:
|
||||||
|
"""Calculate maximum embeddable payload size in bytes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
carrier: Raw carrier file bytes.
|
||||||
|
**options: Backend-specific options (e.g. bits_per_channel).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Maximum payload capacity in bytes.
|
||||||
|
"""
|
||||||
|
...
|
||||||
63
src/stegasoo/backends/registry.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Backend registry for embedding mode dispatch.
|
||||||
|
|
||||||
|
Backends register themselves by mode string. The registry replaces
|
||||||
|
if/elif dispatch in steganography.py with a lookup table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..exceptions import StegasooError
|
||||||
|
from .protocol import EmbeddingBackend
|
||||||
|
|
||||||
|
|
||||||
|
class BackendNotFoundError(StegasooError):
|
||||||
|
"""Raised when a requested backend mode is not registered."""
|
||||||
|
|
||||||
|
|
||||||
|
class BackendRegistry:
|
||||||
|
"""Registry mapping mode strings to embedding backends."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._backends: dict[str, EmbeddingBackend] = {}
|
||||||
|
|
||||||
|
def register(self, backend: EmbeddingBackend) -> None:
|
||||||
|
"""Register a backend for its mode string."""
|
||||||
|
self._backends[backend.mode] = backend
|
||||||
|
|
||||||
|
def get(self, mode: str) -> EmbeddingBackend:
|
||||||
|
"""Look up a backend by mode. Raises BackendNotFoundError if not found."""
|
||||||
|
if mode not in self._backends:
|
||||||
|
available = ", ".join(sorted(self._backends.keys())) or "(none)"
|
||||||
|
raise BackendNotFoundError(
|
||||||
|
f"No backend registered for mode '{mode}'. Available: {available}"
|
||||||
|
)
|
||||||
|
return self._backends[mode]
|
||||||
|
|
||||||
|
def has(self, mode: str) -> bool:
|
||||||
|
"""Check if a backend is registered for the given mode."""
|
||||||
|
return mode in self._backends
|
||||||
|
|
||||||
|
def available_modes(self, carrier_type: str | None = None) -> list[str]:
|
||||||
|
"""List registered mode strings, optionally filtered by carrier type.
|
||||||
|
|
||||||
|
Only includes modes whose backend reports is_available() == True.
|
||||||
|
"""
|
||||||
|
return sorted(
|
||||||
|
mode
|
||||||
|
for mode, backend in self._backends.items()
|
||||||
|
if backend.is_available()
|
||||||
|
and (carrier_type is None or backend.carrier_type == carrier_type)
|
||||||
|
)
|
||||||
|
|
||||||
|
def all_modes(self, carrier_type: str | None = None) -> list[str]:
|
||||||
|
"""List all registered mode strings (including unavailable ones)."""
|
||||||
|
return sorted(
|
||||||
|
mode
|
||||||
|
for mode, backend in self._backends.items()
|
||||||
|
if carrier_type is None or backend.carrier_type == carrier_type
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton
|
||||||
|
registry = BackendRegistry()
|
||||||
@@ -69,6 +69,7 @@ def _get_machine_key() -> bytes:
|
|||||||
# Fallback to hostname
|
# Fallback to hostname
|
||||||
if not machine_id:
|
if not machine_id:
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
machine_id = socket.gethostname()
|
machine_id = socket.gethostname()
|
||||||
|
|
||||||
# Hash to get consistent 32 bytes
|
# Hash to get consistent 32 bytes
|
||||||
@@ -87,10 +88,7 @@ def _encrypt_for_storage(plaintext: str) -> str:
|
|||||||
plaintext_bytes = plaintext.encode()
|
plaintext_bytes = plaintext.encode()
|
||||||
|
|
||||||
# XOR with key (cycling if needed)
|
# XOR with key (cycling if needed)
|
||||||
encrypted = bytes(
|
encrypted = bytes(pb ^ key[i % len(key)] for i, pb in enumerate(plaintext_bytes))
|
||||||
pb ^ key[i % len(key)]
|
|
||||||
for i, pb in enumerate(plaintext_bytes)
|
|
||||||
)
|
|
||||||
|
|
||||||
return ENCRYPTED_PREFIX + base64.b64encode(encrypted).decode()
|
return ENCRYPTED_PREFIX + base64.b64encode(encrypted).decode()
|
||||||
|
|
||||||
@@ -108,14 +106,11 @@ def _decrypt_from_storage(stored: str) -> str | None:
|
|||||||
return stored
|
return stored
|
||||||
|
|
||||||
try:
|
try:
|
||||||
encrypted = base64.b64decode(stored[len(ENCRYPTED_PREFIX):])
|
encrypted = base64.b64decode(stored[len(ENCRYPTED_PREFIX) :])
|
||||||
key = _get_machine_key()
|
key = _get_machine_key()
|
||||||
|
|
||||||
# XOR to decrypt
|
# XOR to decrypt
|
||||||
decrypted = bytes(
|
decrypted = bytes(eb ^ key[i % len(key)] for i, eb in enumerate(encrypted))
|
||||||
eb ^ key[i % len(key)]
|
|
||||||
for i, eb in enumerate(encrypted)
|
|
||||||
)
|
|
||||||
|
|
||||||
return decrypted.decode()
|
return decrypted.decode()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -413,7 +408,11 @@ def get_channel_status() -> dict:
|
|||||||
try:
|
try:
|
||||||
stored = config_path.read_text().strip()
|
stored = config_path.read_text().strip()
|
||||||
file_key = _decrypt_from_storage(stored)
|
file_key = _decrypt_from_storage(stored)
|
||||||
if file_key and validate_channel_key(file_key) and format_channel_key(file_key) == key:
|
if (
|
||||||
|
file_key
|
||||||
|
and validate_channel_key(file_key)
|
||||||
|
and format_channel_key(file_key) == key
|
||||||
|
):
|
||||||
source = str(config_path)
|
source = str(config_path)
|
||||||
break
|
break
|
||||||
except (OSError, PermissionError, ValueError):
|
except (OSError, PermissionError, ValueError):
|
||||||
@@ -485,7 +484,9 @@ def resolve_channel_key(
|
|||||||
>>> resolve_channel_key("ABCD-1234-...") # -> "ABCD-1234-..."
|
>>> resolve_channel_key("ABCD-1234-...") # -> "ABCD-1234-..."
|
||||||
>>> resolve_channel_key(file_path="key.txt") # reads from file
|
>>> resolve_channel_key(file_path="key.txt") # reads from file
|
||||||
"""
|
"""
|
||||||
debug.print(f"resolve_channel_key: value={value}, file_path={file_path}, no_channel={no_channel}")
|
debug.print(
|
||||||
|
f"resolve_channel_key: value={value}, file_path={file_path}, no_channel={no_channel}"
|
||||||
|
)
|
||||||
|
|
||||||
# no_channel flag takes precedence
|
# no_channel flag takes precedence
|
||||||
if no_channel:
|
if no_channel:
|
||||||
|
|||||||
1628
src/stegasoo/cli.py
@@ -9,6 +9,10 @@ import struct
|
|||||||
import zlib
|
import zlib
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
|
||||||
|
from .debug import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
# Optional LZ4 support (faster, slightly worse ratio)
|
# Optional LZ4 support (faster, slightly worse ratio)
|
||||||
try:
|
try:
|
||||||
import lz4.frame
|
import lz4.frame
|
||||||
@@ -17,6 +21,14 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_LZ4 = False
|
HAS_LZ4 = False
|
||||||
|
|
||||||
|
# Optional ZSTD support (best ratio, fast)
|
||||||
|
try:
|
||||||
|
import zstandard as zstd
|
||||||
|
|
||||||
|
HAS_ZSTD = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_ZSTD = False
|
||||||
|
|
||||||
|
|
||||||
class CompressionAlgorithm(IntEnum):
|
class CompressionAlgorithm(IntEnum):
|
||||||
"""Supported compression algorithms."""
|
"""Supported compression algorithms."""
|
||||||
@@ -24,6 +36,7 @@ class CompressionAlgorithm(IntEnum):
|
|||||||
NONE = 0
|
NONE = 0
|
||||||
ZLIB = 1
|
ZLIB = 1
|
||||||
LZ4 = 2
|
LZ4 = 2
|
||||||
|
ZSTD = 3 # v4.2.0: Best ratio, fast compression
|
||||||
|
|
||||||
|
|
||||||
# Magic bytes for compressed payloads
|
# Magic bytes for compressed payloads
|
||||||
@@ -72,6 +85,15 @@ def compress(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm
|
|||||||
algorithm = CompressionAlgorithm.ZLIB
|
algorithm = CompressionAlgorithm.ZLIB
|
||||||
else:
|
else:
|
||||||
compressed = lz4.frame.compress(data)
|
compressed = lz4.frame.compress(data)
|
||||||
|
|
||||||
|
elif algorithm == CompressionAlgorithm.ZSTD:
|
||||||
|
if not HAS_ZSTD:
|
||||||
|
# Fall back to zlib if ZSTD not available
|
||||||
|
compressed = zlib.compress(data, level=ZLIB_LEVEL)
|
||||||
|
algorithm = CompressionAlgorithm.ZLIB
|
||||||
|
else:
|
||||||
|
cctx = zstd.ZstdCompressor(level=19) # High compression level
|
||||||
|
compressed = cctx.compress(data)
|
||||||
else:
|
else:
|
||||||
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
|
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
|
||||||
|
|
||||||
@@ -123,6 +145,15 @@ def decompress(data: bytes) -> bytes:
|
|||||||
result = lz4.frame.decompress(compressed_data)
|
result = lz4.frame.decompress(compressed_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise CompressionError(f"LZ4 decompression failed: {e}")
|
raise CompressionError(f"LZ4 decompression failed: {e}")
|
||||||
|
|
||||||
|
elif algorithm == CompressionAlgorithm.ZSTD:
|
||||||
|
if not HAS_ZSTD:
|
||||||
|
raise CompressionError("ZSTD compression used but zstandard package not installed")
|
||||||
|
try:
|
||||||
|
dctx = zstd.ZstdDecompressor()
|
||||||
|
result = dctx.decompress(compressed_data)
|
||||||
|
except Exception as e:
|
||||||
|
raise CompressionError(f"ZSTD decompression failed: {e}")
|
||||||
else:
|
else:
|
||||||
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
|
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
|
||||||
|
|
||||||
@@ -181,6 +212,9 @@ def estimate_compressed_size(
|
|||||||
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
|
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
|
||||||
elif algorithm == CompressionAlgorithm.LZ4 and HAS_LZ4:
|
elif algorithm == CompressionAlgorithm.LZ4 and HAS_LZ4:
|
||||||
compressed_sample = lz4.frame.compress(sample)
|
compressed_sample = lz4.frame.compress(sample)
|
||||||
|
elif algorithm == CompressionAlgorithm.ZSTD and HAS_ZSTD:
|
||||||
|
cctx = zstd.ZstdCompressor(level=19)
|
||||||
|
compressed_sample = cctx.compress(sample)
|
||||||
else:
|
else:
|
||||||
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
|
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
|
||||||
|
|
||||||
@@ -195,14 +229,24 @@ def get_available_algorithms() -> list[CompressionAlgorithm]:
|
|||||||
algorithms = [CompressionAlgorithm.NONE, CompressionAlgorithm.ZLIB]
|
algorithms = [CompressionAlgorithm.NONE, CompressionAlgorithm.ZLIB]
|
||||||
if HAS_LZ4:
|
if HAS_LZ4:
|
||||||
algorithms.append(CompressionAlgorithm.LZ4)
|
algorithms.append(CompressionAlgorithm.LZ4)
|
||||||
|
if HAS_ZSTD:
|
||||||
|
algorithms.append(CompressionAlgorithm.ZSTD)
|
||||||
return algorithms
|
return algorithms
|
||||||
|
|
||||||
|
|
||||||
|
def get_best_algorithm() -> CompressionAlgorithm:
|
||||||
|
"""Get the best available compression algorithm (prefer ZSTD > ZLIB > LZ4)."""
|
||||||
|
if HAS_ZSTD:
|
||||||
|
return CompressionAlgorithm.ZSTD
|
||||||
|
return CompressionAlgorithm.ZLIB
|
||||||
|
|
||||||
|
|
||||||
def algorithm_name(algo: CompressionAlgorithm) -> str:
|
def algorithm_name(algo: CompressionAlgorithm) -> str:
|
||||||
"""Get human-readable algorithm name."""
|
"""Get human-readable algorithm name."""
|
||||||
names = {
|
names = {
|
||||||
CompressionAlgorithm.NONE: "None",
|
CompressionAlgorithm.NONE: "None",
|
||||||
CompressionAlgorithm.ZLIB: "Zlib (deflate)",
|
CompressionAlgorithm.ZLIB: "Zlib (deflate)",
|
||||||
CompressionAlgorithm.LZ4: "LZ4 (fast)",
|
CompressionAlgorithm.LZ4: "LZ4 (fast)",
|
||||||
|
CompressionAlgorithm.ZSTD: "Zstd (best)",
|
||||||
}
|
}
|
||||||
return names.get(algo, "Unknown")
|
return names.get(algo, "Unknown")
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
Stegasoo Constants and Configuration (v4.0.2 - Web UI Authentication)
|
Stegasoo Constants and Configuration (v4.2.0 - Performance & Compression)
|
||||||
|
|
||||||
Central location for all magic numbers, limits, and crypto parameters.
|
Central location for all magic numbers, limits, and crypto parameters.
|
||||||
All version numbers, limits, and configuration values should be defined here.
|
All version numbers, limits, and configuration values should be defined here.
|
||||||
|
|
||||||
|
CHANGES in v4.2.0:
|
||||||
|
- Added zstd compression for QR codes (better ratio than zlib)
|
||||||
|
- RSA key size capped at 3072 bits (4096 too large for QR codes)
|
||||||
|
- Progress bar improvements for encode/decode operations
|
||||||
|
- File auto-expire increased to 10 minutes
|
||||||
|
|
||||||
CHANGES in v4.0.2:
|
CHANGES in v4.0.2:
|
||||||
- Added Web UI authentication with SQLite3 user storage
|
- Added Web UI authentication with SQLite3 user storage
|
||||||
- Added optional HTTPS with auto-generated self-signed certificates
|
- Added optional HTTPS with auto-generated self-signed certificates
|
||||||
@@ -19,13 +25,14 @@ BREAKING CHANGES in v3.2.0:
|
|||||||
- Renamed day_phrase → passphrase throughout codebase
|
- Renamed day_phrase → passphrase throughout codebase
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import importlib.resources
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# VERSION
|
# VERSION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
__version__ = "4.1.5"
|
__version__ = "4.2.1"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# FILE FORMAT
|
# FILE FORMAT
|
||||||
@@ -37,7 +44,9 @@ MAGIC_HEADER = b"\x89ST3"
|
|||||||
# Version 1-3: Date-dependent encryption (v3.0.x - v3.1.x)
|
# Version 1-3: Date-dependent encryption (v3.0.x - v3.1.x)
|
||||||
# Version 4: Date-independent encryption (v3.2.0)
|
# Version 4: Date-independent encryption (v3.2.0)
|
||||||
# Version 5: Channel key support (v4.0.0) - adds flags byte to header
|
# Version 5: Channel key support (v4.0.0) - adds flags byte to header
|
||||||
FORMAT_VERSION = 5
|
# Version 6: HKDF per-message key derivation (v4.4.0) - adds message nonce to header
|
||||||
|
FORMAT_VERSION = 6
|
||||||
|
FORMAT_VERSION_LEGACY = 5 # For backward-compatible decryption
|
||||||
|
|
||||||
# Payload type markers
|
# Payload type markers
|
||||||
PAYLOAD_TEXT = 0x01
|
PAYLOAD_TEXT = 0x01
|
||||||
@@ -59,6 +68,11 @@ ARGON2_PARALLELISM = 4
|
|||||||
# PBKDF2 fallback parameters
|
# PBKDF2 fallback parameters
|
||||||
PBKDF2_ITERATIONS = 600000
|
PBKDF2_ITERATIONS = 600000
|
||||||
|
|
||||||
|
# HKDF per-message key derivation (v4.4.0 / FORMAT_VERSION 6)
|
||||||
|
MESSAGE_NONCE_SIZE = 16 # 128-bit random nonce per message
|
||||||
|
HKDF_INFO_ENCRYPT = b"stegasoo-v6-encrypt" # HKDF info for encryption key
|
||||||
|
HKDF_INFO_PIXEL = b"stegasoo-v6-pixel" # HKDF info for pixel selection key (reserved)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# INPUT LIMITS
|
# INPUT LIMITS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -98,7 +112,7 @@ DEFAULT_PHRASE_WORDS = DEFAULT_PASSPHRASE_WORDS
|
|||||||
|
|
||||||
# RSA configuration
|
# RSA configuration
|
||||||
MIN_RSA_BITS = 2048
|
MIN_RSA_BITS = 2048
|
||||||
VALID_RSA_SIZES = (2048, 3072, 4096)
|
VALID_RSA_SIZES = (2048, 3072) # 4096 removed - too large for QR codes
|
||||||
DEFAULT_RSA_BITS = 2048
|
DEFAULT_RSA_BITS = 2048
|
||||||
|
|
||||||
MIN_KEY_PASSWORD_LENGTH = 8
|
MIN_KEY_PASSWORD_LENGTH = 8
|
||||||
@@ -108,8 +122,8 @@ MIN_KEY_PASSWORD_LENGTH = 8
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# Temporary file storage
|
# Temporary file storage
|
||||||
TEMP_FILE_EXPIRY = 300 # 5 minutes in seconds
|
TEMP_FILE_EXPIRY = 600 # 10 minutes in seconds
|
||||||
TEMP_FILE_EXPIRY_MINUTES = 5
|
TEMP_FILE_EXPIRY_MINUTES = 10
|
||||||
|
|
||||||
# Thumbnail settings
|
# Thumbnail settings
|
||||||
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnails
|
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnails
|
||||||
@@ -171,15 +185,32 @@ BATCH_OUTPUT_SUFFIX = "_encoded"
|
|||||||
|
|
||||||
|
|
||||||
def get_data_dir() -> Path:
|
def get_data_dir() -> Path:
|
||||||
"""Get the data directory path."""
|
"""Get the data directory path.
|
||||||
# Check multiple locations
|
|
||||||
|
Checks locations in order:
|
||||||
|
1. Package data (installed via pip/wheel) using importlib.resources
|
||||||
|
2. Development layout (src/stegasoo -> project root/data)
|
||||||
|
3. Docker container (/app/data)
|
||||||
|
4. Current working directory fallbacks
|
||||||
|
"""
|
||||||
|
# Try package data first (works when installed via pip)
|
||||||
|
try:
|
||||||
|
pkg_data = importlib.resources.files("stegasoo.data")
|
||||||
|
# Check if the package data directory exists and has our files
|
||||||
|
if (pkg_data / "bip39-words.txt").is_file():
|
||||||
|
# Return as Path - importlib.resources.files returns a Traversable
|
||||||
|
return Path(str(pkg_data))
|
||||||
|
except (ModuleNotFoundError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to file-based locations
|
||||||
# From src/stegasoo/constants.py:
|
# From src/stegasoo/constants.py:
|
||||||
# .parent = src/stegasoo/
|
# .parent = src/stegasoo/
|
||||||
# .parent.parent = src/
|
# .parent.parent = src/
|
||||||
# .parent.parent.parent = project root (where data/ lives)
|
# .parent.parent.parent = project root (where data/ lives)
|
||||||
candidates = [
|
candidates = [
|
||||||
|
Path(__file__).parent / "data", # Installed package (stegasoo/data/)
|
||||||
Path(__file__).parent.parent.parent / "data", # Development: src/stegasoo -> project root
|
Path(__file__).parent.parent.parent / "data", # Development: src/stegasoo -> project root
|
||||||
Path(__file__).parent / "data", # Installed package
|
|
||||||
Path("/app/data"), # Docker
|
Path("/app/data"), # Docker
|
||||||
Path.cwd() / "data", # Current directory
|
Path.cwd() / "data", # Current directory
|
||||||
Path.cwd().parent / "data", # One level up from cwd
|
Path.cwd().parent / "data", # One level up from cwd
|
||||||
@@ -190,8 +221,8 @@ def get_data_dir() -> Path:
|
|||||||
if path.exists():
|
if path.exists():
|
||||||
return path
|
return path
|
||||||
|
|
||||||
# Default to first candidate
|
# Default to package data path for clearer error messages
|
||||||
return candidates[0]
|
return Path(__file__).parent / "data"
|
||||||
|
|
||||||
|
|
||||||
def get_bip39_words() -> list[str]:
|
def get_bip39_words() -> list[str]:
|
||||||
@@ -220,6 +251,17 @@ def get_wordlist() -> list[str]:
|
|||||||
return _bip39_words
|
return _bip39_words
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STEGANALYSIS (v4.4.0)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Chi-square p-value threshold: HIGH p-value = equalized PoV pairs = suspicious
|
||||||
|
STEGANALYSIS_CHI_SUSPICIOUS_THRESHOLD = 0.95 # p > 0.95 → pairs suspiciously equalized
|
||||||
|
|
||||||
|
# RS embedding rate thresholds (primary metric): higher = more likely embedded
|
||||||
|
STEGANALYSIS_RS_HIGH_THRESHOLD = 0.3 # > 30% estimated embedding → high risk
|
||||||
|
STEGANALYSIS_RS_MEDIUM_THRESHOLD = 0.1 # > 10% estimated embedding → medium risk
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DCT STEGANOGRAPHY (v3.0+)
|
# DCT STEGANOGRAPHY (v3.0+)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -238,8 +280,7 @@ DCT_STEP_SIZE = 8 # QIM quantization step
|
|||||||
# SHA256("\x89ST3\x89DCT") - hardcoded so it never changes even if headers are added
|
# SHA256("\x89ST3\x89DCT") - hardcoded so it never changes even if headers are added
|
||||||
# Used to XOR recovery keys in QR codes so they scan as gibberish
|
# Used to XOR recovery keys in QR codes so they scan as gibberish
|
||||||
RECOVERY_OBFUSCATION_KEY = bytes.fromhex(
|
RECOVERY_OBFUSCATION_KEY = bytes.fromhex(
|
||||||
"d6c70bce27780db942562550e9fe1459"
|
"d6c70bce27780db942562550e9fe1459" "9dfdb8421f5acc79696b05db4e7afbd2"
|
||||||
"9dfdb8421f5acc79696b05db4e7afbd2"
|
|
||||||
) # 32 bytes
|
) # 32 bytes
|
||||||
|
|
||||||
# Valid embedding modes
|
# Valid embedding modes
|
||||||
@@ -271,3 +312,144 @@ def detect_stego_mode(encrypted_data: bytes) -> str:
|
|||||||
return EMBED_MODE_DCT
|
return EMBED_MODE_DCT
|
||||||
else:
|
else:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FEATURE TOGGLES (v4.3.1)
|
||||||
|
# =============================================================================
|
||||||
|
# Environment variables to enable/disable optional feature families.
|
||||||
|
# Values: "auto" (default — detect dependencies), "1"/"true" (force on),
|
||||||
|
# "0"/"false" (force off even if deps are installed).
|
||||||
|
# Pi builds or minimal installs can set STEGASOO_AUDIO=0 to stay image-only.
|
||||||
|
|
||||||
|
import os as _os
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_feature_toggle(env_var: str, default: str = "auto") -> str | bool:
|
||||||
|
"""Parse a feature toggle env var. Returns 'auto', True, or False."""
|
||||||
|
val = _os.environ.get(env_var, default).strip().lower()
|
||||||
|
if val in ("1", "true", "yes", "on"):
|
||||||
|
return True
|
||||||
|
if val in ("0", "false", "no", "off"):
|
||||||
|
return False
|
||||||
|
return "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_audio_deps() -> bool:
|
||||||
|
"""Check if audio dependencies (soundfile, numpy) are importable."""
|
||||||
|
try:
|
||||||
|
import numpy # noqa: F401
|
||||||
|
import soundfile # noqa: F401
|
||||||
|
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_video_deps() -> bool:
|
||||||
|
"""Check if video dependencies (ffmpeg binary + audio deps) are available."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
if not _check_audio_deps():
|
||||||
|
return False
|
||||||
|
return shutil.which("ffmpeg") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_feature(toggle: str | bool, dep_check: callable) -> bool:
|
||||||
|
"""Resolve a feature toggle to a final bool."""
|
||||||
|
if toggle is True:
|
||||||
|
if not dep_check():
|
||||||
|
raise ImportError(
|
||||||
|
f"Feature force-enabled but required dependencies are missing. "
|
||||||
|
f"Install the relevant extras (e.g. pip install stegasoo[audio])."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
if toggle is False:
|
||||||
|
return False
|
||||||
|
# auto
|
||||||
|
return dep_check()
|
||||||
|
|
||||||
|
|
||||||
|
_audio_toggle = _parse_feature_toggle("STEGASOO_AUDIO")
|
||||||
|
_video_toggle = _parse_feature_toggle("STEGASOO_VIDEO")
|
||||||
|
|
||||||
|
AUDIO_ENABLED: bool = _resolve_feature(_audio_toggle, _check_audio_deps)
|
||||||
|
VIDEO_ENABLED: bool = _resolve_feature(_video_toggle, _check_video_deps)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AUDIO STEGANOGRAPHY (v4.3.0)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Audio embedding modes
|
||||||
|
EMBED_MODE_AUDIO_LSB = "audio_lsb"
|
||||||
|
EMBED_MODE_AUDIO_SPREAD = "audio_spread"
|
||||||
|
EMBED_MODE_AUDIO_AUTO = "audio_auto"
|
||||||
|
VALID_AUDIO_EMBED_MODES = {EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_SPREAD}
|
||||||
|
|
||||||
|
# Audio magic bytes (for format detection in stego audio)
|
||||||
|
AUDIO_MAGIC_LSB = b"AUDL"
|
||||||
|
AUDIO_MAGIC_SPREAD = b"AUDS"
|
||||||
|
|
||||||
|
# Audio input limits
|
||||||
|
MAX_AUDIO_DURATION = 600 # 10 minutes
|
||||||
|
MAX_AUDIO_FILE_SIZE = 100 * 1024 * 1024 # 100 MB
|
||||||
|
MIN_AUDIO_SAMPLE_RATE = 8000 # G.729 level
|
||||||
|
MAX_AUDIO_SAMPLE_RATE = 192000 # Studio quality
|
||||||
|
ALLOWED_AUDIO_EXTENSIONS = {"wav", "flac", "mp3", "ogg", "opus", "aac", "m4a", "wma"}
|
||||||
|
|
||||||
|
# Spread spectrum parameters
|
||||||
|
AUDIO_SS_CHIP_LENGTH = 1024 # Samples per chip (spreading factor) — legacy/default
|
||||||
|
AUDIO_SS_AMPLITUDE = 0.05 # Per-sample embedding strength (~-26dB, masked by audio)
|
||||||
|
AUDIO_SS_RS_NSYM = 32 # Reed-Solomon parity symbols
|
||||||
|
|
||||||
|
# Spread spectrum v2: per-channel hybrid embedding (v4.4.0)
|
||||||
|
AUDIO_SS_HEADER_VERSION = 2 # v2 header format identifier
|
||||||
|
|
||||||
|
# Chip tier system — trade capacity for robustness
|
||||||
|
AUDIO_SS_CHIP_TIER_LOSSLESS = 0 # 256 chips — lossless carriers (FLAC/WAV/ALAC)
|
||||||
|
AUDIO_SS_CHIP_TIER_HIGH_LOSSY = 1 # 512 chips — high-rate lossy (AAC 256k+)
|
||||||
|
AUDIO_SS_CHIP_TIER_LOW_LOSSY = 2 # 1024 chips — low-rate lossy (AAC 128k, Opus)
|
||||||
|
AUDIO_SS_DEFAULT_CHIP_TIER = 2 # Most robust, backward compatible
|
||||||
|
AUDIO_SS_CHIP_LENGTHS = {0: 256, 1: 512, 2: 1024}
|
||||||
|
|
||||||
|
# Chip tier name mapping (for CLI/UI)
|
||||||
|
AUDIO_SS_CHIP_TIER_NAMES = {
|
||||||
|
"lossless": AUDIO_SS_CHIP_TIER_LOSSLESS,
|
||||||
|
"high": AUDIO_SS_CHIP_TIER_HIGH_LOSSY,
|
||||||
|
"low": AUDIO_SS_CHIP_TIER_LOW_LOSSY,
|
||||||
|
}
|
||||||
|
|
||||||
|
# LFE channel skipping — LFE is bandlimited to ~120Hz, terrible carrier
|
||||||
|
AUDIO_LFE_CHANNEL_INDEX = 3 # Standard WAV/WAVEFORMATEXTENSIBLE ordering
|
||||||
|
AUDIO_LFE_MIN_CHANNELS = 6 # Only skip LFE for 5.1+ layouts
|
||||||
|
|
||||||
|
# Echo hiding parameters
|
||||||
|
AUDIO_ECHO_DELAY_0 = 50 # Echo delay for bit 0 (samples at 44.1kHz ~ 1.1ms)
|
||||||
|
AUDIO_ECHO_DELAY_1 = 100 # Echo delay for bit 1 (samples at 44.1kHz ~ 2.3ms)
|
||||||
|
AUDIO_ECHO_AMPLITUDE = 0.3 # Echo strength (relative to original)
|
||||||
|
AUDIO_ECHO_WINDOW_SIZE = 8192 # Window size for echo embedding
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# VIDEO STEGANOGRAPHY (v4.4.0)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Video embedding modes
|
||||||
|
EMBED_MODE_VIDEO_LSB = "video_lsb"
|
||||||
|
EMBED_MODE_VIDEO_AUTO = "video_auto"
|
||||||
|
VALID_VIDEO_EMBED_MODES = {EMBED_MODE_VIDEO_LSB}
|
||||||
|
|
||||||
|
# Video magic bytes (for format detection in stego video)
|
||||||
|
VIDEO_MAGIC_LSB = b"VIDL"
|
||||||
|
|
||||||
|
# Video input limits
|
||||||
|
MAX_VIDEO_FILE_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB
|
||||||
|
MAX_VIDEO_DURATION = 3600 # 1 hour in seconds
|
||||||
|
MIN_VIDEO_RESOLUTION = (64, 64)
|
||||||
|
MAX_VIDEO_RESOLUTION = (7680, 4320) # 8K UHD
|
||||||
|
ALLOWED_VIDEO_EXTENSIONS = {"mp4", "mkv", "webm", "avi", "mov"}
|
||||||
|
|
||||||
|
# Video output settings
|
||||||
|
VIDEO_OUTPUT_CODEC = "ffv1" # FFV1 lossless codec
|
||||||
|
VIDEO_OUTPUT_CONTAINER = "mkv" # MKV container for FFV1
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ import secrets
|
|||||||
import struct
|
import struct
|
||||||
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives import hashes as _hashes
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
@@ -37,18 +39,24 @@ from .constants import (
|
|||||||
ARGON2_PARALLELISM,
|
ARGON2_PARALLELISM,
|
||||||
ARGON2_TIME_COST,
|
ARGON2_TIME_COST,
|
||||||
FORMAT_VERSION,
|
FORMAT_VERSION,
|
||||||
|
FORMAT_VERSION_LEGACY,
|
||||||
|
HKDF_INFO_ENCRYPT,
|
||||||
IV_SIZE,
|
IV_SIZE,
|
||||||
MAGIC_HEADER,
|
MAGIC_HEADER,
|
||||||
MAX_FILENAME_LENGTH,
|
MAX_FILENAME_LENGTH,
|
||||||
|
MESSAGE_NONCE_SIZE,
|
||||||
PAYLOAD_FILE,
|
PAYLOAD_FILE,
|
||||||
PAYLOAD_TEXT,
|
PAYLOAD_TEXT,
|
||||||
PBKDF2_ITERATIONS,
|
PBKDF2_ITERATIONS,
|
||||||
SALT_SIZE,
|
SALT_SIZE,
|
||||||
TAG_SIZE,
|
TAG_SIZE,
|
||||||
)
|
)
|
||||||
|
from .debug import get_logger
|
||||||
from .exceptions import DecryptionError, EncryptionError, InvalidHeaderError, KeyDerivationError
|
from .exceptions import DecryptionError, EncryptionError, InvalidHeaderError, KeyDerivationError
|
||||||
from .models import DecodeResult, FilePayload
|
from .models import DecodeResult, FilePayload
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
# Check for Argon2 availability
|
# Check for Argon2 availability
|
||||||
try:
|
try:
|
||||||
from argon2.low_level import Type, hash_secret_raw
|
from argon2.low_level import Type, hash_secret_raw
|
||||||
@@ -60,6 +68,7 @@ except ImportError:
|
|||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CHANNEL KEY RESOLUTION
|
# CHANNEL KEY RESOLUTION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -201,6 +210,18 @@ def derive_hybrid_key(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
photo_hash = hash_photo(photo_data)
|
photo_hash = hash_photo(photo_data)
|
||||||
|
logger.debug(
|
||||||
|
"derive_hybrid_key: photo_hash=%s, pin=%s, rsa=%s, channel=%s, salt=%d bytes",
|
||||||
|
photo_hash[:4].hex(),
|
||||||
|
"set" if pin else "none",
|
||||||
|
"set" if rsa_key_data else "none",
|
||||||
|
(
|
||||||
|
"explicit"
|
||||||
|
if isinstance(channel_key, str) and channel_key
|
||||||
|
else "auto" if channel_key is None else "none"
|
||||||
|
),
|
||||||
|
len(salt),
|
||||||
|
)
|
||||||
|
|
||||||
# Resolve channel key (server-specific binding)
|
# Resolve channel key (server-specific binding)
|
||||||
channel_hash = _resolve_channel_key(channel_key)
|
channel_hash = _resolve_channel_key(channel_key)
|
||||||
@@ -217,19 +238,30 @@ def derive_hybrid_key(
|
|||||||
if channel_hash:
|
if channel_hash:
|
||||||
key_material += channel_hash
|
key_material += channel_hash
|
||||||
|
|
||||||
|
logger.debug("Key material: %d bytes", len(key_material))
|
||||||
|
|
||||||
# Run it all through the KDF
|
# Run it all through the KDF
|
||||||
if HAS_ARGON2:
|
if HAS_ARGON2:
|
||||||
|
logger.debug(
|
||||||
|
"KDF: Argon2id (memory=%dKB, time=%d, parallel=%d)",
|
||||||
|
ARGON2_MEMORY_COST,
|
||||||
|
ARGON2_TIME_COST,
|
||||||
|
ARGON2_PARALLELISM,
|
||||||
|
)
|
||||||
# Argon2id: the good stuff
|
# Argon2id: the good stuff
|
||||||
key = hash_secret_raw(
|
key = hash_secret_raw(
|
||||||
secret=key_material,
|
secret=key_material,
|
||||||
salt=salt[:32],
|
salt=salt[:32],
|
||||||
time_cost=ARGON2_TIME_COST, # 4 iterations
|
time_cost=ARGON2_TIME_COST, # 4 iterations
|
||||||
memory_cost=ARGON2_MEMORY_COST, # 256 MB RAM
|
memory_cost=ARGON2_MEMORY_COST, # 256 MB RAM
|
||||||
parallelism=ARGON2_PARALLELISM, # 4 threads
|
parallelism=ARGON2_PARALLELISM, # 4 threads
|
||||||
hash_len=32,
|
hash_len=32,
|
||||||
type=Type.ID, # Hybrid mode: resists side-channel AND GPU attacks
|
type=Type.ID, # Hybrid mode: resists side-channel AND GPU attacks
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"KDF: PBKDF2 fallback (%d iterations) - argon2 not available", PBKDF2_ITERATIONS
|
||||||
|
)
|
||||||
# PBKDF2 fallback for systems without argon2-cffi
|
# PBKDF2 fallback for systems without argon2-cffi
|
||||||
# 600K iterations is slow but not memory-hard
|
# 600K iterations is slow but not memory-hard
|
||||||
kdf = PBKDF2HMAC(
|
kdf = PBKDF2HMAC(
|
||||||
@@ -241,6 +273,7 @@ def derive_hybrid_key(
|
|||||||
)
|
)
|
||||||
key = kdf.derive(key_material)
|
key = kdf.derive(key_material)
|
||||||
|
|
||||||
|
logger.debug("KDF complete, derived %d-byte key", len(key))
|
||||||
return key
|
return key
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -287,6 +320,30 @@ def derive_pixel_key(
|
|||||||
return hashlib.sha256(material + b"pixel_selection").digest()
|
return hashlib.sha256(material + b"pixel_selection").digest()
|
||||||
|
|
||||||
|
|
||||||
|
def derive_message_key(root_key: bytes, nonce: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Derive a per-message encryption key via HKDF-Expand.
|
||||||
|
|
||||||
|
Each message gets a unique encryption key even with identical credentials,
|
||||||
|
because the nonce is random per message. This provides key diversification:
|
||||||
|
compromising the ciphertext of one message doesn't help with another.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root_key: 32-byte root key from Argon2id/PBKDF2
|
||||||
|
nonce: 16-byte random nonce (unique per message)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
32-byte per-message encryption key
|
||||||
|
"""
|
||||||
|
hkdf = HKDFExpand(
|
||||||
|
algorithm=_hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
info=HKDF_INFO_ENCRYPT + nonce,
|
||||||
|
backend=default_backend(),
|
||||||
|
)
|
||||||
|
return hkdf.derive(root_key)
|
||||||
|
|
||||||
|
|
||||||
def _pack_payload(
|
def _pack_payload(
|
||||||
content: str | bytes | FilePayload,
|
content: str | bytes | FilePayload,
|
||||||
) -> tuple[bytes, int]:
|
) -> tuple[bytes, int]:
|
||||||
@@ -445,7 +502,12 @@ def encrypt_message(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
salt = secrets.token_bytes(SALT_SIZE)
|
salt = secrets.token_bytes(SALT_SIZE)
|
||||||
key = derive_hybrid_key(photo_data, passphrase, salt, pin, rsa_key_data, channel_key)
|
root_key = derive_hybrid_key(photo_data, passphrase, salt, pin, rsa_key_data, channel_key)
|
||||||
|
|
||||||
|
# v6: Per-message key via HKDF — each message gets a unique encryption key
|
||||||
|
message_nonce = secrets.token_bytes(MESSAGE_NONCE_SIZE)
|
||||||
|
key = derive_message_key(root_key, message_nonce)
|
||||||
|
|
||||||
iv = secrets.token_bytes(IV_SIZE)
|
iv = secrets.token_bytes(IV_SIZE)
|
||||||
|
|
||||||
# Determine flags
|
# Determine flags
|
||||||
@@ -457,6 +519,13 @@ def encrypt_message(
|
|||||||
# Pack payload with type marker
|
# Pack payload with type marker
|
||||||
packed_payload, _ = _pack_payload(message)
|
packed_payload, _ = _pack_payload(message)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"encrypt_message: packed_payload=%d bytes, flags=0x%02x, format_version=%d",
|
||||||
|
len(packed_payload),
|
||||||
|
flags,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
)
|
||||||
|
|
||||||
# Random padding to hide message length
|
# Random padding to hide message length
|
||||||
padding_len = secrets.randbelow(256) + 64
|
padding_len = secrets.randbelow(256) + 64
|
||||||
padded_len = ((len(packed_payload) + padding_len + 255) // 256) * 256
|
padded_len = ((len(packed_payload) + padding_len + 255) // 256) * 256
|
||||||
@@ -464,19 +533,43 @@ def encrypt_message(
|
|||||||
padding = secrets.token_bytes(padding_needed - 4) + struct.pack(">I", len(packed_payload))
|
padding = secrets.token_bytes(padding_needed - 4) + struct.pack(">I", len(packed_payload))
|
||||||
padded_message = packed_payload + padding
|
padded_message = packed_payload + padding
|
||||||
|
|
||||||
# Build header for AAD
|
logger.debug(
|
||||||
|
"Padded message: %d bytes (payload + %d padding)", len(padded_message), padding_needed
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build header for AAD (v6: includes nonce in authenticated data)
|
||||||
header = MAGIC_HEADER + bytes([FORMAT_VERSION, flags])
|
header = MAGIC_HEADER + bytes([FORMAT_VERSION, flags])
|
||||||
|
|
||||||
# Encrypt with AES-256-GCM
|
# Encrypt with AES-256-GCM
|
||||||
cipher = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend())
|
cipher = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend())
|
||||||
encryptor = cipher.encryptor()
|
encryptor = cipher.encryptor()
|
||||||
encryptor.authenticate_additional_data(header)
|
encryptor.authenticate_additional_data(header + message_nonce)
|
||||||
ciphertext = encryptor.update(padded_message) + encryptor.finalize()
|
ciphertext = encryptor.update(padded_message) + encryptor.finalize()
|
||||||
|
|
||||||
# v4.0.0: Header with flags byte
|
total_size = (
|
||||||
return header + salt + iv + encryptor.tag + ciphertext
|
len(header)
|
||||||
|
+ MESSAGE_NONCE_SIZE
|
||||||
|
+ len(salt)
|
||||||
|
+ len(iv)
|
||||||
|
+ len(encryptor.tag)
|
||||||
|
+ len(ciphertext)
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Encrypted output: %d bytes (header=%d, nonce=%d, salt=%d, iv=%d, tag=%d, ct=%d)",
|
||||||
|
total_size,
|
||||||
|
len(header),
|
||||||
|
MESSAGE_NONCE_SIZE,
|
||||||
|
len(salt),
|
||||||
|
len(iv),
|
||||||
|
len(encryptor.tag),
|
||||||
|
len(ciphertext),
|
||||||
|
)
|
||||||
|
|
||||||
|
# v6: [magic|version|flags|nonce|salt|iv|tag|ciphertext]
|
||||||
|
return header + message_nonce + salt + iv + encryptor.tag + ciphertext
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error("Encryption failed: %s", e)
|
||||||
raise EncryptionError(f"Encryption failed: {e}") from e
|
raise EncryptionError(f"Encryption failed: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
@@ -484,43 +577,78 @@ def parse_header(encrypted_data: bytes) -> dict | None:
|
|||||||
"""
|
"""
|
||||||
Parse the header from encrypted data.
|
Parse the header from encrypted data.
|
||||||
|
|
||||||
v4.0.0: Includes flags byte for channel key indicator.
|
Supports both v5 (legacy) and v6 (HKDF) header formats.
|
||||||
|
|
||||||
|
v5: [magic:4][ver:1][flags:1][salt:32][iv:12][tag:16][ciphertext] (66+ bytes)
|
||||||
|
v6: [magic:4][ver:1][flags:1][nonce:16][salt:32][iv:12][tag:16][ciphertext] (82+ bytes)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
encrypted_data: Raw encrypted bytes
|
encrypted_data: Raw encrypted bytes
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with salt, iv, tag, ciphertext, flags or None if invalid
|
Dict with version, salt, iv, tag, ciphertext, flags, and optionally
|
||||||
|
message_nonce (v6). Returns None if invalid.
|
||||||
"""
|
"""
|
||||||
# Min size: Magic(4) + Version(1) + Flags(1) + Salt(32) + IV(12) + Tag(16) = 66 bytes
|
# Min v5 size: 4+1+1+32+12+16 = 66 bytes
|
||||||
if len(encrypted_data) < 66 or encrypted_data[:4] != MAGIC_HEADER:
|
if len(encrypted_data) < 66 or encrypted_data[:4] != MAGIC_HEADER:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
version = encrypted_data[4]
|
version = encrypted_data[4]
|
||||||
if version != FORMAT_VERSION:
|
|
||||||
|
if version == FORMAT_VERSION:
|
||||||
|
# v6: has message nonce
|
||||||
|
if len(encrypted_data) < 82:
|
||||||
|
return None
|
||||||
|
flags = encrypted_data[5]
|
||||||
|
offset = 6
|
||||||
|
message_nonce = encrypted_data[offset : offset + MESSAGE_NONCE_SIZE]
|
||||||
|
offset += MESSAGE_NONCE_SIZE
|
||||||
|
salt = encrypted_data[offset : offset + SALT_SIZE]
|
||||||
|
offset += SALT_SIZE
|
||||||
|
iv = encrypted_data[offset : offset + IV_SIZE]
|
||||||
|
offset += IV_SIZE
|
||||||
|
tag = encrypted_data[offset : offset + TAG_SIZE]
|
||||||
|
offset += TAG_SIZE
|
||||||
|
ciphertext = encrypted_data[offset:]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": version,
|
||||||
|
"flags": flags,
|
||||||
|
"has_channel_key": bool(flags & FLAG_CHANNEL_KEY),
|
||||||
|
"message_nonce": message_nonce,
|
||||||
|
"salt": salt,
|
||||||
|
"iv": iv,
|
||||||
|
"tag": tag,
|
||||||
|
"ciphertext": ciphertext,
|
||||||
|
}
|
||||||
|
|
||||||
|
elif version == FORMAT_VERSION_LEGACY:
|
||||||
|
# v5: no nonce
|
||||||
|
flags = encrypted_data[5]
|
||||||
|
offset = 6
|
||||||
|
salt = encrypted_data[offset : offset + SALT_SIZE]
|
||||||
|
offset += SALT_SIZE
|
||||||
|
iv = encrypted_data[offset : offset + IV_SIZE]
|
||||||
|
offset += IV_SIZE
|
||||||
|
tag = encrypted_data[offset : offset + TAG_SIZE]
|
||||||
|
offset += TAG_SIZE
|
||||||
|
ciphertext = encrypted_data[offset:]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": version,
|
||||||
|
"flags": flags,
|
||||||
|
"has_channel_key": bool(flags & FLAG_CHANNEL_KEY),
|
||||||
|
"message_nonce": None,
|
||||||
|
"salt": salt,
|
||||||
|
"iv": iv,
|
||||||
|
"tag": tag,
|
||||||
|
"ciphertext": ciphertext,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
flags = encrypted_data[5]
|
|
||||||
|
|
||||||
offset = 6
|
|
||||||
salt = encrypted_data[offset : offset + SALT_SIZE]
|
|
||||||
offset += SALT_SIZE
|
|
||||||
iv = encrypted_data[offset : offset + IV_SIZE]
|
|
||||||
offset += IV_SIZE
|
|
||||||
tag = encrypted_data[offset : offset + TAG_SIZE]
|
|
||||||
offset += TAG_SIZE
|
|
||||||
ciphertext = encrypted_data[offset:]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"version": version,
|
|
||||||
"flags": flags,
|
|
||||||
"has_channel_key": bool(flags & FLAG_CHANNEL_KEY),
|
|
||||||
"salt": salt,
|
|
||||||
"iv": iv,
|
|
||||||
"tag": tag,
|
|
||||||
"ciphertext": ciphertext,
|
|
||||||
}
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -551,22 +679,42 @@ def decrypt_message(
|
|||||||
InvalidHeaderError: If data doesn't have valid Stegasoo header
|
InvalidHeaderError: If data doesn't have valid Stegasoo header
|
||||||
DecryptionError: If decryption fails (wrong credentials)
|
DecryptionError: If decryption fails (wrong credentials)
|
||||||
"""
|
"""
|
||||||
|
logger.debug("decrypt_message: %d bytes of encrypted data", len(encrypted_data))
|
||||||
|
|
||||||
header = parse_header(encrypted_data)
|
header = parse_header(encrypted_data)
|
||||||
if not header:
|
if not header:
|
||||||
|
logger.error("Invalid or missing Stegasoo header in %d bytes", len(encrypted_data))
|
||||||
raise InvalidHeaderError("Invalid or missing Stegasoo header")
|
raise InvalidHeaderError("Invalid or missing Stegasoo header")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Header: version=%d, flags=0x%02x, has_channel_key=%s, ciphertext=%d bytes",
|
||||||
|
header["version"],
|
||||||
|
header["flags"],
|
||||||
|
header["has_channel_key"],
|
||||||
|
len(header["ciphertext"]),
|
||||||
|
)
|
||||||
|
|
||||||
# Check for channel key mismatch and provide helpful error
|
# Check for channel key mismatch and provide helpful error
|
||||||
channel_hash = _resolve_channel_key(channel_key)
|
channel_hash = _resolve_channel_key(channel_key)
|
||||||
has_configured_key = channel_hash is not None
|
has_configured_key = channel_hash is not None
|
||||||
message_has_key = header["has_channel_key"]
|
message_has_key = header["has_channel_key"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
key = derive_hybrid_key(
|
root_key = derive_hybrid_key(
|
||||||
photo_data, passphrase, header["salt"], pin, rsa_key_data, channel_key
|
photo_data, passphrase, header["salt"], pin, rsa_key_data, channel_key
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reconstruct header for AAD verification
|
version = header["version"]
|
||||||
aad_header = MAGIC_HEADER + bytes([FORMAT_VERSION, header["flags"]])
|
message_nonce = header["message_nonce"]
|
||||||
|
|
||||||
|
if version == FORMAT_VERSION and message_nonce is not None:
|
||||||
|
# v6: Derive per-message key via HKDF
|
||||||
|
key = derive_message_key(root_key, message_nonce)
|
||||||
|
aad_header = MAGIC_HEADER + bytes([FORMAT_VERSION, header["flags"]]) + message_nonce
|
||||||
|
else:
|
||||||
|
# v5 (legacy): Root key used directly
|
||||||
|
key = root_key
|
||||||
|
aad_header = MAGIC_HEADER + bytes([FORMAT_VERSION_LEGACY, header["flags"]])
|
||||||
|
|
||||||
cipher = Cipher(
|
cipher = Cipher(
|
||||||
algorithms.AES(key), modes.GCM(header["iv"], header["tag"]), backend=default_backend()
|
algorithms.AES(key), modes.GCM(header["iv"], header["tag"]), backend=default_backend()
|
||||||
@@ -577,9 +725,16 @@ def decrypt_message(
|
|||||||
padded_plaintext = decryptor.update(header["ciphertext"]) + decryptor.finalize()
|
padded_plaintext = decryptor.update(header["ciphertext"]) + decryptor.finalize()
|
||||||
original_length = struct.unpack(">I", padded_plaintext[-4:])[0]
|
original_length = struct.unpack(">I", padded_plaintext[-4:])[0]
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Decrypted %d bytes, original payload length: %d",
|
||||||
|
len(padded_plaintext),
|
||||||
|
original_length,
|
||||||
|
)
|
||||||
|
|
||||||
payload_data = padded_plaintext[:original_length]
|
payload_data = padded_plaintext[:original_length]
|
||||||
result = _unpack_payload(payload_data)
|
result = _unpack_payload(payload_data)
|
||||||
|
|
||||||
|
logger.debug("Decryption successful: %s (v%d)", result.payload_type, version)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
1
src/stegasoo/data/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Package data directory for stegasoo
|
||||||
2048
src/stegasoo/data/bip39-words.txt
Normal file
@@ -2,27 +2,96 @@
|
|||||||
Stegasoo Debugging Utilities
|
Stegasoo Debugging Utilities
|
||||||
|
|
||||||
Debugging, logging, and performance monitoring tools.
|
Debugging, logging, and performance monitoring tools.
|
||||||
Can be disabled for production use.
|
|
||||||
|
Configuration:
|
||||||
|
STEGASOO_LOG_LEVEL env var controls log level:
|
||||||
|
- Not set or empty: logging disabled (production default)
|
||||||
|
- DEBUG: verbose debug output (encode/decode flow, crypto params, etc.)
|
||||||
|
- INFO: operational messages (format detection, mode selection)
|
||||||
|
- WARNING: potential issues (fallback KDF, format transcoding)
|
||||||
|
- ERROR: operation failures
|
||||||
|
|
||||||
|
STEGASOO_DEBUG=1 is a shorthand for STEGASOO_LOG_LEVEL=DEBUG
|
||||||
|
|
||||||
|
CLI: stegasoo --debug encode ... (sets DEBUG level for that invocation)
|
||||||
|
|
||||||
|
All output goes to Python's logging module under the 'stegasoo' logger hierarchy.
|
||||||
|
The legacy debug.print() API is preserved for backward compatibility.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
# Map string level names to logging levels
|
||||||
|
_LEVEL_MAP = {
|
||||||
|
"DEBUG": logging.DEBUG,
|
||||||
|
"INFO": logging.INFO,
|
||||||
|
"WARNING": logging.WARNING,
|
||||||
|
"ERROR": logging.ERROR,
|
||||||
|
"CRITICAL": logging.CRITICAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Root logger for the stegasoo package
|
||||||
|
logger = logging.getLogger("stegasoo")
|
||||||
|
|
||||||
# Global debug configuration
|
# Global debug configuration
|
||||||
DEBUG_ENABLED = False # Set to True to enable debug output
|
|
||||||
LOG_PERFORMANCE = True # Log function timing
|
LOG_PERFORMANCE = True # Log function timing
|
||||||
VALIDATION_ASSERTIONS = True # Enable runtime validation assertions
|
VALIDATION_ASSERTIONS = True # Enable runtime validation assertions
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_from_env() -> bool:
|
||||||
|
"""Configure logging from environment variables. Returns True if debug enabled."""
|
||||||
|
# STEGASOO_DEBUG=1 is shorthand for DEBUG level
|
||||||
|
if os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
|
||||||
|
_setup_logging(logging.DEBUG)
|
||||||
|
return True
|
||||||
|
|
||||||
|
level_str = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
|
||||||
|
if level_str and level_str in _LEVEL_MAP:
|
||||||
|
_setup_logging(_LEVEL_MAP[level_str])
|
||||||
|
return level_str == "DEBUG"
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_logging(level: int) -> None:
|
||||||
|
"""Configure the stegasoo logger with a stderr handler."""
|
||||||
|
logger.setLevel(level)
|
||||||
|
|
||||||
|
# Only add handler if none exist (avoid duplicates on re-init)
|
||||||
|
if not logger.handlers:
|
||||||
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
|
handler.setLevel(level)
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
else:
|
||||||
|
# Update existing handler level
|
||||||
|
for handler in logger.handlers:
|
||||||
|
handler.setLevel(level)
|
||||||
|
|
||||||
|
|
||||||
|
# Auto-configure on import
|
||||||
|
DEBUG_ENABLED = _configure_from_env()
|
||||||
|
|
||||||
|
|
||||||
def enable_debug(enable: bool = True) -> None:
|
def enable_debug(enable: bool = True) -> None:
|
||||||
"""Enable or disable debug mode globally."""
|
"""Enable or disable debug mode globally."""
|
||||||
global DEBUG_ENABLED
|
global DEBUG_ENABLED
|
||||||
DEBUG_ENABLED = enable
|
DEBUG_ENABLED = enable
|
||||||
|
if enable:
|
||||||
|
_setup_logging(logging.DEBUG)
|
||||||
|
else:
|
||||||
|
logger.setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
def enable_performance_logging(enable: bool = True) -> None:
|
def enable_performance_logging(enable: bool = True) -> None:
|
||||||
@@ -38,15 +107,14 @@ def enable_assertions(enable: bool = True) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def debug_print(message: str, level: str = "INFO") -> None:
|
def debug_print(message: str, level: str = "INFO") -> None:
|
||||||
"""Print debug message with timestamp if debugging is enabled."""
|
"""Log a message at the given level via the stegasoo logger."""
|
||||||
if DEBUG_ENABLED:
|
log_level = _LEVEL_MAP.get(level.upper(), logging.DEBUG)
|
||||||
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
logger.log(log_level, message)
|
||||||
print(f"[{timestamp}] [{level}] {message}", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str:
|
def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str:
|
||||||
"""Format bytes for debugging."""
|
"""Format bytes for debugging."""
|
||||||
if not DEBUG_ENABLED:
|
if not logger.isEnabledFor(logging.DEBUG):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
@@ -55,15 +123,17 @@ def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str:
|
|||||||
if len(data) <= max_bytes:
|
if len(data) <= max_bytes:
|
||||||
return f"{label} ({len(data)} bytes): {data.hex()}"
|
return f"{label} ({len(data)} bytes): {data.hex()}"
|
||||||
else:
|
else:
|
||||||
return f"{label} ({len(data)} bytes): {data[:max_bytes//2].hex()}...{data[-max_bytes//2:].hex()}"
|
return (
|
||||||
|
f"{label} ({len(data)} bytes): "
|
||||||
|
f"{data[:max_bytes // 2].hex()}...{data[-max_bytes // 2:].hex()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def debug_exception(e: Exception, context: str = "") -> None:
|
def debug_exception(e: Exception, context: str = "") -> None:
|
||||||
"""Log exception with context for debugging."""
|
"""Log exception with context for debugging."""
|
||||||
if DEBUG_ENABLED:
|
logger.error("Exception in %s: %s: %s", context, type(e).__name__, e)
|
||||||
debug_print(f"Exception in {context}: {type(e).__name__}: {e}", "ERROR")
|
if logger.isEnabledFor(logging.DEBUG):
|
||||||
if DEBUG_ENABLED:
|
logger.debug(traceback.format_exc())
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
|
|
||||||
def time_function(func: Callable) -> Callable:
|
def time_function(func: Callable) -> Callable:
|
||||||
@@ -71,7 +141,7 @@ def time_function(func: Callable) -> Callable:
|
|||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs) -> Any:
|
def wrapper(*args, **kwargs) -> Any:
|
||||||
if not (DEBUG_ENABLED and LOG_PERFORMANCE):
|
if not (logger.isEnabledFor(logging.DEBUG) and LOG_PERFORMANCE):
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
@@ -80,7 +150,7 @@ def time_function(func: Callable) -> Callable:
|
|||||||
return result
|
return result
|
||||||
finally:
|
finally:
|
||||||
end = time.perf_counter()
|
end = time.perf_counter()
|
||||||
debug_print(f"{func.__name__} took {end - start:.6f}s", "PERF")
|
logger.debug("%s took %.6fs", func.__name__, end - start)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
@@ -94,8 +164,6 @@ def validate_assertion(condition: bool, message: str) -> None:
|
|||||||
def memory_usage() -> dict[str, float | str]:
|
def memory_usage() -> dict[str, float | str]:
|
||||||
"""Get current memory usage (if psutil is available)."""
|
"""Get current memory usage (if psutil is available)."""
|
||||||
try:
|
try:
|
||||||
import os
|
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
process = psutil.Process(os.getpid())
|
process = psutil.Process(os.getpid())
|
||||||
@@ -131,8 +199,19 @@ def hexdump(data: bytes, offset: int = 0, length: int = 64) -> str:
|
|||||||
return "\n".join(result)
|
return "\n".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
"""Get a child logger under the stegasoo namespace.
|
||||||
|
|
||||||
|
Usage in modules:
|
||||||
|
from .debug import get_logger
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.debug("message")
|
||||||
|
"""
|
||||||
|
return logging.getLogger(name)
|
||||||
|
|
||||||
|
|
||||||
class Debug:
|
class Debug:
|
||||||
"""Debugging utility class."""
|
"""Debugging utility class (backward-compatible API)."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.enabled = DEBUG_ENABLED
|
self.enabled = DEBUG_ENABLED
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Changes in v4.0.0:
|
|||||||
- Improved error messages for channel key mismatches
|
- Improved error messages for channel key mismatches
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .constants import EMBED_MODE_AUTO
|
from .constants import EMBED_MODE_AUTO
|
||||||
@@ -24,6 +25,25 @@ from .validation import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_progress(progress_file: str | None, current: int, total: int, phase: str) -> None:
|
||||||
|
"""Write progress to file for UI polling."""
|
||||||
|
if progress_file is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(progress_file, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"current": current,
|
||||||
|
"total": total,
|
||||||
|
"percent": (current / total * 100) if total > 0 else 0,
|
||||||
|
"phase": phase,
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def decode(
|
def decode(
|
||||||
stego_image: bytes,
|
stego_image: bytes,
|
||||||
reference_photo: bytes,
|
reference_photo: bytes,
|
||||||
@@ -33,6 +53,8 @@ def decode(
|
|||||||
rsa_password: str | None = None,
|
rsa_password: str | None = None,
|
||||||
embed_mode: str = EMBED_MODE_AUTO,
|
embed_mode: str = EMBED_MODE_AUTO,
|
||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
) -> DecodeResult:
|
) -> DecodeResult:
|
||||||
"""
|
"""
|
||||||
Decode a message or file from a stego image.
|
Decode a message or file from a stego image.
|
||||||
@@ -45,6 +67,7 @@ def decode(
|
|||||||
rsa_key_data: Optional RSA key bytes (if used during encoding)
|
rsa_key_data: Optional RSA key bytes (if used during encoding)
|
||||||
rsa_password: Optional RSA key password
|
rsa_password: Optional RSA key password
|
||||||
embed_mode: 'auto' (default), 'lsb', or 'dct'
|
embed_mode: 'auto' (default), 'lsb', or 'dct'
|
||||||
|
progress_file: Optional path to write progress JSON for UI polling
|
||||||
channel_key: Channel key for deployment/group isolation:
|
channel_key: Channel key for deployment/group isolation:
|
||||||
- None or "auto": Use server's configured key
|
- None or "auto": Use server's configured key
|
||||||
- str: Use this specific channel key
|
- str: Use this specific channel key
|
||||||
@@ -91,16 +114,32 @@ def decode(
|
|||||||
if rsa_key_data:
|
if rsa_key_data:
|
||||||
require_valid_rsa_key(rsa_key_data, rsa_password)
|
require_valid_rsa_key(rsa_key_data, rsa_password)
|
||||||
|
|
||||||
|
# Progress: starting key derivation (Argon2 - slow on Pi)
|
||||||
|
_write_progress(progress_file, 20, 100, "initializing")
|
||||||
|
|
||||||
# Derive pixel/coefficient selection key (with channel key)
|
# Derive pixel/coefficient selection key (with channel key)
|
||||||
from .crypto import derive_pixel_key
|
from .crypto import derive_pixel_key
|
||||||
|
|
||||||
pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key)
|
pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key)
|
||||||
|
|
||||||
|
# Progress: key derivation done, starting extraction
|
||||||
|
_write_progress(progress_file, 25, 100, "extracting")
|
||||||
|
|
||||||
|
# Resolve platform preset for DCT extraction
|
||||||
|
extract_kwargs = {}
|
||||||
|
if platform:
|
||||||
|
from .platform_presets import get_preset
|
||||||
|
|
||||||
|
preset = get_preset(platform)
|
||||||
|
extract_kwargs["quant_step"] = preset.quant_step
|
||||||
|
|
||||||
# Extract encrypted data
|
# Extract encrypted data
|
||||||
encrypted = extract_from_image(
|
encrypted = extract_from_image(
|
||||||
stego_image,
|
stego_image,
|
||||||
pixel_key,
|
pixel_key,
|
||||||
embed_mode=embed_mode,
|
embed_mode=embed_mode,
|
||||||
|
progress_file=progress_file,
|
||||||
|
**extract_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not encrypted:
|
if not encrypted:
|
||||||
@@ -126,6 +165,7 @@ def decode_file(
|
|||||||
rsa_password: str | None = None,
|
rsa_password: str | None = None,
|
||||||
embed_mode: str = EMBED_MODE_AUTO,
|
embed_mode: str = EMBED_MODE_AUTO,
|
||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""
|
"""
|
||||||
Decode a file from a stego image and save it.
|
Decode a file from a stego image and save it.
|
||||||
@@ -140,6 +180,7 @@ def decode_file(
|
|||||||
rsa_password: Optional RSA key password
|
rsa_password: Optional RSA key password
|
||||||
embed_mode: 'auto', 'lsb', or 'dct'
|
embed_mode: 'auto', 'lsb', or 'dct'
|
||||||
channel_key: Channel key parameter (see decode())
|
channel_key: Channel key parameter (see decode())
|
||||||
|
progress_file: Optional path to write progress JSON for UI polling
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path where file was saved
|
Path where file was saved
|
||||||
@@ -156,6 +197,7 @@ def decode_file(
|
|||||||
rsa_password,
|
rsa_password,
|
||||||
embed_mode,
|
embed_mode,
|
||||||
channel_key,
|
channel_key,
|
||||||
|
progress_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not result.is_file:
|
if not result.is_file:
|
||||||
@@ -184,6 +226,7 @@ def decode_text(
|
|||||||
rsa_password: str | None = None,
|
rsa_password: str | None = None,
|
||||||
embed_mode: str = EMBED_MODE_AUTO,
|
embed_mode: str = EMBED_MODE_AUTO,
|
||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Decode a text message from a stego image.
|
Decode a text message from a stego image.
|
||||||
@@ -199,6 +242,7 @@ def decode_text(
|
|||||||
rsa_password: Optional RSA key password
|
rsa_password: Optional RSA key password
|
||||||
embed_mode: 'auto', 'lsb', or 'dct'
|
embed_mode: 'auto', 'lsb', or 'dct'
|
||||||
channel_key: Channel key parameter (see decode())
|
channel_key: Channel key parameter (see decode())
|
||||||
|
progress_file: Optional path to write progress JSON for UI polling
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decoded message string
|
Decoded message string
|
||||||
@@ -215,6 +259,7 @@ def decode_text(
|
|||||||
rsa_password,
|
rsa_password,
|
||||||
embed_mode,
|
embed_mode,
|
||||||
channel_key,
|
channel_key,
|
||||||
|
progress_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.is_file:
|
if result.is_file:
|
||||||
@@ -229,3 +274,219 @@ def decode_text(
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
return result.message or ""
|
return result.message or ""
|
||||||
|
|
||||||
|
|
||||||
|
def decode_audio(
|
||||||
|
stego_audio: bytes,
|
||||||
|
reference_photo: bytes,
|
||||||
|
passphrase: str,
|
||||||
|
pin: str = "",
|
||||||
|
rsa_key_data: bytes | None = None,
|
||||||
|
rsa_password: str | None = None,
|
||||||
|
embed_mode: str = "audio_auto",
|
||||||
|
channel_key: str | bool | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
) -> DecodeResult:
|
||||||
|
"""
|
||||||
|
Decode a message or file from stego audio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stego_audio: Stego audio bytes
|
||||||
|
reference_photo: Shared reference photo bytes
|
||||||
|
passphrase: Shared passphrase
|
||||||
|
pin: Optional static PIN
|
||||||
|
rsa_key_data: Optional RSA key bytes
|
||||||
|
rsa_password: Optional RSA key password
|
||||||
|
embed_mode: 'audio_auto', 'audio_lsb', or 'audio_spread'
|
||||||
|
channel_key: Channel key for deployment/group isolation
|
||||||
|
progress_file: Optional path to write progress JSON
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DecodeResult with message or file data
|
||||||
|
"""
|
||||||
|
from .constants import (
|
||||||
|
AUDIO_ENABLED,
|
||||||
|
EMBED_MODE_AUDIO_AUTO,
|
||||||
|
EMBED_MODE_AUDIO_LSB,
|
||||||
|
EMBED_MODE_AUDIO_SPREAD,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not AUDIO_ENABLED:
|
||||||
|
raise ExtractionError(
|
||||||
|
"Audio support is disabled. Install audio extras (pip install stegasoo[audio]) "
|
||||||
|
"or set STEGASOO_AUDIO=1 to force enable."
|
||||||
|
)
|
||||||
|
|
||||||
|
from .audio_utils import detect_audio_format, transcode_to_wav
|
||||||
|
|
||||||
|
debug.print(
|
||||||
|
f"decode_audio: mode={embed_mode}, " f"passphrase length={len(passphrase.split())} words"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
require_valid_image(reference_photo, "Reference photo")
|
||||||
|
require_security_factors(pin, rsa_key_data)
|
||||||
|
|
||||||
|
if pin:
|
||||||
|
require_valid_pin(pin)
|
||||||
|
if rsa_key_data:
|
||||||
|
require_valid_rsa_key(rsa_key_data, rsa_password)
|
||||||
|
|
||||||
|
# Detect format and transcode to WAV for processing
|
||||||
|
audio_format = detect_audio_format(stego_audio)
|
||||||
|
debug.print(f"Detected audio format: {audio_format}")
|
||||||
|
|
||||||
|
wav_audio = stego_audio
|
||||||
|
if audio_format != "wav":
|
||||||
|
debug.print(f"Transcoding {audio_format} to WAV for extraction")
|
||||||
|
wav_audio = transcode_to_wav(stego_audio)
|
||||||
|
|
||||||
|
_write_progress(progress_file, 20, 100, "initializing")
|
||||||
|
|
||||||
|
# Derive sample selection key
|
||||||
|
from .crypto import derive_pixel_key
|
||||||
|
|
||||||
|
pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key)
|
||||||
|
|
||||||
|
_write_progress(progress_file, 25, 100, "extracting")
|
||||||
|
|
||||||
|
encrypted = None
|
||||||
|
|
||||||
|
if embed_mode == EMBED_MODE_AUDIO_AUTO:
|
||||||
|
# Try modes in order: spread spectrum -> LSB
|
||||||
|
try:
|
||||||
|
from .spread_steganography import extract_from_audio_spread
|
||||||
|
|
||||||
|
encrypted = extract_from_audio_spread(wav_audio, pixel_key)
|
||||||
|
if encrypted:
|
||||||
|
debug.print("Auto-detect: spread spectrum extraction succeeded")
|
||||||
|
except (ImportError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not encrypted:
|
||||||
|
from .audio_steganography import extract_from_audio_lsb
|
||||||
|
|
||||||
|
encrypted = extract_from_audio_lsb(wav_audio, pixel_key)
|
||||||
|
if encrypted:
|
||||||
|
debug.print("Auto-detect: LSB extraction succeeded")
|
||||||
|
|
||||||
|
elif embed_mode == EMBED_MODE_AUDIO_LSB:
|
||||||
|
from .audio_steganography import extract_from_audio_lsb
|
||||||
|
|
||||||
|
encrypted = extract_from_audio_lsb(wav_audio, pixel_key, progress_file=progress_file)
|
||||||
|
|
||||||
|
elif embed_mode == EMBED_MODE_AUDIO_SPREAD:
|
||||||
|
from .spread_steganography import extract_from_audio_spread
|
||||||
|
|
||||||
|
encrypted = extract_from_audio_spread(wav_audio, pixel_key, progress_file=progress_file)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid audio embed mode: {embed_mode}")
|
||||||
|
|
||||||
|
if not encrypted:
|
||||||
|
debug.print("No data extracted from audio")
|
||||||
|
raise ExtractionError("Could not extract data from audio. Check your credentials.")
|
||||||
|
|
||||||
|
debug.print(f"Extracted {len(encrypted)} bytes from audio")
|
||||||
|
|
||||||
|
# Decrypt
|
||||||
|
result = decrypt_message(encrypted, reference_photo, passphrase, pin, rsa_key_data, channel_key)
|
||||||
|
|
||||||
|
debug.print(f"Decryption successful: {result.payload_type}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def decode_video(
|
||||||
|
stego_video: bytes,
|
||||||
|
reference_photo: bytes,
|
||||||
|
passphrase: str,
|
||||||
|
pin: str = "",
|
||||||
|
rsa_key_data: bytes | None = None,
|
||||||
|
rsa_password: str | None = None,
|
||||||
|
embed_mode: str = "video_auto",
|
||||||
|
channel_key: str | bool | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
) -> DecodeResult:
|
||||||
|
"""
|
||||||
|
Decode a message or file from stego video.
|
||||||
|
|
||||||
|
Extracts data from I-frames (keyframes) using LSB steganography.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stego_video: Stego video bytes
|
||||||
|
reference_photo: Shared reference photo bytes
|
||||||
|
passphrase: Shared passphrase
|
||||||
|
pin: Optional static PIN
|
||||||
|
rsa_key_data: Optional RSA key bytes
|
||||||
|
rsa_password: Optional RSA key password
|
||||||
|
embed_mode: 'video_auto' or 'video_lsb'
|
||||||
|
channel_key: Channel key for deployment/group isolation
|
||||||
|
progress_file: Optional path to write progress JSON
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DecodeResult with message or file data
|
||||||
|
"""
|
||||||
|
from .constants import (
|
||||||
|
EMBED_MODE_VIDEO_AUTO,
|
||||||
|
EMBED_MODE_VIDEO_LSB,
|
||||||
|
VIDEO_ENABLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not VIDEO_ENABLED:
|
||||||
|
raise ExtractionError(
|
||||||
|
"Video support is disabled. Install video extras and ffmpeg, "
|
||||||
|
"or set STEGASOO_VIDEO=1 to force enable."
|
||||||
|
)
|
||||||
|
|
||||||
|
from .video_utils import detect_video_format
|
||||||
|
|
||||||
|
debug.print(
|
||||||
|
f"decode_video: mode={embed_mode}, " f"passphrase length={len(passphrase.split())} words"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
require_valid_image(reference_photo, "Reference photo")
|
||||||
|
require_security_factors(pin, rsa_key_data)
|
||||||
|
|
||||||
|
if pin:
|
||||||
|
require_valid_pin(pin)
|
||||||
|
if rsa_key_data:
|
||||||
|
require_valid_rsa_key(rsa_key_data, rsa_password)
|
||||||
|
|
||||||
|
# Detect format
|
||||||
|
video_format = detect_video_format(stego_video)
|
||||||
|
debug.print(f"Detected video format: {video_format}")
|
||||||
|
|
||||||
|
if video_format == "unknown":
|
||||||
|
raise ExtractionError("Could not detect video format.")
|
||||||
|
|
||||||
|
_write_progress(progress_file, 20, 100, "initializing")
|
||||||
|
|
||||||
|
# Derive pixel/frame selection key
|
||||||
|
from .crypto import derive_pixel_key
|
||||||
|
|
||||||
|
pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key)
|
||||||
|
|
||||||
|
_write_progress(progress_file, 25, 100, "extracting")
|
||||||
|
|
||||||
|
encrypted = None
|
||||||
|
|
||||||
|
if embed_mode == EMBED_MODE_VIDEO_AUTO or embed_mode == EMBED_MODE_VIDEO_LSB:
|
||||||
|
from .video_steganography import extract_from_video_lsb
|
||||||
|
|
||||||
|
encrypted = extract_from_video_lsb(stego_video, pixel_key, progress_file=progress_file)
|
||||||
|
if encrypted:
|
||||||
|
debug.print("Video LSB extraction succeeded")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid video embed mode: {embed_mode}")
|
||||||
|
|
||||||
|
if not encrypted:
|
||||||
|
debug.print("No data extracted from video")
|
||||||
|
raise ExtractionError("Could not extract data from video. Check your credentials.")
|
||||||
|
|
||||||
|
debug.print(f"Extracted {len(encrypted)} bytes from video")
|
||||||
|
|
||||||
|
# Decrypt
|
||||||
|
result = decrypt_message(encrypted, reference_photo, passphrase, pin, rsa_key_data, channel_key)
|
||||||
|
|
||||||
|
debug.print(f"Decryption successful: {result.payload_type}")
|
||||||
|
return result
|
||||||
|
|||||||
@@ -5,13 +5,23 @@ High-level encoding functions for hiding messages and files in images.
|
|||||||
|
|
||||||
Changes in v4.0.0:
|
Changes in v4.0.0:
|
||||||
- Added channel_key parameter for deployment/group isolation
|
- Added channel_key parameter for deployment/group isolation
|
||||||
|
|
||||||
|
Changes in v4.3.0:
|
||||||
|
- Added encode_audio() for audio steganography
|
||||||
|
|
||||||
|
Changes in v4.4.0:
|
||||||
|
- Added encode_video() for video steganography
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .constants import EMBED_MODE_LSB
|
from .constants import EMBED_MODE_LSB
|
||||||
from .crypto import derive_pixel_key, encrypt_message
|
from .crypto import derive_pixel_key, encrypt_message
|
||||||
from .debug import debug
|
from .debug import debug
|
||||||
|
from .exceptions import AudioError, VideoError
|
||||||
from .models import EncodeResult, FilePayload
|
from .models import EncodeResult, FilePayload
|
||||||
from .steganography import embed_in_image
|
from .steganography import embed_in_image
|
||||||
from .utils import generate_filename
|
from .utils import generate_filename
|
||||||
@@ -23,6 +33,9 @@ from .validation import (
|
|||||||
require_valid_rsa_key,
|
require_valid_rsa_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .models import AudioEmbedStats, VideoEmbedStats
|
||||||
|
|
||||||
|
|
||||||
def encode(
|
def encode(
|
||||||
message: str | bytes | FilePayload,
|
message: str | bytes | FilePayload,
|
||||||
@@ -38,6 +51,7 @@ def encode(
|
|||||||
dct_color_mode: str = "color",
|
dct_color_mode: str = "color",
|
||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
progress_file: str | None = None,
|
progress_file: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
) -> EncodeResult:
|
) -> EncodeResult:
|
||||||
"""
|
"""
|
||||||
Encode a message or file into an image.
|
Encode a message or file into an image.
|
||||||
@@ -110,6 +124,18 @@ def encode(
|
|||||||
# Derive pixel/coefficient selection key (with channel key)
|
# Derive pixel/coefficient selection key (with channel key)
|
||||||
pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key)
|
pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key)
|
||||||
|
|
||||||
|
# Resolve platform preset for DCT encoding
|
||||||
|
platform_kwargs = {}
|
||||||
|
if platform:
|
||||||
|
from .platform_presets import get_preset
|
||||||
|
|
||||||
|
preset = get_preset(platform)
|
||||||
|
platform_kwargs = {
|
||||||
|
"quant_step": preset.quant_step,
|
||||||
|
"max_dimension": preset.max_dimension,
|
||||||
|
"jpeg_quality": preset.jpeg_quality,
|
||||||
|
}
|
||||||
|
|
||||||
# Embed in image
|
# Embed in image
|
||||||
stego_data, stats, extension = embed_in_image(
|
stego_data, stats, extension = embed_in_image(
|
||||||
encrypted,
|
encrypted,
|
||||||
@@ -120,6 +146,7 @@ def encode(
|
|||||||
dct_output_format=dct_output_format,
|
dct_output_format=dct_output_format,
|
||||||
dct_color_mode=dct_color_mode,
|
dct_color_mode=dct_color_mode,
|
||||||
progress_file=progress_file,
|
progress_file=progress_file,
|
||||||
|
**platform_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate filename
|
# Generate filename
|
||||||
@@ -258,3 +285,194 @@ def encode_bytes(
|
|||||||
dct_color_mode=dct_color_mode,
|
dct_color_mode=dct_color_mode,
|
||||||
channel_key=channel_key,
|
channel_key=channel_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_audio(
|
||||||
|
message: str | bytes | FilePayload,
|
||||||
|
reference_photo: bytes,
|
||||||
|
carrier_audio: bytes,
|
||||||
|
passphrase: str,
|
||||||
|
pin: str = "",
|
||||||
|
rsa_key_data: bytes | None = None,
|
||||||
|
rsa_password: str | None = None,
|
||||||
|
embed_mode: str = "audio_lsb",
|
||||||
|
channel_key: str | bool | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
chip_tier: int | None = None,
|
||||||
|
) -> tuple[bytes, AudioEmbedStats]:
|
||||||
|
"""
|
||||||
|
Encode a message or file into an audio carrier.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Text message, raw bytes, or FilePayload to hide
|
||||||
|
reference_photo: Shared reference photo bytes
|
||||||
|
carrier_audio: Carrier audio bytes (WAV, FLAC, MP3, etc.)
|
||||||
|
passphrase: Shared passphrase
|
||||||
|
pin: Optional static PIN
|
||||||
|
rsa_key_data: Optional RSA private key PEM bytes
|
||||||
|
rsa_password: Optional password for encrypted RSA key
|
||||||
|
embed_mode: 'audio_lsb' or 'audio_spread'
|
||||||
|
channel_key: Channel key for deployment/group isolation
|
||||||
|
progress_file: Optional path to write progress JSON
|
||||||
|
chip_tier: Spread spectrum chip tier (0=lossless, 1=high_lossy, 2=low_lossy).
|
||||||
|
Only used for audio_spread mode. Default None → uses constant default.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (stego audio bytes, AudioEmbedStats)
|
||||||
|
"""
|
||||||
|
from .constants import AUDIO_ENABLED, EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_SPREAD
|
||||||
|
|
||||||
|
if not AUDIO_ENABLED:
|
||||||
|
raise AudioError(
|
||||||
|
"Audio support is disabled. Install audio extras (pip install stegasoo[audio]) "
|
||||||
|
"or set STEGASOO_AUDIO=1 to force enable."
|
||||||
|
)
|
||||||
|
|
||||||
|
from .audio_utils import detect_audio_format, transcode_to_wav
|
||||||
|
|
||||||
|
debug.print(
|
||||||
|
f"encode_audio: mode={embed_mode}, "
|
||||||
|
f"passphrase length={len(passphrase.split())} words, "
|
||||||
|
f"pin={'set' if pin else 'none'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
require_valid_payload(message)
|
||||||
|
require_valid_image(reference_photo, "Reference photo")
|
||||||
|
require_security_factors(pin, rsa_key_data)
|
||||||
|
|
||||||
|
if pin:
|
||||||
|
require_valid_pin(pin)
|
||||||
|
if rsa_key_data:
|
||||||
|
require_valid_rsa_key(rsa_key_data, rsa_password)
|
||||||
|
|
||||||
|
# Detect audio format and transcode to WAV if needed
|
||||||
|
audio_format = detect_audio_format(carrier_audio)
|
||||||
|
debug.print(f"Detected audio format: {audio_format}")
|
||||||
|
|
||||||
|
if audio_format not in ("wav", "flac"):
|
||||||
|
debug.print(f"Transcoding {audio_format} to WAV for embedding")
|
||||||
|
carrier_audio = transcode_to_wav(carrier_audio)
|
||||||
|
|
||||||
|
# Encrypt message
|
||||||
|
encrypted = encrypt_message(
|
||||||
|
message, reference_photo, passphrase, pin, rsa_key_data, channel_key
|
||||||
|
)
|
||||||
|
debug.print(f"Encrypted payload: {len(encrypted)} bytes")
|
||||||
|
|
||||||
|
# Derive sample selection key
|
||||||
|
pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key)
|
||||||
|
|
||||||
|
# Embed based on mode
|
||||||
|
if embed_mode == EMBED_MODE_AUDIO_LSB:
|
||||||
|
from .audio_steganography import embed_in_audio_lsb
|
||||||
|
|
||||||
|
stego_audio, stats = embed_in_audio_lsb(
|
||||||
|
encrypted, carrier_audio, pixel_key, progress_file=progress_file
|
||||||
|
)
|
||||||
|
elif embed_mode == EMBED_MODE_AUDIO_SPREAD:
|
||||||
|
from .constants import AUDIO_SS_DEFAULT_CHIP_TIER
|
||||||
|
from .spread_steganography import embed_in_audio_spread
|
||||||
|
|
||||||
|
tier = chip_tier if chip_tier is not None else AUDIO_SS_DEFAULT_CHIP_TIER
|
||||||
|
stego_audio, stats = embed_in_audio_spread(
|
||||||
|
encrypted, carrier_audio, pixel_key, chip_tier=tier, progress_file=progress_file
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid audio embed mode: {embed_mode}")
|
||||||
|
|
||||||
|
return stego_audio, stats
|
||||||
|
|
||||||
|
|
||||||
|
def encode_video(
|
||||||
|
message: str | bytes | FilePayload,
|
||||||
|
reference_photo: bytes,
|
||||||
|
carrier_video: bytes,
|
||||||
|
passphrase: str,
|
||||||
|
pin: str = "",
|
||||||
|
rsa_key_data: bytes | None = None,
|
||||||
|
rsa_password: str | None = None,
|
||||||
|
embed_mode: str = "video_lsb",
|
||||||
|
channel_key: str | bool | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
) -> tuple[bytes, VideoEmbedStats]:
|
||||||
|
"""
|
||||||
|
Encode a message or file into a video carrier.
|
||||||
|
|
||||||
|
Embeds data across I-frames (keyframes) using LSB steganography.
|
||||||
|
Output is an MKV container with FFV1 lossless codec to preserve
|
||||||
|
the embedded data perfectly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Text message, raw bytes, or FilePayload to hide
|
||||||
|
reference_photo: Shared reference photo bytes
|
||||||
|
carrier_video: Carrier video bytes (MP4, MKV, WebM, AVI, MOV)
|
||||||
|
passphrase: Shared passphrase
|
||||||
|
pin: Optional static PIN
|
||||||
|
rsa_key_data: Optional RSA private key PEM bytes
|
||||||
|
rsa_password: Optional password for encrypted RSA key
|
||||||
|
embed_mode: 'video_lsb' (currently the only option)
|
||||||
|
channel_key: Channel key for deployment/group isolation
|
||||||
|
progress_file: Optional path to write progress JSON
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (stego video bytes, VideoEmbedStats)
|
||||||
|
|
||||||
|
Note:
|
||||||
|
The output video will be in MKV format with FFV1 lossless codec,
|
||||||
|
regardless of the input format. This is necessary to preserve
|
||||||
|
the embedded data without lossy compression artifacts.
|
||||||
|
"""
|
||||||
|
from .constants import EMBED_MODE_VIDEO_LSB, VIDEO_ENABLED
|
||||||
|
|
||||||
|
if not VIDEO_ENABLED:
|
||||||
|
raise VideoError(
|
||||||
|
"Video support is disabled. Install video extras and ffmpeg, "
|
||||||
|
"or set STEGASOO_VIDEO=1 to force enable."
|
||||||
|
)
|
||||||
|
|
||||||
|
from .video_utils import detect_video_format
|
||||||
|
|
||||||
|
debug.print(
|
||||||
|
f"encode_video: mode={embed_mode}, "
|
||||||
|
f"passphrase length={len(passphrase.split())} words, "
|
||||||
|
f"pin={'set' if pin else 'none'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
require_valid_payload(message)
|
||||||
|
require_valid_image(reference_photo, "Reference photo")
|
||||||
|
require_security_factors(pin, rsa_key_data)
|
||||||
|
|
||||||
|
if pin:
|
||||||
|
require_valid_pin(pin)
|
||||||
|
if rsa_key_data:
|
||||||
|
require_valid_rsa_key(rsa_key_data, rsa_password)
|
||||||
|
|
||||||
|
# Detect video format
|
||||||
|
video_format = detect_video_format(carrier_video)
|
||||||
|
debug.print(f"Detected video format: {video_format}")
|
||||||
|
|
||||||
|
if video_format == "unknown":
|
||||||
|
raise VideoError("Could not detect video format. Supported: MP4, MKV, WebM, AVI, MOV.")
|
||||||
|
|
||||||
|
# Encrypt message
|
||||||
|
encrypted = encrypt_message(
|
||||||
|
message, reference_photo, passphrase, pin, rsa_key_data, channel_key
|
||||||
|
)
|
||||||
|
debug.print(f"Encrypted payload: {len(encrypted)} bytes")
|
||||||
|
|
||||||
|
# Derive pixel/frame selection key
|
||||||
|
pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key)
|
||||||
|
|
||||||
|
# Embed based on mode
|
||||||
|
if embed_mode == EMBED_MODE_VIDEO_LSB:
|
||||||
|
from .video_steganography import embed_in_video_lsb
|
||||||
|
|
||||||
|
stego_video, stats = embed_in_video_lsb(
|
||||||
|
encrypted, carrier_video, pixel_key, progress_file=progress_file
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid video embed mode: {embed_mode}")
|
||||||
|
|
||||||
|
return stego_video, stats
|
||||||
|
|||||||
@@ -195,3 +195,99 @@ class UnsupportedFileTypeError(FileError):
|
|||||||
super().__init__(
|
super().__init__(
|
||||||
f"Unsupported file type: .{extension}. Allowed: {', '.join(sorted(allowed))}"
|
f"Unsupported file type: .{extension}. Allowed: {', '.join(sorted(allowed))}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# AUDIO ERRORS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class AudioError(SteganographyError):
|
||||||
|
"""Base class for audio steganography errors."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AudioValidationError(ValidationError):
|
||||||
|
"""Audio validation failed."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AudioCapacityError(CapacityError):
|
||||||
|
"""Audio carrier too small for message."""
|
||||||
|
|
||||||
|
def __init__(self, needed: int, available: int):
|
||||||
|
self.needed = needed
|
||||||
|
self.available = available
|
||||||
|
# Call SteganographyError.__init__ directly (skip CapacityError's image-specific message)
|
||||||
|
SteganographyError.__init__(
|
||||||
|
self,
|
||||||
|
f"Audio carrier too small. Need {needed:,} bytes, have {available:,} bytes capacity.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioExtractionError(ExtractionError):
|
||||||
|
"""Failed to extract hidden data from audio."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AudioTranscodeError(AudioError):
|
||||||
|
"""Audio transcoding failed."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedAudioFormatError(AudioError):
|
||||||
|
"""Audio format not supported."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# VIDEO ERRORS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class VideoError(SteganographyError):
|
||||||
|
"""Base class for video steganography errors."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class VideoValidationError(ValidationError):
|
||||||
|
"""Video validation failed."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class VideoCapacityError(CapacityError):
|
||||||
|
"""Video carrier too small for message."""
|
||||||
|
|
||||||
|
def __init__(self, needed: int, available: int):
|
||||||
|
self.needed = needed
|
||||||
|
self.available = available
|
||||||
|
# Call SteganographyError.__init__ directly (skip CapacityError's image-specific message)
|
||||||
|
SteganographyError.__init__(
|
||||||
|
self,
|
||||||
|
f"Video carrier too small. Need {needed:,} bytes, have {available:,} bytes capacity.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoExtractionError(ExtractionError):
|
||||||
|
"""Failed to extract hidden data from video."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class VideoTranscodeError(VideoError):
|
||||||
|
"""Video transcoding failed."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedVideoFormatError(VideoError):
|
||||||
|
"""Video format not supported."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS, password: str | None = None)
|
|||||||
Generate an RSA private key in PEM format.
|
Generate an RSA private key in PEM format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bits: Key size (2048, 3072, or 4096, default 2048)
|
bits: Key size (2048 or 3072, default 2048)
|
||||||
password: Optional password to encrypt the key
|
password: Optional password to encrypt the key
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey:
|
|||||||
Generate an RSA private key.
|
Generate an RSA private key.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bits: Key size (2048, 3072, or 4096)
|
bits: Key size (2048 or 3072)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RSA private key object
|
RSA private key object
|
||||||
|
|||||||
@@ -281,3 +281,111 @@ class GenerateResult:
|
|||||||
lines.append(f" RSA Key: {len(self.rsa_key_pem)} bytes PEM")
|
lines.append(f" RSA Key: {len(self.rsa_key_pem)} bytes PEM")
|
||||||
lines.append(f" Total Entropy: {self.total_entropy} bits")
|
lines.append(f" Total Entropy: {self.total_entropy} bits")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AUDIO STEGANOGRAPHY MODELS (v4.3.0)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioEmbedStats:
|
||||||
|
"""Statistics from audio embedding."""
|
||||||
|
|
||||||
|
samples_modified: int
|
||||||
|
total_samples: int
|
||||||
|
capacity_used: float # 0.0 - 1.0
|
||||||
|
bytes_embedded: int
|
||||||
|
sample_rate: int
|
||||||
|
channels: int
|
||||||
|
duration_seconds: float
|
||||||
|
embed_mode: str # "audio_lsb" or "audio_spread"
|
||||||
|
chip_tier: int | None = None # v4.4.0: spread spectrum chip tier (0/1/2)
|
||||||
|
chip_length: int | None = None # v4.4.0: samples per chip
|
||||||
|
embeddable_channels: int | None = None # v4.4.0: channels used (excl. LFE)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def modification_percent(self) -> float:
|
||||||
|
"""Percentage of samples modified."""
|
||||||
|
return (self.samples_modified / self.total_samples) * 100 if self.total_samples > 0 else 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioInfo:
|
||||||
|
"""Information about an audio file."""
|
||||||
|
|
||||||
|
sample_rate: int
|
||||||
|
channels: int
|
||||||
|
duration_seconds: float
|
||||||
|
num_samples: int
|
||||||
|
format: str # "wav", "flac", "mp3", etc.
|
||||||
|
bitrate: int | None = None # For lossy formats
|
||||||
|
bit_depth: int | None = None # For lossless formats
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioCapacityInfo:
|
||||||
|
"""Capacity information for audio steganography."""
|
||||||
|
|
||||||
|
total_samples: int
|
||||||
|
usable_capacity_bytes: int
|
||||||
|
embed_mode: str
|
||||||
|
sample_rate: int
|
||||||
|
duration_seconds: float
|
||||||
|
chip_tier: int | None = None # v4.4.0: spread spectrum chip tier
|
||||||
|
chip_length: int | None = None # v4.4.0: samples per chip
|
||||||
|
embeddable_channels: int | None = None # v4.4.0: channels used (excl. LFE)
|
||||||
|
total_channels: int | None = None # v4.4.0: total channels in carrier
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# VIDEO STEGANOGRAPHY MODELS (v4.4.0)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VideoEmbedStats:
|
||||||
|
"""Statistics from video embedding."""
|
||||||
|
|
||||||
|
frames_modified: int
|
||||||
|
total_frames: int
|
||||||
|
capacity_used: float # 0.0 - 1.0
|
||||||
|
bytes_embedded: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
fps: float
|
||||||
|
duration_seconds: float
|
||||||
|
embed_mode: str # "video_lsb"
|
||||||
|
codec: str # Output codec (e.g., "ffv1")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def modification_percent(self) -> float:
|
||||||
|
"""Percentage of frames modified."""
|
||||||
|
return (self.frames_modified / self.total_frames) * 100 if self.total_frames > 0 else 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VideoInfo:
|
||||||
|
"""Information about a video file."""
|
||||||
|
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
fps: float
|
||||||
|
duration_seconds: float
|
||||||
|
total_frames: int
|
||||||
|
i_frame_count: int
|
||||||
|
format: str # "mp4", "mkv", "webm", etc.
|
||||||
|
codec: str # "h264", "vp9", "ffv1", etc.
|
||||||
|
bitrate: int | None = None # For lossy formats
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VideoCapacityInfo:
|
||||||
|
"""Capacity information for video steganography."""
|
||||||
|
|
||||||
|
total_frames: int
|
||||||
|
i_frames: int
|
||||||
|
usable_capacity_bytes: int
|
||||||
|
embed_mode: str
|
||||||
|
resolution: tuple[int, int]
|
||||||
|
duration_seconds: float
|
||||||
|
|||||||
169
src/stegasoo/platform_presets.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Platform-Calibrated DCT Presets (v4.4.0)
|
||||||
|
|
||||||
|
Pre-tuned DCT embedding parameters for social media platforms. Each platform
|
||||||
|
recompresses uploaded images differently — these presets bake in the known
|
||||||
|
parameters so payloads survive the round-trip.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from stegasoo.platform_presets import get_preset, PLATFORMS
|
||||||
|
|
||||||
|
preset = get_preset("telegram")
|
||||||
|
# Use preset.quant_step, preset.jpeg_quality, etc. in DCT encode
|
||||||
|
|
||||||
|
Preset parameters were derived from empirical testing. Platform compression
|
||||||
|
behavior can change without notice — use ``pre_verify_survival()`` to confirm
|
||||||
|
payloads survive before relying on a preset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlatformPreset:
|
||||||
|
"""Tuned DCT parameters for a specific platform."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
jpeg_quality: int # Platform's recompression quality
|
||||||
|
max_dimension: int # Max width/height before platform resizes
|
||||||
|
quant_step: int # QIM quantization step (higher = more robust)
|
||||||
|
embed_start: int # Start index into EMBED_POSITIONS (skip low-freq)
|
||||||
|
embed_end: int # End index into EMBED_POSITIONS (skip high-freq)
|
||||||
|
recompress_quality: int # Quality to simulate platform recompression for pre-verify
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# Platform presets — derived from empirical testing of each platform's
|
||||||
|
# image processing pipeline. These WILL change as platforms update.
|
||||||
|
# Last verified: 2026-03-25
|
||||||
|
|
||||||
|
PRESETS: dict[str, PlatformPreset] = {
|
||||||
|
"telegram": PlatformPreset(
|
||||||
|
name="Telegram",
|
||||||
|
jpeg_quality=82,
|
||||||
|
max_dimension=2560,
|
||||||
|
quant_step=35,
|
||||||
|
embed_start=4,
|
||||||
|
embed_end=16,
|
||||||
|
recompress_quality=80,
|
||||||
|
notes="~81KB max embeddable. Moderate recompression.",
|
||||||
|
),
|
||||||
|
"discord": PlatformPreset(
|
||||||
|
name="Discord",
|
||||||
|
jpeg_quality=85,
|
||||||
|
max_dimension=4096,
|
||||||
|
quant_step=30,
|
||||||
|
embed_start=4,
|
||||||
|
embed_end=18,
|
||||||
|
recompress_quality=83,
|
||||||
|
notes="Varies with Nitro. Non-Nitro users get more aggressive compression.",
|
||||||
|
),
|
||||||
|
"signal": PlatformPreset(
|
||||||
|
name="Signal",
|
||||||
|
jpeg_quality=80,
|
||||||
|
max_dimension=2048,
|
||||||
|
quant_step=40,
|
||||||
|
embed_start=5,
|
||||||
|
embed_end=15,
|
||||||
|
recompress_quality=78,
|
||||||
|
notes="Aggressive recompression. Use smaller payloads for reliability.",
|
||||||
|
),
|
||||||
|
"whatsapp": PlatformPreset(
|
||||||
|
name="WhatsApp",
|
||||||
|
jpeg_quality=70,
|
||||||
|
max_dimension=1600,
|
||||||
|
quant_step=50,
|
||||||
|
embed_start=5,
|
||||||
|
embed_end=14,
|
||||||
|
recompress_quality=68,
|
||||||
|
notes="Most lossy. Capacity is significantly reduced.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
PLATFORMS = sorted(PRESETS.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset(platform: str) -> PlatformPreset:
|
||||||
|
"""Get the preset for a platform.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
platform: Platform name (telegram, discord, signal, whatsapp).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PlatformPreset with tuned DCT parameters.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If platform is not recognized.
|
||||||
|
"""
|
||||||
|
key = platform.lower()
|
||||||
|
if key not in PRESETS:
|
||||||
|
available = ", ".join(PLATFORMS)
|
||||||
|
raise ValueError(f"Unknown platform '{platform}'. Available: {available}")
|
||||||
|
return PRESETS[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_embed_positions(preset: PlatformPreset) -> list[tuple[int, int]]:
|
||||||
|
"""Get the embed positions for a preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset: Platform preset.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (row, col) DCT coefficient positions.
|
||||||
|
"""
|
||||||
|
from .dct_steganography import EMBED_POSITIONS
|
||||||
|
|
||||||
|
return EMBED_POSITIONS[preset.embed_start : preset.embed_end]
|
||||||
|
|
||||||
|
|
||||||
|
def pre_verify_survival(
|
||||||
|
stego_image: bytes,
|
||||||
|
seed: bytes,
|
||||||
|
preset: PlatformPreset,
|
||||||
|
) -> bool:
|
||||||
|
"""Verify that a payload survives simulated platform recompression.
|
||||||
|
|
||||||
|
Encodes → recompresses at platform quality → attempts extraction.
|
||||||
|
If extraction succeeds, the payload should survive the real platform.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stego_image: The stego JPEG image bytes (already encoded).
|
||||||
|
seed: The same seed used for encoding.
|
||||||
|
preset: Platform preset to simulate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if payload survived simulated recompression.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .dct_steganography import extract_from_dct
|
||||||
|
|
||||||
|
# Simulate platform recompression
|
||||||
|
img = Image.open(io.BytesIO(stego_image))
|
||||||
|
|
||||||
|
# Resize if over max dimension
|
||||||
|
w, h = img.size
|
||||||
|
if max(w, h) > preset.max_dimension:
|
||||||
|
scale = preset.max_dimension / max(w, h)
|
||||||
|
new_size = (int(w * scale), int(h * scale))
|
||||||
|
img = img.resize(new_size, Image.LANCZOS)
|
||||||
|
|
||||||
|
# Recompress at platform quality
|
||||||
|
buf = io.BytesIO()
|
||||||
|
if img.mode != "RGB":
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(buf, format="JPEG", quality=preset.recompress_quality)
|
||||||
|
img.close()
|
||||||
|
recompressed = buf.getvalue()
|
||||||
|
|
||||||
|
# Try extraction
|
||||||
|
try:
|
||||||
|
result = extract_from_dct(recompressed, seed)
|
||||||
|
return result is not None and len(result) > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||