From ef5a9ce9cb68d54fa7bc1c2dfbeba69c4c56970e Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 28 Feb 2026 11:58:40 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 20 + CLAUDE.md | 6 +- README.md | 12 +- RELEASE_NOTES.md | 42 + aur-api/PKGBUILD | 4 +- aur-cli/PKGBUILD | 4 +- aur/PKGBUILD | 4 +- docs/stegasoo.1 | 84 +- frontends/api/auth.py | 23 +- frontends/api/main.py | 652 ++++++++++++++- frontends/cli/main.py | 2 +- frontends/web/app.py | 716 ++++++++++++++-- frontends/web/auth.py | 34 +- frontends/web/ssl_utils.py | 12 +- frontends/web/static/js/stegasoo.js | 35 +- frontends/web/stego_worker.py | 170 ++++ frontends/web/subprocess_stego.py | 224 +++++ frontends/web/templates/decode.html | 241 +++++- frontends/web/templates/encode.html | 325 ++++++-- frontends/web/templates/encode_result.html | 62 +- pyproject.toml | 2 +- src/stegasoo/__init__.py | 25 +- src/stegasoo/audio_steganography.py | 16 +- src/stegasoo/audio_utils.py | 14 +- src/stegasoo/channel.py | 23 +- src/stegasoo/cli.py | 426 ++++++++-- src/stegasoo/compression.py | 4 + src/stegasoo/constants.py | 101 ++- src/stegasoo/crypto.py | 70 +- src/stegasoo/dct_steganography.py | 48 +- src/stegasoo/debug.py | 115 ++- src/stegasoo/decode.py | 32 +- src/stegasoo/encode.py | 17 +- src/stegasoo/models.py | 7 + src/stegasoo/qr_utils.py | 4 +- src/stegasoo/recovery.py | 22 +- src/stegasoo/spread_steganography.py | 928 ++++++++++++++------- src/stegasoo/steganography.py | 2 +- src/stegasoo/utils.py | 7 +- src/stegasoo/validation.py | 4 + tests/test_audio.py | 474 ++++++++++- 41 files changed, 4281 insertions(+), 732 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99949ae..866bbe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to Stegasoo will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org). +## [4.3.0] - 2026-02-27 + +### Added +- **Audio Steganography** — Hide messages in audio files (WAV, FLAC, MP3, OGG, AAC, M4A) + - LSB mode: Direct least-significant-bit embedding in audio samples + - Spread Spectrum mode: Noise-resistant encoding using pseudo-random spreading + - Automatic format transcoding to WAV for embedding + - Full CLI support: `stegasoo audio-encode`, `audio-decode`, `audio-info` + - REST API endpoints: `/audio/encode`, `/audio/decode`, `/audio/info` + - Web UI: Unified encode/decode pages with carrier type selector (Image/Audio) +- New `AudioCapacityInfo`, `AudioEmbedStats`, `AudioInfo` model classes +- Audio-specific exceptions: `AudioError`, `AudioValidationError`, `AudioCapacityError`, `AudioExtractionError`, `AudioTranscodeError`, `UnsupportedAudioFormatError` +- Subprocess isolation for audio operations (crash protection) +- `debug.py` module for structured logging across all steganography operations + +### Changed +- Encode/Decode web pages now have a "Carrier Type" step to switch between Image and Audio +- Version bumped to 4.3.0 + ## [4.1.5] - 2026-01-07 ### Added @@ -201,6 +220,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). - CLI interface - Basic PIN authentication +[4.3.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.2.1...v4.3.0 [4.1.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5 [4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3 [4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 79dac5a..6eb29ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # Stegasoo — Claude Code Project Guide Stegasoo is a secure steganography toolkit with hybrid photo + passphrase + PIN authentication. -Version 4.2.1 · Python >=3.11 · MIT License +Version 4.3.0 · Python >=3.11 · MIT License ## Quick commands @@ -27,6 +27,10 @@ src/stegasoo/ Core library 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) diff --git a/README.md b/README.md index c84fc65..078a29c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Stegasoo -A secure steganography system for hiding encrypted messages in images using hybrid authentication. +A secure steganography system for hiding encrypted messages in images and audio using hybrid authentication. [![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 | diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5a3c11c..0123bb5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/aur-api/PKGBUILD b/aur-api/PKGBUILD index 5e91c2e..f19c1c9 100644 --- a/aur-api/PKGBUILD +++ b/aur-api/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Aaron D. Lee 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() { diff --git a/aur-cli/PKGBUILD b/aur-cli/PKGBUILD index 4d455e5..30709cc 100644 --- a/aur-cli/PKGBUILD +++ b/aur-cli/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Aaron D. Lee 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() { diff --git a/aur/PKGBUILD b/aur/PKGBUILD index b377eea..b61b730 100644 --- a/aur/PKGBUILD +++ b/aur/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Aaron D. Lee 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() { diff --git a/docs/stegasoo.1 b/docs/stegasoo.1 index 66e8a9e..6c21e20 100644 --- a/docs/stegasoo.1 +++ b/docs/stegasoo.1 @@ -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 diff --git a/frontends/api/auth.py b/frontends/api/auth.py index 5cf1be3..f23f384 100644 --- a/frontends/api/auth.py +++ b/frontends/api/auth.py @@ -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. diff --git a/frontends/api/main.py b/frontends/api/main.py index ae7fb1d..f9294f1 100644 --- a/frontends/api/main.py +++ b/frontends/api/main.py @@ -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 # ============================================================================ diff --git a/frontends/cli/main.py b/frontends/cli/main.py index a012bed..ffecd47 100644 --- a/frontends/cli/main.py +++ b/frontends/cli/main.py @@ -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() diff --git a/frontends/web/app.py b/frontends/web/app.py index b1a1a29..81fe8e2 100644 --- a/frontends/web/app.py +++ b/frontends/web/app.py @@ -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 # ============================================================================ @@ -1078,15 +1125,105 @@ def _run_encode_job(job_id: str, encode_params: dict) -> None: # 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("stego_audio", ".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,196 @@ 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) ========== + 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("stego_audio", ".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") @@ -1356,16 +1683,20 @@ def encode_page(): # 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 +1765,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 +1784,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 +1799,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/") @@ -1559,10 +1892,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 +2024,163 @@ 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) ========== + 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 +2262,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 +2316,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 +2679,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 +2715,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 +2746,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 +2777,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 +3445,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 +3579,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) diff --git a/frontends/web/auth.py b/frontends/web/auth.py index 3daf0fe..780e933 100644 --- a/frontends/web/auth.py +++ b/frontends/web/auth.py @@ -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: diff --git a/frontends/web/ssl_utils.py b/frontends/web/ssl_utils.py index 882b3cf..a07ea25 100644 --- a/frontends/web/ssl_utils.py +++ b/frontends/web/ssl_utils.py @@ -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) diff --git a/frontends/web/static/js/stegasoo.js b/frontends/web/static/js/stegasoo.js index 4ad13d0..75eff42 100644 --- a/frontends/web/static/js/stegasoo.js +++ b/frontends/web/static/js/stegasoo.js @@ -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 // ======================================================================== @@ -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; }, diff --git a/frontends/web/stego_worker.py b/frontends/web/stego_worker.py index 3582ad1..60743db 100644 --- a/frontends/web/stego_worker.py +++ b/frontends/web/stego_worker.py @@ -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}"} diff --git a/frontends/web/subprocess_stego.py b/frontends/web/subprocess_stego.py index 04b2ab8..c9d8f5e 100644 --- a/frontends/web/subprocess_stego.py +++ b/frontends/web/subprocess_stego.py @@ -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, diff --git a/frontends/web/templates/decode.html b/frontends/web/templates/decode.html index e80228d..2d0eda4 100644 --- a/frontends/web/templates/decode.html +++ b/frontends/web/templates/decode.html @@ -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 @@
+
+

+ +

+
+
+ +
+ + + + +
+
+
+
+ +

-

-
+
@@ -213,41 +249,74 @@
- -
- -
- - Drop image or click -
- -
-
-
-
-
-
-
-
image.png
-
Stego Loaded--
-
-- x -- px
+
+ +
+ +
+ + Drop image or click +
+ +
+
+
+
+
+
+
+
image.png
+
Stego Loaded--
+
-- x -- px
+
+
Image containing the hidden message
+
+ +
+ +
+ +
+ + Drop audio or click +
+
+
audio.wav
+
Audio Loaded--
+
+
+
Audio file containing the hidden message
-
Image containing the hidden message
-
- - - - - - +
+
+ + + + + + +
+
+ +
+
+ + + + + + +
@@ -259,13 +328,13 @@

+

+
+
+ +
+ + + + +
+ {% if not has_audio %} +
+ Audio requires numpy and soundfile packages +
+ {% endif %} +
+
+
+ +

-

-
+
@@ -167,29 +208,51 @@
- -
- -
- - Drop image or click -
- -
-
-
-
-
-
-
-
image.jpg
-
Carrier Loaded--
-
-- x -- px
+
+ +
+ +
+ + Drop image or click +
+ +
+
+
+
+
+
+
+
image.jpg
+
Carrier Loaded--
+
-- x -- px
+
+
Image to hide your message in
+
+ + +
+ +
+ +
+ + Drop audio or click +
+
+
audio.wav
+
Audio Loaded--
+
--:-- duration
+
+
+
Audio file to hide your message in
-
Image to hide your message in
@@ -204,30 +267,54 @@
- -
-
- - - - + +
+
+ - + + LSB: - + Spread: - +
- | - -
- - - - -
-
- - - - -
-
+ + +
+
+
+ + + + +
+ | + +
+ + + + +
+
+ + + + +
+
+
+
+ + +
+
+ + + + +
+
+
{% if has_dct %}Survives social media compression{% else %}Higher capacity for direct transfers{% endif %}
@@ -237,13 +324,13 @@

+ {% if carrier_type == 'audio' %} + +
+
+ +
+ +
+
+ Encoded Audio Preview +
+
+
+ {% else %}
{% if thumbnail_url %}
- Encoded image thumbnail
@@ -29,8 +43,9 @@ {% endif %}
+ {% endif %} -

Your secret has been hidden in the image.

+

Your secret has been hidden in the {{ 'audio file' if carrier_type == 'audio' else 'image' }}.

