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:
20
CHANGELOG.md
20
CHANGELOG.md
@@ -5,6 +5,25 @@ All notable changes to Stegasoo will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [4.3.0] - 2026-02-27
|
||||
|
||||
### Added
|
||||
- **Audio Steganography** — Hide messages in audio files (WAV, FLAC, MP3, OGG, AAC, M4A)
|
||||
- LSB mode: Direct least-significant-bit embedding in audio samples
|
||||
- Spread Spectrum mode: Noise-resistant encoding using pseudo-random spreading
|
||||
- Automatic format transcoding to WAV for embedding
|
||||
- Full CLI support: `stegasoo audio-encode`, `audio-decode`, `audio-info`
|
||||
- REST API endpoints: `/audio/encode`, `/audio/decode`, `/audio/info`
|
||||
- Web UI: Unified encode/decode pages with carrier type selector (Image/Audio)
|
||||
- New `AudioCapacityInfo`, `AudioEmbedStats`, `AudioInfo` model classes
|
||||
- Audio-specific exceptions: `AudioError`, `AudioValidationError`, `AudioCapacityError`, `AudioExtractionError`, `AudioTranscodeError`, `UnsupportedAudioFormatError`
|
||||
- Subprocess isolation for audio operations (crash protection)
|
||||
- `debug.py` module for structured logging across all steganography operations
|
||||
|
||||
### Changed
|
||||
- Encode/Decode web pages now have a "Carrier Type" step to switch between Image and Audio
|
||||
- Version bumped to 4.3.0
|
||||
|
||||
## [4.1.5] - 2026-01-07
|
||||
|
||||
### Added
|
||||
@@ -201,6 +220,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
- CLI interface
|
||||
- Basic PIN authentication
|
||||
|
||||
[4.3.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.2.1...v4.3.0
|
||||
[4.1.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5
|
||||
[4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3
|
||||
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0
|
||||
|
||||
@@ -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)
|
||||
|
||||
12
README.md
12
README.md
@@ -1,6 +1,6 @@
|
||||
# Stegasoo
|
||||
|
||||
A secure steganography system for hiding encrypted messages in images using hybrid authentication.
|
||||
A secure steganography system for hiding encrypted messages in images and audio using hybrid authentication.
|
||||
|
||||
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml)
|
||||
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml)
|
||||
@@ -17,15 +17,25 @@ A secure steganography system for hiding encrypted messages in images using hybr
|
||||
- **Multiple interfaces**: CLI, Web UI, REST API
|
||||
- **File embedding**: Hide any file type (PDF, ZIP, documents)
|
||||
- **DCT steganography**: JPEG-resilient embedding for social media
|
||||
- **Audio steganography**: Hide messages in WAV, FLAC, MP3, OGG, AAC, M4A files (LSB and Spread Spectrum modes)
|
||||
- **Channel keys**: Private group communication channels
|
||||
|
||||
## Embedding Modes
|
||||
|
||||
### Image Modes
|
||||
|
||||
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|
||||
|------|------------------|----------------|----------|
|
||||
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
|
||||
| **LSB** | ~750 KB | No | Email, direct file transfer |
|
||||
|
||||
### Audio Modes
|
||||
|
||||
| Mode | Capacity (5 min WAV) | Noise Resistant | Best For |
|
||||
|------|---------------------|-----------------|----------|
|
||||
| **LSB** | ~1.3 MB | No | Direct file transfer |
|
||||
| **Spread Spectrum** | ~160 KB | Yes | Shared files, light processing |
|
||||
|
||||
## Web UI
|
||||
|
||||
| Home | Encode | Decode | Generate |
|
||||
|
||||
@@ -1,3 +1,45 @@
|
||||
# v4.3.0 — Audio Steganography
|
||||
|
||||
**Release Date:** 2026-02-27
|
||||
|
||||
## Highlights
|
||||
|
||||
Stegasoo can now hide messages in audio files! This release adds full audio steganography support with two embedding modes:
|
||||
|
||||
- **LSB (Least Significant Bit)**: Embeds data directly in audio sample LSBs. High capacity, best for direct file transfers.
|
||||
- **Spread Spectrum**: Spreads data across audio frequencies using pseudo-random sequences. Lower capacity but more resistant to noise and light processing.
|
||||
|
||||
## What's New
|
||||
|
||||
### Audio Steganography
|
||||
- Support for WAV, FLAC, MP3, OGG, AAC, and M4A input formats
|
||||
- Automatic transcoding to WAV (16-bit PCM) for embedding
|
||||
- Same security model: reference photo + passphrase + PIN/RSA + channel key
|
||||
- Full CLI, REST API, and Web UI support
|
||||
|
||||
### Unified Web UI
|
||||
- Encode and Decode pages now feature a "Carrier Type" selector
|
||||
- Switch between Image and Audio modes without leaving the page
|
||||
- Audio capacity display shows LSB and Spread Spectrum capacities
|
||||
- Audio preview player on encode result page
|
||||
|
||||
### New Modules
|
||||
- `audio_steganography.py` — LSB audio embedding/extraction
|
||||
- `spread_steganography.py` — Spread spectrum embedding/extraction
|
||||
- `audio_utils.py` — Audio format detection, validation, transcoding
|
||||
- `debug.py` — Structured logging for all operations
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
Audio steganography requires `numpy` and `soundfile` packages. Install with:
|
||||
```bash
|
||||
pip install stegasoo[audio]
|
||||
```
|
||||
|
||||
For full audio format support (MP3, AAC, etc.), install FFmpeg on your system.
|
||||
|
||||
---
|
||||
|
||||
## Stegasoo v4.2.1
|
||||
|
||||
### API Security
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||
pkgname=stegasoo-api-git
|
||||
pkgver=4.2.1
|
||||
pkgver=4.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Stegasoo REST API with TLS and API key authentication"
|
||||
arch=('x86_64')
|
||||
@@ -30,7 +30,7 @@ sha256sums=('SKIP')
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||
pkgname=stegasoo-cli-git
|
||||
pkgver=4.2.1
|
||||
pkgver=4.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Secure steganography CLI with hybrid photo + passphrase + PIN authentication"
|
||||
arch=('x86_64')
|
||||
@@ -29,7 +29,7 @@ sha256sums=('SKIP')
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||
pkgname=stegasoo-git
|
||||
pkgver=4.2.1
|
||||
pkgver=4.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
arch=('x86_64')
|
||||
@@ -27,7 +27,7 @@ sha256sums=('SKIP')
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Stegasoo man page
|
||||
.\" Generate with: groff -man -Tascii stegasoo.1
|
||||
.TH STEGASOO 1 "January 2026" "Stegasoo 4.1.7" "User Commands"
|
||||
.TH STEGASOO 1 "February 2026" "Stegasoo 4.3.0" "User Commands"
|
||||
.SH NAME
|
||||
stegasoo \- steganography with hybrid authentication
|
||||
.SH SYNOPSIS
|
||||
@@ -12,9 +12,10 @@ stegasoo \- steganography with hybrid authentication
|
||||
[\fIargs\fR]
|
||||
.SH DESCRIPTION
|
||||
.B stegasoo
|
||||
hides messages and files in images using PIN + passphrase security.
|
||||
hides messages and files in images and audio using PIN + passphrase security.
|
||||
It uses LSB (Least Significant Bit) steganography with optional DCT
|
||||
(Discrete Cosine Transform) encoding for JPEG resilience.
|
||||
(Discrete Cosine Transform) encoding for JPEG resilience, and supports
|
||||
audio steganography with LSB and Spread Spectrum modes.
|
||||
.PP
|
||||
Messages are encrypted using a hybrid authentication scheme that combines
|
||||
a reference photo (shared secret), passphrase, and PIN code.
|
||||
@@ -221,6 +222,83 @@ Reset admin password using recovery key.
|
||||
.PP
|
||||
Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR.
|
||||
.RE
|
||||
.SS audio\-encode
|
||||
Encode a message or file into an audio file.
|
||||
.PP
|
||||
.B stegasoo audio\-encode
|
||||
.I audio
|
||||
.B \-r
|
||||
.I reference
|
||||
[\fB\-m\fR \fImessage\fR | \fB\-f\fR \fIfile\fR]
|
||||
[\fIoptions\fR]
|
||||
.TP
|
||||
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||
Reference photo (shared secret). Required.
|
||||
.TP
|
||||
.BR \-m ", " \-\-message " " \fITEXT\fR
|
||||
Message to encode.
|
||||
.TP
|
||||
.BR \-f ", " \-\-file " " \fIPATH\fR
|
||||
File to embed instead of message.
|
||||
.TP
|
||||
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||
Output audio path.
|
||||
.TP
|
||||
.B \-\-passphrase " " \fITEXT\fR
|
||||
Passphrase (recommend 4+ words). Prompts if not provided.
|
||||
.TP
|
||||
.B \-\-pin " " \fITEXT\fR
|
||||
PIN code. Prompts if not provided.
|
||||
.TP
|
||||
.B \-\-mode " " [\fIlsb\fR|\fIspread\fR]
|
||||
Embedding mode: lsb (default) or spread (spread spectrum).
|
||||
.PP
|
||||
.B Examples:
|
||||
.nf
|
||||
stegasoo audio-encode song.wav -r ref.jpg -m "Secret" --passphrase --pin
|
||||
stegasoo audio-encode podcast.mp3 -r ref.jpg -f doc.pdf --mode spread
|
||||
.fi
|
||||
.SS audio\-decode
|
||||
Decode a message or file from a stego audio file.
|
||||
.PP
|
||||
.B stegasoo audio\-decode
|
||||
.I audio
|
||||
.B \-r
|
||||
.I reference
|
||||
[\fIoptions\fR]
|
||||
.TP
|
||||
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||
Reference photo (shared secret). Required.
|
||||
.TP
|
||||
.B \-\-passphrase " " \fITEXT\fR
|
||||
Passphrase. Prompts if not provided.
|
||||
.TP
|
||||
.B \-\-pin " " \fITEXT\fR
|
||||
PIN code. Prompts if not provided.
|
||||
.TP
|
||||
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||
Output path for file payloads.
|
||||
.PP
|
||||
.B Examples:
|
||||
.nf
|
||||
stegasoo audio-decode stego.wav -r ref.jpg --passphrase --pin
|
||||
stegasoo audio-decode stego.wav -r ref.jpg -o ./extracted/
|
||||
.fi
|
||||
.SS audio\-info
|
||||
Display audio file information and steganographic capacity.
|
||||
.PP
|
||||
.B stegasoo audio\-info
|
||||
.I audio
|
||||
[\fB\-\-json\fR]
|
||||
.PP
|
||||
Shows format, sample rate, channels, bit depth, duration, and embedding
|
||||
capacity for both LSB and Spread Spectrum modes.
|
||||
.PP
|
||||
.B Examples:
|
||||
.nf
|
||||
stegasoo audio-info song.wav
|
||||
stegasoo audio-info podcast.mp3 --json
|
||||
.fi
|
||||
.SS tools
|
||||
Image security tools.
|
||||
.PP
|
||||
|
||||
@@ -17,9 +17,8 @@ import json
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
from fastapi import HTTPException, Security
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
# API key header name
|
||||
@@ -55,7 +54,7 @@ def _load_keys(location: str = "user") -> dict:
|
||||
try:
|
||||
with open(keys_file) as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"keys": [], "enabled": True}
|
||||
return {"keys": [], "enabled": True}
|
||||
|
||||
@@ -101,11 +100,13 @@ def add_api_key(name: str, location: str = "user") -> str:
|
||||
if existing["name"] == name:
|
||||
raise ValueError(f"Key with name '{name}' already exists")
|
||||
|
||||
data["keys"].append({
|
||||
data["keys"].append(
|
||||
{
|
||||
"name": name,
|
||||
"hash": key_hash,
|
||||
"created": __import__("datetime").datetime.now().isoformat(),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
_save_keys(data, location)
|
||||
|
||||
@@ -204,12 +205,12 @@ def get_api_key_status() -> dict:
|
||||
"keys": {
|
||||
"user": user_keys,
|
||||
"project": project_keys,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# FastAPI dependency for API key authentication
|
||||
async def require_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> str:
|
||||
async def require_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str:
|
||||
"""
|
||||
FastAPI dependency that requires a valid API key.
|
||||
|
||||
@@ -243,7 +244,7 @@ async def require_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) ->
|
||||
return api_key
|
||||
|
||||
|
||||
async def optional_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> Optional[str]:
|
||||
async def optional_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str | None:
|
||||
"""
|
||||
FastAPI dependency that optionally validates API key.
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stegasoo REST API (v4.2.1)
|
||||
Stegasoo REST API (v4.3.0)
|
||||
|
||||
FastAPI-based REST API for steganography operations.
|
||||
Supports both text messages and file embedding.
|
||||
|
||||
CHANGES in v4.3.0:
|
||||
- Audio steganography endpoints (/audio/*)
|
||||
- LSB and spread spectrum (DSSS) audio embedding modes
|
||||
- Audio info and capacity checking
|
||||
|
||||
CHANGES in v4.2.1:
|
||||
- API key authentication (X-API-Key header)
|
||||
- TLS support with self-signed certificates
|
||||
@@ -32,11 +37,31 @@ NEW in v3.0.1: DCT color mode and JPEG output format.
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
# Configure logging for API frontend
|
||||
_log_level = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
|
||||
if _log_level and hasattr(logging, _log_level):
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, _log_level),
|
||||
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
elif os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
api_logger = logging.getLogger("stegasoo.api")
|
||||
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -44,28 +69,28 @@ from pydantic import BaseModel, Field
|
||||
# API Key Authentication
|
||||
try:
|
||||
from .auth import (
|
||||
require_api_key,
|
||||
get_api_key_status,
|
||||
add_api_key,
|
||||
remove_api_key,
|
||||
list_api_keys,
|
||||
get_api_key_status,
|
||||
is_auth_enabled,
|
||||
list_api_keys,
|
||||
remove_api_key,
|
||||
require_api_key,
|
||||
)
|
||||
except ImportError:
|
||||
# When running directly (not as package)
|
||||
from auth import (
|
||||
require_api_key,
|
||||
get_api_key_status,
|
||||
add_api_key,
|
||||
remove_api_key,
|
||||
get_api_key_status,
|
||||
list_api_keys,
|
||||
is_auth_enabled,
|
||||
remove_api_key,
|
||||
require_api_key,
|
||||
)
|
||||
|
||||
# Add parent to path for development
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from stegasoo import (
|
||||
HAS_AUDIO_SUPPORT,
|
||||
MAX_FILE_PAYLOAD_SIZE,
|
||||
CapacityError,
|
||||
DecryptionError,
|
||||
@@ -87,6 +112,12 @@ from stegasoo import (
|
||||
validate_image,
|
||||
will_fit_by_mode,
|
||||
)
|
||||
|
||||
# Audio steganography (v4.3.0) - conditionally imported
|
||||
if HAS_AUDIO_SUPPORT:
|
||||
from stegasoo import decode_audio, encode_audio, get_audio_info
|
||||
from stegasoo.audio_steganography import calculate_audio_lsb_capacity
|
||||
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
||||
from stegasoo.constants import (
|
||||
DEFAULT_PASSPHRASE_WORDS,
|
||||
MAX_PASSPHRASE_WORDS,
|
||||
@@ -163,6 +194,8 @@ EmbedModeType = Literal["lsb", "dct"]
|
||||
ExtractModeType = Literal["auto", "lsb", "dct"]
|
||||
DctColorModeType = Literal["grayscale", "color"]
|
||||
DctOutputFormatType = Literal["png", "jpeg"]
|
||||
AudioEmbedModeType = Literal["audio_lsb", "audio_spread"]
|
||||
AudioExtractModeType = Literal["audio_auto", "audio_lsb", "audio_spread"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -405,6 +438,7 @@ class ModesResponse(BaseModel):
|
||||
|
||||
lsb: dict
|
||||
dct: DctModeInfo
|
||||
audio: dict | None = Field(default=None, description="Audio steganography modes (v4.3.0)")
|
||||
# Channel key status (v4.0.0)
|
||||
channel: dict | None = Field(default=None, description="Channel key status (v4.0.0)")
|
||||
|
||||
@@ -415,6 +449,7 @@ class StatusResponse(BaseModel):
|
||||
has_qrcode_read: bool
|
||||
has_qrcode_write: bool # v4.2.0: QR generation capability
|
||||
has_dct: bool
|
||||
has_audio: bool = Field(default=False, description="Audio steganography support (v4.3.0)")
|
||||
max_payload_kb: int
|
||||
available_modes: list[str]
|
||||
dct_features: dict | None = Field(default=None, description="DCT mode features (v3.0.1+)")
|
||||
@@ -479,6 +514,124 @@ class ErrorResponse(BaseModel):
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
# --- Audio models (v4.3.0) ---
|
||||
|
||||
|
||||
class AudioEncodeRequest(BaseModel):
|
||||
"""Request to encode a text message into audio."""
|
||||
|
||||
message: str
|
||||
reference_photo_base64: str
|
||||
carrier_audio_base64: str
|
||||
passphrase: str = Field(description="Passphrase for key derivation")
|
||||
pin: str = ""
|
||||
rsa_key_base64: str | None = None
|
||||
rsa_password: str | None = None
|
||||
channel_key: str | None = Field(
|
||||
default=None,
|
||||
description="Channel key for deployment isolation. null=auto, ''=public, 'XXXX-...'=explicit",
|
||||
)
|
||||
embed_mode: AudioEmbedModeType = Field(
|
||||
default="audio_lsb",
|
||||
description="Embedding mode: 'audio_lsb' (default) or 'audio_spread' (DSSS)",
|
||||
)
|
||||
chip_tier: int | None = Field(
|
||||
default=None,
|
||||
description="Spread spectrum chip tier: 0=lossless(256), 1=high_lossy(512), 2=low_lossy(1024). Only for audio_spread.",
|
||||
)
|
||||
|
||||
|
||||
class AudioEncodeFileRequest(BaseModel):
|
||||
"""Request to encode a file into audio."""
|
||||
|
||||
file_data_base64: str
|
||||
filename: str
|
||||
mime_type: str | None = None
|
||||
reference_photo_base64: str
|
||||
carrier_audio_base64: str
|
||||
passphrase: str = Field(description="Passphrase for key derivation")
|
||||
pin: str = ""
|
||||
rsa_key_base64: str | None = None
|
||||
rsa_password: str | None = None
|
||||
channel_key: str | None = Field(
|
||||
default=None,
|
||||
description="Channel key for deployment isolation. null=auto, ''=public, 'XXXX-...'=explicit",
|
||||
)
|
||||
embed_mode: AudioEmbedModeType = Field(
|
||||
default="audio_lsb",
|
||||
description="Embedding mode: 'audio_lsb' (default) or 'audio_spread' (DSSS)",
|
||||
)
|
||||
chip_tier: int | None = Field(
|
||||
default=None,
|
||||
description="Spread spectrum chip tier: 0=lossless(256), 1=high_lossy(512), 2=low_lossy(1024). Only for audio_spread.",
|
||||
)
|
||||
|
||||
|
||||
class AudioEncodeResponse(BaseModel):
|
||||
"""Response from audio encode operations."""
|
||||
|
||||
stego_audio_base64: str
|
||||
embed_mode: str = Field(description="Embedding mode used: 'audio_lsb' or 'audio_spread'")
|
||||
stats: dict = Field(description="Embedding statistics (samples_modified, capacity_used, etc.)")
|
||||
channel_mode: str = Field(default="public", description="Channel mode: 'public' or 'private'")
|
||||
channel_fingerprint: str | None = Field(
|
||||
default=None, description="Channel key fingerprint (if private mode)"
|
||||
)
|
||||
|
||||
|
||||
class AudioDecodeRequest(BaseModel):
|
||||
"""Request to decode a message or file from stego audio."""
|
||||
|
||||
stego_audio_base64: str
|
||||
reference_photo_base64: str
|
||||
passphrase: str = Field(description="Passphrase for key derivation")
|
||||
pin: str = ""
|
||||
rsa_key_base64: str | None = None
|
||||
rsa_password: str | None = None
|
||||
channel_key: str | None = Field(
|
||||
default=None,
|
||||
description="Channel key for decryption. null=auto, ''=public, 'XXXX-...'=explicit",
|
||||
)
|
||||
embed_mode: AudioExtractModeType = Field(
|
||||
default="audio_auto",
|
||||
description="Extraction mode: 'audio_auto' (default), 'audio_lsb', or 'audio_spread'",
|
||||
)
|
||||
|
||||
|
||||
class AudioInfoResponse(BaseModel):
|
||||
"""Response with audio file metadata and capacity info."""
|
||||
|
||||
sample_rate: int
|
||||
channels: int
|
||||
duration_seconds: float
|
||||
num_samples: int
|
||||
format: str
|
||||
bit_depth: int | None = None
|
||||
bitrate: int | None = None
|
||||
capacity_lsb: int = Field(description="LSB mode capacity in bytes")
|
||||
capacity_spread: int = Field(description="Spread spectrum mode capacity in bytes")
|
||||
|
||||
|
||||
class AudioCapacityRequest(BaseModel):
|
||||
"""Request to check if a payload fits in audio carrier."""
|
||||
|
||||
carrier_audio_base64: str
|
||||
payload_size: int = Field(ge=1, description="Payload size in bytes")
|
||||
embed_mode: AudioEmbedModeType = Field(
|
||||
default="audio_lsb", description="Embedding mode to check capacity for"
|
||||
)
|
||||
|
||||
|
||||
class AudioCapacityResponse(BaseModel):
|
||||
"""Response for audio capacity check."""
|
||||
|
||||
fits: bool
|
||||
payload_size: int
|
||||
capacity_bytes: int
|
||||
usage_percent: float
|
||||
embed_mode: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HELPER: RESOLVE CHANNEL KEY
|
||||
# ============================================================================
|
||||
@@ -569,12 +722,18 @@ async def root():
|
||||
"source": channel_status.get("source"),
|
||||
}
|
||||
|
||||
# Audio modes (v4.3.0)
|
||||
if HAS_AUDIO_SUPPORT:
|
||||
available_modes.append("audio_lsb")
|
||||
available_modes.append("audio_spread")
|
||||
|
||||
return StatusResponse(
|
||||
version=__version__,
|
||||
has_argon2=has_argon2(),
|
||||
has_qrcode_read=HAS_QR_READ,
|
||||
has_qrcode_write=HAS_QR_WRITE,
|
||||
has_dct=has_dct_support(),
|
||||
has_audio=HAS_AUDIO_SUPPORT,
|
||||
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
|
||||
available_modes=available_modes,
|
||||
dct_features=dct_features,
|
||||
@@ -606,6 +765,28 @@ async def api_modes():
|
||||
"fingerprint": channel_status.get("fingerprint"),
|
||||
}
|
||||
|
||||
# Audio modes (v4.3.0)
|
||||
audio_info = None
|
||||
if HAS_AUDIO_SUPPORT:
|
||||
audio_info = {
|
||||
"available": True,
|
||||
"modes": {
|
||||
"audio_lsb": {
|
||||
"name": "Audio LSB",
|
||||
"description": "Embed in audio sample LSBs, high capacity",
|
||||
"output_format": "WAV",
|
||||
},
|
||||
"audio_spread": {
|
||||
"name": "Spread Spectrum (DSSS)",
|
||||
"description": "Direct-sequence spread spectrum with Reed-Solomon ECC, better stealth",
|
||||
"output_format": "WAV",
|
||||
},
|
||||
},
|
||||
"supported_formats": ["WAV", "FLAC", "MP3", "OGG", "AAC", "M4A"],
|
||||
"output_format": "WAV",
|
||||
"requires": "soundfile",
|
||||
}
|
||||
|
||||
return ModesResponse(
|
||||
lsb={
|
||||
"available": True,
|
||||
@@ -623,6 +804,7 @@ async def api_modes():
|
||||
capacity_ratio="~20% of LSB",
|
||||
requires="scipy",
|
||||
),
|
||||
audio=audio_info,
|
||||
channel=channel_info,
|
||||
)
|
||||
|
||||
@@ -723,7 +905,7 @@ async def api_channel_set(request: ChannelSetRequest, _: str = Depends(require_a
|
||||
@app.delete("/channel")
|
||||
async def api_channel_clear(
|
||||
_: str = Depends(require_api_key),
|
||||
location: str = Query("user", description="'user', 'project', or 'all'")
|
||||
location: str = Query("user", description="'user', 'project', or 'all'"),
|
||||
):
|
||||
"""
|
||||
Clear/remove channel key from config.
|
||||
@@ -935,7 +1117,7 @@ async def api_will_fit(request: WillFitRequest, _: str = Depends(require_api_key
|
||||
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
|
||||
async def api_extract_key_from_qr(
|
||||
_: str = Depends(require_api_key),
|
||||
qr_image: UploadFile = File(..., description="QR code image containing RSA key")
|
||||
qr_image: UploadFile = File(..., description="QR code image containing RSA key"),
|
||||
):
|
||||
"""
|
||||
Extract RSA key from a QR code image.
|
||||
@@ -1607,6 +1789,454 @@ async def api_image_info(
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ROUTES - AUDIO STEGANOGRAPHY (v4.3.0)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _require_audio():
|
||||
"""Check that audio support is available, raise 501 if not."""
|
||||
if not HAS_AUDIO_SUPPORT:
|
||||
raise HTTPException(
|
||||
501, "Audio steganography not available. Install with: pip install stegasoo[audio]"
|
||||
)
|
||||
|
||||
|
||||
@app.post("/audio/encode", response_model=AudioEncodeResponse)
|
||||
async def api_audio_encode(request: AudioEncodeRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Encode a text message into audio.
|
||||
|
||||
Audio must be base64-encoded. Returns base64-encoded stego WAV.
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
resolved_channel_key = _resolve_channel_key(request.channel_key)
|
||||
|
||||
try:
|
||||
ref_photo = base64.b64decode(request.reference_photo_base64)
|
||||
carrier = base64.b64decode(request.carrier_audio_base64)
|
||||
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
||||
|
||||
stego_audio, stats = await run_in_thread(
|
||||
encode_audio,
|
||||
message=request.message,
|
||||
reference_photo=ref_photo,
|
||||
carrier_audio=carrier,
|
||||
passphrase=request.passphrase,
|
||||
pin=request.pin,
|
||||
rsa_key_data=rsa_key,
|
||||
rsa_password=request.rsa_password,
|
||||
embed_mode=request.embed_mode,
|
||||
channel_key=resolved_channel_key,
|
||||
chip_tier=request.chip_tier,
|
||||
)
|
||||
|
||||
stego_b64 = base64.b64encode(stego_audio).decode("utf-8")
|
||||
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
|
||||
|
||||
return AudioEncodeResponse(
|
||||
stego_audio_base64=stego_b64,
|
||||
embed_mode=stats.embed_mode,
|
||||
stats={
|
||||
"samples_modified": stats.samples_modified,
|
||||
"total_samples": stats.total_samples,
|
||||
"capacity_used": round(stats.capacity_used * 100, 1),
|
||||
"bytes_embedded": stats.bytes_embedded,
|
||||
"sample_rate": stats.sample_rate,
|
||||
"channels": stats.channels,
|
||||
"duration_seconds": round(stats.duration_seconds, 2),
|
||||
},
|
||||
channel_mode=channel_mode,
|
||||
channel_fingerprint=channel_fingerprint,
|
||||
)
|
||||
|
||||
except CapacityError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/audio/encode/file", response_model=AudioEncodeResponse)
|
||||
async def api_audio_encode_file(request: AudioEncodeFileRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Encode a file into audio (JSON with base64).
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
resolved_channel_key = _resolve_channel_key(request.channel_key)
|
||||
|
||||
try:
|
||||
file_data = base64.b64decode(request.file_data_base64)
|
||||
ref_photo = base64.b64decode(request.reference_photo_base64)
|
||||
carrier = base64.b64decode(request.carrier_audio_base64)
|
||||
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
||||
|
||||
payload = FilePayload(
|
||||
data=file_data, filename=request.filename, mime_type=request.mime_type
|
||||
)
|
||||
|
||||
stego_audio, stats = await run_in_thread(
|
||||
encode_audio,
|
||||
message=payload,
|
||||
reference_photo=ref_photo,
|
||||
carrier_audio=carrier,
|
||||
passphrase=request.passphrase,
|
||||
pin=request.pin,
|
||||
rsa_key_data=rsa_key,
|
||||
rsa_password=request.rsa_password,
|
||||
embed_mode=request.embed_mode,
|
||||
channel_key=resolved_channel_key,
|
||||
chip_tier=request.chip_tier,
|
||||
)
|
||||
|
||||
stego_b64 = base64.b64encode(stego_audio).decode("utf-8")
|
||||
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
|
||||
|
||||
return AudioEncodeResponse(
|
||||
stego_audio_base64=stego_b64,
|
||||
embed_mode=stats.embed_mode,
|
||||
stats={
|
||||
"samples_modified": stats.samples_modified,
|
||||
"total_samples": stats.total_samples,
|
||||
"capacity_used": round(stats.capacity_used * 100, 1),
|
||||
"bytes_embedded": stats.bytes_embedded,
|
||||
"sample_rate": stats.sample_rate,
|
||||
"channels": stats.channels,
|
||||
"duration_seconds": round(stats.duration_seconds, 2),
|
||||
},
|
||||
channel_mode=channel_mode,
|
||||
channel_fingerprint=channel_fingerprint,
|
||||
)
|
||||
|
||||
except CapacityError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/audio/encode/multipart")
|
||||
async def api_audio_encode_multipart(
|
||||
_: str = Depends(require_api_key),
|
||||
passphrase: str = Form(..., description="Passphrase for key derivation"),
|
||||
reference_photo: UploadFile = File(...),
|
||||
carrier: UploadFile = File(...),
|
||||
message: str = Form(""),
|
||||
payload_file: UploadFile | None = File(None),
|
||||
pin: str = Form(""),
|
||||
rsa_key: UploadFile | None = File(None),
|
||||
rsa_password: str = Form(""),
|
||||
channel_key: str = Form(
|
||||
"auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"
|
||||
),
|
||||
embed_mode: str = Form("audio_lsb"),
|
||||
chip_tier: int | None = Form(
|
||||
None,
|
||||
description="Spread spectrum chip tier: 0=lossless, 1=high_lossy, 2=low_lossy. Only for audio_spread.",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Encode audio using multipart form data (file uploads).
|
||||
|
||||
Provide either 'message' (text) or 'payload_file' (binary file).
|
||||
Returns the stego WAV directly with metadata headers.
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
if embed_mode not in ("audio_lsb", "audio_spread"):
|
||||
raise HTTPException(400, "embed_mode must be 'audio_lsb' or 'audio_spread'")
|
||||
|
||||
# Resolve channel key
|
||||
if channel_key.lower() == "auto":
|
||||
resolved_channel_key = None
|
||||
elif channel_key.lower() == "none":
|
||||
resolved_channel_key = ""
|
||||
else:
|
||||
resolved_channel_key = _resolve_channel_key(channel_key)
|
||||
|
||||
try:
|
||||
ref_data = await reference_photo.read()
|
||||
carrier_data = await carrier.read()
|
||||
|
||||
rsa_key_data = None
|
||||
if rsa_key and rsa_key.filename:
|
||||
rsa_key_data = await rsa_key.read()
|
||||
|
||||
effective_password = rsa_password if rsa_password else None
|
||||
|
||||
# Determine payload
|
||||
if payload_file and payload_file.filename:
|
||||
file_data = await payload_file.read()
|
||||
payload = FilePayload(
|
||||
data=file_data, filename=payload_file.filename, mime_type=payload_file.content_type
|
||||
)
|
||||
elif message:
|
||||
payload = message
|
||||
else:
|
||||
raise HTTPException(400, "Must provide either 'message' or 'payload_file'")
|
||||
|
||||
stego_audio, stats = await run_in_thread(
|
||||
encode_audio,
|
||||
message=payload,
|
||||
reference_photo=ref_data,
|
||||
carrier_audio=carrier_data,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=effective_password,
|
||||
embed_mode=embed_mode,
|
||||
channel_key=resolved_channel_key,
|
||||
chip_tier=chip_tier,
|
||||
)
|
||||
|
||||
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
|
||||
|
||||
headers = {
|
||||
"Content-Disposition": "attachment; filename=stego_audio.wav",
|
||||
"X-Stegasoo-Embed-Mode": stats.embed_mode,
|
||||
"X-Stegasoo-Capacity-Percent": f"{stats.capacity_used * 100:.1f}",
|
||||
"X-Stegasoo-Samples-Modified": str(stats.samples_modified),
|
||||
"X-Stegasoo-Duration": f"{stats.duration_seconds:.2f}",
|
||||
"X-Stegasoo-Channel-Mode": channel_mode,
|
||||
"X-Stegasoo-Version": __version__,
|
||||
}
|
||||
if channel_fingerprint:
|
||||
headers["X-Stegasoo-Channel-Fingerprint"] = channel_fingerprint
|
||||
|
||||
return Response(
|
||||
content=stego_audio,
|
||||
media_type="audio/wav",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
except CapacityError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/audio/decode", response_model=DecodeResponse)
|
||||
async def api_audio_decode(request: AudioDecodeRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Decode a message or file from stego audio.
|
||||
|
||||
Returns payload_type to indicate if result is text or file.
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
resolved_channel_key = _resolve_channel_key(request.channel_key)
|
||||
|
||||
try:
|
||||
stego = base64.b64decode(request.stego_audio_base64)
|
||||
ref_photo = base64.b64decode(request.reference_photo_base64)
|
||||
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
||||
|
||||
result = await run_in_thread(
|
||||
decode_audio,
|
||||
stego_audio=stego,
|
||||
reference_photo=ref_photo,
|
||||
passphrase=request.passphrase,
|
||||
pin=request.pin,
|
||||
rsa_key_data=rsa_key,
|
||||
rsa_password=request.rsa_password,
|
||||
embed_mode=request.embed_mode,
|
||||
channel_key=resolved_channel_key,
|
||||
)
|
||||
|
||||
if result.is_file:
|
||||
return DecodeResponse(
|
||||
payload_type="file",
|
||||
file_data_base64=base64.b64encode(result.file_data).decode("utf-8"),
|
||||
filename=result.filename,
|
||||
mime_type=result.mime_type,
|
||||
)
|
||||
else:
|
||||
return DecodeResponse(payload_type="text", message=result.message)
|
||||
|
||||
except DecryptionError as e:
|
||||
error_msg = str(e)
|
||||
if "channel key" in error_msg.lower():
|
||||
raise HTTPException(401, error_msg)
|
||||
raise HTTPException(401, "Decryption failed. Check credentials.")
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/audio/decode/multipart", response_model=DecodeResponse)
|
||||
async def api_audio_decode_multipart(
|
||||
_: str = Depends(require_api_key),
|
||||
passphrase: str = Form(..., description="Passphrase for key derivation"),
|
||||
reference_photo: UploadFile = File(...),
|
||||
stego_audio: UploadFile = File(...),
|
||||
pin: str = Form(""),
|
||||
rsa_key: UploadFile | None = File(None),
|
||||
rsa_password: str = Form(""),
|
||||
channel_key: str = Form(
|
||||
"auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"
|
||||
),
|
||||
embed_mode: str = Form("audio_auto"),
|
||||
):
|
||||
"""
|
||||
Decode audio using multipart form data (file uploads).
|
||||
|
||||
Returns JSON with payload_type indicating text or file.
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
if embed_mode not in ("audio_auto", "audio_lsb", "audio_spread"):
|
||||
raise HTTPException(400, "embed_mode must be 'audio_auto', 'audio_lsb', or 'audio_spread'")
|
||||
|
||||
# Resolve channel key
|
||||
if channel_key.lower() == "auto":
|
||||
resolved_channel_key = None
|
||||
elif channel_key.lower() == "none":
|
||||
resolved_channel_key = ""
|
||||
else:
|
||||
resolved_channel_key = _resolve_channel_key(channel_key)
|
||||
|
||||
try:
|
||||
ref_data = await reference_photo.read()
|
||||
stego_data = await stego_audio.read()
|
||||
|
||||
rsa_key_data = None
|
||||
if rsa_key and rsa_key.filename:
|
||||
rsa_key_data = await rsa_key.read()
|
||||
|
||||
effective_password = rsa_password if rsa_password else None
|
||||
|
||||
result = await run_in_thread(
|
||||
decode_audio,
|
||||
stego_audio=stego_data,
|
||||
reference_photo=ref_data,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=effective_password,
|
||||
embed_mode=embed_mode,
|
||||
channel_key=resolved_channel_key,
|
||||
)
|
||||
|
||||
if result.is_file:
|
||||
return DecodeResponse(
|
||||
payload_type="file",
|
||||
file_data_base64=base64.b64encode(result.file_data).decode("utf-8"),
|
||||
filename=result.filename,
|
||||
mime_type=result.mime_type,
|
||||
)
|
||||
else:
|
||||
return DecodeResponse(payload_type="text", message=result.message)
|
||||
|
||||
except DecryptionError as e:
|
||||
error_msg = str(e)
|
||||
if "channel key" in error_msg.lower():
|
||||
raise HTTPException(401, error_msg)
|
||||
raise HTTPException(401, "Decryption failed. Check credentials.")
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/audio/info", response_model=AudioInfoResponse)
|
||||
async def api_audio_info(
|
||||
_: str = Depends(require_api_key),
|
||||
audio: UploadFile = File(...),
|
||||
):
|
||||
"""
|
||||
Get audio file metadata and embedding capacity.
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
try:
|
||||
audio_data = await audio.read()
|
||||
|
||||
info = await run_in_thread(get_audio_info, audio_data)
|
||||
|
||||
# Calculate capacities for both modes
|
||||
lsb_capacity = await run_in_thread(calculate_audio_lsb_capacity, audio_data)
|
||||
try:
|
||||
spread_info = await run_in_thread(calculate_audio_spread_capacity, audio_data)
|
||||
spread_capacity = spread_info.usable_capacity_bytes
|
||||
except Exception:
|
||||
spread_capacity = 0
|
||||
|
||||
return AudioInfoResponse(
|
||||
sample_rate=info.sample_rate,
|
||||
channels=info.channels,
|
||||
duration_seconds=round(info.duration_seconds, 2),
|
||||
num_samples=info.num_samples,
|
||||
format=info.format,
|
||||
bit_depth=info.bit_depth,
|
||||
bitrate=info.bitrate,
|
||||
capacity_lsb=lsb_capacity,
|
||||
capacity_spread=spread_capacity,
|
||||
)
|
||||
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/audio/capacity", response_model=AudioCapacityResponse)
|
||||
async def api_audio_capacity(request: AudioCapacityRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Check if a payload of a given size will fit in an audio carrier.
|
||||
|
||||
v4.3.0: New endpoint for audio steganography.
|
||||
"""
|
||||
_require_audio()
|
||||
|
||||
try:
|
||||
carrier = base64.b64decode(request.carrier_audio_base64)
|
||||
|
||||
if request.embed_mode == "audio_lsb":
|
||||
capacity = await run_in_thread(calculate_audio_lsb_capacity, carrier)
|
||||
else:
|
||||
spread_info = await run_in_thread(calculate_audio_spread_capacity, carrier)
|
||||
capacity = spread_info.usable_capacity_bytes
|
||||
|
||||
fits = request.payload_size <= capacity
|
||||
usage = (request.payload_size / capacity * 100) if capacity > 0 else 100.0
|
||||
|
||||
return AudioCapacityResponse(
|
||||
fits=fits,
|
||||
payload_size=request.payload_size,
|
||||
capacity_bytes=capacity,
|
||||
usage_percent=round(usage, 1),
|
||||
embed_mode=request.embed_mode,
|
||||
)
|
||||
|
||||
except StegasooError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ERROR HANDLERS
|
||||
# ============================================================================
|
||||
|
||||
@@ -361,7 +361,7 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json, q
|
||||
|
||||
qr_bytes = generate_qr_code(creds.rsa_key_pem, compress=True, output_format=fmt)
|
||||
qr_path.write_bytes(qr_bytes)
|
||||
click.secho(f"─── RSA KEY QR CODE ───", fg="green")
|
||||
click.secho("─── RSA KEY QR CODE ───", fg="green")
|
||||
click.secho(f" Saved to: {qr}", fg="bright_white")
|
||||
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
|
||||
click.echo()
|
||||
|
||||
@@ -146,6 +146,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import stegasoo
|
||||
from stegasoo import (
|
||||
HAS_AUDIO_SUPPORT,
|
||||
CapacityError,
|
||||
DecryptionError,
|
||||
FilePayload,
|
||||
@@ -463,6 +464,9 @@ def inject_globals():
|
||||
"is_admin": is_admin(),
|
||||
# NEW in v4.2.0 - Saved channel keys
|
||||
"saved_channel_keys": saved_channel_keys,
|
||||
# NEW in v4.3.0 - Audio support
|
||||
"has_audio": HAS_AUDIO_SUPPORT,
|
||||
"supported_audio_formats": "WAV, FLAC, MP3, OGG, AAC, M4A" if HAS_AUDIO_SUPPORT else "",
|
||||
}
|
||||
|
||||
|
||||
@@ -564,6 +568,14 @@ def allowed_image(filename: str) -> bool:
|
||||
return ext in {"png", "jpg", "jpeg", "bmp", "gif"}
|
||||
|
||||
|
||||
def allowed_audio(filename: str) -> bool:
|
||||
"""Check if file has allowed audio extension."""
|
||||
if not filename or "." not in filename:
|
||||
return False
|
||||
ext = filename.rsplit(".", 1)[1].lower()
|
||||
return ext in {"wav", "flac", "mp3", "ogg", "aac", "m4a", "aiff", "aif"}
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Format file size for display."""
|
||||
if size_bytes < 1024:
|
||||
@@ -710,11 +722,15 @@ def generate():
|
||||
if not qr_too_large:
|
||||
qr_token = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
temp_storage.save_temp_file(qr_token, creds.rsa_key_pem.encode(), {
|
||||
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)
|
||||
|
||||
@@ -77,14 +77,10 @@ def init_db():
|
||||
db = get_db()
|
||||
|
||||
# Check if we need to migrate from old single-user schema
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'"
|
||||
)
|
||||
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'")
|
||||
has_old_table = cursor.fetchone() is not None
|
||||
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||
)
|
||||
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
||||
has_new_table = cursor.fetchone() is not None
|
||||
|
||||
if has_old_table and not has_new_table:
|
||||
@@ -189,9 +185,7 @@ def _ensure_channel_keys_table(db: sqlite3.Connection):
|
||||
|
||||
def _ensure_app_settings_table(db: sqlite3.Connection):
|
||||
"""Ensure app_settings table exists (v4.1.0 migration)."""
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
|
||||
)
|
||||
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'")
|
||||
if cursor.fetchone() is None:
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
@@ -212,9 +206,7 @@ def _ensure_app_settings_table(db: sqlite3.Connection):
|
||||
def get_app_setting(key: str) -> str | None:
|
||||
"""Get an app-level setting value."""
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT value FROM app_settings WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
row = db.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row else None
|
||||
|
||||
|
||||
@@ -384,12 +376,10 @@ def get_user_by_username(username: str) -> User | None:
|
||||
def get_all_users() -> list[User]:
|
||||
"""Get all users, admins first, then by creation date."""
|
||||
db = get_db()
|
||||
rows = db.execute(
|
||||
"""
|
||||
rows = db.execute("""
|
||||
SELECT id, username, role, created_at FROM users
|
||||
ORDER BY role = 'admin' DESC, created_at ASC
|
||||
"""
|
||||
).fetchall()
|
||||
""").fetchall()
|
||||
return [
|
||||
User(
|
||||
id=row["id"],
|
||||
@@ -596,9 +586,7 @@ def create_admin_user(username: str, password: str) -> tuple[bool, str]:
|
||||
return success, msg
|
||||
|
||||
|
||||
def change_password(
|
||||
user_id: int, current_password: str, new_password: str
|
||||
) -> tuple[bool, str]:
|
||||
def change_password(user_id: int, current_password: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change a user's password (requires current password)."""
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
@@ -667,9 +655,7 @@ def delete_user(user_id: int, current_user_id: int) -> tuple[bool, str]:
|
||||
# Check if this is the last admin
|
||||
if user.role == ROLE_ADMIN:
|
||||
db = get_db()
|
||||
admin_count = db.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
|
||||
).fetchone()[0]
|
||||
admin_count = db.execute("SELECT COUNT(*) FROM users WHERE role = 'admin'").fetchone()[0]
|
||||
if admin_count <= 1:
|
||||
return False, "Cannot delete the last admin"
|
||||
|
||||
@@ -848,9 +834,7 @@ def save_channel_key(
|
||||
return False, "This channel key is already saved", None
|
||||
|
||||
|
||||
def update_channel_key_name(
|
||||
key_id: int, user_id: int, new_name: str
|
||||
) -> tuple[bool, str]:
|
||||
def update_channel_key_name(key_id: int, user_id: int, new_name: str) -> tuple[bool, str]:
|
||||
"""Update the name of a saved channel key."""
|
||||
new_name = new_name.strip()
|
||||
if not new_name:
|
||||
|
||||
@@ -81,10 +81,12 @@ def generate_self_signed_cert(
|
||||
)
|
||||
|
||||
# Create certificate
|
||||
subject = issuer = x509.Name([
|
||||
subject = issuer = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
# Subject Alternative Names
|
||||
san_list = [
|
||||
@@ -112,7 +114,7 @@ def generate_self_signed_cert(
|
||||
except (ipaddress.AddressValueError, ValueError):
|
||||
pass
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
|
||||
@@ -95,7 +95,16 @@ const Stegasoo = {
|
||||
if (!isPayloadZone && !isQrZone) {
|
||||
input.addEventListener('change', function() {
|
||||
if (this.files && this.files[0]) {
|
||||
Stegasoo.showImagePreview(this.files[0], preview, label, zone);
|
||||
const file = this.files[0];
|
||||
if (file.type.startsWith('image/') && preview) {
|
||||
Stegasoo.showImagePreview(file, preview, label, zone);
|
||||
} else if (file.type.startsWith('audio/') || !file.type.startsWith('image/')) {
|
||||
// Audio or non-image files: show file info instead of image preview
|
||||
Stegasoo.showAudioFileInfo(file, zone);
|
||||
if (label) {
|
||||
label.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -19,6 +19,8 @@ Usage:
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
@@ -27,6 +29,24 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
# Configure logging for worker subprocess
|
||||
_log_level = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
|
||||
if _log_level and hasattr(logging, _log_level):
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, _log_level),
|
||||
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
elif os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
logger = logging.getLogger("stegasoo.worker")
|
||||
|
||||
|
||||
def _resolve_channel_key(channel_key_param):
|
||||
"""
|
||||
@@ -73,6 +93,7 @@ def _get_channel_info(resolved_key):
|
||||
|
||||
def encode_operation(params: dict) -> dict:
|
||||
"""Handle encode operation."""
|
||||
logger.debug("encode_operation: mode=%s", params.get("embed_mode", "lsb"))
|
||||
from stegasoo import FilePayload, encode
|
||||
|
||||
# Decode base64 inputs
|
||||
@@ -142,6 +163,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
|
||||
return
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(progress_file, "w") as f:
|
||||
json.dump({"percent": percent, "phase": phase}, f)
|
||||
except Exception:
|
||||
@@ -150,6 +172,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
|
||||
|
||||
def decode_operation(params: dict) -> dict:
|
||||
"""Handle decode operation."""
|
||||
logger.debug("decode_operation: mode=%s", params.get("embed_mode", "auto"))
|
||||
from stegasoo import decode
|
||||
|
||||
progress_file = params.get("progress_file")
|
||||
@@ -233,6 +256,145 @@ def capacity_check_operation(params: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def encode_audio_operation(params: dict) -> dict:
|
||||
"""Handle audio encode operation (v4.3.0)."""
|
||||
logger.debug("encode_audio_operation: mode=%s", params.get("embed_mode", "audio_lsb"))
|
||||
from stegasoo import FilePayload, encode_audio
|
||||
|
||||
carrier_data = base64.b64decode(params["carrier_b64"])
|
||||
reference_data = base64.b64decode(params["reference_b64"])
|
||||
|
||||
# Optional RSA key
|
||||
rsa_key_data = None
|
||||
if params.get("rsa_key_b64"):
|
||||
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
|
||||
|
||||
# Determine payload type
|
||||
if params.get("file_b64"):
|
||||
file_data = base64.b64decode(params["file_b64"])
|
||||
payload = FilePayload(
|
||||
data=file_data,
|
||||
filename=params.get("file_name", "file"),
|
||||
mime_type=params.get("file_mime", "application/octet-stream"),
|
||||
)
|
||||
else:
|
||||
payload = params.get("message", "")
|
||||
|
||||
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
||||
|
||||
# Resolve chip_tier from params (None means use default)
|
||||
chip_tier_val = params.get("chip_tier")
|
||||
if chip_tier_val is not None:
|
||||
chip_tier_val = int(chip_tier_val)
|
||||
|
||||
stego_audio, stats = encode_audio(
|
||||
message=payload,
|
||||
reference_photo=reference_data,
|
||||
carrier_audio=carrier_data,
|
||||
passphrase=params.get("passphrase", ""),
|
||||
pin=params.get("pin"),
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=params.get("rsa_password"),
|
||||
embed_mode=params.get("embed_mode", "audio_lsb"),
|
||||
channel_key=resolved_channel_key,
|
||||
progress_file=params.get("progress_file"),
|
||||
chip_tier=chip_tier_val,
|
||||
)
|
||||
|
||||
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"stego_b64": base64.b64encode(stego_audio).decode("ascii"),
|
||||
"stats": {
|
||||
"samples_modified": stats.samples_modified,
|
||||
"total_samples": stats.total_samples,
|
||||
"capacity_used": stats.capacity_used,
|
||||
"bytes_embedded": stats.bytes_embedded,
|
||||
"sample_rate": stats.sample_rate,
|
||||
"channels": stats.channels,
|
||||
"duration_seconds": stats.duration_seconds,
|
||||
"embed_mode": stats.embed_mode,
|
||||
},
|
||||
"channel_mode": channel_mode,
|
||||
"channel_fingerprint": channel_fingerprint,
|
||||
}
|
||||
|
||||
|
||||
def decode_audio_operation(params: dict) -> dict:
|
||||
"""Handle audio decode operation (v4.3.0)."""
|
||||
logger.debug("decode_audio_operation: mode=%s", params.get("embed_mode", "audio_auto"))
|
||||
from stegasoo import decode_audio
|
||||
|
||||
progress_file = params.get("progress_file")
|
||||
_write_decode_progress(progress_file, 5, "reading")
|
||||
|
||||
stego_data = base64.b64decode(params["stego_b64"])
|
||||
reference_data = base64.b64decode(params["reference_b64"])
|
||||
|
||||
_write_decode_progress(progress_file, 15, "reading")
|
||||
|
||||
rsa_key_data = None
|
||||
if params.get("rsa_key_b64"):
|
||||
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
|
||||
|
||||
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
||||
|
||||
result = decode_audio(
|
||||
stego_audio=stego_data,
|
||||
reference_photo=reference_data,
|
||||
passphrase=params.get("passphrase", ""),
|
||||
pin=params.get("pin"),
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=params.get("rsa_password"),
|
||||
embed_mode=params.get("embed_mode", "audio_auto"),
|
||||
channel_key=resolved_channel_key,
|
||||
progress_file=progress_file,
|
||||
)
|
||||
|
||||
if result.is_file:
|
||||
return {
|
||||
"success": True,
|
||||
"is_file": True,
|
||||
"file_b64": base64.b64encode(result.file_data).decode("ascii"),
|
||||
"filename": result.filename,
|
||||
"mime_type": result.mime_type,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"is_file": False,
|
||||
"message": result.message,
|
||||
}
|
||||
|
||||
|
||||
def audio_info_operation(params: dict) -> dict:
|
||||
"""Handle audio info operation (v4.3.0)."""
|
||||
from stegasoo import get_audio_info
|
||||
from stegasoo.audio_steganography import calculate_audio_lsb_capacity
|
||||
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
||||
|
||||
audio_data = base64.b64decode(params["audio_b64"])
|
||||
|
||||
info = get_audio_info(audio_data)
|
||||
lsb_capacity = calculate_audio_lsb_capacity(audio_data)
|
||||
spread_capacity = calculate_audio_spread_capacity(audio_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"info": {
|
||||
"sample_rate": info.sample_rate,
|
||||
"channels": info.channels,
|
||||
"duration_seconds": round(info.duration_seconds, 2),
|
||||
"num_samples": info.num_samples,
|
||||
"format": info.format,
|
||||
"bit_depth": info.bit_depth,
|
||||
"capacity_lsb": lsb_capacity,
|
||||
"capacity_spread": spread_capacity.usable_capacity_bytes,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def channel_status_operation(params: dict) -> dict:
|
||||
"""Handle channel status check (v4.0.0)."""
|
||||
from stegasoo import get_channel_status
|
||||
@@ -263,6 +425,7 @@ def main():
|
||||
else:
|
||||
params = json.loads(input_text)
|
||||
operation = params.get("operation")
|
||||
logger.info("Worker handling operation: %s", operation)
|
||||
|
||||
if operation == "encode":
|
||||
output = encode_operation(params)
|
||||
@@ -274,6 +437,13 @@ def main():
|
||||
output = capacity_check_operation(params)
|
||||
elif operation == "channel_status":
|
||||
output = channel_status_operation(params)
|
||||
# Audio operations (v4.3.0)
|
||||
elif operation == "encode_audio":
|
||||
output = encode_audio_operation(params)
|
||||
elif operation == "decode_audio":
|
||||
output = decode_audio_operation(params)
|
||||
elif operation == "audio_info":
|
||||
output = audio_info_operation(params)
|
||||
else:
|
||||
output = {"success": False, "error": f"Unknown operation: {operation}"}
|
||||
|
||||
|
||||
@@ -115,6 +115,35 @@ class CapacityResult:
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioEncodeResult:
|
||||
"""Result from audio encode operation (v4.3.0)."""
|
||||
|
||||
success: bool
|
||||
stego_data: bytes | None = None
|
||||
stats: dict[str, Any] | None = None
|
||||
channel_mode: str | None = None
|
||||
channel_fingerprint: str | None = None
|
||||
error: str | None = None
|
||||
error_type: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioInfoResult:
|
||||
"""Result from audio info operation (v4.3.0)."""
|
||||
|
||||
success: bool
|
||||
sample_rate: int = 0
|
||||
channels: int = 0
|
||||
duration_seconds: float = 0.0
|
||||
num_samples: int = 0
|
||||
format: str = ""
|
||||
bit_depth: int | None = None
|
||||
capacity_lsb: int = 0
|
||||
capacity_spread: int = 0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelStatusResult:
|
||||
"""Result from channel status check (v4.0.0)."""
|
||||
@@ -456,6 +485,201 @@ class SubprocessStego:
|
||||
error=result.get("error", "Unknown error"),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Audio Steganography (v4.3.0)
|
||||
# =========================================================================
|
||||
|
||||
def encode_audio(
|
||||
self,
|
||||
carrier_data: bytes,
|
||||
reference_data: bytes,
|
||||
message: str | None = None,
|
||||
file_data: bytes | None = None,
|
||||
file_name: str | None = None,
|
||||
file_mime: str | None = None,
|
||||
passphrase: str = "",
|
||||
pin: str | None = None,
|
||||
rsa_key_data: bytes | None = None,
|
||||
rsa_password: str | None = None,
|
||||
embed_mode: str = "audio_lsb",
|
||||
channel_key: str | None = "auto",
|
||||
timeout: int | None = None,
|
||||
progress_file: str | None = None,
|
||||
chip_tier: int | None = None,
|
||||
) -> AudioEncodeResult:
|
||||
"""
|
||||
Encode a message or file into an audio carrier.
|
||||
|
||||
Args:
|
||||
carrier_data: Carrier audio bytes (WAV, FLAC, MP3, etc.)
|
||||
reference_data: Reference photo bytes
|
||||
message: Text message to encode (if not file)
|
||||
file_data: File bytes to encode (if not message)
|
||||
file_name: Original filename (for file payload)
|
||||
file_mime: MIME type (for file payload)
|
||||
passphrase: Encryption passphrase
|
||||
pin: Optional PIN
|
||||
rsa_key_data: Optional RSA key PEM bytes
|
||||
rsa_password: RSA key password if encrypted
|
||||
embed_mode: 'audio_lsb' or 'audio_spread'
|
||||
channel_key: 'auto', 'none', or explicit key
|
||||
timeout: Operation timeout (default 300s for audio)
|
||||
progress_file: Path to write progress updates
|
||||
|
||||
Returns:
|
||||
AudioEncodeResult with stego audio data on success
|
||||
"""
|
||||
params = {
|
||||
"operation": "encode_audio",
|
||||
"carrier_b64": base64.b64encode(carrier_data).decode("ascii"),
|
||||
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
|
||||
"message": message,
|
||||
"passphrase": passphrase,
|
||||
"pin": pin,
|
||||
"embed_mode": embed_mode,
|
||||
"channel_key": channel_key,
|
||||
"progress_file": progress_file,
|
||||
"chip_tier": chip_tier,
|
||||
}
|
||||
|
||||
if file_data:
|
||||
params["file_b64"] = base64.b64encode(file_data).decode("ascii")
|
||||
params["file_name"] = file_name
|
||||
params["file_mime"] = file_mime
|
||||
|
||||
if rsa_key_data:
|
||||
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
|
||||
params["rsa_password"] = rsa_password
|
||||
|
||||
# Audio operations can be slower (especially spread spectrum)
|
||||
result = self._run_worker(params, timeout or 300)
|
||||
|
||||
if result.get("success"):
|
||||
return AudioEncodeResult(
|
||||
success=True,
|
||||
stego_data=base64.b64decode(result["stego_b64"]),
|
||||
stats=result.get("stats"),
|
||||
channel_mode=result.get("channel_mode"),
|
||||
channel_fingerprint=result.get("channel_fingerprint"),
|
||||
)
|
||||
else:
|
||||
return AudioEncodeResult(
|
||||
success=False,
|
||||
error=result.get("error", "Unknown error"),
|
||||
error_type=result.get("error_type"),
|
||||
)
|
||||
|
||||
def decode_audio(
|
||||
self,
|
||||
stego_data: bytes,
|
||||
reference_data: bytes,
|
||||
passphrase: str = "",
|
||||
pin: str | None = None,
|
||||
rsa_key_data: bytes | None = None,
|
||||
rsa_password: str | None = None,
|
||||
embed_mode: str = "audio_auto",
|
||||
channel_key: str | None = "auto",
|
||||
timeout: int | None = None,
|
||||
progress_file: str | None = None,
|
||||
) -> DecodeResult:
|
||||
"""
|
||||
Decode a message or file from stego audio.
|
||||
|
||||
Args:
|
||||
stego_data: Stego audio bytes
|
||||
reference_data: Reference photo bytes
|
||||
passphrase: Decryption passphrase
|
||||
pin: Optional PIN
|
||||
rsa_key_data: Optional RSA key PEM bytes
|
||||
rsa_password: RSA key password if encrypted
|
||||
embed_mode: 'audio_auto', 'audio_lsb', or 'audio_spread'
|
||||
channel_key: 'auto', 'none', or explicit key
|
||||
timeout: Operation timeout (default 300s for audio)
|
||||
progress_file: Path to write progress updates
|
||||
|
||||
Returns:
|
||||
DecodeResult with message or file_data on success
|
||||
"""
|
||||
params = {
|
||||
"operation": "decode_audio",
|
||||
"stego_b64": base64.b64encode(stego_data).decode("ascii"),
|
||||
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
|
||||
"passphrase": passphrase,
|
||||
"pin": pin,
|
||||
"embed_mode": embed_mode,
|
||||
"channel_key": channel_key,
|
||||
"progress_file": progress_file,
|
||||
}
|
||||
|
||||
if rsa_key_data:
|
||||
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
|
||||
params["rsa_password"] = rsa_password
|
||||
|
||||
result = self._run_worker(params, timeout or 300)
|
||||
|
||||
if result.get("success"):
|
||||
if result.get("is_file"):
|
||||
return DecodeResult(
|
||||
success=True,
|
||||
is_file=True,
|
||||
file_data=base64.b64decode(result["file_b64"]),
|
||||
filename=result.get("filename"),
|
||||
mime_type=result.get("mime_type"),
|
||||
)
|
||||
else:
|
||||
return DecodeResult(
|
||||
success=True,
|
||||
is_file=False,
|
||||
message=result.get("message"),
|
||||
)
|
||||
else:
|
||||
return DecodeResult(
|
||||
success=False,
|
||||
error=result.get("error", "Unknown error"),
|
||||
error_type=result.get("error_type"),
|
||||
)
|
||||
|
||||
def audio_info(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
timeout: int | None = None,
|
||||
) -> AudioInfoResult:
|
||||
"""
|
||||
Get audio file information and steganographic capacity.
|
||||
|
||||
Args:
|
||||
audio_data: Audio file bytes
|
||||
timeout: Operation timeout in seconds
|
||||
|
||||
Returns:
|
||||
AudioInfoResult with metadata and capacity info
|
||||
"""
|
||||
params = {
|
||||
"operation": "audio_info",
|
||||
"audio_b64": base64.b64encode(audio_data).decode("ascii"),
|
||||
}
|
||||
|
||||
result = self._run_worker(params, timeout)
|
||||
|
||||
if result.get("success"):
|
||||
info = result.get("info", {})
|
||||
return AudioInfoResult(
|
||||
success=True,
|
||||
sample_rate=info.get("sample_rate", 0),
|
||||
channels=info.get("channels", 0),
|
||||
duration_seconds=info.get("duration_seconds", 0.0),
|
||||
num_samples=info.get("num_samples", 0),
|
||||
format=info.get("format", ""),
|
||||
bit_depth=info.get("bit_depth"),
|
||||
capacity_lsb=info.get("capacity_lsb", 0),
|
||||
capacity_spread=info.get("capacity_spread", 0),
|
||||
)
|
||||
else:
|
||||
return AudioInfoResult(
|
||||
success=False,
|
||||
error=result.get("error", "Unknown error"),
|
||||
)
|
||||
|
||||
def get_channel_status(
|
||||
self,
|
||||
reveal: bool = False,
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
border-left: 3px solid #ffe699;
|
||||
}
|
||||
.step-accordion .accordion-button::after {
|
||||
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.step-accordion .accordion-button:not(.collapsed)::after {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.step-accordion .accordion-body {
|
||||
background: rgba(30, 40, 50, 0.4);
|
||||
@@ -172,19 +176,51 @@
|
||||
<div class="accordion step-accordion" id="decodeAccordion">
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 1: IMAGES & MODE
|
||||
STEP 1: CARRIER TYPE (v4.3.0)
|
||||
================================================================ -->
|
||||
<div class="accordion-item" id="carrierTypeStep">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepCarrierType">
|
||||
<span class="step-title">
|
||||
<span class="step-number" id="stepCarrierTypeNumber">1</span>
|
||||
<i class="bi bi-collection me-1"></i> Carrier Type
|
||||
</span>
|
||||
<span class="step-summary" id="stepCarrierTypeSummary"></span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="stepCarrierType" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
|
||||
<div class="accordion-body">
|
||||
<input type="hidden" name="carrier_type" id="carrierTypeInput" value="image">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<input type="radio" class="btn-check" name="carrier_type_select" id="typeImage" value="image" checked>
|
||||
<label class="btn btn-outline-secondary" for="typeImage">
|
||||
<i class="bi bi-image me-1"></i> Image
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="carrier_type_select" id="typeAudio" value="audio"
|
||||
{% if not has_audio %}disabled{% endif %}>
|
||||
<label class="btn btn-outline-secondary {% if not has_audio %}disabled text-muted{% endif %}" for="typeAudio">
|
||||
<i class="bi bi-music-note-beamed me-1"></i> Audio
|
||||
{% if not has_audio %}<small class="d-block" style="font-size: 0.65rem;">(not available)</small>{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 2: IMAGES & MODE
|
||||
================================================================ -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
||||
<span class="step-title">
|
||||
<span class="step-number" id="stepImagesNumber">1</span>
|
||||
<i class="bi bi-images me-1"></i> Images & Mode
|
||||
<span class="step-number" id="stepImagesNumber">2</span>
|
||||
<i class="bi bi-images me-1"></i> Reference, Carrier, Mode
|
||||
</span>
|
||||
<span class="step-summary" id="stepImagesSummary">Select reference & stego</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
|
||||
<div id="stepImages" class="accordion-collapse collapse" data-bs-parent="#decodeAccordion">
|
||||
<div class="accordion-body">
|
||||
|
||||
<div class="row">
|
||||
@@ -213,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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,27 +2,96 @@
|
||||
Stegasoo Debugging Utilities
|
||||
|
||||
Debugging, logging, and performance monitoring tools.
|
||||
Can be disabled for production use.
|
||||
|
||||
Configuration:
|
||||
STEGASOO_LOG_LEVEL env var controls log level:
|
||||
- Not set or empty: logging disabled (production default)
|
||||
- DEBUG: verbose debug output (encode/decode flow, crypto params, etc.)
|
||||
- INFO: operational messages (format detection, mode selection)
|
||||
- WARNING: potential issues (fallback KDF, format transcoding)
|
||||
- ERROR: operation failures
|
||||
|
||||
STEGASOO_DEBUG=1 is a shorthand for STEGASOO_LOG_LEVEL=DEBUG
|
||||
|
||||
CLI: stegasoo --debug encode ... (sets DEBUG level for that invocation)
|
||||
|
||||
All output goes to Python's logging module under the 'stegasoo' logger hierarchy.
|
||||
The legacy debug.print() API is preserved for backward compatibility.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
# Map string level names to logging levels
|
||||
_LEVEL_MAP = {
|
||||
"DEBUG": logging.DEBUG,
|
||||
"INFO": logging.INFO,
|
||||
"WARNING": logging.WARNING,
|
||||
"ERROR": logging.ERROR,
|
||||
"CRITICAL": logging.CRITICAL,
|
||||
}
|
||||
|
||||
# Root logger for the stegasoo package
|
||||
logger = logging.getLogger("stegasoo")
|
||||
|
||||
# Global debug configuration
|
||||
DEBUG_ENABLED = False # Set to True to enable debug output
|
||||
LOG_PERFORMANCE = True # Log function timing
|
||||
VALIDATION_ASSERTIONS = True # Enable runtime validation assertions
|
||||
|
||||
|
||||
def _configure_from_env() -> bool:
|
||||
"""Configure logging from environment variables. Returns True if debug enabled."""
|
||||
# STEGASOO_DEBUG=1 is shorthand for DEBUG level
|
||||
if os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
|
||||
_setup_logging(logging.DEBUG)
|
||||
return True
|
||||
|
||||
level_str = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
|
||||
if level_str and level_str in _LEVEL_MAP:
|
||||
_setup_logging(_LEVEL_MAP[level_str])
|
||||
return level_str == "DEBUG"
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _setup_logging(level: int) -> None:
|
||||
"""Configure the stegasoo logger with a stderr handler."""
|
||||
logger.setLevel(level)
|
||||
|
||||
# Only add handler if none exist (avoid duplicates on re-init)
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setLevel(level)
|
||||
formatter = logging.Formatter(
|
||||
"[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
else:
|
||||
# Update existing handler level
|
||||
for handler in logger.handlers:
|
||||
handler.setLevel(level)
|
||||
|
||||
|
||||
# Auto-configure on import
|
||||
DEBUG_ENABLED = _configure_from_env()
|
||||
|
||||
|
||||
def enable_debug(enable: bool = True) -> None:
|
||||
"""Enable or disable debug mode globally."""
|
||||
global DEBUG_ENABLED
|
||||
DEBUG_ENABLED = enable
|
||||
if enable:
|
||||
_setup_logging(logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def enable_performance_logging(enable: bool = True) -> None:
|
||||
@@ -38,15 +107,14 @@ def enable_assertions(enable: bool = True) -> None:
|
||||
|
||||
|
||||
def debug_print(message: str, level: str = "INFO") -> None:
|
||||
"""Print debug message with timestamp if debugging is enabled."""
|
||||
if DEBUG_ENABLED:
|
||||
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
print(f"[{timestamp}] [{level}] {message}", file=sys.stderr)
|
||||
"""Log a message at the given level via the stegasoo logger."""
|
||||
log_level = _LEVEL_MAP.get(level.upper(), logging.DEBUG)
|
||||
logger.log(log_level, message)
|
||||
|
||||
|
||||
def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str:
|
||||
"""Format bytes for debugging."""
|
||||
if not DEBUG_ENABLED:
|
||||
if not logger.isEnabledFor(logging.DEBUG):
|
||||
return ""
|
||||
|
||||
if not data:
|
||||
@@ -55,15 +123,17 @@ def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str:
|
||||
if len(data) <= max_bytes:
|
||||
return f"{label} ({len(data)} bytes): {data.hex()}"
|
||||
else:
|
||||
return f"{label} ({len(data)} bytes): {data[:max_bytes//2].hex()}...{data[-max_bytes//2:].hex()}"
|
||||
return (
|
||||
f"{label} ({len(data)} bytes): "
|
||||
f"{data[:max_bytes // 2].hex()}...{data[-max_bytes // 2:].hex()}"
|
||||
)
|
||||
|
||||
|
||||
def debug_exception(e: Exception, context: str = "") -> None:
|
||||
"""Log exception with context for debugging."""
|
||||
if DEBUG_ENABLED:
|
||||
debug_print(f"Exception in {context}: {type(e).__name__}: {e}", "ERROR")
|
||||
if DEBUG_ENABLED:
|
||||
traceback.print_exc()
|
||||
logger.error("Exception in %s: %s: %s", context, type(e).__name__, e)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(traceback.format_exc())
|
||||
|
||||
|
||||
def time_function(func: Callable) -> Callable:
|
||||
@@ -71,7 +141,7 @@ def time_function(func: Callable) -> Callable:
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> Any:
|
||||
if not (DEBUG_ENABLED and LOG_PERFORMANCE):
|
||||
if not (logger.isEnabledFor(logging.DEBUG) and LOG_PERFORMANCE):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
start = time.perf_counter()
|
||||
@@ -80,7 +150,7 @@ def time_function(func: Callable) -> Callable:
|
||||
return result
|
||||
finally:
|
||||
end = time.perf_counter()
|
||||
debug_print(f"{func.__name__} took {end - start:.6f}s", "PERF")
|
||||
logger.debug("%s took %.6fs", func.__name__, end - start)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -94,8 +164,6 @@ def validate_assertion(condition: bool, message: str) -> None:
|
||||
def memory_usage() -> dict[str, float | str]:
|
||||
"""Get current memory usage (if psutil is available)."""
|
||||
try:
|
||||
import os
|
||||
|
||||
import psutil
|
||||
|
||||
process = psutil.Process(os.getpid())
|
||||
@@ -131,8 +199,19 @@ def hexdump(data: bytes, offset: int = 0, length: int = 64) -> str:
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a child logger under the stegasoo namespace.
|
||||
|
||||
Usage in modules:
|
||||
from .debug import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.debug("message")
|
||||
"""
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
class Debug:
|
||||
"""Debugging utility class."""
|
||||
"""Debugging utility class (backward-compatible API)."""
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = DEBUG_ENABLED
|
||||
|
||||
@@ -31,12 +31,15 @@ def _write_progress(progress_file: str | None, current: int, total: int, phase:
|
||||
return
|
||||
try:
|
||||
with open(progress_file, "w") as f:
|
||||
json.dump({
|
||||
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}")
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -54,8 +54,7 @@ def read_image_exif(image_data: bytes) -> dict:
|
||||
gps[gps_tag] = float(gps_value)
|
||||
elif isinstance(gps_value, tuple):
|
||||
gps[gps_tag] = [
|
||||
float(v) if hasattr(v, "numerator") else v
|
||||
for v in gps_value
|
||||
float(v) if hasattr(v, "numerator") else v for v in gps_value
|
||||
]
|
||||
else:
|
||||
gps[gps_tag] = gps_value
|
||||
@@ -69,7 +68,9 @@ def read_image_exif(image_data: bytes) -> dict:
|
||||
# Try to decode as ASCII/UTF-8 text
|
||||
decoded = value.decode("utf-8", errors="strict").strip("\x00")
|
||||
# Only keep if it looks like printable text
|
||||
if decoded.isprintable() or all(c.isspace() or c.isprintable() for c in decoded):
|
||||
if decoded.isprintable() or all(
|
||||
c.isspace() or c.isprintable() for c in decoded
|
||||
):
|
||||
result[tag] = decoded
|
||||
else:
|
||||
result[tag] = f"<{len(value)} bytes binary>"
|
||||
|
||||
@@ -13,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user