8 Commits

Author SHA1 Message Date
adlee-was-taken
c970261e53 Merge branch 'main' of github.com:adlee-was-taken/stegasoo
Some checks failed
Lint / lint (push) Failing after 1m42s
Lint / typecheck (push) Failing after 30s
Tests / test (3.11) (push) Failing after 1m13s
Tests / test (3.12) (push) Failing after 42s
Tests / test (3.13) (push) Failing after 41s
2026-04-04 16:29:34 -04:00
adlee-was-taken
4607ff27dd Minor fixes 2026-04-04 16:29:20 -04:00
Aaron D. Lee
70b941d55a Pre-consolidation snapshot: backends, steganalysis, platform presets, and WIP changes
Snapshot of all uncommitted work before merging stegasoo into soosef monorepo.
Includes: pluggable backends registry, steganalysis detection, platform presets,
and various in-progress modifications across core modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:56:36 -04:00
Aaron D. Lee
14fce4d3ed More video work, planning, etc. -- Need to mark things EXPERIMENTAL. 2026-03-24 16:00:30 -04:00
adlee-was-taken
05382c4081 Merge carrier type toggle into Step 1 accordion, reduce to 3 steps
Remove the dedicated Carrier Type accordion step and merge the
Image/Audio toggle into the Carrier & Mode step. The toggle now sits
in a two-column row aligned with the embedding mode buttons. Steps
renumbered from 4 to 3, carrier label changed to "Carrier File",
mode hint updates on carrier type switch via dispatched change events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:27:02 -05:00
adlee-was-taken
ef5a9ce9cb Add per-channel hybrid audio spread spectrum and env feature toggles
Spread spectrum v2: independent per-channel embedding with round-robin
bit distribution, preserving spatial stereo/surround mix. Adaptive chip
tiers (256/512/1024) trade capacity for lossy codec robustness. LFE
channel skipped for 5.1+ layouts. v2 header (20B) with backward-
compatible v0 decode fallback.

Environment toggles (STEGASOO_AUDIO, STEGASOO_VIDEO) gate audio/video
features for minimal builds (e.g. Raspberry Pi image-only). Values:
auto (default, detect deps), 1/true (force on), 0/false (force off).

Web UI fixes: accordion defaults to step 1 on load, chevron arrow
styling, required attribute toggling for audio carrier type switch,
"Images & Mode" renamed to "Reference, Carrier, Mode".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:58:40 -05:00
adlee-was-taken
0248bec813 Add audio steganography with LSB and spread spectrum modes
Implement two audio embedding modes following the same multi-factor
authentication pipeline as image steganography (passphrase + PIN +
optional RSA key + optional channel key):

- audio_lsb: High-capacity LSB embedding in PCM samples for lossless
  formats (WAV/FLAC). Uses ChaCha20-keyed sample index selection.
- audio_spread: Direct-sequence spread spectrum (DSSS) with ChaCha20-
  keyed bipolar chip codes, Reed-Solomon error correction, and 3-copy
  majority-voted length headers. Designed to survive lossy compression.

New files:
- audio_steganography.py: LSB embed/extract on PCM samples
- spread_steganography.py: Spread spectrum embed/extract
- audio_utils.py: Format detection, transcoding, validation helpers
- tests/test_audio.py: 22 tests covering both modes end-to-end

Updated encode.py, decode.py, cli.py (audio-encode/audio-decode
commands), constants.py, models.py, exceptions.py, validation.py,
__init__.py, and pyproject.toml ([audio] extra).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 20:26:07 -05:00
adlee-was-taken
7aeb26e003 Add CLAUDE.md project guide and worktree documentation
CLAUDE.md gives Claude Code instant context on architecture, commands,
conventions, security-critical modules, and public API surface.
docs/CLAUDE_WORKTREES.md is a beginner-friendly guide to using Claude
Code with git worktrees for isolated feature work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:45:09 -05:00
72 changed files with 12723 additions and 614 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"church@church": true
}
}

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# Embedded repos (AUR packaging)
aur-cli-upload/
# Python
__pycache__/
*.py[cod]

View File

@@ -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
View 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

View 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

View File

@@ -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.
[![Tests](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml/badge.svg)](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml)
[![Lint](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml/badge.svg)](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 |

View File

@@ -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
View 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

View File

@@ -0,0 +1,3 @@
"""Sentiment analysis agent powered by Claude Agent SDK."""
__version__ = "0.1.0"

View 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.30.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

View File

@@ -0,0 +1 @@
"""API clients for social media and forum data sources."""

View 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}"

View 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

View 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

View 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)

View File

@@ -0,0 +1,398 @@
"""Credibility scoring and bot/disinfo detection.
Assigns a 0.01.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

View 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()

View 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)

View 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,
],
)

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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.

View File

@@ -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
# ============================================================================

View File

@@ -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()

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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;
},

View File

@@ -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}"}

View File

@@ -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,

View File

@@ -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
// ============================================================================

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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"

View File

@@ -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
View 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
}

View File

@@ -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",
]

View 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
View 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)

View 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",
]

View 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

View 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)

View 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.
"""
...

View 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()

View File

@@ -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:

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -12,7 +12,7 @@ Why is this cool?
Two approaches depending on what you want:
1. PNG output: We do our own DCT math via scipy (works on any image)
2. JPEG output: We use 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 = []

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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")

View File

@@ -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:

File diff suppressed because it is too large Load Diff

View 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,
}

View File

@@ -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

View File

@@ -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>"

View File

@@ -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)

View 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
View 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,
)

Binary file not shown.

862
tests/test_audio.py Normal file
View 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

View File

@@ -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,
)