Compare commits
8 Commits
worktree-a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c970261e53 | ||
|
|
4607ff27dd | ||
|
|
70b941d55a | ||
|
|
14fce4d3ed | ||
|
|
05382c4081 | ||
|
|
ef5a9ce9cb | ||
|
|
0248bec813 | ||
|
|
7aeb26e003 |
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"church@church": true
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# Embedded repos (AUR packaging)
|
||||
aur-cli-upload/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
20
CHANGELOG.md
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/),
|
||||
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
|
||||
|
||||
### Added
|
||||
@@ -201,6 +220,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
- CLI interface
|
||||
- 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.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
|
||||
|
||||
114
CLAUDE.md
Normal file
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
|
||||
294
IdeasScout_PLANS_20260324.md
Normal file
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
|
||||
12
README.md
12
README.md
@@ -1,6 +1,6 @@
|
||||
# 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/lint.yml)
|
||||
@@ -17,15 +17,25 @@ A secure steganography system for hiding encrypted messages in images using hybr
|
||||
- **Multiple interfaces**: CLI, Web UI, REST API
|
||||
- **File embedding**: Hide any file type (PDF, ZIP, documents)
|
||||
- **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
|
||||
|
||||
## Embedding Modes
|
||||
|
||||
### Image Modes
|
||||
|
||||
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|
||||
|------|------------------|----------------|----------|
|
||||
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
|
||||
| **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
|
||||
|
||||
| Home | Encode | Decode | Generate |
|
||||
|
||||
@@ -1,3 +1,45 @@
|
||||
# v4.3.0 — Audio Steganography
|
||||
|
||||
**Release Date:** 2026-02-27
|
||||
|
||||
## Highlights
|
||||
|
||||
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
|
||||
|
||||
30
agentstuff/pyproject.toml
Normal file
30
agentstuff/pyproject.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[project]
|
||||
name = "sentiment-agent"
|
||||
version = "0.1.0"
|
||||
description = "AI agent for gathering data and performing sentiment analysis"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"claude-agent-sdk",
|
||||
"anyio",
|
||||
"httpx",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"ruff",
|
||||
"mypy",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
sentiment-agent = "sentiment_agent.main:main"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
ignore_missing_imports = true
|
||||
3
agentstuff/sentiment_agent/__init__.py
Normal file
3
agentstuff/sentiment_agent/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Sentiment analysis agent powered by Claude Agent SDK."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
115
agentstuff/sentiment_agent/agent.py
Normal file
115
agentstuff/sentiment_agent/agent.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Core sentiment analysis agent using Claude Agent SDK."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from claude_agent_sdk import (
|
||||
AssistantMessage,
|
||||
ClaudeAgentOptions,
|
||||
ClaudeSDKClient,
|
||||
ResultMessage,
|
||||
TextBlock,
|
||||
)
|
||||
|
||||
from sentiment_agent.config import SafetyConfig
|
||||
from sentiment_agent.tools import create_social_tools_server
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You are a sentiment analysis agent. Your job is to gather data from multiple \
|
||||
platforms and produce a structured, evidence-based sentiment report.
|
||||
|
||||
## Rules — you MUST follow these
|
||||
|
||||
1. **Budget awareness.** You have a limited API call budget. Call \
|
||||
`get_api_budget_status` before starting and after every few tool calls. \
|
||||
Stop gathering data when you have <5 calls remaining and begin your analysis.
|
||||
|
||||
2. **Credibility first.** Every tool result includes credibility scores and \
|
||||
bot/disinfo flags. You MUST:
|
||||
- NEVER quote or cite posts marked `likely_inauthentic` (score < 0.3).
|
||||
- Flag posts marked `suspicious` (score 0.3–0.5) with a warning when citing them.
|
||||
- Give more weight to `likely_authentic` posts (score ≥ 0.7).
|
||||
- If coordination warnings appear (copy-paste campaigns, burst posting), \
|
||||
call them out prominently in your report.
|
||||
|
||||
3. **Platform diversity.** Gather from at least 2 different platforms before \
|
||||
analyzing. Do not over-index on a single source.
|
||||
|
||||
4. **No fabrication.** Only report on data you actually retrieved. If a tool \
|
||||
call fails or returns no results, say so — do not invent data.
|
||||
|
||||
5. **Structured output.** Your final report MUST include these sections:
|
||||
- **Data Quality Summary**: platforms queried, posts analyzed vs excluded, \
|
||||
coordination warnings
|
||||
- **Overall Sentiment**: score (-1.0 to +1.0) and label \
|
||||
(very negative / negative / mixed / neutral / positive / very positive)
|
||||
- **Platform Breakdown**: sentiment per platform with sample size
|
||||
- **Key Themes**: top 3-5 themes with sentiment direction
|
||||
- **Credibility Concerns**: any bot networks, disinfo patterns, or \
|
||||
coordinated campaigns detected
|
||||
- **Notable Quotes**: 3-5 representative quotes (authentic sources only, \
|
||||
with credibility score noted)
|
||||
- **Confidence Assessment**: how confident you are in the analysis given \
|
||||
data quality and volume
|
||||
|
||||
6. **Scope discipline.** Stay focused on the requested topic. Do not expand \
|
||||
scope, follow tangents, or analyze adjacent topics unless explicitly asked.
|
||||
|
||||
7. **No side effects.** Do not write files, run commands, or take any action \
|
||||
beyond reading data and producing your report.
|
||||
"""
|
||||
|
||||
|
||||
async def run_sentiment_analysis(
|
||||
topic: str,
|
||||
sources: list[str] | None = None,
|
||||
config: SafetyConfig | None = None,
|
||||
) -> str:
|
||||
"""Run the sentiment analysis agent on a given topic.
|
||||
|
||||
Args:
|
||||
topic: The topic or subject to analyze sentiment for.
|
||||
sources: Optional list of URLs or data sources to analyze.
|
||||
config: Safety configuration. Defaults to SafetyConfig.from_env().
|
||||
|
||||
Returns:
|
||||
The agent's sentiment analysis report.
|
||||
"""
|
||||
config = config or SafetyConfig.from_env()
|
||||
|
||||
source_instructions = ""
|
||||
if sources:
|
||||
source_list = "\n".join(f"- {s}" for s in sources)
|
||||
source_instructions = f"\n\nAlso analyze these specific sources:\n{source_list}"
|
||||
|
||||
prompt = (
|
||||
f"Perform a sentiment analysis on the following topic: {topic}\n\n"
|
||||
"Start by calling `get_api_budget_status` to check your budget, then "
|
||||
"gather data from multiple platforms (Reddit, Hacker News, Bluesky if "
|
||||
"configured, and web search). Pay close attention to credibility scores "
|
||||
"and coordination warnings in the results."
|
||||
f"{source_instructions}"
|
||||
)
|
||||
|
||||
social_server = create_social_tools_server(config)
|
||||
|
||||
options = ClaudeAgentOptions(
|
||||
# Only allow read-only tools — no Write/Bash to prevent side effects
|
||||
allowed_tools=["WebSearch", "WebFetch", "Read"],
|
||||
max_turns=config.max_turns,
|
||||
max_budget_usd=config.max_budget_usd,
|
||||
mcp_servers={"social": social_server},
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
result_text = ""
|
||||
async with ClaudeSDKClient(options=options) as client:
|
||||
await client.query(prompt)
|
||||
async for message in client.receive_response():
|
||||
if isinstance(message, AssistantMessage):
|
||||
for block in message.content:
|
||||
if isinstance(block, TextBlock):
|
||||
print(block.text, end="", flush=True)
|
||||
if isinstance(message, ResultMessage):
|
||||
result_text = message.result
|
||||
|
||||
return result_text
|
||||
1
agentstuff/sentiment_agent/clients/__init__.py
Normal file
1
agentstuff/sentiment_agent/clients/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API clients for social media and forum data sources."""
|
||||
166
agentstuff/sentiment_agent/clients/bluesky.py
Normal file
166
agentstuff/sentiment_agent/clients/bluesky.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Bluesky client using the AT Protocol API.
|
||||
|
||||
Search requires authentication. Set BLUESKY_HANDLE and BLUESKY_APP_PASSWORD
|
||||
env vars. Create an app password at: https://bsky.app/settings/app-passwords
|
||||
|
||||
Thread fetching works without auth via the public API.
|
||||
"""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
|
||||
BSKY_PUBLIC_API = "https://public.api.bsky.app"
|
||||
BSKY_AUTH_API = "https://bsky.social"
|
||||
|
||||
|
||||
async def _get_session() -> dict | None:
|
||||
"""Authenticate with Bluesky and return session tokens, or None if no creds."""
|
||||
handle = os.environ.get("BLUESKY_HANDLE")
|
||||
app_password = os.environ.get("BLUESKY_APP_PASSWORD")
|
||||
if not handle or not app_password:
|
||||
return None
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"{BSKY_AUTH_API}/xrpc/com.atproto.server.createSession",
|
||||
json={"identifier": handle, "password": app_password},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _format_post(post_view: dict) -> dict:
|
||||
"""Extract relevant fields from an AT Protocol post view."""
|
||||
post = post_view.get("post", post_view)
|
||||
record = post.get("record", {})
|
||||
author = post.get("author", {})
|
||||
return {
|
||||
"text": record.get("text", ""),
|
||||
"author_handle": author.get("handle", ""),
|
||||
"author_display_name": author.get("displayName", ""),
|
||||
"created_at": record.get("createdAt", ""),
|
||||
"like_count": post.get("likeCount", 0),
|
||||
"repost_count": post.get("repostCount", 0),
|
||||
"reply_count": post.get("replyCount", 0),
|
||||
"uri": post.get("uri", ""),
|
||||
"cid": post.get("cid", ""),
|
||||
"url": _uri_to_url(post.get("uri", ""), author.get("handle", "")),
|
||||
}
|
||||
|
||||
|
||||
def _uri_to_url(uri: str, handle: str) -> str:
|
||||
"""Convert an at:// URI to a bsky.app URL."""
|
||||
# at://did:plc:xxx/app.bsky.feed.post/rkey -> https://bsky.app/profile/handle/post/rkey
|
||||
if not uri.startswith("at://"):
|
||||
return ""
|
||||
parts = uri.split("/")
|
||||
if len(parts) >= 5:
|
||||
rkey = parts[-1]
|
||||
return f"https://bsky.app/profile/{handle}/post/{rkey}"
|
||||
return ""
|
||||
|
||||
|
||||
async def search_posts(query: str, limit: int = 25, sort: str = "top") -> list[dict]:
|
||||
"""Search Bluesky for posts matching a query.
|
||||
|
||||
Requires BLUESKY_HANDLE and BLUESKY_APP_PASSWORD env vars.
|
||||
|
||||
Args:
|
||||
query: Search terms.
|
||||
limit: Max results (capped at 100).
|
||||
sort: "top" (most liked) or "latest" (chronological).
|
||||
|
||||
Returns:
|
||||
List of post dicts with: text, author_handle, author_display_name,
|
||||
created_at, like_count, repost_count, reply_count, uri, url.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If Bluesky credentials are not configured.
|
||||
"""
|
||||
session = await _get_session()
|
||||
if not session:
|
||||
raise RuntimeError(
|
||||
"Bluesky search requires authentication. "
|
||||
"Set BLUESKY_HANDLE and BLUESKY_APP_PASSWORD environment variables. "
|
||||
"Create an app password at: https://bsky.app/settings/app-passwords"
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{BSKY_AUTH_API}/xrpc/app.bsky.feed.searchPosts",
|
||||
params={
|
||||
"q": query,
|
||||
"limit": min(limit, 100),
|
||||
"sort": sort,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {session['accessJwt']}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
return [_format_post(p) for p in data.get("posts", [])]
|
||||
|
||||
|
||||
async def get_thread(uri: str, depth: int = 6) -> dict:
|
||||
"""Fetch a Bluesky thread by AT URI or bsky.app URL.
|
||||
|
||||
Args:
|
||||
uri: Either an at:// URI or a https://bsky.app/profile/.../post/... URL.
|
||||
depth: How many levels of replies to fetch (max 1000).
|
||||
|
||||
Returns:
|
||||
Dict with "post" (the root post) and "replies" (list of reply post dicts).
|
||||
"""
|
||||
# Convert bsky.app URL to AT URI if needed
|
||||
if uri.startswith("https://bsky.app/"):
|
||||
uri = await _resolve_url_to_uri(uri)
|
||||
|
||||
headers = {}
|
||||
session = await _get_session()
|
||||
if session:
|
||||
headers["Authorization"] = f"Bearer {session['accessJwt']}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{BSKY_PUBLIC_API}/xrpc/app.bsky.feed.getPostThread",
|
||||
params={"uri": uri, "depth": min(depth, 1000)},
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
thread = data.get("thread", {})
|
||||
root_post = _format_post(thread) if "post" in thread else {}
|
||||
|
||||
replies = []
|
||||
for reply in thread.get("replies", []):
|
||||
if "post" in reply:
|
||||
replies.append(_format_post(reply))
|
||||
# Include nested replies one level deep
|
||||
for nested in reply.get("replies", []):
|
||||
if "post" in nested:
|
||||
replies.append(_format_post(nested))
|
||||
|
||||
return {"post": root_post, "replies": replies}
|
||||
|
||||
|
||||
async def _resolve_url_to_uri(url: str) -> str:
|
||||
"""Convert a bsky.app URL to an AT URI by resolving the handle."""
|
||||
# https://bsky.app/profile/handle.bsky.social/post/rkey
|
||||
parts = url.rstrip("/").split("/")
|
||||
if len(parts) < 6:
|
||||
raise ValueError(f"Invalid Bluesky URL: {url}")
|
||||
|
||||
handle = parts[4] # profile/{handle}
|
||||
rkey = parts[6] # post/{rkey}
|
||||
|
||||
# Resolve handle to DID
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{BSKY_PUBLIC_API}/xrpc/com.atproto.identity.resolveHandle",
|
||||
params={"handle": handle},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
did = resp.json()["did"]
|
||||
|
||||
return f"at://{did}/app.bsky.feed.post/{rkey}"
|
||||
78
agentstuff/sentiment_agent/clients/hackernews.py
Normal file
78
agentstuff/sentiment_agent/clients/hackernews.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Hacker News client using the Algolia HN Search API.
|
||||
|
||||
No authentication required. Docs: https://hn.algolia.com/api
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
HN_API_BASE = "https://hn.algolia.com/api/v1"
|
||||
|
||||
|
||||
async def search_stories(query: str, limit: int = 25) -> list[dict]:
|
||||
"""Search HN for stories matching a query.
|
||||
|
||||
Returns a list of story dicts with: title, url, author, points,
|
||||
num_comments, created_at, objectID, story_text.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{HN_API_BASE}/search",
|
||||
params={
|
||||
"query": query,
|
||||
"tags": "story",
|
||||
"hitsPerPage": min(limit, 50),
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = []
|
||||
for hit in data.get("hits", []):
|
||||
results.append(
|
||||
{
|
||||
"title": hit.get("title", ""),
|
||||
"url": hit.get("url", ""),
|
||||
"author": hit.get("author", ""),
|
||||
"points": hit.get("points", 0),
|
||||
"num_comments": hit.get("num_comments", 0),
|
||||
"created_at": hit.get("created_at", ""),
|
||||
"object_id": hit.get("objectID", ""),
|
||||
"story_text": hit.get("story_text") or "",
|
||||
"hn_url": f"https://news.ycombinator.com/item?id={hit.get('objectID', '')}",
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
async def search_comments(query: str, limit: int = 25) -> list[dict]:
|
||||
"""Search HN for comments matching a query.
|
||||
|
||||
Returns a list of comment dicts with: comment_text, author, points,
|
||||
created_at, story_title, story_url.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{HN_API_BASE}/search",
|
||||
params={
|
||||
"query": query,
|
||||
"tags": "comment",
|
||||
"hitsPerPage": min(limit, 50),
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = []
|
||||
for hit in data.get("hits", []):
|
||||
results.append(
|
||||
{
|
||||
"comment_text": hit.get("comment_text", ""),
|
||||
"author": hit.get("author", ""),
|
||||
"points": hit.get("points", 0),
|
||||
"created_at": hit.get("created_at", ""),
|
||||
"story_title": hit.get("story_title", ""),
|
||||
"story_url": hit.get("story_url", ""),
|
||||
"hn_url": f"https://news.ycombinator.com/item?id={hit.get('objectID', '')}",
|
||||
}
|
||||
)
|
||||
return results
|
||||
117
agentstuff/sentiment_agent/clients/reddit.py
Normal file
117
agentstuff/sentiment_agent/clients/reddit.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Reddit client using the public JSON API.
|
||||
|
||||
No authentication required for read-only search. Reddit requires a descriptive
|
||||
User-Agent header — requests with generic UAs get 429'd.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
REDDIT_BASE = "https://www.reddit.com"
|
||||
USER_AGENT = "sentiment-agent/0.1.0 (research; sentiment analysis tool)"
|
||||
|
||||
|
||||
async def search_posts(
|
||||
query: str,
|
||||
subreddit: str = "all",
|
||||
sort: str = "relevance",
|
||||
time_filter: str = "month",
|
||||
limit: int = 25,
|
||||
) -> list[dict]:
|
||||
"""Search Reddit for posts matching a query.
|
||||
|
||||
Args:
|
||||
query: Search terms.
|
||||
subreddit: Subreddit to search within, or "all" for site-wide.
|
||||
sort: One of "relevance", "hot", "top", "new", "comments".
|
||||
time_filter: One of "hour", "day", "week", "month", "year", "all".
|
||||
limit: Max results (capped at 100 by Reddit).
|
||||
|
||||
Returns:
|
||||
List of post dicts with: title, selftext, author, score,
|
||||
num_comments, subreddit, url, permalink, created_utc.
|
||||
"""
|
||||
url = f"{REDDIT_BASE}/r/{subreddit}/search.json"
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(
|
||||
url,
|
||||
params={
|
||||
"q": query,
|
||||
"sort": sort,
|
||||
"t": time_filter,
|
||||
"limit": min(limit, 100),
|
||||
"restrict_sr": "on" if subreddit != "all" else "off",
|
||||
},
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = []
|
||||
for child in data.get("data", {}).get("children", []):
|
||||
post = child.get("data", {})
|
||||
results.append(
|
||||
{
|
||||
"title": post.get("title", ""),
|
||||
"selftext": (post.get("selftext") or "")[:2000],
|
||||
"author": post.get("author", "[deleted]"),
|
||||
"score": post.get("score", 0),
|
||||
"upvote_ratio": post.get("upvote_ratio", 0),
|
||||
"num_comments": post.get("num_comments", 0),
|
||||
"subreddit": post.get("subreddit", ""),
|
||||
"url": post.get("url", ""),
|
||||
"permalink": f"https://reddit.com{post.get('permalink', '')}",
|
||||
"created_utc": post.get("created_utc", 0),
|
||||
"is_self": post.get("is_self", False),
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
async def get_post_comments(
|
||||
permalink: str,
|
||||
sort: str = "top",
|
||||
limit: int = 25,
|
||||
) -> list[dict]:
|
||||
"""Fetch top-level comments for a Reddit post.
|
||||
|
||||
Args:
|
||||
permalink: The post's permalink path (e.g., "/r/python/comments/abc123/title/").
|
||||
sort: Comment sort order: "top", "best", "new", "controversial".
|
||||
limit: Max comments to return.
|
||||
|
||||
Returns:
|
||||
List of comment dicts with: body, author, score, created_utc.
|
||||
"""
|
||||
# Strip domain if full URL was passed
|
||||
if permalink.startswith("https://"):
|
||||
permalink = permalink.replace("https://reddit.com", "")
|
||||
permalink = permalink.replace("https://www.reddit.com", "")
|
||||
|
||||
url = f"{REDDIT_BASE}{permalink}.json"
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(
|
||||
url,
|
||||
params={"sort": sort, "limit": limit},
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Reddit returns [post_listing, comments_listing]
|
||||
if not isinstance(data, list) or len(data) < 2:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for child in data[1].get("data", {}).get("children", []):
|
||||
if child.get("kind") != "t1":
|
||||
continue
|
||||
comment = child.get("data", {})
|
||||
results.append(
|
||||
{
|
||||
"body": (comment.get("body") or "")[:2000],
|
||||
"author": comment.get("author", "[deleted]"),
|
||||
"score": comment.get("score", 0),
|
||||
"created_utc": comment.get("created_utc", 0),
|
||||
}
|
||||
)
|
||||
return results
|
||||
70
agentstuff/sentiment_agent/config.py
Normal file
70
agentstuff/sentiment_agent/config.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Configuration and safety limits for the sentiment agent.
|
||||
|
||||
All guardrails are centralized here so they can be tuned from one place
|
||||
or overridden via CLI flags / env vars.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimitConfig:
|
||||
"""Per-platform rate limiting."""
|
||||
|
||||
requests_per_minute: int = 10
|
||||
burst_size: int = 3 # max concurrent requests
|
||||
cooldown_after_429: float = 30.0 # seconds to wait after a 429
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SafetyConfig:
|
||||
"""Top-level safety rails for the agent."""
|
||||
|
||||
# --- Agent-level limits ---
|
||||
max_turns: int = 20
|
||||
max_budget_usd: float = 0.50 # hard cap on Claude API spend per run
|
||||
max_total_api_calls: int = 50 # across ALL platforms combined
|
||||
max_results_per_call: int = 50 # cap the `limit` param sent to any API
|
||||
|
||||
# --- Per-platform rate limits ---
|
||||
bluesky_rate: RateLimitConfig = field(default_factory=lambda: RateLimitConfig(
|
||||
requests_per_minute=10, burst_size=2,
|
||||
))
|
||||
reddit_rate: RateLimitConfig = field(default_factory=lambda: RateLimitConfig(
|
||||
requests_per_minute=10, burst_size=2,
|
||||
))
|
||||
hackernews_rate: RateLimitConfig = field(default_factory=lambda: RateLimitConfig(
|
||||
requests_per_minute=15, burst_size=3, # HN Algolia is more generous
|
||||
))
|
||||
|
||||
# --- Content size limits ---
|
||||
max_post_text_chars: int = 2000 # truncate individual posts beyond this
|
||||
max_total_content_bytes: int = 500_000 # ~500KB total data gathered before agent stops
|
||||
|
||||
# --- Timeout ---
|
||||
api_timeout_seconds: float = 15.0
|
||||
|
||||
# --- Credibility thresholds ---
|
||||
min_credibility_score: float = 0.3 # posts below this are flagged/excluded
|
||||
flag_bot_threshold: float = 0.5 # posts between min and this are flagged but included
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> SafetyConfig:
|
||||
"""Build config with env var overrides.
|
||||
|
||||
Env vars: SENTIMENT_MAX_TURNS, SENTIMENT_MAX_BUDGET_USD,
|
||||
SENTIMENT_MAX_API_CALLS, SENTIMENT_MIN_CREDIBILITY.
|
||||
"""
|
||||
kwargs: dict = {}
|
||||
if v := os.environ.get("SENTIMENT_MAX_TURNS"):
|
||||
kwargs["max_turns"] = int(v)
|
||||
if v := os.environ.get("SENTIMENT_MAX_BUDGET_USD"):
|
||||
kwargs["max_budget_usd"] = float(v)
|
||||
if v := os.environ.get("SENTIMENT_MAX_API_CALLS"):
|
||||
kwargs["max_total_api_calls"] = int(v)
|
||||
if v := os.environ.get("SENTIMENT_MIN_CREDIBILITY"):
|
||||
kwargs["min_credibility_score"] = float(v)
|
||||
return cls(**kwargs)
|
||||
398
agentstuff/sentiment_agent/credibility.py
Normal file
398
agentstuff/sentiment_agent/credibility.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""Credibility scoring and bot/disinfo detection.
|
||||
|
||||
Assigns a 0.0–1.0 credibility score to each post based on heuristic signals.
|
||||
Posts below the configured threshold are excluded or flagged so they don't
|
||||
pollute the sentiment analysis.
|
||||
|
||||
Signals are platform-aware — each platform has different indicators of
|
||||
inauthentic behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
class CredibilityResult:
|
||||
"""Credibility assessment for a single post."""
|
||||
|
||||
score: float # 0.0 (likely bot/disinfo) to 1.0 (likely authentic)
|
||||
flags: list[str] = field(default_factory=list) # human-readable reasons
|
||||
is_excluded: bool = False # below min_credibility_score
|
||||
is_flagged: bool = False # between min and flag threshold
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
if self.score >= 0.7:
|
||||
return "likely_authentic"
|
||||
if self.score >= 0.5:
|
||||
return "uncertain"
|
||||
if self.score >= 0.3:
|
||||
return "suspicious"
|
||||
return "likely_inauthentic"
|
||||
|
||||
|
||||
# --- Shared heuristics ---
|
||||
|
||||
# Common bot patterns in text
|
||||
_BOT_TEXT_PATTERNS = [
|
||||
# Crypto/scam spam
|
||||
re.compile(r"(?i)(dm me|check my bio|link in bio|click here|free giveaway)"),
|
||||
re.compile(r"(?i)(join my|subscribe to|follow me for|🔥.*🔥.*🔥)"),
|
||||
# Astroturfing phrases
|
||||
re.compile(r"(?i)(i (just )?(discovered|found|tried) this (amazing|incredible|awesome))"),
|
||||
re.compile(r"(?i)(game.?changer|life.?changing|you won'?t believe)"),
|
||||
# Excessive hashtags (5+)
|
||||
re.compile(r"(#\w+\s*){5,}"),
|
||||
# Walls of emojis (10+ consecutive)
|
||||
re.compile(r"[\U0001F300-\U0001FAFF]{10,}"),
|
||||
# Repetitive characters (spammy emphasis)
|
||||
re.compile(r"(.)\1{9,}"),
|
||||
]
|
||||
|
||||
# Coordinated campaign indicators: identical or near-identical text
|
||||
# This is checked at the batch level, not per-post
|
||||
|
||||
|
||||
def _check_text_patterns(text: str) -> list[str]:
|
||||
"""Check text against common bot/spam patterns."""
|
||||
flags = []
|
||||
for pattern in _BOT_TEXT_PATTERNS:
|
||||
if pattern.search(text):
|
||||
flags.append(f"bot_text_pattern: {pattern.pattern[:60]}")
|
||||
if len(text) < 15:
|
||||
flags.append("very_short_text")
|
||||
return flags
|
||||
|
||||
|
||||
def _engagement_ratio_score(
|
||||
likes: int, reposts: int, replies: int
|
||||
) -> tuple[float, list[str]]:
|
||||
"""Score based on engagement ratios.
|
||||
|
||||
Authentic posts tend to have a mix of likes, replies, and reposts.
|
||||
Bot-amplified posts often have inflated likes with very few replies,
|
||||
or massive repost counts with no discussion.
|
||||
"""
|
||||
flags = []
|
||||
total = likes + reposts + replies
|
||||
|
||||
if total == 0:
|
||||
return 0.5, ["no_engagement"]
|
||||
|
||||
# High repost-to-reply ratio suggests amplification without discussion
|
||||
if reposts > 0 and replies == 0 and reposts > 10:
|
||||
flags.append(f"high_repost_no_replies: {reposts} reposts, 0 replies")
|
||||
return 0.3, flags
|
||||
|
||||
# Extremely high like count with zero replies is suspicious
|
||||
if likes > 100 and replies == 0:
|
||||
flags.append(f"high_likes_no_replies: {likes} likes, 0 replies")
|
||||
return 0.4, flags
|
||||
|
||||
# Normal engagement
|
||||
return min(1.0, 0.5 + (replies / max(total, 1)) * 0.5), flags
|
||||
|
||||
|
||||
# --- Platform-specific scoring ---
|
||||
|
||||
|
||||
def score_bluesky_post(post: dict) -> CredibilityResult:
|
||||
"""Score a Bluesky post for credibility."""
|
||||
score = 1.0
|
||||
flags: list[str] = []
|
||||
|
||||
text = post.get("text", "")
|
||||
handle = post.get("author_handle", "")
|
||||
display_name = post.get("author_display_name", "")
|
||||
likes = post.get("like_count", 0)
|
||||
reposts = post.get("repost_count", 0)
|
||||
replies = post.get("reply_count", 0)
|
||||
|
||||
# Text pattern checks
|
||||
text_flags = _check_text_patterns(text)
|
||||
if text_flags:
|
||||
score -= 0.15 * len(text_flags)
|
||||
flags.extend(text_flags)
|
||||
|
||||
# Handle heuristics
|
||||
# Randomly generated handles (long hex/number strings)
|
||||
if re.match(r"^[a-f0-9]{8,}\.", handle):
|
||||
flags.append(f"random_handle: {handle}")
|
||||
score -= 0.3
|
||||
|
||||
# No display name set
|
||||
if not display_name or display_name == handle:
|
||||
flags.append("no_display_name")
|
||||
score -= 0.1
|
||||
|
||||
# Engagement ratio
|
||||
eng_score, eng_flags = _engagement_ratio_score(likes, reposts, replies)
|
||||
flags.extend(eng_flags)
|
||||
score = score * 0.6 + eng_score * 0.4
|
||||
|
||||
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
|
||||
|
||||
|
||||
def score_reddit_post(post: dict) -> CredibilityResult:
|
||||
"""Score a Reddit post for credibility."""
|
||||
score = 1.0
|
||||
flags: list[str] = []
|
||||
|
||||
text = post.get("selftext", "") or post.get("title", "")
|
||||
author = post.get("author", "")
|
||||
upvote_ratio = post.get("upvote_ratio", 0.5)
|
||||
post_score = post.get("score", 0)
|
||||
num_comments = post.get("num_comments", 0)
|
||||
|
||||
# Text patterns
|
||||
text_flags = _check_text_patterns(text)
|
||||
if text_flags:
|
||||
score -= 0.15 * len(text_flags)
|
||||
flags.extend(text_flags)
|
||||
|
||||
# Deleted author
|
||||
if author in ("[deleted]", "[removed]"):
|
||||
flags.append("deleted_author")
|
||||
score -= 0.2
|
||||
|
||||
# Suspicious username patterns (random alphanumeric + numbers)
|
||||
if re.match(r"^[A-Za-z]+[-_]?\d{4,}$", author):
|
||||
flags.append(f"auto_generated_username: {author}")
|
||||
score -= 0.15
|
||||
|
||||
# Very controversial ratio (lots of up AND down votes)
|
||||
if upvote_ratio < 0.4 and post_score > 0:
|
||||
flags.append(f"highly_controversial: {upvote_ratio:.0%} upvote ratio")
|
||||
score -= 0.1
|
||||
|
||||
# High score but zero comments = potential vote manipulation
|
||||
if post_score > 100 and num_comments == 0:
|
||||
flags.append(f"high_score_no_comments: {post_score} score, 0 comments")
|
||||
score -= 0.2
|
||||
|
||||
# Low-effort cross-post spam: very short title, external link, no selftext
|
||||
if (
|
||||
len(post.get("title", "")) < 20
|
||||
and not post.get("is_self", True)
|
||||
and not post.get("selftext")
|
||||
):
|
||||
flags.append("possible_link_spam")
|
||||
score -= 0.1
|
||||
|
||||
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
|
||||
|
||||
|
||||
def score_reddit_comment(comment: dict) -> CredibilityResult:
|
||||
"""Score a Reddit comment for credibility."""
|
||||
score = 1.0
|
||||
flags: list[str] = []
|
||||
|
||||
body = comment.get("body", "")
|
||||
author = comment.get("author", "")
|
||||
comment_score = comment.get("score", 0)
|
||||
|
||||
text_flags = _check_text_patterns(body)
|
||||
if text_flags:
|
||||
score -= 0.15 * len(text_flags)
|
||||
flags.extend(text_flags)
|
||||
|
||||
if author in ("[deleted]", "[removed]"):
|
||||
flags.append("deleted_author")
|
||||
score -= 0.2
|
||||
|
||||
if re.match(r"^[A-Za-z]+[-_]?\d{4,}$", author):
|
||||
flags.append(f"auto_generated_username: {author}")
|
||||
score -= 0.15
|
||||
|
||||
# Heavily downvoted
|
||||
if comment_score < -5:
|
||||
flags.append(f"heavily_downvoted: {comment_score}")
|
||||
score -= 0.15
|
||||
|
||||
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
|
||||
|
||||
|
||||
def score_hackernews_post(post: dict) -> CredibilityResult:
|
||||
"""Score a HN story for credibility.
|
||||
|
||||
HN is generally higher-signal than social media, but we still check
|
||||
for low-effort submissions and spammy patterns.
|
||||
"""
|
||||
score = 1.0
|
||||
flags: list[str] = []
|
||||
|
||||
title = post.get("title", "")
|
||||
text = post.get("story_text", "") or title
|
||||
points = post.get("points", 0)
|
||||
num_comments = post.get("num_comments", 0)
|
||||
|
||||
text_flags = _check_text_patterns(text)
|
||||
if text_flags:
|
||||
score -= 0.1 * len(text_flags)
|
||||
flags.extend(text_flags)
|
||||
|
||||
# Zero points = the community didn't find it valuable
|
||||
if points == 0:
|
||||
flags.append("zero_points")
|
||||
score -= 0.1
|
||||
|
||||
# HN is generally more credible, start with a bonus
|
||||
score = min(1.0, score + 0.1)
|
||||
|
||||
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
|
||||
|
||||
|
||||
def score_hackernews_comment(comment: dict) -> CredibilityResult:
|
||||
"""Score a HN comment for credibility."""
|
||||
score = 1.0
|
||||
flags: list[str] = []
|
||||
|
||||
text = comment.get("comment_text", "")
|
||||
|
||||
text_flags = _check_text_patterns(text)
|
||||
if text_flags:
|
||||
score -= 0.1 * len(text_flags)
|
||||
flags.extend(text_flags)
|
||||
|
||||
# HN comments are generally higher quality
|
||||
score = min(1.0, score + 0.1)
|
||||
|
||||
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
|
||||
|
||||
|
||||
# --- Batch-level coordination detection ---
|
||||
|
||||
|
||||
def detect_coordination(posts: list[dict], text_key: str = "text") -> list[str]:
|
||||
"""Detect coordinated inauthentic behavior across a batch of posts.
|
||||
|
||||
Looks for:
|
||||
- Duplicate or near-duplicate text (copy-paste campaigns)
|
||||
- Burst posting (many posts in a very short window)
|
||||
- Same talking points with minor variations
|
||||
|
||||
Returns a list of warning strings.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
texts = [p.get(text_key, "") for p in posts if p.get(text_key)]
|
||||
|
||||
if not texts:
|
||||
return warnings
|
||||
|
||||
# Exact duplicates
|
||||
seen: dict[str, int] = {}
|
||||
for t in texts:
|
||||
normalized = t.strip().lower()
|
||||
seen[normalized] = seen.get(normalized, 0) + 1
|
||||
|
||||
duplicates = {text: count for text, count in seen.items() if count > 1}
|
||||
if duplicates:
|
||||
total_dupes = sum(duplicates.values())
|
||||
warnings.append(
|
||||
f"COORDINATION WARNING: {len(duplicates)} duplicate texts found "
|
||||
f"({total_dupes} total copies). Possible copy-paste campaign."
|
||||
)
|
||||
|
||||
# Near-duplicates: check if many posts share a long common substring
|
||||
# (simplified: check if >30% of posts start with the same 50+ chars)
|
||||
if len(texts) >= 5:
|
||||
prefixes: dict[str, int] = {}
|
||||
for t in texts:
|
||||
prefix = t.strip().lower()[:80]
|
||||
if len(prefix) >= 50:
|
||||
prefixes[prefix] = prefixes.get(prefix, 0) + 1
|
||||
|
||||
for prefix, count in prefixes.items():
|
||||
if count >= len(texts) * 0.3:
|
||||
warnings.append(
|
||||
f"COORDINATION WARNING: {count}/{len(texts)} posts share "
|
||||
f"a common prefix ({prefix[:50]}...). Possible template campaign."
|
||||
)
|
||||
|
||||
# Burst detection: if timestamps are available
|
||||
timestamps = []
|
||||
for p in posts:
|
||||
created = p.get("created_at") or p.get("created_utc")
|
||||
if isinstance(created, str):
|
||||
try:
|
||||
timestamps.append(datetime.fromisoformat(created.replace("Z", "+00:00")))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif isinstance(created, (int, float)):
|
||||
timestamps.append(datetime.fromtimestamp(created, tz=timezone.utc))
|
||||
|
||||
if len(timestamps) >= 5:
|
||||
timestamps.sort()
|
||||
# Check if >50% of posts landed within a 5-minute window
|
||||
window_seconds = 300
|
||||
for i in range(len(timestamps) - 2):
|
||||
window_end = timestamps[i] + __import__("datetime").timedelta(seconds=window_seconds)
|
||||
in_window = sum(1 for t in timestamps if timestamps[i] <= t <= window_end)
|
||||
if in_window >= len(timestamps) * 0.5:
|
||||
warnings.append(
|
||||
f"COORDINATION WARNING: {in_window}/{len(timestamps)} posts "
|
||||
f"appeared within a 5-minute window. Possible coordinated posting."
|
||||
)
|
||||
break
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def filter_and_annotate(
|
||||
posts: list[dict],
|
||||
scorer,
|
||||
min_score: float = 0.3,
|
||||
flag_threshold: float = 0.5,
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""Score all posts, filter out low-credibility ones, and annotate the rest.
|
||||
|
||||
Args:
|
||||
posts: List of post dicts from any platform.
|
||||
scorer: A scoring function (e.g., score_reddit_post).
|
||||
min_score: Posts below this are excluded.
|
||||
flag_threshold: Posts between min_score and this are flagged.
|
||||
|
||||
Returns:
|
||||
Tuple of (filtered_posts, stats_dict).
|
||||
Each post in filtered_posts gets a "_credibility" key added.
|
||||
"""
|
||||
filtered = []
|
||||
stats = {
|
||||
"total": len(posts),
|
||||
"excluded": 0,
|
||||
"flagged": 0,
|
||||
"authentic": 0,
|
||||
"excluded_reasons": [],
|
||||
}
|
||||
|
||||
for post in posts:
|
||||
result = scorer(post)
|
||||
result.is_excluded = result.score < min_score
|
||||
result.is_flagged = min_score <= result.score < flag_threshold
|
||||
|
||||
if result.is_excluded:
|
||||
stats["excluded"] += 1
|
||||
stats["excluded_reasons"].append(
|
||||
{"score": round(result.score, 2), "flags": result.flags}
|
||||
)
|
||||
continue
|
||||
|
||||
post["_credibility"] = {
|
||||
"score": round(result.score, 2),
|
||||
"label": result.label,
|
||||
"flags": result.flags,
|
||||
"is_flagged": result.is_flagged,
|
||||
}
|
||||
|
||||
if result.is_flagged:
|
||||
stats["flagged"] += 1
|
||||
else:
|
||||
stats["authentic"] += 1
|
||||
|
||||
filtered.append(post)
|
||||
|
||||
return filtered, stats
|
||||
66
agentstuff/sentiment_agent/main.py
Normal file
66
agentstuff/sentiment_agent/main.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""CLI entry point for the sentiment analysis agent."""
|
||||
|
||||
import argparse
|
||||
import anyio
|
||||
|
||||
from sentiment_agent.agent import run_sentiment_analysis
|
||||
from sentiment_agent.config import SafetyConfig
|
||||
|
||||
|
||||
async def async_main(args: argparse.Namespace) -> None:
|
||||
config = SafetyConfig(
|
||||
max_turns=args.max_turns,
|
||||
max_budget_usd=args.max_budget,
|
||||
max_total_api_calls=args.max_api_calls,
|
||||
min_credibility_score=args.min_credibility,
|
||||
flag_bot_threshold=args.flag_threshold,
|
||||
)
|
||||
|
||||
result = await run_sentiment_analysis(
|
||||
topic=args.topic,
|
||||
sources=args.sources,
|
||||
config=config,
|
||||
)
|
||||
print("\n" + "=" * 60)
|
||||
print("SENTIMENT ANALYSIS REPORT")
|
||||
print("=" * 60)
|
||||
print(result)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run sentiment analysis on a topic with bot/disinfo detection",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument("topic", help="The topic to analyze sentiment for")
|
||||
parser.add_argument(
|
||||
"--sources", nargs="*", help="Specific URLs or sources to also analyze"
|
||||
)
|
||||
|
||||
safety = parser.add_argument_group("safety limits")
|
||||
safety.add_argument(
|
||||
"--max-turns", type=int, default=20, help="Max agent turns"
|
||||
)
|
||||
safety.add_argument(
|
||||
"--max-budget", type=float, default=0.50, help="Max Claude API spend (USD)"
|
||||
)
|
||||
safety.add_argument(
|
||||
"--max-api-calls", type=int, default=50, help="Max total API calls across all platforms"
|
||||
)
|
||||
|
||||
credibility = parser.add_argument_group("credibility filtering")
|
||||
credibility.add_argument(
|
||||
"--min-credibility", type=float, default=0.3,
|
||||
help="Posts below this score are excluded (0.0-1.0)",
|
||||
)
|
||||
credibility.add_argument(
|
||||
"--flag-threshold", type=float, default=0.5,
|
||||
help="Posts between min and this are flagged but included (0.0-1.0)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
anyio.run(async_main, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
169
agentstuff/sentiment_agent/ratelimit.py
Normal file
169
agentstuff/sentiment_agent/ratelimit.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Rate limiter and API call budget tracker.
|
||||
|
||||
Enforces per-platform rate limits and a global call budget so the agent
|
||||
can't hammer APIs or run up unbounded costs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sentiment_agent.config import RateLimitConfig
|
||||
|
||||
|
||||
class BudgetExhaustedError(Exception):
|
||||
"""Raised when the global API call budget is spent."""
|
||||
|
||||
|
||||
class RateLimitExceededError(Exception):
|
||||
"""Raised when a platform's rate limit is hit and cooldown hasn't elapsed."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PlatformState:
|
||||
"""Tracks call timestamps and active request count for one platform."""
|
||||
|
||||
config: RateLimitConfig
|
||||
call_timestamps: list[float] = field(default_factory=list)
|
||||
active_requests: int = 0
|
||||
last_429_at: float = 0.0
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Manages rate limiting across all platforms + a global call budget.
|
||||
|
||||
Usage:
|
||||
limiter = RateLimiter(max_total_calls=50)
|
||||
limiter.register_platform("reddit", RateLimitConfig(...))
|
||||
|
||||
async with limiter.acquire("reddit"):
|
||||
await do_reddit_call()
|
||||
"""
|
||||
|
||||
def __init__(self, max_total_calls: int = 50):
|
||||
self._max_total = max_total_calls
|
||||
self._total_calls = 0
|
||||
self._platforms: dict[str, _PlatformState] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def total_calls(self) -> int:
|
||||
return self._total_calls
|
||||
|
||||
@property
|
||||
def remaining_calls(self) -> int:
|
||||
return max(0, self._max_total - self._total_calls)
|
||||
|
||||
def register_platform(self, name: str, config: RateLimitConfig) -> None:
|
||||
self._platforms[name] = _PlatformState(config=config)
|
||||
|
||||
def acquire(self, platform: str) -> _AcquireContext:
|
||||
"""Context manager that enforces rate limits before allowing a call."""
|
||||
return _AcquireContext(self, platform)
|
||||
|
||||
async def _acquire(self, platform: str) -> None:
|
||||
async with self._lock:
|
||||
if self._total_calls >= self._max_total:
|
||||
raise BudgetExhaustedError(
|
||||
f"Global API call budget exhausted ({self._max_total} calls). "
|
||||
"Increase max_total_api_calls in SafetyConfig to allow more."
|
||||
)
|
||||
|
||||
state = self._platforms.get(platform)
|
||||
if not state:
|
||||
raise ValueError(f"Platform '{platform}' not registered with rate limiter")
|
||||
|
||||
now = time.monotonic()
|
||||
|
||||
# Check 429 cooldown
|
||||
if state.last_429_at:
|
||||
elapsed = now - state.last_429_at
|
||||
if elapsed < state.config.cooldown_after_429:
|
||||
remaining = state.config.cooldown_after_429 - elapsed
|
||||
raise RateLimitExceededError(
|
||||
f"Platform '{platform}' is in cooldown after 429. "
|
||||
f"Try again in {remaining:.0f}s."
|
||||
)
|
||||
state.last_429_at = 0.0
|
||||
|
||||
# Check burst limit
|
||||
if state.active_requests >= state.config.burst_size:
|
||||
raise RateLimitExceededError(
|
||||
f"Platform '{platform}' burst limit reached "
|
||||
f"({state.config.burst_size} concurrent). Wait for a request to finish."
|
||||
)
|
||||
|
||||
# Check RPM: discard timestamps older than 60s, then check count
|
||||
cutoff = now - 60.0
|
||||
state.call_timestamps = [t for t in state.call_timestamps if t > cutoff]
|
||||
|
||||
if len(state.call_timestamps) >= state.config.requests_per_minute:
|
||||
oldest = state.call_timestamps[0]
|
||||
wait_time = 60.0 - (now - oldest)
|
||||
raise RateLimitExceededError(
|
||||
f"Platform '{platform}' rate limit: {state.config.requests_per_minute}/min. "
|
||||
f"Try again in {wait_time:.0f}s."
|
||||
)
|
||||
|
||||
# All clear — record the call
|
||||
state.call_timestamps.append(now)
|
||||
state.active_requests += 1
|
||||
self._total_calls += 1
|
||||
|
||||
async def _release(self, platform: str) -> None:
|
||||
async with self._lock:
|
||||
state = self._platforms.get(platform)
|
||||
if state:
|
||||
state.active_requests = max(0, state.active_requests - 1)
|
||||
|
||||
def record_429(self, platform: str) -> None:
|
||||
"""Call this when an API returns 429 to trigger cooldown."""
|
||||
state = self._platforms.get(platform)
|
||||
if state:
|
||||
state.last_429_at = time.monotonic()
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Return current usage stats for logging/reporting."""
|
||||
stats: dict = {
|
||||
"total_calls": self._total_calls,
|
||||
"remaining_calls": self.remaining_calls,
|
||||
"platforms": {},
|
||||
}
|
||||
for name, state in self._platforms.items():
|
||||
now = time.monotonic()
|
||||
cutoff = now - 60.0
|
||||
recent = [t for t in state.call_timestamps if t > cutoff]
|
||||
stats["platforms"][name] = {
|
||||
"calls_last_60s": len(recent),
|
||||
"active_requests": state.active_requests,
|
||||
"rpm_limit": state.config.requests_per_minute,
|
||||
"in_cooldown": bool(
|
||||
state.last_429_at
|
||||
and (now - state.last_429_at) < state.config.cooldown_after_429
|
||||
),
|
||||
}
|
||||
return stats
|
||||
|
||||
|
||||
class _AcquireContext:
|
||||
"""Async context manager for rate-limited API calls."""
|
||||
|
||||
def __init__(self, limiter: RateLimiter, platform: str):
|
||||
self._limiter = limiter
|
||||
self._platform = platform
|
||||
|
||||
async def __aenter__(self) -> None:
|
||||
await self._limiter._acquire(self._platform)
|
||||
|
||||
async def __aexit__(self, *exc_info) -> None:
|
||||
# Check if the call got a 429
|
||||
if exc_info[0] is not None:
|
||||
import httpx
|
||||
|
||||
exc = exc_info[1]
|
||||
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 429:
|
||||
self._limiter.record_429(self._platform)
|
||||
|
||||
await self._limiter._release(self._platform)
|
||||
352
agentstuff/sentiment_agent/tools.py
Normal file
352
agentstuff/sentiment_agent/tools.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""Custom MCP tools for social media and forum data gathering.
|
||||
|
||||
Each tool wraps an API client, enforces rate limits, runs credibility
|
||||
scoring, and returns MCP-formatted results with bot/disinfo annotations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import traceback
|
||||
|
||||
from claude_agent_sdk import tool, create_sdk_mcp_server
|
||||
|
||||
from sentiment_agent.clients import bluesky, reddit, hackernews
|
||||
from sentiment_agent.config import SafetyConfig
|
||||
from sentiment_agent.credibility import (
|
||||
detect_coordination,
|
||||
filter_and_annotate,
|
||||
score_bluesky_post,
|
||||
score_hackernews_comment,
|
||||
score_hackernews_post,
|
||||
score_reddit_comment,
|
||||
score_reddit_post,
|
||||
)
|
||||
from sentiment_agent.ratelimit import BudgetExhaustedError, RateLimiter
|
||||
|
||||
# Module-level state — initialized by create_social_tools_server()
|
||||
_limiter: RateLimiter | None = None
|
||||
_config: SafetyConfig | None = None
|
||||
|
||||
|
||||
def _get_limiter() -> RateLimiter:
|
||||
if _limiter is None:
|
||||
raise RuntimeError("Tools not initialized — call create_social_tools_server() first")
|
||||
return _limiter
|
||||
|
||||
|
||||
def _get_config() -> SafetyConfig:
|
||||
if _config is None:
|
||||
return SafetyConfig()
|
||||
return _config
|
||||
|
||||
|
||||
def _text_result(text: str) -> dict:
|
||||
return {"content": [{"type": "text", "text": text}]}
|
||||
|
||||
|
||||
def _error_result(error: str) -> dict:
|
||||
return {"content": [{"type": "text", "text": f"Error: {error}"}], "isError": True}
|
||||
|
||||
|
||||
def _clamp_limit(requested: int) -> int:
|
||||
"""Enforce max results per call."""
|
||||
return min(requested, _get_config().max_results_per_call)
|
||||
|
||||
|
||||
def _format_with_stats(
|
||||
posts: list[dict],
|
||||
stats: dict,
|
||||
coordination_warnings: list[str],
|
||||
platform: str,
|
||||
) -> str:
|
||||
"""Format results with credibility stats prepended."""
|
||||
header_parts = [
|
||||
f"Platform: {platform}",
|
||||
f"Results: {stats['authentic']} authentic, {stats['flagged']} flagged, "
|
||||
f"{stats['excluded']} excluded (of {stats['total']} total)",
|
||||
]
|
||||
if coordination_warnings:
|
||||
header_parts.append("--- COORDINATION ALERTS ---")
|
||||
header_parts.extend(coordination_warnings)
|
||||
header_parts.append("---")
|
||||
|
||||
limiter = _get_limiter()
|
||||
header_parts.append(f"API budget remaining: {limiter.remaining_calls} calls")
|
||||
|
||||
header = "\n".join(header_parts)
|
||||
body = json.dumps(posts, indent=2, default=str)
|
||||
return f"{header}\n\n{body}"
|
||||
|
||||
|
||||
# --- Bluesky tools ---
|
||||
|
||||
|
||||
@tool(
|
||||
"search_bluesky",
|
||||
"Search Bluesky for posts about a topic. Returns posts with text, author, "
|
||||
"engagement metrics, credibility scores, and bot/disinfo flags. "
|
||||
"Requires BLUESKY_HANDLE and BLUESKY_APP_PASSWORD env vars.",
|
||||
{"query": str, "limit": int, "sort": str},
|
||||
)
|
||||
async def search_bluesky(args: dict) -> dict:
|
||||
try:
|
||||
limiter = _get_limiter()
|
||||
config = _get_config()
|
||||
|
||||
async with limiter.acquire("bluesky"):
|
||||
posts = await bluesky.search_posts(
|
||||
query=args["query"],
|
||||
limit=_clamp_limit(args.get("limit", 25)),
|
||||
sort=args.get("sort", "top"),
|
||||
)
|
||||
|
||||
if not posts:
|
||||
return _text_result(f"No Bluesky posts found for: {args['query']}")
|
||||
|
||||
coordination = detect_coordination(posts, text_key="text")
|
||||
filtered, stats = filter_and_annotate(
|
||||
posts, score_bluesky_post,
|
||||
min_score=config.min_credibility_score,
|
||||
flag_threshold=config.flag_bot_threshold,
|
||||
)
|
||||
return _text_result(_format_with_stats(filtered, stats, coordination, "Bluesky"))
|
||||
except BudgetExhaustedError as e:
|
||||
return _error_result(str(e))
|
||||
except Exception as e:
|
||||
return _error_result(f"Bluesky search failed: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
@tool(
|
||||
"get_bluesky_thread",
|
||||
"Fetch a Bluesky thread/post and its replies with credibility scoring. "
|
||||
"Accepts an at:// URI or https://bsky.app/... URL.",
|
||||
{"uri": str, "depth": int},
|
||||
)
|
||||
async def get_bluesky_thread(args: dict) -> dict:
|
||||
try:
|
||||
limiter = _get_limiter()
|
||||
config = _get_config()
|
||||
|
||||
async with limiter.acquire("bluesky"):
|
||||
thread = await bluesky.get_thread(
|
||||
uri=args["uri"],
|
||||
depth=args.get("depth", 6),
|
||||
)
|
||||
|
||||
# Score replies
|
||||
if thread.get("replies"):
|
||||
coordination = detect_coordination(thread["replies"], text_key="text")
|
||||
filtered_replies, stats = filter_and_annotate(
|
||||
thread["replies"], score_bluesky_post,
|
||||
min_score=config.min_credibility_score,
|
||||
flag_threshold=config.flag_bot_threshold,
|
||||
)
|
||||
thread["replies"] = filtered_replies
|
||||
thread["_reply_credibility_stats"] = stats
|
||||
thread["_coordination_warnings"] = coordination
|
||||
|
||||
# Score root post
|
||||
if thread.get("post"):
|
||||
result = score_bluesky_post(thread["post"])
|
||||
thread["post"]["_credibility"] = {
|
||||
"score": round(result.score, 2),
|
||||
"label": result.label,
|
||||
"flags": result.flags,
|
||||
}
|
||||
|
||||
return _text_result(json.dumps(thread, indent=2, default=str))
|
||||
except BudgetExhaustedError as e:
|
||||
return _error_result(str(e))
|
||||
except Exception as e:
|
||||
return _error_result(f"Bluesky thread fetch failed: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
# --- Reddit tools ---
|
||||
|
||||
|
||||
@tool(
|
||||
"search_reddit",
|
||||
"Search Reddit for posts about a topic. Returns posts with credibility scores "
|
||||
"and bot/disinfo flags. Posts below the credibility threshold are auto-excluded. "
|
||||
"Use subreddit='all' for site-wide or specify a subreddit name.",
|
||||
{"query": str, "subreddit": str, "sort": str, "time_filter": str, "limit": int},
|
||||
)
|
||||
async def search_reddit_tool(args: dict) -> dict:
|
||||
try:
|
||||
limiter = _get_limiter()
|
||||
config = _get_config()
|
||||
|
||||
async with limiter.acquire("reddit"):
|
||||
posts = await reddit.search_posts(
|
||||
query=args["query"],
|
||||
subreddit=args.get("subreddit", "all"),
|
||||
sort=args.get("sort", "relevance"),
|
||||
time_filter=args.get("time_filter", "month"),
|
||||
limit=_clamp_limit(args.get("limit", 25)),
|
||||
)
|
||||
|
||||
if not posts:
|
||||
return _text_result(f"No Reddit posts found for: {args['query']}")
|
||||
|
||||
coordination = detect_coordination(posts, text_key="title")
|
||||
filtered, stats = filter_and_annotate(
|
||||
posts, score_reddit_post,
|
||||
min_score=config.min_credibility_score,
|
||||
flag_threshold=config.flag_bot_threshold,
|
||||
)
|
||||
return _text_result(_format_with_stats(filtered, stats, coordination, "Reddit"))
|
||||
except BudgetExhaustedError as e:
|
||||
return _error_result(str(e))
|
||||
except Exception as e:
|
||||
return _error_result(f"Reddit search failed: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
@tool(
|
||||
"get_reddit_comments",
|
||||
"Fetch comments for a Reddit post with credibility scoring. "
|
||||
"Pass the permalink path or full URL.",
|
||||
{"permalink": str, "sort": str, "limit": int},
|
||||
)
|
||||
async def get_reddit_comments(args: dict) -> dict:
|
||||
try:
|
||||
limiter = _get_limiter()
|
||||
config = _get_config()
|
||||
|
||||
async with limiter.acquire("reddit"):
|
||||
comments = await reddit.get_post_comments(
|
||||
permalink=args["permalink"],
|
||||
sort=args.get("sort", "top"),
|
||||
limit=_clamp_limit(args.get("limit", 25)),
|
||||
)
|
||||
|
||||
if not comments:
|
||||
return _text_result("No comments found for this post.")
|
||||
|
||||
coordination = detect_coordination(comments, text_key="body")
|
||||
filtered, stats = filter_and_annotate(
|
||||
comments, score_reddit_comment,
|
||||
min_score=config.min_credibility_score,
|
||||
flag_threshold=config.flag_bot_threshold,
|
||||
)
|
||||
return _text_result(_format_with_stats(filtered, stats, coordination, "Reddit Comments"))
|
||||
except BudgetExhaustedError as e:
|
||||
return _error_result(str(e))
|
||||
except Exception as e:
|
||||
return _error_result(f"Reddit comments fetch failed: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
# --- Hacker News tools ---
|
||||
|
||||
|
||||
@tool(
|
||||
"search_hackernews",
|
||||
"Search Hacker News for stories with credibility scoring. "
|
||||
"No authentication required.",
|
||||
{"query": str, "limit": int},
|
||||
)
|
||||
async def search_hackernews_tool(args: dict) -> dict:
|
||||
try:
|
||||
limiter = _get_limiter()
|
||||
config = _get_config()
|
||||
|
||||
async with limiter.acquire("hackernews"):
|
||||
stories = await hackernews.search_stories(
|
||||
query=args["query"],
|
||||
limit=_clamp_limit(args.get("limit", 25)),
|
||||
)
|
||||
|
||||
if not stories:
|
||||
return _text_result(f"No HN stories found for: {args['query']}")
|
||||
|
||||
coordination = detect_coordination(stories, text_key="title")
|
||||
filtered, stats = filter_and_annotate(
|
||||
stories, score_hackernews_post,
|
||||
min_score=config.min_credibility_score,
|
||||
flag_threshold=config.flag_bot_threshold,
|
||||
)
|
||||
return _text_result(_format_with_stats(filtered, stats, coordination, "Hacker News"))
|
||||
except BudgetExhaustedError as e:
|
||||
return _error_result(str(e))
|
||||
except Exception as e:
|
||||
return _error_result(f"HN search failed: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
@tool(
|
||||
"search_hackernews_comments",
|
||||
"Search Hacker News comments for opinions and discussions with credibility scoring.",
|
||||
{"query": str, "limit": int},
|
||||
)
|
||||
async def search_hackernews_comments(args: dict) -> dict:
|
||||
try:
|
||||
limiter = _get_limiter()
|
||||
config = _get_config()
|
||||
|
||||
async with limiter.acquire("hackernews"):
|
||||
comments = await hackernews.search_comments(
|
||||
query=args["query"],
|
||||
limit=_clamp_limit(args.get("limit", 25)),
|
||||
)
|
||||
|
||||
if not comments:
|
||||
return _text_result(f"No HN comments found for: {args['query']}")
|
||||
|
||||
coordination = detect_coordination(comments, text_key="comment_text")
|
||||
filtered, stats = filter_and_annotate(
|
||||
comments, score_hackernews_comment,
|
||||
min_score=config.min_credibility_score,
|
||||
flag_threshold=config.flag_bot_threshold,
|
||||
)
|
||||
return _text_result(
|
||||
_format_with_stats(filtered, stats, coordination, "HN Comments")
|
||||
)
|
||||
except BudgetExhaustedError as e:
|
||||
return _error_result(str(e))
|
||||
except Exception as e:
|
||||
return _error_result(f"HN comment search failed: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
# --- Budget status tool ---
|
||||
|
||||
|
||||
@tool(
|
||||
"get_api_budget_status",
|
||||
"Check remaining API call budget, rate limit status, and per-platform stats. "
|
||||
"Use this before making more API calls to avoid hitting limits.",
|
||||
{},
|
||||
)
|
||||
async def get_api_budget_status(args: dict) -> dict:
|
||||
limiter = _get_limiter()
|
||||
stats = limiter.get_stats()
|
||||
return _text_result(json.dumps(stats, indent=2, default=str))
|
||||
|
||||
|
||||
# --- Server factory ---
|
||||
|
||||
|
||||
def create_social_tools_server(config: SafetyConfig | None = None):
|
||||
"""Create an MCP server with all social media/forum tools.
|
||||
|
||||
Initializes rate limiting and credibility thresholds from config.
|
||||
"""
|
||||
global _limiter, _config
|
||||
|
||||
_config = config or SafetyConfig.from_env()
|
||||
|
||||
_limiter = RateLimiter(max_total_calls=_config.max_total_api_calls)
|
||||
_limiter.register_platform("bluesky", _config.bluesky_rate)
|
||||
_limiter.register_platform("reddit", _config.reddit_rate)
|
||||
_limiter.register_platform("hackernews", _config.hackernews_rate)
|
||||
|
||||
return create_sdk_mcp_server(
|
||||
"social-tools",
|
||||
tools=[
|
||||
search_bluesky,
|
||||
get_bluesky_thread,
|
||||
search_reddit_tool,
|
||||
get_reddit_comments,
|
||||
search_hackernews_tool,
|
||||
search_hackernews_comments,
|
||||
get_api_budget_status,
|
||||
],
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||
pkgname=stegasoo-api-git
|
||||
pkgver=4.2.1
|
||||
pkgver=4.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Stegasoo REST API with TLS and API key authentication"
|
||||
arch=('x86_64')
|
||||
@@ -30,7 +30,7 @@ sha256sums=('SKIP')
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||
pkgname=stegasoo-cli-git
|
||||
pkgver=4.2.1
|
||||
pkgver=4.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Secure steganography CLI with hybrid photo + passphrase + PIN authentication"
|
||||
arch=('x86_64')
|
||||
@@ -29,7 +29,7 @@ sha256sums=('SKIP')
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||
pkgname=stegasoo-git
|
||||
pkgver=4.2.1
|
||||
pkgver=4.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
arch=('x86_64')
|
||||
@@ -27,7 +27,7 @@ sha256sums=('SKIP')
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
|
||||
224
docs/CLAUDE_WORKTREES.md
Normal file
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
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Stegasoo man page
|
||||
.\" 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
|
||||
stegasoo \- steganography with hybrid authentication
|
||||
.SH SYNOPSIS
|
||||
@@ -12,9 +12,10 @@ stegasoo \- steganography with hybrid authentication
|
||||
[\fIargs\fR]
|
||||
.SH DESCRIPTION
|
||||
.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
|
||||
(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
|
||||
Messages are encrypted using a hybrid authentication scheme that combines
|
||||
a reference photo (shared secret), passphrase, and PIN code.
|
||||
@@ -221,6 +222,83 @@ Reset admin password using recovery key.
|
||||
.PP
|
||||
Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR.
|
||||
.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
|
||||
Image security tools.
|
||||
.PP
|
||||
|
||||
@@ -17,9 +17,8 @@ import json
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
from fastapi import HTTPException, Security
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
# API key header name
|
||||
@@ -55,7 +54,7 @@ def _load_keys(location: str = "user") -> dict:
|
||||
try:
|
||||
with open(keys_file) as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"keys": [], "enabled": True}
|
||||
return {"keys": [], "enabled": True}
|
||||
|
||||
@@ -101,11 +100,13 @@ def add_api_key(name: str, location: str = "user") -> str:
|
||||
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(),
|
||||
})
|
||||
data["keys"].append(
|
||||
{
|
||||
"name": name,
|
||||
"hash": key_hash,
|
||||
"created": __import__("datetime").datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
_save_keys(data, location)
|
||||
|
||||
@@ -204,12 +205,12 @@ def get_api_key_status() -> dict:
|
||||
"keys": {
|
||||
"user": user_keys,
|
||||
"project": project_keys,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# FastAPI dependency for API key authentication
|
||||
async def require_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> str:
|
||||
async def require_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str:
|
||||
"""
|
||||
FastAPI dependency that requires a valid API key.
|
||||
|
||||
@@ -243,7 +244,7 @@ async def require_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) ->
|
||||
return api_key
|
||||
|
||||
|
||||
async def optional_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> Optional[str]:
|
||||
async def optional_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str | None:
|
||||
"""
|
||||
FastAPI dependency that optionally validates API key.
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stegasoo REST API (v4.2.1)
|
||||
Stegasoo REST API (v4.3.0)
|
||||
|
||||
FastAPI-based REST API for steganography operations.
|
||||
Supports both text messages and file embedding.
|
||||
|
||||
CHANGES in v4.3.0:
|
||||
- Audio steganography endpoints (/audio/*)
|
||||
- LSB and spread spectrum (DSSS) audio embedding modes
|
||||
- Audio info and capacity checking
|
||||
|
||||
CHANGES in v4.2.1:
|
||||
- API key authentication (X-API-Key header)
|
||||
- TLS support with self-signed certificates
|
||||
@@ -32,11 +37,31 @@ NEW in v3.0.1: DCT color mode and JPEG output format.
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
# Configure logging for API frontend
|
||||
_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,
|
||||
)
|
||||
api_logger = logging.getLogger("stegasoo.api")
|
||||
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -44,28 +69,28 @@ from pydantic import BaseModel, Field
|
||||
# API Key Authentication
|
||||
try:
|
||||
from .auth import (
|
||||
require_api_key,
|
||||
get_api_key_status,
|
||||
add_api_key,
|
||||
remove_api_key,
|
||||
list_api_keys,
|
||||
get_api_key_status,
|
||||
is_auth_enabled,
|
||||
list_api_keys,
|
||||
remove_api_key,
|
||||
require_api_key,
|
||||
)
|
||||
except ImportError:
|
||||
# When running directly (not as package)
|
||||
from auth import (
|
||||
require_api_key,
|
||||
get_api_key_status,
|
||||
add_api_key,
|
||||
remove_api_key,
|
||||
get_api_key_status,
|
||||
list_api_keys,
|
||||
is_auth_enabled,
|
||||
remove_api_key,
|
||||
require_api_key,
|
||||
)
|
||||
|
||||
# Add parent to path for development
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from stegasoo import (
|
||||
HAS_AUDIO_SUPPORT,
|
||||
MAX_FILE_PAYLOAD_SIZE,
|
||||
CapacityError,
|
||||
DecryptionError,
|
||||
@@ -87,6 +112,12 @@ from stegasoo import (
|
||||
validate_image,
|
||||
will_fit_by_mode,
|
||||
)
|
||||
|
||||
# Audio steganography (v4.3.0) - conditionally imported
|
||||
if HAS_AUDIO_SUPPORT:
|
||||
from stegasoo import decode_audio, encode_audio, get_audio_info
|
||||
from stegasoo.audio_steganography import calculate_audio_lsb_capacity
|
||||
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
||||
from stegasoo.constants import (
|
||||
DEFAULT_PASSPHRASE_WORDS,
|
||||
MAX_PASSPHRASE_WORDS,
|
||||
@@ -163,6 +194,8 @@ EmbedModeType = Literal["lsb", "dct"]
|
||||
ExtractModeType = Literal["auto", "lsb", "dct"]
|
||||
DctColorModeType = Literal["grayscale", "color"]
|
||||
DctOutputFormatType = Literal["png", "jpeg"]
|
||||
AudioEmbedModeType = Literal["audio_lsb", "audio_spread"]
|
||||
AudioExtractModeType = Literal["audio_auto", "audio_lsb", "audio_spread"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -405,6 +438,7 @@ class ModesResponse(BaseModel):
|
||||
|
||||
lsb: dict
|
||||
dct: DctModeInfo
|
||||
audio: dict | None = Field(default=None, description="Audio steganography modes (v4.3.0)")
|
||||
# Channel key status (v4.0.0)
|
||||
channel: dict | None = Field(default=None, description="Channel key status (v4.0.0)")
|
||||
|
||||
@@ -415,6 +449,7 @@ class StatusResponse(BaseModel):
|
||||
has_qrcode_read: bool
|
||||
has_qrcode_write: bool # v4.2.0: QR generation capability
|
||||
has_dct: bool
|
||||
has_audio: bool = Field(default=False, description="Audio steganography support (v4.3.0)")
|
||||
max_payload_kb: int
|
||||
available_modes: list[str]
|
||||
dct_features: dict | None = Field(default=None, description="DCT mode features (v3.0.1+)")
|
||||
@@ -479,6 +514,124 @@ class ErrorResponse(BaseModel):
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
# --- Audio models (v4.3.0) ---
|
||||
|
||||
|
||||
class AudioEncodeRequest(BaseModel):
|
||||
"""Request to encode a text message into audio."""
|
||||
|
||||
message: str
|
||||
reference_photo_base64: str
|
||||
carrier_audio_base64: str
|
||||
passphrase: str = Field(description="Passphrase for key derivation")
|
||||
pin: str = ""
|
||||
rsa_key_base64: str | None = None
|
||||
rsa_password: str | None = None
|
||||
channel_key: str | None = Field(
|
||||
default=None,
|
||||
description="Channel key for deployment isolation. null=auto, ''=public, 'XXXX-...'=explicit",
|
||||
)
|
||||
embed_mode: AudioEmbedModeType = Field(
|
||||
default="audio_lsb",
|
||||
description="Embedding mode: 'audio_lsb' (default) or 'audio_spread' (DSSS)",
|
||||
)
|
||||
chip_tier: int | None = Field(
|
||||
default=None,
|
||||
description="Spread spectrum chip tier: 0=lossless(256), 1=high_lossy(512), 2=low_lossy(1024). Only for audio_spread.",
|
||||
)
|
||||
|
||||
|
||||
class AudioEncodeFileRequest(BaseModel):
|
||||
"""Request to encode a file into audio."""
|
||||
|
||||
file_data_base64: str
|
||||
filename: str
|
||||
mime_type: str | None = None
|
||||
reference_photo_base64: str
|
||||
carrier_audio_base64: str
|
||||
passphrase: str = Field(description="Passphrase for key derivation")
|
||||
pin: str = ""
|
||||
rsa_key_base64: str | None = None
|
||||
rsa_password: str | None = None
|
||||
channel_key: str | None = Field(
|
||||
default=None,
|
||||
description="Channel key for deployment isolation. null=auto, ''=public, 'XXXX-...'=explicit",
|
||||
)
|
||||
embed_mode: AudioEmbedModeType = Field(
|
||||
default="audio_lsb",
|
||||
description="Embedding mode: 'audio_lsb' (default) or 'audio_spread' (DSSS)",
|
||||
)
|
||||
chip_tier: int | None = Field(
|
||||
default=None,
|
||||
description="Spread spectrum chip tier: 0=lossless(256), 1=high_lossy(512), 2=low_lossy(1024). Only for audio_spread.",
|
||||
)
|
||||
|
||||
|
||||
class AudioEncodeResponse(BaseModel):
|
||||
"""Response from audio encode operations."""
|
||||
|
||||
stego_audio_base64: str
|
||||
embed_mode: str = Field(description="Embedding mode used: 'audio_lsb' or 'audio_spread'")
|
||||
stats: dict = Field(description="Embedding statistics (samples_modified, capacity_used, etc.)")
|
||||
channel_mode: str = Field(default="public", description="Channel mode: 'public' or 'private'")
|
||||
channel_fingerprint: str | None = Field(
|
||||
default=None, description="Channel key fingerprint (if private mode)"
|
||||
)
|
||||
|
||||
|
||||
class AudioDecodeRequest(BaseModel):
|
||||
"""Request to decode a message or file from stego audio."""
|
||||
|
||||
stego_audio_base64: str
|
||||
reference_photo_base64: str
|
||||
passphrase: str = Field(description="Passphrase for key derivation")
|
||||
pin: str = ""
|
||||
rsa_key_base64: str | None = None
|
||||
rsa_password: str | None = None
|
||||
channel_key: str | None = Field(
|
||||
default=None,
|
||||
description="Channel key for decryption. null=auto, ''=public, 'XXXX-...'=explicit",
|
||||
)
|
||||
embed_mode: AudioExtractModeType = Field(
|
||||
default="audio_auto",
|
||||
description="Extraction mode: 'audio_auto' (default), 'audio_lsb', or 'audio_spread'",
|
||||
)
|
||||
|
||||
|
||||
class AudioInfoResponse(BaseModel):
|
||||
"""Response with audio file metadata and capacity info."""
|
||||
|
||||
sample_rate: int
|
||||
channels: int
|
||||
duration_seconds: float
|
||||
num_samples: int
|
||||
format: str
|
||||
bit_depth: int | None = None
|
||||
bitrate: int | None = None
|
||||
capacity_lsb: int = Field(description="LSB mode capacity in bytes")
|
||||
capacity_spread: int = Field(description="Spread spectrum mode capacity in bytes")
|
||||
|
||||
|
||||
class AudioCapacityRequest(BaseModel):
|
||||
"""Request to check if a payload fits in audio carrier."""
|
||||
|
||||
carrier_audio_base64: str
|
||||
payload_size: int = Field(ge=1, description="Payload size in bytes")
|
||||
embed_mode: AudioEmbedModeType = Field(
|
||||
default="audio_lsb", description="Embedding mode to check capacity for"
|
||||
)
|
||||
|
||||
|
||||
class AudioCapacityResponse(BaseModel):
|
||||
"""Response for audio capacity check."""
|
||||
|
||||
fits: bool
|
||||
payload_size: int
|
||||
capacity_bytes: int
|
||||
usage_percent: float
|
||||
embed_mode: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HELPER: RESOLVE CHANNEL KEY
|
||||
# ============================================================================
|
||||
@@ -569,12 +722,18 @@ async def root():
|
||||
"source": channel_status.get("source"),
|
||||
}
|
||||
|
||||
# Audio modes (v4.3.0)
|
||||
if HAS_AUDIO_SUPPORT:
|
||||
available_modes.append("audio_lsb")
|
||||
available_modes.append("audio_spread")
|
||||
|
||||
return StatusResponse(
|
||||
version=__version__,
|
||||
has_argon2=has_argon2(),
|
||||
has_qrcode_read=HAS_QR_READ,
|
||||
has_qrcode_write=HAS_QR_WRITE,
|
||||
has_dct=has_dct_support(),
|
||||
has_audio=HAS_AUDIO_SUPPORT,
|
||||
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
|
||||
available_modes=available_modes,
|
||||
dct_features=dct_features,
|
||||
@@ -606,6 +765,28 @@ async def api_modes():
|
||||
"fingerprint": channel_status.get("fingerprint"),
|
||||
}
|
||||
|
||||
# Audio modes (v4.3.0)
|
||||
audio_info = None
|
||||
if HAS_AUDIO_SUPPORT:
|
||||
audio_info = {
|
||||
"available": True,
|
||||
"modes": {
|
||||
"audio_lsb": {
|
||||
"name": "Audio LSB",
|
||||
"description": "Embed in audio sample LSBs, high capacity",
|
||||
"output_format": "WAV",
|
||||
},
|
||||
"audio_spread": {
|
||||
"name": "Spread Spectrum (DSSS)",
|
||||
"description": "Direct-sequence spread spectrum with Reed-Solomon ECC, better stealth",
|
||||
"output_format": "WAV",
|
||||
},
|
||||
},
|
||||
"supported_formats": ["WAV", "FLAC", "MP3", "OGG", "AAC", "M4A"],
|
||||
"output_format": "WAV",
|
||||
"requires": "soundfile",
|
||||
}
|
||||
|
||||
return ModesResponse(
|
||||
lsb={
|
||||
"available": True,
|
||||
@@ -623,6 +804,7 @@ async def api_modes():
|
||||
capacity_ratio="~20% of LSB",
|
||||
requires="scipy",
|
||||
),
|
||||
audio=audio_info,
|
||||
channel=channel_info,
|
||||
)
|
||||
|
||||
@@ -723,7 +905,7 @@ async def api_channel_set(request: ChannelSetRequest, _: str = Depends(require_a
|
||||
@app.delete("/channel")
|
||||
async def api_channel_clear(
|
||||
_: str = Depends(require_api_key),
|
||||
location: str = Query("user", description="'user', 'project', or 'all'")
|
||||
location: str = Query("user", description="'user', 'project', or 'all'"),
|
||||
):
|
||||
"""
|
||||
Clear/remove channel key from config.
|
||||
@@ -935,7 +1117,7 @@ async def api_will_fit(request: WillFitRequest, _: str = Depends(require_api_key
|
||||
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
|
||||
async def api_extract_key_from_qr(
|
||||
_: str = Depends(require_api_key),
|
||||
qr_image: UploadFile = File(..., description="QR code image containing RSA key")
|
||||
qr_image: UploadFile = File(..., description="QR code image containing RSA key"),
|
||||
):
|
||||
"""
|
||||
Extract RSA key from a QR code image.
|
||||
@@ -1607,6 +1789,454 @@ async def api_image_info(
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ROUTES - AUDIO STEGANOGRAPHY (v4.3.0)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _require_audio():
|
||||
"""Check that audio support is available, raise 501 if not."""
|
||||
if not HAS_AUDIO_SUPPORT:
|
||||
raise HTTPException(
|
||||
501, "Audio steganography not available. Install with: pip install stegasoo[audio]"
|
||||
)
|
||||
|
||||
|
||||
@app.post("/audio/encode", response_model=AudioEncodeResponse)
|
||||
async def api_audio_encode(request: AudioEncodeRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Encode a text message into audio.
|
||||
|
||||
Audio must be base64-encoded. Returns base64-encoded stego WAV.
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
resolved_channel_key = _resolve_channel_key(request.channel_key)
|
||||
|
||||
try:
|
||||
ref_photo = base64.b64decode(request.reference_photo_base64)
|
||||
carrier = base64.b64decode(request.carrier_audio_base64)
|
||||
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
||||
|
||||
stego_audio, stats = await run_in_thread(
|
||||
encode_audio,
|
||||
message=request.message,
|
||||
reference_photo=ref_photo,
|
||||
carrier_audio=carrier,
|
||||
passphrase=request.passphrase,
|
||||
pin=request.pin,
|
||||
rsa_key_data=rsa_key,
|
||||
rsa_password=request.rsa_password,
|
||||
embed_mode=request.embed_mode,
|
||||
channel_key=resolved_channel_key,
|
||||
chip_tier=request.chip_tier,
|
||||
)
|
||||
|
||||
stego_b64 = base64.b64encode(stego_audio).decode("utf-8")
|
||||
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
|
||||
|
||||
return AudioEncodeResponse(
|
||||
stego_audio_base64=stego_b64,
|
||||
embed_mode=stats.embed_mode,
|
||||
stats={
|
||||
"samples_modified": stats.samples_modified,
|
||||
"total_samples": stats.total_samples,
|
||||
"capacity_used": round(stats.capacity_used * 100, 1),
|
||||
"bytes_embedded": stats.bytes_embedded,
|
||||
"sample_rate": stats.sample_rate,
|
||||
"channels": stats.channels,
|
||||
"duration_seconds": round(stats.duration_seconds, 2),
|
||||
},
|
||||
channel_mode=channel_mode,
|
||||
channel_fingerprint=channel_fingerprint,
|
||||
)
|
||||
|
||||
except CapacityError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/audio/encode/file", response_model=AudioEncodeResponse)
|
||||
async def api_audio_encode_file(request: AudioEncodeFileRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Encode a file into audio (JSON with base64).
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
resolved_channel_key = _resolve_channel_key(request.channel_key)
|
||||
|
||||
try:
|
||||
file_data = base64.b64decode(request.file_data_base64)
|
||||
ref_photo = base64.b64decode(request.reference_photo_base64)
|
||||
carrier = base64.b64decode(request.carrier_audio_base64)
|
||||
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
||||
|
||||
payload = FilePayload(
|
||||
data=file_data, filename=request.filename, mime_type=request.mime_type
|
||||
)
|
||||
|
||||
stego_audio, stats = await run_in_thread(
|
||||
encode_audio,
|
||||
message=payload,
|
||||
reference_photo=ref_photo,
|
||||
carrier_audio=carrier,
|
||||
passphrase=request.passphrase,
|
||||
pin=request.pin,
|
||||
rsa_key_data=rsa_key,
|
||||
rsa_password=request.rsa_password,
|
||||
embed_mode=request.embed_mode,
|
||||
channel_key=resolved_channel_key,
|
||||
chip_tier=request.chip_tier,
|
||||
)
|
||||
|
||||
stego_b64 = base64.b64encode(stego_audio).decode("utf-8")
|
||||
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
|
||||
|
||||
return AudioEncodeResponse(
|
||||
stego_audio_base64=stego_b64,
|
||||
embed_mode=stats.embed_mode,
|
||||
stats={
|
||||
"samples_modified": stats.samples_modified,
|
||||
"total_samples": stats.total_samples,
|
||||
"capacity_used": round(stats.capacity_used * 100, 1),
|
||||
"bytes_embedded": stats.bytes_embedded,
|
||||
"sample_rate": stats.sample_rate,
|
||||
"channels": stats.channels,
|
||||
"duration_seconds": round(stats.duration_seconds, 2),
|
||||
},
|
||||
channel_mode=channel_mode,
|
||||
channel_fingerprint=channel_fingerprint,
|
||||
)
|
||||
|
||||
except CapacityError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/audio/encode/multipart")
|
||||
async def api_audio_encode_multipart(
|
||||
_: str = Depends(require_api_key),
|
||||
passphrase: str = Form(..., description="Passphrase for key derivation"),
|
||||
reference_photo: UploadFile = File(...),
|
||||
carrier: UploadFile = File(...),
|
||||
message: str = Form(""),
|
||||
payload_file: UploadFile | None = File(None),
|
||||
pin: str = Form(""),
|
||||
rsa_key: UploadFile | None = File(None),
|
||||
rsa_password: str = Form(""),
|
||||
channel_key: str = Form(
|
||||
"auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"
|
||||
),
|
||||
embed_mode: str = Form("audio_lsb"),
|
||||
chip_tier: int | None = Form(
|
||||
None,
|
||||
description="Spread spectrum chip tier: 0=lossless, 1=high_lossy, 2=low_lossy. Only for audio_spread.",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Encode audio using multipart form data (file uploads).
|
||||
|
||||
Provide either 'message' (text) or 'payload_file' (binary file).
|
||||
Returns the stego WAV directly with metadata headers.
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
if embed_mode not in ("audio_lsb", "audio_spread"):
|
||||
raise HTTPException(400, "embed_mode must be 'audio_lsb' or 'audio_spread'")
|
||||
|
||||
# Resolve channel key
|
||||
if channel_key.lower() == "auto":
|
||||
resolved_channel_key = None
|
||||
elif channel_key.lower() == "none":
|
||||
resolved_channel_key = ""
|
||||
else:
|
||||
resolved_channel_key = _resolve_channel_key(channel_key)
|
||||
|
||||
try:
|
||||
ref_data = await reference_photo.read()
|
||||
carrier_data = await carrier.read()
|
||||
|
||||
rsa_key_data = None
|
||||
if rsa_key and rsa_key.filename:
|
||||
rsa_key_data = await rsa_key.read()
|
||||
|
||||
effective_password = rsa_password if rsa_password else None
|
||||
|
||||
# Determine payload
|
||||
if payload_file and payload_file.filename:
|
||||
file_data = await payload_file.read()
|
||||
payload = FilePayload(
|
||||
data=file_data, filename=payload_file.filename, mime_type=payload_file.content_type
|
||||
)
|
||||
elif message:
|
||||
payload = message
|
||||
else:
|
||||
raise HTTPException(400, "Must provide either 'message' or 'payload_file'")
|
||||
|
||||
stego_audio, stats = await run_in_thread(
|
||||
encode_audio,
|
||||
message=payload,
|
||||
reference_photo=ref_data,
|
||||
carrier_audio=carrier_data,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=effective_password,
|
||||
embed_mode=embed_mode,
|
||||
channel_key=resolved_channel_key,
|
||||
chip_tier=chip_tier,
|
||||
)
|
||||
|
||||
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
|
||||
|
||||
headers = {
|
||||
"Content-Disposition": "attachment; filename=stego_audio.wav",
|
||||
"X-Stegasoo-Embed-Mode": stats.embed_mode,
|
||||
"X-Stegasoo-Capacity-Percent": f"{stats.capacity_used * 100:.1f}",
|
||||
"X-Stegasoo-Samples-Modified": str(stats.samples_modified),
|
||||
"X-Stegasoo-Duration": f"{stats.duration_seconds:.2f}",
|
||||
"X-Stegasoo-Channel-Mode": channel_mode,
|
||||
"X-Stegasoo-Version": __version__,
|
||||
}
|
||||
if channel_fingerprint:
|
||||
headers["X-Stegasoo-Channel-Fingerprint"] = channel_fingerprint
|
||||
|
||||
return Response(
|
||||
content=stego_audio,
|
||||
media_type="audio/wav",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
except CapacityError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/audio/decode", response_model=DecodeResponse)
|
||||
async def api_audio_decode(request: AudioDecodeRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Decode a message or file from stego audio.
|
||||
|
||||
Returns payload_type to indicate if result is text or file.
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
resolved_channel_key = _resolve_channel_key(request.channel_key)
|
||||
|
||||
try:
|
||||
stego = base64.b64decode(request.stego_audio_base64)
|
||||
ref_photo = base64.b64decode(request.reference_photo_base64)
|
||||
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
||||
|
||||
result = await run_in_thread(
|
||||
decode_audio,
|
||||
stego_audio=stego,
|
||||
reference_photo=ref_photo,
|
||||
passphrase=request.passphrase,
|
||||
pin=request.pin,
|
||||
rsa_key_data=rsa_key,
|
||||
rsa_password=request.rsa_password,
|
||||
embed_mode=request.embed_mode,
|
||||
channel_key=resolved_channel_key,
|
||||
)
|
||||
|
||||
if result.is_file:
|
||||
return DecodeResponse(
|
||||
payload_type="file",
|
||||
file_data_base64=base64.b64encode(result.file_data).decode("utf-8"),
|
||||
filename=result.filename,
|
||||
mime_type=result.mime_type,
|
||||
)
|
||||
else:
|
||||
return DecodeResponse(payload_type="text", message=result.message)
|
||||
|
||||
except DecryptionError as e:
|
||||
error_msg = str(e)
|
||||
if "channel key" in error_msg.lower():
|
||||
raise HTTPException(401, error_msg)
|
||||
raise HTTPException(401, "Decryption failed. Check credentials.")
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/audio/decode/multipart", response_model=DecodeResponse)
|
||||
async def api_audio_decode_multipart(
|
||||
_: str = Depends(require_api_key),
|
||||
passphrase: str = Form(..., description="Passphrase for key derivation"),
|
||||
reference_photo: UploadFile = File(...),
|
||||
stego_audio: UploadFile = File(...),
|
||||
pin: str = Form(""),
|
||||
rsa_key: UploadFile | None = File(None),
|
||||
rsa_password: str = Form(""),
|
||||
channel_key: str = Form(
|
||||
"auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"
|
||||
),
|
||||
embed_mode: str = Form("audio_auto"),
|
||||
):
|
||||
"""
|
||||
Decode audio using multipart form data (file uploads).
|
||||
|
||||
Returns JSON with payload_type indicating text or file.
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
if embed_mode not in ("audio_auto", "audio_lsb", "audio_spread"):
|
||||
raise HTTPException(400, "embed_mode must be 'audio_auto', 'audio_lsb', or 'audio_spread'")
|
||||
|
||||
# Resolve channel key
|
||||
if channel_key.lower() == "auto":
|
||||
resolved_channel_key = None
|
||||
elif channel_key.lower() == "none":
|
||||
resolved_channel_key = ""
|
||||
else:
|
||||
resolved_channel_key = _resolve_channel_key(channel_key)
|
||||
|
||||
try:
|
||||
ref_data = await reference_photo.read()
|
||||
stego_data = await stego_audio.read()
|
||||
|
||||
rsa_key_data = None
|
||||
if rsa_key and rsa_key.filename:
|
||||
rsa_key_data = await rsa_key.read()
|
||||
|
||||
effective_password = rsa_password if rsa_password else None
|
||||
|
||||
result = await run_in_thread(
|
||||
decode_audio,
|
||||
stego_audio=stego_data,
|
||||
reference_photo=ref_data,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=effective_password,
|
||||
embed_mode=embed_mode,
|
||||
channel_key=resolved_channel_key,
|
||||
)
|
||||
|
||||
if result.is_file:
|
||||
return DecodeResponse(
|
||||
payload_type="file",
|
||||
file_data_base64=base64.b64encode(result.file_data).decode("utf-8"),
|
||||
filename=result.filename,
|
||||
mime_type=result.mime_type,
|
||||
)
|
||||
else:
|
||||
return DecodeResponse(payload_type="text", message=result.message)
|
||||
|
||||
except DecryptionError as e:
|
||||
error_msg = str(e)
|
||||
if "channel key" in error_msg.lower():
|
||||
raise HTTPException(401, error_msg)
|
||||
raise HTTPException(401, "Decryption failed. Check credentials.")
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/audio/info", response_model=AudioInfoResponse)
|
||||
async def api_audio_info(
|
||||
_: str = Depends(require_api_key),
|
||||
audio: UploadFile = File(...),
|
||||
):
|
||||
"""
|
||||
Get audio file metadata and embedding capacity.
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
try:
|
||||
audio_data = await audio.read()
|
||||
|
||||
info = await run_in_thread(get_audio_info, audio_data)
|
||||
|
||||
# Calculate capacities for both modes
|
||||
lsb_capacity = await run_in_thread(calculate_audio_lsb_capacity, audio_data)
|
||||
try:
|
||||
spread_info = await run_in_thread(calculate_audio_spread_capacity, audio_data)
|
||||
spread_capacity = spread_info.usable_capacity_bytes
|
||||
except Exception:
|
||||
spread_capacity = 0
|
||||
|
||||
return AudioInfoResponse(
|
||||
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,
|
||||
bitrate=info.bitrate,
|
||||
capacity_lsb=lsb_capacity,
|
||||
capacity_spread=spread_capacity,
|
||||
)
|
||||
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/audio/capacity", response_model=AudioCapacityResponse)
|
||||
async def api_audio_capacity(request: AudioCapacityRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Check if a payload of a given size will fit in an audio carrier.
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
try:
|
||||
carrier = base64.b64decode(request.carrier_audio_base64)
|
||||
|
||||
if request.embed_mode == "audio_lsb":
|
||||
capacity = await run_in_thread(calculate_audio_lsb_capacity, carrier)
|
||||
else:
|
||||
spread_info = await run_in_thread(calculate_audio_spread_capacity, carrier)
|
||||
capacity = spread_info.usable_capacity_bytes
|
||||
|
||||
fits = request.payload_size <= capacity
|
||||
usage = (request.payload_size / capacity * 100) if capacity > 0 else 100.0
|
||||
|
||||
return AudioCapacityResponse(
|
||||
fits=fits,
|
||||
payload_size=request.payload_size,
|
||||
capacity_bytes=capacity,
|
||||
usage_percent=round(usage, 1),
|
||||
embed_mode=request.embed_mode,
|
||||
)
|
||||
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ERROR HANDLERS
|
||||
# ============================================================================
|
||||
|
||||
@@ -361,7 +361,7 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json, q
|
||||
|
||||
qr_bytes = generate_qr_code(creds.rsa_key_pem, compress=True, output_format=fmt)
|
||||
qr_path.write_bytes(qr_bytes)
|
||||
click.secho(f"─── RSA KEY QR CODE ───", fg="green")
|
||||
click.secho("─── 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()
|
||||
|
||||
@@ -146,6 +146,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import stegasoo
|
||||
from stegasoo import (
|
||||
HAS_AUDIO_SUPPORT,
|
||||
CapacityError,
|
||||
DecryptionError,
|
||||
FilePayload,
|
||||
@@ -463,6 +464,9 @@ def inject_globals():
|
||||
"is_admin": is_admin(),
|
||||
# NEW in v4.2.0 - Saved channel keys
|
||||
"saved_channel_keys": saved_channel_keys,
|
||||
# NEW in v4.3.0 - Audio support
|
||||
"has_audio": HAS_AUDIO_SUPPORT,
|
||||
"supported_audio_formats": "WAV, FLAC, MP3, OGG, AAC, M4A" if HAS_AUDIO_SUPPORT else "",
|
||||
}
|
||||
|
||||
|
||||
@@ -564,6 +568,14 @@ def allowed_image(filename: str) -> bool:
|
||||
return ext in {"png", "jpg", "jpeg", "bmp", "gif"}
|
||||
|
||||
|
||||
def allowed_audio(filename: str) -> bool:
|
||||
"""Check if file has allowed audio extension."""
|
||||
if not filename or "." not in filename:
|
||||
return False
|
||||
ext = filename.rsplit(".", 1)[1].lower()
|
||||
return ext in {"wav", "flac", "mp3", "ogg", "aac", "m4a", "aiff", "aif"}
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Format file size for display."""
|
||||
if size_bytes < 1024:
|
||||
@@ -710,11 +722,15 @@ def generate():
|
||||
if not qr_too_large:
|
||||
qr_token = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
temp_storage.save_temp_file(qr_token, creds.rsa_key_pem.encode(), {
|
||||
"filename": "rsa_key.pem",
|
||||
"type": "rsa_key",
|
||||
"compress": qr_needs_compression,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
qr_token,
|
||||
creds.rsa_key_pem.encode(),
|
||||
{
|
||||
"filename": "rsa_key.pem",
|
||||
"type": "rsa_key",
|
||||
"compress": qr_needs_compression,
|
||||
},
|
||||
)
|
||||
|
||||
# v3.2.0: Single passphrase instead of daily phrases
|
||||
return render_template(
|
||||
@@ -1001,6 +1017,37 @@ def api_check_fit():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/audio-capacity", methods=["POST"])
|
||||
@login_required
|
||||
def api_audio_capacity():
|
||||
"""Get audio file capacity for steganography (v4.3.0)."""
|
||||
audio_file = request.files.get("carrier")
|
||||
if not audio_file:
|
||||
return jsonify({"error": "No audio file provided"}), 400
|
||||
|
||||
try:
|
||||
audio_data = audio_file.read()
|
||||
result = subprocess_stego.audio_info(audio_data)
|
||||
|
||||
if not result.success:
|
||||
return jsonify({"error": result.error or "Audio analysis failed"}), 500
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"sample_rate": result.sample_rate,
|
||||
"channels": result.channels,
|
||||
"duration": round(result.duration_seconds, 2),
|
||||
"format": result.format,
|
||||
"bit_depth": result.bit_depth,
|
||||
"lsb_capacity": result.capacity_lsb,
|
||||
"spread_capacity": result.capacity_spread,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENCODE
|
||||
# ============================================================================
|
||||
@@ -1072,21 +1119,111 @@ def _run_encode_job(job_id: str, encode_params: dict) -> None:
|
||||
|
||||
filename = encode_result.filename
|
||||
if not filename:
|
||||
filename = generate_filename("stego", output_ext)
|
||||
filename = generate_filename(prefix="stego", extension=output_ext.lstrip("."))
|
||||
elif embed_mode == "dct" and dct_output_format == "jpeg" and filename.endswith(".png"):
|
||||
filename = filename[:-4] + ".jpg"
|
||||
|
||||
# Store result
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
temp_storage.save_temp_file(file_id, encode_result.stego_data, {
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"color_mode": dct_color_mode if embed_mode == "dct" else None,
|
||||
"mime_type": output_mime,
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
encode_result.stego_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"color_mode": dct_color_mode if embed_mode == "dct" else None,
|
||||
"mime_type": output_mime,
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
},
|
||||
)
|
||||
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "complete",
|
||||
"file_id": file_id,
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
cleanup_progress_file(job_id)
|
||||
|
||||
|
||||
def _run_encode_audio_job(job_id: str, encode_params: dict) -> None:
|
||||
"""Background thread function for async audio encode (v4.3.0)."""
|
||||
progress_file = get_progress_file_path(job_id)
|
||||
|
||||
try:
|
||||
_store_job(job_id, {"status": "running", "created": time.time()})
|
||||
|
||||
if encode_params.get("file_data"):
|
||||
encode_result = subprocess_stego.encode_audio(
|
||||
carrier_data=encode_params["carrier_data"],
|
||||
reference_data=encode_params["ref_data"],
|
||||
file_data=encode_params["file_data"],
|
||||
file_name=encode_params["file_name"],
|
||||
file_mime=encode_params["file_mime"],
|
||||
passphrase=encode_params["passphrase"],
|
||||
pin=encode_params.get("pin"),
|
||||
rsa_key_data=encode_params.get("rsa_key_data"),
|
||||
rsa_password=encode_params.get("key_password"),
|
||||
embed_mode=encode_params["embed_mode"],
|
||||
channel_key=encode_params.get("channel_key"),
|
||||
progress_file=progress_file,
|
||||
chip_tier=encode_params.get("chip_tier"),
|
||||
)
|
||||
else:
|
||||
encode_result = subprocess_stego.encode_audio(
|
||||
carrier_data=encode_params["carrier_data"],
|
||||
reference_data=encode_params["ref_data"],
|
||||
message=encode_params.get("message"),
|
||||
passphrase=encode_params["passphrase"],
|
||||
pin=encode_params.get("pin"),
|
||||
rsa_key_data=encode_params.get("rsa_key_data"),
|
||||
rsa_password=encode_params.get("key_password"),
|
||||
embed_mode=encode_params["embed_mode"],
|
||||
channel_key=encode_params.get("channel_key"),
|
||||
progress_file=progress_file,
|
||||
chip_tier=encode_params.get("chip_tier"),
|
||||
)
|
||||
|
||||
if not encode_result.success:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "error",
|
||||
"error": encode_result.error or "Audio encoding failed",
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
filename = generate_filename(prefix="stego_audio", extension="wav")
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
encode_result.stego_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"embed_mode": encode_params["embed_mode"],
|
||||
"carrier_type": "audio",
|
||||
"mime_type": "audio/wav",
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
},
|
||||
)
|
||||
|
||||
_store_job(
|
||||
job_id,
|
||||
@@ -1131,6 +1268,199 @@ def encode_page():
|
||||
rsa_key_file = request.files.get("rsa_key")
|
||||
payload_file = request.files.get("payload_file")
|
||||
|
||||
# Determine carrier type (v4.3.0)
|
||||
carrier_type = request.form.get("carrier_type", "image")
|
||||
|
||||
if carrier_type == "audio":
|
||||
# ========== AUDIO ENCODE PATH (v4.3.0) ==========
|
||||
# Audio carrier uses a separate form field to avoid name collision
|
||||
carrier = request.files.get("audio_carrier") or carrier
|
||||
|
||||
if not HAS_AUDIO_SUPPORT:
|
||||
return _error_response(
|
||||
"Audio steganography is not available. Install audio dependencies."
|
||||
)
|
||||
|
||||
if not ref_photo or not carrier:
|
||||
return _error_response("Both reference photo and audio carrier are required")
|
||||
|
||||
if not allowed_image(ref_photo.filename):
|
||||
return _error_response("Reference must be an image (PNG, JPG, BMP)")
|
||||
|
||||
if not allowed_audio(carrier.filename):
|
||||
return _error_response(
|
||||
"Invalid audio format. Use WAV, FLAC, MP3, OGG, AAC, or M4A"
|
||||
)
|
||||
|
||||
# Get form data
|
||||
message = request.form.get("message", "")
|
||||
passphrase = request.form.get("passphrase", "")
|
||||
pin = request.form.get("pin", "").strip()
|
||||
rsa_password = request.form.get("rsa_password", "")
|
||||
payload_type = request.form.get("payload_type", "text")
|
||||
|
||||
embed_mode = request.form.get("embed_mode", "audio_lsb")
|
||||
if embed_mode not in ("audio_lsb", "audio_spread"):
|
||||
embed_mode = "audio_lsb"
|
||||
|
||||
# Chip tier for spread spectrum (None = default)
|
||||
chip_tier_str = request.form.get("chip_tier")
|
||||
chip_tier = None
|
||||
if chip_tier_str and chip_tier_str.isdigit():
|
||||
chip_tier = int(chip_tier_str)
|
||||
if chip_tier not in (0, 1, 2):
|
||||
chip_tier = None
|
||||
|
||||
channel_key = resolve_channel_key_form(request.form.get("channel_key", "auto"))
|
||||
|
||||
# Determine payload
|
||||
if payload_type == "file" and payload_file and payload_file.filename:
|
||||
file_data = payload_file.read()
|
||||
result = validate_file_payload(file_data, payload_file.filename)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
mime_type, _ = mimetypes.guess_type(payload_file.filename)
|
||||
payload = FilePayload(
|
||||
data=file_data,
|
||||
filename=payload_file.filename,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
else:
|
||||
result = validate_message(message)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
payload = message
|
||||
|
||||
if not passphrase:
|
||||
return _error_response("Passphrase is required")
|
||||
|
||||
result = validate_passphrase(passphrase)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
if result.warning:
|
||||
flash(result.warning, "warning")
|
||||
|
||||
ref_data = ref_photo.read()
|
||||
carrier_data = carrier.read()
|
||||
|
||||
# Handle RSA key (same as image path)
|
||||
rsa_key_data = None
|
||||
rsa_key_pem = request.form.get("rsa_key_pem", "").strip()
|
||||
rsa_key_qr = request.files.get("rsa_key_qr")
|
||||
rsa_key_from_qr = False
|
||||
|
||||
if rsa_key_pem:
|
||||
if is_compressed(rsa_key_pem):
|
||||
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||
rsa_key_from_qr = True
|
||||
elif rsa_key_file and rsa_key_file.filename:
|
||||
rsa_key_data = rsa_key_file.read()
|
||||
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
|
||||
qr_image_data = rsa_key_qr.read()
|
||||
key_pem = extract_key_from_qr(qr_image_data)
|
||||
if key_pem:
|
||||
rsa_key_data = key_pem.encode("utf-8")
|
||||
rsa_key_from_qr = True
|
||||
else:
|
||||
return _error_response("Could not extract RSA key from QR code image.")
|
||||
|
||||
result = validate_security_factors(pin, rsa_key_data)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
|
||||
if pin:
|
||||
result = validate_pin(pin)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
|
||||
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
||||
|
||||
if rsa_key_data:
|
||||
result = validate_rsa_key(rsa_key_data, key_password)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
|
||||
# Build audio encode params
|
||||
encode_params = {
|
||||
"carrier_data": carrier_data,
|
||||
"ref_data": ref_data,
|
||||
"passphrase": passphrase,
|
||||
"pin": pin if pin else None,
|
||||
"rsa_key_data": rsa_key_data,
|
||||
"key_password": key_password,
|
||||
"embed_mode": embed_mode,
|
||||
"channel_key": channel_key,
|
||||
"carrier_type": "audio",
|
||||
"chip_tier": chip_tier,
|
||||
}
|
||||
|
||||
if payload_type == "file" and payload_file and payload_file.filename:
|
||||
encode_params["file_data"] = payload.data
|
||||
encode_params["file_name"] = payload.filename
|
||||
encode_params["file_mime"] = payload.mime_type
|
||||
else:
|
||||
encode_params["message"] = payload
|
||||
|
||||
if is_async:
|
||||
job_id = generate_job_id()
|
||||
_store_job(job_id, {"status": "pending", "created": time.time()})
|
||||
_executor.submit(_run_encode_audio_job, job_id, encode_params)
|
||||
return jsonify({"job_id": job_id, "status": "pending"})
|
||||
|
||||
# Sync audio encode
|
||||
if encode_params.get("file_data"):
|
||||
encode_result = subprocess_stego.encode_audio(
|
||||
carrier_data=carrier_data,
|
||||
reference_data=ref_data,
|
||||
file_data=encode_params["file_data"],
|
||||
file_name=encode_params["file_name"],
|
||||
file_mime=encode_params["file_mime"],
|
||||
passphrase=passphrase,
|
||||
pin=pin if pin else None,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=key_password,
|
||||
embed_mode=embed_mode,
|
||||
channel_key=channel_key,
|
||||
chip_tier=chip_tier,
|
||||
)
|
||||
else:
|
||||
encode_result = subprocess_stego.encode_audio(
|
||||
carrier_data=carrier_data,
|
||||
reference_data=ref_data,
|
||||
message=payload,
|
||||
passphrase=passphrase,
|
||||
pin=pin if pin else None,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=key_password,
|
||||
embed_mode=embed_mode,
|
||||
channel_key=channel_key,
|
||||
chip_tier=chip_tier,
|
||||
)
|
||||
|
||||
if not encode_result.success:
|
||||
error_msg = encode_result.error or "Audio encoding failed"
|
||||
return _error_response(error_msg)
|
||||
|
||||
filename = generate_filename(prefix="stego_audio", extension="wav")
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
encode_result.stego_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"carrier_type": "audio",
|
||||
"mime_type": "audio/wav",
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
},
|
||||
)
|
||||
|
||||
return redirect(url_for("encode_result", file_id=file_id))
|
||||
|
||||
# ========== IMAGE ENCODE PATH (original) ==========
|
||||
if not ref_photo or not carrier:
|
||||
return _error_response("Both reference photo and carrier image are required")
|
||||
|
||||
@@ -1349,23 +1679,27 @@ def encode_page():
|
||||
# Use filename from result or generate one
|
||||
filename = encode_result.filename
|
||||
if not filename:
|
||||
filename = generate_filename("stego", output_ext)
|
||||
filename = generate_filename(prefix="stego", extension=output_ext.lstrip("."))
|
||||
elif embed_mode == "dct" and dct_output_format == "jpeg" and filename.endswith(".png"):
|
||||
filename = filename[:-4] + ".jpg"
|
||||
|
||||
# Store temporarily
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
temp_storage.save_temp_file(file_id, encode_result.stego_data, {
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"color_mode": dct_color_mode if embed_mode == "dct" else None,
|
||||
"mime_type": output_mime,
|
||||
# Channel info (v4.0.0)
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
encode_result.stego_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"color_mode": dct_color_mode if embed_mode == "dct" else None,
|
||||
"mime_type": output_mime,
|
||||
# Channel info (v4.0.0)
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
},
|
||||
)
|
||||
|
||||
return redirect(url_for("encode_result", file_id=file_id))
|
||||
|
||||
@@ -1434,13 +1768,16 @@ def encode_result(file_id):
|
||||
flash("File expired or not found. Please encode again.", "error")
|
||||
return redirect(url_for("encode_page"))
|
||||
|
||||
# Generate thumbnail
|
||||
thumbnail_data = generate_thumbnail(file_info["data"])
|
||||
thumbnail_id = None
|
||||
carrier_type = file_info.get("carrier_type", "image")
|
||||
|
||||
if thumbnail_data:
|
||||
thumbnail_id = f"{file_id}_thumb"
|
||||
temp_storage.save_thumbnail(thumbnail_id, thumbnail_data)
|
||||
# Generate thumbnail only for images
|
||||
thumbnail_data = None
|
||||
thumbnail_id = None
|
||||
if carrier_type != "audio":
|
||||
thumbnail_data = generate_thumbnail(file_info["data"])
|
||||
if thumbnail_data:
|
||||
thumbnail_id = f"{file_id}_thumb"
|
||||
temp_storage.save_thumbnail(thumbnail_id, thumbnail_data)
|
||||
|
||||
return render_template(
|
||||
"encode_result.html",
|
||||
@@ -1450,6 +1787,7 @@ def encode_result(file_id):
|
||||
embed_mode=file_info.get("embed_mode", "lsb"),
|
||||
output_format=file_info.get("output_format", "png"),
|
||||
color_mode=file_info.get("color_mode"),
|
||||
carrier_type=carrier_type,
|
||||
# Channel info (v4.0.0)
|
||||
channel_mode=file_info.get("channel_mode", "public"),
|
||||
channel_fingerprint=file_info.get("channel_fingerprint"),
|
||||
@@ -1464,9 +1802,7 @@ def encode_thumbnail(thumb_id):
|
||||
if not thumb_data:
|
||||
return "Thumbnail not found", 404
|
||||
|
||||
return send_file(
|
||||
io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False
|
||||
)
|
||||
return send_file(io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False)
|
||||
|
||||
|
||||
@app.route("/encode/download/<file_id>")
|
||||
@@ -1559,10 +1895,92 @@ def _run_decode_job(job_id: str, decode_params: dict) -> None:
|
||||
if decode_result.is_file:
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
filename = decode_result.filename or "decoded_file"
|
||||
temp_storage.save_temp_file(file_id, decode_result.file_data, {
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
decode_result.file_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
},
|
||||
)
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "complete",
|
||||
"file_id": file_id,
|
||||
"is_file": True,
|
||||
"filename": filename,
|
||||
"file_size": len(decode_result.file_data),
|
||||
"mime_type": decode_result.mime_type,
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "complete",
|
||||
"is_file": False,
|
||||
"message": decode_result.message,
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
cleanup_progress_file(job_id)
|
||||
|
||||
|
||||
def _run_decode_audio_job(job_id: str, decode_params: dict) -> None:
|
||||
"""Background thread function for async audio decode (v4.3.0)."""
|
||||
progress_file = get_progress_file_path(job_id)
|
||||
|
||||
try:
|
||||
_store_job(job_id, {"status": "running", "created": time.time()})
|
||||
|
||||
decode_result = subprocess_stego.decode_audio(
|
||||
stego_data=decode_params["stego_data"],
|
||||
reference_data=decode_params["ref_data"],
|
||||
passphrase=decode_params["passphrase"],
|
||||
pin=decode_params.get("pin"),
|
||||
rsa_key_data=decode_params.get("rsa_key_data"),
|
||||
rsa_password=decode_params.get("rsa_password"),
|
||||
embed_mode=decode_params.get("embed_mode", "audio_auto"),
|
||||
channel_key=decode_params.get("channel_key"),
|
||||
progress_file=progress_file,
|
||||
)
|
||||
|
||||
if not decode_result.success:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "error",
|
||||
"error": decode_result.error or "Audio decoding failed",
|
||||
"error_type": decode_result.error_type,
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if decode_result.is_file:
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
filename = decode_result.filename or "decoded_file"
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
decode_result.file_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
},
|
||||
)
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
@@ -1609,6 +2027,166 @@ def decode_page():
|
||||
stego_image = request.files.get("stego_image")
|
||||
rsa_key_file = request.files.get("rsa_key")
|
||||
|
||||
# Determine carrier type (v4.3.0)
|
||||
carrier_type = request.form.get("carrier_type", "image")
|
||||
|
||||
if carrier_type == "audio":
|
||||
# ========== AUDIO DECODE PATH (v4.3.0) ==========
|
||||
# Audio stego uses a separate form field to avoid name collision
|
||||
stego_image = request.files.get("stego_audio") or stego_image
|
||||
|
||||
if not HAS_AUDIO_SUPPORT:
|
||||
flash("Audio steganography is not available.", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if not ref_photo or not stego_image:
|
||||
flash("Both reference photo and stego audio are required", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if not allowed_image(ref_photo.filename):
|
||||
flash("Reference must be an image", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if not allowed_audio(stego_image.filename):
|
||||
flash("Invalid audio format", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
passphrase = request.form.get("passphrase", "")
|
||||
pin = request.form.get("pin", "").strip()
|
||||
rsa_password = request.form.get("rsa_password", "")
|
||||
|
||||
embed_mode = request.form.get("embed_mode", "audio_auto")
|
||||
if embed_mode not in ("audio_auto", "audio_lsb", "audio_spread"):
|
||||
embed_mode = "audio_auto"
|
||||
|
||||
channel_key = resolve_channel_key_form(request.form.get("channel_key", "auto"))
|
||||
|
||||
if not passphrase:
|
||||
flash("Passphrase is required", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
ref_data = ref_photo.read()
|
||||
stego_data = stego_image.read()
|
||||
|
||||
# Handle RSA key (same as image path)
|
||||
rsa_key_data = None
|
||||
rsa_key_pem = request.form.get("rsa_key_pem", "").strip()
|
||||
rsa_key_qr = request.files.get("rsa_key_qr")
|
||||
rsa_key_from_qr = False
|
||||
|
||||
if rsa_key_pem:
|
||||
if is_compressed(rsa_key_pem):
|
||||
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||
rsa_key_from_qr = True
|
||||
elif rsa_key_file and rsa_key_file.filename:
|
||||
rsa_key_data = rsa_key_file.read()
|
||||
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
|
||||
qr_image_data = rsa_key_qr.read()
|
||||
key_pem = extract_key_from_qr(qr_image_data)
|
||||
if key_pem:
|
||||
rsa_key_data = key_pem.encode("utf-8")
|
||||
rsa_key_from_qr = True
|
||||
else:
|
||||
flash("Could not extract RSA key from QR code image.", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
result = validate_security_factors(pin, rsa_key_data)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if pin:
|
||||
result = validate_pin(pin)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
||||
|
||||
if rsa_key_data:
|
||||
result = validate_rsa_key(rsa_key_data, key_password)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
is_async = (
|
||||
request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||
)
|
||||
|
||||
decode_params = {
|
||||
"stego_data": stego_data,
|
||||
"ref_data": ref_data,
|
||||
"passphrase": passphrase,
|
||||
"pin": pin if pin else None,
|
||||
"rsa_key_data": rsa_key_data,
|
||||
"rsa_password": key_password,
|
||||
"embed_mode": embed_mode,
|
||||
"channel_key": channel_key,
|
||||
}
|
||||
|
||||
if is_async:
|
||||
job_id = generate_job_id()
|
||||
_store_job(job_id, {"status": "pending", "created": time.time()})
|
||||
_executor.submit(_run_decode_audio_job, job_id, decode_params)
|
||||
return jsonify({"job_id": job_id, "status": "pending"})
|
||||
|
||||
# Sync audio decode
|
||||
decode_result = subprocess_stego.decode_audio(
|
||||
stego_data=stego_data,
|
||||
reference_data=ref_data,
|
||||
passphrase=passphrase,
|
||||
pin=pin if pin else None,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=key_password,
|
||||
embed_mode=embed_mode,
|
||||
channel_key=channel_key,
|
||||
)
|
||||
|
||||
if not decode_result.success:
|
||||
error_msg = decode_result.error or "Audio decoding failed"
|
||||
if (
|
||||
"decrypt" in error_msg.lower()
|
||||
or decode_result.error_type == "DecryptionError"
|
||||
):
|
||||
flash(
|
||||
"Wrong credentials. Double-check your reference photo, "
|
||||
"passphrase, PIN, and channel key.",
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
flash(error_msg, "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if decode_result.is_file:
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
filename = decode_result.filename or "decoded_file"
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
decode_result.file_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
},
|
||||
)
|
||||
return render_template(
|
||||
"decode.html",
|
||||
decoded_file=True,
|
||||
file_id=file_id,
|
||||
filename=filename,
|
||||
file_size=format_size(len(decode_result.file_data)),
|
||||
mime_type=decode_result.mime_type,
|
||||
has_qrcode_read=HAS_QRCODE_READ,
|
||||
)
|
||||
else:
|
||||
return render_template(
|
||||
"decode.html",
|
||||
decoded_message=decode_result.message,
|
||||
has_qrcode_read=HAS_QRCODE_READ,
|
||||
)
|
||||
|
||||
# ========== IMAGE DECODE PATH (original) ==========
|
||||
if not ref_photo or not stego_image:
|
||||
flash("Both reference photo and stego image are required", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
@@ -1690,7 +2268,9 @@ def decode_page():
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
# Check for async mode (v4.1.5)
|
||||
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||
is_async = (
|
||||
request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||
)
|
||||
|
||||
# Build decode params
|
||||
decode_params = {
|
||||
@@ -1742,10 +2322,14 @@ def decode_page():
|
||||
cleanup_temp_files()
|
||||
|
||||
filename = decode_result.filename or "decoded_file"
|
||||
temp_storage.save_temp_file(file_id, decode_result.file_data, {
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
decode_result.file_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
},
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"decode.html",
|
||||
@@ -2101,11 +2685,12 @@ def api_tools_exif_clear():
|
||||
@login_required
|
||||
def api_tools_rotate():
|
||||
"""Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
|
||||
from PIL import Image
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from PIL import Image
|
||||
|
||||
image_file = request.files.get("image")
|
||||
if not image_file:
|
||||
return jsonify({"success": False, "error": "No image provided"}), 400
|
||||
@@ -2136,9 +2721,18 @@ def api_tools_rotate():
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-rotate", str(rotation), "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True, timeout=30
|
||||
[
|
||||
"jpegtran",
|
||||
"-rotate",
|
||||
str(rotation),
|
||||
"-copy",
|
||||
"all",
|
||||
"-outfile",
|
||||
output_path,
|
||||
input_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
with open(output_path, "rb") as f:
|
||||
@@ -2158,9 +2752,18 @@ def api_tools_rotate():
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-flip", "horizontal", "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True, timeout=30
|
||||
[
|
||||
"jpegtran",
|
||||
"-flip",
|
||||
"horizontal",
|
||||
"-copy",
|
||||
"all",
|
||||
"-outfile",
|
||||
output_path,
|
||||
input_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
with open(output_path, "rb") as f:
|
||||
@@ -2180,9 +2783,18 @@ def api_tools_rotate():
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-flip", "vertical", "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True, timeout=30
|
||||
[
|
||||
"jpegtran",
|
||||
"-flip",
|
||||
"vertical",
|
||||
"-copy",
|
||||
"all",
|
||||
"-outfile",
|
||||
output_path,
|
||||
input_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
with open(output_path, "rb") as f:
|
||||
@@ -2839,10 +3451,7 @@ def admin_settings_unlock():
|
||||
channel_status = get_channel_status()
|
||||
channel_key = channel_status.get("key") if channel_status["configured"] else ""
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"channel_key": channel_key
|
||||
})
|
||||
return jsonify({"success": True, "channel_key": channel_key})
|
||||
|
||||
|
||||
@app.route("/admin/users")
|
||||
@@ -2976,6 +3585,7 @@ if __name__ == "__main__":
|
||||
ssl_context = None
|
||||
if app.config.get("HTTPS_ENABLED", False):
|
||||
import socket
|
||||
|
||||
hostname = os.environ.get("STEGASOO_HOSTNAME") or socket.gethostname()
|
||||
try:
|
||||
cert_path, key_path = ensure_certs(base_dir, hostname)
|
||||
|
||||
@@ -77,14 +77,10 @@ def init_db():
|
||||
db = get_db()
|
||||
|
||||
# Check if we need to migrate from old single-user schema
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'"
|
||||
)
|
||||
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'")
|
||||
has_old_table = cursor.fetchone() is not None
|
||||
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||
)
|
||||
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
||||
has_new_table = cursor.fetchone() is not None
|
||||
|
||||
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):
|
||||
"""Ensure app_settings table exists (v4.1.0 migration)."""
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
|
||||
)
|
||||
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'")
|
||||
if cursor.fetchone() is None:
|
||||
db.executescript("""
|
||||
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:
|
||||
"""Get an app-level setting value."""
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT value FROM app_settings WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
row = db.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone()
|
||||
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]:
|
||||
"""Get all users, admins first, then by creation date."""
|
||||
db = get_db()
|
||||
rows = db.execute(
|
||||
"""
|
||||
rows = db.execute("""
|
||||
SELECT id, username, role, created_at FROM users
|
||||
ORDER BY role = 'admin' DESC, created_at ASC
|
||||
"""
|
||||
).fetchall()
|
||||
""").fetchall()
|
||||
return [
|
||||
User(
|
||||
id=row["id"],
|
||||
@@ -596,9 +586,7 @@ def create_admin_user(username: str, password: str) -> tuple[bool, str]:
|
||||
return success, msg
|
||||
|
||||
|
||||
def change_password(
|
||||
user_id: int, current_password: str, new_password: str
|
||||
) -> tuple[bool, str]:
|
||||
def change_password(user_id: int, current_password: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change a user's password (requires current password)."""
|
||||
user = get_user_by_id(user_id)
|
||||
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
|
||||
if user.role == ROLE_ADMIN:
|
||||
db = get_db()
|
||||
admin_count = db.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
|
||||
).fetchone()[0]
|
||||
admin_count = db.execute("SELECT COUNT(*) FROM users WHERE role = 'admin'").fetchone()[0]
|
||||
if admin_count <= 1:
|
||||
return False, "Cannot delete the last admin"
|
||||
|
||||
@@ -848,9 +834,7 @@ def save_channel_key(
|
||||
return False, "This channel key is already saved", None
|
||||
|
||||
|
||||
def update_channel_key_name(
|
||||
key_id: int, user_id: int, new_name: str
|
||||
) -> tuple[bool, str]:
|
||||
def update_channel_key_name(key_id: int, user_id: int, new_name: str) -> tuple[bool, str]:
|
||||
"""Update the name of a saved channel key."""
|
||||
new_name = new_name.strip()
|
||||
if not new_name:
|
||||
|
||||
@@ -81,10 +81,12 @@ def generate_self_signed_cert(
|
||||
)
|
||||
|
||||
# Create certificate
|
||||
subject = issuer = x509.Name([
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
||||
])
|
||||
subject = issuer = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
||||
]
|
||||
)
|
||||
|
||||
# Subject Alternative Names
|
||||
san_list = [
|
||||
@@ -112,7 +114,7 @@ def generate_self_signed_cert(
|
||||
except (ipaddress.AddressValueError, ValueError):
|
||||
pass
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
|
||||
@@ -95,7 +95,16 @@ const Stegasoo = {
|
||||
if (!isPayloadZone && !isQrZone) {
|
||||
input.addEventListener('change', function() {
|
||||
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);
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
// ========================================================================
|
||||
@@ -951,13 +974,13 @@ const Stegasoo = {
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start encode');
|
||||
throw new Error((result && result.error) || 'Failed to start encode');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.error) {
|
||||
if (result && result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
@@ -1036,6 +1059,10 @@ const Stegasoo = {
|
||||
'saving': 'Saving image...',
|
||||
'finalizing': 'Finalizing...',
|
||||
'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;
|
||||
},
|
||||
@@ -1252,6 +1279,10 @@ const Stegasoo = {
|
||||
'verifying': 'Verifying...',
|
||||
'finalizing': 'Finalizing...',
|
||||
'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;
|
||||
},
|
||||
|
||||
@@ -19,6 +19,8 @@ Usage:
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
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))
|
||||
|
||||
# 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):
|
||||
"""
|
||||
@@ -73,6 +93,7 @@ def _get_channel_info(resolved_key):
|
||||
|
||||
def encode_operation(params: dict) -> dict:
|
||||
"""Handle encode operation."""
|
||||
logger.debug("encode_operation: mode=%s", params.get("embed_mode", "lsb"))
|
||||
from stegasoo import FilePayload, encode
|
||||
|
||||
# Decode base64 inputs
|
||||
@@ -142,6 +163,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
|
||||
return
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(progress_file, "w") as f:
|
||||
json.dump({"percent": percent, "phase": phase}, f)
|
||||
except Exception:
|
||||
@@ -150,6 +172,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
|
||||
|
||||
def decode_operation(params: dict) -> dict:
|
||||
"""Handle decode operation."""
|
||||
logger.debug("decode_operation: mode=%s", params.get("embed_mode", "auto"))
|
||||
from stegasoo import decode
|
||||
|
||||
progress_file = params.get("progress_file")
|
||||
@@ -233,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:
|
||||
"""Handle channel status check (v4.0.0)."""
|
||||
from stegasoo import get_channel_status
|
||||
@@ -263,6 +425,7 @@ def main():
|
||||
else:
|
||||
params = json.loads(input_text)
|
||||
operation = params.get("operation")
|
||||
logger.info("Worker handling operation: %s", operation)
|
||||
|
||||
if operation == "encode":
|
||||
output = encode_operation(params)
|
||||
@@ -274,6 +437,13 @@ def main():
|
||||
output = capacity_check_operation(params)
|
||||
elif operation == "channel_status":
|
||||
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:
|
||||
output = {"success": False, "error": f"Unknown operation: {operation}"}
|
||||
|
||||
|
||||
@@ -115,6 +115,35 @@ class CapacityResult:
|
||||
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
|
||||
class ChannelStatusResult:
|
||||
"""Result from channel status check (v4.0.0)."""
|
||||
@@ -456,6 +485,201 @@ class SubprocessStego:
|
||||
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(
|
||||
self,
|
||||
reveal: bool = False,
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
border-left: 3px solid #ffe699;
|
||||
}
|
||||
.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 {
|
||||
background: rgba(30, 40, 50, 0.4);
|
||||
@@ -172,19 +176,51 @@
|
||||
<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">
|
||||
<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-number" id="stepImagesNumber">1</span>
|
||||
<i class="bi bi-images me-1"></i> Images & Mode
|
||||
<span class="step-number" id="stepImagesNumber">2</span>
|
||||
<i class="bi bi-images me-1"></i> Reference, Carrier, Mode
|
||||
</span>
|
||||
<span class="step-summary" id="stepImagesSummary">Select reference & stego</span>
|
||||
</button>
|
||||
</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="row">
|
||||
@@ -213,41 +249,74 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
||||
</label>
|
||||
<div class="drop-zone pixel-container" id="stegoDropZone">
|
||||
<input type="file" name="stego_image" accept="image/*" required id="stegoInput">
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop image or click</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="stegoPreview">
|
||||
<div class="pixel-blocks"></div>
|
||||
<div class="pixel-scan-line"></div>
|
||||
<div class="pixel-corners">
|
||||
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
|
||||
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
|
||||
</div>
|
||||
<div class="pixel-data-panel">
|
||||
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="stegoFileName">image.png</span></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 id="imageStegoSection">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
||||
</label>
|
||||
<div class="drop-zone pixel-container" id="stegoDropZone">
|
||||
<input type="file" name="stego_image" accept="image/*" required id="stegoInput">
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop image or click</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="stegoPreview">
|
||||
<div class="pixel-blocks"></div>
|
||||
<div class="pixel-scan-line"></div>
|
||||
<div class="pixel-corners">
|
||||
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
|
||||
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
|
||||
</div>
|
||||
<div class="pixel-data-panel">
|
||||
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="stegoFileName">image.png</span></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 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_audio" 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 class="form-text">Image containing the hidden message</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extraction Mode -->
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
|
||||
<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="modeLsb" value="lsb">
|
||||
<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="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 id="imageModeGroup">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
|
||||
<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="modeLsb" value="lsb">
|
||||
<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="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 class="form-text" id="modeHint">
|
||||
@@ -259,13 +328,13 @@
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 2: SECURITY
|
||||
STEP 3: SECURITY
|
||||
================================================================ -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
|
||||
<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
|
||||
</span>
|
||||
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
|
||||
@@ -425,7 +494,10 @@
|
||||
const modeHints = {
|
||||
auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' },
|
||||
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 => {
|
||||
@@ -442,9 +514,14 @@ document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
||||
// ACCORDION SUMMARY UPDATES
|
||||
// ============================================================================
|
||||
|
||||
const carrierTypeInput = document.getElementById('carrierTypeInput');
|
||||
|
||||
function updateImagesSummary() {
|
||||
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 summary = document.getElementById('stepImagesSummary');
|
||||
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.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '1';
|
||||
stepNum.textContent = '2';
|
||||
} else {
|
||||
summary.textContent = 'Select reference & stego';
|
||||
summary.textContent = isAudio ? 'Select reference & audio' : 'Select reference & stego';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '1';
|
||||
stepNum.textContent = '2';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,19 +570,99 @@ function updateSecuritySummary() {
|
||||
summary.textContent = 'Passphrase & keys';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '2';
|
||||
stepNum.textContent = '3';
|
||||
}
|
||||
}
|
||||
|
||||
// Attach listeners
|
||||
document.getElementById('refPhotoInput')?.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('#audioModeGroup input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||
|
||||
document.getElementById('passphraseInput')?.addEventListener('input', updateSecuritySummary);
|
||||
document.getElementById('pinInput')?.addEventListener('input', 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
|
||||
// ============================================================================
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
border-left: 3px solid #ffe699;
|
||||
}
|
||||
.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 {
|
||||
background: rgba(30, 40, 50, 0.4);
|
||||
@@ -126,14 +130,14 @@
|
||||
<div class="accordion step-accordion" id="encodeAccordion">
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 1: IMAGES
|
||||
STEP 1: CARRIER & MODE
|
||||
================================================================ -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
||||
<span class="step-title">
|
||||
<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 class="step-summary" id="stepImagesSummary">Select reference & carrier</span>
|
||||
</button>
|
||||
@@ -141,6 +145,8 @@
|
||||
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#encodeAccordion">
|
||||
<div class="accordion-body">
|
||||
|
||||
<input type="hidden" name="carrier_type" id="carrierTypeInput" value="image">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
@@ -168,28 +174,47 @@
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<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>
|
||||
<div class="drop-zone pixel-container" id="carrierDropZone">
|
||||
<input type="file" name="carrier" accept="image/*" required id="carrierInput">
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop image or click</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="carrierPreview">
|
||||
<div class="pixel-blocks"></div>
|
||||
<div class="pixel-scan-line"></div>
|
||||
<div class="pixel-corners">
|
||||
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
|
||||
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
|
||||
</div>
|
||||
<div class="pixel-data-panel">
|
||||
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="carrierFileName">image.jpg</span></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 id="imageCarrierSection">
|
||||
<div class="drop-zone pixel-container" id="carrierDropZone">
|
||||
<input type="file" name="carrier" accept="image/*" required id="carrierInput">
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop image or click</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="carrierPreview">
|
||||
<div class="pixel-blocks"></div>
|
||||
<div class="pixel-scan-line"></div>
|
||||
<div class="pixel-corners">
|
||||
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
|
||||
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
|
||||
</div>
|
||||
<div class="pixel-data-panel">
|
||||
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="carrierFileName">image.jpg</span></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 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 class="form-text">Image to hide your message in</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,32 +229,76 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedding Mode (compact inline) -->
|
||||
<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>
|
||||
<!-- Audio Capacity Info (v4.3.0) -->
|
||||
<div class="alert alert-info small d-none mb-3" id="audioCapacityPanel">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-music-note-beamed me-1"></i><span id="audioInfo">-</span></span>
|
||||
<span>
|
||||
<span class="badge bg-primary me-1" id="lsbAudioCapacityBadge">LSB: -</span>
|
||||
<span class="badge bg-warning text-dark" id="spreadCapacityBadge">Spread: -</span>
|
||||
</span>
|
||||
</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 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 %}
|
||||
|
||||
<!-- Capacity Warning -->
|
||||
<div class="form-text text-danger d-none" id="capacityWarning">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i><span id="capacityWarningText"></span>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
@@ -449,7 +518,9 @@
|
||||
// ============================================================================
|
||||
const modeHints = {
|
||||
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 => {
|
||||
@@ -462,13 +533,212 @@ 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');
|
||||
|
||||
// Capacity tracking for client-side payload size validation
|
||||
let capacityBytes = { dct: 0, lsb: 0, audio_lsb: 0, audio_spread: 0 };
|
||||
|
||||
function checkCapacity() {
|
||||
const warning = document.getElementById('capacityWarning');
|
||||
const warningText = document.getElementById('capacityWarningText');
|
||||
const encodeBtn = document.getElementById('encodeBtn');
|
||||
if (!warning || !warningText || !encodeBtn) return;
|
||||
|
||||
// Determine payload size
|
||||
const isText = document.getElementById('payloadText')?.checked;
|
||||
let payloadSize = 0;
|
||||
if (isText) {
|
||||
const msg = document.getElementById('messageInput')?.value || '';
|
||||
if (msg) payloadSize = new Blob([msg]).size;
|
||||
} else {
|
||||
const file = document.getElementById('payloadFileInput')?.files[0];
|
||||
if (file) payloadSize = file.size;
|
||||
}
|
||||
|
||||
// Get active mode
|
||||
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'lsb';
|
||||
const cap = capacityBytes[mode] || 0;
|
||||
|
||||
// Update char percent to use real capacity
|
||||
if (isText) {
|
||||
const charPercent = document.getElementById('charPercent');
|
||||
if (charPercent) {
|
||||
const effectiveCap = cap > 0 ? cap : 250000;
|
||||
charPercent.textContent = Math.round((payloadSize / effectiveCap) * 100) + '%';
|
||||
}
|
||||
}
|
||||
|
||||
// Reset badge colors
|
||||
const badgeMap = {
|
||||
dct: 'dctCapacityBadge',
|
||||
lsb: 'lsbCapacityBadge',
|
||||
audio_lsb: 'lsbAudioCapacityBadge',
|
||||
audio_spread: 'spreadCapacityBadge'
|
||||
};
|
||||
|
||||
// Restore default badge colors
|
||||
const dctBadge = document.getElementById('dctCapacityBadge');
|
||||
const lsbBadge = document.getElementById('lsbCapacityBadge');
|
||||
const audioLsbBadge = document.getElementById('lsbAudioCapacityBadge');
|
||||
const spreadBadge = document.getElementById('spreadCapacityBadge');
|
||||
if (dctBadge) { dctBadge.classList.remove('bg-danger'); dctBadge.classList.add('bg-warning'); }
|
||||
if (lsbBadge) { lsbBadge.classList.remove('bg-danger'); lsbBadge.classList.add('bg-primary'); }
|
||||
if (audioLsbBadge) { audioLsbBadge.classList.remove('bg-danger'); audioLsbBadge.classList.add('bg-primary'); }
|
||||
if (spreadBadge) { spreadBadge.classList.remove('bg-danger'); spreadBadge.classList.add('bg-warning'); }
|
||||
|
||||
// No carrier or no payload — clear warning
|
||||
if (cap === 0 || payloadSize === 0) {
|
||||
warning.classList.add('d-none');
|
||||
encodeBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (payloadSize > cap) {
|
||||
// Exceeds capacity — show warning, turn badge red, disable button
|
||||
const activeBadge = document.getElementById(badgeMap[mode]);
|
||||
if (activeBadge) {
|
||||
activeBadge.classList.remove('bg-primary', 'bg-warning');
|
||||
activeBadge.classList.add('bg-danger');
|
||||
}
|
||||
const needed = (payloadSize / 1024).toFixed(1);
|
||||
const available = (cap / 1024).toFixed(1);
|
||||
warningText.textContent = `Payload too large: ${needed} KB needed, only ${available} KB capacity in ${mode.replace('_', ' ').toUpperCase()} mode`;
|
||||
warning.classList.remove('d-none');
|
||||
encodeBtn.disabled = true;
|
||||
} else {
|
||||
warning.classList.add('d-none');
|
||||
encodeBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
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 and reset capacity values
|
||||
if (capacityPanel) capacityPanel.classList.add('d-none');
|
||||
if (audioCapacityPanel) audioCapacityPanel.classList.add('d-none');
|
||||
if (isAudio) {
|
||||
capacityBytes.dct = 0;
|
||||
capacityBytes.lsb = 0;
|
||||
} else {
|
||||
capacityBytes.audio_lsb = 0;
|
||||
capacityBytes.audio_spread = 0;
|
||||
}
|
||||
checkCapacity();
|
||||
|
||||
// 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`;
|
||||
capacityBytes.audio_lsb = data.lsb_capacity;
|
||||
capacityBytes.audio_spread = data.spread_capacity;
|
||||
document.getElementById('audioCapacityPanel')?.classList.remove('d-none');
|
||||
checkCapacity();
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
function updateImagesSummary() {
|
||||
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 summary = document.getElementById('stepImagesSummary');
|
||||
const stepNum = document.getElementById('stepImagesNumber');
|
||||
@@ -486,7 +756,7 @@ function updateImagesSummary() {
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '1';
|
||||
} else {
|
||||
summary.textContent = 'Select reference & carrier';
|
||||
summary.textContent = isAudio ? 'Select reference & audio' : 'Select reference & carrier';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '1';
|
||||
@@ -550,7 +820,9 @@ function updateSecuritySummary() {
|
||||
// Attach listeners
|
||||
document.getElementById('refPhotoInput')?.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('#audioModeGroup input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||
|
||||
document.getElementById('messageInput')?.addEventListener('input', updatePayloadSummary);
|
||||
document.getElementById('payloadFileInput')?.addEventListener('change', updatePayloadSummary);
|
||||
@@ -583,6 +855,7 @@ function updatePayloadSection() {
|
||||
payloadFileInput.setAttribute('required', '');
|
||||
}
|
||||
updatePayloadSummary();
|
||||
checkCapacity();
|
||||
}
|
||||
|
||||
payloadTextRadio?.addEventListener('change', updatePayloadSection);
|
||||
@@ -606,6 +879,7 @@ payloadFileInput?.addEventListener('change', function() {
|
||||
} else {
|
||||
fileInfo?.classList.add('d-none');
|
||||
}
|
||||
checkCapacity();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
@@ -615,7 +889,7 @@ payloadFileInput?.addEventListener('change', function() {
|
||||
messageInput?.addEventListener('input', function() {
|
||||
const count = this.value.length;
|
||||
document.getElementById('charCount').textContent = count.toLocaleString();
|
||||
document.getElementById('charPercent').textContent = Math.round((count / 250000) * 100) + '%';
|
||||
checkCapacity();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
@@ -634,7 +908,10 @@ carrierInput?.addEventListener('change', function() {
|
||||
document.getElementById('carrierDimensions').textContent = `${data.width} x ${data.height}`;
|
||||
document.getElementById('lsbCapacityBadge').textContent = `LSB: ${data.lsb.capacity_kb} KB`;
|
||||
document.getElementById('dctCapacityBadge').textContent = `DCT: ${data.dct.capacity_kb} KB`;
|
||||
capacityBytes.lsb = Math.round(data.lsb.capacity_kb * 1024);
|
||||
capacityBytes.dct = Math.round(data.dct.capacity_kb * 1024);
|
||||
document.getElementById('capacityPanel')?.classList.remove('d-none');
|
||||
checkCapacity();
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -679,7 +956,7 @@ function updateOutputOptions(mode) {
|
||||
}
|
||||
|
||||
modeRadios.forEach(radio => {
|
||||
radio.addEventListener('change', () => updateOutputOptions(radio.value));
|
||||
radio.addEventListener('change', () => { updateOutputOptions(radio.value); checkCapacity(); });
|
||||
});
|
||||
|
||||
// Initialize output options based on initial mode
|
||||
|
||||
@@ -12,12 +12,26 @@
|
||||
</h5>
|
||||
</div>
|
||||
<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">
|
||||
{% if thumbnail_url %}
|
||||
<!-- Thumbnail of the actual encoded image -->
|
||||
<div class="encoded-image-thumbnail">
|
||||
<img src="{{ thumbnail_url }}"
|
||||
alt="Encoded image thumbnail"
|
||||
<img src="{{ thumbnail_url }}"
|
||||
alt="Encoded image thumbnail"
|
||||
class="img-thumbnail rounded"
|
||||
style="max-width: 250px; max-height: 250px; object-fit: contain;">
|
||||
<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>
|
||||
{% endif %}
|
||||
</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">
|
||||
<code class="fs-5">{{ filename }}</code>
|
||||
@@ -38,11 +53,32 @@
|
||||
|
||||
<!-- Mode and format badges -->
|
||||
<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">
|
||||
<i class="bi bi-soundwave me-1"></i>DCT Mode
|
||||
</span>
|
||||
|
||||
|
||||
<!-- Color mode badge (v3.0.1) -->
|
||||
{% if color_mode == 'color' %}
|
||||
<span class="badge bg-success fs-6 ms-1">
|
||||
@@ -53,7 +89,7 @@
|
||||
<i class="bi bi-circle-half me-1"></i>Grayscale
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Output format badge -->
|
||||
{% if output_format == 'jpeg' %}
|
||||
<span class="badge bg-warning text-dark fs-6 ms-1">
|
||||
@@ -78,7 +114,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% else %}
|
||||
<span class="badge bg-primary fs-6">
|
||||
<i class="bi bi-grid-3x3-gap me-1"></i>LSB Mode
|
||||
@@ -114,7 +150,7 @@
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('encode_download', file_id=file_id) }}"
|
||||
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>
|
||||
|
||||
<button type="button" class="btn btn-outline-primary" id="shareBtn" style="display: none;">
|
||||
@@ -129,6 +165,11 @@
|
||||
<strong>Important:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<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>
|
||||
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% 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>
|
||||
{% endif %}
|
||||
@@ -148,7 +190,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,7 +204,7 @@
|
||||
const shareBtn = document.getElementById('shareBtn');
|
||||
const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}";
|
||||
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) {
|
||||
// Check if we can share files
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "stegasoo"
|
||||
version = "4.2.1"
|
||||
version = "4.3.0"
|
||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -52,6 +52,13 @@ dct = [
|
||||
"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",
|
||||
]
|
||||
cli = [
|
||||
"click>=8.0.0",
|
||||
"qrcode>=7.30",
|
||||
@@ -86,7 +93,7 @@ api = [
|
||||
"reedsolo>=1.7.0",
|
||||
]
|
||||
all = [
|
||||
"stegasoo[cli,web,api,dct,compression]",
|
||||
"stegasoo[cli,web,api,dct,audio,compression]",
|
||||
]
|
||||
dev = [
|
||||
"stegasoo[all]",
|
||||
@@ -141,6 +148,8 @@ ignore = ["E501"]
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# YCbCr colorspace variables (R, G, B, Y, Cb, Cr) are standard names
|
||||
"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
|
||||
"src/stegasoo/__init__.py" = ["E402"]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Flash Raspberry Pi image with headless config (Trixie/Bookworm compatible)
|
||||
# Usage: ./flash-stock-img.sh <image.img.xz> <device>
|
||||
# Reads settings from config.json in same directory
|
||||
# Usage: ./flash-stock-img.sh [-c config.json] <image.img.xz> <device>
|
||||
# Reads settings from config.json in same directory (or specify with -c)
|
||||
#
|
||||
# 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)"
|
||||
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
|
||||
# ============================================================================
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -38,9 +58,7 @@ echo
|
||||
# Validate args
|
||||
# ============================================================================
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "Usage: $0 <image.img.xz> <device>"
|
||||
echo "Example: $0 2025-12-04-raspios-trixie-arm64-lite.img.xz /dev/sdb"
|
||||
exit 1
|
||||
usage
|
||||
fi
|
||||
|
||||
IMAGE="$1"
|
||||
|
||||
@@ -14,9 +14,9 @@ BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "Usage: $0 <device> <output.img.zst>"
|
||||
echo "Example: $0 /dev/sdb stegasoo-rpi-4.2.1.img.zst"
|
||||
exit 1
|
||||
echo "Usage: $0 <device> <output.img.zst>"
|
||||
echo "Example: $0 /dev/sdb stegasoo-rpi-4.2.1.img.zst"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEVICE="$1"
|
||||
@@ -24,13 +24,13 @@ OUTPUT="$2"
|
||||
|
||||
# Check for root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||
exit 1
|
||||
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -b "$DEVICE" ]; then
|
||||
echo -e "${RED}Error: Device not found: $DEVICE${NC}"
|
||||
exit 1
|
||||
echo -e "${RED}Error: Device not found: $DEVICE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BOLD}Device info:${NC}"
|
||||
@@ -39,14 +39,14 @@ echo
|
||||
|
||||
# Find partitions
|
||||
if [ -b "${DEVICE}1" ]; then
|
||||
BOOT_PART="${DEVICE}1"
|
||||
ROOT_PART="${DEVICE}2"
|
||||
BOOT_PART="${DEVICE}1"
|
||||
ROOT_PART="${DEVICE}2"
|
||||
elif [ -b "${DEVICE}p1" ]; then
|
||||
BOOT_PART="${DEVICE}p1"
|
||||
ROOT_PART="${DEVICE}p2"
|
||||
BOOT_PART="${DEVICE}p1"
|
||||
ROOT_PART="${DEVICE}p2"
|
||||
else
|
||||
echo -e "${RED}Error: Could not find partitions${NC}"
|
||||
exit 1
|
||||
echo -e "${RED}Error: Could not find partitions${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Unmount any mounted partitions
|
||||
@@ -62,65 +62,65 @@ echo -e "${BOLD}Checking partition size...${NC}"
|
||||
|
||||
# Get current partition size in bytes
|
||||
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)
|
||||
|
||||
echo " Current rootfs size: ${CURRENT_GB}GB"
|
||||
|
||||
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
|
||||
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||
# Get boot partition end in sectors
|
||||
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||
|
||||
# Calculate 16GB in sectors (512 byte sectors)
|
||||
ROOT_SIZE_SECTORS=33554432
|
||||
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||
# Calculate 16GB in sectors (512 byte sectors)
|
||||
ROOT_SIZE_SECTORS=33554432
|
||||
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||
|
||||
# SHRINKING: filesystem first, then partition
|
||||
echo " Checking filesystem..."
|
||||
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
# SHRINKING: filesystem first, then partition
|
||||
echo " Checking filesystem..."
|
||||
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
|
||||
# Shrink filesystem to 15.5GB (leave room for partition overhead)
|
||||
echo " Shrinking filesystem to 15500M..."
|
||||
resize2fs "$ROOT_PART" 15500M
|
||||
# Shrink filesystem to 15.5GB (leave room for partition overhead)
|
||||
echo " Shrinking filesystem to 15500M..."
|
||||
resize2fs "$ROOT_PART" 15500M
|
||||
|
||||
# Delete and recreate partition 2 with 16GB size
|
||||
echo " Shrinking partition to 16GB..."
|
||||
parted -s "$DEVICE" rm 2
|
||||
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||
# Delete and recreate partition 2 with 16GB size
|
||||
echo " Shrinking partition to 16GB..."
|
||||
parted -s "$DEVICE" rm 2
|
||||
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||
|
||||
# Refresh partition table
|
||||
partprobe "$DEVICE"
|
||||
sleep 2
|
||||
# Refresh partition table
|
||||
partprobe "$DEVICE"
|
||||
sleep 2
|
||||
|
||||
# Expand filesystem to fill the partition exactly
|
||||
echo " Expanding filesystem to fill partition..."
|
||||
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
resize2fs "$ROOT_PART"
|
||||
# Expand filesystem to fill the partition exactly
|
||||
echo " Expanding filesystem to fill partition..."
|
||||
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
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
|
||||
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
|
||||
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||
ROOT_SIZE_SECTORS=33554432
|
||||
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||
# Get boot partition end in sectors
|
||||
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||
ROOT_SIZE_SECTORS=33554432
|
||||
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||
|
||||
# EXPANDING: partition first, then filesystem
|
||||
parted -s "$DEVICE" rm 2
|
||||
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||
# EXPANDING: partition first, then filesystem
|
||||
parted -s "$DEVICE" rm 2
|
||||
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||
|
||||
partprobe "$DEVICE"
|
||||
sleep 2
|
||||
partprobe "$DEVICE"
|
||||
sleep 2
|
||||
|
||||
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
resize2fs "$ROOT_PART"
|
||||
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
resize2fs "$ROOT_PART"
|
||||
|
||||
echo -e "${GREEN} Rootfs expanded to 16GB${NC}"
|
||||
echo -e "${GREEN} Rootfs expanded to 16GB${NC}"
|
||||
else
|
||||
echo -e "${GREEN} Rootfs already ~16GB${NC}"
|
||||
echo -e "${GREEN} Rootfs already ~16GB${NC}"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
@@ -135,8 +135,8 @@ echo
|
||||
END_SECTOR=$(parted -s "$DEVICE" unit s print | grep "^ 2" | awk '{print $3}' | tr -d 's')
|
||||
|
||||
if [ -z "$END_SECTOR" ]; then
|
||||
echo -e "${RED}Error: Could not determine partition 2 end sector${NC}"
|
||||
exit 1
|
||||
echo -e "${RED}Error: Could not determine partition 2 end sector${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add a small buffer (1MB = 2048 sectors) for safety
|
||||
@@ -150,8 +150,8 @@ echo
|
||||
|
||||
read -p "Proceed with image pull? [Y/n] " confirm
|
||||
if [[ "$confirm" =~ ^[Nn]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
@@ -159,13 +159,13 @@ echo -e "${GREEN}Pulling image...${NC}"
|
||||
echo
|
||||
|
||||
# Use pv if available for progress, otherwise fallback to dd status
|
||||
if command -v pv &> /dev/null; then
|
||||
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS 2>/dev/null | \
|
||||
pv -s $TOTAL_BYTES | \
|
||||
zstd -T0 -3 > "$OUTPUT"
|
||||
if command -v pv &>/dev/null; then
|
||||
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS 2>/dev/null |
|
||||
pv -s $TOTAL_BYTES |
|
||||
zstd -T0 -19 --ultra >"$OUTPUT"
|
||||
else
|
||||
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS status=progress | \
|
||||
zstd -T0 -3 > "$OUTPUT"
|
||||
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS status=progress |
|
||||
zstd -T0 -19 --ultra >"$OUTPUT"
|
||||
fi
|
||||
|
||||
echo
|
||||
@@ -178,16 +178,16 @@ ls -lh "$OUTPUT"
|
||||
echo
|
||||
read -p "Create .zst.zip wrapper for GitHub? [y/N] " zip_confirm
|
||||
if [[ "$zip_confirm" =~ ^[Yy]$ ]]; then
|
||||
ZIP_OUTPUT="${OUTPUT}.zip"
|
||||
echo -e "${YELLOW}Creating zip wrapper (store mode, no compression)...${NC}"
|
||||
zip -0 "$ZIP_OUTPUT" "$OUTPUT"
|
||||
echo -e "${GREEN}Done!${NC} Upload this to GitHub Releases:"
|
||||
ls -lh "$ZIP_OUTPUT"
|
||||
echo
|
||||
echo "Users can flash with:"
|
||||
echo " sudo ./rpi/flash-image.sh $ZIP_OUTPUT"
|
||||
ZIP_OUTPUT="${OUTPUT}.zip"
|
||||
echo -e "${YELLOW}Creating zip wrapper (store mode, no compression)...${NC}"
|
||||
zip -0 "$ZIP_OUTPUT" "$OUTPUT"
|
||||
echo -e "${GREEN}Done!${NC} Upload this to GitHub Releases:"
|
||||
ls -lh "$ZIP_OUTPUT"
|
||||
echo
|
||||
echo "Users can flash with:"
|
||||
echo " sudo ./rpi/flash-image.sh $ZIP_OUTPUT"
|
||||
else
|
||||
echo
|
||||
echo "To verify:"
|
||||
echo " zstdcat $OUTPUT | fdisk -l /dev/stdin"
|
||||
echo
|
||||
echo "To verify:"
|
||||
echo " zstdcat $OUTPUT | fdisk -l /dev/stdin"
|
||||
fi
|
||||
|
||||
13
rpi/train_proj.json
Normal file
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
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ Changes in v4.0.0:
|
||||
- encode() and decode() now accept channel_key parameter
|
||||
"""
|
||||
|
||||
__version__ = "4.2.1"
|
||||
__version__ = "4.3.0"
|
||||
|
||||
# Core functionality
|
||||
# Channel key management (v4.0.0)
|
||||
@@ -22,6 +22,9 @@ from .channel import (
|
||||
validate_channel_key,
|
||||
)
|
||||
|
||||
# Audio support — gated by STEGASOO_AUDIO env var and dependency availability
|
||||
from .constants import AUDIO_ENABLED, VIDEO_ENABLED
|
||||
|
||||
# Crypto functions
|
||||
from .crypto import get_active_channel_key, get_channel_fingerprint, has_argon2
|
||||
from .decode import decode, decode_file, decode_text
|
||||
@@ -43,6 +46,16 @@ from .image_utils import (
|
||||
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
|
||||
from .steganography import (
|
||||
calculate_capacity_by_mode,
|
||||
@@ -54,6 +67,44 @@ from .steganography import (
|
||||
# Utilities
|
||||
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
|
||||
try:
|
||||
from .qr_utils import (
|
||||
@@ -88,9 +139,14 @@ validate_carrier = validate_image
|
||||
# Constants
|
||||
from .constants import (
|
||||
DEFAULT_PASSPHRASE_WORDS,
|
||||
EMBED_MODE_AUDIO_AUTO,
|
||||
EMBED_MODE_AUDIO_LSB,
|
||||
EMBED_MODE_AUDIO_SPREAD,
|
||||
EMBED_MODE_AUTO,
|
||||
EMBED_MODE_DCT,
|
||||
EMBED_MODE_LSB,
|
||||
EMBED_MODE_VIDEO_AUTO,
|
||||
EMBED_MODE_VIDEO_LSB,
|
||||
FORMAT_VERSION,
|
||||
LOSSLESS_FORMATS,
|
||||
MAX_FILE_PAYLOAD_SIZE,
|
||||
@@ -106,6 +162,11 @@ from .constants import (
|
||||
|
||||
# Exceptions
|
||||
from .exceptions import (
|
||||
AudioCapacityError,
|
||||
AudioError,
|
||||
AudioExtractionError,
|
||||
AudioTranscodeError,
|
||||
AudioValidationError,
|
||||
CapacityError,
|
||||
CryptoError,
|
||||
DecryptionError,
|
||||
@@ -127,11 +188,21 @@ from .exceptions import (
|
||||
SecurityFactorError,
|
||||
SteganographyError,
|
||||
StegasooError,
|
||||
UnsupportedAudioFormatError,
|
||||
UnsupportedVideoFormatError,
|
||||
ValidationError,
|
||||
VideoCapacityError,
|
||||
VideoError,
|
||||
VideoExtractionError,
|
||||
VideoTranscodeError,
|
||||
VideoValidationError,
|
||||
)
|
||||
|
||||
# Models
|
||||
from .models import (
|
||||
AudioCapacityInfo,
|
||||
AudioEmbedStats,
|
||||
AudioInfo,
|
||||
CapacityComparison,
|
||||
Credentials,
|
||||
DecodeResult,
|
||||
@@ -140,8 +211,13 @@ from .models import (
|
||||
GenerateResult,
|
||||
ImageInfo,
|
||||
ValidationResult,
|
||||
VideoCapacityInfo,
|
||||
VideoEmbedStats,
|
||||
VideoInfo,
|
||||
)
|
||||
from .validation import (
|
||||
validate_audio_embed_mode,
|
||||
validate_audio_file,
|
||||
validate_dct_color_mode,
|
||||
validate_dct_output_format,
|
||||
validate_embed_mode,
|
||||
@@ -164,6 +240,24 @@ __all__ = [
|
||||
"decode",
|
||||
"decode_file",
|
||||
"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
|
||||
"generate_pin",
|
||||
"generate_passphrase",
|
||||
@@ -189,6 +283,15 @@ __all__ = [
|
||||
"generate_filename",
|
||||
# Crypto
|
||||
"has_argon2",
|
||||
# Backends
|
||||
"EmbeddingBackend",
|
||||
"backend_registry",
|
||||
"BackendNotFoundError",
|
||||
# Platform presets
|
||||
"get_preset",
|
||||
"PLATFORMS",
|
||||
# Steganalysis
|
||||
"check_image",
|
||||
# Steganography
|
||||
"has_dct_support",
|
||||
"calculate_capacity_by_mode",
|
||||
@@ -221,6 +324,14 @@ __all__ = [
|
||||
"FilePayload",
|
||||
"Credentials",
|
||||
"ValidationResult",
|
||||
# Audio models
|
||||
"AudioEmbedStats",
|
||||
"AudioInfo",
|
||||
"AudioCapacityInfo",
|
||||
# Video models
|
||||
"VideoEmbedStats",
|
||||
"VideoInfo",
|
||||
"VideoCapacityInfo",
|
||||
# Exceptions
|
||||
"StegasooError",
|
||||
"ValidationError",
|
||||
@@ -244,6 +355,20 @@ __all__ = [
|
||||
"ReedSolomonError",
|
||||
"NoDataFoundError",
|
||||
"ModeMismatchError",
|
||||
# Audio exceptions
|
||||
"AudioError",
|
||||
"AudioValidationError",
|
||||
"AudioCapacityError",
|
||||
"AudioExtractionError",
|
||||
"AudioTranscodeError",
|
||||
"UnsupportedAudioFormatError",
|
||||
# Video exceptions
|
||||
"VideoError",
|
||||
"VideoValidationError",
|
||||
"VideoCapacityError",
|
||||
"VideoExtractionError",
|
||||
"VideoTranscodeError",
|
||||
"UnsupportedVideoFormatError",
|
||||
# Constants
|
||||
"FORMAT_VERSION",
|
||||
"MIN_PASSPHRASE_WORDS",
|
||||
@@ -266,4 +391,11 @@ __all__ = [
|
||||
"EMBED_MODE_LSB",
|
||||
"EMBED_MODE_DCT",
|
||||
"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",
|
||||
]
|
||||
|
||||
520
src/stegasoo/audio_steganography.py
Normal file
520
src/stegasoo/audio_steganography.py
Normal file
@@ -0,0 +1,520 @@
|
||||
"""
|
||||
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 as float64 (handles all subtypes correctly)
|
||||
buf = io.BytesIO(carrier_audio)
|
||||
float_samples, samplerate = sf.read(buf, dtype="float64", always_2d=True)
|
||||
original_shape = float_samples.shape
|
||||
channels = original_shape[1]
|
||||
duration = original_shape[0] / samplerate
|
||||
|
||||
# Detect original subtype for output
|
||||
buf.seek(0)
|
||||
carrier_info = sf.info(buf)
|
||||
output_subtype = carrier_info.subtype or "PCM_16"
|
||||
|
||||
debug.print(
|
||||
f"Carrier audio: {samplerate} Hz, {channels} ch, "
|
||||
f"{original_shape[0]} frames, {duration:.2f}s, subtype={output_subtype}"
|
||||
)
|
||||
|
||||
# Convert float64 → int16 for LSB manipulation (32768 matches libsndfile normalization)
|
||||
samples = np.clip(float_samples * 32768.0, -32768, 32767).astype(np.int16)
|
||||
|
||||
# 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 PCM_16 WAV
|
||||
# LSB steganography requires integer samples — writing as FLOAT/DOUBLE
|
||||
# destroys LSBs due to float32 precision loss (33k/65k values fail round-trip).
|
||||
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 as int16 directly (stego output is always PCM_16)
|
||||
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
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
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
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
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
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
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
|
||||
if not machine_id:
|
||||
import socket
|
||||
|
||||
machine_id = socket.gethostname()
|
||||
|
||||
# Hash to get consistent 32 bytes
|
||||
@@ -87,10 +88,7 @@ def _encrypt_for_storage(plaintext: str) -> str:
|
||||
plaintext_bytes = plaintext.encode()
|
||||
|
||||
# XOR with key (cycling if needed)
|
||||
encrypted = bytes(
|
||||
pb ^ key[i % len(key)]
|
||||
for i, pb in enumerate(plaintext_bytes)
|
||||
)
|
||||
encrypted = bytes(pb ^ key[i % len(key)] for i, pb in enumerate(plaintext_bytes))
|
||||
|
||||
return ENCRYPTED_PREFIX + base64.b64encode(encrypted).decode()
|
||||
|
||||
@@ -108,14 +106,11 @@ def _decrypt_from_storage(stored: str) -> str | None:
|
||||
return stored
|
||||
|
||||
try:
|
||||
encrypted = base64.b64decode(stored[len(ENCRYPTED_PREFIX):])
|
||||
encrypted = base64.b64decode(stored[len(ENCRYPTED_PREFIX) :])
|
||||
key = _get_machine_key()
|
||||
|
||||
# XOR to decrypt
|
||||
decrypted = bytes(
|
||||
eb ^ key[i % len(key)]
|
||||
for i, eb in enumerate(encrypted)
|
||||
)
|
||||
decrypted = bytes(eb ^ key[i % len(key)] for i, eb in enumerate(encrypted))
|
||||
|
||||
return decrypted.decode()
|
||||
except Exception:
|
||||
@@ -413,7 +408,11 @@ def get_channel_status() -> dict:
|
||||
try:
|
||||
stored = config_path.read_text().strip()
|
||||
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)
|
||||
break
|
||||
except (OSError, PermissionError, ValueError):
|
||||
@@ -485,7 +484,9 @@ def resolve_channel_key(
|
||||
>>> resolve_channel_key("ABCD-1234-...") # -> "ABCD-1234-..."
|
||||
>>> 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
|
||||
if no_channel:
|
||||
|
||||
1176
src/stegasoo/cli.py
1176
src/stegasoo/cli.py
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,10 @@ import struct
|
||||
import zlib
|
||||
from enum import IntEnum
|
||||
|
||||
from .debug import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Optional LZ4 support (faster, slightly worse ratio)
|
||||
try:
|
||||
import lz4.frame
|
||||
|
||||
@@ -44,7 +44,9 @@ MAGIC_HEADER = b"\x89ST3"
|
||||
# Version 1-3: Date-dependent encryption (v3.0.x - v3.1.x)
|
||||
# Version 4: Date-independent encryption (v3.2.0)
|
||||
# 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_TEXT = 0x01
|
||||
@@ -66,6 +68,11 @@ ARGON2_PARALLELISM = 4
|
||||
# PBKDF2 fallback parameters
|
||||
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
|
||||
# ============================================================================
|
||||
@@ -244,6 +251,17 @@ def get_wordlist() -> list[str]:
|
||||
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+)
|
||||
# =============================================================================
|
||||
@@ -262,8 +280,7 @@ DCT_STEP_SIZE = 8 # QIM quantization step
|
||||
# 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
|
||||
RECOVERY_OBFUSCATION_KEY = bytes.fromhex(
|
||||
"d6c70bce27780db942562550e9fe1459"
|
||||
"9dfdb8421f5acc79696b05db4e7afbd2"
|
||||
"d6c70bce27780db942562550e9fe1459" "9dfdb8421f5acc79696b05db4e7afbd2"
|
||||
) # 32 bytes
|
||||
|
||||
# Valid embedding modes
|
||||
@@ -295,3 +312,158 @@ def detect_stego_mode(encrypted_data: bytes) -> str:
|
||||
return EMBED_MODE_DCT
|
||||
else:
|
||||
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)
|
||||
|
||||
# Adaptive amplitude: embed at a fixed ratio below the carrier's RMS level.
|
||||
# Keeps noise inaudible under content while ensuring extraction reliability.
|
||||
AUDIO_SS_AMPLITUDE_RATIO = 0.25 # Fraction of carrier RMS (≈ -12 dB below signal)
|
||||
AUDIO_SS_AMPLITUDE_MIN = 0.001 # Floor: ensures correlation ≥ 0.256 at chip=256
|
||||
AUDIO_SS_AMPLITUDE_MAX = 0.05 # Ceiling: never exceed original fixed amplitude
|
||||
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
|
||||
|
||||
# Compact padding for audio encryption (limited carrier capacity)
|
||||
AUDIO_PAD_MIN = 8 # Minimum random padding bytes (vs 64 for images)
|
||||
AUDIO_PAD_RANGE = 32 # Random padding range (vs 256 for images)
|
||||
AUDIO_PAD_ALIGN = 32 # Alignment boundary (vs 256 for images)
|
||||
|
||||
# Lossless audio formats (safe for low chip tier)
|
||||
LOSSLESS_AUDIO_FORMATS = {"wav", "flac", "aiff"}
|
||||
|
||||
# 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,26 +29,37 @@ import secrets
|
||||
import struct
|
||||
|
||||
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.kdf.hkdf import HKDFExpand
|
||||
from PIL import Image
|
||||
|
||||
from .constants import (
|
||||
ARGON2_MEMORY_COST,
|
||||
ARGON2_PARALLELISM,
|
||||
ARGON2_TIME_COST,
|
||||
AUDIO_PAD_ALIGN,
|
||||
AUDIO_PAD_MIN,
|
||||
AUDIO_PAD_RANGE,
|
||||
FORMAT_VERSION,
|
||||
FORMAT_VERSION_LEGACY,
|
||||
HKDF_INFO_ENCRYPT,
|
||||
IV_SIZE,
|
||||
MAGIC_HEADER,
|
||||
MAX_FILENAME_LENGTH,
|
||||
MESSAGE_NONCE_SIZE,
|
||||
PAYLOAD_FILE,
|
||||
PAYLOAD_TEXT,
|
||||
PBKDF2_ITERATIONS,
|
||||
SALT_SIZE,
|
||||
TAG_SIZE,
|
||||
)
|
||||
from .debug import get_logger
|
||||
from .exceptions import DecryptionError, EncryptionError, InvalidHeaderError, KeyDerivationError
|
||||
from .models import DecodeResult, FilePayload
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Check for Argon2 availability
|
||||
try:
|
||||
from argon2.low_level import Type, hash_secret_raw
|
||||
@@ -60,6 +71,7 @@ except ImportError:
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CHANNEL KEY RESOLUTION
|
||||
# =============================================================================
|
||||
@@ -201,6 +213,18 @@ def derive_hybrid_key(
|
||||
"""
|
||||
try:
|
||||
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)
|
||||
channel_hash = _resolve_channel_key(channel_key)
|
||||
@@ -217,19 +241,30 @@ def derive_hybrid_key(
|
||||
if channel_hash:
|
||||
key_material += channel_hash
|
||||
|
||||
logger.debug("Key material: %d bytes", len(key_material))
|
||||
|
||||
# Run it all through the KDF
|
||||
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
|
||||
key = hash_secret_raw(
|
||||
secret=key_material,
|
||||
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
|
||||
parallelism=ARGON2_PARALLELISM, # 4 threads
|
||||
hash_len=32,
|
||||
type=Type.ID, # Hybrid mode: resists side-channel AND GPU attacks
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"KDF: PBKDF2 fallback (%d iterations) - argon2 not available", PBKDF2_ITERATIONS
|
||||
)
|
||||
# PBKDF2 fallback for systems without argon2-cffi
|
||||
# 600K iterations is slow but not memory-hard
|
||||
kdf = PBKDF2HMAC(
|
||||
@@ -241,6 +276,7 @@ def derive_hybrid_key(
|
||||
)
|
||||
key = kdf.derive(key_material)
|
||||
|
||||
logger.debug("KDF complete, derived %d-byte key", len(key))
|
||||
return key
|
||||
|
||||
except Exception as e:
|
||||
@@ -287,6 +323,30 @@ def derive_pixel_key(
|
||||
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(
|
||||
content: str | bytes | FilePayload,
|
||||
) -> tuple[bytes, int]:
|
||||
@@ -406,6 +466,7 @@ def encrypt_message(
|
||||
pin: str = "",
|
||||
rsa_key_data: bytes | None = None,
|
||||
channel_key: str | bool | None = None,
|
||||
compact: bool = False,
|
||||
) -> bytes:
|
||||
"""
|
||||
Encrypt message or file using AES-256-GCM.
|
||||
@@ -445,7 +506,12 @@ def encrypt_message(
|
||||
"""
|
||||
try:
|
||||
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)
|
||||
|
||||
# Determine flags
|
||||
@@ -457,26 +523,66 @@ def encrypt_message(
|
||||
# Pack payload with type marker
|
||||
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
|
||||
padding_len = secrets.randbelow(256) + 64
|
||||
padded_len = ((len(packed_payload) + padding_len + 255) // 256) * 256
|
||||
# Compact mode uses smaller padding for capacity-limited carriers (audio)
|
||||
if compact:
|
||||
pad_min = AUDIO_PAD_MIN
|
||||
pad_range = AUDIO_PAD_RANGE
|
||||
pad_align = AUDIO_PAD_ALIGN
|
||||
else:
|
||||
pad_min = 64
|
||||
pad_range = 256
|
||||
pad_align = 256
|
||||
padding_len = secrets.randbelow(pad_range) + pad_min
|
||||
padded_len = ((len(packed_payload) + padding_len + pad_align - 1) // pad_align) * pad_align
|
||||
padding_needed = padded_len - len(packed_payload)
|
||||
padding = secrets.token_bytes(padding_needed - 4) + struct.pack(">I", len(packed_payload))
|
||||
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])
|
||||
|
||||
# Encrypt with AES-256-GCM
|
||||
cipher = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend())
|
||||
encryptor = cipher.encryptor()
|
||||
encryptor.authenticate_additional_data(header)
|
||||
encryptor.authenticate_additional_data(header + message_nonce)
|
||||
ciphertext = encryptor.update(padded_message) + encryptor.finalize()
|
||||
|
||||
# v4.0.0: Header with flags byte
|
||||
return header + salt + iv + encryptor.tag + ciphertext
|
||||
total_size = (
|
||||
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:
|
||||
logger.error("Encryption failed: %s", e)
|
||||
raise EncryptionError(f"Encryption failed: {e}") from e
|
||||
|
||||
|
||||
@@ -484,43 +590,78 @@ def parse_header(encrypted_data: bytes) -> dict | None:
|
||||
"""
|
||||
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:
|
||||
encrypted_data: Raw encrypted bytes
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
try:
|
||||
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
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
@@ -551,22 +692,42 @@ def decrypt_message(
|
||||
InvalidHeaderError: If data doesn't have valid Stegasoo header
|
||||
DecryptionError: If decryption fails (wrong credentials)
|
||||
"""
|
||||
logger.debug("decrypt_message: %d bytes of encrypted data", len(encrypted_data))
|
||||
|
||||
header = parse_header(encrypted_data)
|
||||
if not header:
|
||||
logger.error("Invalid or missing Stegasoo header in %d bytes", len(encrypted_data))
|
||||
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
|
||||
channel_hash = _resolve_channel_key(channel_key)
|
||||
has_configured_key = channel_hash is not None
|
||||
message_has_key = header["has_channel_key"]
|
||||
|
||||
try:
|
||||
key = derive_hybrid_key(
|
||||
root_key = derive_hybrid_key(
|
||||
photo_data, passphrase, header["salt"], pin, rsa_key_data, channel_key
|
||||
)
|
||||
|
||||
# Reconstruct header for AAD verification
|
||||
aad_header = MAGIC_HEADER + bytes([FORMAT_VERSION, header["flags"]])
|
||||
version = header["version"]
|
||||
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(
|
||||
algorithms.AES(key), modes.GCM(header["iv"], header["tag"]), backend=default_backend()
|
||||
@@ -577,9 +738,16 @@ def decrypt_message(
|
||||
padded_plaintext = decryptor.update(header["ciphertext"]) + decryptor.finalize()
|
||||
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]
|
||||
result = _unpack_payload(payload_data)
|
||||
|
||||
logger.debug("Decryption successful: %s (v%d)", result.payload_type, version)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -12,7 +12,7 @@ Why is this cool?
|
||||
|
||||
Two approaches depending on what you want:
|
||||
1. PNG output: We do our own DCT math via scipy (works on any image)
|
||||
2. JPEG output: We use jpeglib to directly tweak the coefficients (chef's kiss)
|
||||
2. JPEG output: We use jpeglib to directly modify the coefficients (chef's kiss)
|
||||
|
||||
v4.1.0 - The "please stop corrupting my data" release:
|
||||
- Reed-Solomon error correction (can fix up to 16 byte errors per chunk)
|
||||
@@ -40,12 +40,12 @@ from PIL import Image, ImageOps
|
||||
# Check for scipy availability (for PNG/DCT mode)
|
||||
# Prefer scipy.fft (newer, more stable) over scipy.fftpack
|
||||
try:
|
||||
from scipy.fft import dct, idct, dctn, idctn
|
||||
from scipy.fft import dct, dctn, idct, idctn
|
||||
|
||||
HAS_SCIPY = True
|
||||
except ImportError:
|
||||
try:
|
||||
from scipy.fftpack import dct, idct, dctn, idctn
|
||||
from scipy.fftpack import dct, dctn, idct, idctn
|
||||
|
||||
HAS_SCIPY = True
|
||||
except ImportError:
|
||||
@@ -56,13 +56,12 @@ except ImportError:
|
||||
idctn = None
|
||||
|
||||
# Check for jpeglib availability (for proper JPEG mode)
|
||||
# jpeglib replaces jpegio for Python 3.13+ compatibility
|
||||
try:
|
||||
import jpeglib
|
||||
|
||||
HAS_JPEGIO = True # Keep variable name for compatibility
|
||||
HAS_JPEGLIB = True
|
||||
except ImportError:
|
||||
HAS_JPEGIO = False
|
||||
HAS_JPEGLIB = False
|
||||
jpeglib = None
|
||||
|
||||
# Import custom exceptions
|
||||
@@ -120,9 +119,9 @@ BLOCK_SIZE = 8
|
||||
# Position (0,0) is the DC coefficient - the average brightness of the block.
|
||||
# We NEVER touch DC because changing it causes visible brightness shifts.
|
||||
EMBED_POSITIONS = [
|
||||
(0, 1), # 1st AC coefficient
|
||||
(1, 0), # 2nd AC coefficient
|
||||
(2, 0), # ... and so on in zig-zag order
|
||||
(0, 1), # 1st AC coefficient
|
||||
(1, 0), # 2nd AC coefficient
|
||||
(2, 0), # ... and so on in zig-zag order
|
||||
(1, 1),
|
||||
(0, 2),
|
||||
(0, 3),
|
||||
@@ -169,25 +168,25 @@ DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20]
|
||||
QUANT_STEP = 25
|
||||
|
||||
# Magic bytes so we can identify our own images
|
||||
DCT_MAGIC = b"DCTS" # scipy DCT mode marker
|
||||
JPEGIO_MAGIC = b"JPGS" # jpegio native JPEG mode marker
|
||||
HEADER_SIZE = 10 # Magic (4) + version (1) + flags (1) + length (4)
|
||||
DCT_MAGIC = b"DCTS" # scipy DCT mode marker
|
||||
JPEGLIB_MAGIC = b"JPGS" # jpeglib native JPEG mode marker
|
||||
HEADER_SIZE = 10 # Magic (4) + version (1) + flags (1) + length (4)
|
||||
|
||||
OUTPUT_FORMAT_PNG = "png"
|
||||
OUTPUT_FORMAT_JPEG = "jpeg"
|
||||
JPEG_OUTPUT_QUALITY = 95 # High quality but not 100 (100 causes issues, see below)
|
||||
|
||||
# For jpegio mode: we only embed in coefficients with magnitude >= 2
|
||||
# For jpeglib mode: we only embed in coefficients with magnitude >= 2
|
||||
# Coefficients of 0 or 1 are usually quantized noise - unreliable
|
||||
JPEGIO_MIN_COEF_MAGNITUDE = 2
|
||||
JPEGLIB_MIN_COEF_MAGNITUDE = 2
|
||||
|
||||
# We embed in the Y (luminance) channel only - it has the most capacity
|
||||
# Cb/Cr are often subsampled 4:2:0 anyway
|
||||
JPEGIO_EMBED_CHANNEL = 0
|
||||
JPEGLIB_EMBED_CHANNEL = 0
|
||||
|
||||
# Header flags
|
||||
FLAG_COLOR_MODE = 0x01 # Set if we preserved color (YCbCr mode)
|
||||
FLAG_RS_PROTECTED = 0x02 # Set if Reed-Solomon protected (v4.1.0+)
|
||||
FLAG_COLOR_MODE = 0x01 # Set if we preserved color (YCbCr mode)
|
||||
FLAG_RS_PROTECTED = 0x02 # Set if Reed-Solomon protected (v4.1.0+)
|
||||
|
||||
# Reed-Solomon settings - the "please don't lose my data" system
|
||||
# 32 parity symbols per chunk means we can correct up to 16 byte errors
|
||||
@@ -196,18 +195,18 @@ RS_NSYM = 32
|
||||
|
||||
# We store the payload length 3 times and take majority vote
|
||||
# Because if the length is wrong, everything is wrong
|
||||
RS_LENGTH_HEADER_SIZE = 8 # 4 bytes raw length + 4 bytes RS-encoded length
|
||||
RS_LENGTH_COPIES = 3 # Store 3 copies, need 2 to agree
|
||||
RS_LENGTH_HEADER_SIZE = 8 # 4 bytes raw length + 4 bytes RS-encoded length
|
||||
RS_LENGTH_COPIES = 3 # Store 3 copies, need 2 to agree
|
||||
RS_LENGTH_PREFIX_SIZE = RS_LENGTH_HEADER_SIZE * RS_LENGTH_COPIES # 24 bytes total
|
||||
|
||||
# Chunking for large images - scipy's FFT gets memory-corrupty on huge arrays
|
||||
MAX_CHUNK_HEIGHT = 512 # Process in strips to keep memory sane
|
||||
|
||||
# Fun bug: JPEGs saved with quality=100 have quantization tables full of 1s
|
||||
# This makes the DCT coefficients HUGE and jpegio crashes spectacularly
|
||||
# This makes the DCT coefficients HUGE and jpeglib crashes spectacularly
|
||||
# Solution: detect and re-save at quality 95 first
|
||||
JPEGIO_NORMALIZE_QUALITY = 95
|
||||
JPEGIO_MAX_QUANT_VALUE_THRESHOLD = 1 # All 1s in quant table = bad news
|
||||
JPEGLIB_NORMALIZE_QUALITY = 95
|
||||
JPEGLIB_MAX_QUANT_VALUE_THRESHOLD = 1 # All 1s in quant table = bad news
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -261,8 +260,8 @@ def has_dct_support() -> bool:
|
||||
return HAS_SCIPY
|
||||
|
||||
|
||||
def has_jpegio_support() -> bool:
|
||||
return HAS_JPEGIO
|
||||
def has_jpeglib_support() -> bool:
|
||||
return HAS_JPEGLIB
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -287,6 +286,7 @@ def has_jpegio_support() -> bool:
|
||||
|
||||
try:
|
||||
from reedsolo import ReedSolomonError, RSCodec
|
||||
|
||||
HAS_REEDSOLO = True
|
||||
except ImportError:
|
||||
HAS_REEDSOLO = False
|
||||
@@ -653,11 +653,11 @@ def _parse_header(header_bits: list) -> tuple[int, int, int]:
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# JPEGIO HELPERS
|
||||
# JPEGLIB HELPERS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _jpegio_bytes_to_file(data: bytes, suffix: str = ".jpg") -> str:
|
||||
def _jpeglib_bytes_to_file(data: bytes, suffix: str = ".jpg") -> str:
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
@@ -669,19 +669,19 @@ def _jpegio_bytes_to_file(data: bytes, suffix: str = ".jpg") -> str:
|
||||
return path
|
||||
|
||||
|
||||
def _jpegio_get_usable_positions(coef_array: np.ndarray) -> list:
|
||||
def _jpeglib_get_usable_positions(coef_array: np.ndarray) -> list:
|
||||
positions = []
|
||||
h, w = coef_array.shape
|
||||
for row in range(h):
|
||||
for col in range(w):
|
||||
if (row % BLOCK_SIZE == 0) and (col % BLOCK_SIZE == 0):
|
||||
continue
|
||||
if abs(coef_array[row, col]) >= JPEGIO_MIN_COEF_MAGNITUDE:
|
||||
if abs(coef_array[row, col]) >= JPEGLIB_MIN_COEF_MAGNITUDE:
|
||||
positions.append((row, col))
|
||||
return positions
|
||||
|
||||
|
||||
def _jpegio_generate_order(num_positions: int, seed: bytes) -> list:
|
||||
def _jpeglib_generate_order(num_positions: int, seed: bytes) -> list:
|
||||
hash_bytes = hashlib.sha256(seed + b"jpeg_coef_order").digest()
|
||||
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], "big"))
|
||||
order = list(range(num_positions))
|
||||
@@ -689,15 +689,15 @@ def _jpegio_generate_order(num_positions: int, seed: bytes) -> list:
|
||||
return order
|
||||
|
||||
|
||||
def _jpegio_create_header(data_length: int, flags: int = 0) -> bytes:
|
||||
return struct.pack(">4sBBI", JPEGIO_MAGIC, 1, flags, data_length)
|
||||
def _jpeglib_create_header(data_length: int, flags: int = 0) -> bytes:
|
||||
return struct.pack(">4sBBI", JPEGLIB_MAGIC, 1, flags, data_length)
|
||||
|
||||
|
||||
def _jpegio_parse_header(header_bytes: bytes) -> tuple[int, int, int]:
|
||||
def _jpeglib_parse_header(header_bytes: bytes) -> tuple[int, int, int]:
|
||||
if len(header_bytes) < HEADER_SIZE:
|
||||
raise ValueError("Insufficient header data")
|
||||
magic, version, flags, length = struct.unpack(">4sBBI", header_bytes[:HEADER_SIZE])
|
||||
if magic != JPEGIO_MAGIC:
|
||||
if magic != JPEGLIB_MAGIC:
|
||||
raise InvalidMagicBytesError("Not a Stegasoo JPEG or wrong mode")
|
||||
return version, flags, length
|
||||
|
||||
@@ -781,7 +781,7 @@ def estimate_capacity_comparison(image_data: bytes) -> dict:
|
||||
"available": HAS_SCIPY,
|
||||
},
|
||||
"jpeg_native": {
|
||||
"available": HAS_JPEGIO,
|
||||
"available": HAS_JPEGLIB,
|
||||
"note": "Uses jpeglib for proper JPEG coefficient embedding",
|
||||
},
|
||||
}
|
||||
@@ -794,24 +794,54 @@ def embed_in_dct(
|
||||
output_format: str = OUTPUT_FORMAT_PNG,
|
||||
color_mode: str = "color",
|
||||
progress_file: str | None = None,
|
||||
quant_step: int | None = None,
|
||||
jpeg_quality: int | None = None,
|
||||
max_dimension: int | None = None,
|
||||
) -> tuple[bytes, DCTEmbedStats]:
|
||||
"""Embed data using DCT coefficient modification."""
|
||||
"""Embed data using DCT coefficient modification.
|
||||
|
||||
Args:
|
||||
data: Payload bytes to embed.
|
||||
carrier_image: Carrier image bytes.
|
||||
seed: Key for block selection.
|
||||
output_format: 'png' or 'jpeg'.
|
||||
color_mode: 'color' or 'grayscale'.
|
||||
progress_file: Optional progress file.
|
||||
quant_step: Override QIM quantization step (default: QUANT_STEP).
|
||||
Higher = more robust to recompression, more visible.
|
||||
jpeg_quality: Override JPEG output quality (default: JPEG_OUTPUT_QUALITY).
|
||||
max_dimension: Resize carrier if larger than this.
|
||||
"""
|
||||
if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG):
|
||||
raise ValueError(f"Invalid output format: {output_format}")
|
||||
|
||||
if color_mode not in ("color", "grayscale"):
|
||||
color_mode = "color"
|
||||
|
||||
qs = quant_step if quant_step is not None else QUANT_STEP
|
||||
|
||||
# Apply EXIF orientation to carrier image before embedding
|
||||
# This ensures portrait photos are embedded in their correct visual orientation
|
||||
carrier_image = _apply_exif_orientation(carrier_image)
|
||||
|
||||
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
|
||||
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
|
||||
# Resize if max_dimension specified (for platform presets)
|
||||
if max_dimension is not None:
|
||||
img_check = Image.open(io.BytesIO(carrier_image))
|
||||
w, h = img_check.size
|
||||
if max(w, h) > max_dimension:
|
||||
scale = max_dimension / max(w, h)
|
||||
new_size = (int(w * scale), int(h * scale))
|
||||
img_check = img_check.resize(new_size, Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img_check.save(buf, format="PNG")
|
||||
carrier_image = buf.getvalue()
|
||||
img_check.close()
|
||||
|
||||
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGLIB:
|
||||
return _embed_jpeglib(data, carrier_image, seed, color_mode, progress_file)
|
||||
|
||||
_check_scipy()
|
||||
return _embed_scipy_dct_safe(
|
||||
data, carrier_image, seed, output_format, color_mode, progress_file
|
||||
data, carrier_image, seed, output_format, color_mode, progress_file, quant_step=qs
|
||||
)
|
||||
|
||||
|
||||
@@ -822,6 +852,7 @@ def _embed_scipy_dct_safe(
|
||||
output_format: str,
|
||||
color_mode: str = "color",
|
||||
progress_file: str | None = None,
|
||||
quant_step: int = QUANT_STEP,
|
||||
) -> tuple[bytes, DCTEmbedStats]:
|
||||
"""
|
||||
Embed using scipy DCT with safe memory handling.
|
||||
@@ -884,7 +915,9 @@ def _embed_scipy_dct_safe(
|
||||
gc.collect()
|
||||
|
||||
# Embed in Y channel
|
||||
Y_embedded = _embed_in_channel_safe(Y_padded, bits, block_order, blocks_x, progress_file)
|
||||
Y_embedded = _embed_in_channel_safe(
|
||||
Y_padded, bits, block_order, blocks_x, progress_file, quant_step=quant_step
|
||||
)
|
||||
del Y_padded
|
||||
gc.collect()
|
||||
|
||||
@@ -908,7 +941,9 @@ def _embed_scipy_dct_safe(
|
||||
del image
|
||||
gc.collect()
|
||||
|
||||
embedded = _embed_in_channel_safe(padded, bits, block_order, blocks_x, progress_file)
|
||||
embedded = _embed_in_channel_safe(
|
||||
padded, bits, block_order, blocks_x, progress_file, quant_step=quant_step
|
||||
)
|
||||
del padded
|
||||
gc.collect()
|
||||
|
||||
@@ -942,6 +977,7 @@ def _embed_in_channel_safe(
|
||||
block_order: list,
|
||||
blocks_x: int,
|
||||
progress_file: str | None = None,
|
||||
quant_step: int = QUANT_STEP,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Embed bits in channel using vectorized DCT operations.
|
||||
@@ -1004,16 +1040,17 @@ def _embed_in_channel_safe(
|
||||
coeffs = dct_blocks[i, embed_rows, embed_cols]
|
||||
bit_array = np.array(block_bits)
|
||||
# QIM embedding: round to grid, adjust for bit
|
||||
quantized = np.round(coeffs / QUANT_STEP).astype(int)
|
||||
quantized = np.round(coeffs / quant_step).astype(int)
|
||||
# If quantized % 2 != bit, nudge coefficient
|
||||
needs_adjust = (quantized % 2) != bit_array
|
||||
# Determine direction to nudge
|
||||
dct_blocks[i, embed_rows[needs_adjust], embed_cols[needs_adjust]] = (
|
||||
(quantized[needs_adjust] + (1 - 2 * (quantized[needs_adjust] % 2 == 1))) * QUANT_STEP
|
||||
(quantized[needs_adjust] + (1 - 2 * (quantized[needs_adjust] % 2 == 1)))
|
||||
* quant_step
|
||||
).astype(np.float64)
|
||||
# For bits that already match, just quantize
|
||||
dct_blocks[i, embed_rows[~needs_adjust], embed_cols[~needs_adjust]] = (
|
||||
quantized[~needs_adjust] * QUANT_STEP
|
||||
quantized[~needs_adjust] * quant_step
|
||||
).astype(np.float64)
|
||||
else:
|
||||
# Partial block - process remaining bits individually
|
||||
@@ -1050,12 +1087,12 @@ def _embed_in_channel_safe(
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_jpeg_for_jpegio(image_data: bytes) -> bytes:
|
||||
def _normalize_jpeg_for_jpeglib(image_data: bytes) -> bytes:
|
||||
"""
|
||||
Normalize a JPEG image to ensure jpegio can process it safely.
|
||||
Normalize a JPEG image to ensure jpeglib can process it safely.
|
||||
|
||||
JPEGs saved with quality=100 have quantization tables with all values = 1,
|
||||
which causes jpegio to crash due to huge coefficient magnitudes.
|
||||
which causes jpeglib to crash due to huge coefficient magnitudes.
|
||||
This function detects such images and re-saves them at a safe quality level.
|
||||
|
||||
Args:
|
||||
@@ -1076,7 +1113,7 @@ def _normalize_jpeg_for_jpegio(image_data: bytes) -> bytes:
|
||||
if hasattr(img, "quantization") and img.quantization:
|
||||
for table_id, table in img.quantization.items():
|
||||
# If all values in any table are <= threshold, normalize
|
||||
if max(table) <= JPEGIO_MAX_QUANT_VALUE_THRESHOLD:
|
||||
if max(table) <= JPEGLIB_MAX_QUANT_VALUE_THRESHOLD:
|
||||
needs_normalization = True
|
||||
break
|
||||
|
||||
@@ -1089,25 +1126,25 @@ def _normalize_jpeg_for_jpegio(image_data: bytes) -> bytes:
|
||||
img = img.convert("RGB")
|
||||
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="JPEG", quality=JPEGIO_NORMALIZE_QUALITY, subsampling=0)
|
||||
img.save(buffer, format="JPEG", quality=JPEGLIB_NORMALIZE_QUALITY, subsampling=0)
|
||||
img.close()
|
||||
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _embed_jpegio(
|
||||
def _embed_jpeglib(
|
||||
data: bytes,
|
||||
carrier_image: bytes,
|
||||
seed: bytes,
|
||||
color_mode: str = "color",
|
||||
progress_file: str | None = None,
|
||||
) -> tuple[bytes, DCTEmbedStats]:
|
||||
"""Embed using jpegio for proper JPEG coefficient modification."""
|
||||
"""Embed using jpeglib for proper JPEG coefficient modification."""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
# Normalize JPEG to avoid crashes with quality=100 images
|
||||
carrier_image = _normalize_jpeg_for_jpegio(carrier_image)
|
||||
carrier_image = _normalize_jpeg_for_jpeglib(carrier_image)
|
||||
|
||||
img = Image.open(io.BytesIO(carrier_image))
|
||||
width, height = img.size
|
||||
@@ -1120,20 +1157,20 @@ def _embed_jpegio(
|
||||
carrier_image = buffer.getvalue()
|
||||
img.close()
|
||||
|
||||
input_path = _jpegio_bytes_to_file(carrier_image, suffix=".jpg")
|
||||
input_path = _jpeglib_bytes_to_file(carrier_image, suffix=".jpg")
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
|
||||
flags = FLAG_COLOR_MODE if color_mode == "color" else 0
|
||||
|
||||
try:
|
||||
jpeg = jpeglib.to_jpegio(jpeglib.read_dct(input_path))
|
||||
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
|
||||
coef_array = jpeg.coef_arrays[JPEGLIB_EMBED_CHANNEL]
|
||||
|
||||
all_positions = _jpegio_get_usable_positions(coef_array)
|
||||
order = _jpegio_generate_order(len(all_positions), seed)
|
||||
all_positions = _jpeglib_get_usable_positions(coef_array)
|
||||
order = _jpeglib_generate_order(len(all_positions), seed)
|
||||
|
||||
# Build raw payload (header + data)
|
||||
header = _jpegio_create_header(len(data), flags)
|
||||
header = _jpeglib_create_header(len(data), flags)
|
||||
raw_payload = header + data
|
||||
|
||||
# Apply Reed-Solomon error correction to entire payload if available
|
||||
@@ -1219,6 +1256,7 @@ def _embed_jpegio(
|
||||
def _jpegtran_available() -> bool:
|
||||
"""Check if jpegtran is available on the system."""
|
||||
import shutil
|
||||
|
||||
return shutil.which("jpegtran") is not None
|
||||
|
||||
|
||||
@@ -1237,9 +1275,9 @@ def _jpegtran_rotate(image_data: bytes, rotation: int) -> bytes:
|
||||
Returns:
|
||||
Rotated JPEG bytes with DCT coefficients preserved
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
if rotation not in (90, 180, 270):
|
||||
raise ValueError(f"Invalid rotation: {rotation}")
|
||||
@@ -1257,10 +1295,18 @@ def _jpegtran_rotate(image_data: bytes, rotation: int) -> bytes:
|
||||
# NOTE: Don't use -trim as it drops edge blocks and destroys stego data
|
||||
# NOTE: Don't use -perfect as it fails on images with non-MCU-aligned edges
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-rotate", str(rotation), "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
[
|
||||
"jpegtran",
|
||||
"-rotate",
|
||||
str(rotation),
|
||||
"-copy",
|
||||
"all",
|
||||
"-outfile",
|
||||
output_path,
|
||||
input_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
@@ -1367,6 +1413,7 @@ def _quick_validate_dct_header(image_data: bytes, seed: bytes) -> bool:
|
||||
copies.append(length_prefix_bytes[start:end])
|
||||
|
||||
from collections import Counter
|
||||
|
||||
counter = Counter(copies)
|
||||
_, count = counter.most_common(1)[0]
|
||||
|
||||
@@ -1390,6 +1437,7 @@ def extract_from_dct(
|
||||
stego_image: bytes,
|
||||
seed: bytes,
|
||||
progress_file: str | None = None,
|
||||
quant_step: int | None = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Extract data from DCT stego image.
|
||||
@@ -1400,6 +1448,7 @@ def extract_from_dct(
|
||||
|
||||
Uses quick header validation to skip obviously invalid rotations.
|
||||
"""
|
||||
qs = quant_step if quant_step is not None else QUANT_STEP
|
||||
rotations_to_try = [0, 90, 180, 270]
|
||||
last_error = None
|
||||
valid_rotations = []
|
||||
@@ -1417,7 +1466,7 @@ def extract_from_dct(
|
||||
# If no rotations pass quick check, try all anyway (fallback)
|
||||
if not valid_rotations:
|
||||
# Must try all rotations - quick validation might have failed due to
|
||||
# scipy vs jpegio differences or other edge cases
|
||||
# scipy vs jpeglib differences or other edge cases
|
||||
for rotation in rotations_to_try:
|
||||
if rotation == 0:
|
||||
valid_rotations.append((0, stego_image))
|
||||
@@ -1431,12 +1480,13 @@ def extract_from_dct(
|
||||
fmt = img.format
|
||||
img.close()
|
||||
|
||||
if fmt == "JPEG" and HAS_JPEGIO:
|
||||
if fmt == "JPEG" and HAS_JPEGLIB:
|
||||
try:
|
||||
result = _extract_jpegio(image_to_decode, seed, progress_file)
|
||||
result = _extract_jpeglib(image_to_decode, seed, progress_file)
|
||||
if rotation != 0:
|
||||
try:
|
||||
from . import debug
|
||||
|
||||
debug.print(f"DCT decode succeeded after {rotation}° rotation")
|
||||
except Exception:
|
||||
pass # Don't let debug logging break extraction
|
||||
@@ -1446,10 +1496,11 @@ def extract_from_dct(
|
||||
continue
|
||||
|
||||
_check_scipy()
|
||||
result = _extract_scipy_dct_safe(image_to_decode, seed, progress_file)
|
||||
result = _extract_scipy_dct_safe(image_to_decode, seed, progress_file, quant_step=qs)
|
||||
if rotation != 0:
|
||||
try:
|
||||
from . import debug
|
||||
|
||||
debug.print(f"DCT decode succeeded after {rotation}° rotation")
|
||||
except Exception:
|
||||
pass # Don't let debug logging break extraction
|
||||
@@ -1467,6 +1518,7 @@ def _extract_scipy_dct_safe(
|
||||
stego_image: bytes,
|
||||
seed: bytes,
|
||||
progress_file: str | None = None,
|
||||
quant_step: int = QUANT_STEP,
|
||||
) -> bytes:
|
||||
"""Extract using safe DCT operations with vectorized processing."""
|
||||
# Progress starts at 25% (decode.py writes 20% for Argon2, 25% before extraction)
|
||||
@@ -1528,7 +1580,7 @@ def _extract_scipy_dct_safe(
|
||||
coeffs = dct_blocks[:, embed_rows, embed_cols]
|
||||
|
||||
# Quantize and extract bits (vectorized)
|
||||
quantized = np.round(coeffs / QUANT_STEP).astype(int)
|
||||
quantized = np.round(coeffs / quant_step).astype(int)
|
||||
bits = (quantized % 2).flatten().tolist()
|
||||
all_bits.extend(bits)
|
||||
|
||||
@@ -1646,28 +1698,28 @@ def _extract_scipy_dct_safe(
|
||||
return data
|
||||
|
||||
|
||||
def _extract_jpegio(
|
||||
def _extract_jpeglib(
|
||||
stego_image: bytes,
|
||||
seed: bytes,
|
||||
progress_file: str | None = None,
|
||||
) -> bytes:
|
||||
"""Extract using jpegio for JPEG images."""
|
||||
"""Extract using jpeglib for JPEG images."""
|
||||
import os
|
||||
|
||||
# Progress starts at 25% (decode.py writes 20% for Argon2, 25% before extraction)
|
||||
|
||||
# Normalize JPEG to avoid crashes with quality=100 images
|
||||
# (shouldn't happen with stego images, but be defensive)
|
||||
stego_image = _normalize_jpeg_for_jpegio(stego_image)
|
||||
stego_image = _normalize_jpeg_for_jpeglib(stego_image)
|
||||
|
||||
temp_path = _jpegio_bytes_to_file(stego_image, suffix=".jpg")
|
||||
temp_path = _jpeglib_bytes_to_file(stego_image, suffix=".jpg")
|
||||
|
||||
try:
|
||||
jpeg = jpeglib.to_jpegio(jpeglib.read_dct(temp_path))
|
||||
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
|
||||
coef_array = jpeg.coef_arrays[JPEGLIB_EMBED_CHANNEL]
|
||||
|
||||
all_positions = _jpegio_get_usable_positions(coef_array)
|
||||
order = _jpegio_generate_order(len(all_positions), seed)
|
||||
all_positions = _jpeglib_get_usable_positions(coef_array)
|
||||
order = _jpeglib_generate_order(len(all_positions), seed)
|
||||
|
||||
_write_progress(progress_file, 30, 100, "extracting")
|
||||
|
||||
@@ -1737,7 +1789,7 @@ def _extract_jpegio(
|
||||
_write_progress(progress_file, 75, 100, "decoding")
|
||||
raw_payload = _rs_decode(rs_encoded)
|
||||
_write_progress(progress_file, 95, 100, "decoding")
|
||||
_, flags, data_length = _jpegio_parse_header(raw_payload[:HEADER_SIZE])
|
||||
_, flags, data_length = _jpeglib_parse_header(raw_payload[:HEADER_SIZE])
|
||||
data = raw_payload[HEADER_SIZE : HEADER_SIZE + data_length]
|
||||
_write_progress(progress_file, 100, 100, "complete")
|
||||
return data
|
||||
@@ -1758,7 +1810,7 @@ def _extract_jpegio(
|
||||
]
|
||||
)
|
||||
|
||||
_, flags, data_length = _jpegio_parse_header(header_bytes)
|
||||
_, flags, data_length = _jpeglib_parse_header(header_bytes)
|
||||
total_bits_needed = (HEADER_SIZE + data_length) * 8
|
||||
|
||||
all_bits = []
|
||||
|
||||
@@ -2,27 +2,96 @@
|
||||
Stegasoo Debugging Utilities
|
||||
|
||||
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 time
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
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
|
||||
DEBUG_ENABLED = False # Set to True to enable debug output
|
||||
LOG_PERFORMANCE = True # Log function timing
|
||||
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:
|
||||
"""Enable or disable debug mode globally."""
|
||||
global DEBUG_ENABLED
|
||||
DEBUG_ENABLED = enable
|
||||
if enable:
|
||||
_setup_logging(logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(logging.WARNING)
|
||||
|
||||
|
||||
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:
|
||||
"""Print debug message with timestamp if debugging is enabled."""
|
||||
if DEBUG_ENABLED:
|
||||
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
print(f"[{timestamp}] [{level}] {message}", file=sys.stderr)
|
||||
"""Log a message at the given level via the stegasoo logger."""
|
||||
log_level = _LEVEL_MAP.get(level.upper(), logging.DEBUG)
|
||||
logger.log(log_level, message)
|
||||
|
||||
|
||||
def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str:
|
||||
"""Format bytes for debugging."""
|
||||
if not DEBUG_ENABLED:
|
||||
if not logger.isEnabledFor(logging.DEBUG):
|
||||
return ""
|
||||
|
||||
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:
|
||||
return f"{label} ({len(data)} bytes): {data.hex()}"
|
||||
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:
|
||||
"""Log exception with context for debugging."""
|
||||
if DEBUG_ENABLED:
|
||||
debug_print(f"Exception in {context}: {type(e).__name__}: {e}", "ERROR")
|
||||
if DEBUG_ENABLED:
|
||||
traceback.print_exc()
|
||||
logger.error("Exception in %s: %s: %s", context, type(e).__name__, e)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(traceback.format_exc())
|
||||
|
||||
|
||||
def time_function(func: Callable) -> Callable:
|
||||
@@ -71,7 +141,7 @@ def time_function(func: Callable) -> Callable:
|
||||
|
||||
@wraps(func)
|
||||
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)
|
||||
|
||||
start = time.perf_counter()
|
||||
@@ -80,7 +150,7 @@ def time_function(func: Callable) -> Callable:
|
||||
return result
|
||||
finally:
|
||||
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
|
||||
|
||||
@@ -94,8 +164,6 @@ def validate_assertion(condition: bool, message: str) -> None:
|
||||
def memory_usage() -> dict[str, float | str]:
|
||||
"""Get current memory usage (if psutil is available)."""
|
||||
try:
|
||||
import os
|
||||
|
||||
import psutil
|
||||
|
||||
process = psutil.Process(os.getpid())
|
||||
@@ -131,8 +199,19 @@ def hexdump(data: bytes, offset: int = 0, length: int = 64) -> str:
|
||||
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:
|
||||
"""Debugging utility class."""
|
||||
"""Debugging utility class (backward-compatible API)."""
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = DEBUG_ENABLED
|
||||
|
||||
@@ -31,12 +31,15 @@ def _write_progress(progress_file: str | None, current: int, total: int, phase:
|
||||
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)
|
||||
json.dump(
|
||||
{
|
||||
"current": current,
|
||||
"total": total,
|
||||
"percent": (current / total * 100) if total > 0 else 0,
|
||||
"phase": phase,
|
||||
},
|
||||
f,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -51,6 +54,7 @@ def decode(
|
||||
embed_mode: str = EMBED_MODE_AUTO,
|
||||
channel_key: str | bool | None = None,
|
||||
progress_file: str | None = None,
|
||||
platform: str | None = None,
|
||||
) -> DecodeResult:
|
||||
"""
|
||||
Decode a message or file from a stego image.
|
||||
@@ -121,12 +125,21 @@ def decode(
|
||||
# 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
|
||||
encrypted = extract_from_image(
|
||||
stego_image,
|
||||
pixel_key,
|
||||
embed_mode=embed_mode,
|
||||
progress_file=progress_file,
|
||||
**extract_kwargs,
|
||||
)
|
||||
|
||||
if not encrypted:
|
||||
@@ -261,3 +274,219 @@ def decode_text(
|
||||
return ""
|
||||
|
||||
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:
|
||||
- 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 typing import TYPE_CHECKING
|
||||
|
||||
from .constants import EMBED_MODE_LSB
|
||||
from .crypto import derive_pixel_key, encrypt_message
|
||||
from .debug import debug
|
||||
from .exceptions import AudioError, VideoError
|
||||
from .models import EncodeResult, FilePayload
|
||||
from .steganography import embed_in_image
|
||||
from .utils import generate_filename
|
||||
@@ -23,6 +33,9 @@ from .validation import (
|
||||
require_valid_rsa_key,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import AudioEmbedStats, VideoEmbedStats
|
||||
|
||||
|
||||
def encode(
|
||||
message: str | bytes | FilePayload,
|
||||
@@ -38,6 +51,7 @@ def encode(
|
||||
dct_color_mode: str = "color",
|
||||
channel_key: str | bool | None = None,
|
||||
progress_file: str | None = None,
|
||||
platform: str | None = None,
|
||||
) -> EncodeResult:
|
||||
"""
|
||||
Encode a message or file into an image.
|
||||
@@ -110,6 +124,18 @@ def encode(
|
||||
# Derive pixel/coefficient selection key (with 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
|
||||
stego_data, stats, extension = embed_in_image(
|
||||
encrypted,
|
||||
@@ -120,6 +146,7 @@ def encode(
|
||||
dct_output_format=dct_output_format,
|
||||
dct_color_mode=dct_color_mode,
|
||||
progress_file=progress_file,
|
||||
**platform_kwargs,
|
||||
)
|
||||
|
||||
# Generate filename
|
||||
@@ -258,3 +285,206 @@ def encode_bytes(
|
||||
dct_color_mode=dct_color_mode,
|
||||
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 (compact padding for audio's limited capacity)
|
||||
encrypted = encrypt_message(
|
||||
message, reference_photo, passphrase, pin, rsa_key_data, channel_key,
|
||||
compact=True,
|
||||
)
|
||||
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_CHIP_TIER_LOSSLESS,
|
||||
AUDIO_SS_DEFAULT_CHIP_TIER,
|
||||
LOSSLESS_AUDIO_FORMATS,
|
||||
)
|
||||
from .spread_steganography import embed_in_audio_spread
|
||||
|
||||
if chip_tier is not None:
|
||||
tier = chip_tier
|
||||
elif audio_format in LOSSLESS_AUDIO_FORMATS:
|
||||
tier = AUDIO_SS_CHIP_TIER_LOSSLESS
|
||||
debug.print(f"Auto-selected chip tier 0 (lossless) for {audio_format} carrier")
|
||||
else:
|
||||
tier = AUDIO_SS_DEFAULT_CHIP_TIER
|
||||
debug.print(f"Auto-selected chip tier {tier} (lossy) for {audio_format} carrier")
|
||||
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__(
|
||||
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
|
||||
|
||||
@@ -281,3 +281,111 @@ class GenerateResult:
|
||||
lines.append(f" RSA Key: {len(self.rsa_key_pem)} bytes PEM")
|
||||
lines.append(f" Total Entropy: {self.total_entropy} bits")
|
||||
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
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
|
||||
@@ -105,14 +105,14 @@ def decompress_data(data: str) -> str:
|
||||
"Data compressed with zstd but zstandard package not installed. "
|
||||
"Run: pip install zstandard"
|
||||
)
|
||||
encoded = data[len(COMPRESSION_PREFIX_ZSTD):]
|
||||
encoded = data[len(COMPRESSION_PREFIX_ZSTD) :]
|
||||
compressed = base64.b64decode(encoded)
|
||||
dctx = zstd.ZstdDecompressor()
|
||||
return dctx.decompress(compressed).decode("utf-8")
|
||||
|
||||
elif data.startswith(COMPRESSION_PREFIX_ZLIB):
|
||||
# Legacy zlib compression
|
||||
encoded = data[len(COMPRESSION_PREFIX_ZLIB):]
|
||||
encoded = data[len(COMPRESSION_PREFIX_ZLIB) :]
|
||||
compressed = base64.b64decode(encoded)
|
||||
return zlib.decompress(compressed).decode("utf-8")
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ _RECOVERY_STEGO_PASSPHRASE = "stegasoo-recovery-v1"
|
||||
_RECOVERY_STEGO_PIN = "314159" # Pi digits - fixed, not secret
|
||||
|
||||
# Size limits for carrier image
|
||||
STEGO_BACKUP_MIN_SIZE = 50 * 1024 # 50 KB
|
||||
STEGO_BACKUP_MIN_SIZE = 50 * 1024 # 50 KB
|
||||
STEGO_BACKUP_MAX_SIZE = 2 * 1024 * 1024 # 2 MB
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ def extract_stego_backup(
|
||||
debug.print(f"Stego backup extraction failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Recovery key format: same as channel key (32 chars, 8 groups of 4)
|
||||
RECOVERY_KEY_LENGTH = 32
|
||||
RECOVERY_KEY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
@@ -205,16 +206,10 @@ def generate_recovery_key() -> str:
|
||||
7
|
||||
"""
|
||||
# Generate 32 random alphanumeric characters
|
||||
raw_key = "".join(
|
||||
secrets.choice(RECOVERY_KEY_ALPHABET)
|
||||
for _ in range(RECOVERY_KEY_LENGTH)
|
||||
)
|
||||
raw_key = "".join(secrets.choice(RECOVERY_KEY_ALPHABET) for _ in range(RECOVERY_KEY_LENGTH))
|
||||
|
||||
# Format with dashes every 4 characters
|
||||
formatted = "-".join(
|
||||
raw_key[i:i + 4]
|
||||
for i in range(0, RECOVERY_KEY_LENGTH, 4)
|
||||
)
|
||||
formatted = "-".join(raw_key[i : i + 4] for i in range(0, RECOVERY_KEY_LENGTH, 4))
|
||||
|
||||
debug.print(f"Generated recovery key: {formatted[:4]}-••••-...-{formatted[-4:]}")
|
||||
return formatted
|
||||
@@ -245,15 +240,12 @@ def normalize_recovery_key(key: str) -> str:
|
||||
# Validate length
|
||||
if len(clean) != RECOVERY_KEY_LENGTH:
|
||||
raise ValueError(
|
||||
f"Recovery key must be {RECOVERY_KEY_LENGTH} characters "
|
||||
f"(got {len(clean)})"
|
||||
f"Recovery key must be {RECOVERY_KEY_LENGTH} characters " f"(got {len(clean)})"
|
||||
)
|
||||
|
||||
# Validate characters
|
||||
if not all(c in RECOVERY_KEY_ALPHABET for c in clean):
|
||||
raise ValueError(
|
||||
"Recovery key must contain only letters A-Z and digits 0-9"
|
||||
)
|
||||
raise ValueError("Recovery key must contain only letters A-Z and digits 0-9")
|
||||
|
||||
return clean
|
||||
|
||||
@@ -273,7 +265,7 @@ def format_recovery_key(key: str) -> str:
|
||||
"ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
|
||||
"""
|
||||
clean = normalize_recovery_key(key)
|
||||
return "-".join(clean[i:i + 4] for i in range(0, RECOVERY_KEY_LENGTH, 4))
|
||||
return "-".join(clean[i : i + 4] for i in range(0, RECOVERY_KEY_LENGTH, 4))
|
||||
|
||||
|
||||
def hash_recovery_key(key: str) -> str:
|
||||
|
||||
1111
src/stegasoo/spread_steganography.py
Normal file
1111
src/stegasoo/spread_steganography.py
Normal file
File diff suppressed because it is too large
Load Diff
281
src/stegasoo/steganalysis.py
Normal file
281
src/stegasoo/steganalysis.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Steganalysis Self-Check Module (v4.4.0)
|
||||
|
||||
Statistical analysis to estimate detectability risk of stego images.
|
||||
Runs chi-square and RS (Regular-Singular) analysis on pixel data
|
||||
to assess how visible the embedding is to an attacker.
|
||||
|
||||
Currently LSB-only. DCT steganalysis (calibration attack) deferred.
|
||||
|
||||
Usage::
|
||||
|
||||
from stegasoo.steganalysis import check_image
|
||||
|
||||
result = check_image(image_data)
|
||||
print(result["risk"]) # "low", "medium", or "high"
|
||||
print(result["chi_square"]) # per-channel chi-square p-values
|
||||
print(result["rs"]) # per-channel RS embedding estimates
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .constants import (
|
||||
STEGANALYSIS_CHI_SUSPICIOUS_THRESHOLD,
|
||||
STEGANALYSIS_RS_HIGH_THRESHOLD,
|
||||
STEGANALYSIS_RS_MEDIUM_THRESHOLD,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SteganalysisResult:
|
||||
"""Result of steganalysis on an image."""
|
||||
|
||||
risk: str # "low", "medium", or "high"
|
||||
chi_square: dict = field(default_factory=dict) # per-channel p-values
|
||||
rs: dict = field(default_factory=dict) # per-channel embedding estimates
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
channels: int = 0
|
||||
mode: str = "lsb"
|
||||
|
||||
|
||||
def chi_square_analysis(channel_data: np.ndarray) -> float:
|
||||
"""Chi-square test on LSB distribution of a single channel.
|
||||
|
||||
Groups pixel values into pairs (2i, 2i+1) — so-called "pairs of values"
|
||||
(PoVs). In a clean image, each pair has a natural frequency ratio.
|
||||
LSB embedding with random data forces each pair toward equal frequency.
|
||||
|
||||
The test measures H0: "pairs are equalized" (consistent with embedding).
|
||||
|
||||
Args:
|
||||
channel_data: Flattened 1-D array of pixel values (uint8).
|
||||
|
||||
Returns:
|
||||
p-value from chi-square test.
|
||||
HIGH p-value (close to 1.0) → pairs are equalized → suspicious.
|
||||
LOW p-value (close to 0.0) → pairs are not equalized → less suspicious.
|
||||
"""
|
||||
from scipy.stats import chi2
|
||||
|
||||
# Count occurrences of each value 0-255
|
||||
histogram = np.bincount(channel_data.ravel(), minlength=256)
|
||||
|
||||
# Group into 128 pairs: (0,1), (2,3), ..., (254,255)
|
||||
chi_sq = 0.0
|
||||
degrees_of_freedom = 0
|
||||
|
||||
for i in range(0, 256, 2):
|
||||
observed_even = histogram[i]
|
||||
observed_odd = histogram[i + 1]
|
||||
total = observed_even + observed_odd
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
expected = total / 2.0
|
||||
chi_sq += (observed_even - expected) ** 2 / expected
|
||||
chi_sq += (observed_odd - expected) ** 2 / expected
|
||||
degrees_of_freedom += 1
|
||||
|
||||
if degrees_of_freedom == 0:
|
||||
return 1.0 # No data to analyze
|
||||
|
||||
# p-value: probability of observing this chi-square value by chance
|
||||
# Low p-value = LSBs are suspiciously uniform = likely embedded
|
||||
p_value = 1.0 - chi2.cdf(chi_sq, degrees_of_freedom)
|
||||
return float(p_value)
|
||||
|
||||
|
||||
def rs_analysis(channel_data: np.ndarray, block_size: int = 8) -> float:
|
||||
"""Regular-Singular groups analysis on a single channel.
|
||||
|
||||
Divides the image channel into groups of `block_size` pixels and measures
|
||||
the "smoothness" (variation) of each group. Applying a flipping function
|
||||
F1 (flip LSB) and F-1 (flip LSB of value-1) produces Regular (smoother)
|
||||
and Singular (rougher) groups.
|
||||
|
||||
In a clean image: R_m ≈ R_{-m} and S_m ≈ S_{-m}.
|
||||
LSB embedding causes R_m and S_{-m} to converge while S_m and R_{-m}
|
||||
diverge, allowing estimation of the embedding rate.
|
||||
|
||||
Args:
|
||||
channel_data: Flattened 1-D array of pixel values (uint8).
|
||||
block_size: Number of pixels per group (default 8).
|
||||
|
||||
Returns:
|
||||
Estimated embedding rate (0.0 = clean, 1.0 = fully embedded).
|
||||
Values > 0.5 strongly indicate LSB embedding.
|
||||
"""
|
||||
data = channel_data.ravel().astype(np.int16)
|
||||
n = len(data)
|
||||
# Trim to multiple of block_size
|
||||
n_blocks = n // block_size
|
||||
if n_blocks < 10:
|
||||
return 0.0 # Not enough data
|
||||
|
||||
data = data[: n_blocks * block_size].reshape(n_blocks, block_size)
|
||||
|
||||
def variation(block: np.ndarray) -> float:
|
||||
"""Sum of absolute differences between adjacent pixels."""
|
||||
return float(np.sum(np.abs(np.diff(block))))
|
||||
|
||||
def flip_positive(block: np.ndarray) -> np.ndarray:
|
||||
"""F1: flip LSB (0↔1, 2↔3, 4↔5, ...)."""
|
||||
return block ^ 1
|
||||
|
||||
def flip_negative(block: np.ndarray) -> np.ndarray:
|
||||
"""F-1: flip LSB of (value - 1), i.e. -1↔0, 1↔2, 3↔4, ..."""
|
||||
result = block.copy()
|
||||
even_mask = (block % 2) == 0
|
||||
result[even_mask] -= 1
|
||||
result[~even_mask] += 1
|
||||
return result
|
||||
|
||||
r_m = s_m = r_neg = s_neg = 0
|
||||
|
||||
for i in range(n_blocks):
|
||||
block = data[i]
|
||||
v_orig = variation(block)
|
||||
|
||||
v_f1 = variation(flip_positive(block))
|
||||
if v_f1 > v_orig:
|
||||
r_m += 1
|
||||
elif v_f1 < v_orig:
|
||||
s_m += 1
|
||||
|
||||
v_fn1 = variation(flip_negative(block))
|
||||
if v_fn1 > v_orig:
|
||||
r_neg += 1
|
||||
elif v_fn1 < v_orig:
|
||||
s_neg += 1
|
||||
|
||||
# Estimate embedding rate using the RS quadratic formula
|
||||
# d0 = R_m - S_m, d1 = R_{-m} - S_{-m}
|
||||
# The embedding rate p satisfies: d(p/2) = d0, d(1 - p/2) = d1
|
||||
# Simplified estimator: p ≈ (R_m - S_m) / (R_{-m} - S_{-m}) divergence
|
||||
d0 = r_m - s_m
|
||||
d1 = r_neg - s_neg
|
||||
|
||||
if n_blocks == 0:
|
||||
return 0.0
|
||||
|
||||
# Use the simplified dual-statistic estimator
|
||||
# In clean images: d0 ≈ d1 (both positive)
|
||||
# In embedded images: d0 → 0 while d1 stays positive
|
||||
if d1 == 0:
|
||||
# Can't estimate — likely very embedded or degenerate
|
||||
return 0.5 if d0 == 0 else 0.0
|
||||
|
||||
# Ratio-based estimate: how much has d0 dropped relative to d1
|
||||
ratio = d0 / d1
|
||||
if ratio >= 1.0:
|
||||
return 0.0 # d0 ≥ d1 means no evidence of embedding
|
||||
if ratio <= 0.0:
|
||||
return 1.0 # d0 collapsed or inverted
|
||||
|
||||
# Linear interpolation: ratio=1 → 0% embedded, ratio=0 → 100% embedded
|
||||
estimate = 1.0 - ratio
|
||||
return float(np.clip(estimate, 0.0, 1.0))
|
||||
|
||||
|
||||
def assess_risk(chi_p_values: dict[str, float], rs_estimates: dict[str, float]) -> str:
|
||||
"""Map analysis results to a risk level.
|
||||
|
||||
RS analysis is the primary metric (reliable for both sequential and
|
||||
random-order embedding). Chi-square is supplementary — high p-values
|
||||
indicate equalized PoV pairs, which is suspicious for random LSB embedding.
|
||||
|
||||
Args:
|
||||
chi_p_values: Per-channel chi-square p-values (high = suspicious).
|
||||
rs_estimates: Per-channel RS embedding rate estimates (high = suspicious).
|
||||
|
||||
Returns:
|
||||
"low", "medium", or "high" detectability risk.
|
||||
"""
|
||||
if not chi_p_values and not rs_estimates:
|
||||
return "low"
|
||||
|
||||
# RS is the primary indicator: any channel with high embedding estimate
|
||||
max_rs = max(rs_estimates.values()) if rs_estimates else 0.0
|
||||
|
||||
# Chi-square: high p-value means pairs are equalized (suspicious)
|
||||
max_chi_p = max(chi_p_values.values()) if chi_p_values else 0.0
|
||||
chi_suspicious = max_chi_p > STEGANALYSIS_CHI_SUSPICIOUS_THRESHOLD
|
||||
|
||||
# High risk: RS strongly indicates embedding
|
||||
if max_rs > STEGANALYSIS_RS_HIGH_THRESHOLD:
|
||||
return "high"
|
||||
|
||||
# Medium risk: moderate RS signal, or RS + chi-square both flagging
|
||||
if max_rs > STEGANALYSIS_RS_MEDIUM_THRESHOLD:
|
||||
return "medium"
|
||||
if chi_suspicious and max_rs > 0.05:
|
||||
return "medium"
|
||||
|
||||
return "low"
|
||||
|
||||
|
||||
def check_image(image_data: bytes, mode: str = "lsb") -> dict:
|
||||
"""Run steganalysis on an image and return detectability assessment.
|
||||
|
||||
Args:
|
||||
image_data: Raw image bytes (PNG, BMP, etc.).
|
||||
mode: Analysis mode — currently only "lsb" is supported.
|
||||
|
||||
Returns:
|
||||
Dict with keys: risk, chi_square, rs, width, height, channels, mode.
|
||||
"""
|
||||
if mode not in ("lsb", "auto"):
|
||||
raise ValueError(f"Unsupported steganalysis mode: {mode}. Use 'lsb' or 'auto'.")
|
||||
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
if img.mode not in ("RGB", "RGBA", "L"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
width, height = img.size
|
||||
pixels = np.array(img)
|
||||
img.close()
|
||||
|
||||
channel_names = ["R", "G", "B"] if pixels.ndim == 3 else ["L"]
|
||||
if pixels.ndim == 2:
|
||||
pixels = pixels[:, :, np.newaxis]
|
||||
|
||||
num_channels = min(pixels.shape[2], 3) # Skip alpha
|
||||
|
||||
chi_p_values = {}
|
||||
rs_estimates = {}
|
||||
|
||||
for i in range(num_channels):
|
||||
name = channel_names[i]
|
||||
channel = pixels[:, :, i].ravel()
|
||||
chi_p_values[name] = chi_square_analysis(channel)
|
||||
rs_estimates[name] = rs_analysis(channel)
|
||||
|
||||
risk = assess_risk(chi_p_values, rs_estimates)
|
||||
|
||||
result = SteganalysisResult(
|
||||
risk=risk,
|
||||
chi_square=chi_p_values,
|
||||
rs=rs_estimates,
|
||||
width=width,
|
||||
height=height,
|
||||
channels=num_channels,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
return {
|
||||
"risk": result.risk,
|
||||
"chi_square": result.chi_square,
|
||||
"rs": result.rs,
|
||||
"width": result.width,
|
||||
"height": result.height,
|
||||
"channels": result.channels,
|
||||
"mode": result.mode,
|
||||
}
|
||||
@@ -107,13 +107,14 @@ EXT_TO_FORMAT = {
|
||||
# - v3.1.0: 76 bytes (had date field - 10+1 bytes)
|
||||
# - v3.2.0: 65 bytes (removed date, simpler)
|
||||
# - v4.0.0: 66 bytes (added flags byte for channel key)
|
||||
# - v4.4.0: 82 bytes (added 16-byte message nonce for HKDF)
|
||||
|
||||
HEADER_OVERHEAD = 66 # What the crypto layer adds to any message
|
||||
LENGTH_PREFIX = 4 # We prepend the payload length for LSB extraction
|
||||
ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX # Total: 70 bytes
|
||||
HEADER_OVERHEAD = 82 # What the crypto layer adds to any message (v6 format)
|
||||
LENGTH_PREFIX = 4 # We prepend the payload length for LSB extraction
|
||||
ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX # Total: 86 bytes
|
||||
|
||||
# That 70 bytes is your minimum image capacity requirement.
|
||||
# A tiny 100x100 image gives you ~3750 bytes capacity, minus 70 = ~3680 usable.
|
||||
# That 86 bytes is your minimum image capacity requirement.
|
||||
# A tiny 100x100 image gives you ~3750 bytes capacity, minus 86 = ~3664 usable.
|
||||
|
||||
# DCT output format options (v3.0.1)
|
||||
DCT_OUTPUT_PNG = "png"
|
||||
@@ -609,6 +610,9 @@ def embed_in_image(
|
||||
dct_output_format: str = DCT_OUTPUT_PNG,
|
||||
dct_color_mode: str = "color",
|
||||
progress_file: str | None = None,
|
||||
quant_step: int | None = None,
|
||||
jpeg_quality: int | None = None,
|
||||
max_dimension: int | None = None,
|
||||
) -> tuple[bytes, Union[EmbedStats, "DCTEmbedStats"], str]:
|
||||
"""
|
||||
Embed data into an image using specified mode.
|
||||
@@ -636,49 +640,54 @@ def embed_in_image(
|
||||
embed_mode in VALID_EMBED_MODES, f"Invalid embed_mode: {embed_mode}. Use 'lsb' or 'dct'"
|
||||
)
|
||||
|
||||
# DCT MODE
|
||||
if embed_mode == EMBED_MODE_DCT:
|
||||
if not has_dct_support():
|
||||
raise ImportError(
|
||||
"scipy is required for DCT embedding mode. " "Install with: pip install scipy"
|
||||
)
|
||||
# Dispatch via backend registry
|
||||
from .backends import registry
|
||||
|
||||
# Validate DCT output format
|
||||
backend = registry.get(embed_mode)
|
||||
if not backend.is_available():
|
||||
raise ImportError(
|
||||
f"Dependencies for '{embed_mode}' mode are not installed. "
|
||||
f"Install with: pip install stegasoo[dct]"
|
||||
)
|
||||
|
||||
if embed_mode == EMBED_MODE_DCT:
|
||||
# Validate DCT-specific options
|
||||
if dct_output_format not in (DCT_OUTPUT_PNG, DCT_OUTPUT_JPEG):
|
||||
debug.print(f"Invalid dct_output_format '{dct_output_format}', defaulting to PNG")
|
||||
dct_output_format = DCT_OUTPUT_PNG
|
||||
|
||||
# Validate DCT color mode (v3.0.1)
|
||||
if dct_color_mode not in ("grayscale", "color"):
|
||||
debug.print(f"Invalid dct_color_mode '{dct_color_mode}', defaulting to color")
|
||||
dct_color_mode = "color"
|
||||
|
||||
dct_mod = _get_dct_module()
|
||||
|
||||
# Pass output_format and color_mode to DCT module (v3.0.1)
|
||||
stego_bytes, dct_stats = dct_mod.embed_in_dct(
|
||||
stego_bytes, dct_stats = backend.embed(
|
||||
data,
|
||||
image_data,
|
||||
pixel_key,
|
||||
output_format=dct_output_format,
|
||||
color_mode=dct_color_mode,
|
||||
progress_file=progress_file,
|
||||
dct_output_format=dct_output_format,
|
||||
dct_color_mode=dct_color_mode,
|
||||
quant_step=quant_step,
|
||||
jpeg_quality=jpeg_quality,
|
||||
max_dimension=max_dimension,
|
||||
)
|
||||
|
||||
# Determine extension based on output format
|
||||
if dct_output_format == DCT_OUTPUT_JPEG:
|
||||
ext = "jpg"
|
||||
else:
|
||||
ext = "png"
|
||||
|
||||
ext = "jpg" if dct_output_format == DCT_OUTPUT_JPEG else "png"
|
||||
debug.print(
|
||||
f"DCT embedding complete: {dct_output_format.upper()} output, "
|
||||
f"color_mode={dct_color_mode}, ext={ext}"
|
||||
)
|
||||
return stego_bytes, dct_stats, ext
|
||||
|
||||
# LSB MODE
|
||||
return _embed_lsb(data, image_data, pixel_key, bits_per_channel, output_format, progress_file)
|
||||
# LSB and other image backends
|
||||
stego_bytes, stats = backend.embed(
|
||||
data,
|
||||
image_data,
|
||||
pixel_key,
|
||||
progress_file=progress_file,
|
||||
bits_per_channel=bits_per_channel,
|
||||
output_format=output_format,
|
||||
)
|
||||
ext = getattr(stats, "output_extension", "png")
|
||||
return stego_bytes, stats, ext
|
||||
|
||||
|
||||
def _embed_lsb(
|
||||
@@ -844,6 +853,7 @@ def extract_from_image(
|
||||
bits_per_channel: int = 1,
|
||||
embed_mode: str = EMBED_MODE_AUTO,
|
||||
progress_file: str | None = None,
|
||||
quant_step: int | None = None,
|
||||
) -> bytes | None:
|
||||
"""
|
||||
Extract hidden data from a stego image.
|
||||
@@ -860,32 +870,40 @@ def extract_from_image(
|
||||
"""
|
||||
debug.print(f"extract_from_image: mode={embed_mode}")
|
||||
|
||||
# AUTO MODE: Try LSB first, then DCT
|
||||
from .backends import registry
|
||||
|
||||
# AUTO MODE: Try LSB first (cheaper), then other backends
|
||||
if embed_mode == EMBED_MODE_AUTO:
|
||||
result = _extract_lsb(image_data, pixel_key, bits_per_channel)
|
||||
if result is not None:
|
||||
debug.print("Auto-detect: LSB extraction succeeded")
|
||||
return result
|
||||
|
||||
if has_dct_support():
|
||||
debug.print("Auto-detect: LSB failed, trying DCT")
|
||||
result = _extract_dct(image_data, pixel_key, progress_file)
|
||||
auto_order = [EMBED_MODE_LSB] + [
|
||||
m for m in registry.available_modes(carrier_type="image") if m != EMBED_MODE_LSB
|
||||
]
|
||||
for mode in auto_order:
|
||||
backend = registry.get(mode)
|
||||
debug.print(f"Auto-detect: trying {mode}")
|
||||
result = backend.extract(
|
||||
image_data,
|
||||
pixel_key,
|
||||
progress_file=progress_file,
|
||||
bits_per_channel=bits_per_channel,
|
||||
quant_step=quant_step,
|
||||
)
|
||||
if result is not None:
|
||||
debug.print("Auto-detect: DCT extraction succeeded")
|
||||
debug.print(f"Auto-detect: {mode} extraction succeeded")
|
||||
return result
|
||||
|
||||
debug.print("Auto-detect: All modes failed")
|
||||
return None
|
||||
|
||||
# EXPLICIT DCT MODE
|
||||
elif embed_mode == EMBED_MODE_DCT:
|
||||
if not has_dct_support():
|
||||
raise ImportError("scipy required for DCT mode")
|
||||
return _extract_dct(image_data, pixel_key, progress_file)
|
||||
|
||||
# EXPLICIT LSB MODE
|
||||
else:
|
||||
return _extract_lsb(image_data, pixel_key, bits_per_channel)
|
||||
# EXPLICIT MODE
|
||||
backend = registry.get(embed_mode)
|
||||
if not backend.is_available():
|
||||
raise ImportError(f"Dependencies for '{embed_mode}' mode are not installed.")
|
||||
return backend.extract(
|
||||
image_data,
|
||||
pixel_key,
|
||||
progress_file=progress_file,
|
||||
bits_per_channel=bits_per_channel,
|
||||
quant_step=quant_step,
|
||||
)
|
||||
|
||||
|
||||
def _extract_dct(
|
||||
@@ -1099,9 +1117,9 @@ def peek_image(image_data: bytes) -> dict:
|
||||
|
||||
# Try DCT extraction (requires scipy/jpeglib)
|
||||
try:
|
||||
from .dct_steganography import HAS_JPEGIO, HAS_SCIPY
|
||||
from .dct_steganography import HAS_JPEGLIB, HAS_SCIPY
|
||||
|
||||
if HAS_SCIPY or HAS_JPEGIO:
|
||||
if HAS_SCIPY or HAS_JPEGLIB:
|
||||
from .dct_steganography import extract_from_dct
|
||||
|
||||
# Extract first few bytes to check header
|
||||
|
||||
@@ -54,8 +54,7 @@ def read_image_exif(image_data: bytes) -> dict:
|
||||
gps[gps_tag] = float(gps_value)
|
||||
elif isinstance(gps_value, tuple):
|
||||
gps[gps_tag] = [
|
||||
float(v) if hasattr(v, "numerator") else v
|
||||
for v in gps_value
|
||||
float(v) if hasattr(v, "numerator") else v for v in gps_value
|
||||
]
|
||||
else:
|
||||
gps[gps_tag] = gps_value
|
||||
@@ -69,7 +68,9 @@ def read_image_exif(image_data: bytes) -> dict:
|
||||
# Try to decode as ASCII/UTF-8 text
|
||||
decoded = value.decode("utf-8", errors="strict").strip("\x00")
|
||||
# Only keep if it looks like printable text
|
||||
if decoded.isprintable() or all(c.isspace() or c.isprintable() for c in decoded):
|
||||
if decoded.isprintable() or all(
|
||||
c.isspace() or c.isprintable() for c in decoded
|
||||
):
|
||||
result[tag] = decoded
|
||||
else:
|
||||
result[tag] = f"<{len(value)} bytes binary>"
|
||||
|
||||
@@ -13,9 +13,15 @@ import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from .debug import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
from .constants import (
|
||||
ALLOWED_AUDIO_EXTENSIONS,
|
||||
ALLOWED_IMAGE_EXTENSIONS,
|
||||
ALLOWED_KEY_EXTENSIONS,
|
||||
EMBED_MODE_AUDIO_AUTO,
|
||||
EMBED_MODE_AUTO,
|
||||
EMBED_MODE_DCT,
|
||||
EMBED_MODE_LSB,
|
||||
@@ -29,8 +35,10 @@ from .constants import (
|
||||
MIN_PIN_LENGTH,
|
||||
MIN_RSA_BITS,
|
||||
RECOMMENDED_PASSPHRASE_WORDS,
|
||||
VALID_AUDIO_EMBED_MODES,
|
||||
)
|
||||
from .exceptions import (
|
||||
AudioValidationError,
|
||||
ImageValidationError,
|
||||
KeyValidationError,
|
||||
MessageValidationError,
|
||||
@@ -475,3 +483,33 @@ def require_security_factors(pin: str, rsa_key_data: bytes | None) -> None:
|
||||
result = validate_security_factors(pin, rsa_key_data)
|
||||
if not result.is_valid:
|
||||
raise SecurityFactorError(result.error_message)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AUDIO VALIDATORS (v4.3.0)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def validate_audio_file(filename: str) -> ValidationResult:
|
||||
"""Validate audio file extension."""
|
||||
return validate_file_extension(filename, ALLOWED_AUDIO_EXTENSIONS, "Audio file")
|
||||
|
||||
|
||||
def validate_audio_embed_mode(mode: str) -> ValidationResult:
|
||||
"""Validate audio embedding mode."""
|
||||
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)
|
||||
|
||||
|
||||
def require_valid_audio(audio_data: bytes, name: str = "Audio") -> None:
|
||||
"""Validate audio, raising AudioValidationError on failure."""
|
||||
from .audio_utils import validate_audio
|
||||
|
||||
result = validate_audio(audio_data, name)
|
||||
if not result.is_valid:
|
||||
raise AudioValidationError(result.error_message)
|
||||
|
||||
496
src/stegasoo/video_steganography.py
Normal file
496
src/stegasoo/video_steganography.py
Normal file
@@ -0,0 +1,496 @@
|
||||
"""
|
||||
Stegasoo Video Steganography — LSB Embedding/Extraction (v4.4.0)
|
||||
|
||||
Frame-based LSB embedding for video files.
|
||||
|
||||
Hides data in the least significant bits of video frame pixels. Uses the
|
||||
existing image steganography engine for per-frame embedding, providing
|
||||
high capacity across multiple I-frames.
|
||||
|
||||
Strategy:
|
||||
1. Extract I-frames (keyframes) from video using ffmpeg
|
||||
2. Embed payload across I-frames using existing LSB engine
|
||||
3. Re-encode video with modified frames using FFV1 lossless codec
|
||||
4. Output: MKV container with embedded data
|
||||
|
||||
Uses ChaCha20 as a CSPRNG for pseudo-random frame selection and pixel
|
||||
selection within frames, ensuring that without the key an attacker cannot
|
||||
determine which frames/pixels were modified.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from .constants import (
|
||||
EMBED_MODE_VIDEO_LSB,
|
||||
VIDEO_MAGIC_LSB,
|
||||
VIDEO_OUTPUT_CODEC,
|
||||
)
|
||||
from .debug import debug
|
||||
from .exceptions import VideoCapacityError, VideoError
|
||||
from .models import VideoEmbedStats
|
||||
from .steganography import ENCRYPTION_OVERHEAD, _embed_lsb, _extract_lsb
|
||||
from .video_utils import extract_frames, get_video_info, reassemble_video
|
||||
|
||||
# Progress reporting interval — write every N frames
|
||||
PROGRESS_INTERVAL = 5
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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_video_lsb_capacity(video_data: bytes) -> int:
|
||||
"""
|
||||
Calculate the maximum bytes that can be embedded in a video via LSB.
|
||||
|
||||
Calculates capacity based on I-frames (keyframes) only. Each I-frame
|
||||
provides capacity proportional to its pixel count.
|
||||
|
||||
Args:
|
||||
video_data: Raw bytes of a video file.
|
||||
|
||||
Returns:
|
||||
Maximum embeddable payload size in bytes (after subtracting overhead).
|
||||
|
||||
Raises:
|
||||
VideoError: If the video cannot be read or is in an unsupported format.
|
||||
"""
|
||||
from .video_utils import calculate_video_capacity
|
||||
|
||||
capacity_info = calculate_video_capacity(video_data, EMBED_MODE_VIDEO_LSB)
|
||||
|
||||
debug.print(
|
||||
f"Video LSB capacity: {capacity_info.usable_capacity_bytes} bytes "
|
||||
f"({capacity_info.i_frames} I-frames, {capacity_info.resolution[0]}x{capacity_info.resolution[1]})"
|
||||
)
|
||||
|
||||
return capacity_info.usable_capacity_bytes
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FRAME INDEX GENERATION (ChaCha20 CSPRNG)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def generate_frame_indices(key: bytes, num_frames: int, num_needed: int) -> list[int]:
|
||||
"""
|
||||
Generate pseudo-random frame indices using ChaCha20 as a CSPRNG.
|
||||
|
||||
Produces a deterministic sequence of unique frame indices so that
|
||||
the same key always yields the same embedding locations.
|
||||
|
||||
Args:
|
||||
key: 32-byte key for the ChaCha20 cipher.
|
||||
num_frames: Total number of frames available.
|
||||
num_needed: How many unique frame indices are required.
|
||||
|
||||
Returns:
|
||||
List of ``num_needed`` unique indices in [0, num_frames).
|
||||
"""
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
||||
|
||||
debug.validate(len(key) == 32, f"Frame key must be 32 bytes, got {len(key)}")
|
||||
debug.validate(num_frames > 0, f"Number of frames must be positive, got {num_frames}")
|
||||
debug.validate(num_needed > 0, f"Number needed must be positive, got {num_needed}")
|
||||
debug.validate(
|
||||
num_needed <= num_frames,
|
||||
f"Cannot select {num_needed} frames from {num_frames} available",
|
||||
)
|
||||
|
||||
debug.print(f"Generating {num_needed} frame indices from {num_frames} total frames")
|
||||
|
||||
# Use a different nonce offset for frame selection (vs pixel selection)
|
||||
nonce = b"\x01" + b"\x00" * 15 # Different from pixel selection nonce
|
||||
|
||||
if num_needed >= num_frames // 2:
|
||||
# Full Fisher-Yates shuffle
|
||||
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
|
||||
encryptor = cipher.encryptor()
|
||||
|
||||
indices = list(range(num_frames))
|
||||
random_bytes = encryptor.update(b"\x00" * (num_frames * 4))
|
||||
|
||||
for i in range(num_frames - 1, 0, -1):
|
||||
j_bytes = random_bytes[(num_frames - 1 - i) * 4 : (num_frames - i) * 4]
|
||||
j = int.from_bytes(j_bytes, "big") % (i + 1)
|
||||
indices[i], indices[j] = indices[j], indices[i]
|
||||
|
||||
return indices[:num_needed]
|
||||
|
||||
# Direct sampling
|
||||
selected: list[int] = []
|
||||
used: set[int] = set()
|
||||
|
||||
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
|
||||
encryptor = cipher.encryptor()
|
||||
|
||||
bytes_needed = (num_needed * 2) * 4
|
||||
random_bytes = encryptor.update(b"\x00" * bytes_needed)
|
||||
|
||||
byte_offset = 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_frames
|
||||
byte_offset += 4
|
||||
|
||||
if idx not in used:
|
||||
used.add(idx)
|
||||
selected.append(idx)
|
||||
|
||||
debug.validate(
|
||||
len(selected) == num_needed,
|
||||
f"Failed to generate enough indices: {len(selected)}/{num_needed}",
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EMBEDDING
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@debug.time
|
||||
def embed_in_video_lsb(
|
||||
data: bytes,
|
||||
carrier_video: bytes,
|
||||
pixel_key: bytes,
|
||||
progress_file: str | None = None,
|
||||
) -> tuple[bytes, VideoEmbedStats]:
|
||||
"""
|
||||
Embed data into video frames using LSB steganography.
|
||||
|
||||
The payload is prepended with a 4-byte magic header and a 4-byte
|
||||
big-endian length prefix. Data is distributed across I-frames using
|
||||
pseudo-random selection based on the pixel_key.
|
||||
|
||||
The output video uses FFV1 lossless codec in MKV container to
|
||||
preserve the embedded data perfectly.
|
||||
|
||||
Args:
|
||||
data: Encrypted payload bytes to embed.
|
||||
carrier_video: Raw bytes of the carrier video file.
|
||||
pixel_key: 32-byte key for frame and pixel selection.
|
||||
progress_file: Optional path for progress JSON (frontend polling).
|
||||
|
||||
Returns:
|
||||
Tuple of (stego video bytes, VideoEmbedStats).
|
||||
|
||||
Raises:
|
||||
VideoCapacityError: If the payload is too large for the carrier.
|
||||
VideoError: On any other embedding failure.
|
||||
"""
|
||||
debug.print(f"Video LSB embedding {len(data)} bytes")
|
||||
debug.data(pixel_key, "Pixel key for embedding")
|
||||
debug.validate(len(pixel_key) == 32, f"Pixel key must be 32 bytes, got {len(pixel_key)}")
|
||||
|
||||
try:
|
||||
# Get video info
|
||||
video_info = get_video_info(carrier_video)
|
||||
debug.print(
|
||||
f"Carrier video: {video_info.width}x{video_info.height}, "
|
||||
f"{video_info.fps:.2f} fps, {video_info.duration_seconds:.1f}s, "
|
||||
f"{video_info.i_frame_count} I-frames"
|
||||
)
|
||||
|
||||
# Prepend magic + length prefix
|
||||
header = VIDEO_MAGIC_LSB + struct.pack(">I", len(data))
|
||||
payload = header + data
|
||||
debug.print(f"Payload with header: {len(payload)} bytes")
|
||||
|
||||
# Calculate capacity and check fit
|
||||
capacity = calculate_video_lsb_capacity(carrier_video)
|
||||
if len(payload) > capacity + ENCRYPTION_OVERHEAD:
|
||||
raise VideoCapacityError(len(payload), capacity)
|
||||
|
||||
# Extract I-frames to temp directory
|
||||
with tempfile.TemporaryDirectory(prefix="stegasoo_video_") as temp_dir_str:
|
||||
temp_dir = Path(temp_dir_str)
|
||||
|
||||
_write_progress(progress_file, 5, 100, "extracting_frames")
|
||||
|
||||
frames, _ = extract_frames(carrier_video, temp_dir, keyframes_only=True)
|
||||
num_frames = len(frames)
|
||||
|
||||
debug.print(f"Extracted {num_frames} I-frames for embedding")
|
||||
|
||||
if num_frames == 0:
|
||||
raise VideoError("No I-frames found in video")
|
||||
|
||||
# Calculate bytes per frame (minus 4 byte length prefix used by _embed_lsb)
|
||||
pixels_per_frame = video_info.width * video_info.height
|
||||
bytes_per_frame = (pixels_per_frame * 3) // 8 - 4 # 3 bits per pixel, minus len prefix
|
||||
|
||||
# For simplicity, embed entire payload in first frame if it fits
|
||||
# This makes extraction straightforward
|
||||
if len(payload) <= bytes_per_frame:
|
||||
debug.print(f"Payload fits in single frame ({len(payload)} <= {bytes_per_frame})")
|
||||
frame_path = frames[0]
|
||||
|
||||
with open(frame_path, "rb") as f:
|
||||
frame_data = f.read()
|
||||
|
||||
try:
|
||||
stego_frame, stats, ext = _embed_lsb(
|
||||
payload,
|
||||
frame_data,
|
||||
pixel_key,
|
||||
bits_per_channel=1,
|
||||
output_format="PNG",
|
||||
)
|
||||
|
||||
with open(frame_path, "wb") as f:
|
||||
f.write(stego_frame)
|
||||
|
||||
modified_frames = 1
|
||||
|
||||
except Exception as e:
|
||||
debug.print(f"Failed to embed in frame: {e}")
|
||||
raise VideoError(f"Failed to embed in frame: {e}")
|
||||
else:
|
||||
# For larger payloads, we need to split across frames
|
||||
# Each frame stores: 4-byte chunk length + chunk data
|
||||
debug.print("Splitting payload across multiple frames")
|
||||
|
||||
frames_needed = (len(payload) + bytes_per_frame - 1) // bytes_per_frame
|
||||
frames_needed = min(frames_needed, num_frames)
|
||||
|
||||
debug.print(f"Using {frames_needed} frames to embed {len(payload)} bytes")
|
||||
|
||||
# For now, use sequential frames for simplicity
|
||||
modified_frames = 0
|
||||
bytes_remaining = len(payload)
|
||||
payload_offset = 0
|
||||
|
||||
for frame_idx in range(frames_needed):
|
||||
if bytes_remaining <= 0:
|
||||
break
|
||||
|
||||
frame_path = frames[frame_idx]
|
||||
|
||||
with open(frame_path, "rb") as f:
|
||||
frame_data = f.read()
|
||||
|
||||
chunk_size = min(bytes_remaining, bytes_per_frame)
|
||||
chunk = payload[payload_offset : payload_offset + chunk_size]
|
||||
|
||||
try:
|
||||
stego_frame, stats, ext = _embed_lsb(
|
||||
chunk,
|
||||
frame_data,
|
||||
pixel_key,
|
||||
bits_per_channel=1,
|
||||
output_format="PNG",
|
||||
)
|
||||
|
||||
with open(frame_path, "wb") as f:
|
||||
f.write(stego_frame)
|
||||
|
||||
modified_frames += 1
|
||||
payload_offset += chunk_size
|
||||
bytes_remaining -= chunk_size
|
||||
|
||||
except Exception as e:
|
||||
debug.print(f"Failed to embed in frame {frame_idx}: {e}")
|
||||
raise VideoError(f"Failed to embed in frame {frame_idx}: {e}")
|
||||
|
||||
if progress_file and frame_idx % PROGRESS_INTERVAL == 0:
|
||||
pct = 10 + int((frame_idx / frames_needed) * 70)
|
||||
_write_progress(progress_file, pct, 100, "embedding")
|
||||
|
||||
_write_progress(progress_file, 80, 100, "reassembling")
|
||||
|
||||
# Reassemble video with modified frames
|
||||
stego_video = reassemble_video(
|
||||
frames,
|
||||
carrier_video,
|
||||
fps=1.0, # I-frame only videos use 1 fps
|
||||
)
|
||||
|
||||
_write_progress(progress_file, 100, 100, "complete")
|
||||
|
||||
video_stats = VideoEmbedStats(
|
||||
frames_modified=modified_frames,
|
||||
total_frames=video_info.total_frames,
|
||||
capacity_used=len(payload) / (capacity + ENCRYPTION_OVERHEAD),
|
||||
bytes_embedded=len(payload),
|
||||
width=video_info.width,
|
||||
height=video_info.height,
|
||||
fps=video_info.fps,
|
||||
duration_seconds=video_info.duration_seconds,
|
||||
embed_mode=EMBED_MODE_VIDEO_LSB,
|
||||
codec=VIDEO_OUTPUT_CODEC,
|
||||
)
|
||||
|
||||
debug.print(
|
||||
f"Video LSB embedding complete: {len(stego_video)} bytes, "
|
||||
f"{modified_frames} frames modified"
|
||||
)
|
||||
|
||||
return stego_video, video_stats
|
||||
|
||||
except VideoCapacityError:
|
||||
raise
|
||||
except VideoError:
|
||||
raise
|
||||
except Exception as e:
|
||||
debug.exception(e, "embed_in_video_lsb")
|
||||
raise VideoError(f"Failed to embed data in video: {e}") from e
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EXTRACTION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@debug.time
|
||||
def extract_from_video_lsb(
|
||||
video_data: bytes,
|
||||
pixel_key: bytes,
|
||||
progress_file: str | None = None,
|
||||
) -> bytes | None:
|
||||
"""
|
||||
Extract hidden data from video using LSB steganography.
|
||||
|
||||
Extracts I-frames, reads LSBs from the same pseudo-random locations
|
||||
used during embedding, and reconstructs the payload.
|
||||
|
||||
Args:
|
||||
video_data: Raw bytes of the stego video file.
|
||||
pixel_key: 32-byte key (must match the one used for 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"Video LSB extracting from {len(video_data)} byte video")
|
||||
debug.data(pixel_key, "Pixel key for extraction")
|
||||
|
||||
try:
|
||||
# Get video info
|
||||
video_info = get_video_info(video_data)
|
||||
debug.print(
|
||||
f"Video: {video_info.width}x{video_info.height}, "
|
||||
f"{video_info.i_frame_count} I-frames"
|
||||
)
|
||||
|
||||
# Extract I-frames
|
||||
with tempfile.TemporaryDirectory(prefix="stegasoo_video_extract_") as temp_dir_str:
|
||||
temp_dir = Path(temp_dir_str)
|
||||
|
||||
_write_progress(progress_file, 5, 100, "extracting_frames")
|
||||
|
||||
frames, _ = extract_frames(video_data, temp_dir, keyframes_only=True)
|
||||
num_frames = len(frames)
|
||||
|
||||
if num_frames == 0:
|
||||
debug.print("No I-frames found in video")
|
||||
return None
|
||||
|
||||
debug.print(f"Extracted {num_frames} I-frames for extraction")
|
||||
|
||||
_write_progress(progress_file, 20, 100, "extracting_data")
|
||||
|
||||
# First, try to extract from frame 0 to get magic and total length
|
||||
frame_path = frames[0]
|
||||
with open(frame_path, "rb") as f:
|
||||
frame_data = f.read()
|
||||
|
||||
first_chunk = _extract_lsb(frame_data, pixel_key, bits_per_channel=1)
|
||||
if first_chunk is None or len(first_chunk) < 8:
|
||||
debug.print("Failed to extract initial data from first frame")
|
||||
return None
|
||||
|
||||
# Check magic bytes
|
||||
magic = first_chunk[:4]
|
||||
if magic != VIDEO_MAGIC_LSB:
|
||||
debug.print(f"Magic mismatch: got {magic!r}, expected {VIDEO_MAGIC_LSB!r}")
|
||||
return None
|
||||
|
||||
# Get total payload length
|
||||
total_length = struct.unpack(">I", first_chunk[4:8])[0]
|
||||
debug.print(f"Total payload length: {total_length} bytes")
|
||||
|
||||
# Sanity check
|
||||
pixels_per_frame = video_info.width * video_info.height
|
||||
bytes_per_frame = (pixels_per_frame * 3) // 8 - 4 # minus length prefix
|
||||
max_possible = bytes_per_frame * num_frames
|
||||
|
||||
if total_length > max_possible or total_length < 1:
|
||||
debug.print(f"Invalid payload length: {total_length}")
|
||||
return None
|
||||
|
||||
# If the entire payload fits in the first frame, return it directly
|
||||
# This matches the simplified single-frame embedding approach
|
||||
if len(first_chunk) >= 8 + total_length:
|
||||
debug.print("Payload fits in single frame, extracting directly")
|
||||
payload = first_chunk[8 : 8 + total_length]
|
||||
else:
|
||||
# Multi-frame extraction
|
||||
debug.print("Multi-frame extraction needed")
|
||||
frames_needed = (total_length + 8 + bytes_per_frame - 1) // bytes_per_frame
|
||||
frames_needed = min(frames_needed, num_frames)
|
||||
|
||||
# Extract sequentially (matching the embedding approach)
|
||||
extracted_chunks = [first_chunk]
|
||||
for frame_idx in range(1, frames_needed):
|
||||
frame_path = frames[frame_idx]
|
||||
with open(frame_path, "rb") as f:
|
||||
frame_data = f.read()
|
||||
|
||||
chunk = _extract_lsb(frame_data, pixel_key, bits_per_channel=1)
|
||||
if chunk:
|
||||
extracted_chunks.append(chunk)
|
||||
|
||||
if progress_file and frame_idx % PROGRESS_INTERVAL == 0:
|
||||
pct = 20 + int((frame_idx / frames_needed) * 70)
|
||||
_write_progress(progress_file, pct, 100, "extracting_data")
|
||||
|
||||
# Combine chunks
|
||||
combined = b"".join(extracted_chunks)
|
||||
|
||||
if len(combined) < 8 + total_length:
|
||||
debug.print(
|
||||
f"Insufficient data: have {len(combined) - 8}, need {total_length}"
|
||||
)
|
||||
return None
|
||||
|
||||
payload = combined[8 : 8 + total_length]
|
||||
|
||||
_write_progress(progress_file, 100, 100, "complete")
|
||||
|
||||
debug.print(f"Video LSB successfully extracted {len(payload)} bytes")
|
||||
return payload
|
||||
|
||||
except Exception as e:
|
||||
debug.exception(e, "extract_from_video_lsb")
|
||||
return None
|
||||
732
src/stegasoo/video_utils.py
Normal file
732
src/stegasoo/video_utils.py
Normal file
@@ -0,0 +1,732 @@
|
||||
"""
|
||||
Stegasoo Video Utilities (v4.4.0)
|
||||
|
||||
Video format detection, frame extraction, and transcoding for video steganography.
|
||||
|
||||
Dependencies:
|
||||
- ffmpeg binary: Required for all video operations
|
||||
- numpy: For frame data manipulation
|
||||
- PIL/Pillow: For frame image handling
|
||||
|
||||
Uses ffmpeg for:
|
||||
- Format detection and metadata extraction
|
||||
- I-frame extraction
|
||||
- Video reassembly with FFV1 lossless codec
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from .constants import (
|
||||
EMBED_MODE_VIDEO_AUTO,
|
||||
EMBED_MODE_VIDEO_LSB,
|
||||
MAX_VIDEO_DURATION,
|
||||
MAX_VIDEO_FILE_SIZE,
|
||||
MAX_VIDEO_RESOLUTION,
|
||||
MIN_VIDEO_RESOLUTION,
|
||||
VALID_VIDEO_EMBED_MODES,
|
||||
VIDEO_OUTPUT_CODEC,
|
||||
VIDEO_OUTPUT_CONTAINER,
|
||||
)
|
||||
from .debug import get_logger
|
||||
from .exceptions import (
|
||||
UnsupportedVideoFormatError,
|
||||
VideoTranscodeError,
|
||||
VideoValidationError,
|
||||
)
|
||||
from .models import ValidationResult, VideoCapacityInfo, VideoInfo
|
||||
|
||||
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
|
||||
|
||||
|
||||
def has_ffprobe_support() -> bool:
|
||||
"""Check if ffprobe is available on the system.
|
||||
|
||||
Returns:
|
||||
True if ffprobe is found on PATH, False otherwise.
|
||||
"""
|
||||
return shutil.which("ffprobe") is not None
|
||||
|
||||
|
||||
def _require_ffmpeg() -> None:
|
||||
"""Raise error if ffmpeg is not available."""
|
||||
if not has_ffmpeg_support():
|
||||
raise VideoTranscodeError(
|
||||
"ffmpeg is required for video operations. Install ffmpeg on your system."
|
||||
)
|
||||
|
||||
|
||||
def _require_ffprobe() -> None:
|
||||
"""Raise error if ffprobe is not available."""
|
||||
if not has_ffprobe_support():
|
||||
raise VideoTranscodeError(
|
||||
"ffprobe is required for video metadata. Install ffmpeg on your system."
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FORMAT DETECTION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def detect_video_format(video_data: bytes) -> str:
|
||||
"""Detect video format from magic bytes.
|
||||
|
||||
Examines the first bytes of video data to identify the container format.
|
||||
|
||||
Magic byte signatures:
|
||||
- MP4/M4V: b"ftyp" at offset 4
|
||||
- MKV/WebM: b"\\x1a\\x45\\xdf\\xa3" (EBML header)
|
||||
- AVI: b"RIFF" at offset 0 + b"AVI " at offset 8
|
||||
- MOV: b"ftyp" with "qt" brand or b"moov"/"mdat" early
|
||||
|
||||
Args:
|
||||
video_data: Raw video file bytes.
|
||||
|
||||
Returns:
|
||||
Format string: "mp4", "mkv", "webm", "avi", "mov", or "unknown".
|
||||
"""
|
||||
if len(video_data) < 12:
|
||||
logger.debug("detect_video_format: data too short (%d bytes)", len(video_data))
|
||||
return "unknown"
|
||||
|
||||
# MP4/M4V/MOV: "ftyp" atom at offset 4
|
||||
if video_data[4:8] == b"ftyp":
|
||||
# Check brand for specific type
|
||||
brand = video_data[8:12]
|
||||
if brand in (b"qt ", b"mqt "):
|
||||
return "mov"
|
||||
if brand in (b"isom", b"iso2", b"mp41", b"mp42", b"avc1", b"M4V "):
|
||||
return "mp4"
|
||||
# Default to mp4 for ftyp containers
|
||||
return "mp4"
|
||||
|
||||
# MKV/WebM: EBML header
|
||||
if video_data[:4] == b"\x1a\x45\xdf\xa3":
|
||||
# Check doctype to distinguish MKV from WebM
|
||||
# WebM uses "webm" doctype, MKV uses "matroska"
|
||||
# Simple heuristic: search for doctype string in first 64 bytes
|
||||
header = video_data[:64]
|
||||
if b"webm" in header.lower():
|
||||
return "webm"
|
||||
return "mkv"
|
||||
|
||||
# AVI: RIFF....AVI
|
||||
if video_data[:4] == b"RIFF" and video_data[8:12] == b"AVI ":
|
||||
return "avi"
|
||||
|
||||
# MOV without ftyp (older format): check for moov/mdat atoms
|
||||
if video_data[4:8] in (b"moov", b"mdat", b"wide", b"free"):
|
||||
return "mov"
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# METADATA EXTRACTION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_video_info(video_data: bytes) -> VideoInfo:
|
||||
"""Extract video metadata from raw video bytes.
|
||||
|
||||
Uses ffprobe to extract detailed video information including
|
||||
resolution, frame rate, duration, codec, and I-frame count.
|
||||
|
||||
Args:
|
||||
video_data: Raw video file bytes.
|
||||
|
||||
Returns:
|
||||
VideoInfo dataclass with video metadata.
|
||||
|
||||
Raises:
|
||||
UnsupportedVideoFormatError: If the format cannot be detected.
|
||||
VideoTranscodeError: If metadata extraction fails.
|
||||
"""
|
||||
_require_ffprobe()
|
||||
|
||||
fmt = detect_video_format(video_data)
|
||||
if fmt == "unknown":
|
||||
raise UnsupportedVideoFormatError(
|
||||
"Cannot detect video format. Supported: MP4, MKV, WebM, AVI, MOV."
|
||||
)
|
||||
|
||||
# Write to temp file for ffprobe
|
||||
with tempfile.NamedTemporaryFile(suffix=f".{fmt}", delete=False) as f:
|
||||
f.write(video_data)
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
# Get stream info
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
temp_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise VideoTranscodeError(f"ffprobe failed: {result.stderr}")
|
||||
|
||||
info = json.loads(result.stdout)
|
||||
|
||||
# Extract video stream info
|
||||
if not info.get("streams"):
|
||||
raise VideoTranscodeError("No video stream found in file")
|
||||
|
||||
stream = info["streams"][0]
|
||||
format_info = info.get("format", {})
|
||||
|
||||
width = int(stream.get("width", 0))
|
||||
height = int(stream.get("height", 0))
|
||||
codec = stream.get("codec_name", "unknown")
|
||||
|
||||
# Parse frame rate (can be "30/1" or "29.97")
|
||||
fps_str = stream.get("r_frame_rate", "0/1")
|
||||
if "/" in fps_str:
|
||||
num, den = fps_str.split("/")
|
||||
fps = float(num) / float(den) if float(den) > 0 else 0.0
|
||||
else:
|
||||
fps = float(fps_str)
|
||||
|
||||
# Get duration
|
||||
duration = float(stream.get("duration", format_info.get("duration", 0)))
|
||||
|
||||
# Get total frames
|
||||
nb_frames = stream.get("nb_frames")
|
||||
if nb_frames:
|
||||
total_frames = int(nb_frames)
|
||||
else:
|
||||
# Estimate from duration and fps
|
||||
total_frames = int(duration * fps) if fps > 0 else 0
|
||||
|
||||
# Get bitrate
|
||||
bitrate = None
|
||||
if format_info.get("bit_rate"):
|
||||
bitrate = int(format_info["bit_rate"])
|
||||
|
||||
# Count I-frames using ffprobe
|
||||
i_frame_count = _count_i_frames(temp_path, timeout=120)
|
||||
|
||||
return VideoInfo(
|
||||
width=width,
|
||||
height=height,
|
||||
fps=fps,
|
||||
duration_seconds=duration,
|
||||
total_frames=total_frames,
|
||||
i_frame_count=i_frame_count,
|
||||
format=fmt,
|
||||
codec=codec,
|
||||
bitrate=bitrate,
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise VideoTranscodeError(f"Failed to parse ffprobe output: {e}")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise VideoTranscodeError("ffprobe timed out")
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
def _count_i_frames(video_path: str, timeout: int = 120) -> int:
|
||||
"""Count I-frames (keyframes) in a video file.
|
||||
|
||||
Args:
|
||||
video_path: Path to video file.
|
||||
timeout: Maximum time in seconds.
|
||||
|
||||
Returns:
|
||||
Number of I-frames in the video.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"frame=pict_type",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
video_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.warning("Failed to count I-frames: %s", result.stderr)
|
||||
return 0
|
||||
|
||||
# Count lines containing 'I'
|
||||
return sum(1 for line in result.stdout.strip().split("\n") if line.strip() == "I")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("I-frame counting timed out")
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.warning("I-frame counting failed: %s", e)
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FRAME EXTRACTION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def extract_frames(
|
||||
video_data: bytes,
|
||||
output_dir: Path | None = None,
|
||||
keyframes_only: bool = True,
|
||||
) -> tuple[list[Path], VideoInfo]:
|
||||
"""Extract frames from video as PNG images.
|
||||
|
||||
Uses ffmpeg to extract frames from the video. By default extracts only
|
||||
I-frames (keyframes) which are more robust to re-encoding.
|
||||
|
||||
Args:
|
||||
video_data: Raw video file bytes.
|
||||
output_dir: Directory to save frames (temp dir if None).
|
||||
keyframes_only: If True, only extract I-frames (keyframes).
|
||||
|
||||
Returns:
|
||||
Tuple of (list of frame paths sorted by frame number, VideoInfo).
|
||||
|
||||
Raises:
|
||||
VideoTranscodeError: If frame extraction fails.
|
||||
"""
|
||||
_require_ffmpeg()
|
||||
|
||||
fmt = detect_video_format(video_data)
|
||||
if fmt == "unknown":
|
||||
raise UnsupportedVideoFormatError(
|
||||
"Cannot detect video format. Supported: MP4, MKV, WebM, AVI, MOV."
|
||||
)
|
||||
|
||||
# Get video info first
|
||||
video_info = get_video_info(video_data)
|
||||
|
||||
# Create output directory
|
||||
if output_dir is None:
|
||||
output_dir = Path(tempfile.mkdtemp(prefix="stegasoo_frames_"))
|
||||
else:
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write video to temp file
|
||||
with tempfile.NamedTemporaryFile(suffix=f".{fmt}", delete=False) as f:
|
||||
f.write(video_data)
|
||||
video_path = f.name
|
||||
|
||||
try:
|
||||
# Build ffmpeg command
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
video_path,
|
||||
"-vsync",
|
||||
"0",
|
||||
]
|
||||
|
||||
if keyframes_only:
|
||||
# Extract only I-frames
|
||||
cmd.extend(["-vf", "select='eq(pict_type,I)'"])
|
||||
|
||||
# Output as PNG with frame number
|
||||
output_pattern = str(output_dir / "frame_%06d.png")
|
||||
cmd.extend(["-start_number", "0", output_pattern])
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600, # 10 minute timeout
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise VideoTranscodeError(f"Frame extraction failed: {result.stderr}")
|
||||
|
||||
# Collect extracted frames
|
||||
frames = sorted(output_dir.glob("frame_*.png"))
|
||||
|
||||
if not frames:
|
||||
raise VideoTranscodeError("No frames were extracted from video")
|
||||
|
||||
logger.info(
|
||||
"Extracted %d %s from video",
|
||||
len(frames),
|
||||
"I-frames" if keyframes_only else "frames",
|
||||
)
|
||||
|
||||
return frames, video_info
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
raise VideoTranscodeError("Frame extraction timed out")
|
||||
finally:
|
||||
os.unlink(video_path)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VIDEO REASSEMBLY
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def reassemble_video(
|
||||
frames: list[Path],
|
||||
original_video_data: bytes,
|
||||
output_path: Path | None = None,
|
||||
fps: float | None = None,
|
||||
audio_data: bytes | None = None,
|
||||
) -> bytes:
|
||||
"""Reassemble frames back into a video file.
|
||||
|
||||
Creates a new video from the modified frames using FFV1 lossless codec
|
||||
in an MKV container. This preserves the embedded data perfectly.
|
||||
|
||||
Args:
|
||||
frames: List of frame image paths in order.
|
||||
original_video_data: Original video bytes (for audio track extraction).
|
||||
output_path: Optional output path (temp file if None).
|
||||
fps: Frame rate (auto-detected from original if None).
|
||||
audio_data: Optional audio track data to mux in.
|
||||
|
||||
Returns:
|
||||
Video file bytes (MKV container with FFV1 codec).
|
||||
|
||||
Raises:
|
||||
VideoTranscodeError: If reassembly fails.
|
||||
"""
|
||||
_require_ffmpeg()
|
||||
|
||||
if not frames:
|
||||
raise VideoTranscodeError("No frames provided for reassembly")
|
||||
|
||||
# Get original video format
|
||||
fmt = detect_video_format(original_video_data)
|
||||
|
||||
if fps is None:
|
||||
# Use a fixed low framerate for I-frame sequences
|
||||
# since I-frames are sparse (typically 1 per 30-60 frames)
|
||||
fps = 1.0 # 1 fps for I-frame only videos
|
||||
|
||||
# Create temp directory for work
|
||||
with tempfile.TemporaryDirectory(prefix="stegasoo_reassemble_") as temp_dir_str:
|
||||
temp_dir = Path(temp_dir_str)
|
||||
|
||||
# Write original video for audio extraction
|
||||
original_path = temp_dir / f"original.{fmt}"
|
||||
original_path.write_bytes(original_video_data)
|
||||
|
||||
# Create frame list file for ffmpeg
|
||||
frame_list = temp_dir / "frames.txt"
|
||||
with open(frame_list, "w") as f:
|
||||
for frame in frames:
|
||||
# FFmpeg concat format
|
||||
f.write(f"file '{frame.absolute()}'\n")
|
||||
f.write(f"duration {1.0 / fps}\n")
|
||||
|
||||
# Output path
|
||||
if output_path is None:
|
||||
output_file = temp_dir / f"output.{VIDEO_OUTPUT_CONTAINER}"
|
||||
else:
|
||||
output_file = Path(output_path)
|
||||
|
||||
# Build ffmpeg command
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y", # Overwrite output
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(frame_list),
|
||||
]
|
||||
|
||||
# Add audio from original video if available
|
||||
# Check if original has audio
|
||||
has_audio = _video_has_audio(original_path)
|
||||
if has_audio:
|
||||
cmd.extend(["-i", str(original_path)])
|
||||
|
||||
# Video encoding settings (FFV1 lossless)
|
||||
cmd.extend(
|
||||
[
|
||||
"-c:v",
|
||||
VIDEO_OUTPUT_CODEC,
|
||||
"-level",
|
||||
"3", # FFV1 level 3 for better compression
|
||||
"-coder",
|
||||
"1", # Range coder
|
||||
"-context",
|
||||
"1", # Large context
|
||||
"-slicecrc",
|
||||
"1", # Error detection
|
||||
]
|
||||
)
|
||||
|
||||
# Audio settings
|
||||
if has_audio:
|
||||
cmd.extend(
|
||||
[
|
||||
"-map",
|
||||
"0:v", # Video from frames
|
||||
"-map",
|
||||
"1:a?", # Audio from original (if exists)
|
||||
"-c:a",
|
||||
"copy", # Copy audio without re-encoding
|
||||
]
|
||||
)
|
||||
|
||||
cmd.append(str(output_file))
|
||||
|
||||
logger.debug("Running ffmpeg: %s", " ".join(cmd))
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise VideoTranscodeError(f"Video reassembly failed: {result.stderr}")
|
||||
|
||||
# Read output
|
||||
return output_file.read_bytes()
|
||||
|
||||
|
||||
def _video_has_audio(video_path: Path) -> bool:
|
||||
"""Check if a video file has an audio stream.
|
||||
|
||||
Args:
|
||||
video_path: Path to video file.
|
||||
|
||||
Returns:
|
||||
True if video has audio, False otherwise.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=index",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
str(video_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VALIDATION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def validate_video(
|
||||
video_data: bytes,
|
||||
name: str = "Video",
|
||||
check_duration: bool = True,
|
||||
) -> ValidationResult:
|
||||
"""Validate video data for steganography.
|
||||
|
||||
Checks:
|
||||
- Not empty
|
||||
- Not too large (MAX_VIDEO_FILE_SIZE)
|
||||
- Valid video format (detectable via magic bytes)
|
||||
- Duration within limits (MAX_VIDEO_DURATION) if check_duration=True
|
||||
- Resolution within limits (MIN/MAX_VIDEO_RESOLUTION)
|
||||
|
||||
Args:
|
||||
video_data: Raw video file bytes.
|
||||
name: Descriptive name for error messages (default: "Video").
|
||||
check_duration: Whether to enforce duration limit (default: True).
|
||||
|
||||
Returns:
|
||||
ValidationResult with video info in details on success.
|
||||
"""
|
||||
if not video_data:
|
||||
return ValidationResult.error(f"{name} is required")
|
||||
|
||||
if len(video_data) > MAX_VIDEO_FILE_SIZE:
|
||||
size_gb = len(video_data) / (1024**3)
|
||||
max_gb = MAX_VIDEO_FILE_SIZE / (1024**3)
|
||||
return ValidationResult.error(
|
||||
f"{name} too large ({size_gb:.1f} GB). Maximum: {max_gb:.0f} GB"
|
||||
)
|
||||
|
||||
# Detect format
|
||||
fmt = detect_video_format(video_data)
|
||||
if fmt == "unknown":
|
||||
return ValidationResult.error(
|
||||
f"Could not detect {name} format. " "Supported formats: MP4, MKV, WebM, AVI, MOV."
|
||||
)
|
||||
|
||||
# Check ffmpeg availability
|
||||
if not has_ffmpeg_support():
|
||||
return ValidationResult.error(
|
||||
"ffmpeg is required for video processing. Please install ffmpeg."
|
||||
)
|
||||
|
||||
# Extract metadata for further validation
|
||||
try:
|
||||
info = get_video_info(video_data)
|
||||
except (VideoTranscodeError, UnsupportedVideoFormatError) 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_VIDEO_DURATION:
|
||||
return ValidationResult.error(
|
||||
f"{name} too long ({info.duration_seconds:.1f}s). "
|
||||
f"Maximum: {MAX_VIDEO_DURATION}s ({MAX_VIDEO_DURATION // 60} minutes)"
|
||||
)
|
||||
|
||||
# Check resolution
|
||||
if info.width < MIN_VIDEO_RESOLUTION[0] or info.height < MIN_VIDEO_RESOLUTION[1]:
|
||||
return ValidationResult.error(
|
||||
f"{name} resolution too small ({info.width}x{info.height}). "
|
||||
f"Minimum: {MIN_VIDEO_RESOLUTION[0]}x{MIN_VIDEO_RESOLUTION[1]}"
|
||||
)
|
||||
|
||||
if info.width > MAX_VIDEO_RESOLUTION[0] or info.height > MAX_VIDEO_RESOLUTION[1]:
|
||||
return ValidationResult.error(
|
||||
f"{name} resolution too large ({info.width}x{info.height}). "
|
||||
f"Maximum: {MAX_VIDEO_RESOLUTION[0]}x{MAX_VIDEO_RESOLUTION[1]}"
|
||||
)
|
||||
|
||||
# Check I-frame count
|
||||
if info.i_frame_count < 1:
|
||||
return ValidationResult.error(f"{name} has no I-frames (keyframes) for embedding")
|
||||
|
||||
return ValidationResult.ok(
|
||||
width=info.width,
|
||||
height=info.height,
|
||||
fps=info.fps,
|
||||
duration=info.duration_seconds,
|
||||
total_frames=info.total_frames,
|
||||
i_frame_count=info.i_frame_count,
|
||||
format=info.format,
|
||||
codec=info.codec,
|
||||
bitrate=info.bitrate,
|
||||
)
|
||||
|
||||
|
||||
def require_valid_video(video_data: bytes, name: str = "Video") -> None:
|
||||
"""Validate video, raising VideoValidationError on failure.
|
||||
|
||||
Args:
|
||||
video_data: Raw video file bytes.
|
||||
name: Descriptive name for error messages.
|
||||
|
||||
Raises:
|
||||
VideoValidationError: If validation fails.
|
||||
"""
|
||||
result = validate_video(video_data, name)
|
||||
if not result.is_valid:
|
||||
raise VideoValidationError(result.error_message)
|
||||
|
||||
|
||||
def validate_video_embed_mode(mode: str) -> ValidationResult:
|
||||
"""Validate video embedding mode string.
|
||||
|
||||
Args:
|
||||
mode: Embedding mode to validate.
|
||||
|
||||
Returns:
|
||||
ValidationResult with mode in details on success.
|
||||
"""
|
||||
valid_modes = VALID_VIDEO_EMBED_MODES | {EMBED_MODE_VIDEO_AUTO}
|
||||
if mode not in valid_modes:
|
||||
return ValidationResult.error(
|
||||
f"Invalid video embed_mode: '{mode}'. "
|
||||
f"Valid options: {', '.join(sorted(valid_modes))}"
|
||||
)
|
||||
return ValidationResult.ok(mode=mode)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CAPACITY CALCULATION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def calculate_video_capacity(video_data: bytes, embed_mode: str = EMBED_MODE_VIDEO_LSB) -> VideoCapacityInfo:
|
||||
"""Calculate steganographic capacity for a video file.
|
||||
|
||||
Capacity is based on I-frames only (keyframes). Each I-frame provides
|
||||
capacity similar to an image of the same dimensions.
|
||||
|
||||
Args:
|
||||
video_data: Raw video file bytes.
|
||||
embed_mode: Embedding mode (currently only video_lsb).
|
||||
|
||||
Returns:
|
||||
VideoCapacityInfo with capacity details.
|
||||
"""
|
||||
info = get_video_info(video_data)
|
||||
|
||||
# Calculate capacity per I-frame
|
||||
# RGB image: 3 bits per pixel (1 bit per channel) / 8 = 0.375 bytes per pixel
|
||||
# Subtract overhead per frame for header
|
||||
pixels_per_frame = info.width * info.height
|
||||
bytes_per_frame = (pixels_per_frame * 3) // 8 # 3 bits per pixel
|
||||
|
||||
# Total capacity across all I-frames
|
||||
# Subtract 70 bytes overhead for the encrypted payload header
|
||||
from .steganography import ENCRYPTION_OVERHEAD
|
||||
|
||||
total_capacity = (bytes_per_frame * info.i_frame_count) - ENCRYPTION_OVERHEAD
|
||||
|
||||
return VideoCapacityInfo(
|
||||
total_frames=info.total_frames,
|
||||
i_frames=info.i_frame_count,
|
||||
usable_capacity_bytes=max(0, total_capacity),
|
||||
embed_mode=embed_mode,
|
||||
resolution=(info.width, info.height),
|
||||
duration_seconds=info.duration_seconds,
|
||||
)
|
||||
BIN
test_data/stupid_elitist_speech.wav
Normal file
BIN
test_data/stupid_elitist_speech.wav
Normal file
Binary file not shown.
862
tests/test_audio.py
Normal file
862
tests/test_audio.py
Normal file
@@ -0,0 +1,862 @@
|
||||
"""
|
||||
Tests for Stegasoo audio steganography.
|
||||
|
||||
Tests cover:
|
||||
- Audio LSB roundtrip (encode + decode)
|
||||
- Audio spread spectrum roundtrip (v0 legacy + v2 per-channel)
|
||||
- Wrong credentials fail to decode
|
||||
- Capacity calculations (per-tier)
|
||||
- Format detection
|
||||
- Audio validation
|
||||
- Per-channel stereo/multichannel embedding (v4.4.0)
|
||||
- Chip tier roundtrips (v4.4.0)
|
||||
- LFE channel skipping (v4.4.0)
|
||||
- Backward compat: v0 decode from v2 code
|
||||
- Header v2 build/parse roundtrip
|
||||
- Round-robin bit distribution
|
||||
"""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
|
||||
from stegasoo.constants import AUDIO_ENABLED, EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_SPREAD
|
||||
from stegasoo.models import AudioCapacityInfo, AudioEmbedStats, AudioInfo
|
||||
|
||||
pytestmark = pytest.mark.skipif(not AUDIO_ENABLED, reason="Audio support disabled (STEGASOO_AUDIO)")
|
||||
|
||||
# Path to real test data files
|
||||
_TEST_DATA = Path(__file__).parent.parent / "test_data"
|
||||
_REFERENCE_PNG = _TEST_DATA / "reference.png"
|
||||
_SPEECH_WAV = _TEST_DATA / "stupid_elitist_speech.wav"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def carrier_wav() -> bytes:
|
||||
"""Generate a small test WAV file (1 second, 44100 Hz, mono, 16-bit)."""
|
||||
sample_rate = 44100
|
||||
duration = 1.0
|
||||
num_samples = int(sample_rate * duration)
|
||||
t = np.linspace(0, duration, num_samples, endpoint=False)
|
||||
samples = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16)
|
||||
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def carrier_wav_stereo() -> bytes:
|
||||
"""Generate a stereo test WAV file (5 seconds for spread spectrum capacity)."""
|
||||
sample_rate = 44100
|
||||
duration = 5.0
|
||||
num_samples = int(sample_rate * duration)
|
||||
t = np.linspace(0, duration, num_samples, endpoint=False)
|
||||
left = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16)
|
||||
right = (np.sin(2 * np.pi * 880 * t) * 16000).astype(np.int16)
|
||||
samples = np.column_stack([left, right])
|
||||
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def carrier_wav_long() -> bytes:
|
||||
"""Generate a longer WAV (15 seconds) for spread spectrum tests."""
|
||||
sample_rate = 44100
|
||||
duration = 15.0
|
||||
num_samples = int(sample_rate * duration)
|
||||
t = np.linspace(0, duration, num_samples, endpoint=False)
|
||||
samples = (
|
||||
(np.sin(2 * np.pi * 440 * t) + np.sin(2 * np.pi * 880 * t) + np.sin(2 * np.pi * 1320 * t))
|
||||
* 5000
|
||||
).astype(np.int16)
|
||||
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def carrier_wav_stereo_long() -> bytes:
|
||||
"""Generate a stereo WAV (15 seconds) for per-channel spread tests."""
|
||||
sample_rate = 48000
|
||||
duration = 15.0
|
||||
num_samples = int(sample_rate * duration)
|
||||
t = np.linspace(0, duration, num_samples, endpoint=False)
|
||||
left = (np.sin(2 * np.pi * 440 * t) * 10000).astype(np.float64) / 32768.0
|
||||
right = (np.sin(2 * np.pi * 660 * t) * 10000).astype(np.float64) / 32768.0
|
||||
samples = np.column_stack([left, right])
|
||||
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def carrier_wav_5_1() -> bytes:
|
||||
"""Generate a 6-channel (5.1) WAV for LFE skip tests."""
|
||||
sample_rate = 48000
|
||||
duration = 15.0
|
||||
num_samples = int(sample_rate * duration)
|
||||
t = np.linspace(0, duration, num_samples, endpoint=False)
|
||||
|
||||
# 6 channels with different frequencies
|
||||
freqs = [440, 554, 660, 80, 880, 1100] # ch3 = LFE (low freq)
|
||||
channels = []
|
||||
for freq in freqs:
|
||||
ch = (np.sin(2 * np.pi * freq * t) * 8000).astype(np.float64) / 32768.0
|
||||
channels.append(ch)
|
||||
samples = np.column_stack(channels)
|
||||
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def carrier_wav_spread_integration() -> bytes:
|
||||
"""Generate a very long WAV (150 seconds) for spread spectrum integration tests."""
|
||||
sample_rate = 44100
|
||||
duration = 150.0
|
||||
num_samples = int(sample_rate * duration)
|
||||
t = np.linspace(0, duration, num_samples, endpoint=False)
|
||||
samples = (
|
||||
(np.sin(2 * np.pi * 440 * t) + np.sin(2 * np.pi * 880 * t) + np.sin(2 * np.pi * 1320 * t))
|
||||
* 5000
|
||||
).astype(np.int16)
|
||||
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reference_photo() -> bytes:
|
||||
"""Load real reference photo from test_data, or generate a small one."""
|
||||
if _REFERENCE_PNG.exists():
|
||||
return _REFERENCE_PNG.read_bytes()
|
||||
from PIL import Image
|
||||
|
||||
img = Image.new("RGB", (100, 100), color=(128, 64, 32))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, "PNG")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def speech_wav() -> bytes:
|
||||
"""Load real speech WAV from test_data (48kHz mono, ~68s)."""
|
||||
if not _SPEECH_WAV.exists():
|
||||
pytest.skip("test_data/stupid_elitist_speech.wav not found")
|
||||
return _SPEECH_WAV.read_bytes()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AUDIO LSB TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestAudioLSB:
|
||||
"""Tests for audio LSB steganography."""
|
||||
|
||||
def test_calculate_capacity(self, carrier_wav):
|
||||
from stegasoo.audio_steganography import calculate_audio_lsb_capacity
|
||||
|
||||
capacity = calculate_audio_lsb_capacity(carrier_wav)
|
||||
assert capacity > 0
|
||||
# 1 second at 44100 Hz mono should give ~5KB capacity at 1 bit/sample
|
||||
assert capacity > 4000
|
||||
|
||||
def test_embed_extract_roundtrip(self, carrier_wav):
|
||||
"""Test basic LSB embed/extract roundtrip."""
|
||||
from stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb
|
||||
|
||||
payload = b"Hello, audio steganography!"
|
||||
key = b"\x42" * 32
|
||||
|
||||
stego_audio, stats = embed_in_audio_lsb(payload, carrier_wav, key)
|
||||
|
||||
assert isinstance(stats, AudioEmbedStats)
|
||||
assert stats.embed_mode == EMBED_MODE_AUDIO_LSB
|
||||
assert stats.bytes_embedded > 0
|
||||
assert stats.samples_modified > 0
|
||||
assert 0 < stats.capacity_used <= 1.0
|
||||
|
||||
extracted = extract_from_audio_lsb(stego_audio, key)
|
||||
assert extracted is not None
|
||||
assert extracted == payload
|
||||
|
||||
def test_embed_extract_stereo(self, carrier_wav_stereo):
|
||||
"""Test LSB roundtrip with stereo audio."""
|
||||
from stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb
|
||||
|
||||
payload = b"Stereo test message"
|
||||
key = b"\xAB" * 32
|
||||
|
||||
stego_audio, stats = embed_in_audio_lsb(payload, carrier_wav_stereo, key)
|
||||
assert stats.channels == 2
|
||||
|
||||
extracted = extract_from_audio_lsb(stego_audio, key)
|
||||
assert extracted == payload
|
||||
|
||||
def test_wrong_key_fails(self, carrier_wav):
|
||||
"""Test that wrong key produces no valid extraction."""
|
||||
from stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb
|
||||
|
||||
payload = b"Secret message"
|
||||
correct_key = b"\x42" * 32
|
||||
wrong_key = b"\xFF" * 32
|
||||
|
||||
stego_audio, _ = embed_in_audio_lsb(payload, carrier_wav, correct_key)
|
||||
|
||||
extracted = extract_from_audio_lsb(stego_audio, wrong_key)
|
||||
assert extracted is None or extracted != payload
|
||||
|
||||
def test_two_bits_per_sample(self, carrier_wav):
|
||||
"""Test embedding with 2 bits per sample."""
|
||||
from stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb
|
||||
|
||||
payload = b"Two bits per sample test"
|
||||
key = b"\x55" * 32
|
||||
|
||||
stego_audio, stats = embed_in_audio_lsb(payload, carrier_wav, key, bits_per_sample=2)
|
||||
|
||||
extracted = extract_from_audio_lsb(stego_audio, key, bits_per_sample=2)
|
||||
assert extracted == payload
|
||||
|
||||
def test_generate_sample_indices(self):
|
||||
"""Test deterministic sample index generation."""
|
||||
from stegasoo.audio_steganography import generate_sample_indices
|
||||
|
||||
key = b"\x42" * 32
|
||||
indices1 = generate_sample_indices(key, 10000, 100)
|
||||
indices2 = generate_sample_indices(key, 10000, 100)
|
||||
|
||||
assert indices1 == indices2
|
||||
assert all(0 <= i < 10000 for i in indices1)
|
||||
assert len(set(indices1)) == len(indices1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AUDIO SPREAD SPECTRUM TESTS (v2 per-channel)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestAudioSpread:
|
||||
"""Tests for audio spread spectrum steganography (v2 per-channel)."""
|
||||
|
||||
def test_calculate_capacity_default_tier(self, carrier_wav_long):
|
||||
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
||||
|
||||
capacity = calculate_audio_spread_capacity(carrier_wav_long)
|
||||
assert isinstance(capacity, AudioCapacityInfo)
|
||||
assert capacity.usable_capacity_bytes > 0
|
||||
assert capacity.embed_mode == EMBED_MODE_AUDIO_SPREAD
|
||||
assert capacity.chip_tier == 2 # default
|
||||
assert capacity.chip_length == 1024
|
||||
|
||||
def test_calculate_capacity_per_tier(self, carrier_wav_long):
|
||||
"""Capacity should increase as chip length decreases."""
|
||||
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
||||
|
||||
cap_lossless = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=0)
|
||||
cap_high = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=1)
|
||||
cap_low = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=2)
|
||||
|
||||
assert cap_lossless.chip_length == 256
|
||||
assert cap_high.chip_length == 512
|
||||
assert cap_low.chip_length == 1024
|
||||
|
||||
# Smaller chip = more capacity
|
||||
assert cap_lossless.usable_capacity_bytes > cap_high.usable_capacity_bytes
|
||||
assert cap_high.usable_capacity_bytes > cap_low.usable_capacity_bytes
|
||||
|
||||
def test_spread_roundtrip_default_tier(self, carrier_wav_long):
|
||||
"""Test spread spectrum embed/extract roundtrip (default tier 2)."""
|
||||
from stegasoo.spread_steganography import (
|
||||
embed_in_audio_spread,
|
||||
extract_from_audio_spread,
|
||||
)
|
||||
|
||||
payload = b"Spread test v2"
|
||||
seed = b"\x42" * 32
|
||||
|
||||
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed)
|
||||
|
||||
assert isinstance(stats, AudioEmbedStats)
|
||||
assert stats.embed_mode == EMBED_MODE_AUDIO_SPREAD
|
||||
assert stats.chip_tier == 2
|
||||
assert stats.chip_length == 1024
|
||||
|
||||
extracted = extract_from_audio_spread(stego_audio, seed)
|
||||
assert extracted is not None
|
||||
assert extracted == payload
|
||||
|
||||
def test_spread_roundtrip_tier_0(self, carrier_wav_long):
|
||||
"""Test spread spectrum at tier 0 (chip=256, lossless)."""
|
||||
from stegasoo.spread_steganography import (
|
||||
embed_in_audio_spread,
|
||||
extract_from_audio_spread,
|
||||
)
|
||||
|
||||
payload = b"Lossless tier test with more data to embed for coverage"
|
||||
seed = b"\x42" * 32
|
||||
|
||||
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed, chip_tier=0)
|
||||
assert stats.chip_tier == 0
|
||||
assert stats.chip_length == 256
|
||||
|
||||
extracted = extract_from_audio_spread(stego_audio, seed)
|
||||
assert extracted is not None
|
||||
assert extracted == payload
|
||||
|
||||
def test_spread_roundtrip_tier_1(self, carrier_wav_long):
|
||||
"""Test spread spectrum at tier 1 (chip=512, high lossy)."""
|
||||
from stegasoo.spread_steganography import (
|
||||
embed_in_audio_spread,
|
||||
extract_from_audio_spread,
|
||||
)
|
||||
|
||||
payload = b"High lossy tier test"
|
||||
seed = b"\x42" * 32
|
||||
|
||||
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed, chip_tier=1)
|
||||
assert stats.chip_tier == 1
|
||||
assert stats.chip_length == 512
|
||||
|
||||
extracted = extract_from_audio_spread(stego_audio, seed)
|
||||
assert extracted is not None
|
||||
assert extracted == payload
|
||||
|
||||
def test_wrong_seed_fails(self, carrier_wav_long):
|
||||
"""Test that wrong seed produces no valid extraction."""
|
||||
from stegasoo.spread_steganography import (
|
||||
embed_in_audio_spread,
|
||||
extract_from_audio_spread,
|
||||
)
|
||||
|
||||
payload = b"Secret spread"
|
||||
correct_seed = b"\x42" * 32
|
||||
wrong_seed = b"\xFF" * 32
|
||||
|
||||
stego_audio, _ = embed_in_audio_spread(payload, carrier_wav_long, correct_seed)
|
||||
|
||||
extracted = extract_from_audio_spread(stego_audio, wrong_seed)
|
||||
assert extracted is None or extracted != payload
|
||||
|
||||
def test_per_channel_stereo_roundtrip(self, carrier_wav_stereo_long):
|
||||
"""Test that stereo per-channel embedding/extraction works."""
|
||||
from stegasoo.spread_steganography import (
|
||||
embed_in_audio_spread,
|
||||
extract_from_audio_spread,
|
||||
)
|
||||
|
||||
payload = b"Stereo per-channel test"
|
||||
seed = b"\xAB" * 32
|
||||
|
||||
stego_audio, stats = embed_in_audio_spread(
|
||||
payload, carrier_wav_stereo_long, seed, chip_tier=0
|
||||
)
|
||||
assert stats.channels == 2
|
||||
assert stats.embeddable_channels == 2
|
||||
|
||||
extracted = extract_from_audio_spread(stego_audio, seed)
|
||||
assert extracted is not None
|
||||
assert extracted == payload
|
||||
|
||||
def test_per_channel_preserves_spatial_mix(self, carrier_wav_stereo_long):
|
||||
"""Verify that per-channel embedding doesn't destroy the spatial mix.
|
||||
|
||||
The difference between left and right channels should be preserved
|
||||
(not zeroed out as the old mono-broadcast approach would do).
|
||||
"""
|
||||
from stegasoo.spread_steganography import embed_in_audio_spread
|
||||
|
||||
payload = b"Spatial preservation test"
|
||||
seed = b"\xCD" * 32
|
||||
|
||||
# Read original
|
||||
orig_samples, _ = sf.read(io.BytesIO(carrier_wav_stereo_long), dtype="float64", always_2d=True)
|
||||
orig_diff = orig_samples[:, 0] - orig_samples[:, 1]
|
||||
|
||||
# Embed
|
||||
stego_bytes, _ = embed_in_audio_spread(
|
||||
payload, carrier_wav_stereo_long, seed, chip_tier=0
|
||||
)
|
||||
|
||||
# Read stego
|
||||
stego_samples, _ = sf.read(io.BytesIO(stego_bytes), dtype="float64", always_2d=True)
|
||||
stego_diff = stego_samples[:, 0] - stego_samples[:, 1]
|
||||
|
||||
# The channel difference should not be identical (embedding adds different
|
||||
# noise per channel), but should be very close (embedding is subtle)
|
||||
# With the old mono-broadcast approach, stego_diff would equal orig_diff
|
||||
# exactly in unmodified regions but differ where data was embedded.
|
||||
# With per-channel, both channels get independent modifications.
|
||||
correlation = np.corrcoef(orig_diff, stego_diff)[0, 1]
|
||||
assert correlation > 0.95, f"Spatial mix correlation too low: {correlation}"
|
||||
|
||||
def test_capacity_scales_with_channels(self, carrier_wav_long, carrier_wav_stereo_long):
|
||||
"""Stereo should have roughly double the capacity of mono."""
|
||||
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
||||
|
||||
mono_cap = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=0)
|
||||
stereo_cap = calculate_audio_spread_capacity(carrier_wav_stereo_long, chip_tier=0)
|
||||
|
||||
# Stereo should be ~1.5-2.2x mono (not exact because header is ch0 only
|
||||
# and the files have slightly different durations/sample rates)
|
||||
ratio = stereo_cap.usable_capacity_bytes / mono_cap.usable_capacity_bytes
|
||||
assert ratio > 1.3, f"Stereo/mono capacity ratio too low: {ratio}"
|
||||
|
||||
def test_lfe_skip_5_1(self, carrier_wav_5_1):
|
||||
"""LFE channel (index 3) should be unmodified in 6-channel audio."""
|
||||
from stegasoo.spread_steganography import embed_in_audio_spread
|
||||
|
||||
payload = b"LFE skip test"
|
||||
seed = b"\xEE" * 32
|
||||
|
||||
# Read original LFE channel
|
||||
orig_samples, _ = sf.read(io.BytesIO(carrier_wav_5_1), dtype="float64", always_2d=True)
|
||||
orig_lfe = orig_samples[:, 3].copy()
|
||||
|
||||
stego_bytes, stats = embed_in_audio_spread(
|
||||
payload, carrier_wav_5_1, seed, chip_tier=0
|
||||
)
|
||||
assert stats.embeddable_channels == 5 # 6 channels - 1 LFE = 5
|
||||
|
||||
stego_samples, _ = sf.read(io.BytesIO(stego_bytes), dtype="float64", always_2d=True)
|
||||
stego_lfe = stego_samples[:, 3]
|
||||
|
||||
# LFE channel should be completely unmodified
|
||||
np.testing.assert_array_equal(orig_lfe, stego_lfe)
|
||||
|
||||
def test_lfe_skip_roundtrip(self, carrier_wav_5_1):
|
||||
"""5.1 audio embed/extract roundtrip with LFE skipping."""
|
||||
from stegasoo.spread_steganography import (
|
||||
embed_in_audio_spread,
|
||||
extract_from_audio_spread,
|
||||
)
|
||||
|
||||
payload = b"5.1 surround test"
|
||||
seed = b"\xEE" * 32
|
||||
|
||||
stego_bytes, stats = embed_in_audio_spread(
|
||||
payload, carrier_wav_5_1, seed, chip_tier=0
|
||||
)
|
||||
assert stats.channels == 6
|
||||
assert stats.embeddable_channels == 5
|
||||
|
||||
extracted = extract_from_audio_spread(stego_bytes, seed)
|
||||
assert extracted is not None
|
||||
assert extracted == payload
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HEADER V2 TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestHeaderV2:
|
||||
"""Tests for v2 header construction and parsing."""
|
||||
|
||||
def test_header_v2_build_parse_roundtrip(self):
|
||||
from stegasoo.spread_steganography import _build_header_v2, _parse_header
|
||||
|
||||
data_length = 12345
|
||||
chip_tier = 1
|
||||
num_ch = 2
|
||||
lfe_skipped = False
|
||||
|
||||
header = _build_header_v2(data_length, chip_tier, num_ch, lfe_skipped)
|
||||
assert len(header) == 20
|
||||
|
||||
magic_valid, version, length, tier, nch, lfe = _parse_header(header)
|
||||
assert magic_valid
|
||||
assert version == 2
|
||||
assert length == data_length
|
||||
assert tier == chip_tier
|
||||
assert nch == num_ch
|
||||
assert lfe is False
|
||||
|
||||
def test_header_v2_with_lfe_flag(self):
|
||||
from stegasoo.spread_steganography import _build_header_v2, _parse_header
|
||||
|
||||
header = _build_header_v2(999, 0, 5, lfe_skipped=True)
|
||||
magic_valid, version, length, tier, nch, lfe = _parse_header(header)
|
||||
assert magic_valid
|
||||
assert version == 2
|
||||
assert length == 999
|
||||
assert tier == 0
|
||||
assert nch == 5
|
||||
assert lfe is True
|
||||
|
||||
def test_header_v0_build_parse(self):
|
||||
from stegasoo.spread_steganography import _build_header_v0, _parse_header
|
||||
|
||||
header = _build_header_v0(4567)
|
||||
assert len(header) == 16
|
||||
|
||||
magic_valid, version, length, tier, nch, lfe = _parse_header(header)
|
||||
assert magic_valid
|
||||
assert version == 0
|
||||
assert length == 4567
|
||||
assert tier is None
|
||||
assert nch is None
|
||||
|
||||
def test_header_bad_magic(self):
|
||||
from stegasoo.spread_steganography import _parse_header
|
||||
|
||||
bad_header = b"XXXX" + b"\x00" * 16
|
||||
magic_valid, version, length, tier, nch, lfe = _parse_header(bad_header)
|
||||
assert not magic_valid
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ROUND-ROBIN BIT DISTRIBUTION TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestRoundRobin:
|
||||
"""Tests for round-robin bit distribution."""
|
||||
|
||||
def test_distribute_and_collect_identity(self):
|
||||
from stegasoo.spread_steganography import (
|
||||
_collect_bits_round_robin,
|
||||
_distribute_bits_round_robin,
|
||||
)
|
||||
|
||||
bits = [1, 0, 1, 1, 0, 0, 1, 0, 1, 1]
|
||||
for num_ch in [1, 2, 3, 4, 5]:
|
||||
per_ch = _distribute_bits_round_robin(bits, num_ch)
|
||||
assert len(per_ch) == num_ch
|
||||
reassembled = _collect_bits_round_robin(per_ch)
|
||||
assert reassembled == bits, f"Failed for {num_ch} channels"
|
||||
|
||||
def test_distribute_round_robin_ordering(self):
|
||||
from stegasoo.spread_steganography import _distribute_bits_round_robin
|
||||
|
||||
bits = [0, 1, 2, 3, 4, 5] # using ints for clarity
|
||||
per_ch = _distribute_bits_round_robin(bits, 3)
|
||||
# ch0: bits 0, 3 ch1: bits 1, 4 ch2: bits 2, 5
|
||||
assert per_ch[0] == [0, 3]
|
||||
assert per_ch[1] == [1, 4]
|
||||
assert per_ch[2] == [2, 5]
|
||||
|
||||
def test_distribute_uneven(self):
|
||||
from stegasoo.spread_steganography import (
|
||||
_collect_bits_round_robin,
|
||||
_distribute_bits_round_robin,
|
||||
)
|
||||
|
||||
bits = [0, 1, 2, 3, 4] # 5 bits across 3 channels
|
||||
per_ch = _distribute_bits_round_robin(bits, 3)
|
||||
assert per_ch[0] == [0, 3]
|
||||
assert per_ch[1] == [1, 4]
|
||||
assert per_ch[2] == [2]
|
||||
|
||||
reassembled = _collect_bits_round_robin(per_ch)
|
||||
assert reassembled == bits
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CHANNEL MANAGEMENT TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestChannelManagement:
|
||||
"""Tests for embeddable channel selection."""
|
||||
|
||||
def test_mono(self):
|
||||
from stegasoo.spread_steganography import _embeddable_channels
|
||||
|
||||
assert _embeddable_channels(1) == [0]
|
||||
|
||||
def test_stereo(self):
|
||||
from stegasoo.spread_steganography import _embeddable_channels
|
||||
|
||||
assert _embeddable_channels(2) == [0, 1]
|
||||
|
||||
def test_5_1_skips_lfe(self):
|
||||
from stegasoo.spread_steganography import _embeddable_channels
|
||||
|
||||
channels = _embeddable_channels(6)
|
||||
assert channels == [0, 1, 2, 4, 5]
|
||||
assert 3 not in channels # LFE skipped
|
||||
|
||||
def test_7_1_skips_lfe(self):
|
||||
from stegasoo.spread_steganography import _embeddable_channels
|
||||
|
||||
channels = _embeddable_channels(8)
|
||||
assert 3 not in channels
|
||||
assert len(channels) == 7
|
||||
|
||||
def test_quad_no_skip(self):
|
||||
from stegasoo.spread_steganography import _embeddable_channels
|
||||
|
||||
# 4 channels < 6, so no LFE skip
|
||||
assert _embeddable_channels(4) == [0, 1, 2, 3]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FORMAT DETECTION TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFormatDetection:
|
||||
"""Tests for audio format detection."""
|
||||
|
||||
def test_detect_wav(self, carrier_wav):
|
||||
from stegasoo.audio_utils import detect_audio_format
|
||||
|
||||
assert detect_audio_format(carrier_wav) == "wav"
|
||||
|
||||
def test_detect_unknown(self):
|
||||
from stegasoo.audio_utils import detect_audio_format
|
||||
|
||||
assert detect_audio_format(b"not audio data") == "unknown"
|
||||
|
||||
def test_detect_empty(self):
|
||||
from stegasoo.audio_utils import detect_audio_format
|
||||
|
||||
assert detect_audio_format(b"") == "unknown"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AUDIO INFO TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestAudioInfo:
|
||||
"""Tests for audio info extraction."""
|
||||
|
||||
def test_get_wav_info(self, carrier_wav):
|
||||
from stegasoo.audio_utils import get_audio_info
|
||||
|
||||
info = get_audio_info(carrier_wav)
|
||||
assert isinstance(info, AudioInfo)
|
||||
assert info.sample_rate == 44100
|
||||
assert info.channels == 1
|
||||
assert info.format == "wav"
|
||||
assert abs(info.duration_seconds - 1.0) < 0.1
|
||||
|
||||
def test_get_stereo_info(self, carrier_wav_stereo):
|
||||
from stegasoo.audio_utils import get_audio_info
|
||||
|
||||
info = get_audio_info(carrier_wav_stereo)
|
||||
assert info.channels == 2
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VALIDATION TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestAudioValidation:
|
||||
"""Tests for audio validation."""
|
||||
|
||||
def test_validate_valid_audio(self, carrier_wav):
|
||||
from stegasoo.audio_utils import validate_audio
|
||||
|
||||
result = validate_audio(carrier_wav)
|
||||
assert result.is_valid
|
||||
|
||||
def test_validate_empty_audio(self):
|
||||
from stegasoo.audio_utils import validate_audio
|
||||
|
||||
result = validate_audio(b"")
|
||||
assert not result.is_valid
|
||||
|
||||
def test_validate_invalid_audio(self):
|
||||
from stegasoo.audio_utils import validate_audio
|
||||
|
||||
result = validate_audio(b"not audio data at all")
|
||||
assert not result.is_valid
|
||||
|
||||
def test_validate_audio_embed_mode(self):
|
||||
from stegasoo.validation import validate_audio_embed_mode
|
||||
|
||||
assert validate_audio_embed_mode("audio_lsb").is_valid
|
||||
assert validate_audio_embed_mode("audio_spread").is_valid
|
||||
assert validate_audio_embed_mode("audio_auto").is_valid
|
||||
assert not validate_audio_embed_mode("invalid").is_valid
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTEGRATION TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""End-to-end integration tests using encode_audio/decode_audio."""
|
||||
|
||||
def test_lsb_encode_decode(self, carrier_wav, reference_photo):
|
||||
from stegasoo.decode import decode_audio
|
||||
from stegasoo.encode import encode_audio
|
||||
|
||||
stego_audio, stats = encode_audio(
|
||||
message="Hello from audio steganography!",
|
||||
reference_photo=reference_photo,
|
||||
carrier_audio=carrier_wav,
|
||||
passphrase="test words here now",
|
||||
pin="123456",
|
||||
embed_mode="audio_lsb",
|
||||
)
|
||||
|
||||
assert len(stego_audio) > 0
|
||||
|
||||
result = decode_audio(
|
||||
stego_audio=stego_audio,
|
||||
reference_photo=reference_photo,
|
||||
passphrase="test words here now",
|
||||
pin="123456",
|
||||
embed_mode="audio_lsb",
|
||||
)
|
||||
|
||||
assert result.is_text
|
||||
assert result.message == "Hello from audio steganography!"
|
||||
|
||||
def test_lsb_wrong_credentials(self, carrier_wav, reference_photo):
|
||||
from stegasoo.decode import decode_audio
|
||||
from stegasoo.encode import encode_audio
|
||||
|
||||
stego_audio, _ = encode_audio(
|
||||
message="Secret",
|
||||
reference_photo=reference_photo,
|
||||
carrier_audio=carrier_wav,
|
||||
passphrase="correct horse battery staple",
|
||||
pin="123456",
|
||||
embed_mode="audio_lsb",
|
||||
)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
decode_audio(
|
||||
stego_audio=stego_audio,
|
||||
reference_photo=reference_photo,
|
||||
passphrase="wrong passphrase words here",
|
||||
pin="654321",
|
||||
embed_mode="audio_lsb",
|
||||
)
|
||||
|
||||
def test_spread_encode_decode(self, carrier_wav_spread_integration, reference_photo):
|
||||
"""Test full spread spectrum encode/decode pipeline."""
|
||||
from stegasoo.decode import decode_audio
|
||||
from stegasoo.encode import encode_audio
|
||||
|
||||
stego_audio, stats = encode_audio(
|
||||
message="Spread integration test",
|
||||
reference_photo=reference_photo,
|
||||
carrier_audio=carrier_wav_spread_integration,
|
||||
passphrase="test words here now",
|
||||
pin="123456",
|
||||
embed_mode="audio_spread",
|
||||
)
|
||||
|
||||
result = decode_audio(
|
||||
stego_audio=stego_audio,
|
||||
reference_photo=reference_photo,
|
||||
passphrase="test words here now",
|
||||
pin="123456",
|
||||
embed_mode="audio_spread",
|
||||
)
|
||||
|
||||
assert result.message == "Spread integration test"
|
||||
|
||||
def test_spread_encode_decode_with_chip_tier(
|
||||
self, carrier_wav_spread_integration, reference_photo
|
||||
):
|
||||
"""Test spread spectrum with explicit chip tier."""
|
||||
from stegasoo.decode import decode_audio
|
||||
from stegasoo.encode import encode_audio
|
||||
|
||||
stego_audio, stats = encode_audio(
|
||||
message="Tier 0 integration",
|
||||
reference_photo=reference_photo,
|
||||
carrier_audio=carrier_wav_spread_integration,
|
||||
passphrase="test words here now",
|
||||
pin="123456",
|
||||
embed_mode="audio_spread",
|
||||
chip_tier=0,
|
||||
)
|
||||
|
||||
assert stats.chip_tier == 0
|
||||
assert stats.chip_length == 256
|
||||
|
||||
result = decode_audio(
|
||||
stego_audio=stego_audio,
|
||||
reference_photo=reference_photo,
|
||||
passphrase="test words here now",
|
||||
pin="123456",
|
||||
embed_mode="audio_spread",
|
||||
)
|
||||
|
||||
assert result.message == "Tier 0 integration"
|
||||
|
||||
def test_auto_detect_lsb(self, carrier_wav, reference_photo):
|
||||
"""Test auto-detection finds LSB encoded audio."""
|
||||
from stegasoo.decode import decode_audio
|
||||
from stegasoo.encode import encode_audio
|
||||
|
||||
stego_audio, _ = encode_audio(
|
||||
message="Auto-detect test",
|
||||
reference_photo=reference_photo,
|
||||
carrier_audio=carrier_wav,
|
||||
passphrase="test words here now",
|
||||
pin="123456",
|
||||
embed_mode="audio_lsb",
|
||||
)
|
||||
|
||||
result = decode_audio(
|
||||
stego_audio=stego_audio,
|
||||
reference_photo=reference_photo,
|
||||
passphrase="test words here now",
|
||||
pin="123456",
|
||||
embed_mode="audio_auto",
|
||||
)
|
||||
|
||||
assert result.message == "Auto-detect test"
|
||||
|
||||
def test_spread_with_real_speech(self, speech_wav, reference_photo):
|
||||
"""Test spread spectrum with real speech audio from test_data."""
|
||||
from stegasoo.decode import decode_audio
|
||||
from stegasoo.encode import encode_audio
|
||||
|
||||
message = "Hidden in a speech about elitism"
|
||||
|
||||
stego_audio, stats = encode_audio(
|
||||
message=message,
|
||||
reference_photo=reference_photo,
|
||||
carrier_audio=speech_wav,
|
||||
passphrase="test words here now",
|
||||
pin="123456",
|
||||
embed_mode="audio_spread",
|
||||
chip_tier=0, # lossless tier for max capacity
|
||||
)
|
||||
|
||||
assert stats.chip_tier == 0
|
||||
|
||||
result = decode_audio(
|
||||
stego_audio=stego_audio,
|
||||
reference_photo=reference_photo,
|
||||
passphrase="test words here now",
|
||||
pin="123456",
|
||||
embed_mode="audio_spread",
|
||||
)
|
||||
|
||||
assert result.message == message
|
||||
@@ -451,3 +451,231 @@ class TestEdgeCases:
|
||||
)
|
||||
|
||||
assert decoded.message == special_msg
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VIDEO STEGANOGRAPHY TESTS (v4.4.0)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_video_bytes():
|
||||
"""Create a minimal test video using ffmpeg.
|
||||
|
||||
Creates a 2-second test video with solid color frames.
|
||||
Returns None if ffmpeg is not available.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
if not shutil.which("ffmpeg"):
|
||||
return None
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
|
||||
output_path = f.name
|
||||
|
||||
try:
|
||||
# Create a simple 2-second video with colored frames
|
||||
# Using lavfi (libavfilter) to generate test pattern
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=blue:s=320x240:d=2:r=10",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-g",
|
||||
"5", # GOP size - creates I-frames every 5 frames
|
||||
output_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
with open(output_path, "rb") as f:
|
||||
video_data = f.read()
|
||||
|
||||
return video_data
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
import os
|
||||
|
||||
try:
|
||||
os.unlink(output_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class TestVideoSupport:
|
||||
"""Test video steganography support detection."""
|
||||
|
||||
def test_video_support_flag_exists(self):
|
||||
"""HAS_VIDEO_SUPPORT flag should exist."""
|
||||
assert hasattr(stegasoo, "HAS_VIDEO_SUPPORT")
|
||||
assert isinstance(stegasoo.HAS_VIDEO_SUPPORT, bool)
|
||||
|
||||
def test_video_constants_exist(self):
|
||||
"""Video-related constants should exist."""
|
||||
assert hasattr(stegasoo, "EMBED_MODE_VIDEO_LSB")
|
||||
assert hasattr(stegasoo, "EMBED_MODE_VIDEO_AUTO")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not stegasoo.HAS_VIDEO_SUPPORT,
|
||||
reason="Video support not available (ffmpeg or dependencies missing)",
|
||||
)
|
||||
class TestVideoFormatDetection:
|
||||
"""Test video format detection."""
|
||||
|
||||
def test_detect_video_format_mp4(self, test_video_bytes):
|
||||
"""Should detect MP4 format from magic bytes."""
|
||||
if test_video_bytes is None:
|
||||
pytest.skip("Could not create test video")
|
||||
|
||||
from stegasoo import detect_video_format
|
||||
|
||||
fmt = detect_video_format(test_video_bytes)
|
||||
assert fmt in ("mp4", "mov")
|
||||
|
||||
def test_detect_video_format_unknown(self):
|
||||
"""Should return 'unknown' for non-video data."""
|
||||
from stegasoo import detect_video_format
|
||||
|
||||
fmt = detect_video_format(b"not a video")
|
||||
assert fmt == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not stegasoo.HAS_VIDEO_SUPPORT,
|
||||
reason="Video support not available (ffmpeg or dependencies missing)",
|
||||
)
|
||||
class TestVideoInfo:
|
||||
"""Test video metadata extraction."""
|
||||
|
||||
def test_get_video_info(self, test_video_bytes):
|
||||
"""Should extract video metadata."""
|
||||
if test_video_bytes is None:
|
||||
pytest.skip("Could not create test video")
|
||||
|
||||
from stegasoo import get_video_info
|
||||
|
||||
info = get_video_info(test_video_bytes)
|
||||
|
||||
assert info.width == 320
|
||||
assert info.height == 240
|
||||
assert info.fps > 0
|
||||
assert info.duration_seconds > 0
|
||||
assert info.total_frames > 0
|
||||
assert info.format in ("mp4", "mov")
|
||||
|
||||
def test_validate_video(self, test_video_bytes):
|
||||
"""Should validate video data."""
|
||||
if test_video_bytes is None:
|
||||
pytest.skip("Could not create test video")
|
||||
|
||||
from stegasoo import validate_video
|
||||
|
||||
result = validate_video(test_video_bytes, check_duration=False)
|
||||
|
||||
assert result.is_valid
|
||||
assert result.details.get("format") in ("mp4", "mov")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not stegasoo.HAS_VIDEO_SUPPORT,
|
||||
reason="Video support not available (ffmpeg or dependencies missing)",
|
||||
)
|
||||
class TestVideoCapacity:
|
||||
"""Test video capacity calculation."""
|
||||
|
||||
def test_calculate_video_capacity(self, test_video_bytes):
|
||||
"""Should calculate steganographic capacity."""
|
||||
if test_video_bytes is None:
|
||||
pytest.skip("Could not create test video")
|
||||
|
||||
from stegasoo import calculate_video_capacity
|
||||
|
||||
capacity_info = calculate_video_capacity(test_video_bytes)
|
||||
|
||||
assert capacity_info.total_frames > 0
|
||||
assert capacity_info.i_frames > 0
|
||||
assert capacity_info.usable_capacity_bytes > 0
|
||||
assert capacity_info.embed_mode == "video_lsb"
|
||||
assert capacity_info.resolution == (320, 240)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not stegasoo.HAS_VIDEO_SUPPORT,
|
||||
reason="Video support not available (ffmpeg or dependencies missing)",
|
||||
)
|
||||
class TestVideoEncodeDecode:
|
||||
"""Test video steganography round-trip."""
|
||||
|
||||
def test_video_roundtrip(self, test_video_bytes, ref_bytes):
|
||||
"""Test encoding and decoding a message in video."""
|
||||
if test_video_bytes is None:
|
||||
pytest.skip("Could not create test video")
|
||||
|
||||
from stegasoo import decode_video, encode_video
|
||||
|
||||
message = "Secret video message!"
|
||||
|
||||
# Encode
|
||||
stego_video, stats = encode_video(
|
||||
message=message,
|
||||
reference_photo=ref_bytes,
|
||||
carrier_video=test_video_bytes,
|
||||
passphrase=TEST_PASSPHRASE,
|
||||
pin=TEST_PIN,
|
||||
)
|
||||
|
||||
assert stego_video
|
||||
assert len(stego_video) > 0
|
||||
assert stats.frames_modified > 0
|
||||
assert stats.codec == "ffv1" # Should use lossless codec
|
||||
|
||||
# Decode
|
||||
result = decode_video(
|
||||
stego_video=stego_video,
|
||||
reference_photo=ref_bytes,
|
||||
passphrase=TEST_PASSPHRASE,
|
||||
pin=TEST_PIN,
|
||||
)
|
||||
|
||||
assert result.is_text
|
||||
assert result.message == message
|
||||
|
||||
def test_video_wrong_passphrase_fails(self, test_video_bytes, ref_bytes):
|
||||
"""Decoding with wrong passphrase should fail."""
|
||||
if test_video_bytes is None:
|
||||
pytest.skip("Could not create test video")
|
||||
|
||||
from stegasoo import decode_video, encode_video
|
||||
|
||||
message = "Secret video message!"
|
||||
|
||||
stego_video, _ = encode_video(
|
||||
message=message,
|
||||
reference_photo=ref_bytes,
|
||||
carrier_video=test_video_bytes,
|
||||
passphrase=TEST_PASSPHRASE,
|
||||
pin=TEST_PIN,
|
||||
)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
decode_video(
|
||||
stego_video=stego_video,
|
||||
reference_photo=ref_bytes,
|
||||
passphrase="wrong passphrase words here",
|
||||
pin=TEST_PIN,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user