{{ filename }} @@ -38,11 +53,32 @@
- {% if embed_mode == 'dct' %} + {% if carrier_type == 'audio' %} + + {% if embed_mode == 'audio_spread' %} + + Spread Spectrum + + {% else %} + + Audio LSB + + {% endif %} + + WAV + +
+ {% if embed_mode == 'audio_spread' %} + Spread spectrum embedding in audio samples + {% else %} + LSB embedding in audio samples, WAV output + {% endif %} +
+ {% elif embed_mode == 'dct' %} DCT Mode - + {% if color_mode == 'color' %} @@ -53,7 +89,7 @@ Grayscale {% endif %} - + {% if output_format == 'jpeg' %} @@ -78,7 +114,7 @@ {% endif %}
{% endif %} - + {% else %} LSB Mode @@ -114,7 +150,7 @@
- Download Image + Download {{ 'Audio' if carrier_type == 'audio' else 'Image' }}
- Encode Another Message + Encode Another
@@ -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 diff --git a/pyproject.toml b/pyproject.toml index 52f46da..dd53ed5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/stegasoo/__init__.py b/src/stegasoo/__init__.py index bcb1a6c..fb47037 100644 --- a/src/stegasoo/__init__.py +++ b/src/stegasoo/__init__.py @@ -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) @@ -24,8 +24,8 @@ from .channel import ( # Crypto functions from .crypto import get_active_channel_key, get_channel_fingerprint, has_argon2 -from .decode import decode, decode_audio, decode_file, decode_text -from .encode import encode, encode_audio +from .decode import decode, decode_file, decode_text +from .encode import encode # Credential generation from .generate import ( @@ -54,22 +54,28 @@ from .steganography import ( # Utilities from .utils import generate_filename -# Audio utilities - optional, may not be available (v4.3.0) -try: +# Audio support — gated by STEGASOO_AUDIO env var and dependency availability +from .constants import AUDIO_ENABLED, VIDEO_ENABLED + +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, ) - - HAS_AUDIO_SUPPORT = True -except ImportError: - HAS_AUDIO_SUPPORT = False + 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 # QR Code utilities - optional, may not be available try: @@ -203,6 +209,7 @@ __all__ = [ "has_ffmpeg_support", "validate_audio", "HAS_AUDIO_SUPPORT", + "HAS_VIDEO_SUPPORT", "validate_audio_embed_mode", "validate_audio_file", # Generation diff --git a/src/stegasoo/audio_steganography.py b/src/stegasoo/audio_steganography.py index 76edb60..cf5f870 100644 --- a/src/stegasoo/audio_steganography.py +++ b/src/stegasoo/audio_steganography.py @@ -283,7 +283,9 @@ def embed_in_audio_lsb( # 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)})") + 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 @@ -463,9 +465,7 @@ def extract_from_audio_lsb( 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" - ) + 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") @@ -483,14 +483,10 @@ def extract_from_audio_lsb( 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" - ) + _write_progress(progress_file, progress_idx, total_samples_needed, "extracting") if progress_file: - _write_progress( - progress_file, total_samples_needed, total_samples_needed, "extracting" - ) + _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)] diff --git a/src/stegasoo/audio_utils.py b/src/stegasoo/audio_utils.py index 598a7a0..77f55d2 100644 --- a/src/stegasoo/audio_utils.py +++ b/src/stegasoo/audio_utils.py @@ -13,7 +13,6 @@ Both are optional — functions degrade gracefully when unavailable. from __future__ import annotations import io -import logging import shutil from .constants import ( @@ -24,10 +23,11 @@ from .constants import ( 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 = logging.getLogger(__name__) +logger = get_logger(__name__) # ============================================================================= @@ -69,10 +69,12 @@ def detect_audio_format(audio_data: bytes) -> str: 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 @@ -124,6 +126,7 @@ def transcode_to_wav(audio_data: bytes) -> bytes: 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( @@ -325,7 +328,9 @@ def _get_info_soundfile(audio_data: bytes, fmt: str) -> AudioInfo: try: import soundfile as sf except ImportError: - raise AudioTranscodeError("soundfile package is required. Install with: pip install soundfile") + raise AudioTranscodeError( + "soundfile package is required. Install with: pip install soundfile" + ) try: buf = io.BytesIO(audio_data) @@ -460,8 +465,7 @@ def validate_audio( 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." + f"Could not detect {name} format. " "Supported formats: WAV, FLAC, MP3, OGG, AAC, M4A." ) # Extract metadata for further validation diff --git a/src/stegasoo/channel.py b/src/stegasoo/channel.py index 3a6ca19..24836ee 100644 --- a/src/stegasoo/channel.py +++ b/src/stegasoo/channel.py @@ -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: diff --git a/src/stegasoo/cli.py b/src/stegasoo/cli.py index e1e1b30..7264516 100644 --- a/src/stegasoo/cli.py +++ b/src/stegasoo/cli.py @@ -108,8 +108,9 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) @click.group(context_settings=CONTEXT_SETTINGS) @click.version_option(__version__, "-v", "--version") @click.option("--json", "json_output", is_flag=True, help="Output results as JSON") +@click.option("--debug", "debug_mode", is_flag=True, help="Enable debug logging to stderr") @click.pass_context -def cli(ctx, json_output): +def cli(ctx, json_output, debug_mode): """ Stegasoo - Steganography with hybrid authentication. @@ -120,6 +121,11 @@ def cli(ctx, json_output): ctx.ensure_object(dict) ctx.obj["json"] = json_output + if debug_mode: + from .debug import debug + + debug.enable(True) + # ============================================================================= # ENCODE COMMANDS @@ -179,9 +185,7 @@ def cli(ctx, json_output): @click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code") @click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding") @click.pass_context -def encode( - ctx, carrier, reference, message, file_payload, output, passphrase, pin, dry_run -): +def encode(ctx, carrier, reference, message, file_payload, output, passphrase, pin, dry_run): """ Encode a message or file into an image. @@ -245,14 +249,14 @@ def encode( # Default to JPEG for JPEG carriers (preserves DCT mode benefits) carrier_ext = Path(carrier).suffix.lower() if not output: - if carrier_ext in ('.jpg', '.jpeg'): + if carrier_ext in (".jpg", ".jpeg"): output = f"{Path(carrier).stem}_encoded.jpg" else: output = f"{Path(carrier).stem}_encoded.png" # Detect output format from extension output_ext = Path(output).suffix.lower() - use_dct = output_ext in ('.jpg', '.jpeg') + use_dct = output_ext in (".jpg", ".jpeg") from .steganography import EMBED_MODE_DCT, EMBED_MODE_LSB @@ -442,8 +446,38 @@ def decode(ctx, image, reference, passphrase, pin, output): help="Passphrase (recommend 4+ words)", ) @click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code") +@click.option( + "--rsa-key", + type=click.Path(exists=True), + help="RSA private key PEM file", +) +@click.option("--rsa-password", default=None, help="Password for encrypted RSA key") +@click.option("--channel-key", default=None, help="Channel key for deployment isolation") +@click.option( + "--chip-tier", + "chip_tier", + default=None, + type=click.Choice(["lossless", "high", "low"]), + help="Spread spectrum chip tier (lossless=256, high=512, low=1024). Only for audio_spread.", +) +@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding") @click.pass_context -def audio_encode(ctx, carrier, reference, message, file_payload, output, embed_mode, passphrase, pin): +def audio_encode( + ctx, + carrier, + reference, + message, + file_payload, + output, + embed_mode, + passphrase, + pin, + rsa_key, + rsa_password, + channel_key, + chip_tier, + dry_run, +): """ Encode a message or file into an audio carrier. @@ -452,26 +486,100 @@ def audio_encode(ctx, carrier, reference, message, file_payload, output, embed_m stegasoo audio-encode carrier.wav -r ref.jpg -m "Secret" --mode audio_lsb stegasoo audio-encode carrier.wav -r ref.jpg -f secret.pdf --mode audio_spread + + stegasoo audio-encode carrier.wav -r ref.jpg -m "Secret" --dry-run """ + from .constants import AUDIO_ENABLED + + if not AUDIO_ENABLED: + raise click.UsageError( + "Audio support is disabled. Install audio extras (pip install stegasoo[audio]) " + "or set STEGASOO_AUDIO=1 to force enable." + ) + + from .audio_steganography import calculate_audio_lsb_capacity from .encode import encode_audio from .models import FilePayload + from .spread_steganography import calculate_audio_spread_capacity if not message and not file_payload: raise click.UsageError("Either --message or --file is required") + # Read RSA key if provided + rsa_key_data = None + if rsa_key: + with open(rsa_key, "rb") as f: + rsa_key_data = f.read() + + # Calculate payload size + if file_payload: + payload_size = Path(file_payload).stat().st_size + payload_type = "file" + else: + payload_size = len(message.encode("utf-8")) + payload_type = "text" + # Read input files with open(reference, "rb") as f: reference_data = f.read() with open(carrier, "rb") as f: carrier_data = f.read() + if dry_run: + try: + from .audio_utils import get_audio_info + + info = get_audio_info(carrier_data) + lsb_capacity = calculate_audio_lsb_capacity(carrier_data) + spread_capacity = calculate_audio_spread_capacity(carrier_data) + + if embed_mode == "audio_lsb": + capacity = lsb_capacity + else: + capacity = spread_capacity.usable_capacity_bytes + + result = { + "carrier": carrier, + "reference": reference, + "format": info.format, + "sample_rate": info.sample_rate, + "channels": info.channels, + "duration_seconds": round(info.duration_seconds, 2), + "embed_mode": embed_mode, + "capacity_bytes": capacity, + "lsb_capacity_bytes": lsb_capacity, + "spread_capacity_bytes": spread_capacity.usable_capacity_bytes, + "payload_type": payload_type, + "payload_size": payload_size, + "usage_percent": round(payload_size / capacity * 100, 1) if capacity > 0 else 0, + "fits": payload_size < capacity, + } + + if ctx.obj.get("json"): + click.echo(json.dumps(result, indent=2)) + else: + click.echo( + f"Carrier: {carrier} ({info.format}, {info.sample_rate}Hz, {info.channels}ch)" + ) + click.echo(f"Duration: {info.duration_seconds:.1f}s") + click.echo(f"Reference: {reference}") + click.echo(f"Mode: {embed_mode}") + click.echo(f"LSB capacity: {lsb_capacity:,} bytes ({lsb_capacity // 1024} KB)") + click.echo(f"Spread capacity: {spread_capacity.usable_capacity_bytes:,} bytes") + click.echo(f"Payload: {payload_size:,} bytes ({payload_type})") + click.echo(f"Usage: {result['usage_percent']}%") + click.echo(f"Status: {'✓ Fits' if result['fits'] else '✗ Too large'}") + except Exception as e: + if ctx.obj.get("json"): + click.echo(json.dumps({"status": "error", "error": str(e)}, indent=2)) + else: + click.echo(f"✗ Capacity check failed: {e}", err=True) + raise SystemExit(1) + return + # Determine output path if not output: - carrier_path = Path(carrier) - if embed_mode == "audio_lsb": - output = f"{carrier_path.stem}_encoded.wav" - else: - output = f"{carrier_path.stem}_encoded.wav" + output = f"{Path(carrier).stem}_encoded.wav" try: if file_payload: @@ -479,13 +587,24 @@ def audio_encode(ctx, carrier, reference, message, file_payload, output, embed_m else: payload = message + # Resolve chip tier name to integer + resolved_chip_tier = None + if chip_tier is not None: + from .constants import AUDIO_SS_CHIP_TIER_NAMES + + resolved_chip_tier = AUDIO_SS_CHIP_TIER_NAMES.get(chip_tier) + stego_audio, stats = encode_audio( message=payload, reference_photo=reference_data, carrier_audio=carrier_data, passphrase=passphrase, pin=pin, + rsa_key_data=rsa_key_data, + rsa_password=rsa_password, embed_mode=embed_mode, + channel_key=channel_key, + chip_tier=resolved_chip_tier, ) with open(output, "wb") as f: @@ -539,9 +658,18 @@ def audio_encode(ctx, carrier, reference, message, file_payload, output, embed_m ) @click.option("--passphrase", prompt=True, hide_input=True, help="Passphrase") @click.option("--pin", prompt=True, hide_input=True, help="PIN code") +@click.option( + "--rsa-key", + type=click.Path(exists=True), + help="RSA private key PEM file", +) +@click.option("--rsa-password", default=None, help="Password for encrypted RSA key") +@click.option("--channel-key", default=None, help="Channel key for deployment isolation") @click.option("-o", "--output", type=click.Path(), help="Output path for file payloads") @click.pass_context -def audio_decode(ctx, audio, reference, embed_mode, passphrase, pin, output): +def audio_decode( + ctx, audio, reference, embed_mode, passphrase, pin, rsa_key, rsa_password, channel_key, output +): """ Decode a message or file from stego audio. @@ -551,8 +679,22 @@ def audio_decode(ctx, audio, reference, embed_mode, passphrase, pin, output): stegasoo audio-decode stego.wav -r ref.jpg --mode audio_lsb -o ./extracted/ """ + from .constants import AUDIO_ENABLED + + if not AUDIO_ENABLED: + raise click.UsageError( + "Audio support is disabled. Install audio extras (pip install stegasoo[audio]) " + "or set STEGASOO_AUDIO=1 to force enable." + ) + from .decode import decode_audio + # Read RSA key if provided + rsa_key_data = None + if rsa_key: + with open(rsa_key, "rb") as f: + rsa_key_data = f.read() + with open(audio, "rb") as f: audio_data = f.read() with open(reference, "rb") as f: @@ -564,7 +706,10 @@ def audio_decode(ctx, audio, reference, embed_mode, passphrase, pin, output): reference_photo=reference_data, passphrase=passphrase, pin=pin, + rsa_key_data=rsa_key_data, + rsa_password=rsa_password, embed_mode=embed_mode, + channel_key=channel_key, ) if result.is_file: @@ -617,6 +762,97 @@ def audio_decode(ctx, audio, reference, embed_mode, passphrase, pin, output): raise SystemExit(1) +@cli.command("audio-info") +@click.argument("audio", type=click.Path(exists=True)) +@click.pass_context +def audio_info(ctx, audio): + """ + Show audio file information and steganographic capacity. + + Examples: + + stegasoo audio-info carrier.wav + + stegasoo --json audio-info carrier.wav + """ + from .constants import AUDIO_ENABLED + + if not AUDIO_ENABLED: + raise click.UsageError( + "Audio support is disabled. Install audio extras (pip install stegasoo[audio]) " + "or set STEGASOO_AUDIO=1 to force enable." + ) + + from .audio_steganography import calculate_audio_lsb_capacity + from .audio_utils import get_audio_info + from .spread_steganography import calculate_audio_spread_capacity + + with open(audio, "rb") as f: + audio_data = f.read() + + try: + info = get_audio_info(audio_data) + lsb_capacity = calculate_audio_lsb_capacity(audio_data) + + # Calculate spread capacity at each chip tier + spread_tiers = {} + for tier_name, tier_val in [("lossless", 0), ("high", 1), ("low", 2)]: + cap = calculate_audio_spread_capacity(audio_data, chip_tier=tier_val) + spread_tiers[tier_name] = { + "bytes": cap.usable_capacity_bytes, + "kb": round(cap.usable_capacity_bytes / 1024, 1), + "chip_length": cap.chip_length, + "embeddable_channels": cap.embeddable_channels, + } + + result = { + "file": audio, + "format": info.format, + "sample_rate": info.sample_rate, + "channels": info.channels, + "duration_seconds": round(info.duration_seconds, 2), + "num_samples": info.num_samples, + "bit_depth": info.bit_depth, + "file_size": len(audio_data), + "capacity": { + "audio_lsb": { + "bytes": lsb_capacity, + "kb": round(lsb_capacity / 1024, 1), + }, + "audio_spread": spread_tiers, + }, + } + + if ctx.obj.get("json"): + click.echo(json.dumps(result, indent=2)) + else: + click.echo(f"File: {audio}") + click.echo(f"Format: {info.format}") + click.echo(f"Sample rate: {info.sample_rate} Hz") + click.echo(f"Channels: {info.channels}") + click.echo(f"Duration: {info.duration_seconds:.1f}s") + click.echo(f"Samples: {info.num_samples:,}") + if info.bit_depth: + click.echo(f"Bit depth: {info.bit_depth}-bit") + click.echo(f"File size: {len(audio_data):,} bytes") + click.echo() + click.echo("Steganographic capacity:") + click.echo(f" LSB: {lsb_capacity:,} bytes ({lsb_capacity // 1024} KB)") + for tier_name in ("lossless", "high", "low"): + t = spread_tiers[tier_name] + click.echo( + f" Spread ({tier_name:>8}, chip={t['chip_length']}): " + f"{t['bytes']:,} bytes ({t['kb']} KB)" + ) + + except Exception as e: + if ctx.obj.get("json"): + click.echo(json.dumps({"status": "error", "error": str(e)}, indent=2)) + else: + click.echo(f"✗ Audio info failed: {e}", err=True) + raise SystemExit(1) + + # ============================================================================= # BATCH COMMANDS # ============================================================================= @@ -828,9 +1064,7 @@ def batch_check(ctx, images, recursive): @click.option( "--pin-length", default=DEFAULT_PIN_LENGTH, help=f"PIN length (default: {DEFAULT_PIN_LENGTH})" ) -@click.option( - "--channel-key", is_flag=True, help="Also generate a 256-bit channel key" -) +@click.option("--channel-key", is_flag=True, help="Also generate a 256-bit channel key") @click.pass_context def generate(ctx, words, pin_length, channel_key): """ @@ -889,6 +1123,7 @@ def generate(ctx, words, pin_length, channel_key): # Generate channel key if requested if channel_key: from .channel import generate_channel_key + result["channel_key"] = generate_channel_key() if ctx.obj.get("json"): @@ -912,6 +1147,7 @@ def info(ctx, full): # Check for DCT support try: from .dct_steganography import HAS_JPEGIO, HAS_SCIPY + has_dct = HAS_SCIPY and HAS_JPEGIO except ImportError: has_dct = False @@ -954,6 +1190,7 @@ def info(ctx, full): channel_source = None try: from .channel import get_channel_fingerprint, get_channel_key, get_channel_status + key = get_channel_key() if key: channel_fingerprint = get_channel_fingerprint(key) @@ -986,7 +1223,7 @@ def info(ctx, full): try: # Disk free st = os.statvfs("/") - disk_free = (st.f_bavail * st.f_frsize) / (1024 ** 3) # GB + disk_free = (st.f_bavail * st.f_frsize) / (1024**3) # GB except OSError: pass @@ -1005,20 +1242,28 @@ def info(ctx, full): "service": service_status, "url": service_url, "dct_support": has_dct, - "channel": { - "fingerprint": channel_fingerprint, - "source": channel_source, - } if channel_fingerprint else None, + "channel": ( + { + "fingerprint": channel_fingerprint, + "source": channel_source, + } + if channel_fingerprint + else None + ), "limits": { "max_message_bytes": MAX_MESSAGE_SIZE, "max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE, }, - "system": { - "cpu_mhz": cpu_freq, - "temp_c": cpu_temp, - "disk_free_gb": round(disk_free, 1) if disk_free else None, - "uptime": uptime, - } if full else None, + "system": ( + { + "cpu_mhz": cpu_freq, + "temp_c": cpu_temp, + "disk_free_gb": round(disk_free, 1) if disk_free else None, + "uptime": uptime, + } + if full + else None + ), } if ctx.obj.get("json"): @@ -1055,7 +1300,9 @@ def info(ctx, full): if cpu_freq: click.echo(f" CPU: {cpu_freq} MHz") if cpu_temp: - temp_color = "\033[32m" if cpu_temp < 60 else "\033[33m" if cpu_temp < 75 else "\033[31m" + temp_color = ( + "\033[32m" if cpu_temp < 60 else "\033[33m" if cpu_temp < 75 else "\033[31m" + ) click.echo(f" Temp: {temp_color}{cpu_temp:.1f}°C\033[0m") if uptime: click.echo(f" Uptime: {uptime}") @@ -1384,7 +1631,7 @@ def tools_capacity(image, as_json): click.echo(f" Megapixels: {result['megapixels']} MP") click.echo(f" {'─' * 40}") click.echo(f" LSB Capacity: {result['lsb']['capacity_kb']:.1f} KB") - if result['dct']['available']: + if result["dct"]["available"]: click.echo(f" DCT Capacity: {result['dct']['capacity_kb']:.1f} KB") else: click.echo(" DCT Capacity: N/A (scipy required)") @@ -1394,7 +1641,9 @@ def tools_capacity(image, as_json): @tools.command("strip") @click.argument("image", type=click.Path(exists=True)) @click.option("-o", "--output", type=click.Path(), help="Output file (default: _clean.png)") -@click.option("--format", "fmt", type=click.Choice(["png", "bmp"]), default="png", help="Output format") +@click.option( + "--format", "fmt", type=click.Choice(["png", "bmp"]), default="png", help="Output format" +) def tools_strip(image, output, fmt): """Strip EXIF/metadata from an image. @@ -1529,7 +1778,9 @@ def tools_exif(image, clear, set_fields, output, as_json): @tools.command("compress") @click.argument("image", type=click.Path(exists=True)) @click.option("-q", "--quality", type=int, default=75, help="JPEG quality (1-100, default: 75)") -@click.option("-o", "--output", type=click.Path(), help="Output file (default: _q.jpg)") +@click.option( + "-o", "--output", type=click.Path(), help="Output file (default: _q.jpg)" +) def tools_compress(image, quality, output): """Compress a JPEG image. @@ -1541,9 +1792,10 @@ def tools_compress(image, quality, output): stegasoo tools compress photo.jpg -q 60 stegasoo tools compress photo.jpg -q 80 -o smaller.jpg """ - from PIL import Image import io + from PIL import Image + if not 1 <= quality <= 100: raise click.UsageError("Quality must be between 1 and 100") @@ -1578,7 +1830,9 @@ def tools_compress(image, quality, output): @tools.command("rotate") @click.argument("image", type=click.Path(exists=True)) -@click.option("-r", "--rotation", type=click.Choice(["90", "180", "270"]), help="Rotation degrees clockwise") +@click.option( + "-r", "--rotation", type=click.Choice(["90", "180", "270"]), help="Rotation degrees clockwise" +) @click.option("--flip-h", is_flag=True, help="Flip horizontally") @click.option("--flip-v", is_flag=True, help="Flip vertically") @click.option("-o", "--output", type=click.Path(), help="Output file") @@ -1593,10 +1847,11 @@ def tools_rotate(image, rotation, flip_h, flip_v, output): stegasoo tools rotate photo.jpg -r 90 stegasoo tools rotate photo.jpg -r 180 --flip-h -o rotated.jpg """ - from PIL import Image import io import shutil + from PIL import Image + with open(image, "rb") as f: image_data = f.read() @@ -1622,9 +1877,9 @@ def tools_rotate(image, rotation, flip_h, flip_v, output): # Apply flips using jpegtran if flip_h or flip_v: + import os import subprocess import tempfile - import os for flip_type in (["horizontal"] if flip_h else []) + (["vertical"] if flip_v else []): with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f: @@ -1633,9 +1888,19 @@ def tools_rotate(image, rotation, flip_h, flip_v, output): output_path = tempfile.mktemp(suffix=".jpg") try: subprocess.run( - ["jpegtran", "-flip", flip_type, "-copy", "all", - "-outfile", output_path, input_path], - capture_output=True, timeout=30, check=True + [ + "jpegtran", + "-flip", + flip_type, + "-copy", + "all", + "-outfile", + output_path, + input_path, + ], + capture_output=True, + timeout=30, + check=True, ) with open(output_path, "rb") as f: result_data = f.read() @@ -1680,8 +1945,17 @@ def tools_rotate(image, rotation, flip_h, flip_v, output): @tools.command("convert") @click.argument("image", type=click.Path(exists=True)) -@click.option("-f", "--format", "fmt", type=click.Choice(["png", "jpg", "bmp", "webp"]), required=True, help="Output format") -@click.option("-q", "--quality", type=int, default=95, help="Quality for lossy formats (default: 95)") +@click.option( + "-f", + "--format", + "fmt", + type=click.Choice(["png", "jpg", "bmp", "webp"]), + required=True, + help="Output format", +) +@click.option( + "-q", "--quality", type=int, default=95, help="Quality for lossy formats (default: 95)" +) @click.option("-o", "--output", type=click.Path(), help="Output file") def tools_convert(image, fmt, quality, output): """Convert image to a different format. @@ -1691,9 +1965,10 @@ def tools_convert(image, fmt, quality, output): stegasoo tools convert photo.png -f jpg stegasoo tools convert photo.jpg -f png -o lossless.png """ - from PIL import Image import io + from PIL import Image + with open(image, "rb") as f: image_data = f.read() @@ -1737,12 +2012,14 @@ def admin(ctx): @admin.command("recover") @click.option( - "--db", "db_path", + "--db", + "db_path", type=click.Path(exists=True), - help="Path to stegasoo.db (default: frontends/web/instance/stegasoo.db)" + help="Path to stegasoo.db (default: frontends/web/instance/stegasoo.db)", +) +@click.option( + "--password", prompt=True, hide_input=True, confirmation_prompt=True, help="New admin password" ) -@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True, - help="New admin password") def admin_recover(db_path, password): """Reset admin password using recovery key. @@ -1772,9 +2049,7 @@ def admin_recover(db_path, password): break if not db_path or not Path(db_path).exists(): - raise click.UsageError( - "Database not found. Use --db to specify path to stegasoo.db" - ) + raise click.UsageError("Database not found. Use --db to specify path to stegasoo.db") click.echo(f"Database: {db_path}") @@ -1783,16 +2058,13 @@ def admin_recover(db_path, password): db.row_factory = sqlite3.Row # Get recovery key hash from app_settings - cursor = db.execute( - "SELECT value FROM app_settings WHERE key = 'recovery_key_hash'" - ) + cursor = db.execute("SELECT value FROM app_settings WHERE key = 'recovery_key_hash'") row = cursor.fetchone() if not row: db.close() raise click.ClickException( - "No recovery key configured for this instance. " - "Password reset is not possible." + "No recovery key configured for this instance. " "Password reset is not possible." ) stored_hash = row["value"] @@ -1869,6 +2141,7 @@ def admin_generate_key(show_qr): if show_qr: try: import qrcode + qr = qrcode.QRCode(box_size=1, border=1) qr.add_data(key) qr.make() @@ -1920,8 +2193,12 @@ def api_keys(): @api_keys.command("list") -@click.option("--location", type=click.Choice(["user", "project", "all"]), default="all", - help="Config location to list keys from") +@click.option( + "--location", + type=click.Choice(["user", "project", "all"]), + default="all", + help="Config location to list keys from", +) def api_keys_list(location): """List configured API keys. @@ -1935,7 +2212,7 @@ def api_keys_list(location): _setup_frontends_path() try: - from api.auth import list_api_keys, get_api_key_status + from api.auth import get_api_key_status, list_api_keys except ImportError: raise click.ClickException("API frontend not available") @@ -1959,8 +2236,12 @@ def api_keys_list(location): @api_keys.command("create") @click.argument("name") -@click.option("--location", type=click.Choice(["user", "project"]), default="user", - help="Where to store the key") +@click.option( + "--location", + type=click.Choice(["user", "project"]), + default="user", + help="Where to store the key", +) def api_keys_create(name, location): """Create a new API key. @@ -1993,8 +2274,9 @@ def api_keys_create(name, location): @api_keys.command("delete") @click.argument("name") -@click.option("--location", type=click.Choice(["user", "project"]), default="user", - help="Config location") +@click.option( + "--location", type=click.Choice(["user", "project"]), default="user", help="Config location" +) def api_keys_delete(name, location): """Delete an API key by name. @@ -2025,7 +2307,9 @@ def api_tls(): @api_tls.command("generate") @click.option("--hostname", default="localhost", help="Server hostname for certificate") @click.option("--days", default=365, help="Certificate validity in days") -@click.option("--output", "-o", type=click.Path(), help="Output directory (default: ~/.stegasoo/certs)") +@click.option( + "--output", "-o", type=click.Path(), help="Output directory (default: ~/.stegasoo/certs)" +) def api_tls_generate(hostname, days, output): """Generate self-signed TLS certificate. @@ -2065,7 +2349,12 @@ def api_tls_generate(hostname, days, output): @api_tls.command("info") -@click.option("--cert", "-c", type=click.Path(exists=True), help="Certificate file (default: ~/.stegasoo/certs/server.crt)") +@click.option( + "--cert", + "-c", + type=click.Path(exists=True), + help="Certificate file (default: ~/.stegasoo/certs/server.crt)", +) def api_tls_info(cert): """Show information about a TLS certificate. @@ -2075,12 +2364,13 @@ def api_tls_info(cert): stegasoo api tls info --cert /path/to/server.crt """ from cryptography import x509 - from cryptography.hazmat.primitives import serialization if not cert: cert = Path.home() / ".stegasoo" / "certs" / "server.crt" if not cert.exists(): - raise click.ClickException(f"No certificate found at {cert}. Generate one with: stegasoo api tls generate") + raise click.ClickException( + f"No certificate found at {cert}. Generate one with: stegasoo api tls generate" + ) cert_data = Path(cert).read_bytes() certificate = x509.load_pem_x509_certificate(cert_data) @@ -2095,7 +2385,8 @@ def api_tls_info(cert): # Check expiry import datetime - now = datetime.datetime.now(datetime.timezone.utc) + + now = datetime.datetime.now(datetime.UTC) if certificate.not_valid_after_utc < now: click.echo("\nStatus: EXPIRED") elif certificate.not_valid_after_utc < now + datetime.timedelta(days=30): @@ -2144,8 +2435,11 @@ def api_serve(host, port, ssl, cert, key, do_reload): else: try: from web.ssl_utils import ensure_certs + base_dir = Path.home() / ".stegasoo" - cert_path, key_path = ensure_certs(base_dir, host if host != "0.0.0.0" else "localhost") + cert_path, key_path = ensure_certs( + base_dir, host if host != "0.0.0.0" else "localhost" + ) except ImportError: raise click.ClickException("ssl_utils not available") diff --git a/src/stegasoo/compression.py b/src/stegasoo/compression.py index 50f5f4c..252bcb1 100644 --- a/src/stegasoo/compression.py +++ b/src/stegasoo/compression.py @@ -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 diff --git a/src/stegasoo/constants.py b/src/stegasoo/constants.py index 2c5ae43..d5a8e8d 100644 --- a/src/stegasoo/constants.py +++ b/src/stegasoo/constants.py @@ -262,8 +262,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 @@ -297,6 +296,69 @@ def detect_stego_mode(encrypted_data: bytes) -> str: 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) # ============================================================================= @@ -319,12 +381,33 @@ 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) -AUDIO_SS_AMPLITUDE = 0.05 # Per-sample embedding strength (~-26dB, masked by audio) -AUDIO_SS_RS_NSYM = 32 # Reed-Solomon parity symbols +AUDIO_SS_CHIP_LENGTH = 1024 # Samples per chip (spreading factor) — legacy/default +AUDIO_SS_AMPLITUDE = 0.05 # Per-sample embedding strength (~-26dB, masked by audio) +AUDIO_SS_RS_NSYM = 32 # Reed-Solomon parity symbols + +# Spread spectrum v2: per-channel hybrid embedding (v4.4.0) +AUDIO_SS_HEADER_VERSION = 2 # v2 header format identifier + +# Chip tier system — trade capacity for robustness +AUDIO_SS_CHIP_TIER_LOSSLESS = 0 # 256 chips — lossless carriers (FLAC/WAV/ALAC) +AUDIO_SS_CHIP_TIER_HIGH_LOSSY = 1 # 512 chips — high-rate lossy (AAC 256k+) +AUDIO_SS_CHIP_TIER_LOW_LOSSY = 2 # 1024 chips — low-rate lossy (AAC 128k, Opus) +AUDIO_SS_DEFAULT_CHIP_TIER = 2 # Most robust, backward compatible +AUDIO_SS_CHIP_LENGTHS = {0: 256, 1: 512, 2: 1024} + +# Chip tier name mapping (for CLI/UI) +AUDIO_SS_CHIP_TIER_NAMES = { + "lossless": AUDIO_SS_CHIP_TIER_LOSSLESS, + "high": AUDIO_SS_CHIP_TIER_HIGH_LOSSY, + "low": AUDIO_SS_CHIP_TIER_LOW_LOSSY, +} + +# LFE channel skipping — LFE is bandlimited to ~120Hz, terrible carrier +AUDIO_LFE_CHANNEL_INDEX = 3 # Standard WAV/WAVEFORMATEXTENSIBLE ordering +AUDIO_LFE_MIN_CHANNELS = 6 # Only skip LFE for 5.1+ layouts # Echo hiding parameters -AUDIO_ECHO_DELAY_0 = 50 # Echo delay for bit 0 (samples at 44.1kHz ~ 1.1ms) -AUDIO_ECHO_DELAY_1 = 100 # Echo delay for bit 1 (samples at 44.1kHz ~ 2.3ms) -AUDIO_ECHO_AMPLITUDE = 0.3 # Echo strength (relative to original) -AUDIO_ECHO_WINDOW_SIZE = 8192 # Window size for echo embedding +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 diff --git a/src/stegasoo/crypto.py b/src/stegasoo/crypto.py index 0e99dca..3536c2e 100644 --- a/src/stegasoo/crypto.py +++ b/src/stegasoo/crypto.py @@ -46,9 +46,12 @@ from .constants import ( 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 @@ -201,6 +204,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 +232,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 +267,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: @@ -457,6 +484,13 @@ 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 @@ -464,6 +498,10 @@ def encrypt_message( padding = secrets.token_bytes(padding_needed - 4) + struct.pack(">I", len(packed_payload)) padded_message = packed_payload + padding + logger.debug( + "Padded message: %d bytes (payload + %d padding)", len(padded_message), padding_needed + ) + # Build header for AAD header = MAGIC_HEADER + bytes([FORMAT_VERSION, flags]) @@ -473,10 +511,22 @@ def encrypt_message( encryptor.authenticate_additional_data(header) ciphertext = encryptor.update(padded_message) + encryptor.finalize() + total_size = len(header) + len(salt) + len(iv) + len(encryptor.tag) + len(ciphertext) + logger.debug( + "Encrypted output: %d bytes (header=%d, salt=%d, iv=%d, tag=%d, ciphertext=%d)", + total_size, + len(header), + len(salt), + len(iv), + len(encryptor.tag), + len(ciphertext), + ) + # v4.0.0: Header with flags byte return header + salt + iv + encryptor.tag + ciphertext except Exception as e: + logger.error("Encryption failed: %s", e) raise EncryptionError(f"Encryption failed: {e}") from e @@ -551,10 +601,21 @@ 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 @@ -577,9 +638,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", result.payload_type) return result except Exception as e: diff --git a/src/stegasoo/dct_steganography.py b/src/stegasoo/dct_steganography.py index ea9daf4..0b96459 100644 --- a/src/stegasoo/dct_steganography.py +++ b/src/stegasoo/dct_steganography.py @@ -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: @@ -120,9 +120,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,9 +169,9 @@ 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 +JPEGIO_MAGIC = b"JPGS" # jpegio native JPEG mode marker +HEADER_SIZE = 10 # Magic (4) + version (1) + flags (1) + length (4) OUTPUT_FORMAT_PNG = "png" OUTPUT_FORMAT_JPEG = "jpeg" @@ -186,8 +186,8 @@ JPEGIO_MIN_COEF_MAGNITUDE = 2 JPEGIO_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,8 +196,8 @@ 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 @@ -287,6 +287,7 @@ def has_jpegio_support() -> bool: try: from reedsolo import ReedSolomonError, RSCodec + HAS_REEDSOLO = True except ImportError: HAS_REEDSOLO = False @@ -1009,7 +1010,8 @@ def _embed_in_channel_safe( 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]] = ( @@ -1219,6 +1221,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 +1240,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 +1260,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 +1378,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] @@ -1437,6 +1449,7 @@ def extract_from_dct( 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 @@ -1450,6 +1463,7 @@ def extract_from_dct( 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 diff --git a/src/stegasoo/debug.py b/src/stegasoo/debug.py index ca22bd4..70f4cc1 100644 --- a/src/stegasoo/debug.py +++ b/src/stegasoo/debug.py @@ -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 diff --git a/src/stegasoo/decode.py b/src/stegasoo/decode.py index 1390d9b..e0b3bdd 100644 --- a/src/stegasoo/decode.py +++ b/src/stegasoo/decode.py @@ -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 @@ -291,16 +294,23 @@ def decode_audio( Returns: DecodeResult with message or file data """ - from .audio_utils import detect_audio_format, transcode_to_wav 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" + f"decode_audio: mode={embed_mode}, " f"passphrase length={len(passphrase.split())} words" ) # Validate inputs @@ -358,9 +368,7 @@ def decode_audio( 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 - ) + encrypted = extract_from_audio_spread(wav_audio, pixel_key, progress_file=progress_file) else: raise ValueError(f"Invalid audio embed mode: {embed_mode}") diff --git a/src/stegasoo/encode.py b/src/stegasoo/encode.py index 5a3f670..604d2b0 100644 --- a/src/stegasoo/encode.py +++ b/src/stegasoo/encode.py @@ -18,6 +18,7 @@ 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 from .models import EncodeResult, FilePayload from .steganography import embed_in_image from .utils import generate_filename @@ -280,6 +281,7 @@ def encode_audio( 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. @@ -295,12 +297,21 @@ def encode_audio( 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 - from .constants import EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_SPREAD debug.print( f"encode_audio: mode={embed_mode}, " @@ -343,10 +354,12 @@ def encode_audio( encrypted, carrier_audio, pixel_key, progress_file=progress_file ) elif embed_mode == EMBED_MODE_AUDIO_SPREAD: + from .constants import AUDIO_SS_DEFAULT_CHIP_TIER from .spread_steganography import embed_in_audio_spread + tier = chip_tier if chip_tier is not None else AUDIO_SS_DEFAULT_CHIP_TIER stego_audio, stats = embed_in_audio_spread( - encrypted, carrier_audio, pixel_key, progress_file=progress_file + encrypted, carrier_audio, pixel_key, chip_tier=tier, progress_file=progress_file ) else: raise ValueError(f"Invalid audio embed mode: {embed_mode}") diff --git a/src/stegasoo/models.py b/src/stegasoo/models.py index 1c5c795..4e71bc0 100644 --- a/src/stegasoo/models.py +++ b/src/stegasoo/models.py @@ -300,6 +300,9 @@ class AudioEmbedStats: 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: @@ -329,3 +332,7 @@ class AudioCapacityInfo: 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 diff --git a/src/stegasoo/qr_utils.py b/src/stegasoo/qr_utils.py index c793917..eabc35c 100644 --- a/src/stegasoo/qr_utils.py +++ b/src/stegasoo/qr_utils.py @@ -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") diff --git a/src/stegasoo/recovery.py b/src/stegasoo/recovery.py index 25b73e3..3b7835c 100644 --- a/src/stegasoo/recovery.py +++ b/src/stegasoo/recovery.py @@ -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: diff --git a/src/stegasoo/spread_steganography.py b/src/stegasoo/spread_steganography.py index 77ef6f6..5f8f08e 100644 --- a/src/stegasoo/spread_steganography.py +++ b/src/stegasoo/spread_steganography.py @@ -1,21 +1,32 @@ """ -Spread Spectrum Audio Steganography Module (v4.3.0) +Spread Spectrum Audio Steganography Module (v4.4.0 — Per-Channel Hybrid) Hides data in audio by adding keyed pseudo-random noise (spread spectrum) below the threshold of audibility. Designed to survive lossy compression (MP3, AAC, Opus) better than LSB embedding, which requires lossless carriers. -How it works: - Each payload bit is "spread" over AUDIO_SS_CHIP_LENGTH audio samples using - a unique ChaCha20-derived chip sequence. A '1' bit adds the chip pattern; - a '0' bit subtracts it. On extraction, correlating the stego audio against - the same chip sequence recovers each bit -- even after moderate lossy - compression, because the correlation survives quantisation noise. +v4.4.0 changes: + - Per-channel independent embedding (preserves spatial stereo/surround mix) + - Adaptive chip length tiers (256/512/1024) for capacity vs robustness + - LFE channel skipping for 5.1+ layouts + - Round-robin bit distribution across embeddable channels + - v2 header format with backward-compatible v0 decode fallback -Data layout in the carrier: - [4B magic AUDS] [4B length x3 copies] [RS-encoded payload] - All converted to bits and embedded sequentially via spread spectrum. - Three copies of the length field enable majority voting for recovery. +How it works: + Each payload bit is "spread" over chip_length audio samples using a unique + ChaCha20-derived chip sequence. A '1' bit adds the chip pattern; a '0' bit + subtracts it. On extraction, correlating the stego audio against the same + chip sequence recovers each bit. + +v2 data layout in the carrier: + Header (20 bytes, channel 0 only, chip=1024 for robustness): + [4B magic AUDS] [1B version=0x02] [1B chip_tier] [1B num_channels] + [1B flags] [4B length x3 copies] + Payload (round-robin across embeddable channels, chip=tier-selected): + RS-encoded payload bits distributed: bit 0→ch0, bit 1→ch1, ... + + Legacy v0 layout (backward compat): + [4B magic AUDS] [4B length x3 copies] [RS-encoded payload] — all mono Error correction: The raw payload is protected with Reed-Solomon coding (AUDIO_SS_RS_NSYM @@ -35,9 +46,14 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms from .constants import ( + AUDIO_LFE_CHANNEL_INDEX, + AUDIO_LFE_MIN_CHANNELS, AUDIO_MAGIC_SPREAD, AUDIO_SS_AMPLITUDE, AUDIO_SS_CHIP_LENGTH, + AUDIO_SS_CHIP_LENGTHS, + AUDIO_SS_DEFAULT_CHIP_TIER, + AUDIO_SS_HEADER_VERSION, AUDIO_SS_RS_NSYM, EMBED_MODE_AUDIO_SPREAD, ) @@ -65,11 +81,18 @@ except ImportError: ReedSolomonError = None # type: ignore[assignment,misc] -# Header layout: 4B magic + 3 x 4B length = 16 bytes = 128 bits -_HEADER_SIZE = 16 +# Header sizes +_V0_HEADER_SIZE = 16 # Legacy: 4B magic + 3x4B length +_V2_HEADER_SIZE = 20 # v2: 4B magic + 1B ver + 1B tier + 1B nch + 1B flags + 3x4B length _MAGIC_SIZE = 4 _LENGTH_COPIES = 3 +# Header chip length — always 1024 for maximum robustness regardless of payload tier +_HEADER_CHIP_LENGTH = 1024 + +# v2 header flags +_FLAG_LFE_SKIPPED = 0x01 + # Progress reporting interval (every N bits) _PROGRESS_INTERVAL = 500 @@ -140,26 +163,55 @@ def _rs_decode(data: bytes) -> bytes | None: return None +def _rs_encoded_size(data_length: int) -> int: + """Calculate the RS-encoded size for a given data length.""" + if not HAS_REEDSOLO or AUDIO_SS_RS_NSYM <= 0: + return data_length + data_block_size = 255 - AUDIO_SS_RS_NSYM + num_blocks = (data_length + data_block_size - 1) // data_block_size + return data_length + num_blocks * AUDIO_SS_RS_NSYM + + +# ============================================================================= +# CHANNEL MANAGEMENT +# ============================================================================= + + +def _embeddable_channels(num_channels: int) -> list[int]: + """ + Return the list of channel indices suitable for embedding. + + Skips the LFE channel (index 3) for 5.1+ layouts because LFE is + bandlimited to ~120Hz — terrible as a spread spectrum carrier. + """ + if num_channels < AUDIO_LFE_MIN_CHANNELS: + return list(range(num_channels)) + return [i for i in range(num_channels) if i != AUDIO_LFE_CHANNEL_INDEX] + + # ============================================================================= # CHIP SEQUENCE GENERATION (ChaCha20 CSPRNG) # ============================================================================= -def _generate_chip_sequence(seed: bytes, chip_index: int, length: int) -> np.ndarray: +def _generate_chip_sequence( + seed: bytes, chip_index: int, length: int, channel_index: int = 0 +) -> np.ndarray: """ Generate a pseudo-random chip sequence for spread spectrum embedding. - Uses ChaCha20 as a CSPRNG keyed by ``seed``, with ``chip_index`` encoded - into the nonce so that each bit position gets a unique, deterministic - spreading code. + Uses ChaCha20 as a CSPRNG keyed by ``seed``, with ``chip_index`` and + ``channel_index`` encoded into the nonce so that each (channel, bit) + pair gets a unique, deterministic spreading code. Args: seed: 32-byte key for ChaCha20. Padded/hashed to 32B if shorter. chip_index: Index of the bit being embedded (used as nonce material). - length: Number of samples in the chip (AUDIO_SS_CHIP_LENGTH). + length: Number of samples in the chip. + channel_index: Audio channel index (0 for legacy/header, per-channel for v2). Returns: - Float64 numpy array of ``length`` elements, normalised to unit energy. + Float64 numpy array of ``length`` elements in bipolar {-1, +1}. """ # Ensure seed is exactly 32 bytes if len(seed) < 32: @@ -169,8 +221,11 @@ def _generate_chip_sequence(seed: bytes, chip_index: int, length: int) -> np.nda elif len(seed) > 32: seed = seed[:32] - # Build a 16-byte nonce from chip_index (ChaCha20 uses 16B nonce in cryptography lib) - nonce = chip_index.to_bytes(16, byteorder="big") + # Build a 16-byte nonce encoding both channel and chip index + # v2: channel_index in high 4 bytes, chip_index in low 12 bytes + # v0 compat: channel_index=0 → nonce is just chip_index in full 16 bytes, + # which matches the old layout since high bytes are 0 + nonce = channel_index.to_bytes(4, byteorder="big") + chip_index.to_bytes(12, byteorder="big") cipher = Cipher(algorithms.ChaCha20(seed, nonce), mode=None, backend=default_backend()) encryptor = cipher.encryptor() @@ -183,100 +238,22 @@ def _generate_chip_sequence(seed: bytes, chip_index: int, length: int) -> np.nda return chip -# ============================================================================= -# SPREAD SPECTRUM CORE -# ============================================================================= +def _generate_chip_sequence_v0(seed: bytes, chip_index: int, length: int) -> np.ndarray: + """Legacy v0 chip sequence generation — chip_index fills entire 16-byte nonce.""" + if len(seed) < 32: + import hashlib + seed = hashlib.sha256(seed).digest() + elif len(seed) > 32: + seed = seed[:32] -def _embed_spread_spectrum( - samples: np.ndarray, - bits: list[int], - seed: bytes, - amplitude: float, - offset: int = 0, - progress_file: str | None = None, -) -> np.ndarray: - """ - Embed bits into audio samples using direct-sequence spread spectrum. + nonce = chip_index.to_bytes(16, byteorder="big") + cipher = Cipher(algorithms.ChaCha20(seed, nonce), mode=None, backend=default_backend()) + encryptor = cipher.encryptor() + random_bytes = encryptor.update(b"\x00" * length) - For each bit at index i: - - Generate the chip sequence for that index - - bit 1 -> add amplitude * chip to the carrier - - bit 0 -> subtract amplitude * chip from the carrier - - Args: - samples: 1-D float64 audio samples (modified in-place and returned). - bits: List of 0/1 ints to embed. - seed: 32-byte key for chip generation. - amplitude: Embedding strength (AUDIO_SS_AMPLITUDE). - offset: Sample offset at which spread embedding begins. - progress_file: Optional path for progress JSON. - - Returns: - Modified samples array. - """ - total_bits = len(bits) - for i, bit in enumerate(bits): - start = offset + i * AUDIO_SS_CHIP_LENGTH - end = start + AUDIO_SS_CHIP_LENGTH - - if end > len(samples): - debug.print(f"Warning: ran out of samples at bit {i}/{total_bits}") - break - - chip = _generate_chip_sequence(seed, i, AUDIO_SS_CHIP_LENGTH) - - if bit == 1: - samples[start:end] += amplitude * chip - else: - samples[start:end] -= amplitude * chip - - if progress_file and i % _PROGRESS_INTERVAL == 0: - _write_progress(progress_file, i, total_bits, "embedding") - - return samples - - -def _extract_spread_spectrum( - samples: np.ndarray, - num_bits: int, - seed: bytes, - offset: int = 0, - progress_file: str | None = None, -) -> list[int]: - """ - Extract bits from audio using spread spectrum correlation. - - For each bit index i, correlate the sample window with the chip - sequence. Positive correlation -> 1, negative -> 0. - - Args: - samples: 1-D float64 audio samples. - num_bits: Number of bits to extract. - seed: 32-byte key (must match embedding key). - offset: Sample offset where spread data begins. - progress_file: Optional path for progress JSON. - - Returns: - List of extracted 0/1 ints. - """ - bits: list[int] = [] - for i in range(num_bits): - start = offset + i * AUDIO_SS_CHIP_LENGTH - end = start + AUDIO_SS_CHIP_LENGTH - - if end > len(samples): - debug.print(f"Warning: ran out of samples at bit {i}/{num_bits}") - break - - chip = _generate_chip_sequence(seed, i, AUDIO_SS_CHIP_LENGTH) - correlation = np.dot(samples[start:end], chip) - bits.append(1 if correlation > 0 else 0) - - if progress_file and i % _PROGRESS_INTERVAL == 0: - _write_progress(progress_file, i, num_bits, "extracting") - - return bits + raw = np.frombuffer(random_bytes, dtype=np.uint8) + return np.where(raw < 128, np.float64(-1.0), np.float64(1.0)) # ============================================================================= @@ -337,37 +314,241 @@ def _majority_vote_length(length_bytes: bytes) -> int | None: # ============================================================================= -# HEADER CONSTRUCTION +# HEADER CONSTRUCTION & PARSING # ============================================================================= -def _build_header(data_length: int) -> bytes: - """ - Build the spread spectrum header. - - Layout: AUDIO_MAGIC_SPREAD (4B) + length (4B) x 3 copies = 16 bytes. - """ +def _build_header_v0(data_length: int) -> bytes: + """Build legacy v0 header: 4B magic + 3x4B length = 16 bytes.""" length_packed = struct.pack(">I", data_length) return AUDIO_MAGIC_SPREAD + length_packed * _LENGTH_COPIES -def _parse_header(header_bytes: bytes) -> tuple[bool, int | None]: +def _build_header_v2( + data_length: int, chip_tier: int, num_embeddable_channels: int, lfe_skipped: bool +) -> bytes: """ - Parse and validate the spread spectrum header. + Build v2 header (20 bytes). + + Layout: + [4B magic AUDS] [1B version=0x02] [1B chip_tier] [1B num_channels] + [1B flags] [4B length x3 copies] + """ + flags = _FLAG_LFE_SKIPPED if lfe_skipped else 0 + length_packed = struct.pack(">I", data_length) + return ( + AUDIO_MAGIC_SPREAD + + struct.pack("BBBB", AUDIO_SS_HEADER_VERSION, chip_tier, num_embeddable_channels, flags) + + length_packed * _LENGTH_COPIES + ) + + +def _parse_header( + header_bytes: bytes, +) -> tuple[bool, int, int | None, int | None, int | None, bool]: + """ + Parse and validate the spread spectrum header (version-dispatching). + + Reads byte 4 after magic to determine version: + - 0x02 → v2 header (20 bytes) + - anything else → v0 header (16 bytes, legacy mono) Returns: - (magic_valid, payload_length) -- length is None if voting fails. + (magic_valid, version, payload_length, chip_tier, num_channels, lfe_skipped) + version is 0 for legacy, 2 for v2. + payload_length is None if majority vote fails. + chip_tier/num_channels are None for v0. """ - if len(header_bytes) < _HEADER_SIZE: - return False, None + if len(header_bytes) < _V0_HEADER_SIZE: + return False, 0, None, None, None, False magic = header_bytes[:_MAGIC_SIZE] if magic != AUDIO_MAGIC_SPREAD: debug.print(f"Magic mismatch: got {magic!r}, expected {AUDIO_MAGIC_SPREAD!r}") - return False, None + return False, 0, None, None, None, False - length = _majority_vote_length(header_bytes[_MAGIC_SIZE:_HEADER_SIZE]) - return True, length + # Check version byte + version_byte = header_bytes[4] + + if version_byte == AUDIO_SS_HEADER_VERSION: + # v2 header — 20 bytes + if len(header_bytes) < _V2_HEADER_SIZE: + return False, 2, None, None, None, False + + chip_tier = header_bytes[5] + num_channels = header_bytes[6] + flags = header_bytes[7] + lfe_skipped = bool(flags & _FLAG_LFE_SKIPPED) + + length = _majority_vote_length(header_bytes[8:20]) + debug.print( + f"v2 header: tier={chip_tier}, channels={num_channels}, " + f"lfe_skip={lfe_skipped}, length={length}" + ) + return True, 2, length, chip_tier, num_channels, lfe_skipped + else: + # v0 header — 16 bytes, byte 4 is part of the first length copy + length = _majority_vote_length(header_bytes[_MAGIC_SIZE:_V0_HEADER_SIZE]) + debug.print(f"v0 header: length={length}") + return True, 0, length, None, None, False + + +# ============================================================================= +# SPREAD SPECTRUM CORE — PER-CHANNEL (v2) +# ============================================================================= + + +def _embed_channel( + channel_samples: np.ndarray, + bits: list[int], + seed: bytes, + amplitude: float, + chip_length: int, + channel_index: int, + offset: int = 0, +) -> int: + """ + Embed bits into a single channel's samples. Returns count of bits embedded. + """ + count = 0 + for i, bit in enumerate(bits): + start = offset + i * chip_length + end = start + chip_length + + if end > len(channel_samples): + break + + chip = _generate_chip_sequence(seed, i, chip_length, channel_index=channel_index) + + if bit == 1: + channel_samples[start:end] += amplitude * chip + else: + channel_samples[start:end] -= amplitude * chip + count += 1 + + return count + + +def _extract_channel( + channel_samples: np.ndarray, + num_bits: int, + seed: bytes, + chip_length: int, + channel_index: int, + offset: int = 0, +) -> list[int]: + """Extract bits from a single channel's samples.""" + bits: list[int] = [] + for i in range(num_bits): + start = offset + i * chip_length + end = start + chip_length + + if end > len(channel_samples): + break + + chip = _generate_chip_sequence(seed, i, chip_length, channel_index=channel_index) + correlation = np.dot(channel_samples[start:end], chip) + bits.append(1 if correlation > 0 else 0) + + return bits + + +def _distribute_bits_round_robin( + bits: list[int], num_channels: int +) -> list[list[int]]: + """ + Distribute bits round-robin across channels. + + bit 0 → ch0, bit 1 → ch1, ..., bit N → ch(N % num_channels), ... + + Returns a list of per-channel bit lists. + """ + per_channel: list[list[int]] = [[] for _ in range(num_channels)] + for i, bit in enumerate(bits): + per_channel[i % num_channels].append(bit) + return per_channel + + +def _collect_bits_round_robin(per_channel_bits: list[list[int]]) -> list[int]: + """ + Reassemble bits from per-channel lists back into original order. + + Inverse of _distribute_bits_round_robin. + """ + num_channels = len(per_channel_bits) + if num_channels == 0: + return [] + + max_len = max(len(ch) for ch in per_channel_bits) + result: list[int] = [] + for i in range(max_len): + for ch in range(num_channels): + if i < len(per_channel_bits[ch]): + result.append(per_channel_bits[ch][i]) + return result + + +# ============================================================================= +# LEGACY MONO CORE (v0 compat) +# ============================================================================= + + +def _embed_spread_spectrum_v0( + samples: np.ndarray, + bits: list[int], + seed: bytes, + amplitude: float, + offset: int = 0, + progress_file: str | None = None, +) -> np.ndarray: + """Legacy v0 mono embedding with original nonce layout.""" + total_bits = len(bits) + for i, bit in enumerate(bits): + start = offset + i * AUDIO_SS_CHIP_LENGTH + end = start + AUDIO_SS_CHIP_LENGTH + + if end > len(samples): + debug.print(f"Warning: ran out of samples at bit {i}/{total_bits}") + break + + chip = _generate_chip_sequence_v0(seed, i, AUDIO_SS_CHIP_LENGTH) + + if bit == 1: + samples[start:end] += amplitude * chip + else: + samples[start:end] -= amplitude * chip + + if progress_file and i % _PROGRESS_INTERVAL == 0: + _write_progress(progress_file, i, total_bits, "embedding") + + return samples + + +def _extract_spread_spectrum_v0( + samples: np.ndarray, + num_bits: int, + seed: bytes, + offset: int = 0, + progress_file: str | None = None, +) -> list[int]: + """Legacy v0 mono extraction with original nonce layout.""" + bits: list[int] = [] + for i in range(num_bits): + start = offset + i * AUDIO_SS_CHIP_LENGTH + end = start + AUDIO_SS_CHIP_LENGTH + + if end > len(samples): + debug.print(f"Warning: ran out of samples at bit {i}/{num_bits}") + break + + chip = _generate_chip_sequence_v0(seed, i, AUDIO_SS_CHIP_LENGTH) + correlation = np.dot(samples[start:end], chip) + bits.append(1 if correlation > 0 else 0) + + if progress_file and i % _PROGRESS_INTERVAL == 0: + _write_progress(progress_file, i, num_bits, "extracting") + + return bits # ============================================================================= @@ -375,16 +556,15 @@ def _parse_header(header_bytes: bytes) -> tuple[bool, int | None]: # ============================================================================= -def calculate_audio_spread_capacity(audio_data: bytes) -> AudioCapacityInfo: +def calculate_audio_spread_capacity( + audio_data: bytes, chip_tier: int = AUDIO_SS_DEFAULT_CHIP_TIER +) -> AudioCapacityInfo: """ Calculate embedding capacity for spread spectrum audio steganography. - Loads the carrier audio, determines how many spread spectrum bits can - fit, accounts for Reed-Solomon overhead and the fixed header, and - returns the usable payload capacity in bytes. - Args: audio_data: Raw bytes of a WAV file. + chip_tier: Chip tier (0=lossless/256, 1=high_lossy/512, 2=low_lossy/1024). Returns: AudioCapacityInfo with capacity details. @@ -400,34 +580,60 @@ def calculate_audio_spread_capacity(audio_data: bytes) -> AudioCapacityInfo: except Exception as e: raise AudioError(f"Failed to read audio file: {e}") from e - total_samples = info.frames * info.channels - total_bits = total_samples // AUDIO_SS_CHIP_LENGTH - total_bytes = total_bits // 8 + chip_length = AUDIO_SS_CHIP_LENGTHS.get(chip_tier, AUDIO_SS_CHIP_LENGTH) + embed_channels = _embeddable_channels(info.channels) + num_embed_ch = len(embed_channels) + num_frames = info.frames + duration = num_frames / info.samplerate - # Subtract header overhead (16 bytes) - after_header = max(0, total_bytes - _HEADER_SIZE) + # Header is always in channel 0 with chip=1024 + header_bits = _V2_HEADER_SIZE * 8 # 160 bits + header_samples = header_bits * _HEADER_CHIP_LENGTH # always 1024 - # Account for Reed-Solomon overhead: RS adds RS_NSYM parity bytes per 255-byte block - # Usable fraction is (255 - RS_NSYM) / 255 + # Payload samples available per channel (after header eats into ch0) + samples_per_channel_for_payload = num_frames - header_samples + if samples_per_channel_for_payload <= 0: + return AudioCapacityInfo( + total_samples=num_frames * info.channels, + usable_capacity_bytes=0, + embed_mode=EMBED_MODE_AUDIO_SPREAD, + sample_rate=info.samplerate, + duration_seconds=duration, + chip_tier=chip_tier, + chip_length=chip_length, + embeddable_channels=num_embed_ch, + total_channels=info.channels, + ) + + # Bits per channel for payload + bits_per_channel = samples_per_channel_for_payload // chip_length + + # Total payload bits across all embeddable channels + total_payload_bits = bits_per_channel * num_embed_ch + total_payload_bytes = total_payload_bits // 8 + + # Account for RS overhead if HAS_REEDSOLO and AUDIO_SS_RS_NSYM > 0: - usable_bytes = int(after_header * (255 - AUDIO_SS_RS_NSYM) / 255) + usable_bytes = int(total_payload_bytes * (255 - AUDIO_SS_RS_NSYM) / 255) else: - usable_bytes = after_header - - duration = info.frames / info.samplerate + usable_bytes = total_payload_bytes debug.print( - f"Spread spectrum capacity: {usable_bytes} bytes " - f"({total_samples} samples, {total_bits} bits, " - f"{info.samplerate} Hz, {info.channels} ch, {duration:.2f}s)" + f"Spread capacity (tier {chip_tier}, chip={chip_length}): {usable_bytes} bytes " + f"({num_frames} frames, {num_embed_ch}/{info.channels} ch, " + f"{info.samplerate} Hz, {duration:.2f}s)" ) return AudioCapacityInfo( - total_samples=total_samples, + total_samples=num_frames * info.channels, usable_capacity_bytes=usable_bytes, embed_mode=EMBED_MODE_AUDIO_SPREAD, sample_rate=info.samplerate, duration_seconds=duration, + chip_tier=chip_tier, + chip_length=chip_length, + embeddable_channels=num_embed_ch, + total_channels=info.channels, ) @@ -435,24 +641,21 @@ def embed_in_audio_spread( data: bytes, carrier_audio: bytes, seed: bytes, + chip_tier: int = AUDIO_SS_DEFAULT_CHIP_TIER, progress_file: str | None = None, ) -> tuple[bytes, AudioEmbedStats]: """ - Embed data into audio using spread spectrum steganography. + Embed data into audio using per-channel spread spectrum steganography. - The payload is RS-encoded, prepended with a magic+length header - (with three copies of the length for majority voting), converted to - bits, and embedded by adding keyed pseudo-random chip sequences - to the carrier audio samples. - - Stereo audio is mixed to mono for embedding then the modification - is applied equally to all channels of the original. + v4.4.0: Embeds independently per channel with round-robin bit distribution, + preserving the spatial stereo/surround mix. Header is always in channel 0 + with chip=1024 for robustness. Payload uses the selected chip tier. Args: data: Raw payload bytes to embed (already encrypted by caller). carrier_audio: Raw bytes of the carrier WAV file. - seed: Key material for chip sequence generation (any length, - hashed to 32 bytes internally if needed). + seed: Key material for chip sequence generation. + chip_tier: Chip tier (0=256, 1=512, 2=1024). Default 2 (most robust). progress_file: Optional path for frontend progress polling. Returns: @@ -465,105 +668,133 @@ def embed_in_audio_spread( if not HAS_SOUNDFILE: raise AudioError("soundfile is required for audio spread spectrum steganography") - debug.print(f"Spread spectrum embedding {len(data)} bytes") + debug.print(f"Spread spectrum v2 embedding {len(data)} bytes, tier={chip_tier}") try: - # 1. Read carrier audio as float64 + # 1. Read carrier audio as float64 2D (frames x channels) buf = io.BytesIO(carrier_audio) samples, sample_rate = sf.read(buf, dtype="float64", always_2d=True) - original_shape = samples.shape - channels = original_shape[1] - num_frames = original_shape[0] + num_frames, channels = samples.shape duration = num_frames / sample_rate - # Read subtype from input to preserve on output buf.seek(0) carrier_info = sf.info(buf) output_subtype = carrier_info.subtype if carrier_info.subtype else "PCM_16" + # 2. Build embeddable channels list + embed_ch = _embeddable_channels(channels) + num_embed_ch = len(embed_ch) + lfe_skipped = len(embed_ch) < channels + chip_length = AUDIO_SS_CHIP_LENGTHS.get(chip_tier, AUDIO_SS_CHIP_LENGTH) + debug.print( - f"Carrier: {sample_rate} Hz, {channels} ch, " - f"{num_frames} frames, {duration:.2f}s, subtype={output_subtype}" + f"Carrier: {sample_rate} Hz, {channels} ch ({num_embed_ch} embeddable), " + f"{num_frames} frames, {duration:.2f}s, chip={chip_length}" ) - # 2. Mix to mono for embedding (average across channels) - if channels > 1: - mono_samples = np.mean(samples, axis=1) - else: - mono_samples = samples[:, 0].copy() - - total_samples = len(mono_samples) - # 3. RS-encode the payload rs_data = _rs_encode(data) debug.print(f"RS-encoded payload: {len(data)} -> {len(rs_data)} bytes") - # 4. Build header: magic (4B) + length x3 (12B) = 16B - header = _build_header(len(data)) + # 4. Build v2 header + header = _build_header_v2(len(data), chip_tier, num_embed_ch, lfe_skipped) + header_bits = _bytes_to_bits(header) - # 5. Combine header + RS-encoded data and convert to bits - full_payload = header + rs_data - bits = _bytes_to_bits(full_payload) + # 5. Embed header in channel 0 only, chip=1024, starting at sample 0 + header_samples_needed = len(header_bits) * _HEADER_CHIP_LENGTH + if header_samples_needed > num_frames: + raise AudioCapacityError(len(header), num_frames // _HEADER_CHIP_LENGTH // 8) - total_bits = len(bits) - samples_needed = total_bits * AUDIO_SS_CHIP_LENGTH - - debug.print( - f"Total payload: {len(full_payload)} bytes = {total_bits} bits, " - f"needs {samples_needed} samples (have {total_samples})" - ) - - # 6. Check capacity - if samples_needed > total_samples: - max_bytes = (total_samples // AUDIO_SS_CHIP_LENGTH) // 8 - raise AudioCapacityError(len(full_payload), max_bytes) - - capacity_used = samples_needed / total_samples - - # 7. Initial progress - _write_progress(progress_file, 0, total_bits, "embedding") - - # 8. Embed via spread spectrum into mono - mono_modified = _embed_spread_spectrum( - mono_samples, - bits, + _embed_channel( + samples[:, embed_ch[0]], + header_bits, seed, AUDIO_SS_AMPLITUDE, + _HEADER_CHIP_LENGTH, + channel_index=0, offset=0, - progress_file=progress_file, ) - # 9. Apply modification back to all channels - # delta = modified_mono - original_mono, add delta to each channel - delta = mono_modified - (np.mean(samples, axis=1) if channels > 1 else samples[:, 0]) - for ch in range(channels): - samples[:, ch] += delta + # 6. Calculate payload offset (in samples, same for all channels) + payload_offset = header_samples_needed - # Clip to [-1.0, 1.0] to prevent clipping artefacts + # 7. Convert payload to bits and distribute round-robin + payload_bits = _bytes_to_bits(rs_data) + total_payload_bits = len(payload_bits) + + # Check capacity across all channels + payload_samples_available = num_frames - payload_offset + bits_per_channel = payload_samples_available // chip_length + total_capacity_bits = bits_per_channel * num_embed_ch + + if total_payload_bits > total_capacity_bits: + max_bytes = (total_capacity_bits // 8) + if HAS_REEDSOLO and AUDIO_SS_RS_NSYM > 0: + max_bytes = int(max_bytes * (255 - AUDIO_SS_RS_NSYM) / 255) + raise AudioCapacityError(len(data), max_bytes) + + per_channel_bits = _distribute_bits_round_robin(payload_bits, num_embed_ch) + + debug.print( + f"Distributing {total_payload_bits} payload bits across {num_embed_ch} channels " + f"(~{total_payload_bits // num_embed_ch} bits/ch)" + ) + + _write_progress(progress_file, 0, total_payload_bits, "embedding") + + # 8. Embed payload bits per channel + total_embedded = 0 + for ch_idx, ch in enumerate(embed_ch): + bits_for_ch = per_channel_bits[ch_idx] + if not bits_for_ch: + continue + count = _embed_channel( + samples[:, ch], + bits_for_ch, + seed, + AUDIO_SS_AMPLITUDE, + chip_length, + channel_index=ch, + offset=payload_offset, + ) + total_embedded += count + + if progress_file: + _write_progress(progress_file, total_embedded, total_payload_bits, "embedding") + + # 9. Clip to [-1.0, 1.0] np.clip(samples, -1.0, 1.0, out=samples) - _write_progress(progress_file, total_bits, total_bits, "saving") + _write_progress(progress_file, total_payload_bits, total_payload_bits, "saving") - # 10. Write back as WAV preserving original subtype + # 10. Write back as WAV output_buf = io.BytesIO() sf.write(output_buf, samples, sample_rate, format="WAV", subtype=output_subtype) output_buf.seek(0) stego_bytes = output_buf.getvalue() - samples_modified = samples_needed # every chip-length region was touched + # Calculate capacity used + header_capacity = header_samples_needed # in ch0 + payload_capacity = total_embedded * chip_length # across all channels + total_sample_slots = num_frames * num_embed_ch + capacity_used = (header_capacity + payload_capacity) / total_sample_slots + stats = AudioEmbedStats( - samples_modified=samples_modified, - total_samples=total_samples * channels, + samples_modified=header_capacity + payload_capacity, + total_samples=num_frames * channels, capacity_used=capacity_used, - bytes_embedded=len(full_payload), + bytes_embedded=len(header) + len(rs_data), sample_rate=sample_rate, channels=channels, duration_seconds=duration, embed_mode=EMBED_MODE_AUDIO_SPREAD, + chip_tier=chip_tier, + chip_length=chip_length, + embeddable_channels=num_embed_ch, ) debug.print( - f"Spread spectrum embedding complete: {len(stego_bytes)} byte WAV, " + f"Spread spectrum v2 embedding complete: {len(stego_bytes)} byte WAV, " f"capacity used {capacity_used * 100:.1f}%" ) return stego_bytes, stats @@ -583,9 +814,9 @@ def extract_from_audio_spread( """ Extract hidden data from audio using spread spectrum correlation. - Loads the stego audio, extracts the header bits to recover the magic - marker and payload length (via majority voting on three copies), then - extracts the full RS-protected payload and decodes it. + Auto-detects header version: + - v2: per-channel extraction with round-robin reassembly + - v0: legacy mono extraction (backward compat) Args: audio_data: Raw bytes of the stego WAV file. @@ -593,8 +824,7 @@ def extract_from_audio_spread( progress_file: Optional path for frontend progress polling. Returns: - Extracted payload bytes, or None if extraction fails (wrong key, - no data found, corrupted beyond recovery). + Extracted payload bytes, or None if extraction fails. """ if not HAS_SOUNDFILE: debug.print("soundfile not available for spread spectrum extraction") @@ -603,133 +833,257 @@ def extract_from_audio_spread( debug.print(f"Spread spectrum extracting from {len(audio_data)} byte audio") try: - # 1. Read stego audio as float64 + # 1. Read stego audio as float64 2D samples, sample_rate = sf.read(io.BytesIO(audio_data), dtype="float64", always_2d=True) - channels = samples.shape[1] + num_frames, channels = samples.shape - # Mix to mono (same as embedding) - if channels > 1: - mono_samples = np.mean(samples, axis=1) - else: - mono_samples = samples[:, 0].copy() + debug.print(f"Stego audio: {sample_rate} Hz, {channels} ch, {num_frames} frames") - total_samples = len(mono_samples) + # 2. Extract header from channel 0 with chip=1024 + # We need at least v2 header size (20 bytes = 160 bits) + header_bits_needed = _V2_HEADER_SIZE * 8 + header_samples_needed = header_bits_needed * _HEADER_CHIP_LENGTH - debug.print(f"Stego audio: {sample_rate} Hz, {channels} ch, {total_samples} samples") + # Get channel 0 (first embeddable channel — always index 0 for header) + embed_ch = _embeddable_channels(channels) + ch0_samples = samples[:, embed_ch[0]] - # 2. Extract header bits: 16 bytes = 128 bits - header_bits_needed = _HEADER_SIZE * 8 - header_samples_needed = header_bits_needed * AUDIO_SS_CHIP_LENGTH - - if header_samples_needed > total_samples: + if header_samples_needed > len(ch0_samples): debug.print("Audio too short to contain spread spectrum header") return None _write_progress(progress_file, 0, header_bits_needed, "extracting header") - header_bits = _extract_spread_spectrum( - mono_samples, + # Extract header bits from channel 0 with channel_index=0 + header_bits = _extract_channel( + ch0_samples, header_bits_needed, seed, + _HEADER_CHIP_LENGTH, + channel_index=0, offset=0, - progress_file=None, # don't spam progress for header ) if len(header_bits) < header_bits_needed: - debug.print( - f"Could not extract enough header bits: {len(header_bits)}/{header_bits_needed}" - ) + debug.print(f"Short header extraction: {len(header_bits)}/{header_bits_needed}") return None header_bytes = _bits_to_bytes(header_bits) - # 3. Parse and validate header - magic_valid, data_length = _parse_header(header_bytes) + # 3. Parse header (version-dispatching) + magic_valid, version, data_length, chip_tier, num_ch, lfe_skipped = _parse_header( + header_bytes + ) if not magic_valid: - debug.print("Spread spectrum magic not found -- wrong key or no embedded data") - return None + # Try legacy v0 with mono extraction + debug.print("v2 header not found, trying legacy v0 mono extraction") + return _extract_v0_fallback(samples, seed, progress_file) if data_length is None: debug.print("Could not determine payload length (majority vote failed)") return None - debug.print(f"Header valid: magic=AUDS, payload_length={data_length}") + if version == 0: + # v0 header detected — use legacy mono path + debug.print(f"v0 header detected, payload_length={data_length}") + return _extract_v0_with_known_length(samples, seed, data_length, progress_file) - # Sanity check the length - max_payload = (total_samples // AUDIO_SS_CHIP_LENGTH) // 8 - _HEADER_SIZE - if data_length < 1 or data_length > max_payload: - debug.print(f"Invalid payload length {data_length} (max possible: {max_payload})") - return None + # v2 extraction + debug.print( + f"v2 header: payload_length={data_length}, tier={chip_tier}, " + f"channels={num_ch}, lfe_skip={lfe_skipped}" + ) - # 4. Calculate total bits for RS-encoded data - # RS adds AUDIO_SS_RS_NSYM parity bytes per (255 - RS_NSYM) data bytes - if HAS_REEDSOLO and AUDIO_SS_RS_NSYM > 0: - # RSCodec encodes in blocks: each block has 255 bytes (data + parity) - # For input of N bytes, output is N + ceil(N / (255 - RS_NSYM)) * RS_NSYM - data_block_size = 255 - AUDIO_SS_RS_NSYM - num_blocks = (data_length + data_block_size - 1) // data_block_size - rs_encoded_size = data_length + num_blocks * AUDIO_SS_RS_NSYM - else: - rs_encoded_size = data_length + chip_length = AUDIO_SS_CHIP_LENGTHS.get(chip_tier, AUDIO_SS_CHIP_LENGTH) + payload_offset = _V2_HEADER_SIZE * 8 * _HEADER_CHIP_LENGTH - total_payload_bytes = _HEADER_SIZE + rs_encoded_size - total_bits_needed = total_payload_bytes * 8 - total_samples_needed = total_bits_needed * AUDIO_SS_CHIP_LENGTH + # 4. Calculate RS-encoded size + rs_encoded_size = _rs_encoded_size(data_length) + total_payload_bits = rs_encoded_size * 8 - if total_samples_needed > total_samples: + # Sanity check + payload_samples_available = num_frames - payload_offset + bits_per_channel = payload_samples_available // chip_length + max_capacity_bits = bits_per_channel * num_ch + + if total_payload_bits > max_capacity_bits: debug.print( - f"Need {total_samples_needed} samples for full extraction " - f"but only have {total_samples}" + f"Payload too large for carrier: need {total_payload_bits} bits, " + f"have {max_capacity_bits}" ) return None + # 5. Calculate how many bits per channel + bits_per_ch_needed = [0] * num_ch + for i in range(total_payload_bits): + bits_per_ch_needed[i % num_ch] += 1 + debug.print( - f"Extracting {total_bits_needed} bits " - f"({_HEADER_SIZE}B header + {rs_encoded_size}B RS payload)" + f"Extracting {total_payload_bits} payload bits from {num_ch} channels " + f"(chip={chip_length})" ) - # 5. Extract all bits (including header again -- simpler and no perf issue) - _write_progress(progress_file, 0, total_bits_needed, "extracting") + _write_progress(progress_file, 0, total_payload_bits, "extracting") - all_bits = _extract_spread_spectrum( - mono_samples, - total_bits_needed, - seed, - offset=0, - progress_file=progress_file, - ) + # 6. Extract per-channel bits + per_channel_bits: list[list[int]] = [] + total_extracted = 0 - if len(all_bits) < total_bits_needed: - debug.print(f"Short extraction: {len(all_bits)}/{total_bits_needed} bits") + for ch_idx in range(num_ch): + # Map ch_idx back to actual channel index + ch = embed_ch[ch_idx] if ch_idx < len(embed_ch) else ch_idx + + ch_bits = _extract_channel( + samples[:, ch], + bits_per_ch_needed[ch_idx], + seed, + chip_length, + channel_index=ch, + offset=payload_offset, + ) + per_channel_bits.append(ch_bits) + total_extracted += len(ch_bits) + + if progress_file: + _write_progress(progress_file, total_extracted, total_payload_bits, "extracting") + + # 7. Reassemble bits in round-robin order + all_payload_bits = _collect_bits_round_robin(per_channel_bits) + + if len(all_payload_bits) < total_payload_bits: + debug.print( + f"Short extraction: {len(all_payload_bits)}/{total_payload_bits} bits" + ) return None - _write_progress(progress_file, total_bits_needed, total_bits_needed, "decoding") + _write_progress(progress_file, total_payload_bits, total_payload_bits, "decoding") - # 6. Convert bits to bytes, skip header, get RS payload - all_bytes = _bits_to_bytes(all_bits) - rs_payload = all_bytes[_HEADER_SIZE : _HEADER_SIZE + rs_encoded_size] + # 8. Convert to bytes and RS-decode + rs_payload = _bits_to_bytes(all_payload_bits[:total_payload_bits]) if len(rs_payload) < rs_encoded_size: debug.print(f"RS payload too short: {len(rs_payload)}/{rs_encoded_size} bytes") return None - # 7. RS-decode + rs_payload = rs_payload[:rs_encoded_size] decoded = _rs_decode(rs_payload) if decoded is None: - debug.print("Reed-Solomon decoding failed -- data too corrupted") + debug.print("Reed-Solomon decoding failed — data too corrupted") return None - # 8. Verify decoded length matches header if len(decoded) < data_length: debug.print(f"Decoded data shorter than expected: {len(decoded)}/{data_length}") return None payload = decoded[:data_length] - - debug.print(f"Spread spectrum extraction successful: {len(payload)} bytes") + debug.print(f"Spread spectrum v2 extraction successful: {len(payload)} bytes") return payload except Exception as e: debug.exception(e, "extract_from_audio_spread") return None + + +# ============================================================================= +# LEGACY V0 EXTRACTION (backward compat) +# ============================================================================= + + +def _extract_v0_fallback( + samples: np.ndarray, seed: bytes, progress_file: str | None = None +) -> bytes | None: + """Full v0 extraction — read header + payload from mono mix.""" + channels = samples.shape[1] + + if channels > 1: + mono_samples = np.mean(samples, axis=1) + else: + mono_samples = samples[:, 0].copy() + + total_samples = len(mono_samples) + + # Extract v0 header (16 bytes = 128 bits) + header_bits_needed = _V0_HEADER_SIZE * 8 + header_samples_needed = header_bits_needed * AUDIO_SS_CHIP_LENGTH + + if header_samples_needed > total_samples: + debug.print("Audio too short for v0 header") + return None + + header_bits = _extract_spread_spectrum_v0(mono_samples, header_bits_needed, seed, offset=0) + if len(header_bits) < header_bits_needed: + return None + + header_bytes = _bits_to_bytes(header_bits) + + magic = header_bytes[:_MAGIC_SIZE] + if magic != AUDIO_MAGIC_SPREAD: + debug.print("v0 fallback: magic mismatch") + return None + + data_length = _majority_vote_length(header_bytes[_MAGIC_SIZE:_V0_HEADER_SIZE]) + if data_length is None: + return None + + return _extract_v0_with_known_length(samples, seed, data_length, progress_file) + + +def _extract_v0_with_known_length( + samples: np.ndarray, seed: bytes, data_length: int, progress_file: str | None = None +) -> bytes | None: + """v0 extraction with known payload length — mono mix path.""" + channels = samples.shape[1] + + if channels > 1: + mono_samples = np.mean(samples, axis=1) + else: + mono_samples = samples[:, 0].copy() + + total_samples = len(mono_samples) + + # Sanity check + max_payload = (total_samples // AUDIO_SS_CHIP_LENGTH) // 8 - _V0_HEADER_SIZE + if data_length < 1 or data_length > max_payload: + debug.print(f"v0: invalid payload length {data_length} (max {max_payload})") + return None + + rs_encoded_size = _rs_encoded_size(data_length) + total_payload_bytes = _V0_HEADER_SIZE + rs_encoded_size + total_bits_needed = total_payload_bytes * 8 + total_samples_needed = total_bits_needed * AUDIO_SS_CHIP_LENGTH + + if total_samples_needed > total_samples: + debug.print(f"v0: need {total_samples_needed} samples, have {total_samples}") + return None + + debug.print(f"v0 extraction: {total_bits_needed} bits ({data_length}B payload)") + + _write_progress(progress_file, 0, total_bits_needed, "extracting") + + all_bits = _extract_spread_spectrum_v0( + mono_samples, total_bits_needed, seed, offset=0, progress_file=progress_file + ) + + if len(all_bits) < total_bits_needed: + return None + + _write_progress(progress_file, total_bits_needed, total_bits_needed, "decoding") + + all_bytes = _bits_to_bytes(all_bits) + rs_payload = all_bytes[_V0_HEADER_SIZE : _V0_HEADER_SIZE + rs_encoded_size] + + if len(rs_payload) < rs_encoded_size: + return None + + decoded = _rs_decode(rs_payload) + if decoded is None: + debug.print("v0: Reed-Solomon decoding failed") + return None + + if len(decoded) < data_length: + return None + + payload = decoded[:data_length] + debug.print(f"v0 extraction successful: {len(payload)} bytes") + return payload diff --git a/src/stegasoo/steganography.py b/src/stegasoo/steganography.py index 3375f09..4877507 100644 --- a/src/stegasoo/steganography.py +++ b/src/stegasoo/steganography.py @@ -109,7 +109,7 @@ EXT_TO_FORMAT = { # - v4.0.0: 66 bytes (added flags byte for channel key) HEADER_OVERHEAD = 66 # What the crypto layer adds to any message -LENGTH_PREFIX = 4 # We prepend the payload length for LSB extraction +LENGTH_PREFIX = 4 # We prepend the payload length for LSB extraction ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX # Total: 70 bytes # That 70 bytes is your minimum image capacity requirement. diff --git a/src/stegasoo/utils.py b/src/stegasoo/utils.py index b8b4051..046fd12 100644 --- a/src/stegasoo/utils.py +++ b/src/stegasoo/utils.py @@ -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>" diff --git a/src/stegasoo/validation.py b/src/stegasoo/validation.py index 7efc709..18e7c2d 100644 --- a/src/stegasoo/validation.py +++ b/src/stegasoo/validation.py @@ -13,6 +13,10 @@ import io from PIL import Image +from .debug import get_logger + +logger = get_logger(__name__) + from .constants import ( ALLOWED_AUDIO_EXTENSIONS, ALLOWED_IMAGE_EXTENSIONS, diff --git a/tests/test_audio.py b/tests/test_audio.py index 3b40550..193ce88 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -3,25 +3,37 @@ Tests for Stegasoo audio steganography. Tests cover: - Audio LSB roundtrip (encode + decode) -- Audio MDCT roundtrip (encode + decode) +- Audio spread spectrum roundtrip (v0 legacy + v2 per-channel) - Wrong credentials fail to decode -- Capacity calculations +- 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 ( - EMBED_MODE_AUDIO_LSB, - EMBED_MODE_AUDIO_SPREAD, -) +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 # ============================================================================= @@ -33,7 +45,6 @@ def carrier_wav() -> bytes: sample_rate = 44100 duration = 1.0 num_samples = int(sample_rate * duration) - # Generate a simple sine wave t = np.linspace(0, duration, num_samples, endpoint=False) samples = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16) @@ -45,9 +56,9 @@ def carrier_wav() -> bytes: @pytest.fixture def carrier_wav_stereo() -> bytes: - """Generate a stereo test WAV file.""" + """Generate a stereo test WAV file (5 seconds for spread spectrum capacity).""" sample_rate = 44100 - duration = 1.0 + 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) @@ -67,7 +78,6 @@ def carrier_wav_long() -> bytes: duration = 15.0 num_samples = int(sample_rate * duration) t = np.linspace(0, duration, num_samples, endpoint=False) - # Mix of frequencies for better MDCT embedding samples = ( (np.sin(2 * np.pi * 440 * t) + np.sin(2 * np.pi * 880 * t) + np.sin(2 * np.pi * 1320 * t)) * 5000 @@ -80,12 +90,47 @@ def carrier_wav_long() -> bytes: @pytest.fixture -def carrier_wav_spread_integration() -> bytes: - """Generate a very long WAV (150 seconds) for spread spectrum integration tests. +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]) - Spread spectrum needs 1024 samples per bit. With encryption + RS overhead (~690 bytes), - we need at least 690*8*1024 = 5.7M samples ~ 130 seconds at 44.1kHz. - """ + 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) @@ -103,7 +148,9 @@ def carrier_wav_spread_integration() -> bytes: @pytest.fixture def reference_photo() -> bytes: - """Generate a small reference photo (PNG).""" + """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)) @@ -113,6 +160,14 @@ def reference_photo() -> bytes: 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 # ============================================================================= @@ -134,7 +189,6 @@ class TestAudioLSB: from stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb payload = b"Hello, audio steganography!" - # Prepend with magic header to simulate real usage pattern key = b"\x42" * 32 stego_audio, stats = embed_in_audio_lsb(payload, carrier_wav, key) @@ -145,7 +199,6 @@ class TestAudioLSB: assert stats.samples_modified > 0 assert 0 < stats.capacity_used <= 1.0 - # Extract extracted = extract_from_audio_lsb(stego_audio, key) assert extracted is not None assert extracted == payload @@ -174,7 +227,6 @@ class TestAudioLSB: stego_audio, _ = embed_in_audio_lsb(payload, carrier_wav, correct_key) extracted = extract_from_audio_lsb(stego_audio, wrong_key) - # Should return None or garbage (not the original message) assert extracted is None or extracted != payload def test_two_bits_per_sample(self, carrier_wav): @@ -197,46 +249,97 @@ class TestAudioLSB: indices1 = generate_sample_indices(key, 10000, 100) indices2 = generate_sample_indices(key, 10000, 100) - # Same key should produce same indices assert indices1 == indices2 - - # All indices should be valid assert all(0 <= i < 10000 for i in indices1) - - # No duplicates assert len(set(indices1)) == len(indices1) # ============================================================================= -# AUDIO SPREAD SPECTRUM TESTS +# AUDIO SPREAD SPECTRUM TESTS (v2 per-channel) # ============================================================================= class TestAudioSpread: - """Tests for audio spread spectrum steganography.""" + """Tests for audio spread spectrum steganography (v2 per-channel).""" - def test_calculate_capacity(self, carrier_wav_long): + 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_spread_roundtrip(self, carrier_wav_long): - """Test spread spectrum embed/extract roundtrip.""" + 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" + 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 @@ -258,6 +361,258 @@ class TestAudioSpread: 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 @@ -423,6 +778,36 @@ class TestIntegration: 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 @@ -446,3 +831,32 @@ class TestIntegration: ) 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