Add per-channel hybrid audio spread spectrum and env feature toggles

Spread spectrum v2: independent per-channel embedding with round-robin
bit distribution, preserving spatial stereo/surround mix. Adaptive chip
tiers (256/512/1024) trade capacity for lossy codec robustness. LFE
channel skipped for 5.1+ layouts. v2 header (20B) with backward-
compatible v0 decode fallback.

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-02-28 11:58:40 -05:00
parent 0248bec813
commit ef5a9ce9cb
41 changed files with 4281 additions and 732 deletions

View File

@@ -5,6 +5,25 @@ All notable changes to Stegasoo will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org). and this project adheres to [Semantic Versioning](https://semver.org).
## [4.3.0] - 2026-02-27
### Added
- **Audio Steganography** — Hide messages in audio files (WAV, FLAC, MP3, OGG, AAC, M4A)
- LSB mode: Direct least-significant-bit embedding in audio samples
- Spread Spectrum mode: Noise-resistant encoding using pseudo-random spreading
- Automatic format transcoding to WAV for embedding
- Full CLI support: `stegasoo audio-encode`, `audio-decode`, `audio-info`
- REST API endpoints: `/audio/encode`, `/audio/decode`, `/audio/info`
- Web UI: Unified encode/decode pages with carrier type selector (Image/Audio)
- New `AudioCapacityInfo`, `AudioEmbedStats`, `AudioInfo` model classes
- Audio-specific exceptions: `AudioError`, `AudioValidationError`, `AudioCapacityError`, `AudioExtractionError`, `AudioTranscodeError`, `UnsupportedAudioFormatError`
- Subprocess isolation for audio operations (crash protection)
- `debug.py` module for structured logging across all steganography operations
### Changed
- Encode/Decode web pages now have a "Carrier Type" step to switch between Image and Audio
- Version bumped to 4.3.0
## [4.1.5] - 2026-01-07 ## [4.1.5] - 2026-01-07
### Added ### Added
@@ -201,6 +220,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
- CLI interface - CLI interface
- Basic PIN authentication - Basic PIN authentication
[4.3.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.2.1...v4.3.0
[4.1.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5 [4.1.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5
[4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3 [4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0 [4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0

View File

@@ -1,7 +1,7 @@
# Stegasoo — Claude Code Project Guide # Stegasoo — Claude Code Project Guide
Stegasoo is a secure steganography toolkit with hybrid photo + passphrase + PIN authentication. 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 ## Quick commands
@@ -27,6 +27,10 @@ src/stegasoo/ Core library
models.py Dataclasses (EncodeResult, DecodeResult, etc.) models.py Dataclasses (EncodeResult, DecodeResult, etc.)
encode.py / decode.py High-level encode/decode orchestration encode.py / decode.py High-level encode/decode orchestration
channel.py Channel key management (v4.0+) 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 compression.py Zstandard / zlib / lz4 payload compression
cli.py Click CLI entry point cli.py Click CLI entry point
generate.py Credential generation (passphrase, PIN, RSA keys) generate.py Credential generation (passphrase, PIN, RSA keys)

View File

@@ -1,6 +1,6 @@
# Stegasoo # Stegasoo
A secure steganography system for hiding encrypted messages in images using hybrid authentication. A secure steganography system for hiding encrypted messages in images and audio using hybrid authentication.
[![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) [![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) [![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 - **Multiple interfaces**: CLI, Web UI, REST API
- **File embedding**: Hide any file type (PDF, ZIP, documents) - **File embedding**: Hide any file type (PDF, ZIP, documents)
- **DCT steganography**: JPEG-resilient embedding for social media - **DCT steganography**: JPEG-resilient embedding for social media
- **Audio steganography**: Hide messages in WAV, FLAC, MP3, OGG, AAC, M4A files (LSB and Spread Spectrum modes)
- **Channel keys**: Private group communication channels - **Channel keys**: Private group communication channels
## Embedding Modes ## Embedding Modes
### Image Modes
| Mode | Capacity (1080p) | JPEG Resilient | Best For | | Mode | Capacity (1080p) | JPEG Resilient | Best For |
|------|------------------|----------------|----------| |------|------------------|----------------|----------|
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps | | **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
| **LSB** | ~750 KB | No | Email, direct file transfer | | **LSB** | ~750 KB | No | Email, direct file transfer |
### Audio Modes
| Mode | Capacity (5 min WAV) | Noise Resistant | Best For |
|------|---------------------|-----------------|----------|
| **LSB** | ~1.3 MB | No | Direct file transfer |
| **Spread Spectrum** | ~160 KB | Yes | Shared files, light processing |
## Web UI ## Web UI
| Home | Encode | Decode | Generate | | Home | Encode | Decode | Generate |

View File

@@ -1,3 +1,45 @@
# v4.3.0 — Audio Steganography
**Release Date:** 2026-02-27
## Highlights
Stegasoo can now hide messages in audio files! This release adds full audio steganography support with two embedding modes:
- **LSB (Least Significant Bit)**: Embeds data directly in audio sample LSBs. High capacity, best for direct file transfers.
- **Spread Spectrum**: Spreads data across audio frequencies using pseudo-random sequences. Lower capacity but more resistant to noise and light processing.
## What's New
### Audio Steganography
- Support for WAV, FLAC, MP3, OGG, AAC, and M4A input formats
- Automatic transcoding to WAV (16-bit PCM) for embedding
- Same security model: reference photo + passphrase + PIN/RSA + channel key
- Full CLI, REST API, and Web UI support
### Unified Web UI
- Encode and Decode pages now feature a "Carrier Type" selector
- Switch between Image and Audio modes without leaving the page
- Audio capacity display shows LSB and Spread Spectrum capacities
- Audio preview player on encode result page
### New Modules
- `audio_steganography.py` — LSB audio embedding/extraction
- `spread_steganography.py` — Spread spectrum embedding/extraction
- `audio_utils.py` — Audio format detection, validation, transcoding
- `debug.py` — Structured logging for all operations
## Upgrade Notes
Audio steganography requires `numpy` and `soundfile` packages. Install with:
```bash
pip install stegasoo[audio]
```
For full audio format support (MP3, AAC, etc.), install FFmpeg on your system.
---
## Stegasoo v4.2.1 ## Stegasoo v4.2.1
### API Security ### API Security

View File

@@ -1,6 +1,6 @@
# Maintainer: Aaron D. Lee <your-email@example.com> # Maintainer: Aaron D. Lee <your-email@example.com>
pkgname=stegasoo-api-git pkgname=stegasoo-api-git
pkgver=4.2.1 pkgver=4.3.0
pkgrel=1 pkgrel=1
pkgdesc="Stegasoo REST API with TLS and API key authentication" pkgdesc="Stegasoo REST API with TLS and API key authentication"
arch=('x86_64') arch=('x86_64')
@@ -30,7 +30,7 @@ sha256sums=('SKIP')
pkgver() { pkgver() {
cd "$pkgname" cd "$pkgname"
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \ git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
printf "%s.r%s.g%s" "4.2.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() { build() {

View File

@@ -1,6 +1,6 @@
# Maintainer: Aaron D. Lee <your-email@example.com> # Maintainer: Aaron D. Lee <your-email@example.com>
pkgname=stegasoo-cli-git pkgname=stegasoo-cli-git
pkgver=4.2.1 pkgver=4.3.0
pkgrel=1 pkgrel=1
pkgdesc="Secure steganography CLI with hybrid photo + passphrase + PIN authentication" pkgdesc="Secure steganography CLI with hybrid photo + passphrase + PIN authentication"
arch=('x86_64') arch=('x86_64')
@@ -29,7 +29,7 @@ sha256sums=('SKIP')
pkgver() { pkgver() {
cd "$pkgname" cd "$pkgname"
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \ git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
printf "%s.r%s.g%s" "4.2.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() { build() {

View File

@@ -1,6 +1,6 @@
# Maintainer: Aaron D. Lee <your-email@example.com> # Maintainer: Aaron D. Lee <your-email@example.com>
pkgname=stegasoo-git pkgname=stegasoo-git
pkgver=4.2.1 pkgver=4.3.0
pkgrel=1 pkgrel=1
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication" pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
arch=('x86_64') arch=('x86_64')
@@ -27,7 +27,7 @@ sha256sums=('SKIP')
pkgver() { pkgver() {
cd "$pkgname" cd "$pkgname"
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \ git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
printf "%s.r%s.g%s" "4.2.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() { build() {

View File

@@ -1,6 +1,6 @@
.\" Stegasoo man page .\" Stegasoo man page
.\" Generate with: groff -man -Tascii stegasoo.1 .\" Generate with: groff -man -Tascii stegasoo.1
.TH STEGASOO 1 "January 2026" "Stegasoo 4.1.7" "User Commands" .TH STEGASOO 1 "February 2026" "Stegasoo 4.3.0" "User Commands"
.SH NAME .SH NAME
stegasoo \- steganography with hybrid authentication stegasoo \- steganography with hybrid authentication
.SH SYNOPSIS .SH SYNOPSIS
@@ -12,9 +12,10 @@ stegasoo \- steganography with hybrid authentication
[\fIargs\fR] [\fIargs\fR]
.SH DESCRIPTION .SH DESCRIPTION
.B stegasoo .B stegasoo
hides messages and files in images using PIN + passphrase security. hides messages and files in images and audio using PIN + passphrase security.
It uses LSB (Least Significant Bit) steganography with optional DCT It uses LSB (Least Significant Bit) steganography with optional DCT
(Discrete Cosine Transform) encoding for JPEG resilience. (Discrete Cosine Transform) encoding for JPEG resilience, and supports
audio steganography with LSB and Spread Spectrum modes.
.PP .PP
Messages are encrypted using a hybrid authentication scheme that combines Messages are encrypted using a hybrid authentication scheme that combines
a reference photo (shared secret), passphrase, and PIN code. a reference photo (shared secret), passphrase, and PIN code.
@@ -221,6 +222,83 @@ Reset admin password using recovery key.
.PP .PP
Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR. Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR.
.RE .RE
.SS audio\-encode
Encode a message or file into an audio file.
.PP
.B stegasoo audio\-encode
.I audio
.B \-r
.I reference
[\fB\-m\fR \fImessage\fR | \fB\-f\fR \fIfile\fR]
[\fIoptions\fR]
.TP
.BR \-r ", " \-\-reference " " \fIPATH\fR
Reference photo (shared secret). Required.
.TP
.BR \-m ", " \-\-message " " \fITEXT\fR
Message to encode.
.TP
.BR \-f ", " \-\-file " " \fIPATH\fR
File to embed instead of message.
.TP
.BR \-o ", " \-\-output " " \fIPATH\fR
Output audio path.
.TP
.B \-\-passphrase " " \fITEXT\fR
Passphrase (recommend 4+ words). Prompts if not provided.
.TP
.B \-\-pin " " \fITEXT\fR
PIN code. Prompts if not provided.
.TP
.B \-\-mode " " [\fIlsb\fR|\fIspread\fR]
Embedding mode: lsb (default) or spread (spread spectrum).
.PP
.B Examples:
.nf
stegasoo audio-encode song.wav -r ref.jpg -m "Secret" --passphrase --pin
stegasoo audio-encode podcast.mp3 -r ref.jpg -f doc.pdf --mode spread
.fi
.SS audio\-decode
Decode a message or file from a stego audio file.
.PP
.B stegasoo audio\-decode
.I audio
.B \-r
.I reference
[\fIoptions\fR]
.TP
.BR \-r ", " \-\-reference " " \fIPATH\fR
Reference photo (shared secret). Required.
.TP
.B \-\-passphrase " " \fITEXT\fR
Passphrase. Prompts if not provided.
.TP
.B \-\-pin " " \fITEXT\fR
PIN code. Prompts if not provided.
.TP
.BR \-o ", " \-\-output " " \fIPATH\fR
Output path for file payloads.
.PP
.B Examples:
.nf
stegasoo audio-decode stego.wav -r ref.jpg --passphrase --pin
stegasoo audio-decode stego.wav -r ref.jpg -o ./extracted/
.fi
.SS audio\-info
Display audio file information and steganographic capacity.
.PP
.B stegasoo audio\-info
.I audio
[\fB\-\-json\fR]
.PP
Shows format, sample rate, channels, bit depth, duration, and embedding
capacity for both LSB and Spread Spectrum modes.
.PP
.B Examples:
.nf
stegasoo audio-info song.wav
stegasoo audio-info podcast.mp3 --json
.fi
.SS tools .SS tools
Image security tools. Image security tools.
.PP .PP

View File

@@ -17,9 +17,8 @@ import json
import os import os
import secrets import secrets
from pathlib import Path from pathlib import Path
from typing import Optional
from fastapi import Depends, HTTPException, Security from fastapi import HTTPException, Security
from fastapi.security import APIKeyHeader from fastapi.security import APIKeyHeader
# API key header name # API key header name
@@ -55,7 +54,7 @@ def _load_keys(location: str = "user") -> dict:
try: try:
with open(keys_file) as f: with open(keys_file) as f:
return json.load(f) return json.load(f)
except (json.JSONDecodeError, IOError): except (OSError, json.JSONDecodeError):
return {"keys": [], "enabled": True} return {"keys": [], "enabled": True}
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: if existing["name"] == name:
raise ValueError(f"Key with name '{name}' already exists") raise ValueError(f"Key with name '{name}' already exists")
data["keys"].append({ data["keys"].append(
{
"name": name, "name": name,
"hash": key_hash, "hash": key_hash,
"created": __import__("datetime").datetime.now().isoformat(), "created": __import__("datetime").datetime.now().isoformat(),
}) }
)
_save_keys(data, location) _save_keys(data, location)
@@ -204,12 +205,12 @@ def get_api_key_status() -> dict:
"keys": { "keys": {
"user": user_keys, "user": user_keys,
"project": project_keys, "project": project_keys,
} },
} }
# FastAPI dependency for API key authentication # 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. 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 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. FastAPI dependency that optionally validates API key.

View File

@@ -1,10 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Stegasoo REST API (v4.2.1) Stegasoo REST API (v4.3.0)
FastAPI-based REST API for steganography operations. FastAPI-based REST API for steganography operations.
Supports both text messages and file embedding. Supports both text messages and file embedding.
CHANGES in v4.3.0:
- Audio steganography endpoints (/audio/*)
- LSB and spread spectrum (DSSS) audio embedding modes
- Audio info and capacity checking
CHANGES in v4.2.1: CHANGES in v4.2.1:
- API key authentication (X-API-Key header) - API key authentication (X-API-Key header)
- TLS support with self-signed certificates - 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 asyncio
import base64 import base64
import logging
import os
import sys import sys
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import Literal 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 import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import JSONResponse, Response from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -44,28 +69,28 @@ from pydantic import BaseModel, Field
# API Key Authentication # API Key Authentication
try: try:
from .auth import ( from .auth import (
require_api_key,
get_api_key_status,
add_api_key, add_api_key,
remove_api_key, get_api_key_status,
list_api_keys,
is_auth_enabled, is_auth_enabled,
list_api_keys,
remove_api_key,
require_api_key,
) )
except ImportError: except ImportError:
# When running directly (not as package) # When running directly (not as package)
from auth import ( from auth import (
require_api_key,
get_api_key_status,
add_api_key, add_api_key,
remove_api_key, get_api_key_status,
list_api_keys, list_api_keys,
is_auth_enabled, remove_api_key,
require_api_key,
) )
# Add parent to path for development # Add parent to path for development
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from stegasoo import ( from stegasoo import (
HAS_AUDIO_SUPPORT,
MAX_FILE_PAYLOAD_SIZE, MAX_FILE_PAYLOAD_SIZE,
CapacityError, CapacityError,
DecryptionError, DecryptionError,
@@ -87,6 +112,12 @@ from stegasoo import (
validate_image, validate_image,
will_fit_by_mode, 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 ( from stegasoo.constants import (
DEFAULT_PASSPHRASE_WORDS, DEFAULT_PASSPHRASE_WORDS,
MAX_PASSPHRASE_WORDS, MAX_PASSPHRASE_WORDS,
@@ -163,6 +194,8 @@ EmbedModeType = Literal["lsb", "dct"]
ExtractModeType = Literal["auto", "lsb", "dct"] ExtractModeType = Literal["auto", "lsb", "dct"]
DctColorModeType = Literal["grayscale", "color"] DctColorModeType = Literal["grayscale", "color"]
DctOutputFormatType = Literal["png", "jpeg"] 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 lsb: dict
dct: DctModeInfo dct: DctModeInfo
audio: dict | None = Field(default=None, description="Audio steganography modes (v4.3.0)")
# Channel key status (v4.0.0) # Channel key status (v4.0.0)
channel: dict | None = Field(default=None, description="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_read: bool
has_qrcode_write: bool # v4.2.0: QR generation capability has_qrcode_write: bool # v4.2.0: QR generation capability
has_dct: bool has_dct: bool
has_audio: bool = Field(default=False, description="Audio steganography support (v4.3.0)")
max_payload_kb: int max_payload_kb: int
available_modes: list[str] available_modes: list[str]
dct_features: dict | None = Field(default=None, description="DCT mode features (v3.0.1+)") 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 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 # HELPER: RESOLVE CHANNEL KEY
# ============================================================================ # ============================================================================
@@ -569,12 +722,18 @@ async def root():
"source": channel_status.get("source"), "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( return StatusResponse(
version=__version__, version=__version__,
has_argon2=has_argon2(), has_argon2=has_argon2(),
has_qrcode_read=HAS_QR_READ, has_qrcode_read=HAS_QR_READ,
has_qrcode_write=HAS_QR_WRITE, has_qrcode_write=HAS_QR_WRITE,
has_dct=has_dct_support(), has_dct=has_dct_support(),
has_audio=HAS_AUDIO_SUPPORT,
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024, max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
available_modes=available_modes, available_modes=available_modes,
dct_features=dct_features, dct_features=dct_features,
@@ -606,6 +765,28 @@ async def api_modes():
"fingerprint": channel_status.get("fingerprint"), "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( return ModesResponse(
lsb={ lsb={
"available": True, "available": True,
@@ -623,6 +804,7 @@ async def api_modes():
capacity_ratio="~20% of LSB", capacity_ratio="~20% of LSB",
requires="scipy", requires="scipy",
), ),
audio=audio_info,
channel=channel_info, channel=channel_info,
) )
@@ -723,7 +905,7 @@ async def api_channel_set(request: ChannelSetRequest, _: str = Depends(require_a
@app.delete("/channel") @app.delete("/channel")
async def api_channel_clear( async def api_channel_clear(
_: str = Depends(require_api_key), _: 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. 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) @app.post("/extract-key-from-qr", response_model=QrExtractResponse)
async def api_extract_key_from_qr( async def api_extract_key_from_qr(
_: str = Depends(require_api_key), _: 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. Extract RSA key from a QR code image.
@@ -1607,6 +1789,454 @@ async def api_image_info(
raise HTTPException(500, str(e)) 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 # ERROR HANDLERS
# ============================================================================ # ============================================================================

View File

@@ -361,7 +361,7 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json, q
qr_bytes = generate_qr_code(creds.rsa_key_pem, compress=True, output_format=fmt) qr_bytes = generate_qr_code(creds.rsa_key_pem, compress=True, output_format=fmt)
qr_path.write_bytes(qr_bytes) 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(f" Saved to: {qr}", fg="bright_white")
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow") click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
click.echo() click.echo()

View File

@@ -146,6 +146,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import stegasoo import stegasoo
from stegasoo import ( from stegasoo import (
HAS_AUDIO_SUPPORT,
CapacityError, CapacityError,
DecryptionError, DecryptionError,
FilePayload, FilePayload,
@@ -463,6 +464,9 @@ def inject_globals():
"is_admin": is_admin(), "is_admin": is_admin(),
# NEW in v4.2.0 - Saved channel keys # NEW in v4.2.0 - Saved channel keys
"saved_channel_keys": 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"} 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: def format_size(size_bytes: int) -> str:
"""Format file size for display.""" """Format file size for display."""
if size_bytes < 1024: if size_bytes < 1024:
@@ -710,11 +722,15 @@ def generate():
if not qr_too_large: if not qr_too_large:
qr_token = secrets.token_urlsafe(16) qr_token = secrets.token_urlsafe(16)
cleanup_temp_files() cleanup_temp_files()
temp_storage.save_temp_file(qr_token, creds.rsa_key_pem.encode(), { temp_storage.save_temp_file(
qr_token,
creds.rsa_key_pem.encode(),
{
"filename": "rsa_key.pem", "filename": "rsa_key.pem",
"type": "rsa_key", "type": "rsa_key",
"compress": qr_needs_compression, "compress": qr_needs_compression,
}) },
)
# v3.2.0: Single passphrase instead of daily phrases # v3.2.0: Single passphrase instead of daily phrases
return render_template( return render_template(
@@ -1001,6 +1017,37 @@ def api_check_fit():
return jsonify({"error": str(e)}), 500 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 # ENCODE
# ============================================================================ # ============================================================================
@@ -1078,7 +1125,10 @@ def _run_encode_job(job_id: str, encode_params: dict) -> None:
# Store result # Store result
file_id = secrets.token_urlsafe(16) file_id = secrets.token_urlsafe(16)
temp_storage.save_temp_file(file_id, encode_result.stego_data, { temp_storage.save_temp_file(
file_id,
encode_result.stego_data,
{
"filename": filename, "filename": filename,
"embed_mode": embed_mode, "embed_mode": embed_mode,
"output_format": dct_output_format if embed_mode == "dct" else "png", "output_format": dct_output_format if embed_mode == "dct" else "png",
@@ -1086,7 +1136,94 @@ def _run_encode_job(job_id: str, encode_params: dict) -> None:
"mime_type": output_mime, "mime_type": output_mime,
"channel_mode": encode_result.channel_mode, "channel_mode": encode_result.channel_mode,
"channel_fingerprint": encode_result.channel_fingerprint, "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( _store_job(
job_id, job_id,
@@ -1131,6 +1268,196 @@ def encode_page():
rsa_key_file = request.files.get("rsa_key") rsa_key_file = request.files.get("rsa_key")
payload_file = request.files.get("payload_file") 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: if not ref_photo or not carrier:
return _error_response("Both reference photo and carrier image are required") return _error_response("Both reference photo and carrier image are required")
@@ -1356,7 +1683,10 @@ def encode_page():
# Store temporarily # Store temporarily
file_id = secrets.token_urlsafe(16) file_id = secrets.token_urlsafe(16)
cleanup_temp_files() cleanup_temp_files()
temp_storage.save_temp_file(file_id, encode_result.stego_data, { temp_storage.save_temp_file(
file_id,
encode_result.stego_data,
{
"filename": filename, "filename": filename,
"embed_mode": embed_mode, "embed_mode": embed_mode,
"output_format": dct_output_format if embed_mode == "dct" else "png", "output_format": dct_output_format if embed_mode == "dct" else "png",
@@ -1365,7 +1695,8 @@ def encode_page():
# Channel info (v4.0.0) # Channel info (v4.0.0)
"channel_mode": encode_result.channel_mode, "channel_mode": encode_result.channel_mode,
"channel_fingerprint": encode_result.channel_fingerprint, "channel_fingerprint": encode_result.channel_fingerprint,
}) },
)
return redirect(url_for("encode_result", file_id=file_id)) return redirect(url_for("encode_result", file_id=file_id))
@@ -1434,10 +1765,13 @@ def encode_result(file_id):
flash("File expired or not found. Please encode again.", "error") flash("File expired or not found. Please encode again.", "error")
return redirect(url_for("encode_page")) return redirect(url_for("encode_page"))
# Generate thumbnail carrier_type = file_info.get("carrier_type", "image")
thumbnail_data = generate_thumbnail(file_info["data"])
thumbnail_id = None
# 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: if thumbnail_data:
thumbnail_id = f"{file_id}_thumb" thumbnail_id = f"{file_id}_thumb"
temp_storage.save_thumbnail(thumbnail_id, thumbnail_data) temp_storage.save_thumbnail(thumbnail_id, thumbnail_data)
@@ -1450,6 +1784,7 @@ def encode_result(file_id):
embed_mode=file_info.get("embed_mode", "lsb"), embed_mode=file_info.get("embed_mode", "lsb"),
output_format=file_info.get("output_format", "png"), output_format=file_info.get("output_format", "png"),
color_mode=file_info.get("color_mode"), color_mode=file_info.get("color_mode"),
carrier_type=carrier_type,
# Channel info (v4.0.0) # Channel info (v4.0.0)
channel_mode=file_info.get("channel_mode", "public"), channel_mode=file_info.get("channel_mode", "public"),
channel_fingerprint=file_info.get("channel_fingerprint"), channel_fingerprint=file_info.get("channel_fingerprint"),
@@ -1464,9 +1799,7 @@ def encode_thumbnail(thumb_id):
if not thumb_data: if not thumb_data:
return "Thumbnail not found", 404 return "Thumbnail not found", 404
return send_file( return send_file(io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False)
io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False
)
@app.route("/encode/download/<file_id>") @app.route("/encode/download/<file_id>")
@@ -1559,10 +1892,92 @@ def _run_decode_job(job_id: str, decode_params: dict) -> None:
if decode_result.is_file: if decode_result.is_file:
file_id = secrets.token_urlsafe(16) file_id = secrets.token_urlsafe(16)
filename = decode_result.filename or "decoded_file" filename = decode_result.filename or "decoded_file"
temp_storage.save_temp_file(file_id, decode_result.file_data, { temp_storage.save_temp_file(
file_id,
decode_result.file_data,
{
"filename": filename, "filename": filename,
"mime_type": decode_result.mime_type, "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( _store_job(
job_id, job_id,
{ {
@@ -1609,6 +2024,163 @@ def decode_page():
stego_image = request.files.get("stego_image") stego_image = request.files.get("stego_image")
rsa_key_file = request.files.get("rsa_key") 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: if not ref_photo or not stego_image:
flash("Both reference photo and stego image are required", "error") flash("Both reference photo and stego image are required", "error")
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) 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) return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
# Check for async mode (v4.1.5) # 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 # Build decode params
decode_params = { decode_params = {
@@ -1742,10 +2316,14 @@ def decode_page():
cleanup_temp_files() cleanup_temp_files()
filename = decode_result.filename or "decoded_file" filename = decode_result.filename or "decoded_file"
temp_storage.save_temp_file(file_id, decode_result.file_data, { temp_storage.save_temp_file(
file_id,
decode_result.file_data,
{
"filename": filename, "filename": filename,
"mime_type": decode_result.mime_type, "mime_type": decode_result.mime_type,
}) },
)
return render_template( return render_template(
"decode.html", "decode.html",
@@ -2101,11 +2679,12 @@ def api_tools_exif_clear():
@login_required @login_required
def api_tools_rotate(): def api_tools_rotate():
"""Rotate and/or flip an image, using lossless jpegtran for JPEGs.""" """Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
from PIL import Image
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
from PIL import Image
image_file = request.files.get("image") image_file = request.files.get("image")
if not image_file: if not image_file:
return jsonify({"success": False, "error": "No image provided"}), 400 return jsonify({"success": False, "error": "No image provided"}), 400
@@ -2136,9 +2715,18 @@ def api_tools_rotate():
output_path = tempfile.mktemp(suffix=".jpg") output_path = tempfile.mktemp(suffix=".jpg")
try: try:
result = subprocess.run( result = subprocess.run(
["jpegtran", "-rotate", str(rotation), "-copy", "all", [
"-outfile", output_path, input_path], "jpegtran",
capture_output=True, timeout=30 "-rotate",
str(rotation),
"-copy",
"all",
"-outfile",
output_path,
input_path,
],
capture_output=True,
timeout=30,
) )
if result.returncode == 0: if result.returncode == 0:
with open(output_path, "rb") as f: with open(output_path, "rb") as f:
@@ -2158,9 +2746,18 @@ def api_tools_rotate():
output_path = tempfile.mktemp(suffix=".jpg") output_path = tempfile.mktemp(suffix=".jpg")
try: try:
result = subprocess.run( result = subprocess.run(
["jpegtran", "-flip", "horizontal", "-copy", "all", [
"-outfile", output_path, input_path], "jpegtran",
capture_output=True, timeout=30 "-flip",
"horizontal",
"-copy",
"all",
"-outfile",
output_path,
input_path,
],
capture_output=True,
timeout=30,
) )
if result.returncode == 0: if result.returncode == 0:
with open(output_path, "rb") as f: with open(output_path, "rb") as f:
@@ -2180,9 +2777,18 @@ def api_tools_rotate():
output_path = tempfile.mktemp(suffix=".jpg") output_path = tempfile.mktemp(suffix=".jpg")
try: try:
result = subprocess.run( result = subprocess.run(
["jpegtran", "-flip", "vertical", "-copy", "all", [
"-outfile", output_path, input_path], "jpegtran",
capture_output=True, timeout=30 "-flip",
"vertical",
"-copy",
"all",
"-outfile",
output_path,
input_path,
],
capture_output=True,
timeout=30,
) )
if result.returncode == 0: if result.returncode == 0:
with open(output_path, "rb") as f: with open(output_path, "rb") as f:
@@ -2839,10 +3445,7 @@ def admin_settings_unlock():
channel_status = get_channel_status() channel_status = get_channel_status()
channel_key = channel_status.get("key") if channel_status["configured"] else "" channel_key = channel_status.get("key") if channel_status["configured"] else ""
return jsonify({ return jsonify({"success": True, "channel_key": channel_key})
"success": True,
"channel_key": channel_key
})
@app.route("/admin/users") @app.route("/admin/users")
@@ -2976,6 +3579,7 @@ if __name__ == "__main__":
ssl_context = None ssl_context = None
if app.config.get("HTTPS_ENABLED", False): if app.config.get("HTTPS_ENABLED", False):
import socket import socket
hostname = os.environ.get("STEGASOO_HOSTNAME") or socket.gethostname() hostname = os.environ.get("STEGASOO_HOSTNAME") or socket.gethostname()
try: try:
cert_path, key_path = ensure_certs(base_dir, hostname) cert_path, key_path = ensure_certs(base_dir, hostname)

View File

@@ -77,14 +77,10 @@ def init_db():
db = get_db() db = get_db()
# Check if we need to migrate from old single-user schema # Check if we need to migrate from old single-user schema
cursor = db.execute( cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'")
"SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'"
)
has_old_table = cursor.fetchone() is not None has_old_table = cursor.fetchone() is not None
cursor = db.execute( cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
)
has_new_table = cursor.fetchone() is not None has_new_table = cursor.fetchone() is not None
if has_old_table and not has_new_table: if has_old_table and not has_new_table:
@@ -189,9 +185,7 @@ def _ensure_channel_keys_table(db: sqlite3.Connection):
def _ensure_app_settings_table(db: sqlite3.Connection): def _ensure_app_settings_table(db: sqlite3.Connection):
"""Ensure app_settings table exists (v4.1.0 migration).""" """Ensure app_settings table exists (v4.1.0 migration)."""
cursor = db.execute( cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'")
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
)
if cursor.fetchone() is None: if cursor.fetchone() is None:
db.executescript(""" db.executescript("""
CREATE TABLE IF NOT EXISTS app_settings ( CREATE TABLE IF NOT EXISTS app_settings (
@@ -212,9 +206,7 @@ def _ensure_app_settings_table(db: sqlite3.Connection):
def get_app_setting(key: str) -> str | None: def get_app_setting(key: str) -> str | None:
"""Get an app-level setting value.""" """Get an app-level setting value."""
db = get_db() db = get_db()
row = db.execute( row = db.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone()
"SELECT value FROM app_settings WHERE key = ?", (key,)
).fetchone()
return row["value"] if row else None return row["value"] if row else None
@@ -384,12 +376,10 @@ def get_user_by_username(username: str) -> User | None:
def get_all_users() -> list[User]: def get_all_users() -> list[User]:
"""Get all users, admins first, then by creation date.""" """Get all users, admins first, then by creation date."""
db = get_db() db = get_db()
rows = db.execute( rows = db.execute("""
"""
SELECT id, username, role, created_at FROM users SELECT id, username, role, created_at FROM users
ORDER BY role = 'admin' DESC, created_at ASC ORDER BY role = 'admin' DESC, created_at ASC
""" """).fetchall()
).fetchall()
return [ return [
User( User(
id=row["id"], id=row["id"],
@@ -596,9 +586,7 @@ def create_admin_user(username: str, password: str) -> tuple[bool, str]:
return success, msg return success, msg
def change_password( def change_password(user_id: int, current_password: str, new_password: str) -> tuple[bool, str]:
user_id: int, current_password: str, new_password: str
) -> tuple[bool, str]:
"""Change a user's password (requires current password).""" """Change a user's password (requires current password)."""
user = get_user_by_id(user_id) user = get_user_by_id(user_id)
if not user: if not user:
@@ -667,9 +655,7 @@ def delete_user(user_id: int, current_user_id: int) -> tuple[bool, str]:
# Check if this is the last admin # Check if this is the last admin
if user.role == ROLE_ADMIN: if user.role == ROLE_ADMIN:
db = get_db() db = get_db()
admin_count = db.execute( admin_count = db.execute("SELECT COUNT(*) FROM users WHERE role = 'admin'").fetchone()[0]
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
).fetchone()[0]
if admin_count <= 1: if admin_count <= 1:
return False, "Cannot delete the last admin" return False, "Cannot delete the last admin"
@@ -848,9 +834,7 @@ def save_channel_key(
return False, "This channel key is already saved", None return False, "This channel key is already saved", None
def update_channel_key_name( def update_channel_key_name(key_id: int, user_id: int, new_name: str) -> tuple[bool, str]:
key_id: int, user_id: int, new_name: str
) -> tuple[bool, str]:
"""Update the name of a saved channel key.""" """Update the name of a saved channel key."""
new_name = new_name.strip() new_name = new_name.strip()
if not new_name: if not new_name:

View File

@@ -81,10 +81,12 @@ def generate_self_signed_cert(
) )
# Create certificate # Create certificate
subject = issuer = x509.Name([ subject = issuer = x509.Name(
[
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
x509.NameAttribute(NameOID.COMMON_NAME, hostname), x509.NameAttribute(NameOID.COMMON_NAME, hostname),
]) ]
)
# Subject Alternative Names # Subject Alternative Names
san_list = [ san_list = [
@@ -112,7 +114,7 @@ def generate_self_signed_cert(
except (ipaddress.AddressValueError, ValueError): except (ipaddress.AddressValueError, ValueError):
pass pass
now = datetime.datetime.now(datetime.timezone.utc) now = datetime.datetime.now(datetime.UTC)
cert = ( cert = (
x509.CertificateBuilder() x509.CertificateBuilder()
.subject_name(subject) .subject_name(subject)

View File

@@ -95,7 +95,16 @@ const Stegasoo = {
if (!isPayloadZone && !isQrZone) { if (!isPayloadZone && !isQrZone) {
input.addEventListener('change', function() { input.addEventListener('change', function() {
if (this.files && this.files[0]) { if (this.files && this.files[0]) {
Stegasoo.showImagePreview(this.files[0], preview, label, zone); const file = this.files[0];
if (file.type.startsWith('image/') && preview) {
Stegasoo.showImagePreview(file, preview, label, zone);
} else if (file.type.startsWith('audio/') || !file.type.startsWith('image/')) {
// Audio or non-image files: show file info instead of image preview
Stegasoo.showAudioFileInfo(file, zone);
if (label) {
label.classList.add('d-none');
}
}
} }
}); });
} }
@@ -154,6 +163,20 @@ const Stegasoo = {
reader.readAsDataURL(file); reader.readAsDataURL(file);
}, },
/**
* Format audio file info for display in drop zones (v4.3.0)
*/
showAudioFileInfo(file, zone) {
const filenameEl = zone.querySelector('.pixel-data-filename span, .scan-data-filename span');
const sizeEl = zone.querySelector('.pixel-data-value, .scan-data-value');
if (filenameEl) filenameEl.textContent = file.name;
if (sizeEl) {
const kb = file.size / 1024;
sizeEl.textContent = kb >= 1024 ? (kb / 1024).toFixed(1) + ' MB' : kb.toFixed(1) + ' KB';
}
zone.classList.add('has-file');
},
// ======================================================================== // ========================================================================
// REFERENCE PHOTO SCAN ANIMATION // REFERENCE PHOTO SCAN ANIMATION
// ======================================================================== // ========================================================================
@@ -1036,6 +1059,10 @@ const Stegasoo = {
'saving': 'Saving image...', 'saving': 'Saving image...',
'finalizing': 'Finalizing...', 'finalizing': 'Finalizing...',
'complete': 'Complete!', 'complete': 'Complete!',
// Audio encode phases (v4.3.0)
'audio_transcoding': 'Transcoding audio...',
'audio_embedding': 'Embedding in audio...',
'spread_embedding': 'Spread spectrum embedding...',
}; };
return phases[phase] || phase; return phases[phase] || phase;
}, },
@@ -1252,6 +1279,10 @@ const Stegasoo = {
'verifying': 'Verifying...', 'verifying': 'Verifying...',
'finalizing': 'Finalizing...', 'finalizing': 'Finalizing...',
'complete': 'Complete!', 'complete': 'Complete!',
// Audio decode phases (v4.3.0)
'audio_transcoding': 'Transcoding audio...',
'audio_extracting': 'Extracting from audio...',
'spread_extracting': 'Spread spectrum extracting...',
}; };
return phases[phase] || phase; return phases[phase] || phase;
}, },

View File

@@ -19,6 +19,8 @@ Usage:
import base64 import base64
import json import json
import logging
import os
import sys import sys
import traceback import traceback
from pathlib import Path from pathlib import Path
@@ -27,6 +29,24 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
# Configure logging for worker subprocess
_log_level = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
if _log_level and hasattr(logging, _log_level):
logging.basicConfig(
level=getattr(logging, _log_level),
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%H:%M:%S",
stream=sys.stderr,
)
elif os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
logging.basicConfig(
level=logging.DEBUG,
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%H:%M:%S",
stream=sys.stderr,
)
logger = logging.getLogger("stegasoo.worker")
def _resolve_channel_key(channel_key_param): def _resolve_channel_key(channel_key_param):
""" """
@@ -73,6 +93,7 @@ def _get_channel_info(resolved_key):
def encode_operation(params: dict) -> dict: def encode_operation(params: dict) -> dict:
"""Handle encode operation.""" """Handle encode operation."""
logger.debug("encode_operation: mode=%s", params.get("embed_mode", "lsb"))
from stegasoo import FilePayload, encode from stegasoo import FilePayload, encode
# Decode base64 inputs # Decode base64 inputs
@@ -142,6 +163,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
return return
try: try:
import json import json
with open(progress_file, "w") as f: with open(progress_file, "w") as f:
json.dump({"percent": percent, "phase": phase}, f) json.dump({"percent": percent, "phase": phase}, f)
except Exception: except Exception:
@@ -150,6 +172,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
def decode_operation(params: dict) -> dict: def decode_operation(params: dict) -> dict:
"""Handle decode operation.""" """Handle decode operation."""
logger.debug("decode_operation: mode=%s", params.get("embed_mode", "auto"))
from stegasoo import decode from stegasoo import decode
progress_file = params.get("progress_file") progress_file = params.get("progress_file")
@@ -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: def channel_status_operation(params: dict) -> dict:
"""Handle channel status check (v4.0.0).""" """Handle channel status check (v4.0.0)."""
from stegasoo import get_channel_status from stegasoo import get_channel_status
@@ -263,6 +425,7 @@ def main():
else: else:
params = json.loads(input_text) params = json.loads(input_text)
operation = params.get("operation") operation = params.get("operation")
logger.info("Worker handling operation: %s", operation)
if operation == "encode": if operation == "encode":
output = encode_operation(params) output = encode_operation(params)
@@ -274,6 +437,13 @@ def main():
output = capacity_check_operation(params) output = capacity_check_operation(params)
elif operation == "channel_status": elif operation == "channel_status":
output = channel_status_operation(params) output = channel_status_operation(params)
# Audio operations (v4.3.0)
elif operation == "encode_audio":
output = encode_audio_operation(params)
elif operation == "decode_audio":
output = decode_audio_operation(params)
elif operation == "audio_info":
output = audio_info_operation(params)
else: else:
output = {"success": False, "error": f"Unknown operation: {operation}"} output = {"success": False, "error": f"Unknown operation: {operation}"}

View File

@@ -115,6 +115,35 @@ class CapacityResult:
error: str | None = None error: str | None = None
@dataclass
class AudioEncodeResult:
"""Result from audio encode operation (v4.3.0)."""
success: bool
stego_data: bytes | None = None
stats: dict[str, Any] | None = None
channel_mode: str | None = None
channel_fingerprint: str | None = None
error: str | None = None
error_type: str | None = None
@dataclass
class AudioInfoResult:
"""Result from audio info operation (v4.3.0)."""
success: bool
sample_rate: int = 0
channels: int = 0
duration_seconds: float = 0.0
num_samples: int = 0
format: str = ""
bit_depth: int | None = None
capacity_lsb: int = 0
capacity_spread: int = 0
error: str | None = None
@dataclass @dataclass
class ChannelStatusResult: class ChannelStatusResult:
"""Result from channel status check (v4.0.0).""" """Result from channel status check (v4.0.0)."""
@@ -456,6 +485,201 @@ class SubprocessStego:
error=result.get("error", "Unknown error"), error=result.get("error", "Unknown error"),
) )
# =========================================================================
# Audio Steganography (v4.3.0)
# =========================================================================
def encode_audio(
self,
carrier_data: bytes,
reference_data: bytes,
message: str | None = None,
file_data: bytes | None = None,
file_name: str | None = None,
file_mime: str | None = None,
passphrase: str = "",
pin: str | None = None,
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
embed_mode: str = "audio_lsb",
channel_key: str | None = "auto",
timeout: int | None = None,
progress_file: str | None = None,
chip_tier: int | None = None,
) -> AudioEncodeResult:
"""
Encode a message or file into an audio carrier.
Args:
carrier_data: Carrier audio bytes (WAV, FLAC, MP3, etc.)
reference_data: Reference photo bytes
message: Text message to encode (if not file)
file_data: File bytes to encode (if not message)
file_name: Original filename (for file payload)
file_mime: MIME type (for file payload)
passphrase: Encryption passphrase
pin: Optional PIN
rsa_key_data: Optional RSA key PEM bytes
rsa_password: RSA key password if encrypted
embed_mode: 'audio_lsb' or 'audio_spread'
channel_key: 'auto', 'none', or explicit key
timeout: Operation timeout (default 300s for audio)
progress_file: Path to write progress updates
Returns:
AudioEncodeResult with stego audio data on success
"""
params = {
"operation": "encode_audio",
"carrier_b64": base64.b64encode(carrier_data).decode("ascii"),
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
"message": message,
"passphrase": passphrase,
"pin": pin,
"embed_mode": embed_mode,
"channel_key": channel_key,
"progress_file": progress_file,
"chip_tier": chip_tier,
}
if file_data:
params["file_b64"] = base64.b64encode(file_data).decode("ascii")
params["file_name"] = file_name
params["file_mime"] = file_mime
if rsa_key_data:
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
params["rsa_password"] = rsa_password
# Audio operations can be slower (especially spread spectrum)
result = self._run_worker(params, timeout or 300)
if result.get("success"):
return AudioEncodeResult(
success=True,
stego_data=base64.b64decode(result["stego_b64"]),
stats=result.get("stats"),
channel_mode=result.get("channel_mode"),
channel_fingerprint=result.get("channel_fingerprint"),
)
else:
return AudioEncodeResult(
success=False,
error=result.get("error", "Unknown error"),
error_type=result.get("error_type"),
)
def decode_audio(
self,
stego_data: bytes,
reference_data: bytes,
passphrase: str = "",
pin: str | None = None,
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
embed_mode: str = "audio_auto",
channel_key: str | None = "auto",
timeout: int | None = None,
progress_file: str | None = None,
) -> DecodeResult:
"""
Decode a message or file from stego audio.
Args:
stego_data: Stego audio bytes
reference_data: Reference photo bytes
passphrase: Decryption passphrase
pin: Optional PIN
rsa_key_data: Optional RSA key PEM bytes
rsa_password: RSA key password if encrypted
embed_mode: 'audio_auto', 'audio_lsb', or 'audio_spread'
channel_key: 'auto', 'none', or explicit key
timeout: Operation timeout (default 300s for audio)
progress_file: Path to write progress updates
Returns:
DecodeResult with message or file_data on success
"""
params = {
"operation": "decode_audio",
"stego_b64": base64.b64encode(stego_data).decode("ascii"),
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
"passphrase": passphrase,
"pin": pin,
"embed_mode": embed_mode,
"channel_key": channel_key,
"progress_file": progress_file,
}
if rsa_key_data:
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
params["rsa_password"] = rsa_password
result = self._run_worker(params, timeout or 300)
if result.get("success"):
if result.get("is_file"):
return DecodeResult(
success=True,
is_file=True,
file_data=base64.b64decode(result["file_b64"]),
filename=result.get("filename"),
mime_type=result.get("mime_type"),
)
else:
return DecodeResult(
success=True,
is_file=False,
message=result.get("message"),
)
else:
return DecodeResult(
success=False,
error=result.get("error", "Unknown error"),
error_type=result.get("error_type"),
)
def audio_info(
self,
audio_data: bytes,
timeout: int | None = None,
) -> AudioInfoResult:
"""
Get audio file information and steganographic capacity.
Args:
audio_data: Audio file bytes
timeout: Operation timeout in seconds
Returns:
AudioInfoResult with metadata and capacity info
"""
params = {
"operation": "audio_info",
"audio_b64": base64.b64encode(audio_data).decode("ascii"),
}
result = self._run_worker(params, timeout)
if result.get("success"):
info = result.get("info", {})
return AudioInfoResult(
success=True,
sample_rate=info.get("sample_rate", 0),
channels=info.get("channels", 0),
duration_seconds=info.get("duration_seconds", 0.0),
num_samples=info.get("num_samples", 0),
format=info.get("format", ""),
bit_depth=info.get("bit_depth"),
capacity_lsb=info.get("capacity_lsb", 0),
capacity_spread=info.get("capacity_spread", 0),
)
else:
return AudioInfoResult(
success=False,
error=result.get("error", "Unknown error"),
)
def get_channel_status( def get_channel_status(
self, self,
reveal: bool = False, reveal: bool = False,

View File

@@ -24,7 +24,11 @@
border-left: 3px solid #ffe699; border-left: 3px solid #ffe699;
} }
.step-accordion .accordion-button::after { .step-accordion .accordion-button::after {
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2); filter: brightness(0) invert(1);
opacity: 0.5;
}
.step-accordion .accordion-button:not(.collapsed)::after {
opacity: 0.9;
} }
.step-accordion .accordion-body { .step-accordion .accordion-body {
background: rgba(30, 40, 50, 0.4); background: rgba(30, 40, 50, 0.4);
@@ -172,19 +176,51 @@
<div class="accordion step-accordion" id="decodeAccordion"> <div class="accordion step-accordion" id="decodeAccordion">
<!-- ================================================================ <!-- ================================================================
STEP 1: IMAGES & MODE STEP 1: CARRIER TYPE (v4.3.0)
================================================================ -->
<div class="accordion-item" id="carrierTypeStep">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepCarrierType">
<span class="step-title">
<span class="step-number" id="stepCarrierTypeNumber">1</span>
<i class="bi bi-collection me-1"></i> Carrier Type
</span>
<span class="step-summary" id="stepCarrierTypeSummary"></span>
</button>
</h2>
<div id="stepCarrierType" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
<div class="accordion-body">
<input type="hidden" name="carrier_type" id="carrierTypeInput" value="image">
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="carrier_type_select" id="typeImage" value="image" checked>
<label class="btn btn-outline-secondary" for="typeImage">
<i class="bi bi-image me-1"></i> Image
</label>
<input type="radio" class="btn-check" name="carrier_type_select" id="typeAudio" value="audio"
{% if not has_audio %}disabled{% endif %}>
<label class="btn btn-outline-secondary {% if not has_audio %}disabled text-muted{% endif %}" for="typeAudio">
<i class="bi bi-music-note-beamed me-1"></i> Audio
{% if not has_audio %}<small class="d-block" style="font-size: 0.65rem;">(not available)</small>{% endif %}
</label>
</div>
</div>
</div>
</div>
<!-- ================================================================
STEP 2: IMAGES & MODE
================================================================ --> ================================================================ -->
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
<span class="step-title"> <span class="step-title">
<span class="step-number" id="stepImagesNumber">1</span> <span class="step-number" id="stepImagesNumber">2</span>
<i class="bi bi-images me-1"></i> Images & Mode <i class="bi bi-images me-1"></i> Reference, Carrier, Mode
</span> </span>
<span class="step-summary" id="stepImagesSummary">Select reference & stego</span> <span class="step-summary" id="stepImagesSummary">Select reference & stego</span>
</button> </button>
</h2> </h2>
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion"> <div id="stepImages" class="accordion-collapse collapse" data-bs-parent="#decodeAccordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="row"> <div class="row">
@@ -213,6 +249,7 @@
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div id="imageStegoSection">
<label class="form-label"> <label class="form-label">
<i class="bi bi-file-earmark-image me-1"></i> Stego Image <i class="bi bi-file-earmark-image me-1"></i> Stego Image
</label> </label>
@@ -237,10 +274,30 @@
</div> </div>
<div class="form-text">Image containing the hidden message</div> <div class="form-text">Image containing the hidden message</div>
</div> </div>
<!-- Audio Stego (hidden by default) -->
<div class="d-none" id="audioStegoSection">
<label class="form-label">
<i class="bi bi-file-earmark-music me-1"></i> Stego Audio
</label>
<div class="drop-zone pixel-container" id="audioStegoDropZone">
<input type="file" name="stego_image" accept="audio/*" id="audioStegoInput">
<div class="drop-zone-label">
<i class="bi bi-music-note-beamed fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop audio or click</span>
</div>
<div class="pixel-data-panel">
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="audioStegoFileName">audio.wav</span></div>
<div class="pixel-data-row"><span class="pixel-status-badge">Audio Loaded</span><span class="pixel-data-value" id="audioStegoFileSize">--</span></div>
</div>
</div>
<div class="form-text">Audio file containing the hidden message</div>
</div>
</div>
</div> </div>
<!-- Extraction Mode --> <!-- Extraction Mode -->
<div class="d-flex gap-2 align-items-center flex-wrap mb-2"> <div class="d-flex gap-2 align-items-center flex-wrap mb-2">
<div id="imageModeGroup">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked> <input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
<label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label> <label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label>
@@ -250,6 +307,18 @@
<label class="btn btn-outline-secondary text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label> <label class="btn btn-outline-secondary text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
</div> </div>
</div> </div>
<!-- Audio Extraction Modes (hidden by default) -->
<div class="d-none" id="audioModeGroup">
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioAuto" value="audio_auto">
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioAuto"><i class="bi bi-magic me-1"></i>Auto</label>
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioLsb" value="audio_lsb">
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioSpread" value="audio_spread">
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioSpread"><i class="bi bi-broadcast me-1"></i>Spread</label>
</div>
</div>
</div>
<div class="form-text" id="modeHint"> <div class="form-text" id="modeHint">
<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT <i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT
</div> </div>
@@ -259,13 +328,13 @@
</div> </div>
<!-- ================================================================ <!-- ================================================================
STEP 2: SECURITY STEP 3: SECURITY
================================================================ --> ================================================================ -->
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
<span class="step-title"> <span class="step-title">
<span class="step-number" id="stepSecurityNumber">2</span> <span class="step-number" id="stepSecurityNumber">3</span>
<i class="bi bi-shield-lock me-1"></i> Security <i class="bi bi-shield-lock me-1"></i> Security
</span> </span>
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span> <span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
@@ -425,7 +494,10 @@
const modeHints = { const modeHints = {
auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' }, auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' },
lsb: { icon: 'hdd', text: 'For email and direct transfers' }, lsb: { icon: 'hdd', text: 'For email and direct transfers' },
dct: { icon: 'phone', text: 'For social media images' } dct: { icon: 'phone', text: 'For social media images' },
audio_auto: { icon: 'lightning', text: 'Tries LSB first, then Spread Spectrum' },
audio_lsb: { icon: 'grid-3x3-gap', text: 'Direct bit embedding in audio samples' },
audio_spread: { icon: 'broadcast', text: 'Noise-resistant spread spectrum encoding' }
}; };
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => { document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
@@ -442,9 +514,14 @@ document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
// ACCORDION SUMMARY UPDATES // ACCORDION SUMMARY UPDATES
// ============================================================================ // ============================================================================
const carrierTypeInput = document.getElementById('carrierTypeInput');
function updateImagesSummary() { function updateImagesSummary() {
const ref = document.getElementById('refPhotoInput')?.files[0]; const ref = document.getElementById('refPhotoInput')?.files[0];
const stego = document.getElementById('stegoInput')?.files[0]; const isAudio = carrierTypeInput?.value === 'audio';
const stego = isAudio
? document.getElementById('audioStegoInput')?.files[0]
: document.getElementById('stegoInput')?.files[0];
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'AUTO'; const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'AUTO';
const summary = document.getElementById('stepImagesSummary'); const summary = document.getElementById('stepImagesSummary');
const stepNum = document.getElementById('stepImagesNumber'); const stepNum = document.getElementById('stepImagesNumber');
@@ -460,12 +537,12 @@ function updateImagesSummary() {
summary.textContent = ref ? ref.name.slice(0, 15) : stego.name.slice(0, 15); summary.textContent = ref ? ref.name.slice(0, 15) : stego.name.slice(0, 15);
summary.classList.remove('has-content'); summary.classList.remove('has-content');
stepNum.classList.remove('complete'); stepNum.classList.remove('complete');
stepNum.textContent = '1'; stepNum.textContent = '2';
} else { } else {
summary.textContent = 'Select reference & stego'; summary.textContent = isAudio ? 'Select reference & audio' : 'Select reference & stego';
summary.classList.remove('has-content'); summary.classList.remove('has-content');
stepNum.classList.remove('complete'); stepNum.classList.remove('complete');
stepNum.textContent = '1'; stepNum.textContent = '2';
} }
} }
@@ -493,19 +570,99 @@ function updateSecuritySummary() {
summary.textContent = 'Passphrase & keys'; summary.textContent = 'Passphrase & keys';
summary.classList.remove('has-content'); summary.classList.remove('has-content');
stepNum.classList.remove('complete'); stepNum.classList.remove('complete');
stepNum.textContent = '2'; stepNum.textContent = '3';
} }
} }
// Attach listeners // Attach listeners
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary); document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
document.getElementById('stegoInput')?.addEventListener('change', updateImagesSummary); document.getElementById('stegoInput')?.addEventListener('change', updateImagesSummary);
document.getElementById('audioStegoInput')?.addEventListener('change', updateImagesSummary);
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary)); document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
document.querySelectorAll('#audioModeGroup input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
document.getElementById('passphraseInput')?.addEventListener('input', updateSecuritySummary); document.getElementById('passphraseInput')?.addEventListener('input', updateSecuritySummary);
document.getElementById('pinInput')?.addEventListener('input', updateSecuritySummary); document.getElementById('pinInput')?.addEventListener('input', updateSecuritySummary);
document.querySelector('input[name="rsa_key"]')?.addEventListener('change', updateSecuritySummary); document.querySelector('input[name="rsa_key"]')?.addEventListener('change', updateSecuritySummary);
// ============================================================================
// CARRIER TYPE TOGGLE (v4.3.0)
// ============================================================================
const carrierTypeRadios = document.querySelectorAll('input[name="carrier_type_select"]');
const imageStegoSection = document.getElementById('imageStegoSection');
const audioStegoSection = document.getElementById('audioStegoSection');
const imageModeGroup = document.getElementById('imageModeGroup');
const audioModeGroup = document.getElementById('audioModeGroup');
const stepCarrierTypeSummary = document.getElementById('stepCarrierTypeSummary');
carrierTypeRadios.forEach(radio => {
radio.addEventListener('change', function() {
const isAudio = this.value === 'audio';
carrierTypeInput.value = this.value;
// Toggle stego sections
if (imageStegoSection) imageStegoSection.classList.toggle('d-none', isAudio);
if (audioStegoSection) audioStegoSection.classList.toggle('d-none', !isAudio);
// Toggle required attribute so hidden inputs don't block form submission
const imgStego = document.getElementById('stegoInput');
const audStego = document.getElementById('audioStegoInput');
if (imgStego) { if (isAudio) imgStego.removeAttribute('required'); else imgStego.setAttribute('required', ''); }
if (audStego) { if (isAudio) audStego.setAttribute('required', ''); else audStego.removeAttribute('required'); }
// Toggle mode groups
if (imageModeGroup) imageModeGroup.classList.toggle('d-none', isAudio);
if (audioModeGroup) audioModeGroup.classList.toggle('d-none', !isAudio);
// Update summary
if (stepCarrierTypeSummary) {
stepCarrierTypeSummary.textContent = isAudio ? 'Audio' : 'Image';
}
// Select default mode
if (isAudio) {
const audioAuto = document.getElementById('modeAudioAuto');
if (audioAuto) audioAuto.checked = true;
} else {
const autoMode = document.getElementById('modeAuto');
if (autoMode) autoMode.checked = true;
}
// Clear stego file selections
const stegoInput = document.getElementById('stegoInput');
const audioStegoInput = document.getElementById('audioStegoInput');
if (stegoInput) stegoInput.value = '';
if (audioStegoInput) audioStegoInput.value = '';
// Reset previews
document.getElementById('stegoPreview')?.classList.add('d-none');
// Update mode hint
const hint = document.getElementById('modeHint');
if (hint) {
if (isAudio) {
hint.innerHTML = '<i class="bi bi-lightning me-1"></i>Tries LSB first, then Spread Spectrum';
} else {
hint.innerHTML = '<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT';
}
}
updateImagesSummary();
});
});
// Audio stego file info display
const audioStegoInput = document.getElementById('audioStegoInput');
audioStegoInput?.addEventListener('change', function() {
if (this.files && this.files[0]) {
const file = this.files[0];
document.getElementById('audioStegoFileName').textContent = file.name;
document.getElementById('audioStegoFileSize').textContent = (file.size / 1024).toFixed(1) + ' KB';
updateImagesSummary();
}
});
// ============================================================================ // ============================================================================
// MODE SWITCHING // MODE SWITCHING
// ============================================================================ // ============================================================================

View File

@@ -24,7 +24,11 @@
border-left: 3px solid #ffe699; border-left: 3px solid #ffe699;
} }
.step-accordion .accordion-button::after { .step-accordion .accordion-button::after {
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2); filter: brightness(0) invert(1);
opacity: 0.5;
}
.step-accordion .accordion-button:not(.collapsed)::after {
opacity: 0.9;
} }
.step-accordion .accordion-body { .step-accordion .accordion-body {
background: rgba(30, 40, 50, 0.4); background: rgba(30, 40, 50, 0.4);
@@ -126,19 +130,56 @@
<div class="accordion step-accordion" id="encodeAccordion"> <div class="accordion step-accordion" id="encodeAccordion">
<!-- ================================================================ <!-- ================================================================
STEP 1: IMAGES STEP 1: CARRIER TYPE (v4.3.0)
================================================================ -->
<div class="accordion-item" id="carrierTypeStep">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepCarrierType">
<span class="step-title">
<span class="step-number" id="stepCarrierTypeNumber">1</span>
<i class="bi bi-collection me-1"></i> Carrier Type
</span>
<span class="step-summary" id="stepCarrierTypeSummary"></span>
</button>
</h2>
<div id="stepCarrierType" class="accordion-collapse collapse show" data-bs-parent="#encodeAccordion">
<div class="accordion-body">
<input type="hidden" name="carrier_type" id="carrierTypeInput" value="image">
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="carrier_type_select" id="typeImage" value="image" checked>
<label class="btn btn-outline-secondary" for="typeImage">
<i class="bi bi-image me-1"></i> Image
</label>
<input type="radio" class="btn-check" name="carrier_type_select" id="typeAudio" value="audio"
{% if not has_audio %}disabled{% endif %}>
<label class="btn btn-outline-secondary {% if not has_audio %}disabled text-muted{% endif %}" for="typeAudio">
<i class="bi bi-music-note-beamed me-1"></i> Audio
{% if not has_audio %}<small class="d-block" style="font-size: 0.65rem;">(not available)</small>{% endif %}
</label>
</div>
{% if not has_audio %}
<div class="form-text text-warning mt-2">
<i class="bi bi-exclamation-triangle me-1"></i>Audio requires numpy and soundfile packages
</div>
{% endif %}
</div>
</div>
</div>
<!-- ================================================================
STEP 2: IMAGES & MODE
================================================================ --> ================================================================ -->
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
<span class="step-title"> <span class="step-title">
<span class="step-number" id="stepImagesNumber">1</span> <span class="step-number" id="stepImagesNumber">2</span>
<i class="bi bi-images me-1"></i> Images & Mode <i class="bi bi-images me-1"></i> Reference, Carrier, Mode
</span> </span>
<span class="step-summary" id="stepImagesSummary">Select reference & carrier</span> <span class="step-summary" id="stepImagesSummary">Select reference & carrier</span>
</button> </button>
</h2> </h2>
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#encodeAccordion"> <div id="stepImages" class="accordion-collapse collapse" data-bs-parent="#encodeAccordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="row"> <div class="row">
@@ -167,6 +208,7 @@
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div id="imageCarrierSection">
<label class="form-label"> <label class="form-label">
<i class="bi bi-file-earmark-image me-1"></i> Carrier Image <i class="bi bi-file-earmark-image me-1"></i> Carrier Image
</label> </label>
@@ -191,6 +233,27 @@
</div> </div>
<div class="form-text">Image to hide your message in</div> <div class="form-text">Image to hide your message in</div>
</div> </div>
<!-- Audio Carrier (hidden by default, shown when audio type selected) -->
<div class="d-none" id="audioCarrierSection">
<label class="form-label">
<i class="bi bi-file-earmark-music me-1"></i> Carrier Audio
</label>
<div class="drop-zone pixel-container" id="audioCarrierDropZone">
<input type="file" name="carrier" accept="audio/*" id="audioCarrierInput">
<div class="drop-zone-label">
<i class="bi bi-music-note-beamed fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop audio or click</span>
</div>
<div class="pixel-data-panel">
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="audioCarrierFileName">audio.wav</span></div>
<div class="pixel-data-row"><span class="pixel-status-badge">Audio Loaded</span><span class="pixel-data-value" id="audioCarrierFileSize">--</span></div>
<div class="pixel-dimensions" id="audioCarrierDuration">--:-- duration</div>
</div>
</div>
<div class="form-text">Audio file to hide your message in</div>
</div>
</div>
</div> </div>
<!-- Capacity Info --> <!-- Capacity Info -->
@@ -204,7 +267,19 @@
</div> </div>
</div> </div>
<!-- Audio Capacity Info (v4.3.0) -->
<div class="alert alert-info small d-none mb-3" id="audioCapacityPanel">
<div class="d-flex justify-content-between align-items-center">
<span><i class="bi bi-music-note-beamed me-1"></i><span id="audioInfo">-</span></span>
<span>
<span class="badge bg-primary me-1" id="lsbAudioCapacityBadge">LSB: -</span>
<span class="badge bg-warning text-dark" id="spreadCapacityBadge">Spread: -</span>
</span>
</div>
</div>
<!-- Embedding Mode (compact inline) --> <!-- Embedding Mode (compact inline) -->
<div id="imageModeGroup">
<div class="d-flex gap-2 align-items-center flex-wrap mb-2"> <div class="d-flex gap-2 align-items-center flex-wrap mb-2">
<div class="btn-group btn-group-sm" role="group"> <div class="btn-group btn-group-sm" role="group">
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}> <input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
@@ -228,6 +303,18 @@
</div> </div>
</span> </span>
</div> </div>
</div>
<!-- Audio Modes (hidden by default) -->
<div class="d-none" id="audioModeGroup">
<div class="btn-group btn-group-sm" role="group">
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioLsb" value="audio_lsb">
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeAudioLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioSpread" value="audio_spread">
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeAudioSpread"><i class="bi bi-broadcast me-1"></i>Spread</label>
</div>
</div>
<div class="form-text" id="modeHint"> <div class="form-text" id="modeHint">
<i class="bi bi-{% if has_dct %}phone{% else %}hdd{% endif %} me-1"></i>{% if has_dct %}Survives social media compression{% else %}Higher capacity for direct transfers{% endif %} <i class="bi bi-{% if has_dct %}phone{% else %}hdd{% endif %} me-1"></i>{% if has_dct %}Survives social media compression{% else %}Higher capacity for direct transfers{% endif %}
</div> </div>
@@ -237,13 +324,13 @@
</div> </div>
<!-- ================================================================ <!-- ================================================================
STEP 2: PAYLOAD STEP 3: PAYLOAD
================================================================ --> ================================================================ -->
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepPayload"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepPayload">
<span class="step-title"> <span class="step-title">
<span class="step-number" id="stepPayloadNumber">2</span> <span class="step-number" id="stepPayloadNumber">3</span>
<i class="bi bi-box me-1"></i> Payload <i class="bi bi-box me-1"></i> Payload
</span> </span>
<span class="step-summary" id="stepPayloadSummary">Message or file to hide</span> <span class="step-summary" id="stepPayloadSummary">Message or file to hide</span>
@@ -295,13 +382,13 @@
</div> </div>
<!-- ================================================================ <!-- ================================================================
STEP 3: SECURITY STEP 4: SECURITY
================================================================ --> ================================================================ -->
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
<span class="step-title"> <span class="step-title">
<span class="step-number" id="stepSecurityNumber">3</span> <span class="step-number" id="stepSecurityNumber">4</span>
<i class="bi bi-shield-lock me-1"></i> Security <i class="bi bi-shield-lock me-1"></i> Security
</span> </span>
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span> <span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
@@ -462,13 +549,131 @@ document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
}); });
}); });
// ============================================================================
// CARRIER TYPE TOGGLE (v4.3.0)
// ============================================================================
const carrierTypeRadios = document.querySelectorAll('input[name="carrier_type_select"]');
const carrierTypeInput = document.getElementById('carrierTypeInput');
const imageCarrierSection = document.getElementById('imageCarrierSection');
const audioCarrierSection = document.getElementById('audioCarrierSection');
const imageModeGroup = document.getElementById('imageModeGroup');
const audioModeGroup = document.getElementById('audioModeGroup');
const capacityPanel = document.getElementById('capacityPanel');
const audioCapacityPanel = document.getElementById('audioCapacityPanel');
const stepCarrierTypeSummary = document.getElementById('stepCarrierTypeSummary');
carrierTypeRadios.forEach(radio => {
radio.addEventListener('change', function() {
const isAudio = this.value === 'audio';
carrierTypeInput.value = this.value;
// Toggle carrier sections
if (imageCarrierSection) imageCarrierSection.classList.toggle('d-none', isAudio);
if (audioCarrierSection) audioCarrierSection.classList.toggle('d-none', !isAudio);
// Toggle required attribute so hidden inputs don't block form submission
const imgCarrier = document.getElementById('carrierInput');
const audCarrier = document.getElementById('audioCarrierInput');
if (imgCarrier) { if (isAudio) imgCarrier.removeAttribute('required'); else imgCarrier.setAttribute('required', ''); }
if (audCarrier) { if (isAudio) audCarrier.setAttribute('required', ''); else audCarrier.removeAttribute('required'); }
// Toggle mode groups
if (imageModeGroup) imageModeGroup.classList.toggle('d-none', isAudio);
if (audioModeGroup) audioModeGroup.classList.toggle('d-none', !isAudio);
// Toggle capacity panels
if (capacityPanel) capacityPanel.classList.add('d-none');
if (audioCapacityPanel) audioCapacityPanel.classList.add('d-none');
// Update summary
if (stepCarrierTypeSummary) {
stepCarrierTypeSummary.textContent = isAudio ? 'Audio' : 'Image';
}
// Select default mode for the active type
if (isAudio) {
const audioLsb = document.getElementById('modeAudioLsb');
if (audioLsb) audioLsb.checked = true;
} else {
// Reset to DCT if available, else LSB
const dctRadio = document.getElementById('modeDct');
const lsbRadio = document.getElementById('modeLsb');
if (dctRadio && !dctRadio.disabled) {
dctRadio.checked = true;
} else if (lsbRadio) {
lsbRadio.checked = true;
}
}
// Clear carrier file selections
const carrierInput = document.getElementById('carrierInput');
const audioCarrierInput = document.getElementById('audioCarrierInput');
if (carrierInput) carrierInput.value = '';
if (audioCarrierInput) audioCarrierInput.value = '';
// Reset previews
document.getElementById('carrierPreview')?.classList.add('d-none');
// Update step title
const stepImagesTitle = document.querySelector('#stepImages')?.closest('.accordion-item')?.querySelector('.accordion-button .step-title');
if (stepImagesTitle) {
const icon = stepImagesTitle.querySelector('i:not(.step-number i)');
const textNode = stepImagesTitle.childNodes[stepImagesTitle.childNodes.length - 1];
if (icon) {
icon.className = isAudio ? 'bi bi-music-note-beamed me-1' : 'bi bi-images me-1';
}
}
updateImagesSummary();
});
});
// Audio carrier file change handler
const audioCarrierInput = document.getElementById('audioCarrierInput');
audioCarrierInput?.addEventListener('change', function() {
if (this.files && this.files[0]) {
const file = this.files[0];
document.getElementById('audioCarrierFileName').textContent = file.name;
document.getElementById('audioCarrierFileSize').textContent = (file.size / 1024).toFixed(1) + ' KB';
// Fetch audio capacity
const formData = new FormData();
formData.append('carrier', file);
fetch('/api/audio-capacity', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.error) return;
const info = `${data.format || 'Audio'} · ${data.sample_rate}Hz · ${data.channels}ch · ${data.duration}s`;
document.getElementById('audioInfo').textContent = info;
document.getElementById('lsbAudioCapacityBadge').textContent = `LSB: ${(data.lsb_capacity / 1024).toFixed(1)} KB`;
document.getElementById('spreadCapacityBadge').textContent = `Spread: ${(data.spread_capacity / 1024).toFixed(1)} KB`;
document.getElementById('audioCapacityPanel')?.classList.remove('d-none');
if (data.duration) {
document.getElementById('audioCarrierDuration').textContent = data.duration + 's duration';
}
}).catch(() => {});
// Trigger the drop zone animation
const dropZone = document.getElementById('audioCarrierDropZone');
if (dropZone) {
dropZone.classList.add('has-file');
}
updateImagesSummary();
}
});
// ============================================================================ // ============================================================================
// ACCORDION SUMMARY UPDATES // ACCORDION SUMMARY UPDATES
// ============================================================================ // ============================================================================
function updateImagesSummary() { function updateImagesSummary() {
const ref = document.getElementById('refPhotoInput')?.files[0]; const ref = document.getElementById('refPhotoInput')?.files[0];
const carrier = document.getElementById('carrierInput')?.files[0]; const isAudio = carrierTypeInput?.value === 'audio';
const carrier = isAudio
? document.getElementById('audioCarrierInput')?.files[0]
: document.getElementById('carrierInput')?.files[0];
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'LSB'; const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'LSB';
const summary = document.getElementById('stepImagesSummary'); const summary = document.getElementById('stepImagesSummary');
const stepNum = document.getElementById('stepImagesNumber'); const stepNum = document.getElementById('stepImagesNumber');
@@ -484,12 +689,12 @@ function updateImagesSummary() {
summary.textContent = ref ? ref.name.slice(0, 15) : carrier.name.slice(0, 15); summary.textContent = ref ? ref.name.slice(0, 15) : carrier.name.slice(0, 15);
summary.classList.remove('has-content'); summary.classList.remove('has-content');
stepNum.classList.remove('complete'); stepNum.classList.remove('complete');
stepNum.textContent = '1'; stepNum.textContent = '2';
} else { } else {
summary.textContent = 'Select reference & carrier'; summary.textContent = isAudio ? 'Select reference & audio' : 'Select reference & carrier';
summary.classList.remove('has-content'); summary.classList.remove('has-content');
stepNum.classList.remove('complete'); stepNum.classList.remove('complete');
stepNum.textContent = '1'; stepNum.textContent = '2';
} }
} }
@@ -515,7 +720,7 @@ function updatePayloadSummary() {
summary.textContent = 'Message or file to hide'; summary.textContent = 'Message or file to hide';
summary.classList.remove('has-content'); summary.classList.remove('has-content');
stepNum.classList.remove('complete'); stepNum.classList.remove('complete');
stepNum.textContent = '2'; stepNum.textContent = '3';
} }
} }
@@ -543,14 +748,16 @@ function updateSecuritySummary() {
summary.textContent = 'Passphrase & keys'; summary.textContent = 'Passphrase & keys';
summary.classList.remove('has-content'); summary.classList.remove('has-content');
stepNum.classList.remove('complete'); stepNum.classList.remove('complete');
stepNum.textContent = '3'; stepNum.textContent = '4';
} }
} }
// Attach listeners // Attach listeners
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary); document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
document.getElementById('carrierInput')?.addEventListener('change', updateImagesSummary); document.getElementById('carrierInput')?.addEventListener('change', updateImagesSummary);
document.getElementById('audioCarrierInput')?.addEventListener('change', updateImagesSummary);
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary)); document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
document.querySelectorAll('#audioModeGroup input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
document.getElementById('messageInput')?.addEventListener('input', updatePayloadSummary); document.getElementById('messageInput')?.addEventListener('input', updatePayloadSummary);
document.getElementById('payloadFileInput')?.addEventListener('change', updatePayloadSummary); document.getElementById('payloadFileInput')?.addEventListener('change', updatePayloadSummary);

View File

@@ -12,6 +12,20 @@
</h5> </h5>
</div> </div>
<div class="card-body text-center"> <div class="card-body text-center">
{% if carrier_type == 'audio' %}
<!-- Audio Preview -->
<div class="my-4">
<div class="text-center">
<i class="bi bi-music-note-beamed text-success" style="font-size: 4rem;"></i>
<div class="mt-2">
<audio controls src="{{ url_for('encode_file_route', file_id=file_id) }}" class="w-100" style="max-width: 400px;"></audio>
</div>
<div class="mt-2 small text-muted">
<i class="bi bi-music-note-beamed me-1"></i>Encoded Audio Preview
</div>
</div>
</div>
{% else %}
<div class="my-4"> <div class="my-4">
{% if thumbnail_url %} {% if thumbnail_url %}
<!-- Thumbnail of the actual encoded image --> <!-- Thumbnail of the actual encoded image -->
@@ -29,8 +43,9 @@
<i class="bi bi-file-earmark-image text-success" style="font-size: 4rem;"></i> <i class="bi bi-file-earmark-image text-success" style="font-size: 4rem;"></i>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<p class="lead mb-4">Your secret has been hidden in the image.</p> <p class="lead mb-4">Your secret has been hidden in the {{ 'audio file' if carrier_type == 'audio' else 'image' }}.</p>
<div class="mb-3"> <div class="mb-3">
<code class="fs-5">{{ filename }}</code> <code class="fs-5">{{ filename }}</code>
@@ -38,7 +53,28 @@
<!-- Mode and format badges --> <!-- Mode and format badges -->
<div class="mb-4"> <div class="mb-4">
{% if embed_mode == 'dct' %} {% if carrier_type == 'audio' %}
<!-- Audio mode badges -->
{% if embed_mode == 'audio_spread' %}
<span class="badge bg-warning text-dark fs-6">
<i class="bi bi-broadcast me-1"></i>Spread Spectrum
</span>
{% else %}
<span class="badge bg-primary fs-6">
<i class="bi bi-grid-3x3-gap me-1"></i>Audio LSB
</span>
{% endif %}
<span class="badge bg-info fs-6 ms-1">
<i class="bi bi-file-earmark-music me-1"></i>WAV
</span>
<div class="small text-muted mt-2">
{% if embed_mode == 'audio_spread' %}
Spread spectrum embedding in audio samples
{% else %}
LSB embedding in audio samples, WAV output
{% endif %}
</div>
{% elif embed_mode == 'dct' %}
<span class="badge bg-info fs-6"> <span class="badge bg-info fs-6">
<i class="bi bi-soundwave me-1"></i>DCT Mode <i class="bi bi-soundwave me-1"></i>DCT Mode
</span> </span>
@@ -114,7 +150,7 @@
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<a href="{{ url_for('encode_download', file_id=file_id) }}" <a href="{{ url_for('encode_download', file_id=file_id) }}"
class="btn btn-primary btn-lg" id="downloadBtn"> class="btn btn-primary btn-lg" id="downloadBtn">
<i class="bi bi-download me-2"></i>Download Image <i class="bi bi-download me-2"></i>Download {{ 'Audio' if carrier_type == 'audio' else 'Image' }}
</a> </a>
<button type="button" class="btn btn-outline-primary" id="shareBtn" style="display: none;"> <button type="button" class="btn btn-outline-primary" id="shareBtn" style="display: none;">
@@ -129,6 +165,11 @@
<strong>Important:</strong> <strong>Important:</strong>
<ul class="mb-0 mt-2"> <ul class="mb-0 mt-2">
<li>This file expires in <strong>10 minutes</strong></li> <li>This file expires in <strong>10 minutes</strong></li>
{% if carrier_type == 'audio' %}
<li>Do <strong>not</strong> re-encode or convert the audio file</li>
<li>WAV format preserves your hidden data losslessly</li>
<li>Sharing via platforms that re-encode audio will destroy the hidden data</li>
{% else %}
<li>Do <strong>not</strong> resize or recompress the image</li> <li>Do <strong>not</strong> resize or recompress the image</li>
{% if embed_mode == 'dct' and output_format == 'jpeg' %} {% if embed_mode == 'dct' and output_format == 'jpeg' %}
<li>JPEG format is lossy - avoid re-saving or editing</li> <li>JPEG format is lossy - avoid re-saving or editing</li>
@@ -141,6 +182,7 @@
<li>Color preserved - extraction works on both color and grayscale</li> <li>Color preserved - extraction works on both color and grayscale</li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endif %}
{% if channel_mode == 'private' %} {% if channel_mode == 'private' %}
<li><i class="bi bi-shield-lock text-warning me-1"></i>Recipient needs the <strong>same channel key</strong> to decode</li> <li><i class="bi bi-shield-lock text-warning me-1"></i>Recipient needs the <strong>same channel key</strong> to decode</li>
{% endif %} {% endif %}
@@ -148,7 +190,7 @@
</div> </div>
<a href="{{ url_for('encode_page') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('encode_page') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-repeat me-2"></i>Encode Another Message <i class="bi bi-arrow-repeat me-2"></i>Encode Another
</a> </a>
</div> </div>
</div> </div>
@@ -162,7 +204,7 @@
const shareBtn = document.getElementById('shareBtn'); const shareBtn = document.getElementById('shareBtn');
const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}"; const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}";
const fileName = "{{ filename }}"; const fileName = "{{ filename }}";
const mimeType = "{{ 'image/jpeg' if embed_mode == 'dct' and output_format == 'jpeg' else 'image/png' }}"; const mimeType = "{{ 'audio/wav' if carrier_type == 'audio' else ('image/jpeg' if embed_mode == 'dct' and output_format == 'jpeg' else 'image/png') }}";
if (navigator.share && navigator.canShare) { if (navigator.share && navigator.canShare) {
// Check if we can share files // Check if we can share files

View File

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

View File

@@ -7,7 +7,7 @@ Changes in v4.0.0:
- encode() and decode() now accept channel_key parameter - encode() and decode() now accept channel_key parameter
""" """
__version__ = "4.2.1" __version__ = "4.3.0"
# Core functionality # Core functionality
# Channel key management (v4.0.0) # Channel key management (v4.0.0)
@@ -24,8 +24,8 @@ from .channel import (
# Crypto functions # Crypto functions
from .crypto import get_active_channel_key, get_channel_fingerprint, has_argon2 from .crypto import get_active_channel_key, get_channel_fingerprint, has_argon2
from .decode import decode, decode_audio, decode_file, decode_text from .decode import decode, decode_file, decode_text
from .encode import encode, encode_audio from .encode import encode
# Credential generation # Credential generation
from .generate import ( from .generate import (
@@ -54,22 +54,28 @@ from .steganography import (
# Utilities # Utilities
from .utils import generate_filename from .utils import generate_filename
# Audio utilities - optional, may not be available (v4.3.0) # Audio support — gated by STEGASOO_AUDIO env var and dependency availability
try: from .constants import AUDIO_ENABLED, VIDEO_ENABLED
HAS_AUDIO_SUPPORT = AUDIO_ENABLED
HAS_VIDEO_SUPPORT = VIDEO_ENABLED
if AUDIO_ENABLED:
from .audio_utils import ( from .audio_utils import (
detect_audio_format, detect_audio_format,
get_audio_info, get_audio_info,
has_ffmpeg_support, has_ffmpeg_support,
validate_audio, validate_audio,
) )
from .decode import decode_audio
HAS_AUDIO_SUPPORT = True from .encode import encode_audio
except ImportError: else:
HAS_AUDIO_SUPPORT = False
detect_audio_format = None detect_audio_format = None
get_audio_info = None get_audio_info = None
has_ffmpeg_support = None has_ffmpeg_support = None
validate_audio = None validate_audio = None
encode_audio = None
decode_audio = None
# QR Code utilities - optional, may not be available # QR Code utilities - optional, may not be available
try: try:
@@ -203,6 +209,7 @@ __all__ = [
"has_ffmpeg_support", "has_ffmpeg_support",
"validate_audio", "validate_audio",
"HAS_AUDIO_SUPPORT", "HAS_AUDIO_SUPPORT",
"HAS_VIDEO_SUPPORT",
"validate_audio_embed_mode", "validate_audio_embed_mode",
"validate_audio_file", "validate_audio_file",
# Generation # Generation

View File

@@ -283,7 +283,9 @@ def embed_in_audio_lsb(
# 2. Prepend magic + length prefix # 2. Prepend magic + length prefix
header = AUDIO_MAGIC_LSB + struct.pack(">I", len(data)) header = AUDIO_MAGIC_LSB + struct.pack(">I", len(data))
payload = header + 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 # 3. Check capacity
max_bytes = (num_samples * bits_per_sample) // 8 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 total_samples_needed = (total_bits + bits_per_sample - 1) // bits_per_sample
if total_samples_needed > num_samples: if total_samples_needed > num_samples:
debug.print( debug.print(f"Need {total_samples_needed} samples but only {num_samples} available")
f"Need {total_samples_needed} samples but only {num_samples} available"
)
return None return None
debug.print(f"Need {total_samples_needed} samples to extract {data_length} bytes") 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) binary_data += str((val >> bit_pos) & 1)
if progress_file and progress_idx % PROGRESS_INTERVAL == 0: if progress_file and progress_idx % PROGRESS_INTERVAL == 0:
_write_progress( _write_progress(progress_file, progress_idx, total_samples_needed, "extracting")
progress_file, progress_idx, total_samples_needed, "extracting"
)
if progress_file: if progress_file:
_write_progress( _write_progress(progress_file, total_samples_needed, total_samples_needed, "extracting")
progress_file, total_samples_needed, total_samples_needed, "extracting"
)
# Skip the 8-byte header (magic + length) = 64 bits # Skip the 8-byte header (magic + length) = 64 bits
data_bits = binary_data[64 : 64 + (data_length * 8)] data_bits = binary_data[64 : 64 + (data_length * 8)]

View File

@@ -13,7 +13,6 @@ Both are optional — functions degrade gracefully when unavailable.
from __future__ import annotations from __future__ import annotations
import io import io
import logging
import shutil import shutil
from .constants import ( from .constants import (
@@ -24,10 +23,11 @@ from .constants import (
MIN_AUDIO_SAMPLE_RATE, MIN_AUDIO_SAMPLE_RATE,
VALID_AUDIO_EMBED_MODES, VALID_AUDIO_EMBED_MODES,
) )
from .debug import get_logger
from .exceptions import AudioTranscodeError, AudioValidationError, UnsupportedAudioFormatError from .exceptions import AudioTranscodeError, AudioValidationError, UnsupportedAudioFormatError
from .models import AudioInfo, ValidationResult 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". Format string: "wav", "flac", "mp3", "ogg", "aac", "m4a", or "unknown".
""" """
if len(audio_data) < 12: if len(audio_data) < 12:
logger.debug("detect_audio_format: data too short (%d bytes)", len(audio_data))
return "unknown" return "unknown"
# WAV: RIFF....WAVE # WAV: RIFF....WAVE
if audio_data[:4] == b"RIFF" and audio_data[8:12] == b"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" return "wav"
# FLAC # FLAC
@@ -124,6 +126,7 @@ def transcode_to_wav(audio_data: bytes) -> bytes:
UnsupportedAudioFormatError: If the format cannot be detected. UnsupportedAudioFormatError: If the format cannot be detected.
""" """
fmt = detect_audio_format(audio_data) fmt = detect_audio_format(audio_data)
logger.info("transcode_to_wav: input format=%s, size=%d bytes", fmt, len(audio_data))
if fmt == "unknown": if fmt == "unknown":
raise UnsupportedAudioFormatError( raise UnsupportedAudioFormatError(
@@ -325,7 +328,9 @@ def _get_info_soundfile(audio_data: bytes, fmt: str) -> AudioInfo:
try: try:
import soundfile as sf import soundfile as sf
except ImportError: 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: try:
buf = io.BytesIO(audio_data) buf = io.BytesIO(audio_data)
@@ -460,8 +465,7 @@ def validate_audio(
fmt = detect_audio_format(audio_data) fmt = detect_audio_format(audio_data)
if fmt == "unknown": if fmt == "unknown":
return ValidationResult.error( return ValidationResult.error(
f"Could not detect {name} format. " f"Could not detect {name} format. " "Supported formats: WAV, FLAC, MP3, OGG, AAC, M4A."
"Supported formats: WAV, FLAC, MP3, OGG, AAC, M4A."
) )
# Extract metadata for further validation # Extract metadata for further validation

View File

@@ -69,6 +69,7 @@ def _get_machine_key() -> bytes:
# Fallback to hostname # Fallback to hostname
if not machine_id: if not machine_id:
import socket import socket
machine_id = socket.gethostname() machine_id = socket.gethostname()
# Hash to get consistent 32 bytes # Hash to get consistent 32 bytes
@@ -87,10 +88,7 @@ def _encrypt_for_storage(plaintext: str) -> str:
plaintext_bytes = plaintext.encode() plaintext_bytes = plaintext.encode()
# XOR with key (cycling if needed) # XOR with key (cycling if needed)
encrypted = bytes( encrypted = bytes(pb ^ key[i % len(key)] for i, pb in enumerate(plaintext_bytes))
pb ^ key[i % len(key)]
for i, pb in enumerate(plaintext_bytes)
)
return ENCRYPTED_PREFIX + base64.b64encode(encrypted).decode() return ENCRYPTED_PREFIX + base64.b64encode(encrypted).decode()
@@ -108,14 +106,11 @@ def _decrypt_from_storage(stored: str) -> str | None:
return stored return stored
try: try:
encrypted = base64.b64decode(stored[len(ENCRYPTED_PREFIX):]) encrypted = base64.b64decode(stored[len(ENCRYPTED_PREFIX) :])
key = _get_machine_key() key = _get_machine_key()
# XOR to decrypt # XOR to decrypt
decrypted = bytes( decrypted = bytes(eb ^ key[i % len(key)] for i, eb in enumerate(encrypted))
eb ^ key[i % len(key)]
for i, eb in enumerate(encrypted)
)
return decrypted.decode() return decrypted.decode()
except Exception: except Exception:
@@ -413,7 +408,11 @@ def get_channel_status() -> dict:
try: try:
stored = config_path.read_text().strip() stored = config_path.read_text().strip()
file_key = _decrypt_from_storage(stored) file_key = _decrypt_from_storage(stored)
if file_key and validate_channel_key(file_key) and format_channel_key(file_key) == key: if (
file_key
and validate_channel_key(file_key)
and format_channel_key(file_key) == key
):
source = str(config_path) source = str(config_path)
break break
except (OSError, PermissionError, ValueError): except (OSError, PermissionError, ValueError):
@@ -485,7 +484,9 @@ def resolve_channel_key(
>>> resolve_channel_key("ABCD-1234-...") # -> "ABCD-1234-..." >>> resolve_channel_key("ABCD-1234-...") # -> "ABCD-1234-..."
>>> resolve_channel_key(file_path="key.txt") # reads from file >>> resolve_channel_key(file_path="key.txt") # reads from file
""" """
debug.print(f"resolve_channel_key: value={value}, file_path={file_path}, no_channel={no_channel}") debug.print(
f"resolve_channel_key: value={value}, file_path={file_path}, no_channel={no_channel}"
)
# no_channel flag takes precedence # no_channel flag takes precedence
if no_channel: if no_channel:

View File

@@ -108,8 +108,9 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@click.group(context_settings=CONTEXT_SETTINGS) @click.group(context_settings=CONTEXT_SETTINGS)
@click.version_option(__version__, "-v", "--version") @click.version_option(__version__, "-v", "--version")
@click.option("--json", "json_output", is_flag=True, help="Output results as JSON") @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 @click.pass_context
def cli(ctx, json_output): def cli(ctx, json_output, debug_mode):
""" """
Stegasoo - Steganography with hybrid authentication. Stegasoo - Steganography with hybrid authentication.
@@ -120,6 +121,11 @@ def cli(ctx, json_output):
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["json"] = json_output ctx.obj["json"] = json_output
if debug_mode:
from .debug import debug
debug.enable(True)
# ============================================================================= # =============================================================================
# ENCODE COMMANDS # 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("--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.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
@click.pass_context @click.pass_context
def encode( def encode(ctx, carrier, reference, message, file_payload, output, passphrase, pin, dry_run):
ctx, carrier, reference, message, file_payload, output, passphrase, pin, dry_run
):
""" """
Encode a message or file into an image. Encode a message or file into an image.
@@ -245,14 +249,14 @@ def encode(
# Default to JPEG for JPEG carriers (preserves DCT mode benefits) # Default to JPEG for JPEG carriers (preserves DCT mode benefits)
carrier_ext = Path(carrier).suffix.lower() carrier_ext = Path(carrier).suffix.lower()
if not output: if not output:
if carrier_ext in ('.jpg', '.jpeg'): if carrier_ext in (".jpg", ".jpeg"):
output = f"{Path(carrier).stem}_encoded.jpg" output = f"{Path(carrier).stem}_encoded.jpg"
else: else:
output = f"{Path(carrier).stem}_encoded.png" output = f"{Path(carrier).stem}_encoded.png"
# Detect output format from extension # Detect output format from extension
output_ext = Path(output).suffix.lower() 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 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)", help="Passphrase (recommend 4+ words)",
) )
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code") @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 @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. 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 -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 -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 .encode import encode_audio
from .models import FilePayload from .models import FilePayload
from .spread_steganography import calculate_audio_spread_capacity
if not message and not file_payload: if not message and not file_payload:
raise click.UsageError("Either --message or --file is required") 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 # Read input files
with open(reference, "rb") as f: with open(reference, "rb") as f:
reference_data = f.read() reference_data = f.read()
with open(carrier, "rb") as f: with open(carrier, "rb") as f:
carrier_data = f.read() 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 # Determine output path
if not output: if not output:
carrier_path = Path(carrier) output = f"{Path(carrier).stem}_encoded.wav"
if embed_mode == "audio_lsb":
output = f"{carrier_path.stem}_encoded.wav"
else:
output = f"{carrier_path.stem}_encoded.wav"
try: try:
if file_payload: if file_payload:
@@ -479,13 +587,24 @@ def audio_encode(ctx, carrier, reference, message, file_payload, output, embed_m
else: else:
payload = message 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( stego_audio, stats = encode_audio(
message=payload, message=payload,
reference_photo=reference_data, reference_photo=reference_data,
carrier_audio=carrier_data, carrier_audio=carrier_data,
passphrase=passphrase, passphrase=passphrase,
pin=pin, pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password,
embed_mode=embed_mode, embed_mode=embed_mode,
channel_key=channel_key,
chip_tier=resolved_chip_tier,
) )
with open(output, "wb") as f: 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("--passphrase", prompt=True, hide_input=True, help="Passphrase")
@click.option("--pin", prompt=True, hide_input=True, help="PIN code") @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.option("-o", "--output", type=click.Path(), help="Output path for file payloads")
@click.pass_context @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. 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/ 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 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: with open(audio, "rb") as f:
audio_data = f.read() audio_data = f.read()
with open(reference, "rb") as f: 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, reference_photo=reference_data,
passphrase=passphrase, passphrase=passphrase,
pin=pin, pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password,
embed_mode=embed_mode, embed_mode=embed_mode,
channel_key=channel_key,
) )
if result.is_file: if result.is_file:
@@ -617,6 +762,97 @@ def audio_decode(ctx, audio, reference, embed_mode, passphrase, pin, output):
raise SystemExit(1) 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 # BATCH COMMANDS
# ============================================================================= # =============================================================================
@@ -828,9 +1064,7 @@ def batch_check(ctx, images, recursive):
@click.option( @click.option(
"--pin-length", default=DEFAULT_PIN_LENGTH, help=f"PIN length (default: {DEFAULT_PIN_LENGTH})" "--pin-length", default=DEFAULT_PIN_LENGTH, help=f"PIN length (default: {DEFAULT_PIN_LENGTH})"
) )
@click.option( @click.option("--channel-key", is_flag=True, help="Also generate a 256-bit channel key")
"--channel-key", is_flag=True, help="Also generate a 256-bit channel key"
)
@click.pass_context @click.pass_context
def generate(ctx, words, pin_length, channel_key): 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 # Generate channel key if requested
if channel_key: if channel_key:
from .channel import generate_channel_key from .channel import generate_channel_key
result["channel_key"] = generate_channel_key() result["channel_key"] = generate_channel_key()
if ctx.obj.get("json"): if ctx.obj.get("json"):
@@ -912,6 +1147,7 @@ def info(ctx, full):
# Check for DCT support # Check for DCT support
try: try:
from .dct_steganography import HAS_JPEGIO, HAS_SCIPY from .dct_steganography import HAS_JPEGIO, HAS_SCIPY
has_dct = HAS_SCIPY and HAS_JPEGIO has_dct = HAS_SCIPY and HAS_JPEGIO
except ImportError: except ImportError:
has_dct = False has_dct = False
@@ -954,6 +1190,7 @@ def info(ctx, full):
channel_source = None channel_source = None
try: try:
from .channel import get_channel_fingerprint, get_channel_key, get_channel_status from .channel import get_channel_fingerprint, get_channel_key, get_channel_status
key = get_channel_key() key = get_channel_key()
if key: if key:
channel_fingerprint = get_channel_fingerprint(key) channel_fingerprint = get_channel_fingerprint(key)
@@ -986,7 +1223,7 @@ def info(ctx, full):
try: try:
# Disk free # Disk free
st = os.statvfs("/") 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: except OSError:
pass pass
@@ -1005,20 +1242,28 @@ def info(ctx, full):
"service": service_status, "service": service_status,
"url": service_url, "url": service_url,
"dct_support": has_dct, "dct_support": has_dct,
"channel": { "channel": (
{
"fingerprint": channel_fingerprint, "fingerprint": channel_fingerprint,
"source": channel_source, "source": channel_source,
} if channel_fingerprint else None, }
if channel_fingerprint
else None
),
"limits": { "limits": {
"max_message_bytes": MAX_MESSAGE_SIZE, "max_message_bytes": MAX_MESSAGE_SIZE,
"max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE, "max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE,
}, },
"system": { "system": (
{
"cpu_mhz": cpu_freq, "cpu_mhz": cpu_freq,
"temp_c": cpu_temp, "temp_c": cpu_temp,
"disk_free_gb": round(disk_free, 1) if disk_free else None, "disk_free_gb": round(disk_free, 1) if disk_free else None,
"uptime": uptime, "uptime": uptime,
} if full else None, }
if full
else None
),
} }
if ctx.obj.get("json"): if ctx.obj.get("json"):
@@ -1055,7 +1300,9 @@ def info(ctx, full):
if cpu_freq: if cpu_freq:
click.echo(f" CPU: {cpu_freq} MHz") click.echo(f" CPU: {cpu_freq} MHz")
if cpu_temp: 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") click.echo(f" Temp: {temp_color}{cpu_temp:.1f}°C\033[0m")
if uptime: if uptime:
click.echo(f" Uptime: {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" Megapixels: {result['megapixels']} MP")
click.echo(f" {'' * 40}") click.echo(f" {'' * 40}")
click.echo(f" LSB Capacity: {result['lsb']['capacity_kb']:.1f} KB") 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") click.echo(f" DCT Capacity: {result['dct']['capacity_kb']:.1f} KB")
else: else:
click.echo(" DCT Capacity: N/A (scipy required)") click.echo(" DCT Capacity: N/A (scipy required)")
@@ -1394,7 +1641,9 @@ def tools_capacity(image, as_json):
@tools.command("strip") @tools.command("strip")
@click.argument("image", type=click.Path(exists=True)) @click.argument("image", type=click.Path(exists=True))
@click.option("-o", "--output", type=click.Path(), help="Output file (default: <name>_clean.png)") @click.option("-o", "--output", type=click.Path(), help="Output file (default: <name>_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): def tools_strip(image, output, fmt):
"""Strip EXIF/metadata from an image. """Strip EXIF/metadata from an image.
@@ -1529,7 +1778,9 @@ def tools_exif(image, clear, set_fields, output, as_json):
@tools.command("compress") @tools.command("compress")
@click.argument("image", type=click.Path(exists=True)) @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("-q", "--quality", type=int, default=75, help="JPEG quality (1-100, default: 75)")
@click.option("-o", "--output", type=click.Path(), help="Output file (default: <name>_q<quality>.jpg)") @click.option(
"-o", "--output", type=click.Path(), help="Output file (default: <name>_q<quality>.jpg)"
)
def tools_compress(image, quality, output): def tools_compress(image, quality, output):
"""Compress a JPEG image. """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 60
stegasoo tools compress photo.jpg -q 80 -o smaller.jpg stegasoo tools compress photo.jpg -q 80 -o smaller.jpg
""" """
from PIL import Image
import io import io
from PIL import Image
if not 1 <= quality <= 100: if not 1 <= quality <= 100:
raise click.UsageError("Quality must be between 1 and 100") raise click.UsageError("Quality must be between 1 and 100")
@@ -1578,7 +1830,9 @@ def tools_compress(image, quality, output):
@tools.command("rotate") @tools.command("rotate")
@click.argument("image", type=click.Path(exists=True)) @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-h", is_flag=True, help="Flip horizontally")
@click.option("--flip-v", is_flag=True, help="Flip vertically") @click.option("--flip-v", is_flag=True, help="Flip vertically")
@click.option("-o", "--output", type=click.Path(), help="Output file") @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 90
stegasoo tools rotate photo.jpg -r 180 --flip-h -o rotated.jpg stegasoo tools rotate photo.jpg -r 180 --flip-h -o rotated.jpg
""" """
from PIL import Image
import io import io
import shutil import shutil
from PIL import Image
with open(image, "rb") as f: with open(image, "rb") as f:
image_data = f.read() image_data = f.read()
@@ -1622,9 +1877,9 @@ def tools_rotate(image, rotation, flip_h, flip_v, output):
# Apply flips using jpegtran # Apply flips using jpegtran
if flip_h or flip_v: if flip_h or flip_v:
import os
import subprocess import subprocess
import tempfile import tempfile
import os
for flip_type in (["horizontal"] if flip_h else []) + (["vertical"] if flip_v else []): for flip_type in (["horizontal"] if flip_h else []) + (["vertical"] if flip_v else []):
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f: 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") output_path = tempfile.mktemp(suffix=".jpg")
try: try:
subprocess.run( subprocess.run(
["jpegtran", "-flip", flip_type, "-copy", "all", [
"-outfile", output_path, input_path], "jpegtran",
capture_output=True, timeout=30, check=True "-flip",
flip_type,
"-copy",
"all",
"-outfile",
output_path,
input_path,
],
capture_output=True,
timeout=30,
check=True,
) )
with open(output_path, "rb") as f: with open(output_path, "rb") as f:
result_data = f.read() result_data = f.read()
@@ -1680,8 +1945,17 @@ def tools_rotate(image, rotation, flip_h, flip_v, output):
@tools.command("convert") @tools.command("convert")
@click.argument("image", type=click.Path(exists=True)) @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(
@click.option("-q", "--quality", type=int, default=95, help="Quality for lossy formats (default: 95)") "-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") @click.option("-o", "--output", type=click.Path(), help="Output file")
def tools_convert(image, fmt, quality, output): def tools_convert(image, fmt, quality, output):
"""Convert image to a different format. """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.png -f jpg
stegasoo tools convert photo.jpg -f png -o lossless.png stegasoo tools convert photo.jpg -f png -o lossless.png
""" """
from PIL import Image
import io import io
from PIL import Image
with open(image, "rb") as f: with open(image, "rb") as f:
image_data = f.read() image_data = f.read()
@@ -1737,12 +2012,14 @@ def admin(ctx):
@admin.command("recover") @admin.command("recover")
@click.option( @click.option(
"--db", "db_path", "--db",
"db_path",
type=click.Path(exists=True), 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): def admin_recover(db_path, password):
"""Reset admin password using recovery key. """Reset admin password using recovery key.
@@ -1772,9 +2049,7 @@ def admin_recover(db_path, password):
break break
if not db_path or not Path(db_path).exists(): if not db_path or not Path(db_path).exists():
raise click.UsageError( raise click.UsageError("Database not found. Use --db to specify path to stegasoo.db")
"Database not found. Use --db to specify path to stegasoo.db"
)
click.echo(f"Database: {db_path}") click.echo(f"Database: {db_path}")
@@ -1783,16 +2058,13 @@ def admin_recover(db_path, password):
db.row_factory = sqlite3.Row db.row_factory = sqlite3.Row
# Get recovery key hash from app_settings # Get recovery key hash from app_settings
cursor = db.execute( cursor = db.execute("SELECT value FROM app_settings WHERE key = 'recovery_key_hash'")
"SELECT value FROM app_settings WHERE key = 'recovery_key_hash'"
)
row = cursor.fetchone() row = cursor.fetchone()
if not row: if not row:
db.close() db.close()
raise click.ClickException( raise click.ClickException(
"No recovery key configured for this instance. " "No recovery key configured for this instance. " "Password reset is not possible."
"Password reset is not possible."
) )
stored_hash = row["value"] stored_hash = row["value"]
@@ -1869,6 +2141,7 @@ def admin_generate_key(show_qr):
if show_qr: if show_qr:
try: try:
import qrcode import qrcode
qr = qrcode.QRCode(box_size=1, border=1) qr = qrcode.QRCode(box_size=1, border=1)
qr.add_data(key) qr.add_data(key)
qr.make() qr.make()
@@ -1920,8 +2193,12 @@ def api_keys():
@api_keys.command("list") @api_keys.command("list")
@click.option("--location", type=click.Choice(["user", "project", "all"]), default="all", @click.option(
help="Config location to list keys from") "--location",
type=click.Choice(["user", "project", "all"]),
default="all",
help="Config location to list keys from",
)
def api_keys_list(location): def api_keys_list(location):
"""List configured API keys. """List configured API keys.
@@ -1935,7 +2212,7 @@ def api_keys_list(location):
_setup_frontends_path() _setup_frontends_path()
try: 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: except ImportError:
raise click.ClickException("API frontend not available") raise click.ClickException("API frontend not available")
@@ -1959,8 +2236,12 @@ def api_keys_list(location):
@api_keys.command("create") @api_keys.command("create")
@click.argument("name") @click.argument("name")
@click.option("--location", type=click.Choice(["user", "project"]), default="user", @click.option(
help="Where to store the key") "--location",
type=click.Choice(["user", "project"]),
default="user",
help="Where to store the key",
)
def api_keys_create(name, location): def api_keys_create(name, location):
"""Create a new API key. """Create a new API key.
@@ -1993,8 +2274,9 @@ def api_keys_create(name, location):
@api_keys.command("delete") @api_keys.command("delete")
@click.argument("name") @click.argument("name")
@click.option("--location", type=click.Choice(["user", "project"]), default="user", @click.option(
help="Config location") "--location", type=click.Choice(["user", "project"]), default="user", help="Config location"
)
def api_keys_delete(name, location): def api_keys_delete(name, location):
"""Delete an API key by name. """Delete an API key by name.
@@ -2025,7 +2307,9 @@ def api_tls():
@api_tls.command("generate") @api_tls.command("generate")
@click.option("--hostname", default="localhost", help="Server hostname for certificate") @click.option("--hostname", default="localhost", help="Server hostname for certificate")
@click.option("--days", default=365, help="Certificate validity in days") @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): def api_tls_generate(hostname, days, output):
"""Generate self-signed TLS certificate. """Generate self-signed TLS certificate.
@@ -2065,7 +2349,12 @@ def api_tls_generate(hostname, days, output):
@api_tls.command("info") @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): def api_tls_info(cert):
"""Show information about a TLS certificate. """Show information about a TLS certificate.
@@ -2075,12 +2364,13 @@ def api_tls_info(cert):
stegasoo api tls info --cert /path/to/server.crt stegasoo api tls info --cert /path/to/server.crt
""" """
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.primitives import serialization
if not cert: if not cert:
cert = Path.home() / ".stegasoo" / "certs" / "server.crt" cert = Path.home() / ".stegasoo" / "certs" / "server.crt"
if not cert.exists(): 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() cert_data = Path(cert).read_bytes()
certificate = x509.load_pem_x509_certificate(cert_data) certificate = x509.load_pem_x509_certificate(cert_data)
@@ -2095,7 +2385,8 @@ def api_tls_info(cert):
# Check expiry # Check expiry
import datetime import datetime
now = datetime.datetime.now(datetime.timezone.utc)
now = datetime.datetime.now(datetime.UTC)
if certificate.not_valid_after_utc < now: if certificate.not_valid_after_utc < now:
click.echo("\nStatus: EXPIRED") click.echo("\nStatus: EXPIRED")
elif certificate.not_valid_after_utc < now + datetime.timedelta(days=30): 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: else:
try: try:
from web.ssl_utils import ensure_certs from web.ssl_utils import ensure_certs
base_dir = Path.home() / ".stegasoo" 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: except ImportError:
raise click.ClickException("ssl_utils not available") raise click.ClickException("ssl_utils not available")

View File

@@ -9,6 +9,10 @@ import struct
import zlib import zlib
from enum import IntEnum from enum import IntEnum
from .debug import get_logger
logger = get_logger(__name__)
# Optional LZ4 support (faster, slightly worse ratio) # Optional LZ4 support (faster, slightly worse ratio)
try: try:
import lz4.frame import lz4.frame

View File

@@ -262,8 +262,7 @@ DCT_STEP_SIZE = 8 # QIM quantization step
# SHA256("\x89ST3\x89DCT") - hardcoded so it never changes even if headers are added # SHA256("\x89ST3\x89DCT") - hardcoded so it never changes even if headers are added
# Used to XOR recovery keys in QR codes so they scan as gibberish # Used to XOR recovery keys in QR codes so they scan as gibberish
RECOVERY_OBFUSCATION_KEY = bytes.fromhex( RECOVERY_OBFUSCATION_KEY = bytes.fromhex(
"d6c70bce27780db942562550e9fe1459" "d6c70bce27780db942562550e9fe1459" "9dfdb8421f5acc79696b05db4e7afbd2"
"9dfdb8421f5acc79696b05db4e7afbd2"
) # 32 bytes ) # 32 bytes
# Valid embedding modes # Valid embedding modes
@@ -297,6 +296,69 @@ def detect_stego_mode(encrypted_data: bytes) -> str:
return "unknown" return "unknown"
# =============================================================================
# FEATURE TOGGLES (v4.3.1)
# =============================================================================
# Environment variables to enable/disable optional feature families.
# Values: "auto" (default — detect dependencies), "1"/"true" (force on),
# "0"/"false" (force off even if deps are installed).
# Pi builds or minimal installs can set STEGASOO_AUDIO=0 to stay image-only.
import os as _os
def _parse_feature_toggle(env_var: str, default: str = "auto") -> str | bool:
"""Parse a feature toggle env var. Returns 'auto', True, or False."""
val = _os.environ.get(env_var, default).strip().lower()
if val in ("1", "true", "yes", "on"):
return True
if val in ("0", "false", "no", "off"):
return False
return "auto"
def _check_audio_deps() -> bool:
"""Check if audio dependencies (soundfile, numpy) are importable."""
try:
import numpy # noqa: F401
import soundfile # noqa: F401
return True
except ImportError:
return False
def _check_video_deps() -> bool:
"""Check if video dependencies (ffmpeg binary + audio deps) are available."""
import shutil
if not _check_audio_deps():
return False
return shutil.which("ffmpeg") is not None
def _resolve_feature(toggle: str | bool, dep_check: callable) -> bool:
"""Resolve a feature toggle to a final bool."""
if toggle is True:
if not dep_check():
raise ImportError(
f"Feature force-enabled but required dependencies are missing. "
f"Install the relevant extras (e.g. pip install stegasoo[audio])."
)
return True
if toggle is False:
return False
# auto
return dep_check()
_audio_toggle = _parse_feature_toggle("STEGASOO_AUDIO")
_video_toggle = _parse_feature_toggle("STEGASOO_VIDEO")
AUDIO_ENABLED: bool = _resolve_feature(_audio_toggle, _check_audio_deps)
VIDEO_ENABLED: bool = _resolve_feature(_video_toggle, _check_video_deps)
# ============================================================================= # =============================================================================
# AUDIO STEGANOGRAPHY (v4.3.0) # AUDIO STEGANOGRAPHY (v4.3.0)
# ============================================================================= # =============================================================================
@@ -319,10 +381,31 @@ MAX_AUDIO_SAMPLE_RATE = 192000 # Studio quality
ALLOWED_AUDIO_EXTENSIONS = {"wav", "flac", "mp3", "ogg", "opus", "aac", "m4a", "wma"} ALLOWED_AUDIO_EXTENSIONS = {"wav", "flac", "mp3", "ogg", "opus", "aac", "m4a", "wma"}
# Spread spectrum parameters # Spread spectrum parameters
AUDIO_SS_CHIP_LENGTH = 1024 # Samples per chip (spreading factor) 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_AMPLITUDE = 0.05 # Per-sample embedding strength (~-26dB, masked by audio)
AUDIO_SS_RS_NSYM = 32 # Reed-Solomon parity symbols 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 # Echo hiding parameters
AUDIO_ECHO_DELAY_0 = 50 # Echo delay for bit 0 (samples at 44.1kHz ~ 1.1ms) 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_DELAY_1 = 100 # Echo delay for bit 1 (samples at 44.1kHz ~ 2.3ms)

View File

@@ -46,9 +46,12 @@ from .constants import (
SALT_SIZE, SALT_SIZE,
TAG_SIZE, TAG_SIZE,
) )
from .debug import get_logger
from .exceptions import DecryptionError, EncryptionError, InvalidHeaderError, KeyDerivationError from .exceptions import DecryptionError, EncryptionError, InvalidHeaderError, KeyDerivationError
from .models import DecodeResult, FilePayload from .models import DecodeResult, FilePayload
logger = get_logger(__name__)
# Check for Argon2 availability # Check for Argon2 availability
try: try:
from argon2.low_level import Type, hash_secret_raw from argon2.low_level import Type, hash_secret_raw
@@ -201,6 +204,18 @@ def derive_hybrid_key(
""" """
try: try:
photo_hash = hash_photo(photo_data) photo_hash = hash_photo(photo_data)
logger.debug(
"derive_hybrid_key: photo_hash=%s, pin=%s, rsa=%s, channel=%s, salt=%d bytes",
photo_hash[:4].hex(),
"set" if pin else "none",
"set" if rsa_key_data else "none",
(
"explicit"
if isinstance(channel_key, str) and channel_key
else "auto" if channel_key is None else "none"
),
len(salt),
)
# Resolve channel key (server-specific binding) # Resolve channel key (server-specific binding)
channel_hash = _resolve_channel_key(channel_key) channel_hash = _resolve_channel_key(channel_key)
@@ -217,8 +232,16 @@ def derive_hybrid_key(
if channel_hash: if channel_hash:
key_material += channel_hash key_material += channel_hash
logger.debug("Key material: %d bytes", len(key_material))
# Run it all through the KDF # Run it all through the KDF
if HAS_ARGON2: if HAS_ARGON2:
logger.debug(
"KDF: Argon2id (memory=%dKB, time=%d, parallel=%d)",
ARGON2_MEMORY_COST,
ARGON2_TIME_COST,
ARGON2_PARALLELISM,
)
# Argon2id: the good stuff # Argon2id: the good stuff
key = hash_secret_raw( key = hash_secret_raw(
secret=key_material, secret=key_material,
@@ -230,6 +253,9 @@ def derive_hybrid_key(
type=Type.ID, # Hybrid mode: resists side-channel AND GPU attacks type=Type.ID, # Hybrid mode: resists side-channel AND GPU attacks
) )
else: else:
logger.warning(
"KDF: PBKDF2 fallback (%d iterations) - argon2 not available", PBKDF2_ITERATIONS
)
# PBKDF2 fallback for systems without argon2-cffi # PBKDF2 fallback for systems without argon2-cffi
# 600K iterations is slow but not memory-hard # 600K iterations is slow but not memory-hard
kdf = PBKDF2HMAC( kdf = PBKDF2HMAC(
@@ -241,6 +267,7 @@ def derive_hybrid_key(
) )
key = kdf.derive(key_material) key = kdf.derive(key_material)
logger.debug("KDF complete, derived %d-byte key", len(key))
return key return key
except Exception as e: except Exception as e:
@@ -457,6 +484,13 @@ def encrypt_message(
# Pack payload with type marker # Pack payload with type marker
packed_payload, _ = _pack_payload(message) packed_payload, _ = _pack_payload(message)
logger.debug(
"encrypt_message: packed_payload=%d bytes, flags=0x%02x, format_version=%d",
len(packed_payload),
flags,
FORMAT_VERSION,
)
# Random padding to hide message length # Random padding to hide message length
padding_len = secrets.randbelow(256) + 64 padding_len = secrets.randbelow(256) + 64
padded_len = ((len(packed_payload) + padding_len + 255) // 256) * 256 padded_len = ((len(packed_payload) + padding_len + 255) // 256) * 256
@@ -464,6 +498,10 @@ def encrypt_message(
padding = secrets.token_bytes(padding_needed - 4) + struct.pack(">I", len(packed_payload)) padding = secrets.token_bytes(padding_needed - 4) + struct.pack(">I", len(packed_payload))
padded_message = packed_payload + padding padded_message = packed_payload + padding
logger.debug(
"Padded message: %d bytes (payload + %d padding)", len(padded_message), padding_needed
)
# Build header for AAD # Build header for AAD
header = MAGIC_HEADER + bytes([FORMAT_VERSION, flags]) header = MAGIC_HEADER + bytes([FORMAT_VERSION, flags])
@@ -473,10 +511,22 @@ def encrypt_message(
encryptor.authenticate_additional_data(header) encryptor.authenticate_additional_data(header)
ciphertext = encryptor.update(padded_message) + encryptor.finalize() 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 # v4.0.0: Header with flags byte
return header + salt + iv + encryptor.tag + ciphertext return header + salt + iv + encryptor.tag + ciphertext
except Exception as e: except Exception as e:
logger.error("Encryption failed: %s", e)
raise EncryptionError(f"Encryption failed: {e}") from e raise EncryptionError(f"Encryption failed: {e}") from e
@@ -551,10 +601,21 @@ def decrypt_message(
InvalidHeaderError: If data doesn't have valid Stegasoo header InvalidHeaderError: If data doesn't have valid Stegasoo header
DecryptionError: If decryption fails (wrong credentials) DecryptionError: If decryption fails (wrong credentials)
""" """
logger.debug("decrypt_message: %d bytes of encrypted data", len(encrypted_data))
header = parse_header(encrypted_data) header = parse_header(encrypted_data)
if not header: if not header:
logger.error("Invalid or missing Stegasoo header in %d bytes", len(encrypted_data))
raise InvalidHeaderError("Invalid or missing Stegasoo header") raise InvalidHeaderError("Invalid or missing Stegasoo header")
logger.debug(
"Header: version=%d, flags=0x%02x, has_channel_key=%s, ciphertext=%d bytes",
header["version"],
header["flags"],
header["has_channel_key"],
len(header["ciphertext"]),
)
# Check for channel key mismatch and provide helpful error # Check for channel key mismatch and provide helpful error
channel_hash = _resolve_channel_key(channel_key) channel_hash = _resolve_channel_key(channel_key)
has_configured_key = channel_hash is not None has_configured_key = channel_hash is not None
@@ -577,9 +638,16 @@ def decrypt_message(
padded_plaintext = decryptor.update(header["ciphertext"]) + decryptor.finalize() padded_plaintext = decryptor.update(header["ciphertext"]) + decryptor.finalize()
original_length = struct.unpack(">I", padded_plaintext[-4:])[0] original_length = struct.unpack(">I", padded_plaintext[-4:])[0]
logger.debug(
"Decrypted %d bytes, original payload length: %d",
len(padded_plaintext),
original_length,
)
payload_data = padded_plaintext[:original_length] payload_data = padded_plaintext[:original_length]
result = _unpack_payload(payload_data) result = _unpack_payload(payload_data)
logger.debug("Decryption successful: %s", result.payload_type)
return result return result
except Exception as e: except Exception as e:

View File

@@ -40,12 +40,12 @@ from PIL import Image, ImageOps
# Check for scipy availability (for PNG/DCT mode) # Check for scipy availability (for PNG/DCT mode)
# Prefer scipy.fft (newer, more stable) over scipy.fftpack # Prefer scipy.fft (newer, more stable) over scipy.fftpack
try: try:
from scipy.fft import dct, idct, dctn, idctn from scipy.fft import dct, dctn, idct, idctn
HAS_SCIPY = True HAS_SCIPY = True
except ImportError: except ImportError:
try: try:
from scipy.fftpack import dct, idct, dctn, idctn from scipy.fftpack import dct, dctn, idct, idctn
HAS_SCIPY = True HAS_SCIPY = True
except ImportError: except ImportError:
@@ -287,6 +287,7 @@ def has_jpegio_support() -> bool:
try: try:
from reedsolo import ReedSolomonError, RSCodec from reedsolo import ReedSolomonError, RSCodec
HAS_REEDSOLO = True HAS_REEDSOLO = True
except ImportError: except ImportError:
HAS_REEDSOLO = False HAS_REEDSOLO = False
@@ -1009,7 +1010,8 @@ def _embed_in_channel_safe(
needs_adjust = (quantized % 2) != bit_array needs_adjust = (quantized % 2) != bit_array
# Determine direction to nudge # Determine direction to nudge
dct_blocks[i, embed_rows[needs_adjust], embed_cols[needs_adjust]] = ( 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) ).astype(np.float64)
# For bits that already match, just quantize # For bits that already match, just quantize
dct_blocks[i, embed_rows[~needs_adjust], embed_cols[~needs_adjust]] = ( dct_blocks[i, embed_rows[~needs_adjust], embed_cols[~needs_adjust]] = (
@@ -1219,6 +1221,7 @@ def _embed_jpegio(
def _jpegtran_available() -> bool: def _jpegtran_available() -> bool:
"""Check if jpegtran is available on the system.""" """Check if jpegtran is available on the system."""
import shutil import shutil
return shutil.which("jpegtran") is not None return shutil.which("jpegtran") is not None
@@ -1237,9 +1240,9 @@ def _jpegtran_rotate(image_data: bytes, rotation: int) -> bytes:
Returns: Returns:
Rotated JPEG bytes with DCT coefficients preserved Rotated JPEG bytes with DCT coefficients preserved
""" """
import os
import subprocess import subprocess
import tempfile import tempfile
import os
if rotation not in (90, 180, 270): if rotation not in (90, 180, 270):
raise ValueError(f"Invalid rotation: {rotation}") 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 -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 # NOTE: Don't use -perfect as it fails on images with non-MCU-aligned edges
result = subprocess.run( 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, capture_output=True,
timeout=30 timeout=30,
) )
if result.returncode != 0: 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]) copies.append(length_prefix_bytes[start:end])
from collections import Counter from collections import Counter
counter = Counter(copies) counter = Counter(copies)
_, count = counter.most_common(1)[0] _, count = counter.most_common(1)[0]
@@ -1437,6 +1449,7 @@ def extract_from_dct(
if rotation != 0: if rotation != 0:
try: try:
from . import debug from . import debug
debug.print(f"DCT decode succeeded after {rotation}° rotation") debug.print(f"DCT decode succeeded after {rotation}° rotation")
except Exception: except Exception:
pass # Don't let debug logging break extraction pass # Don't let debug logging break extraction
@@ -1450,6 +1463,7 @@ def extract_from_dct(
if rotation != 0: if rotation != 0:
try: try:
from . import debug from . import debug
debug.print(f"DCT decode succeeded after {rotation}° rotation") debug.print(f"DCT decode succeeded after {rotation}° rotation")
except Exception: except Exception:
pass # Don't let debug logging break extraction pass # Don't let debug logging break extraction

View File

@@ -2,27 +2,96 @@
Stegasoo Debugging Utilities Stegasoo Debugging Utilities
Debugging, logging, and performance monitoring tools. Debugging, logging, and performance monitoring tools.
Can be disabled for production use.
Configuration:
STEGASOO_LOG_LEVEL env var controls log level:
- Not set or empty: logging disabled (production default)
- DEBUG: verbose debug output (encode/decode flow, crypto params, etc.)
- INFO: operational messages (format detection, mode selection)
- WARNING: potential issues (fallback KDF, format transcoding)
- ERROR: operation failures
STEGASOO_DEBUG=1 is a shorthand for STEGASOO_LOG_LEVEL=DEBUG
CLI: stegasoo --debug encode ... (sets DEBUG level for that invocation)
All output goes to Python's logging module under the 'stegasoo' logger hierarchy.
The legacy debug.print() API is preserved for backward compatibility.
""" """
import logging
import os
import sys import sys
import time import time
import traceback import traceback
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime
from functools import wraps from functools import wraps
from typing import Any from typing import Any
# Map string level names to logging levels
_LEVEL_MAP = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL,
}
# Root logger for the stegasoo package
logger = logging.getLogger("stegasoo")
# Global debug configuration # Global debug configuration
DEBUG_ENABLED = False # Set to True to enable debug output
LOG_PERFORMANCE = True # Log function timing LOG_PERFORMANCE = True # Log function timing
VALIDATION_ASSERTIONS = True # Enable runtime validation assertions VALIDATION_ASSERTIONS = True # Enable runtime validation assertions
def _configure_from_env() -> bool:
"""Configure logging from environment variables. Returns True if debug enabled."""
# STEGASOO_DEBUG=1 is shorthand for DEBUG level
if os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
_setup_logging(logging.DEBUG)
return True
level_str = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
if level_str and level_str in _LEVEL_MAP:
_setup_logging(_LEVEL_MAP[level_str])
return level_str == "DEBUG"
return False
def _setup_logging(level: int) -> None:
"""Configure the stegasoo logger with a stderr handler."""
logger.setLevel(level)
# Only add handler if none exist (avoid duplicates on re-init)
if not logger.handlers:
handler = logging.StreamHandler(sys.stderr)
handler.setLevel(level)
formatter = logging.Formatter(
"[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%H:%M:%S",
)
handler.setFormatter(formatter)
logger.addHandler(handler)
else:
# Update existing handler level
for handler in logger.handlers:
handler.setLevel(level)
# Auto-configure on import
DEBUG_ENABLED = _configure_from_env()
def enable_debug(enable: bool = True) -> None: def enable_debug(enable: bool = True) -> None:
"""Enable or disable debug mode globally.""" """Enable or disable debug mode globally."""
global DEBUG_ENABLED global DEBUG_ENABLED
DEBUG_ENABLED = enable DEBUG_ENABLED = enable
if enable:
_setup_logging(logging.DEBUG)
else:
logger.setLevel(logging.WARNING)
def enable_performance_logging(enable: bool = True) -> None: def enable_performance_logging(enable: bool = True) -> None:
@@ -38,15 +107,14 @@ def enable_assertions(enable: bool = True) -> None:
def debug_print(message: str, level: str = "INFO") -> None: def debug_print(message: str, level: str = "INFO") -> None:
"""Print debug message with timestamp if debugging is enabled.""" """Log a message at the given level via the stegasoo logger."""
if DEBUG_ENABLED: log_level = _LEVEL_MAP.get(level.upper(), logging.DEBUG)
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] logger.log(log_level, message)
print(f"[{timestamp}] [{level}] {message}", file=sys.stderr)
def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str: def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str:
"""Format bytes for debugging.""" """Format bytes for debugging."""
if not DEBUG_ENABLED: if not logger.isEnabledFor(logging.DEBUG):
return "" return ""
if not data: if not data:
@@ -55,15 +123,17 @@ def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str:
if len(data) <= max_bytes: if len(data) <= max_bytes:
return f"{label} ({len(data)} bytes): {data.hex()}" return f"{label} ({len(data)} bytes): {data.hex()}"
else: else:
return f"{label} ({len(data)} bytes): {data[:max_bytes//2].hex()}...{data[-max_bytes//2:].hex()}" return (
f"{label} ({len(data)} bytes): "
f"{data[:max_bytes // 2].hex()}...{data[-max_bytes // 2:].hex()}"
)
def debug_exception(e: Exception, context: str = "") -> None: def debug_exception(e: Exception, context: str = "") -> None:
"""Log exception with context for debugging.""" """Log exception with context for debugging."""
if DEBUG_ENABLED: logger.error("Exception in %s: %s: %s", context, type(e).__name__, e)
debug_print(f"Exception in {context}: {type(e).__name__}: {e}", "ERROR") if logger.isEnabledFor(logging.DEBUG):
if DEBUG_ENABLED: logger.debug(traceback.format_exc())
traceback.print_exc()
def time_function(func: Callable) -> Callable: def time_function(func: Callable) -> Callable:
@@ -71,7 +141,7 @@ def time_function(func: Callable) -> Callable:
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs) -> Any: def wrapper(*args, **kwargs) -> Any:
if not (DEBUG_ENABLED and LOG_PERFORMANCE): if not (logger.isEnabledFor(logging.DEBUG) and LOG_PERFORMANCE):
return func(*args, **kwargs) return func(*args, **kwargs)
start = time.perf_counter() start = time.perf_counter()
@@ -80,7 +150,7 @@ def time_function(func: Callable) -> Callable:
return result return result
finally: finally:
end = time.perf_counter() end = time.perf_counter()
debug_print(f"{func.__name__} took {end - start:.6f}s", "PERF") logger.debug("%s took %.6fs", func.__name__, end - start)
return wrapper return wrapper
@@ -94,8 +164,6 @@ def validate_assertion(condition: bool, message: str) -> None:
def memory_usage() -> dict[str, float | str]: def memory_usage() -> dict[str, float | str]:
"""Get current memory usage (if psutil is available).""" """Get current memory usage (if psutil is available)."""
try: try:
import os
import psutil import psutil
process = psutil.Process(os.getpid()) process = psutil.Process(os.getpid())
@@ -131,8 +199,19 @@ def hexdump(data: bytes, offset: int = 0, length: int = 64) -> str:
return "\n".join(result) return "\n".join(result)
def get_logger(name: str) -> logging.Logger:
"""Get a child logger under the stegasoo namespace.
Usage in modules:
from .debug import get_logger
logger = get_logger(__name__)
logger.debug("message")
"""
return logging.getLogger(name)
class Debug: class Debug:
"""Debugging utility class.""" """Debugging utility class (backward-compatible API)."""
def __init__(self): def __init__(self):
self.enabled = DEBUG_ENABLED self.enabled = DEBUG_ENABLED

View File

@@ -31,12 +31,15 @@ def _write_progress(progress_file: str | None, current: int, total: int, phase:
return return
try: try:
with open(progress_file, "w") as f: with open(progress_file, "w") as f:
json.dump({ json.dump(
{
"current": current, "current": current,
"total": total, "total": total,
"percent": (current / total * 100) if total > 0 else 0, "percent": (current / total * 100) if total > 0 else 0,
"phase": phase, "phase": phase,
}, f) },
f,
)
except OSError: except OSError:
pass pass
@@ -291,16 +294,23 @@ def decode_audio(
Returns: Returns:
DecodeResult with message or file data DecodeResult with message or file data
""" """
from .audio_utils import detect_audio_format, transcode_to_wav
from .constants import ( from .constants import (
AUDIO_ENABLED,
EMBED_MODE_AUDIO_AUTO, EMBED_MODE_AUDIO_AUTO,
EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_LSB,
EMBED_MODE_AUDIO_SPREAD, 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( debug.print(
f"decode_audio: mode={embed_mode}, " f"decode_audio: mode={embed_mode}, " f"passphrase length={len(passphrase.split())} words"
f"passphrase length={len(passphrase.split())} words"
) )
# Validate inputs # Validate inputs
@@ -358,9 +368,7 @@ def decode_audio(
elif embed_mode == EMBED_MODE_AUDIO_SPREAD: elif embed_mode == EMBED_MODE_AUDIO_SPREAD:
from .spread_steganography import extract_from_audio_spread from .spread_steganography import extract_from_audio_spread
encrypted = extract_from_audio_spread( encrypted = extract_from_audio_spread(wav_audio, pixel_key, progress_file=progress_file)
wav_audio, pixel_key, progress_file=progress_file
)
else: else:
raise ValueError(f"Invalid audio embed mode: {embed_mode}") raise ValueError(f"Invalid audio embed mode: {embed_mode}")

View File

@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
from .constants import EMBED_MODE_LSB from .constants import EMBED_MODE_LSB
from .crypto import derive_pixel_key, encrypt_message from .crypto import derive_pixel_key, encrypt_message
from .debug import debug from .debug import debug
from .exceptions import AudioError
from .models import EncodeResult, FilePayload from .models import EncodeResult, FilePayload
from .steganography import embed_in_image from .steganography import embed_in_image
from .utils import generate_filename from .utils import generate_filename
@@ -280,6 +281,7 @@ def encode_audio(
embed_mode: str = "audio_lsb", embed_mode: str = "audio_lsb",
channel_key: str | bool | None = None, channel_key: str | bool | None = None,
progress_file: str | None = None, progress_file: str | None = None,
chip_tier: int | None = None,
) -> tuple[bytes, AudioEmbedStats]: ) -> tuple[bytes, AudioEmbedStats]:
""" """
Encode a message or file into an audio carrier. Encode a message or file into an audio carrier.
@@ -295,12 +297,21 @@ def encode_audio(
embed_mode: 'audio_lsb' or 'audio_spread' embed_mode: 'audio_lsb' or 'audio_spread'
channel_key: Channel key for deployment/group isolation channel_key: Channel key for deployment/group isolation
progress_file: Optional path to write progress JSON 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: Returns:
Tuple of (stego audio bytes, AudioEmbedStats) 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 .audio_utils import detect_audio_format, transcode_to_wav
from .constants import EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_SPREAD
debug.print( debug.print(
f"encode_audio: mode={embed_mode}, " f"encode_audio: mode={embed_mode}, "
@@ -343,10 +354,12 @@ def encode_audio(
encrypted, carrier_audio, pixel_key, progress_file=progress_file encrypted, carrier_audio, pixel_key, progress_file=progress_file
) )
elif embed_mode == EMBED_MODE_AUDIO_SPREAD: elif embed_mode == EMBED_MODE_AUDIO_SPREAD:
from .constants import AUDIO_SS_DEFAULT_CHIP_TIER
from .spread_steganography import embed_in_audio_spread 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( 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: else:
raise ValueError(f"Invalid audio embed mode: {embed_mode}") raise ValueError(f"Invalid audio embed mode: {embed_mode}")

View File

@@ -300,6 +300,9 @@ class AudioEmbedStats:
channels: int channels: int
duration_seconds: float duration_seconds: float
embed_mode: str # "audio_lsb" or "audio_spread" 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 @property
def modification_percent(self) -> float: def modification_percent(self) -> float:
@@ -329,3 +332,7 @@ class AudioCapacityInfo:
embed_mode: str embed_mode: str
sample_rate: int sample_rate: int
duration_seconds: float 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

View File

@@ -105,14 +105,14 @@ def decompress_data(data: str) -> str:
"Data compressed with zstd but zstandard package not installed. " "Data compressed with zstd but zstandard package not installed. "
"Run: pip install zstandard" "Run: pip install zstandard"
) )
encoded = data[len(COMPRESSION_PREFIX_ZSTD):] encoded = data[len(COMPRESSION_PREFIX_ZSTD) :]
compressed = base64.b64decode(encoded) compressed = base64.b64decode(encoded)
dctx = zstd.ZstdDecompressor() dctx = zstd.ZstdDecompressor()
return dctx.decompress(compressed).decode("utf-8") return dctx.decompress(compressed).decode("utf-8")
elif data.startswith(COMPRESSION_PREFIX_ZLIB): elif data.startswith(COMPRESSION_PREFIX_ZLIB):
# Legacy zlib compression # Legacy zlib compression
encoded = data[len(COMPRESSION_PREFIX_ZLIB):] encoded = data[len(COMPRESSION_PREFIX_ZLIB) :]
compressed = base64.b64decode(encoded) compressed = base64.b64decode(encoded)
return zlib.decompress(compressed).decode("utf-8") return zlib.decompress(compressed).decode("utf-8")

View File

@@ -182,6 +182,7 @@ def extract_stego_backup(
debug.print(f"Stego backup extraction failed: {e}") debug.print(f"Stego backup extraction failed: {e}")
return None return None
# Recovery key format: same as channel key (32 chars, 8 groups of 4) # Recovery key format: same as channel key (32 chars, 8 groups of 4)
RECOVERY_KEY_LENGTH = 32 RECOVERY_KEY_LENGTH = 32
RECOVERY_KEY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" RECOVERY_KEY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
@@ -205,16 +206,10 @@ def generate_recovery_key() -> str:
7 7
""" """
# Generate 32 random alphanumeric characters # Generate 32 random alphanumeric characters
raw_key = "".join( raw_key = "".join(secrets.choice(RECOVERY_KEY_ALPHABET) for _ in range(RECOVERY_KEY_LENGTH))
secrets.choice(RECOVERY_KEY_ALPHABET)
for _ in range(RECOVERY_KEY_LENGTH)
)
# Format with dashes every 4 characters # Format with dashes every 4 characters
formatted = "-".join( formatted = "-".join(raw_key[i : i + 4] for i in range(0, RECOVERY_KEY_LENGTH, 4))
raw_key[i:i + 4]
for i in range(0, RECOVERY_KEY_LENGTH, 4)
)
debug.print(f"Generated recovery key: {formatted[:4]}-••••-...-{formatted[-4:]}") debug.print(f"Generated recovery key: {formatted[:4]}-••••-...-{formatted[-4:]}")
return formatted return formatted
@@ -245,15 +240,12 @@ def normalize_recovery_key(key: str) -> str:
# Validate length # Validate length
if len(clean) != RECOVERY_KEY_LENGTH: if len(clean) != RECOVERY_KEY_LENGTH:
raise ValueError( raise ValueError(
f"Recovery key must be {RECOVERY_KEY_LENGTH} characters " f"Recovery key must be {RECOVERY_KEY_LENGTH} characters " f"(got {len(clean)})"
f"(got {len(clean)})"
) )
# Validate characters # Validate characters
if not all(c in RECOVERY_KEY_ALPHABET for c in clean): if not all(c in RECOVERY_KEY_ALPHABET for c in clean):
raise ValueError( raise ValueError("Recovery key must contain only letters A-Z and digits 0-9")
"Recovery key must contain only letters A-Z and digits 0-9"
)
return clean return clean
@@ -273,7 +265,7 @@ def format_recovery_key(key: str) -> str:
"ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456" "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
""" """
clean = normalize_recovery_key(key) 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: def hash_recovery_key(key: str) -> str:

File diff suppressed because it is too large Load Diff

View File

@@ -54,8 +54,7 @@ def read_image_exif(image_data: bytes) -> dict:
gps[gps_tag] = float(gps_value) gps[gps_tag] = float(gps_value)
elif isinstance(gps_value, tuple): elif isinstance(gps_value, tuple):
gps[gps_tag] = [ gps[gps_tag] = [
float(v) if hasattr(v, "numerator") else v float(v) if hasattr(v, "numerator") else v for v in gps_value
for v in gps_value
] ]
else: else:
gps[gps_tag] = gps_value 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 # Try to decode as ASCII/UTF-8 text
decoded = value.decode("utf-8", errors="strict").strip("\x00") decoded = value.decode("utf-8", errors="strict").strip("\x00")
# Only keep if it looks like printable text # 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 result[tag] = decoded
else: else:
result[tag] = f"<{len(value)} bytes binary>" result[tag] = f"<{len(value)} bytes binary>"

View File

@@ -13,6 +13,10 @@ import io
from PIL import Image from PIL import Image
from .debug import get_logger
logger = get_logger(__name__)
from .constants import ( from .constants import (
ALLOWED_AUDIO_EXTENSIONS, ALLOWED_AUDIO_EXTENSIONS,
ALLOWED_IMAGE_EXTENSIONS, ALLOWED_IMAGE_EXTENSIONS,

View File

@@ -3,25 +3,37 @@ Tests for Stegasoo audio steganography.
Tests cover: Tests cover:
- Audio LSB roundtrip (encode + decode) - Audio LSB roundtrip (encode + decode)
- Audio MDCT roundtrip (encode + decode) - Audio spread spectrum roundtrip (v0 legacy + v2 per-channel)
- Wrong credentials fail to decode - Wrong credentials fail to decode
- Capacity calculations - Capacity calculations (per-tier)
- Format detection - Format detection
- Audio validation - 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 import io
from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
import soundfile as sf import soundfile as sf
from stegasoo.constants import ( from stegasoo.constants import AUDIO_ENABLED, EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_SPREAD
EMBED_MODE_AUDIO_LSB,
EMBED_MODE_AUDIO_SPREAD,
)
from stegasoo.models import AudioCapacityInfo, AudioEmbedStats, AudioInfo 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 # FIXTURES
# ============================================================================= # =============================================================================
@@ -33,7 +45,6 @@ def carrier_wav() -> bytes:
sample_rate = 44100 sample_rate = 44100
duration = 1.0 duration = 1.0
num_samples = int(sample_rate * duration) num_samples = int(sample_rate * duration)
# Generate a simple sine wave
t = np.linspace(0, duration, num_samples, endpoint=False) t = np.linspace(0, duration, num_samples, endpoint=False)
samples = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16) samples = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16)
@@ -45,9 +56,9 @@ def carrier_wav() -> bytes:
@pytest.fixture @pytest.fixture
def carrier_wav_stereo() -> bytes: 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 sample_rate = 44100
duration = 1.0 duration = 5.0
num_samples = int(sample_rate * duration) num_samples = int(sample_rate * duration)
t = np.linspace(0, duration, num_samples, endpoint=False) t = np.linspace(0, duration, num_samples, endpoint=False)
left = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16) left = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16)
@@ -67,7 +78,6 @@ def carrier_wav_long() -> bytes:
duration = 15.0 duration = 15.0
num_samples = int(sample_rate * duration) num_samples = int(sample_rate * duration)
t = np.linspace(0, duration, num_samples, endpoint=False) t = np.linspace(0, duration, num_samples, endpoint=False)
# Mix of frequencies for better MDCT embedding
samples = ( samples = (
(np.sin(2 * np.pi * 440 * t) + np.sin(2 * np.pi * 880 * t) + np.sin(2 * np.pi * 1320 * t)) (np.sin(2 * np.pi * 440 * t) + np.sin(2 * np.pi * 880 * t) + np.sin(2 * np.pi * 1320 * t))
* 5000 * 5000
@@ -80,12 +90,47 @@ def carrier_wav_long() -> bytes:
@pytest.fixture @pytest.fixture
def carrier_wav_spread_integration() -> bytes: def carrier_wav_stereo_long() -> bytes:
"""Generate a very long WAV (150 seconds) for spread spectrum integration tests. """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), buf = io.BytesIO()
we need at least 690*8*1024 = 5.7M samples ~ 130 seconds at 44.1kHz. 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 sample_rate = 44100
duration = 150.0 duration = 150.0
num_samples = int(sample_rate * duration) num_samples = int(sample_rate * duration)
@@ -103,7 +148,9 @@ def carrier_wav_spread_integration() -> bytes:
@pytest.fixture @pytest.fixture
def reference_photo() -> bytes: 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 from PIL import Image
img = Image.new("RGB", (100, 100), color=(128, 64, 32)) img = Image.new("RGB", (100, 100), color=(128, 64, 32))
@@ -113,6 +160,14 @@ def reference_photo() -> bytes:
return buf.read() 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 # AUDIO LSB TESTS
# ============================================================================= # =============================================================================
@@ -134,7 +189,6 @@ class TestAudioLSB:
from stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb from stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb
payload = b"Hello, audio steganography!" payload = b"Hello, audio steganography!"
# Prepend with magic header to simulate real usage pattern
key = b"\x42" * 32 key = b"\x42" * 32
stego_audio, stats = embed_in_audio_lsb(payload, carrier_wav, key) stego_audio, stats = embed_in_audio_lsb(payload, carrier_wav, key)
@@ -145,7 +199,6 @@ class TestAudioLSB:
assert stats.samples_modified > 0 assert stats.samples_modified > 0
assert 0 < stats.capacity_used <= 1.0 assert 0 < stats.capacity_used <= 1.0
# Extract
extracted = extract_from_audio_lsb(stego_audio, key) extracted = extract_from_audio_lsb(stego_audio, key)
assert extracted is not None assert extracted is not None
assert extracted == payload assert extracted == payload
@@ -174,7 +227,6 @@ class TestAudioLSB:
stego_audio, _ = embed_in_audio_lsb(payload, carrier_wav, correct_key) stego_audio, _ = embed_in_audio_lsb(payload, carrier_wav, correct_key)
extracted = extract_from_audio_lsb(stego_audio, wrong_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 assert extracted is None or extracted != payload
def test_two_bits_per_sample(self, carrier_wav): def test_two_bits_per_sample(self, carrier_wav):
@@ -197,46 +249,97 @@ class TestAudioLSB:
indices1 = generate_sample_indices(key, 10000, 100) indices1 = generate_sample_indices(key, 10000, 100)
indices2 = generate_sample_indices(key, 10000, 100) indices2 = generate_sample_indices(key, 10000, 100)
# Same key should produce same indices
assert indices1 == indices2 assert indices1 == indices2
# All indices should be valid
assert all(0 <= i < 10000 for i in indices1) assert all(0 <= i < 10000 for i in indices1)
# No duplicates
assert len(set(indices1)) == len(indices1) assert len(set(indices1)) == len(indices1)
# ============================================================================= # =============================================================================
# AUDIO SPREAD SPECTRUM TESTS # AUDIO SPREAD SPECTRUM TESTS (v2 per-channel)
# ============================================================================= # =============================================================================
class TestAudioSpread: 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 from stegasoo.spread_steganography import calculate_audio_spread_capacity
capacity = calculate_audio_spread_capacity(carrier_wav_long) capacity = calculate_audio_spread_capacity(carrier_wav_long)
assert isinstance(capacity, AudioCapacityInfo) assert isinstance(capacity, AudioCapacityInfo)
assert capacity.usable_capacity_bytes > 0 assert capacity.usable_capacity_bytes > 0
assert capacity.embed_mode == EMBED_MODE_AUDIO_SPREAD 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): def test_calculate_capacity_per_tier(self, carrier_wav_long):
"""Test spread spectrum embed/extract roundtrip.""" """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 ( from stegasoo.spread_steganography import (
embed_in_audio_spread, embed_in_audio_spread,
extract_from_audio_spread, extract_from_audio_spread,
) )
payload = b"Spread test" payload = b"Spread test v2"
seed = b"\x42" * 32 seed = b"\x42" * 32
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed) stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed)
assert isinstance(stats, AudioEmbedStats) assert isinstance(stats, AudioEmbedStats)
assert stats.embed_mode == EMBED_MODE_AUDIO_SPREAD 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) extracted = extract_from_audio_spread(stego_audio, seed)
assert extracted is not None assert extracted is not None
@@ -258,6 +361,258 @@ class TestAudioSpread:
extracted = extract_from_audio_spread(stego_audio, wrong_seed) extracted = extract_from_audio_spread(stego_audio, wrong_seed)
assert extracted is None or extracted != payload 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 # FORMAT DETECTION TESTS
@@ -423,6 +778,36 @@ class TestIntegration:
assert result.message == "Spread integration test" 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): def test_auto_detect_lsb(self, carrier_wav, reference_photo):
"""Test auto-detection finds LSB encoded audio.""" """Test auto-detection finds LSB encoded audio."""
from stegasoo.decode import decode_audio from stegasoo.decode import decode_audio
@@ -446,3 +831,32 @@ class TestIntegration:
) )
assert result.message == "Auto-detect test" 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