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

View File

@@ -1,7 +1,7 @@
# Stegasoo — Claude Code Project Guide
Stegasoo is a secure steganography toolkit with hybrid photo + passphrase + PIN authentication.
Version 4.2.1 · Python >=3.11 · MIT License
Version 4.3.0 · Python >=3.11 · MIT License
## Quick commands
@@ -27,6 +27,10 @@ src/stegasoo/ Core library
models.py Dataclasses (EncodeResult, DecodeResult, etc.)
encode.py / decode.py High-level encode/decode orchestration
channel.py Channel key management (v4.0+)
audio_steganography.py LSB audio embedding/extraction (v4.3.0)
spread_steganography.py Spread spectrum audio embedding (v4.3.0)
audio_utils.py Audio format detection, validation, transcoding (v4.3.0)
debug.py Structured logging for operations (v4.3.0)
compression.py Zstandard / zlib / lz4 payload compression
cli.py Click CLI entry point
generate.py Credential generation (passphrase, PIN, RSA keys)

View File

@@ -1,6 +1,6 @@
# Stegasoo
A secure steganography system for hiding encrypted messages in images using hybrid authentication.
A secure steganography system for hiding encrypted messages in images and audio using hybrid authentication.
[![Tests](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml/badge.svg)](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml)
[![Lint](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml/badge.svg)](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml)
@@ -17,15 +17,25 @@ A secure steganography system for hiding encrypted messages in images using hybr
- **Multiple interfaces**: CLI, Web UI, REST API
- **File embedding**: Hide any file type (PDF, ZIP, documents)
- **DCT steganography**: JPEG-resilient embedding for social media
- **Audio steganography**: Hide messages in WAV, FLAC, MP3, OGG, AAC, M4A files (LSB and Spread Spectrum modes)
- **Channel keys**: Private group communication channels
## Embedding Modes
### Image Modes
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|------|------------------|----------------|----------|
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
| **LSB** | ~750 KB | No | Email, direct file transfer |
### Audio Modes
| Mode | Capacity (5 min WAV) | Noise Resistant | Best For |
|------|---------------------|-----------------|----------|
| **LSB** | ~1.3 MB | No | Direct file transfer |
| **Spread Spectrum** | ~160 KB | Yes | Shared files, light processing |
## Web UI
| Home | Encode | Decode | Generate |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,15 @@
#!/usr/bin/env python3
"""
Stegasoo REST API (v4.2.1)
Stegasoo REST API (v4.3.0)
FastAPI-based REST API for steganography operations.
Supports both text messages and file embedding.
CHANGES in v4.3.0:
- Audio steganography endpoints (/audio/*)
- LSB and spread spectrum (DSSS) audio embedding modes
- Audio info and capacity checking
CHANGES in v4.2.1:
- API key authentication (X-API-Key header)
- TLS support with self-signed certificates
@@ -32,11 +37,31 @@ NEW in v3.0.1: DCT color mode and JPEG output format.
import asyncio
import base64
import logging
import os
import sys
from functools import partial
from pathlib import Path
from typing import Literal
# Configure logging for API frontend
_log_level = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
if _log_level and hasattr(logging, _log_level):
logging.basicConfig(
level=getattr(logging, _log_level),
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%H:%M:%S",
stream=sys.stderr,
)
elif os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
logging.basicConfig(
level=logging.DEBUG,
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%H:%M:%S",
stream=sys.stderr,
)
api_logger = logging.getLogger("stegasoo.api")
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field
@@ -44,28 +69,28 @@ from pydantic import BaseModel, Field
# API Key Authentication
try:
from .auth import (
require_api_key,
get_api_key_status,
add_api_key,
remove_api_key,
list_api_keys,
get_api_key_status,
is_auth_enabled,
list_api_keys,
remove_api_key,
require_api_key,
)
except ImportError:
# When running directly (not as package)
from auth import (
require_api_key,
get_api_key_status,
add_api_key,
remove_api_key,
get_api_key_status,
list_api_keys,
is_auth_enabled,
remove_api_key,
require_api_key,
)
# Add parent to path for development
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from stegasoo import (
HAS_AUDIO_SUPPORT,
MAX_FILE_PAYLOAD_SIZE,
CapacityError,
DecryptionError,
@@ -87,6 +112,12 @@ from stegasoo import (
validate_image,
will_fit_by_mode,
)
# Audio steganography (v4.3.0) - conditionally imported
if HAS_AUDIO_SUPPORT:
from stegasoo import decode_audio, encode_audio, get_audio_info
from stegasoo.audio_steganography import calculate_audio_lsb_capacity
from stegasoo.spread_steganography import calculate_audio_spread_capacity
from stegasoo.constants import (
DEFAULT_PASSPHRASE_WORDS,
MAX_PASSPHRASE_WORDS,
@@ -163,6 +194,8 @@ EmbedModeType = Literal["lsb", "dct"]
ExtractModeType = Literal["auto", "lsb", "dct"]
DctColorModeType = Literal["grayscale", "color"]
DctOutputFormatType = Literal["png", "jpeg"]
AudioEmbedModeType = Literal["audio_lsb", "audio_spread"]
AudioExtractModeType = Literal["audio_auto", "audio_lsb", "audio_spread"]
# ============================================================================
@@ -405,6 +438,7 @@ class ModesResponse(BaseModel):
lsb: dict
dct: DctModeInfo
audio: dict | None = Field(default=None, description="Audio steganography modes (v4.3.0)")
# Channel key status (v4.0.0)
channel: dict | None = Field(default=None, description="Channel key status (v4.0.0)")
@@ -415,6 +449,7 @@ class StatusResponse(BaseModel):
has_qrcode_read: bool
has_qrcode_write: bool # v4.2.0: QR generation capability
has_dct: bool
has_audio: bool = Field(default=False, description="Audio steganography support (v4.3.0)")
max_payload_kb: int
available_modes: list[str]
dct_features: dict | None = Field(default=None, description="DCT mode features (v3.0.1+)")
@@ -479,6 +514,124 @@ class ErrorResponse(BaseModel):
detail: str | None = None
# --- Audio models (v4.3.0) ---
class AudioEncodeRequest(BaseModel):
"""Request to encode a text message into audio."""
message: str
reference_photo_base64: str
carrier_audio_base64: str
passphrase: str = Field(description="Passphrase for key derivation")
pin: str = ""
rsa_key_base64: str | None = None
rsa_password: str | None = None
channel_key: str | None = Field(
default=None,
description="Channel key for deployment isolation. null=auto, ''=public, 'XXXX-...'=explicit",
)
embed_mode: AudioEmbedModeType = Field(
default="audio_lsb",
description="Embedding mode: 'audio_lsb' (default) or 'audio_spread' (DSSS)",
)
chip_tier: int | None = Field(
default=None,
description="Spread spectrum chip tier: 0=lossless(256), 1=high_lossy(512), 2=low_lossy(1024). Only for audio_spread.",
)
class AudioEncodeFileRequest(BaseModel):
"""Request to encode a file into audio."""
file_data_base64: str
filename: str
mime_type: str | None = None
reference_photo_base64: str
carrier_audio_base64: str
passphrase: str = Field(description="Passphrase for key derivation")
pin: str = ""
rsa_key_base64: str | None = None
rsa_password: str | None = None
channel_key: str | None = Field(
default=None,
description="Channel key for deployment isolation. null=auto, ''=public, 'XXXX-...'=explicit",
)
embed_mode: AudioEmbedModeType = Field(
default="audio_lsb",
description="Embedding mode: 'audio_lsb' (default) or 'audio_spread' (DSSS)",
)
chip_tier: int | None = Field(
default=None,
description="Spread spectrum chip tier: 0=lossless(256), 1=high_lossy(512), 2=low_lossy(1024). Only for audio_spread.",
)
class AudioEncodeResponse(BaseModel):
"""Response from audio encode operations."""
stego_audio_base64: str
embed_mode: str = Field(description="Embedding mode used: 'audio_lsb' or 'audio_spread'")
stats: dict = Field(description="Embedding statistics (samples_modified, capacity_used, etc.)")
channel_mode: str = Field(default="public", description="Channel mode: 'public' or 'private'")
channel_fingerprint: str | None = Field(
default=None, description="Channel key fingerprint (if private mode)"
)
class AudioDecodeRequest(BaseModel):
"""Request to decode a message or file from stego audio."""
stego_audio_base64: str
reference_photo_base64: str
passphrase: str = Field(description="Passphrase for key derivation")
pin: str = ""
rsa_key_base64: str | None = None
rsa_password: str | None = None
channel_key: str | None = Field(
default=None,
description="Channel key for decryption. null=auto, ''=public, 'XXXX-...'=explicit",
)
embed_mode: AudioExtractModeType = Field(
default="audio_auto",
description="Extraction mode: 'audio_auto' (default), 'audio_lsb', or 'audio_spread'",
)
class AudioInfoResponse(BaseModel):
"""Response with audio file metadata and capacity info."""
sample_rate: int
channels: int
duration_seconds: float
num_samples: int
format: str
bit_depth: int | None = None
bitrate: int | None = None
capacity_lsb: int = Field(description="LSB mode capacity in bytes")
capacity_spread: int = Field(description="Spread spectrum mode capacity in bytes")
class AudioCapacityRequest(BaseModel):
"""Request to check if a payload fits in audio carrier."""
carrier_audio_base64: str
payload_size: int = Field(ge=1, description="Payload size in bytes")
embed_mode: AudioEmbedModeType = Field(
default="audio_lsb", description="Embedding mode to check capacity for"
)
class AudioCapacityResponse(BaseModel):
"""Response for audio capacity check."""
fits: bool
payload_size: int
capacity_bytes: int
usage_percent: float
embed_mode: str
# ============================================================================
# HELPER: RESOLVE CHANNEL KEY
# ============================================================================
@@ -569,12 +722,18 @@ async def root():
"source": channel_status.get("source"),
}
# Audio modes (v4.3.0)
if HAS_AUDIO_SUPPORT:
available_modes.append("audio_lsb")
available_modes.append("audio_spread")
return StatusResponse(
version=__version__,
has_argon2=has_argon2(),
has_qrcode_read=HAS_QR_READ,
has_qrcode_write=HAS_QR_WRITE,
has_dct=has_dct_support(),
has_audio=HAS_AUDIO_SUPPORT,
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
available_modes=available_modes,
dct_features=dct_features,
@@ -606,6 +765,28 @@ async def api_modes():
"fingerprint": channel_status.get("fingerprint"),
}
# Audio modes (v4.3.0)
audio_info = None
if HAS_AUDIO_SUPPORT:
audio_info = {
"available": True,
"modes": {
"audio_lsb": {
"name": "Audio LSB",
"description": "Embed in audio sample LSBs, high capacity",
"output_format": "WAV",
},
"audio_spread": {
"name": "Spread Spectrum (DSSS)",
"description": "Direct-sequence spread spectrum with Reed-Solomon ECC, better stealth",
"output_format": "WAV",
},
},
"supported_formats": ["WAV", "FLAC", "MP3", "OGG", "AAC", "M4A"],
"output_format": "WAV",
"requires": "soundfile",
}
return ModesResponse(
lsb={
"available": True,
@@ -623,6 +804,7 @@ async def api_modes():
capacity_ratio="~20% of LSB",
requires="scipy",
),
audio=audio_info,
channel=channel_info,
)
@@ -723,7 +905,7 @@ async def api_channel_set(request: ChannelSetRequest, _: str = Depends(require_a
@app.delete("/channel")
async def api_channel_clear(
_: str = Depends(require_api_key),
location: str = Query("user", description="'user', 'project', or 'all'")
location: str = Query("user", description="'user', 'project', or 'all'"),
):
"""
Clear/remove channel key from config.
@@ -935,7 +1117,7 @@ async def api_will_fit(request: WillFitRequest, _: str = Depends(require_api_key
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
async def api_extract_key_from_qr(
_: str = Depends(require_api_key),
qr_image: UploadFile = File(..., description="QR code image containing RSA key")
qr_image: UploadFile = File(..., description="QR code image containing RSA key"),
):
"""
Extract RSA key from a QR code image.
@@ -1607,6 +1789,454 @@ async def api_image_info(
raise HTTPException(500, str(e))
# ============================================================================
# ROUTES - AUDIO STEGANOGRAPHY (v4.3.0)
# ============================================================================
def _require_audio():
"""Check that audio support is available, raise 501 if not."""
if not HAS_AUDIO_SUPPORT:
raise HTTPException(
501, "Audio steganography not available. Install with: pip install stegasoo[audio]"
)
@app.post("/audio/encode", response_model=AudioEncodeResponse)
async def api_audio_encode(request: AudioEncodeRequest, _: str = Depends(require_api_key)):
"""
Encode a text message into audio.
Audio must be base64-encoded. Returns base64-encoded stego WAV.
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
resolved_channel_key = _resolve_channel_key(request.channel_key)
try:
ref_photo = base64.b64decode(request.reference_photo_base64)
carrier = base64.b64decode(request.carrier_audio_base64)
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
stego_audio, stats = await run_in_thread(
encode_audio,
message=request.message,
reference_photo=ref_photo,
carrier_audio=carrier,
passphrase=request.passphrase,
pin=request.pin,
rsa_key_data=rsa_key,
rsa_password=request.rsa_password,
embed_mode=request.embed_mode,
channel_key=resolved_channel_key,
chip_tier=request.chip_tier,
)
stego_b64 = base64.b64encode(stego_audio).decode("utf-8")
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
return AudioEncodeResponse(
stego_audio_base64=stego_b64,
embed_mode=stats.embed_mode,
stats={
"samples_modified": stats.samples_modified,
"total_samples": stats.total_samples,
"capacity_used": round(stats.capacity_used * 100, 1),
"bytes_embedded": stats.bytes_embedded,
"sample_rate": stats.sample_rate,
"channels": stats.channels,
"duration_seconds": round(stats.duration_seconds, 2),
},
channel_mode=channel_mode,
channel_fingerprint=channel_fingerprint,
)
except CapacityError as e:
raise HTTPException(400, str(e))
except StegasooError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/audio/encode/file", response_model=AudioEncodeResponse)
async def api_audio_encode_file(request: AudioEncodeFileRequest, _: str = Depends(require_api_key)):
"""
Encode a file into audio (JSON with base64).
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
resolved_channel_key = _resolve_channel_key(request.channel_key)
try:
file_data = base64.b64decode(request.file_data_base64)
ref_photo = base64.b64decode(request.reference_photo_base64)
carrier = base64.b64decode(request.carrier_audio_base64)
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
payload = FilePayload(
data=file_data, filename=request.filename, mime_type=request.mime_type
)
stego_audio, stats = await run_in_thread(
encode_audio,
message=payload,
reference_photo=ref_photo,
carrier_audio=carrier,
passphrase=request.passphrase,
pin=request.pin,
rsa_key_data=rsa_key,
rsa_password=request.rsa_password,
embed_mode=request.embed_mode,
channel_key=resolved_channel_key,
chip_tier=request.chip_tier,
)
stego_b64 = base64.b64encode(stego_audio).decode("utf-8")
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
return AudioEncodeResponse(
stego_audio_base64=stego_b64,
embed_mode=stats.embed_mode,
stats={
"samples_modified": stats.samples_modified,
"total_samples": stats.total_samples,
"capacity_used": round(stats.capacity_used * 100, 1),
"bytes_embedded": stats.bytes_embedded,
"sample_rate": stats.sample_rate,
"channels": stats.channels,
"duration_seconds": round(stats.duration_seconds, 2),
},
channel_mode=channel_mode,
channel_fingerprint=channel_fingerprint,
)
except CapacityError as e:
raise HTTPException(400, str(e))
except StegasooError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/audio/encode/multipart")
async def api_audio_encode_multipart(
_: str = Depends(require_api_key),
passphrase: str = Form(..., description="Passphrase for key derivation"),
reference_photo: UploadFile = File(...),
carrier: UploadFile = File(...),
message: str = Form(""),
payload_file: UploadFile | None = File(None),
pin: str = Form(""),
rsa_key: UploadFile | None = File(None),
rsa_password: str = Form(""),
channel_key: str = Form(
"auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"
),
embed_mode: str = Form("audio_lsb"),
chip_tier: int | None = Form(
None,
description="Spread spectrum chip tier: 0=lossless, 1=high_lossy, 2=low_lossy. Only for audio_spread.",
),
):
"""
Encode audio using multipart form data (file uploads).
Provide either 'message' (text) or 'payload_file' (binary file).
Returns the stego WAV directly with metadata headers.
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
if embed_mode not in ("audio_lsb", "audio_spread"):
raise HTTPException(400, "embed_mode must be 'audio_lsb' or 'audio_spread'")
# Resolve channel key
if channel_key.lower() == "auto":
resolved_channel_key = None
elif channel_key.lower() == "none":
resolved_channel_key = ""
else:
resolved_channel_key = _resolve_channel_key(channel_key)
try:
ref_data = await reference_photo.read()
carrier_data = await carrier.read()
rsa_key_data = None
if rsa_key and rsa_key.filename:
rsa_key_data = await rsa_key.read()
effective_password = rsa_password if rsa_password else None
# Determine payload
if payload_file and payload_file.filename:
file_data = await payload_file.read()
payload = FilePayload(
data=file_data, filename=payload_file.filename, mime_type=payload_file.content_type
)
elif message:
payload = message
else:
raise HTTPException(400, "Must provide either 'message' or 'payload_file'")
stego_audio, stats = await run_in_thread(
encode_audio,
message=payload,
reference_photo=ref_data,
carrier_audio=carrier_data,
passphrase=passphrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=effective_password,
embed_mode=embed_mode,
channel_key=resolved_channel_key,
chip_tier=chip_tier,
)
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
headers = {
"Content-Disposition": "attachment; filename=stego_audio.wav",
"X-Stegasoo-Embed-Mode": stats.embed_mode,
"X-Stegasoo-Capacity-Percent": f"{stats.capacity_used * 100:.1f}",
"X-Stegasoo-Samples-Modified": str(stats.samples_modified),
"X-Stegasoo-Duration": f"{stats.duration_seconds:.2f}",
"X-Stegasoo-Channel-Mode": channel_mode,
"X-Stegasoo-Version": __version__,
}
if channel_fingerprint:
headers["X-Stegasoo-Channel-Fingerprint"] = channel_fingerprint
return Response(
content=stego_audio,
media_type="audio/wav",
headers=headers,
)
except CapacityError as e:
raise HTTPException(400, str(e))
except StegasooError as e:
raise HTTPException(400, str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/audio/decode", response_model=DecodeResponse)
async def api_audio_decode(request: AudioDecodeRequest, _: str = Depends(require_api_key)):
"""
Decode a message or file from stego audio.
Returns payload_type to indicate if result is text or file.
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
resolved_channel_key = _resolve_channel_key(request.channel_key)
try:
stego = base64.b64decode(request.stego_audio_base64)
ref_photo = base64.b64decode(request.reference_photo_base64)
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
result = await run_in_thread(
decode_audio,
stego_audio=stego,
reference_photo=ref_photo,
passphrase=request.passphrase,
pin=request.pin,
rsa_key_data=rsa_key,
rsa_password=request.rsa_password,
embed_mode=request.embed_mode,
channel_key=resolved_channel_key,
)
if result.is_file:
return DecodeResponse(
payload_type="file",
file_data_base64=base64.b64encode(result.file_data).decode("utf-8"),
filename=result.filename,
mime_type=result.mime_type,
)
else:
return DecodeResponse(payload_type="text", message=result.message)
except DecryptionError as e:
error_msg = str(e)
if "channel key" in error_msg.lower():
raise HTTPException(401, error_msg)
raise HTTPException(401, "Decryption failed. Check credentials.")
except StegasooError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/audio/decode/multipart", response_model=DecodeResponse)
async def api_audio_decode_multipart(
_: str = Depends(require_api_key),
passphrase: str = Form(..., description="Passphrase for key derivation"),
reference_photo: UploadFile = File(...),
stego_audio: UploadFile = File(...),
pin: str = Form(""),
rsa_key: UploadFile | None = File(None),
rsa_password: str = Form(""),
channel_key: str = Form(
"auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"
),
embed_mode: str = Form("audio_auto"),
):
"""
Decode audio using multipart form data (file uploads).
Returns JSON with payload_type indicating text or file.
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
if embed_mode not in ("audio_auto", "audio_lsb", "audio_spread"):
raise HTTPException(400, "embed_mode must be 'audio_auto', 'audio_lsb', or 'audio_spread'")
# Resolve channel key
if channel_key.lower() == "auto":
resolved_channel_key = None
elif channel_key.lower() == "none":
resolved_channel_key = ""
else:
resolved_channel_key = _resolve_channel_key(channel_key)
try:
ref_data = await reference_photo.read()
stego_data = await stego_audio.read()
rsa_key_data = None
if rsa_key and rsa_key.filename:
rsa_key_data = await rsa_key.read()
effective_password = rsa_password if rsa_password else None
result = await run_in_thread(
decode_audio,
stego_audio=stego_data,
reference_photo=ref_data,
passphrase=passphrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=effective_password,
embed_mode=embed_mode,
channel_key=resolved_channel_key,
)
if result.is_file:
return DecodeResponse(
payload_type="file",
file_data_base64=base64.b64encode(result.file_data).decode("utf-8"),
filename=result.filename,
mime_type=result.mime_type,
)
else:
return DecodeResponse(payload_type="text", message=result.message)
except DecryptionError as e:
error_msg = str(e)
if "channel key" in error_msg.lower():
raise HTTPException(401, error_msg)
raise HTTPException(401, "Decryption failed. Check credentials.")
except StegasooError as e:
raise HTTPException(400, str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/audio/info", response_model=AudioInfoResponse)
async def api_audio_info(
_: str = Depends(require_api_key),
audio: UploadFile = File(...),
):
"""
Get audio file metadata and embedding capacity.
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
try:
audio_data = await audio.read()
info = await run_in_thread(get_audio_info, audio_data)
# Calculate capacities for both modes
lsb_capacity = await run_in_thread(calculate_audio_lsb_capacity, audio_data)
try:
spread_info = await run_in_thread(calculate_audio_spread_capacity, audio_data)
spread_capacity = spread_info.usable_capacity_bytes
except Exception:
spread_capacity = 0
return AudioInfoResponse(
sample_rate=info.sample_rate,
channels=info.channels,
duration_seconds=round(info.duration_seconds, 2),
num_samples=info.num_samples,
format=info.format,
bit_depth=info.bit_depth,
bitrate=info.bitrate,
capacity_lsb=lsb_capacity,
capacity_spread=spread_capacity,
)
except StegasooError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/audio/capacity", response_model=AudioCapacityResponse)
async def api_audio_capacity(request: AudioCapacityRequest, _: str = Depends(require_api_key)):
"""
Check if a payload of a given size will fit in an audio carrier.
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
try:
carrier = base64.b64decode(request.carrier_audio_base64)
if request.embed_mode == "audio_lsb":
capacity = await run_in_thread(calculate_audio_lsb_capacity, carrier)
else:
spread_info = await run_in_thread(calculate_audio_spread_capacity, carrier)
capacity = spread_info.usable_capacity_bytes
fits = request.payload_size <= capacity
usage = (request.payload_size / capacity * 100) if capacity > 0 else 100.0
return AudioCapacityResponse(
fits=fits,
payload_size=request.payload_size,
capacity_bytes=capacity,
usage_percent=round(usage, 1),
embed_mode=request.embed_mode,
)
except StegasooError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, str(e))
# ============================================================================
# ERROR HANDLERS
# ============================================================================

View File

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

View File

@@ -146,6 +146,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import stegasoo
from stegasoo import (
HAS_AUDIO_SUPPORT,
CapacityError,
DecryptionError,
FilePayload,
@@ -463,6 +464,9 @@ def inject_globals():
"is_admin": is_admin(),
# NEW in v4.2.0 - Saved channel keys
"saved_channel_keys": saved_channel_keys,
# NEW in v4.3.0 - Audio support
"has_audio": HAS_AUDIO_SUPPORT,
"supported_audio_formats": "WAV, FLAC, MP3, OGG, AAC, M4A" if HAS_AUDIO_SUPPORT else "",
}
@@ -564,6 +568,14 @@ def allowed_image(filename: str) -> bool:
return ext in {"png", "jpg", "jpeg", "bmp", "gif"}
def allowed_audio(filename: str) -> bool:
"""Check if file has allowed audio extension."""
if not filename or "." not in filename:
return False
ext = filename.rsplit(".", 1)[1].lower()
return ext in {"wav", "flac", "mp3", "ogg", "aac", "m4a", "aiff", "aif"}
def format_size(size_bytes: int) -> str:
"""Format file size for display."""
if size_bytes < 1024:
@@ -710,11 +722,15 @@ def generate():
if not qr_too_large:
qr_token = secrets.token_urlsafe(16)
cleanup_temp_files()
temp_storage.save_temp_file(qr_token, creds.rsa_key_pem.encode(), {
temp_storage.save_temp_file(
qr_token,
creds.rsa_key_pem.encode(),
{
"filename": "rsa_key.pem",
"type": "rsa_key",
"compress": qr_needs_compression,
})
},
)
# v3.2.0: Single passphrase instead of daily phrases
return render_template(
@@ -1001,6 +1017,37 @@ def api_check_fit():
return jsonify({"error": str(e)}), 500
@app.route("/api/audio-capacity", methods=["POST"])
@login_required
def api_audio_capacity():
"""Get audio file capacity for steganography (v4.3.0)."""
audio_file = request.files.get("carrier")
if not audio_file:
return jsonify({"error": "No audio file provided"}), 400
try:
audio_data = audio_file.read()
result = subprocess_stego.audio_info(audio_data)
if not result.success:
return jsonify({"error": result.error or "Audio analysis failed"}), 500
return jsonify(
{
"success": True,
"sample_rate": result.sample_rate,
"channels": result.channels,
"duration": round(result.duration_seconds, 2),
"format": result.format,
"bit_depth": result.bit_depth,
"lsb_capacity": result.capacity_lsb,
"spread_capacity": result.capacity_spread,
}
)
except Exception as e:
return jsonify({"error": str(e)}), 500
# ============================================================================
# ENCODE
# ============================================================================
@@ -1078,7 +1125,10 @@ def _run_encode_job(job_id: str, encode_params: dict) -> None:
# Store result
file_id = secrets.token_urlsafe(16)
temp_storage.save_temp_file(file_id, encode_result.stego_data, {
temp_storage.save_temp_file(
file_id,
encode_result.stego_data,
{
"filename": filename,
"embed_mode": embed_mode,
"output_format": dct_output_format if embed_mode == "dct" else "png",
@@ -1086,7 +1136,94 @@ def _run_encode_job(job_id: str, encode_params: dict) -> None:
"mime_type": output_mime,
"channel_mode": encode_result.channel_mode,
"channel_fingerprint": encode_result.channel_fingerprint,
})
},
)
_store_job(
job_id,
{
"status": "complete",
"file_id": file_id,
"created": time.time(),
},
)
except Exception as e:
_store_job(
job_id,
{
"status": "error",
"error": str(e),
"created": time.time(),
},
)
finally:
cleanup_progress_file(job_id)
def _run_encode_audio_job(job_id: str, encode_params: dict) -> None:
"""Background thread function for async audio encode (v4.3.0)."""
progress_file = get_progress_file_path(job_id)
try:
_store_job(job_id, {"status": "running", "created": time.time()})
if encode_params.get("file_data"):
encode_result = subprocess_stego.encode_audio(
carrier_data=encode_params["carrier_data"],
reference_data=encode_params["ref_data"],
file_data=encode_params["file_data"],
file_name=encode_params["file_name"],
file_mime=encode_params["file_mime"],
passphrase=encode_params["passphrase"],
pin=encode_params.get("pin"),
rsa_key_data=encode_params.get("rsa_key_data"),
rsa_password=encode_params.get("key_password"),
embed_mode=encode_params["embed_mode"],
channel_key=encode_params.get("channel_key"),
progress_file=progress_file,
chip_tier=encode_params.get("chip_tier"),
)
else:
encode_result = subprocess_stego.encode_audio(
carrier_data=encode_params["carrier_data"],
reference_data=encode_params["ref_data"],
message=encode_params.get("message"),
passphrase=encode_params["passphrase"],
pin=encode_params.get("pin"),
rsa_key_data=encode_params.get("rsa_key_data"),
rsa_password=encode_params.get("key_password"),
embed_mode=encode_params["embed_mode"],
channel_key=encode_params.get("channel_key"),
progress_file=progress_file,
chip_tier=encode_params.get("chip_tier"),
)
if not encode_result.success:
_store_job(
job_id,
{
"status": "error",
"error": encode_result.error or "Audio encoding failed",
"created": time.time(),
},
)
return
filename = generate_filename("stego_audio", ".wav")
file_id = secrets.token_urlsafe(16)
temp_storage.save_temp_file(
file_id,
encode_result.stego_data,
{
"filename": filename,
"embed_mode": encode_params["embed_mode"],
"carrier_type": "audio",
"mime_type": "audio/wav",
"channel_mode": encode_result.channel_mode,
"channel_fingerprint": encode_result.channel_fingerprint,
},
)
_store_job(
job_id,
@@ -1131,6 +1268,196 @@ def encode_page():
rsa_key_file = request.files.get("rsa_key")
payload_file = request.files.get("payload_file")
# Determine carrier type (v4.3.0)
carrier_type = request.form.get("carrier_type", "image")
if carrier_type == "audio":
# ========== AUDIO ENCODE PATH (v4.3.0) ==========
if not HAS_AUDIO_SUPPORT:
return _error_response(
"Audio steganography is not available. Install audio dependencies."
)
if not ref_photo or not carrier:
return _error_response("Both reference photo and audio carrier are required")
if not allowed_image(ref_photo.filename):
return _error_response("Reference must be an image (PNG, JPG, BMP)")
if not allowed_audio(carrier.filename):
return _error_response(
"Invalid audio format. Use WAV, FLAC, MP3, OGG, AAC, or M4A"
)
# Get form data
message = request.form.get("message", "")
passphrase = request.form.get("passphrase", "")
pin = request.form.get("pin", "").strip()
rsa_password = request.form.get("rsa_password", "")
payload_type = request.form.get("payload_type", "text")
embed_mode = request.form.get("embed_mode", "audio_lsb")
if embed_mode not in ("audio_lsb", "audio_spread"):
embed_mode = "audio_lsb"
# Chip tier for spread spectrum (None = default)
chip_tier_str = request.form.get("chip_tier")
chip_tier = None
if chip_tier_str and chip_tier_str.isdigit():
chip_tier = int(chip_tier_str)
if chip_tier not in (0, 1, 2):
chip_tier = None
channel_key = resolve_channel_key_form(request.form.get("channel_key", "auto"))
# Determine payload
if payload_type == "file" and payload_file and payload_file.filename:
file_data = payload_file.read()
result = validate_file_payload(file_data, payload_file.filename)
if not result.is_valid:
return _error_response(result.error_message)
mime_type, _ = mimetypes.guess_type(payload_file.filename)
payload = FilePayload(
data=file_data,
filename=payload_file.filename,
mime_type=mime_type,
)
else:
result = validate_message(message)
if not result.is_valid:
return _error_response(result.error_message)
payload = message
if not passphrase:
return _error_response("Passphrase is required")
result = validate_passphrase(passphrase)
if not result.is_valid:
return _error_response(result.error_message)
if result.warning:
flash(result.warning, "warning")
ref_data = ref_photo.read()
carrier_data = carrier.read()
# Handle RSA key (same as image path)
rsa_key_data = None
rsa_key_pem = request.form.get("rsa_key_pem", "").strip()
rsa_key_qr = request.files.get("rsa_key_qr")
rsa_key_from_qr = False
if rsa_key_pem:
if is_compressed(rsa_key_pem):
rsa_key_pem = decompress_data(rsa_key_pem)
rsa_key_data = rsa_key_pem.encode("utf-8")
rsa_key_from_qr = True
elif rsa_key_file and rsa_key_file.filename:
rsa_key_data = rsa_key_file.read()
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
qr_image_data = rsa_key_qr.read()
key_pem = extract_key_from_qr(qr_image_data)
if key_pem:
rsa_key_data = key_pem.encode("utf-8")
rsa_key_from_qr = True
else:
return _error_response("Could not extract RSA key from QR code image.")
result = validate_security_factors(pin, rsa_key_data)
if not result.is_valid:
return _error_response(result.error_message)
if pin:
result = validate_pin(pin)
if not result.is_valid:
return _error_response(result.error_message)
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
if rsa_key_data:
result = validate_rsa_key(rsa_key_data, key_password)
if not result.is_valid:
return _error_response(result.error_message)
# Build audio encode params
encode_params = {
"carrier_data": carrier_data,
"ref_data": ref_data,
"passphrase": passphrase,
"pin": pin if pin else None,
"rsa_key_data": rsa_key_data,
"key_password": key_password,
"embed_mode": embed_mode,
"channel_key": channel_key,
"carrier_type": "audio",
"chip_tier": chip_tier,
}
if payload_type == "file" and payload_file and payload_file.filename:
encode_params["file_data"] = payload.data
encode_params["file_name"] = payload.filename
encode_params["file_mime"] = payload.mime_type
else:
encode_params["message"] = payload
if is_async:
job_id = generate_job_id()
_store_job(job_id, {"status": "pending", "created": time.time()})
_executor.submit(_run_encode_audio_job, job_id, encode_params)
return jsonify({"job_id": job_id, "status": "pending"})
# Sync audio encode
if encode_params.get("file_data"):
encode_result = subprocess_stego.encode_audio(
carrier_data=carrier_data,
reference_data=ref_data,
file_data=encode_params["file_data"],
file_name=encode_params["file_name"],
file_mime=encode_params["file_mime"],
passphrase=passphrase,
pin=pin if pin else None,
rsa_key_data=rsa_key_data,
rsa_password=key_password,
embed_mode=embed_mode,
channel_key=channel_key,
chip_tier=chip_tier,
)
else:
encode_result = subprocess_stego.encode_audio(
carrier_data=carrier_data,
reference_data=ref_data,
message=payload,
passphrase=passphrase,
pin=pin if pin else None,
rsa_key_data=rsa_key_data,
rsa_password=key_password,
embed_mode=embed_mode,
channel_key=channel_key,
chip_tier=chip_tier,
)
if not encode_result.success:
error_msg = encode_result.error or "Audio encoding failed"
return _error_response(error_msg)
filename = generate_filename("stego_audio", ".wav")
file_id = secrets.token_urlsafe(16)
cleanup_temp_files()
temp_storage.save_temp_file(
file_id,
encode_result.stego_data,
{
"filename": filename,
"embed_mode": embed_mode,
"carrier_type": "audio",
"mime_type": "audio/wav",
"channel_mode": encode_result.channel_mode,
"channel_fingerprint": encode_result.channel_fingerprint,
},
)
return redirect(url_for("encode_result", file_id=file_id))
# ========== IMAGE ENCODE PATH (original) ==========
if not ref_photo or not carrier:
return _error_response("Both reference photo and carrier image are required")
@@ -1356,7 +1683,10 @@ def encode_page():
# Store temporarily
file_id = secrets.token_urlsafe(16)
cleanup_temp_files()
temp_storage.save_temp_file(file_id, encode_result.stego_data, {
temp_storage.save_temp_file(
file_id,
encode_result.stego_data,
{
"filename": filename,
"embed_mode": embed_mode,
"output_format": dct_output_format if embed_mode == "dct" else "png",
@@ -1365,7 +1695,8 @@ def encode_page():
# Channel info (v4.0.0)
"channel_mode": encode_result.channel_mode,
"channel_fingerprint": encode_result.channel_fingerprint,
})
},
)
return redirect(url_for("encode_result", file_id=file_id))
@@ -1434,10 +1765,13 @@ def encode_result(file_id):
flash("File expired or not found. Please encode again.", "error")
return redirect(url_for("encode_page"))
# Generate thumbnail
thumbnail_data = generate_thumbnail(file_info["data"])
thumbnail_id = None
carrier_type = file_info.get("carrier_type", "image")
# Generate thumbnail only for images
thumbnail_data = None
thumbnail_id = None
if carrier_type != "audio":
thumbnail_data = generate_thumbnail(file_info["data"])
if thumbnail_data:
thumbnail_id = f"{file_id}_thumb"
temp_storage.save_thumbnail(thumbnail_id, thumbnail_data)
@@ -1450,6 +1784,7 @@ def encode_result(file_id):
embed_mode=file_info.get("embed_mode", "lsb"),
output_format=file_info.get("output_format", "png"),
color_mode=file_info.get("color_mode"),
carrier_type=carrier_type,
# Channel info (v4.0.0)
channel_mode=file_info.get("channel_mode", "public"),
channel_fingerprint=file_info.get("channel_fingerprint"),
@@ -1464,9 +1799,7 @@ def encode_thumbnail(thumb_id):
if not thumb_data:
return "Thumbnail not found", 404
return send_file(
io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False
)
return send_file(io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False)
@app.route("/encode/download/<file_id>")
@@ -1559,10 +1892,92 @@ def _run_decode_job(job_id: str, decode_params: dict) -> None:
if decode_result.is_file:
file_id = secrets.token_urlsafe(16)
filename = decode_result.filename or "decoded_file"
temp_storage.save_temp_file(file_id, decode_result.file_data, {
temp_storage.save_temp_file(
file_id,
decode_result.file_data,
{
"filename": filename,
"mime_type": decode_result.mime_type,
})
},
)
_store_job(
job_id,
{
"status": "complete",
"file_id": file_id,
"is_file": True,
"filename": filename,
"file_size": len(decode_result.file_data),
"mime_type": decode_result.mime_type,
"created": time.time(),
},
)
else:
_store_job(
job_id,
{
"status": "complete",
"is_file": False,
"message": decode_result.message,
"created": time.time(),
},
)
except Exception as e:
_store_job(
job_id,
{
"status": "error",
"error": str(e),
"created": time.time(),
},
)
finally:
cleanup_progress_file(job_id)
def _run_decode_audio_job(job_id: str, decode_params: dict) -> None:
"""Background thread function for async audio decode (v4.3.0)."""
progress_file = get_progress_file_path(job_id)
try:
_store_job(job_id, {"status": "running", "created": time.time()})
decode_result = subprocess_stego.decode_audio(
stego_data=decode_params["stego_data"],
reference_data=decode_params["ref_data"],
passphrase=decode_params["passphrase"],
pin=decode_params.get("pin"),
rsa_key_data=decode_params.get("rsa_key_data"),
rsa_password=decode_params.get("rsa_password"),
embed_mode=decode_params.get("embed_mode", "audio_auto"),
channel_key=decode_params.get("channel_key"),
progress_file=progress_file,
)
if not decode_result.success:
_store_job(
job_id,
{
"status": "error",
"error": decode_result.error or "Audio decoding failed",
"error_type": decode_result.error_type,
"created": time.time(),
},
)
return
if decode_result.is_file:
file_id = secrets.token_urlsafe(16)
filename = decode_result.filename or "decoded_file"
temp_storage.save_temp_file(
file_id,
decode_result.file_data,
{
"filename": filename,
"mime_type": decode_result.mime_type,
},
)
_store_job(
job_id,
{
@@ -1609,6 +2024,163 @@ def decode_page():
stego_image = request.files.get("stego_image")
rsa_key_file = request.files.get("rsa_key")
# Determine carrier type (v4.3.0)
carrier_type = request.form.get("carrier_type", "image")
if carrier_type == "audio":
# ========== AUDIO DECODE PATH (v4.3.0) ==========
if not HAS_AUDIO_SUPPORT:
flash("Audio steganography is not available.", "error")
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
if not ref_photo or not stego_image:
flash("Both reference photo and stego audio are required", "error")
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
if not allowed_image(ref_photo.filename):
flash("Reference must be an image", "error")
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
if not allowed_audio(stego_image.filename):
flash("Invalid audio format", "error")
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
passphrase = request.form.get("passphrase", "")
pin = request.form.get("pin", "").strip()
rsa_password = request.form.get("rsa_password", "")
embed_mode = request.form.get("embed_mode", "audio_auto")
if embed_mode not in ("audio_auto", "audio_lsb", "audio_spread"):
embed_mode = "audio_auto"
channel_key = resolve_channel_key_form(request.form.get("channel_key", "auto"))
if not passphrase:
flash("Passphrase is required", "error")
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
ref_data = ref_photo.read()
stego_data = stego_image.read()
# Handle RSA key (same as image path)
rsa_key_data = None
rsa_key_pem = request.form.get("rsa_key_pem", "").strip()
rsa_key_qr = request.files.get("rsa_key_qr")
rsa_key_from_qr = False
if rsa_key_pem:
if is_compressed(rsa_key_pem):
rsa_key_pem = decompress_data(rsa_key_pem)
rsa_key_data = rsa_key_pem.encode("utf-8")
rsa_key_from_qr = True
elif rsa_key_file and rsa_key_file.filename:
rsa_key_data = rsa_key_file.read()
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
qr_image_data = rsa_key_qr.read()
key_pem = extract_key_from_qr(qr_image_data)
if key_pem:
rsa_key_data = key_pem.encode("utf-8")
rsa_key_from_qr = True
else:
flash("Could not extract RSA key from QR code image.", "error")
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
result = validate_security_factors(pin, rsa_key_data)
if not result.is_valid:
flash(result.error_message, "error")
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
if pin:
result = validate_pin(pin)
if not result.is_valid:
flash(result.error_message, "error")
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
if rsa_key_data:
result = validate_rsa_key(rsa_key_data, key_password)
if not result.is_valid:
flash(result.error_message, "error")
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
is_async = (
request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
)
decode_params = {
"stego_data": stego_data,
"ref_data": ref_data,
"passphrase": passphrase,
"pin": pin if pin else None,
"rsa_key_data": rsa_key_data,
"rsa_password": key_password,
"embed_mode": embed_mode,
"channel_key": channel_key,
}
if is_async:
job_id = generate_job_id()
_store_job(job_id, {"status": "pending", "created": time.time()})
_executor.submit(_run_decode_audio_job, job_id, decode_params)
return jsonify({"job_id": job_id, "status": "pending"})
# Sync audio decode
decode_result = subprocess_stego.decode_audio(
stego_data=stego_data,
reference_data=ref_data,
passphrase=passphrase,
pin=pin if pin else None,
rsa_key_data=rsa_key_data,
rsa_password=key_password,
embed_mode=embed_mode,
channel_key=channel_key,
)
if not decode_result.success:
error_msg = decode_result.error or "Audio decoding failed"
if (
"decrypt" in error_msg.lower()
or decode_result.error_type == "DecryptionError"
):
flash(
"Wrong credentials. Double-check your reference photo, "
"passphrase, PIN, and channel key.",
"warning",
)
else:
flash(error_msg, "error")
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
if decode_result.is_file:
file_id = secrets.token_urlsafe(16)
cleanup_temp_files()
filename = decode_result.filename or "decoded_file"
temp_storage.save_temp_file(
file_id,
decode_result.file_data,
{
"filename": filename,
"mime_type": decode_result.mime_type,
},
)
return render_template(
"decode.html",
decoded_file=True,
file_id=file_id,
filename=filename,
file_size=format_size(len(decode_result.file_data)),
mime_type=decode_result.mime_type,
has_qrcode_read=HAS_QRCODE_READ,
)
else:
return render_template(
"decode.html",
decoded_message=decode_result.message,
has_qrcode_read=HAS_QRCODE_READ,
)
# ========== IMAGE DECODE PATH (original) ==========
if not ref_photo or not stego_image:
flash("Both reference photo and stego image are required", "error")
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
@@ -1690,7 +2262,9 @@ def decode_page():
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
# Check for async mode (v4.1.5)
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
is_async = (
request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
)
# Build decode params
decode_params = {
@@ -1742,10 +2316,14 @@ def decode_page():
cleanup_temp_files()
filename = decode_result.filename or "decoded_file"
temp_storage.save_temp_file(file_id, decode_result.file_data, {
temp_storage.save_temp_file(
file_id,
decode_result.file_data,
{
"filename": filename,
"mime_type": decode_result.mime_type,
})
},
)
return render_template(
"decode.html",
@@ -2101,11 +2679,12 @@ def api_tools_exif_clear():
@login_required
def api_tools_rotate():
"""Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
from PIL import Image
import shutil
import subprocess
import tempfile
from PIL import Image
image_file = request.files.get("image")
if not image_file:
return jsonify({"success": False, "error": "No image provided"}), 400
@@ -2136,9 +2715,18 @@ def api_tools_rotate():
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-rotate", str(rotation), "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
[
"jpegtran",
"-rotate",
str(rotation),
"-copy",
"all",
"-outfile",
output_path,
input_path,
],
capture_output=True,
timeout=30,
)
if result.returncode == 0:
with open(output_path, "rb") as f:
@@ -2158,9 +2746,18 @@ def api_tools_rotate():
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-flip", "horizontal", "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
[
"jpegtran",
"-flip",
"horizontal",
"-copy",
"all",
"-outfile",
output_path,
input_path,
],
capture_output=True,
timeout=30,
)
if result.returncode == 0:
with open(output_path, "rb") as f:
@@ -2180,9 +2777,18 @@ def api_tools_rotate():
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-flip", "vertical", "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
[
"jpegtran",
"-flip",
"vertical",
"-copy",
"all",
"-outfile",
output_path,
input_path,
],
capture_output=True,
timeout=30,
)
if result.returncode == 0:
with open(output_path, "rb") as f:
@@ -2839,10 +3445,7 @@ def admin_settings_unlock():
channel_status = get_channel_status()
channel_key = channel_status.get("key") if channel_status["configured"] else ""
return jsonify({
"success": True,
"channel_key": channel_key
})
return jsonify({"success": True, "channel_key": channel_key})
@app.route("/admin/users")
@@ -2976,6 +3579,7 @@ if __name__ == "__main__":
ssl_context = None
if app.config.get("HTTPS_ENABLED", False):
import socket
hostname = os.environ.get("STEGASOO_HOSTNAME") or socket.gethostname()
try:
cert_path, key_path = ensure_certs(base_dir, hostname)

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,8 @@ Usage:
import base64
import json
import logging
import os
import sys
import traceback
from pathlib import Path
@@ -27,6 +29,24 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
sys.path.insert(0, str(Path(__file__).parent))
# Configure logging for worker subprocess
_log_level = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
if _log_level and hasattr(logging, _log_level):
logging.basicConfig(
level=getattr(logging, _log_level),
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%H:%M:%S",
stream=sys.stderr,
)
elif os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
logging.basicConfig(
level=logging.DEBUG,
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%H:%M:%S",
stream=sys.stderr,
)
logger = logging.getLogger("stegasoo.worker")
def _resolve_channel_key(channel_key_param):
"""
@@ -73,6 +93,7 @@ def _get_channel_info(resolved_key):
def encode_operation(params: dict) -> dict:
"""Handle encode operation."""
logger.debug("encode_operation: mode=%s", params.get("embed_mode", "lsb"))
from stegasoo import FilePayload, encode
# Decode base64 inputs
@@ -142,6 +163,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
return
try:
import json
with open(progress_file, "w") as f:
json.dump({"percent": percent, "phase": phase}, f)
except Exception:
@@ -150,6 +172,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
def decode_operation(params: dict) -> dict:
"""Handle decode operation."""
logger.debug("decode_operation: mode=%s", params.get("embed_mode", "auto"))
from stegasoo import decode
progress_file = params.get("progress_file")
@@ -233,6 +256,145 @@ def capacity_check_operation(params: dict) -> dict:
}
def encode_audio_operation(params: dict) -> dict:
"""Handle audio encode operation (v4.3.0)."""
logger.debug("encode_audio_operation: mode=%s", params.get("embed_mode", "audio_lsb"))
from stegasoo import FilePayload, encode_audio
carrier_data = base64.b64decode(params["carrier_b64"])
reference_data = base64.b64decode(params["reference_b64"])
# Optional RSA key
rsa_key_data = None
if params.get("rsa_key_b64"):
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
# Determine payload type
if params.get("file_b64"):
file_data = base64.b64decode(params["file_b64"])
payload = FilePayload(
data=file_data,
filename=params.get("file_name", "file"),
mime_type=params.get("file_mime", "application/octet-stream"),
)
else:
payload = params.get("message", "")
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
# Resolve chip_tier from params (None means use default)
chip_tier_val = params.get("chip_tier")
if chip_tier_val is not None:
chip_tier_val = int(chip_tier_val)
stego_audio, stats = encode_audio(
message=payload,
reference_photo=reference_data,
carrier_audio=carrier_data,
passphrase=params.get("passphrase", ""),
pin=params.get("pin"),
rsa_key_data=rsa_key_data,
rsa_password=params.get("rsa_password"),
embed_mode=params.get("embed_mode", "audio_lsb"),
channel_key=resolved_channel_key,
progress_file=params.get("progress_file"),
chip_tier=chip_tier_val,
)
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
return {
"success": True,
"stego_b64": base64.b64encode(stego_audio).decode("ascii"),
"stats": {
"samples_modified": stats.samples_modified,
"total_samples": stats.total_samples,
"capacity_used": stats.capacity_used,
"bytes_embedded": stats.bytes_embedded,
"sample_rate": stats.sample_rate,
"channels": stats.channels,
"duration_seconds": stats.duration_seconds,
"embed_mode": stats.embed_mode,
},
"channel_mode": channel_mode,
"channel_fingerprint": channel_fingerprint,
}
def decode_audio_operation(params: dict) -> dict:
"""Handle audio decode operation (v4.3.0)."""
logger.debug("decode_audio_operation: mode=%s", params.get("embed_mode", "audio_auto"))
from stegasoo import decode_audio
progress_file = params.get("progress_file")
_write_decode_progress(progress_file, 5, "reading")
stego_data = base64.b64decode(params["stego_b64"])
reference_data = base64.b64decode(params["reference_b64"])
_write_decode_progress(progress_file, 15, "reading")
rsa_key_data = None
if params.get("rsa_key_b64"):
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
result = decode_audio(
stego_audio=stego_data,
reference_photo=reference_data,
passphrase=params.get("passphrase", ""),
pin=params.get("pin"),
rsa_key_data=rsa_key_data,
rsa_password=params.get("rsa_password"),
embed_mode=params.get("embed_mode", "audio_auto"),
channel_key=resolved_channel_key,
progress_file=progress_file,
)
if result.is_file:
return {
"success": True,
"is_file": True,
"file_b64": base64.b64encode(result.file_data).decode("ascii"),
"filename": result.filename,
"mime_type": result.mime_type,
}
else:
return {
"success": True,
"is_file": False,
"message": result.message,
}
def audio_info_operation(params: dict) -> dict:
"""Handle audio info operation (v4.3.0)."""
from stegasoo import get_audio_info
from stegasoo.audio_steganography import calculate_audio_lsb_capacity
from stegasoo.spread_steganography import calculate_audio_spread_capacity
audio_data = base64.b64decode(params["audio_b64"])
info = get_audio_info(audio_data)
lsb_capacity = calculate_audio_lsb_capacity(audio_data)
spread_capacity = calculate_audio_spread_capacity(audio_data)
return {
"success": True,
"info": {
"sample_rate": info.sample_rate,
"channels": info.channels,
"duration_seconds": round(info.duration_seconds, 2),
"num_samples": info.num_samples,
"format": info.format,
"bit_depth": info.bit_depth,
"capacity_lsb": lsb_capacity,
"capacity_spread": spread_capacity.usable_capacity_bytes,
},
}
def channel_status_operation(params: dict) -> dict:
"""Handle channel status check (v4.0.0)."""
from stegasoo import get_channel_status
@@ -263,6 +425,7 @@ def main():
else:
params = json.loads(input_text)
operation = params.get("operation")
logger.info("Worker handling operation: %s", operation)
if operation == "encode":
output = encode_operation(params)
@@ -274,6 +437,13 @@ def main():
output = capacity_check_operation(params)
elif operation == "channel_status":
output = channel_status_operation(params)
# Audio operations (v4.3.0)
elif operation == "encode_audio":
output = encode_audio_operation(params)
elif operation == "decode_audio":
output = decode_audio_operation(params)
elif operation == "audio_info":
output = audio_info_operation(params)
else:
output = {"success": False, "error": f"Unknown operation: {operation}"}

View File

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

View File

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

View File

@@ -24,7 +24,11 @@
border-left: 3px solid #ffe699;
}
.step-accordion .accordion-button::after {
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
filter: brightness(0) invert(1);
opacity: 0.5;
}
.step-accordion .accordion-button:not(.collapsed)::after {
opacity: 0.9;
}
.step-accordion .accordion-body {
background: rgba(30, 40, 50, 0.4);
@@ -126,19 +130,56 @@
<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">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
<span class="step-title">
<span class="step-number" id="stepImagesNumber">1</span>
<i class="bi bi-images me-1"></i> Images & Mode
<span class="step-number" id="stepImagesNumber">2</span>
<i class="bi bi-images me-1"></i> Reference, Carrier, Mode
</span>
<span class="step-summary" id="stepImagesSummary">Select reference & carrier</span>
</button>
</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="row">
@@ -167,6 +208,7 @@
</div>
<div class="col-md-6 mb-3">
<div id="imageCarrierSection">
<label class="form-label">
<i class="bi bi-file-earmark-image me-1"></i> Carrier Image
</label>
@@ -191,6 +233,27 @@
</div>
<div class="form-text">Image to hide your message in</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>
<!-- Capacity Info -->
@@ -204,7 +267,19 @@
</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) -->
<div id="imageModeGroup">
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
<div class="btn-group btn-group-sm" role="group">
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
@@ -228,6 +303,18 @@
</div>
</span>
</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">
<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>
@@ -237,13 +324,13 @@
</div>
<!-- ================================================================
STEP 2: PAYLOAD
STEP 3: PAYLOAD
================================================================ -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepPayload">
<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
</span>
<span class="step-summary" id="stepPayloadSummary">Message or file to hide</span>
@@ -295,13 +382,13 @@
</div>
<!-- ================================================================
STEP 3: SECURITY
STEP 4: SECURITY
================================================================ -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
<span class="step-title">
<span class="step-number" id="stepSecurityNumber">3</span>
<span class="step-number" id="stepSecurityNumber">4</span>
<i class="bi bi-shield-lock me-1"></i> Security
</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
// ============================================================================
function updateImagesSummary() {
const ref = document.getElementById('refPhotoInput')?.files[0];
const carrier = document.getElementById('carrierInput')?.files[0];
const isAudio = carrierTypeInput?.value === 'audio';
const carrier = isAudio
? document.getElementById('audioCarrierInput')?.files[0]
: document.getElementById('carrierInput')?.files[0];
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'LSB';
const summary = document.getElementById('stepImagesSummary');
const stepNum = document.getElementById('stepImagesNumber');
@@ -484,12 +689,12 @@ function updateImagesSummary() {
summary.textContent = ref ? ref.name.slice(0, 15) : carrier.name.slice(0, 15);
summary.classList.remove('has-content');
stepNum.classList.remove('complete');
stepNum.textContent = '1';
stepNum.textContent = '2';
} else {
summary.textContent = 'Select reference & carrier';
summary.textContent = isAudio ? 'Select reference & audio' : 'Select reference & carrier';
summary.classList.remove('has-content');
stepNum.classList.remove('complete');
stepNum.textContent = '1';
stepNum.textContent = '2';
}
}
@@ -515,7 +720,7 @@ function updatePayloadSummary() {
summary.textContent = 'Message or file to hide';
summary.classList.remove('has-content');
stepNum.classList.remove('complete');
stepNum.textContent = '2';
stepNum.textContent = '3';
}
}
@@ -543,14 +748,16 @@ function updateSecuritySummary() {
summary.textContent = 'Passphrase & keys';
summary.classList.remove('has-content');
stepNum.classList.remove('complete');
stepNum.textContent = '3';
stepNum.textContent = '4';
}
}
// Attach listeners
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
document.getElementById('carrierInput')?.addEventListener('change', updateImagesSummary);
document.getElementById('audioCarrierInput')?.addEventListener('change', updateImagesSummary);
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
document.querySelectorAll('#audioModeGroup input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
document.getElementById('messageInput')?.addEventListener('input', updatePayloadSummary);
document.getElementById('payloadFileInput')?.addEventListener('change', updatePayloadSummary);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -108,8 +108,9 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@click.group(context_settings=CONTEXT_SETTINGS)
@click.version_option(__version__, "-v", "--version")
@click.option("--json", "json_output", is_flag=True, help="Output results as JSON")
@click.option("--debug", "debug_mode", is_flag=True, help="Enable debug logging to stderr")
@click.pass_context
def cli(ctx, json_output):
def cli(ctx, json_output, debug_mode):
"""
Stegasoo - Steganography with hybrid authentication.
@@ -120,6 +121,11 @@ def cli(ctx, json_output):
ctx.ensure_object(dict)
ctx.obj["json"] = json_output
if debug_mode:
from .debug import debug
debug.enable(True)
# =============================================================================
# ENCODE COMMANDS
@@ -179,9 +185,7 @@ def cli(ctx, json_output):
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
@click.pass_context
def encode(
ctx, carrier, reference, message, file_payload, output, passphrase, pin, dry_run
):
def encode(ctx, carrier, reference, message, file_payload, output, passphrase, pin, dry_run):
"""
Encode a message or file into an image.
@@ -245,14 +249,14 @@ def encode(
# Default to JPEG for JPEG carriers (preserves DCT mode benefits)
carrier_ext = Path(carrier).suffix.lower()
if not output:
if carrier_ext in ('.jpg', '.jpeg'):
if carrier_ext in (".jpg", ".jpeg"):
output = f"{Path(carrier).stem}_encoded.jpg"
else:
output = f"{Path(carrier).stem}_encoded.png"
# Detect output format from extension
output_ext = Path(output).suffix.lower()
use_dct = output_ext in ('.jpg', '.jpeg')
use_dct = output_ext in (".jpg", ".jpeg")
from .steganography import EMBED_MODE_DCT, EMBED_MODE_LSB
@@ -442,8 +446,38 @@ def decode(ctx, image, reference, passphrase, pin, output):
help="Passphrase (recommend 4+ words)",
)
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
@click.option(
"--rsa-key",
type=click.Path(exists=True),
help="RSA private key PEM file",
)
@click.option("--rsa-password", default=None, help="Password for encrypted RSA key")
@click.option("--channel-key", default=None, help="Channel key for deployment isolation")
@click.option(
"--chip-tier",
"chip_tier",
default=None,
type=click.Choice(["lossless", "high", "low"]),
help="Spread spectrum chip tier (lossless=256, high=512, low=1024). Only for audio_spread.",
)
@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
@click.pass_context
def audio_encode(ctx, carrier, reference, message, file_payload, output, embed_mode, passphrase, pin):
def audio_encode(
ctx,
carrier,
reference,
message,
file_payload,
output,
embed_mode,
passphrase,
pin,
rsa_key,
rsa_password,
channel_key,
chip_tier,
dry_run,
):
"""
Encode a message or file into an audio carrier.
@@ -452,26 +486,100 @@ def audio_encode(ctx, carrier, reference, message, file_payload, output, embed_m
stegasoo audio-encode carrier.wav -r ref.jpg -m "Secret" --mode audio_lsb
stegasoo audio-encode carrier.wav -r ref.jpg -f secret.pdf --mode audio_spread
stegasoo audio-encode carrier.wav -r ref.jpg -m "Secret" --dry-run
"""
from .constants import AUDIO_ENABLED
if not AUDIO_ENABLED:
raise click.UsageError(
"Audio support is disabled. Install audio extras (pip install stegasoo[audio]) "
"or set STEGASOO_AUDIO=1 to force enable."
)
from .audio_steganography import calculate_audio_lsb_capacity
from .encode import encode_audio
from .models import FilePayload
from .spread_steganography import calculate_audio_spread_capacity
if not message and not file_payload:
raise click.UsageError("Either --message or --file is required")
# Read RSA key if provided
rsa_key_data = None
if rsa_key:
with open(rsa_key, "rb") as f:
rsa_key_data = f.read()
# Calculate payload size
if file_payload:
payload_size = Path(file_payload).stat().st_size
payload_type = "file"
else:
payload_size = len(message.encode("utf-8"))
payload_type = "text"
# Read input files
with open(reference, "rb") as f:
reference_data = f.read()
with open(carrier, "rb") as f:
carrier_data = f.read()
if dry_run:
try:
from .audio_utils import get_audio_info
info = get_audio_info(carrier_data)
lsb_capacity = calculate_audio_lsb_capacity(carrier_data)
spread_capacity = calculate_audio_spread_capacity(carrier_data)
if embed_mode == "audio_lsb":
capacity = lsb_capacity
else:
capacity = spread_capacity.usable_capacity_bytes
result = {
"carrier": carrier,
"reference": reference,
"format": info.format,
"sample_rate": info.sample_rate,
"channels": info.channels,
"duration_seconds": round(info.duration_seconds, 2),
"embed_mode": embed_mode,
"capacity_bytes": capacity,
"lsb_capacity_bytes": lsb_capacity,
"spread_capacity_bytes": spread_capacity.usable_capacity_bytes,
"payload_type": payload_type,
"payload_size": payload_size,
"usage_percent": round(payload_size / capacity * 100, 1) if capacity > 0 else 0,
"fits": payload_size < capacity,
}
if ctx.obj.get("json"):
click.echo(json.dumps(result, indent=2))
else:
click.echo(
f"Carrier: {carrier} ({info.format}, {info.sample_rate}Hz, {info.channels}ch)"
)
click.echo(f"Duration: {info.duration_seconds:.1f}s")
click.echo(f"Reference: {reference}")
click.echo(f"Mode: {embed_mode}")
click.echo(f"LSB capacity: {lsb_capacity:,} bytes ({lsb_capacity // 1024} KB)")
click.echo(f"Spread capacity: {spread_capacity.usable_capacity_bytes:,} bytes")
click.echo(f"Payload: {payload_size:,} bytes ({payload_type})")
click.echo(f"Usage: {result['usage_percent']}%")
click.echo(f"Status: {'✓ Fits' if result['fits'] else '✗ Too large'}")
except Exception as e:
if ctx.obj.get("json"):
click.echo(json.dumps({"status": "error", "error": str(e)}, indent=2))
else:
click.echo(f"✗ Capacity check failed: {e}", err=True)
raise SystemExit(1)
return
# Determine output path
if not output:
carrier_path = Path(carrier)
if embed_mode == "audio_lsb":
output = f"{carrier_path.stem}_encoded.wav"
else:
output = f"{carrier_path.stem}_encoded.wav"
output = f"{Path(carrier).stem}_encoded.wav"
try:
if file_payload:
@@ -479,13 +587,24 @@ def audio_encode(ctx, carrier, reference, message, file_payload, output, embed_m
else:
payload = message
# Resolve chip tier name to integer
resolved_chip_tier = None
if chip_tier is not None:
from .constants import AUDIO_SS_CHIP_TIER_NAMES
resolved_chip_tier = AUDIO_SS_CHIP_TIER_NAMES.get(chip_tier)
stego_audio, stats = encode_audio(
message=payload,
reference_photo=reference_data,
carrier_audio=carrier_data,
passphrase=passphrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password,
embed_mode=embed_mode,
channel_key=channel_key,
chip_tier=resolved_chip_tier,
)
with open(output, "wb") as f:
@@ -539,9 +658,18 @@ def audio_encode(ctx, carrier, reference, message, file_payload, output, embed_m
)
@click.option("--passphrase", prompt=True, hide_input=True, help="Passphrase")
@click.option("--pin", prompt=True, hide_input=True, help="PIN code")
@click.option(
"--rsa-key",
type=click.Path(exists=True),
help="RSA private key PEM file",
)
@click.option("--rsa-password", default=None, help="Password for encrypted RSA key")
@click.option("--channel-key", default=None, help="Channel key for deployment isolation")
@click.option("-o", "--output", type=click.Path(), help="Output path for file payloads")
@click.pass_context
def audio_decode(ctx, audio, reference, embed_mode, passphrase, pin, output):
def audio_decode(
ctx, audio, reference, embed_mode, passphrase, pin, rsa_key, rsa_password, channel_key, output
):
"""
Decode a message or file from stego audio.
@@ -551,8 +679,22 @@ def audio_decode(ctx, audio, reference, embed_mode, passphrase, pin, output):
stegasoo audio-decode stego.wav -r ref.jpg --mode audio_lsb -o ./extracted/
"""
from .constants import AUDIO_ENABLED
if not AUDIO_ENABLED:
raise click.UsageError(
"Audio support is disabled. Install audio extras (pip install stegasoo[audio]) "
"or set STEGASOO_AUDIO=1 to force enable."
)
from .decode import decode_audio
# Read RSA key if provided
rsa_key_data = None
if rsa_key:
with open(rsa_key, "rb") as f:
rsa_key_data = f.read()
with open(audio, "rb") as f:
audio_data = f.read()
with open(reference, "rb") as f:
@@ -564,7 +706,10 @@ def audio_decode(ctx, audio, reference, embed_mode, passphrase, pin, output):
reference_photo=reference_data,
passphrase=passphrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password,
embed_mode=embed_mode,
channel_key=channel_key,
)
if result.is_file:
@@ -617,6 +762,97 @@ def audio_decode(ctx, audio, reference, embed_mode, passphrase, pin, output):
raise SystemExit(1)
@cli.command("audio-info")
@click.argument("audio", type=click.Path(exists=True))
@click.pass_context
def audio_info(ctx, audio):
"""
Show audio file information and steganographic capacity.
Examples:
stegasoo audio-info carrier.wav
stegasoo --json audio-info carrier.wav
"""
from .constants import AUDIO_ENABLED
if not AUDIO_ENABLED:
raise click.UsageError(
"Audio support is disabled. Install audio extras (pip install stegasoo[audio]) "
"or set STEGASOO_AUDIO=1 to force enable."
)
from .audio_steganography import calculate_audio_lsb_capacity
from .audio_utils import get_audio_info
from .spread_steganography import calculate_audio_spread_capacity
with open(audio, "rb") as f:
audio_data = f.read()
try:
info = get_audio_info(audio_data)
lsb_capacity = calculate_audio_lsb_capacity(audio_data)
# Calculate spread capacity at each chip tier
spread_tiers = {}
for tier_name, tier_val in [("lossless", 0), ("high", 1), ("low", 2)]:
cap = calculate_audio_spread_capacity(audio_data, chip_tier=tier_val)
spread_tiers[tier_name] = {
"bytes": cap.usable_capacity_bytes,
"kb": round(cap.usable_capacity_bytes / 1024, 1),
"chip_length": cap.chip_length,
"embeddable_channels": cap.embeddable_channels,
}
result = {
"file": audio,
"format": info.format,
"sample_rate": info.sample_rate,
"channels": info.channels,
"duration_seconds": round(info.duration_seconds, 2),
"num_samples": info.num_samples,
"bit_depth": info.bit_depth,
"file_size": len(audio_data),
"capacity": {
"audio_lsb": {
"bytes": lsb_capacity,
"kb": round(lsb_capacity / 1024, 1),
},
"audio_spread": spread_tiers,
},
}
if ctx.obj.get("json"):
click.echo(json.dumps(result, indent=2))
else:
click.echo(f"File: {audio}")
click.echo(f"Format: {info.format}")
click.echo(f"Sample rate: {info.sample_rate} Hz")
click.echo(f"Channels: {info.channels}")
click.echo(f"Duration: {info.duration_seconds:.1f}s")
click.echo(f"Samples: {info.num_samples:,}")
if info.bit_depth:
click.echo(f"Bit depth: {info.bit_depth}-bit")
click.echo(f"File size: {len(audio_data):,} bytes")
click.echo()
click.echo("Steganographic capacity:")
click.echo(f" LSB: {lsb_capacity:,} bytes ({lsb_capacity // 1024} KB)")
for tier_name in ("lossless", "high", "low"):
t = spread_tiers[tier_name]
click.echo(
f" Spread ({tier_name:>8}, chip={t['chip_length']}): "
f"{t['bytes']:,} bytes ({t['kb']} KB)"
)
except Exception as e:
if ctx.obj.get("json"):
click.echo(json.dumps({"status": "error", "error": str(e)}, indent=2))
else:
click.echo(f"✗ Audio info failed: {e}", err=True)
raise SystemExit(1)
# =============================================================================
# BATCH COMMANDS
# =============================================================================
@@ -828,9 +1064,7 @@ def batch_check(ctx, images, recursive):
@click.option(
"--pin-length", default=DEFAULT_PIN_LENGTH, help=f"PIN length (default: {DEFAULT_PIN_LENGTH})"
)
@click.option(
"--channel-key", is_flag=True, help="Also generate a 256-bit channel key"
)
@click.option("--channel-key", is_flag=True, help="Also generate a 256-bit channel key")
@click.pass_context
def generate(ctx, words, pin_length, channel_key):
"""
@@ -889,6 +1123,7 @@ def generate(ctx, words, pin_length, channel_key):
# Generate channel key if requested
if channel_key:
from .channel import generate_channel_key
result["channel_key"] = generate_channel_key()
if ctx.obj.get("json"):
@@ -912,6 +1147,7 @@ def info(ctx, full):
# Check for DCT support
try:
from .dct_steganography import HAS_JPEGIO, HAS_SCIPY
has_dct = HAS_SCIPY and HAS_JPEGIO
except ImportError:
has_dct = False
@@ -954,6 +1190,7 @@ def info(ctx, full):
channel_source = None
try:
from .channel import get_channel_fingerprint, get_channel_key, get_channel_status
key = get_channel_key()
if key:
channel_fingerprint = get_channel_fingerprint(key)
@@ -986,7 +1223,7 @@ def info(ctx, full):
try:
# Disk free
st = os.statvfs("/")
disk_free = (st.f_bavail * st.f_frsize) / (1024 ** 3) # GB
disk_free = (st.f_bavail * st.f_frsize) / (1024**3) # GB
except OSError:
pass
@@ -1005,20 +1242,28 @@ def info(ctx, full):
"service": service_status,
"url": service_url,
"dct_support": has_dct,
"channel": {
"channel": (
{
"fingerprint": channel_fingerprint,
"source": channel_source,
} if channel_fingerprint else None,
}
if channel_fingerprint
else None
),
"limits": {
"max_message_bytes": MAX_MESSAGE_SIZE,
"max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE,
},
"system": {
"system": (
{
"cpu_mhz": cpu_freq,
"temp_c": cpu_temp,
"disk_free_gb": round(disk_free, 1) if disk_free else None,
"uptime": uptime,
} if full else None,
}
if full
else None
),
}
if ctx.obj.get("json"):
@@ -1055,7 +1300,9 @@ def info(ctx, full):
if cpu_freq:
click.echo(f" CPU: {cpu_freq} MHz")
if cpu_temp:
temp_color = "\033[32m" if cpu_temp < 60 else "\033[33m" if cpu_temp < 75 else "\033[31m"
temp_color = (
"\033[32m" if cpu_temp < 60 else "\033[33m" if cpu_temp < 75 else "\033[31m"
)
click.echo(f" Temp: {temp_color}{cpu_temp:.1f}°C\033[0m")
if uptime:
click.echo(f" Uptime: {uptime}")
@@ -1384,7 +1631,7 @@ def tools_capacity(image, as_json):
click.echo(f" Megapixels: {result['megapixels']} MP")
click.echo(f" {'' * 40}")
click.echo(f" LSB Capacity: {result['lsb']['capacity_kb']:.1f} KB")
if result['dct']['available']:
if result["dct"]["available"]:
click.echo(f" DCT Capacity: {result['dct']['capacity_kb']:.1f} KB")
else:
click.echo(" DCT Capacity: N/A (scipy required)")
@@ -1394,7 +1641,9 @@ def tools_capacity(image, as_json):
@tools.command("strip")
@click.argument("image", type=click.Path(exists=True))
@click.option("-o", "--output", type=click.Path(), help="Output file (default: <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):
"""Strip EXIF/metadata from an image.
@@ -1529,7 +1778,9 @@ def tools_exif(image, clear, set_fields, output, as_json):
@tools.command("compress")
@click.argument("image", type=click.Path(exists=True))
@click.option("-q", "--quality", type=int, default=75, help="JPEG quality (1-100, default: 75)")
@click.option("-o", "--output", type=click.Path(), help="Output file (default: <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):
"""Compress a JPEG image.
@@ -1541,9 +1792,10 @@ def tools_compress(image, quality, output):
stegasoo tools compress photo.jpg -q 60
stegasoo tools compress photo.jpg -q 80 -o smaller.jpg
"""
from PIL import Image
import io
from PIL import Image
if not 1 <= quality <= 100:
raise click.UsageError("Quality must be between 1 and 100")
@@ -1578,7 +1830,9 @@ def tools_compress(image, quality, output):
@tools.command("rotate")
@click.argument("image", type=click.Path(exists=True))
@click.option("-r", "--rotation", type=click.Choice(["90", "180", "270"]), help="Rotation degrees clockwise")
@click.option(
"-r", "--rotation", type=click.Choice(["90", "180", "270"]), help="Rotation degrees clockwise"
)
@click.option("--flip-h", is_flag=True, help="Flip horizontally")
@click.option("--flip-v", is_flag=True, help="Flip vertically")
@click.option("-o", "--output", type=click.Path(), help="Output file")
@@ -1593,10 +1847,11 @@ def tools_rotate(image, rotation, flip_h, flip_v, output):
stegasoo tools rotate photo.jpg -r 90
stegasoo tools rotate photo.jpg -r 180 --flip-h -o rotated.jpg
"""
from PIL import Image
import io
import shutil
from PIL import Image
with open(image, "rb") as f:
image_data = f.read()
@@ -1622,9 +1877,9 @@ def tools_rotate(image, rotation, flip_h, flip_v, output):
# Apply flips using jpegtran
if flip_h or flip_v:
import os
import subprocess
import tempfile
import os
for flip_type in (["horizontal"] if flip_h else []) + (["vertical"] if flip_v else []):
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
@@ -1633,9 +1888,19 @@ def tools_rotate(image, rotation, flip_h, flip_v, output):
output_path = tempfile.mktemp(suffix=".jpg")
try:
subprocess.run(
["jpegtran", "-flip", flip_type, "-copy", "all",
"-outfile", output_path, input_path],
capture_output=True, timeout=30, check=True
[
"jpegtran",
"-flip",
flip_type,
"-copy",
"all",
"-outfile",
output_path,
input_path,
],
capture_output=True,
timeout=30,
check=True,
)
with open(output_path, "rb") as f:
result_data = f.read()
@@ -1680,8 +1945,17 @@ def tools_rotate(image, rotation, flip_h, flip_v, output):
@tools.command("convert")
@click.argument("image", type=click.Path(exists=True))
@click.option("-f", "--format", "fmt", type=click.Choice(["png", "jpg", "bmp", "webp"]), required=True, help="Output format")
@click.option("-q", "--quality", type=int, default=95, help="Quality for lossy formats (default: 95)")
@click.option(
"-f",
"--format",
"fmt",
type=click.Choice(["png", "jpg", "bmp", "webp"]),
required=True,
help="Output format",
)
@click.option(
"-q", "--quality", type=int, default=95, help="Quality for lossy formats (default: 95)"
)
@click.option("-o", "--output", type=click.Path(), help="Output file")
def tools_convert(image, fmt, quality, output):
"""Convert image to a different format.
@@ -1691,9 +1965,10 @@ def tools_convert(image, fmt, quality, output):
stegasoo tools convert photo.png -f jpg
stegasoo tools convert photo.jpg -f png -o lossless.png
"""
from PIL import Image
import io
from PIL import Image
with open(image, "rb") as f:
image_data = f.read()
@@ -1737,12 +2012,14 @@ def admin(ctx):
@admin.command("recover")
@click.option(
"--db", "db_path",
"--db",
"db_path",
type=click.Path(exists=True),
help="Path to stegasoo.db (default: frontends/web/instance/stegasoo.db)"
help="Path to stegasoo.db (default: frontends/web/instance/stegasoo.db)",
)
@click.option(
"--password", prompt=True, hide_input=True, confirmation_prompt=True, help="New admin password"
)
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True,
help="New admin password")
def admin_recover(db_path, password):
"""Reset admin password using recovery key.
@@ -1772,9 +2049,7 @@ def admin_recover(db_path, password):
break
if not db_path or not Path(db_path).exists():
raise click.UsageError(
"Database not found. Use --db to specify path to stegasoo.db"
)
raise click.UsageError("Database not found. Use --db to specify path to stegasoo.db")
click.echo(f"Database: {db_path}")
@@ -1783,16 +2058,13 @@ def admin_recover(db_path, password):
db.row_factory = sqlite3.Row
# Get recovery key hash from app_settings
cursor = db.execute(
"SELECT value FROM app_settings WHERE key = 'recovery_key_hash'"
)
cursor = db.execute("SELECT value FROM app_settings WHERE key = 'recovery_key_hash'")
row = cursor.fetchone()
if not row:
db.close()
raise click.ClickException(
"No recovery key configured for this instance. "
"Password reset is not possible."
"No recovery key configured for this instance. " "Password reset is not possible."
)
stored_hash = row["value"]
@@ -1869,6 +2141,7 @@ def admin_generate_key(show_qr):
if show_qr:
try:
import qrcode
qr = qrcode.QRCode(box_size=1, border=1)
qr.add_data(key)
qr.make()
@@ -1920,8 +2193,12 @@ def api_keys():
@api_keys.command("list")
@click.option("--location", type=click.Choice(["user", "project", "all"]), default="all",
help="Config location to list keys from")
@click.option(
"--location",
type=click.Choice(["user", "project", "all"]),
default="all",
help="Config location to list keys from",
)
def api_keys_list(location):
"""List configured API keys.
@@ -1935,7 +2212,7 @@ def api_keys_list(location):
_setup_frontends_path()
try:
from api.auth import list_api_keys, get_api_key_status
from api.auth import get_api_key_status, list_api_keys
except ImportError:
raise click.ClickException("API frontend not available")
@@ -1959,8 +2236,12 @@ def api_keys_list(location):
@api_keys.command("create")
@click.argument("name")
@click.option("--location", type=click.Choice(["user", "project"]), default="user",
help="Where to store the key")
@click.option(
"--location",
type=click.Choice(["user", "project"]),
default="user",
help="Where to store the key",
)
def api_keys_create(name, location):
"""Create a new API key.
@@ -1993,8 +2274,9 @@ def api_keys_create(name, location):
@api_keys.command("delete")
@click.argument("name")
@click.option("--location", type=click.Choice(["user", "project"]), default="user",
help="Config location")
@click.option(
"--location", type=click.Choice(["user", "project"]), default="user", help="Config location"
)
def api_keys_delete(name, location):
"""Delete an API key by name.
@@ -2025,7 +2307,9 @@ def api_tls():
@api_tls.command("generate")
@click.option("--hostname", default="localhost", help="Server hostname for certificate")
@click.option("--days", default=365, help="Certificate validity in days")
@click.option("--output", "-o", type=click.Path(), help="Output directory (default: ~/.stegasoo/certs)")
@click.option(
"--output", "-o", type=click.Path(), help="Output directory (default: ~/.stegasoo/certs)"
)
def api_tls_generate(hostname, days, output):
"""Generate self-signed TLS certificate.
@@ -2065,7 +2349,12 @@ def api_tls_generate(hostname, days, output):
@api_tls.command("info")
@click.option("--cert", "-c", type=click.Path(exists=True), help="Certificate file (default: ~/.stegasoo/certs/server.crt)")
@click.option(
"--cert",
"-c",
type=click.Path(exists=True),
help="Certificate file (default: ~/.stegasoo/certs/server.crt)",
)
def api_tls_info(cert):
"""Show information about a TLS certificate.
@@ -2075,12 +2364,13 @@ def api_tls_info(cert):
stegasoo api tls info --cert /path/to/server.crt
"""
from cryptography import x509
from cryptography.hazmat.primitives import serialization
if not cert:
cert = Path.home() / ".stegasoo" / "certs" / "server.crt"
if not cert.exists():
raise click.ClickException(f"No certificate found at {cert}. Generate one with: stegasoo api tls generate")
raise click.ClickException(
f"No certificate found at {cert}. Generate one with: stegasoo api tls generate"
)
cert_data = Path(cert).read_bytes()
certificate = x509.load_pem_x509_certificate(cert_data)
@@ -2095,7 +2385,8 @@ def api_tls_info(cert):
# Check expiry
import datetime
now = datetime.datetime.now(datetime.timezone.utc)
now = datetime.datetime.now(datetime.UTC)
if certificate.not_valid_after_utc < now:
click.echo("\nStatus: EXPIRED")
elif certificate.not_valid_after_utc < now + datetime.timedelta(days=30):
@@ -2144,8 +2435,11 @@ def api_serve(host, port, ssl, cert, key, do_reload):
else:
try:
from web.ssl_utils import ensure_certs
base_dir = Path.home() / ".stegasoo"
cert_path, key_path = ensure_certs(base_dir, host if host != "0.0.0.0" else "localhost")
cert_path, key_path = ensure_certs(
base_dir, host if host != "0.0.0.0" else "localhost"
)
except ImportError:
raise click.ClickException("ssl_utils not available")

View File

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

View File

@@ -262,8 +262,7 @@ DCT_STEP_SIZE = 8 # QIM quantization step
# SHA256("\x89ST3\x89DCT") - hardcoded so it never changes even if headers are added
# Used to XOR recovery keys in QR codes so they scan as gibberish
RECOVERY_OBFUSCATION_KEY = bytes.fromhex(
"d6c70bce27780db942562550e9fe1459"
"9dfdb8421f5acc79696b05db4e7afbd2"
"d6c70bce27780db942562550e9fe1459" "9dfdb8421f5acc79696b05db4e7afbd2"
) # 32 bytes
# Valid embedding modes
@@ -297,6 +296,69 @@ def detect_stego_mode(encrypted_data: bytes) -> str:
return "unknown"
# =============================================================================
# FEATURE TOGGLES (v4.3.1)
# =============================================================================
# Environment variables to enable/disable optional feature families.
# Values: "auto" (default — detect dependencies), "1"/"true" (force on),
# "0"/"false" (force off even if deps are installed).
# Pi builds or minimal installs can set STEGASOO_AUDIO=0 to stay image-only.
import os as _os
def _parse_feature_toggle(env_var: str, default: str = "auto") -> str | bool:
"""Parse a feature toggle env var. Returns 'auto', True, or False."""
val = _os.environ.get(env_var, default).strip().lower()
if val in ("1", "true", "yes", "on"):
return True
if val in ("0", "false", "no", "off"):
return False
return "auto"
def _check_audio_deps() -> bool:
"""Check if audio dependencies (soundfile, numpy) are importable."""
try:
import numpy # noqa: F401
import soundfile # noqa: F401
return True
except ImportError:
return False
def _check_video_deps() -> bool:
"""Check if video dependencies (ffmpeg binary + audio deps) are available."""
import shutil
if not _check_audio_deps():
return False
return shutil.which("ffmpeg") is not None
def _resolve_feature(toggle: str | bool, dep_check: callable) -> bool:
"""Resolve a feature toggle to a final bool."""
if toggle is True:
if not dep_check():
raise ImportError(
f"Feature force-enabled but required dependencies are missing. "
f"Install the relevant extras (e.g. pip install stegasoo[audio])."
)
return True
if toggle is False:
return False
# auto
return dep_check()
_audio_toggle = _parse_feature_toggle("STEGASOO_AUDIO")
_video_toggle = _parse_feature_toggle("STEGASOO_VIDEO")
AUDIO_ENABLED: bool = _resolve_feature(_audio_toggle, _check_audio_deps)
VIDEO_ENABLED: bool = _resolve_feature(_video_toggle, _check_video_deps)
# =============================================================================
# AUDIO STEGANOGRAPHY (v4.3.0)
# =============================================================================
@@ -319,10 +381,31 @@ MAX_AUDIO_SAMPLE_RATE = 192000 # Studio quality
ALLOWED_AUDIO_EXTENSIONS = {"wav", "flac", "mp3", "ogg", "opus", "aac", "m4a", "wma"}
# Spread spectrum parameters
AUDIO_SS_CHIP_LENGTH = 1024 # Samples per chip (spreading factor)
AUDIO_SS_CHIP_LENGTH = 1024 # Samples per chip (spreading factor) — legacy/default
AUDIO_SS_AMPLITUDE = 0.05 # Per-sample embedding strength (~-26dB, masked by audio)
AUDIO_SS_RS_NSYM = 32 # Reed-Solomon parity symbols
# Spread spectrum v2: per-channel hybrid embedding (v4.4.0)
AUDIO_SS_HEADER_VERSION = 2 # v2 header format identifier
# Chip tier system — trade capacity for robustness
AUDIO_SS_CHIP_TIER_LOSSLESS = 0 # 256 chips — lossless carriers (FLAC/WAV/ALAC)
AUDIO_SS_CHIP_TIER_HIGH_LOSSY = 1 # 512 chips — high-rate lossy (AAC 256k+)
AUDIO_SS_CHIP_TIER_LOW_LOSSY = 2 # 1024 chips — low-rate lossy (AAC 128k, Opus)
AUDIO_SS_DEFAULT_CHIP_TIER = 2 # Most robust, backward compatible
AUDIO_SS_CHIP_LENGTHS = {0: 256, 1: 512, 2: 1024}
# Chip tier name mapping (for CLI/UI)
AUDIO_SS_CHIP_TIER_NAMES = {
"lossless": AUDIO_SS_CHIP_TIER_LOSSLESS,
"high": AUDIO_SS_CHIP_TIER_HIGH_LOSSY,
"low": AUDIO_SS_CHIP_TIER_LOW_LOSSY,
}
# LFE channel skipping — LFE is bandlimited to ~120Hz, terrible carrier
AUDIO_LFE_CHANNEL_INDEX = 3 # Standard WAV/WAVEFORMATEXTENSIBLE ordering
AUDIO_LFE_MIN_CHANNELS = 6 # Only skip LFE for 5.1+ layouts
# Echo hiding parameters
AUDIO_ECHO_DELAY_0 = 50 # Echo delay for bit 0 (samples at 44.1kHz ~ 1.1ms)
AUDIO_ECHO_DELAY_1 = 100 # Echo delay for bit 1 (samples at 44.1kHz ~ 2.3ms)

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
from .constants import EMBED_MODE_LSB
from .crypto import derive_pixel_key, encrypt_message
from .debug import debug
from .exceptions import AudioError
from .models import EncodeResult, FilePayload
from .steganography import embed_in_image
from .utils import generate_filename
@@ -280,6 +281,7 @@ def encode_audio(
embed_mode: str = "audio_lsb",
channel_key: str | bool | None = None,
progress_file: str | None = None,
chip_tier: int | None = None,
) -> tuple[bytes, AudioEmbedStats]:
"""
Encode a message or file into an audio carrier.
@@ -295,12 +297,21 @@ def encode_audio(
embed_mode: 'audio_lsb' or 'audio_spread'
channel_key: Channel key for deployment/group isolation
progress_file: Optional path to write progress JSON
chip_tier: Spread spectrum chip tier (0=lossless, 1=high_lossy, 2=low_lossy).
Only used for audio_spread mode. Default None → uses constant default.
Returns:
Tuple of (stego audio bytes, AudioEmbedStats)
"""
from .constants import AUDIO_ENABLED, EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_SPREAD
if not AUDIO_ENABLED:
raise AudioError(
"Audio support is disabled. Install audio extras (pip install stegasoo[audio]) "
"or set STEGASOO_AUDIO=1 to force enable."
)
from .audio_utils import detect_audio_format, transcode_to_wav
from .constants import EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_SPREAD
debug.print(
f"encode_audio: mode={embed_mode}, "
@@ -343,10 +354,12 @@ def encode_audio(
encrypted, carrier_audio, pixel_key, progress_file=progress_file
)
elif embed_mode == EMBED_MODE_AUDIO_SPREAD:
from .constants import AUDIO_SS_DEFAULT_CHIP_TIER
from .spread_steganography import embed_in_audio_spread
tier = chip_tier if chip_tier is not None else AUDIO_SS_DEFAULT_CHIP_TIER
stego_audio, stats = embed_in_audio_spread(
encrypted, carrier_audio, pixel_key, progress_file=progress_file
encrypted, carrier_audio, pixel_key, chip_tier=tier, progress_file=progress_file
)
else:
raise ValueError(f"Invalid audio embed mode: {embed_mode}")

View File

@@ -300,6 +300,9 @@ class AudioEmbedStats:
channels: int
duration_seconds: float
embed_mode: str # "audio_lsb" or "audio_spread"
chip_tier: int | None = None # v4.4.0: spread spectrum chip tier (0/1/2)
chip_length: int | None = None # v4.4.0: samples per chip
embeddable_channels: int | None = None # v4.4.0: channels used (excl. LFE)
@property
def modification_percent(self) -> float:
@@ -329,3 +332,7 @@ class AudioCapacityInfo:
embed_mode: str
sample_rate: int
duration_seconds: float
chip_tier: int | None = None # v4.4.0: spread spectrum chip tier
chip_length: int | None = None # v4.4.0: samples per chip
embeddable_channels: int | None = None # v4.4.0: channels used (excl. LFE)
total_channels: int | None = None # v4.4.0: total channels in carrier

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -3,25 +3,37 @@ Tests for Stegasoo audio steganography.
Tests cover:
- Audio LSB roundtrip (encode + decode)
- Audio MDCT roundtrip (encode + decode)
- Audio spread spectrum roundtrip (v0 legacy + v2 per-channel)
- Wrong credentials fail to decode
- Capacity calculations
- Capacity calculations (per-tier)
- Format detection
- Audio validation
- Per-channel stereo/multichannel embedding (v4.4.0)
- Chip tier roundtrips (v4.4.0)
- LFE channel skipping (v4.4.0)
- Backward compat: v0 decode from v2 code
- Header v2 build/parse roundtrip
- Round-robin bit distribution
"""
import io
from pathlib import Path
import numpy as np
import pytest
import soundfile as sf
from stegasoo.constants import (
EMBED_MODE_AUDIO_LSB,
EMBED_MODE_AUDIO_SPREAD,
)
from stegasoo.constants import AUDIO_ENABLED, EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_SPREAD
from stegasoo.models import AudioCapacityInfo, AudioEmbedStats, AudioInfo
pytestmark = pytest.mark.skipif(not AUDIO_ENABLED, reason="Audio support disabled (STEGASOO_AUDIO)")
# Path to real test data files
_TEST_DATA = Path(__file__).parent.parent / "test_data"
_REFERENCE_PNG = _TEST_DATA / "reference.png"
_SPEECH_WAV = _TEST_DATA / "stupid_elitist_speech.wav"
# =============================================================================
# FIXTURES
# =============================================================================
@@ -33,7 +45,6 @@ def carrier_wav() -> bytes:
sample_rate = 44100
duration = 1.0
num_samples = int(sample_rate * duration)
# Generate a simple sine wave
t = np.linspace(0, duration, num_samples, endpoint=False)
samples = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16)
@@ -45,9 +56,9 @@ def carrier_wav() -> bytes:
@pytest.fixture
def carrier_wav_stereo() -> bytes:
"""Generate a stereo test WAV file."""
"""Generate a stereo test WAV file (5 seconds for spread spectrum capacity)."""
sample_rate = 44100
duration = 1.0
duration = 5.0
num_samples = int(sample_rate * duration)
t = np.linspace(0, duration, num_samples, endpoint=False)
left = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16)
@@ -67,7 +78,6 @@ def carrier_wav_long() -> bytes:
duration = 15.0
num_samples = int(sample_rate * duration)
t = np.linspace(0, duration, num_samples, endpoint=False)
# Mix of frequencies for better MDCT embedding
samples = (
(np.sin(2 * np.pi * 440 * t) + np.sin(2 * np.pi * 880 * t) + np.sin(2 * np.pi * 1320 * t))
* 5000
@@ -80,12 +90,47 @@ def carrier_wav_long() -> bytes:
@pytest.fixture
def carrier_wav_spread_integration() -> bytes:
"""Generate a very long WAV (150 seconds) for spread spectrum integration tests.
def carrier_wav_stereo_long() -> bytes:
"""Generate a stereo WAV (15 seconds) for per-channel spread tests."""
sample_rate = 48000
duration = 15.0
num_samples = int(sample_rate * duration)
t = np.linspace(0, duration, num_samples, endpoint=False)
left = (np.sin(2 * np.pi * 440 * t) * 10000).astype(np.float64) / 32768.0
right = (np.sin(2 * np.pi * 660 * t) * 10000).astype(np.float64) / 32768.0
samples = np.column_stack([left, right])
Spread spectrum needs 1024 samples per bit. With encryption + RS overhead (~690 bytes),
we need at least 690*8*1024 = 5.7M samples ~ 130 seconds at 44.1kHz.
"""
buf = io.BytesIO()
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
buf.seek(0)
return buf.read()
@pytest.fixture
def carrier_wav_5_1() -> bytes:
"""Generate a 6-channel (5.1) WAV for LFE skip tests."""
sample_rate = 48000
duration = 15.0
num_samples = int(sample_rate * duration)
t = np.linspace(0, duration, num_samples, endpoint=False)
# 6 channels with different frequencies
freqs = [440, 554, 660, 80, 880, 1100] # ch3 = LFE (low freq)
channels = []
for freq in freqs:
ch = (np.sin(2 * np.pi * freq * t) * 8000).astype(np.float64) / 32768.0
channels.append(ch)
samples = np.column_stack(channels)
buf = io.BytesIO()
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
buf.seek(0)
return buf.read()
@pytest.fixture
def carrier_wav_spread_integration() -> bytes:
"""Generate a very long WAV (150 seconds) for spread spectrum integration tests."""
sample_rate = 44100
duration = 150.0
num_samples = int(sample_rate * duration)
@@ -103,7 +148,9 @@ def carrier_wav_spread_integration() -> bytes:
@pytest.fixture
def reference_photo() -> bytes:
"""Generate a small reference photo (PNG)."""
"""Load real reference photo from test_data, or generate a small one."""
if _REFERENCE_PNG.exists():
return _REFERENCE_PNG.read_bytes()
from PIL import Image
img = Image.new("RGB", (100, 100), color=(128, 64, 32))
@@ -113,6 +160,14 @@ def reference_photo() -> bytes:
return buf.read()
@pytest.fixture
def speech_wav() -> bytes:
"""Load real speech WAV from test_data (48kHz mono, ~68s)."""
if not _SPEECH_WAV.exists():
pytest.skip("test_data/stupid_elitist_speech.wav not found")
return _SPEECH_WAV.read_bytes()
# =============================================================================
# AUDIO LSB TESTS
# =============================================================================
@@ -134,7 +189,6 @@ class TestAudioLSB:
from stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb
payload = b"Hello, audio steganography!"
# Prepend with magic header to simulate real usage pattern
key = b"\x42" * 32
stego_audio, stats = embed_in_audio_lsb(payload, carrier_wav, key)
@@ -145,7 +199,6 @@ class TestAudioLSB:
assert stats.samples_modified > 0
assert 0 < stats.capacity_used <= 1.0
# Extract
extracted = extract_from_audio_lsb(stego_audio, key)
assert extracted is not None
assert extracted == payload
@@ -174,7 +227,6 @@ class TestAudioLSB:
stego_audio, _ = embed_in_audio_lsb(payload, carrier_wav, correct_key)
extracted = extract_from_audio_lsb(stego_audio, wrong_key)
# Should return None or garbage (not the original message)
assert extracted is None or extracted != payload
def test_two_bits_per_sample(self, carrier_wav):
@@ -197,46 +249,97 @@ class TestAudioLSB:
indices1 = generate_sample_indices(key, 10000, 100)
indices2 = generate_sample_indices(key, 10000, 100)
# Same key should produce same indices
assert indices1 == indices2
# All indices should be valid
assert all(0 <= i < 10000 for i in indices1)
# No duplicates
assert len(set(indices1)) == len(indices1)
# =============================================================================
# AUDIO SPREAD SPECTRUM TESTS
# AUDIO SPREAD SPECTRUM TESTS (v2 per-channel)
# =============================================================================
class TestAudioSpread:
"""Tests for audio spread spectrum steganography."""
"""Tests for audio spread spectrum steganography (v2 per-channel)."""
def test_calculate_capacity(self, carrier_wav_long):
def test_calculate_capacity_default_tier(self, carrier_wav_long):
from stegasoo.spread_steganography import calculate_audio_spread_capacity
capacity = calculate_audio_spread_capacity(carrier_wav_long)
assert isinstance(capacity, AudioCapacityInfo)
assert capacity.usable_capacity_bytes > 0
assert capacity.embed_mode == EMBED_MODE_AUDIO_SPREAD
assert capacity.chip_tier == 2 # default
assert capacity.chip_length == 1024
def test_spread_roundtrip(self, carrier_wav_long):
"""Test spread spectrum embed/extract roundtrip."""
def test_calculate_capacity_per_tier(self, carrier_wav_long):
"""Capacity should increase as chip length decreases."""
from stegasoo.spread_steganography import calculate_audio_spread_capacity
cap_lossless = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=0)
cap_high = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=1)
cap_low = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=2)
assert cap_lossless.chip_length == 256
assert cap_high.chip_length == 512
assert cap_low.chip_length == 1024
# Smaller chip = more capacity
assert cap_lossless.usable_capacity_bytes > cap_high.usable_capacity_bytes
assert cap_high.usable_capacity_bytes > cap_low.usable_capacity_bytes
def test_spread_roundtrip_default_tier(self, carrier_wav_long):
"""Test spread spectrum embed/extract roundtrip (default tier 2)."""
from stegasoo.spread_steganography import (
embed_in_audio_spread,
extract_from_audio_spread,
)
payload = b"Spread test"
payload = b"Spread test v2"
seed = b"\x42" * 32
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed)
assert isinstance(stats, AudioEmbedStats)
assert stats.embed_mode == EMBED_MODE_AUDIO_SPREAD
assert stats.chip_tier == 2
assert stats.chip_length == 1024
extracted = extract_from_audio_spread(stego_audio, seed)
assert extracted is not None
assert extracted == payload
def test_spread_roundtrip_tier_0(self, carrier_wav_long):
"""Test spread spectrum at tier 0 (chip=256, lossless)."""
from stegasoo.spread_steganography import (
embed_in_audio_spread,
extract_from_audio_spread,
)
payload = b"Lossless tier test with more data to embed for coverage"
seed = b"\x42" * 32
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed, chip_tier=0)
assert stats.chip_tier == 0
assert stats.chip_length == 256
extracted = extract_from_audio_spread(stego_audio, seed)
assert extracted is not None
assert extracted == payload
def test_spread_roundtrip_tier_1(self, carrier_wav_long):
"""Test spread spectrum at tier 1 (chip=512, high lossy)."""
from stegasoo.spread_steganography import (
embed_in_audio_spread,
extract_from_audio_spread,
)
payload = b"High lossy tier test"
seed = b"\x42" * 32
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed, chip_tier=1)
assert stats.chip_tier == 1
assert stats.chip_length == 512
extracted = extract_from_audio_spread(stego_audio, seed)
assert extracted is not None
@@ -258,6 +361,258 @@ class TestAudioSpread:
extracted = extract_from_audio_spread(stego_audio, wrong_seed)
assert extracted is None or extracted != payload
def test_per_channel_stereo_roundtrip(self, carrier_wav_stereo_long):
"""Test that stereo per-channel embedding/extraction works."""
from stegasoo.spread_steganography import (
embed_in_audio_spread,
extract_from_audio_spread,
)
payload = b"Stereo per-channel test"
seed = b"\xAB" * 32
stego_audio, stats = embed_in_audio_spread(
payload, carrier_wav_stereo_long, seed, chip_tier=0
)
assert stats.channels == 2
assert stats.embeddable_channels == 2
extracted = extract_from_audio_spread(stego_audio, seed)
assert extracted is not None
assert extracted == payload
def test_per_channel_preserves_spatial_mix(self, carrier_wav_stereo_long):
"""Verify that per-channel embedding doesn't destroy the spatial mix.
The difference between left and right channels should be preserved
(not zeroed out as the old mono-broadcast approach would do).
"""
from stegasoo.spread_steganography import embed_in_audio_spread
payload = b"Spatial preservation test"
seed = b"\xCD" * 32
# Read original
orig_samples, _ = sf.read(io.BytesIO(carrier_wav_stereo_long), dtype="float64", always_2d=True)
orig_diff = orig_samples[:, 0] - orig_samples[:, 1]
# Embed
stego_bytes, _ = embed_in_audio_spread(
payload, carrier_wav_stereo_long, seed, chip_tier=0
)
# Read stego
stego_samples, _ = sf.read(io.BytesIO(stego_bytes), dtype="float64", always_2d=True)
stego_diff = stego_samples[:, 0] - stego_samples[:, 1]
# The channel difference should not be identical (embedding adds different
# noise per channel), but should be very close (embedding is subtle)
# With the old mono-broadcast approach, stego_diff would equal orig_diff
# exactly in unmodified regions but differ where data was embedded.
# With per-channel, both channels get independent modifications.
correlation = np.corrcoef(orig_diff, stego_diff)[0, 1]
assert correlation > 0.95, f"Spatial mix correlation too low: {correlation}"
def test_capacity_scales_with_channels(self, carrier_wav_long, carrier_wav_stereo_long):
"""Stereo should have roughly double the capacity of mono."""
from stegasoo.spread_steganography import calculate_audio_spread_capacity
mono_cap = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=0)
stereo_cap = calculate_audio_spread_capacity(carrier_wav_stereo_long, chip_tier=0)
# Stereo should be ~1.5-2.2x mono (not exact because header is ch0 only
# and the files have slightly different durations/sample rates)
ratio = stereo_cap.usable_capacity_bytes / mono_cap.usable_capacity_bytes
assert ratio > 1.3, f"Stereo/mono capacity ratio too low: {ratio}"
def test_lfe_skip_5_1(self, carrier_wav_5_1):
"""LFE channel (index 3) should be unmodified in 6-channel audio."""
from stegasoo.spread_steganography import embed_in_audio_spread
payload = b"LFE skip test"
seed = b"\xEE" * 32
# Read original LFE channel
orig_samples, _ = sf.read(io.BytesIO(carrier_wav_5_1), dtype="float64", always_2d=True)
orig_lfe = orig_samples[:, 3].copy()
stego_bytes, stats = embed_in_audio_spread(
payload, carrier_wav_5_1, seed, chip_tier=0
)
assert stats.embeddable_channels == 5 # 6 channels - 1 LFE = 5
stego_samples, _ = sf.read(io.BytesIO(stego_bytes), dtype="float64", always_2d=True)
stego_lfe = stego_samples[:, 3]
# LFE channel should be completely unmodified
np.testing.assert_array_equal(orig_lfe, stego_lfe)
def test_lfe_skip_roundtrip(self, carrier_wav_5_1):
"""5.1 audio embed/extract roundtrip with LFE skipping."""
from stegasoo.spread_steganography import (
embed_in_audio_spread,
extract_from_audio_spread,
)
payload = b"5.1 surround test"
seed = b"\xEE" * 32
stego_bytes, stats = embed_in_audio_spread(
payload, carrier_wav_5_1, seed, chip_tier=0
)
assert stats.channels == 6
assert stats.embeddable_channels == 5
extracted = extract_from_audio_spread(stego_bytes, seed)
assert extracted is not None
assert extracted == payload
# =============================================================================
# HEADER V2 TESTS
# =============================================================================
class TestHeaderV2:
"""Tests for v2 header construction and parsing."""
def test_header_v2_build_parse_roundtrip(self):
from stegasoo.spread_steganography import _build_header_v2, _parse_header
data_length = 12345
chip_tier = 1
num_ch = 2
lfe_skipped = False
header = _build_header_v2(data_length, chip_tier, num_ch, lfe_skipped)
assert len(header) == 20
magic_valid, version, length, tier, nch, lfe = _parse_header(header)
assert magic_valid
assert version == 2
assert length == data_length
assert tier == chip_tier
assert nch == num_ch
assert lfe is False
def test_header_v2_with_lfe_flag(self):
from stegasoo.spread_steganography import _build_header_v2, _parse_header
header = _build_header_v2(999, 0, 5, lfe_skipped=True)
magic_valid, version, length, tier, nch, lfe = _parse_header(header)
assert magic_valid
assert version == 2
assert length == 999
assert tier == 0
assert nch == 5
assert lfe is True
def test_header_v0_build_parse(self):
from stegasoo.spread_steganography import _build_header_v0, _parse_header
header = _build_header_v0(4567)
assert len(header) == 16
magic_valid, version, length, tier, nch, lfe = _parse_header(header)
assert magic_valid
assert version == 0
assert length == 4567
assert tier is None
assert nch is None
def test_header_bad_magic(self):
from stegasoo.spread_steganography import _parse_header
bad_header = b"XXXX" + b"\x00" * 16
magic_valid, version, length, tier, nch, lfe = _parse_header(bad_header)
assert not magic_valid
# =============================================================================
# ROUND-ROBIN BIT DISTRIBUTION TESTS
# =============================================================================
class TestRoundRobin:
"""Tests for round-robin bit distribution."""
def test_distribute_and_collect_identity(self):
from stegasoo.spread_steganography import (
_collect_bits_round_robin,
_distribute_bits_round_robin,
)
bits = [1, 0, 1, 1, 0, 0, 1, 0, 1, 1]
for num_ch in [1, 2, 3, 4, 5]:
per_ch = _distribute_bits_round_robin(bits, num_ch)
assert len(per_ch) == num_ch
reassembled = _collect_bits_round_robin(per_ch)
assert reassembled == bits, f"Failed for {num_ch} channels"
def test_distribute_round_robin_ordering(self):
from stegasoo.spread_steganography import _distribute_bits_round_robin
bits = [0, 1, 2, 3, 4, 5] # using ints for clarity
per_ch = _distribute_bits_round_robin(bits, 3)
# ch0: bits 0, 3 ch1: bits 1, 4 ch2: bits 2, 5
assert per_ch[0] == [0, 3]
assert per_ch[1] == [1, 4]
assert per_ch[2] == [2, 5]
def test_distribute_uneven(self):
from stegasoo.spread_steganography import (
_collect_bits_round_robin,
_distribute_bits_round_robin,
)
bits = [0, 1, 2, 3, 4] # 5 bits across 3 channels
per_ch = _distribute_bits_round_robin(bits, 3)
assert per_ch[0] == [0, 3]
assert per_ch[1] == [1, 4]
assert per_ch[2] == [2]
reassembled = _collect_bits_round_robin(per_ch)
assert reassembled == bits
# =============================================================================
# CHANNEL MANAGEMENT TESTS
# =============================================================================
class TestChannelManagement:
"""Tests for embeddable channel selection."""
def test_mono(self):
from stegasoo.spread_steganography import _embeddable_channels
assert _embeddable_channels(1) == [0]
def test_stereo(self):
from stegasoo.spread_steganography import _embeddable_channels
assert _embeddable_channels(2) == [0, 1]
def test_5_1_skips_lfe(self):
from stegasoo.spread_steganography import _embeddable_channels
channels = _embeddable_channels(6)
assert channels == [0, 1, 2, 4, 5]
assert 3 not in channels # LFE skipped
def test_7_1_skips_lfe(self):
from stegasoo.spread_steganography import _embeddable_channels
channels = _embeddable_channels(8)
assert 3 not in channels
assert len(channels) == 7
def test_quad_no_skip(self):
from stegasoo.spread_steganography import _embeddable_channels
# 4 channels < 6, so no LFE skip
assert _embeddable_channels(4) == [0, 1, 2, 3]
# =============================================================================
# FORMAT DETECTION TESTS
@@ -423,6 +778,36 @@ class TestIntegration:
assert result.message == "Spread integration test"
def test_spread_encode_decode_with_chip_tier(
self, carrier_wav_spread_integration, reference_photo
):
"""Test spread spectrum with explicit chip tier."""
from stegasoo.decode import decode_audio
from stegasoo.encode import encode_audio
stego_audio, stats = encode_audio(
message="Tier 0 integration",
reference_photo=reference_photo,
carrier_audio=carrier_wav_spread_integration,
passphrase="test words here now",
pin="123456",
embed_mode="audio_spread",
chip_tier=0,
)
assert stats.chip_tier == 0
assert stats.chip_length == 256
result = decode_audio(
stego_audio=stego_audio,
reference_photo=reference_photo,
passphrase="test words here now",
pin="123456",
embed_mode="audio_spread",
)
assert result.message == "Tier 0 integration"
def test_auto_detect_lsb(self, carrier_wav, reference_photo):
"""Test auto-detection finds LSB encoded audio."""
from stegasoo.decode import decode_audio
@@ -446,3 +831,32 @@ class TestIntegration:
)
assert result.message == "Auto-detect test"
def test_spread_with_real_speech(self, speech_wav, reference_photo):
"""Test spread spectrum with real speech audio from test_data."""
from stegasoo.decode import decode_audio
from stegasoo.encode import encode_audio
message = "Hidden in a speech about elitism"
stego_audio, stats = encode_audio(
message=message,
reference_photo=reference_photo,
carrier_audio=speech_wav,
passphrase="test words here now",
pin="123456",
embed_mode="audio_spread",
chip_tier=0, # lossless tier for max capacity
)
assert stats.chip_tier == 0
result = decode_audio(
stego_audio=stego_audio,
reference_photo=reference_photo,
passphrase="test words here now",
pin="123456",
embed_mode="audio_spread",
)
assert result.message == message