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/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org).
|
and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [4.3.0] - 2026-02-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Audio Steganography** — Hide messages in audio files (WAV, FLAC, MP3, OGG, AAC, M4A)
|
||||||
|
- LSB mode: Direct least-significant-bit embedding in audio samples
|
||||||
|
- Spread Spectrum mode: Noise-resistant encoding using pseudo-random spreading
|
||||||
|
- Automatic format transcoding to WAV for embedding
|
||||||
|
- Full CLI support: `stegasoo audio-encode`, `audio-decode`, `audio-info`
|
||||||
|
- REST API endpoints: `/audio/encode`, `/audio/decode`, `/audio/info`
|
||||||
|
- Web UI: Unified encode/decode pages with carrier type selector (Image/Audio)
|
||||||
|
- New `AudioCapacityInfo`, `AudioEmbedStats`, `AudioInfo` model classes
|
||||||
|
- Audio-specific exceptions: `AudioError`, `AudioValidationError`, `AudioCapacityError`, `AudioExtractionError`, `AudioTranscodeError`, `UnsupportedAudioFormatError`
|
||||||
|
- Subprocess isolation for audio operations (crash protection)
|
||||||
|
- `debug.py` module for structured logging across all steganography operations
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Encode/Decode web pages now have a "Carrier Type" step to switch between Image and Audio
|
||||||
|
- Version bumped to 4.3.0
|
||||||
|
|
||||||
## [4.1.5] - 2026-01-07
|
## [4.1.5] - 2026-01-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -201,6 +220,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
|
|||||||
- CLI interface
|
- CLI interface
|
||||||
- Basic PIN authentication
|
- Basic PIN authentication
|
||||||
|
|
||||||
|
[4.3.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.2.1...v4.3.0
|
||||||
[4.1.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5
|
[4.1.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5
|
||||||
[4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3
|
[4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3
|
||||||
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0
|
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Stegasoo — Claude Code Project Guide
|
# Stegasoo — Claude Code Project Guide
|
||||||
|
|
||||||
Stegasoo is a secure steganography toolkit with hybrid photo + passphrase + PIN authentication.
|
Stegasoo is a secure steganography toolkit with hybrid photo + passphrase + PIN authentication.
|
||||||
Version 4.2.1 · Python >=3.11 · MIT License
|
Version 4.3.0 · Python >=3.11 · MIT License
|
||||||
|
|
||||||
## Quick commands
|
## Quick commands
|
||||||
|
|
||||||
@@ -27,6 +27,10 @@ src/stegasoo/ Core library
|
|||||||
models.py Dataclasses (EncodeResult, DecodeResult, etc.)
|
models.py Dataclasses (EncodeResult, DecodeResult, etc.)
|
||||||
encode.py / decode.py High-level encode/decode orchestration
|
encode.py / decode.py High-level encode/decode orchestration
|
||||||
channel.py Channel key management (v4.0+)
|
channel.py Channel key management (v4.0+)
|
||||||
|
audio_steganography.py LSB audio embedding/extraction (v4.3.0)
|
||||||
|
spread_steganography.py Spread spectrum audio embedding (v4.3.0)
|
||||||
|
audio_utils.py Audio format detection, validation, transcoding (v4.3.0)
|
||||||
|
debug.py Structured logging for operations (v4.3.0)
|
||||||
compression.py Zstandard / zlib / lz4 payload compression
|
compression.py Zstandard / zlib / lz4 payload compression
|
||||||
cli.py Click CLI entry point
|
cli.py Click CLI entry point
|
||||||
generate.py Credential generation (passphrase, PIN, RSA keys)
|
generate.py Credential generation (passphrase, PIN, RSA keys)
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Stegasoo
|
# Stegasoo
|
||||||
|
|
||||||
A secure steganography system for hiding encrypted messages in images using hybrid authentication.
|
A secure steganography system for hiding encrypted messages in images and audio using hybrid authentication.
|
||||||
|
|
||||||
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml)
|
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml)
|
||||||
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.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
|
- **Multiple interfaces**: CLI, Web UI, REST API
|
||||||
- **File embedding**: Hide any file type (PDF, ZIP, documents)
|
- **File embedding**: Hide any file type (PDF, ZIP, documents)
|
||||||
- **DCT steganography**: JPEG-resilient embedding for social media
|
- **DCT steganography**: JPEG-resilient embedding for social media
|
||||||
|
- **Audio steganography**: Hide messages in WAV, FLAC, MP3, OGG, AAC, M4A files (LSB and Spread Spectrum modes)
|
||||||
- **Channel keys**: Private group communication channels
|
- **Channel keys**: Private group communication channels
|
||||||
|
|
||||||
## Embedding Modes
|
## Embedding Modes
|
||||||
|
|
||||||
|
### Image Modes
|
||||||
|
|
||||||
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|
||||||
|------|------------------|----------------|----------|
|
|------|------------------|----------------|----------|
|
||||||
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
|
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
|
||||||
| **LSB** | ~750 KB | No | Email, direct file transfer |
|
| **LSB** | ~750 KB | No | Email, direct file transfer |
|
||||||
|
|
||||||
|
### Audio Modes
|
||||||
|
|
||||||
|
| Mode | Capacity (5 min WAV) | Noise Resistant | Best For |
|
||||||
|
|------|---------------------|-----------------|----------|
|
||||||
|
| **LSB** | ~1.3 MB | No | Direct file transfer |
|
||||||
|
| **Spread Spectrum** | ~160 KB | Yes | Shared files, light processing |
|
||||||
|
|
||||||
## Web UI
|
## Web UI
|
||||||
|
|
||||||
| Home | Encode | Decode | Generate |
|
| Home | Encode | Decode | Generate |
|
||||||
|
|||||||
@@ -1,3 +1,45 @@
|
|||||||
|
# v4.3.0 — Audio Steganography
|
||||||
|
|
||||||
|
**Release Date:** 2026-02-27
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
Stegasoo can now hide messages in audio files! This release adds full audio steganography support with two embedding modes:
|
||||||
|
|
||||||
|
- **LSB (Least Significant Bit)**: Embeds data directly in audio sample LSBs. High capacity, best for direct file transfers.
|
||||||
|
- **Spread Spectrum**: Spreads data across audio frequencies using pseudo-random sequences. Lower capacity but more resistant to noise and light processing.
|
||||||
|
|
||||||
|
## What's New
|
||||||
|
|
||||||
|
### Audio Steganography
|
||||||
|
- Support for WAV, FLAC, MP3, OGG, AAC, and M4A input formats
|
||||||
|
- Automatic transcoding to WAV (16-bit PCM) for embedding
|
||||||
|
- Same security model: reference photo + passphrase + PIN/RSA + channel key
|
||||||
|
- Full CLI, REST API, and Web UI support
|
||||||
|
|
||||||
|
### Unified Web UI
|
||||||
|
- Encode and Decode pages now feature a "Carrier Type" selector
|
||||||
|
- Switch between Image and Audio modes without leaving the page
|
||||||
|
- Audio capacity display shows LSB and Spread Spectrum capacities
|
||||||
|
- Audio preview player on encode result page
|
||||||
|
|
||||||
|
### New Modules
|
||||||
|
- `audio_steganography.py` — LSB audio embedding/extraction
|
||||||
|
- `spread_steganography.py` — Spread spectrum embedding/extraction
|
||||||
|
- `audio_utils.py` — Audio format detection, validation, transcoding
|
||||||
|
- `debug.py` — Structured logging for all operations
|
||||||
|
|
||||||
|
## Upgrade Notes
|
||||||
|
|
||||||
|
Audio steganography requires `numpy` and `soundfile` packages. Install with:
|
||||||
|
```bash
|
||||||
|
pip install stegasoo[audio]
|
||||||
|
```
|
||||||
|
|
||||||
|
For full audio format support (MP3, AAC, etc.), install FFmpeg on your system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Stegasoo v4.2.1
|
## Stegasoo v4.2.1
|
||||||
|
|
||||||
### API Security
|
### API Security
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||||
pkgname=stegasoo-api-git
|
pkgname=stegasoo-api-git
|
||||||
pkgver=4.2.1
|
pkgver=4.3.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Stegasoo REST API with TLS and API key authentication"
|
pkgdesc="Stegasoo REST API with TLS and API key authentication"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -30,7 +30,7 @@ sha256sums=('SKIP')
|
|||||||
pkgver() {
|
pkgver() {
|
||||||
cd "$pkgname"
|
cd "$pkgname"
|
||||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||||
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||||
pkgname=stegasoo-cli-git
|
pkgname=stegasoo-cli-git
|
||||||
pkgver=4.2.1
|
pkgver=4.3.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Secure steganography CLI with hybrid photo + passphrase + PIN authentication"
|
pkgdesc="Secure steganography CLI with hybrid photo + passphrase + PIN authentication"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -29,7 +29,7 @@ sha256sums=('SKIP')
|
|||||||
pkgver() {
|
pkgver() {
|
||||||
cd "$pkgname"
|
cd "$pkgname"
|
||||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||||
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||||
pkgname=stegasoo-git
|
pkgname=stegasoo-git
|
||||||
pkgver=4.2.1
|
pkgver=4.3.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
|
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -27,7 +27,7 @@ sha256sums=('SKIP')
|
|||||||
pkgver() {
|
pkgver() {
|
||||||
cd "$pkgname"
|
cd "$pkgname"
|
||||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||||
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.\" Stegasoo man page
|
.\" Stegasoo man page
|
||||||
.\" Generate with: groff -man -Tascii stegasoo.1
|
.\" Generate with: groff -man -Tascii stegasoo.1
|
||||||
.TH STEGASOO 1 "January 2026" "Stegasoo 4.1.7" "User Commands"
|
.TH STEGASOO 1 "February 2026" "Stegasoo 4.3.0" "User Commands"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
stegasoo \- steganography with hybrid authentication
|
stegasoo \- steganography with hybrid authentication
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -12,9 +12,10 @@ stegasoo \- steganography with hybrid authentication
|
|||||||
[\fIargs\fR]
|
[\fIargs\fR]
|
||||||
.SH DESCRIPTION
|
.SH DESCRIPTION
|
||||||
.B stegasoo
|
.B stegasoo
|
||||||
hides messages and files in images using PIN + passphrase security.
|
hides messages and files in images and audio using PIN + passphrase security.
|
||||||
It uses LSB (Least Significant Bit) steganography with optional DCT
|
It uses LSB (Least Significant Bit) steganography with optional DCT
|
||||||
(Discrete Cosine Transform) encoding for JPEG resilience.
|
(Discrete Cosine Transform) encoding for JPEG resilience, and supports
|
||||||
|
audio steganography with LSB and Spread Spectrum modes.
|
||||||
.PP
|
.PP
|
||||||
Messages are encrypted using a hybrid authentication scheme that combines
|
Messages are encrypted using a hybrid authentication scheme that combines
|
||||||
a reference photo (shared secret), passphrase, and PIN code.
|
a reference photo (shared secret), passphrase, and PIN code.
|
||||||
@@ -221,6 +222,83 @@ Reset admin password using recovery key.
|
|||||||
.PP
|
.PP
|
||||||
Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR.
|
Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR.
|
||||||
.RE
|
.RE
|
||||||
|
.SS audio\-encode
|
||||||
|
Encode a message or file into an audio file.
|
||||||
|
.PP
|
||||||
|
.B stegasoo audio\-encode
|
||||||
|
.I audio
|
||||||
|
.B \-r
|
||||||
|
.I reference
|
||||||
|
[\fB\-m\fR \fImessage\fR | \fB\-f\fR \fIfile\fR]
|
||||||
|
[\fIoptions\fR]
|
||||||
|
.TP
|
||||||
|
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||||
|
Reference photo (shared secret). Required.
|
||||||
|
.TP
|
||||||
|
.BR \-m ", " \-\-message " " \fITEXT\fR
|
||||||
|
Message to encode.
|
||||||
|
.TP
|
||||||
|
.BR \-f ", " \-\-file " " \fIPATH\fR
|
||||||
|
File to embed instead of message.
|
||||||
|
.TP
|
||||||
|
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||||
|
Output audio path.
|
||||||
|
.TP
|
||||||
|
.B \-\-passphrase " " \fITEXT\fR
|
||||||
|
Passphrase (recommend 4+ words). Prompts if not provided.
|
||||||
|
.TP
|
||||||
|
.B \-\-pin " " \fITEXT\fR
|
||||||
|
PIN code. Prompts if not provided.
|
||||||
|
.TP
|
||||||
|
.B \-\-mode " " [\fIlsb\fR|\fIspread\fR]
|
||||||
|
Embedding mode: lsb (default) or spread (spread spectrum).
|
||||||
|
.PP
|
||||||
|
.B Examples:
|
||||||
|
.nf
|
||||||
|
stegasoo audio-encode song.wav -r ref.jpg -m "Secret" --passphrase --pin
|
||||||
|
stegasoo audio-encode podcast.mp3 -r ref.jpg -f doc.pdf --mode spread
|
||||||
|
.fi
|
||||||
|
.SS audio\-decode
|
||||||
|
Decode a message or file from a stego audio file.
|
||||||
|
.PP
|
||||||
|
.B stegasoo audio\-decode
|
||||||
|
.I audio
|
||||||
|
.B \-r
|
||||||
|
.I reference
|
||||||
|
[\fIoptions\fR]
|
||||||
|
.TP
|
||||||
|
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||||
|
Reference photo (shared secret). Required.
|
||||||
|
.TP
|
||||||
|
.B \-\-passphrase " " \fITEXT\fR
|
||||||
|
Passphrase. Prompts if not provided.
|
||||||
|
.TP
|
||||||
|
.B \-\-pin " " \fITEXT\fR
|
||||||
|
PIN code. Prompts if not provided.
|
||||||
|
.TP
|
||||||
|
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||||
|
Output path for file payloads.
|
||||||
|
.PP
|
||||||
|
.B Examples:
|
||||||
|
.nf
|
||||||
|
stegasoo audio-decode stego.wav -r ref.jpg --passphrase --pin
|
||||||
|
stegasoo audio-decode stego.wav -r ref.jpg -o ./extracted/
|
||||||
|
.fi
|
||||||
|
.SS audio\-info
|
||||||
|
Display audio file information and steganographic capacity.
|
||||||
|
.PP
|
||||||
|
.B stegasoo audio\-info
|
||||||
|
.I audio
|
||||||
|
[\fB\-\-json\fR]
|
||||||
|
.PP
|
||||||
|
Shows format, sample rate, channels, bit depth, duration, and embedding
|
||||||
|
capacity for both LSB and Spread Spectrum modes.
|
||||||
|
.PP
|
||||||
|
.B Examples:
|
||||||
|
.nf
|
||||||
|
stegasoo audio-info song.wav
|
||||||
|
stegasoo audio-info podcast.mp3 --json
|
||||||
|
.fi
|
||||||
.SS tools
|
.SS tools
|
||||||
Image security tools.
|
Image security tools.
|
||||||
.PP
|
.PP
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ import json
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Security
|
from fastapi import HTTPException, Security
|
||||||
from fastapi.security import APIKeyHeader
|
from fastapi.security import APIKeyHeader
|
||||||
|
|
||||||
# API key header name
|
# API key header name
|
||||||
@@ -55,7 +54,7 @@ def _load_keys(location: str = "user") -> dict:
|
|||||||
try:
|
try:
|
||||||
with open(keys_file) as f:
|
with open(keys_file) as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except (json.JSONDecodeError, IOError):
|
except (OSError, json.JSONDecodeError):
|
||||||
return {"keys": [], "enabled": True}
|
return {"keys": [], "enabled": True}
|
||||||
return {"keys": [], "enabled": True}
|
return {"keys": [], "enabled": True}
|
||||||
|
|
||||||
@@ -101,11 +100,13 @@ def add_api_key(name: str, location: str = "user") -> str:
|
|||||||
if existing["name"] == name:
|
if existing["name"] == name:
|
||||||
raise ValueError(f"Key with name '{name}' already exists")
|
raise ValueError(f"Key with name '{name}' already exists")
|
||||||
|
|
||||||
data["keys"].append({
|
data["keys"].append(
|
||||||
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"hash": key_hash,
|
"hash": key_hash,
|
||||||
"created": __import__("datetime").datetime.now().isoformat(),
|
"created": __import__("datetime").datetime.now().isoformat(),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
_save_keys(data, location)
|
_save_keys(data, location)
|
||||||
|
|
||||||
@@ -204,12 +205,12 @@ def get_api_key_status() -> dict:
|
|||||||
"keys": {
|
"keys": {
|
||||||
"user": user_keys,
|
"user": user_keys,
|
||||||
"project": project_keys,
|
"project": project_keys,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# FastAPI dependency for API key authentication
|
# FastAPI dependency for API key authentication
|
||||||
async def require_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> str:
|
async def require_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str:
|
||||||
"""
|
"""
|
||||||
FastAPI dependency that requires a valid API key.
|
FastAPI dependency that requires a valid API key.
|
||||||
|
|
||||||
@@ -243,7 +244,7 @@ async def require_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) ->
|
|||||||
return api_key
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
async def optional_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> Optional[str]:
|
async def optional_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str | None:
|
||||||
"""
|
"""
|
||||||
FastAPI dependency that optionally validates API key.
|
FastAPI dependency that optionally validates API key.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Stegasoo REST API (v4.2.1)
|
Stegasoo REST API (v4.3.0)
|
||||||
|
|
||||||
FastAPI-based REST API for steganography operations.
|
FastAPI-based REST API for steganography operations.
|
||||||
Supports both text messages and file embedding.
|
Supports both text messages and file embedding.
|
||||||
|
|
||||||
|
CHANGES in v4.3.0:
|
||||||
|
- Audio steganography endpoints (/audio/*)
|
||||||
|
- LSB and spread spectrum (DSSS) audio embedding modes
|
||||||
|
- Audio info and capacity checking
|
||||||
|
|
||||||
CHANGES in v4.2.1:
|
CHANGES in v4.2.1:
|
||||||
- API key authentication (X-API-Key header)
|
- API key authentication (X-API-Key header)
|
||||||
- TLS support with self-signed certificates
|
- TLS support with self-signed certificates
|
||||||
@@ -32,11 +37,31 @@ NEW in v3.0.1: DCT color mode and JPEG output format.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
# Configure logging for API frontend
|
||||||
|
_log_level = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
|
||||||
|
if _log_level and hasattr(logging, _log_level):
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, _log_level),
|
||||||
|
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
stream=sys.stderr,
|
||||||
|
)
|
||||||
|
elif os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
stream=sys.stderr,
|
||||||
|
)
|
||||||
|
api_logger = logging.getLogger("stegasoo.api")
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
|
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -44,28 +69,28 @@ from pydantic import BaseModel, Field
|
|||||||
# API Key Authentication
|
# API Key Authentication
|
||||||
try:
|
try:
|
||||||
from .auth import (
|
from .auth import (
|
||||||
require_api_key,
|
|
||||||
get_api_key_status,
|
|
||||||
add_api_key,
|
add_api_key,
|
||||||
remove_api_key,
|
get_api_key_status,
|
||||||
list_api_keys,
|
|
||||||
is_auth_enabled,
|
is_auth_enabled,
|
||||||
|
list_api_keys,
|
||||||
|
remove_api_key,
|
||||||
|
require_api_key,
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# When running directly (not as package)
|
# When running directly (not as package)
|
||||||
from auth import (
|
from auth import (
|
||||||
require_api_key,
|
|
||||||
get_api_key_status,
|
|
||||||
add_api_key,
|
add_api_key,
|
||||||
remove_api_key,
|
get_api_key_status,
|
||||||
list_api_keys,
|
list_api_keys,
|
||||||
is_auth_enabled,
|
remove_api_key,
|
||||||
|
require_api_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add parent to path for development
|
# Add parent to path for development
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||||
|
|
||||||
from stegasoo import (
|
from stegasoo import (
|
||||||
|
HAS_AUDIO_SUPPORT,
|
||||||
MAX_FILE_PAYLOAD_SIZE,
|
MAX_FILE_PAYLOAD_SIZE,
|
||||||
CapacityError,
|
CapacityError,
|
||||||
DecryptionError,
|
DecryptionError,
|
||||||
@@ -87,6 +112,12 @@ from stegasoo import (
|
|||||||
validate_image,
|
validate_image,
|
||||||
will_fit_by_mode,
|
will_fit_by_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Audio steganography (v4.3.0) - conditionally imported
|
||||||
|
if HAS_AUDIO_SUPPORT:
|
||||||
|
from stegasoo import decode_audio, encode_audio, get_audio_info
|
||||||
|
from stegasoo.audio_steganography import calculate_audio_lsb_capacity
|
||||||
|
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
||||||
from stegasoo.constants import (
|
from stegasoo.constants import (
|
||||||
DEFAULT_PASSPHRASE_WORDS,
|
DEFAULT_PASSPHRASE_WORDS,
|
||||||
MAX_PASSPHRASE_WORDS,
|
MAX_PASSPHRASE_WORDS,
|
||||||
@@ -163,6 +194,8 @@ EmbedModeType = Literal["lsb", "dct"]
|
|||||||
ExtractModeType = Literal["auto", "lsb", "dct"]
|
ExtractModeType = Literal["auto", "lsb", "dct"]
|
||||||
DctColorModeType = Literal["grayscale", "color"]
|
DctColorModeType = Literal["grayscale", "color"]
|
||||||
DctOutputFormatType = Literal["png", "jpeg"]
|
DctOutputFormatType = Literal["png", "jpeg"]
|
||||||
|
AudioEmbedModeType = Literal["audio_lsb", "audio_spread"]
|
||||||
|
AudioExtractModeType = Literal["audio_auto", "audio_lsb", "audio_spread"]
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -405,6 +438,7 @@ class ModesResponse(BaseModel):
|
|||||||
|
|
||||||
lsb: dict
|
lsb: dict
|
||||||
dct: DctModeInfo
|
dct: DctModeInfo
|
||||||
|
audio: dict | None = Field(default=None, description="Audio steganography modes (v4.3.0)")
|
||||||
# Channel key status (v4.0.0)
|
# Channel key status (v4.0.0)
|
||||||
channel: dict | None = Field(default=None, description="Channel key status (v4.0.0)")
|
channel: dict | None = Field(default=None, description="Channel key status (v4.0.0)")
|
||||||
|
|
||||||
@@ -415,6 +449,7 @@ class StatusResponse(BaseModel):
|
|||||||
has_qrcode_read: bool
|
has_qrcode_read: bool
|
||||||
has_qrcode_write: bool # v4.2.0: QR generation capability
|
has_qrcode_write: bool # v4.2.0: QR generation capability
|
||||||
has_dct: bool
|
has_dct: bool
|
||||||
|
has_audio: bool = Field(default=False, description="Audio steganography support (v4.3.0)")
|
||||||
max_payload_kb: int
|
max_payload_kb: int
|
||||||
available_modes: list[str]
|
available_modes: list[str]
|
||||||
dct_features: dict | None = Field(default=None, description="DCT mode features (v3.0.1+)")
|
dct_features: dict | None = Field(default=None, description="DCT mode features (v3.0.1+)")
|
||||||
@@ -479,6 +514,124 @@ class ErrorResponse(BaseModel):
|
|||||||
detail: str | None = None
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Audio models (v4.3.0) ---
|
||||||
|
|
||||||
|
|
||||||
|
class AudioEncodeRequest(BaseModel):
|
||||||
|
"""Request to encode a text message into audio."""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
reference_photo_base64: str
|
||||||
|
carrier_audio_base64: str
|
||||||
|
passphrase: str = Field(description="Passphrase for key derivation")
|
||||||
|
pin: str = ""
|
||||||
|
rsa_key_base64: str | None = None
|
||||||
|
rsa_password: str | None = None
|
||||||
|
channel_key: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Channel key for deployment isolation. null=auto, ''=public, 'XXXX-...'=explicit",
|
||||||
|
)
|
||||||
|
embed_mode: AudioEmbedModeType = Field(
|
||||||
|
default="audio_lsb",
|
||||||
|
description="Embedding mode: 'audio_lsb' (default) or 'audio_spread' (DSSS)",
|
||||||
|
)
|
||||||
|
chip_tier: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Spread spectrum chip tier: 0=lossless(256), 1=high_lossy(512), 2=low_lossy(1024). Only for audio_spread.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioEncodeFileRequest(BaseModel):
|
||||||
|
"""Request to encode a file into audio."""
|
||||||
|
|
||||||
|
file_data_base64: str
|
||||||
|
filename: str
|
||||||
|
mime_type: str | None = None
|
||||||
|
reference_photo_base64: str
|
||||||
|
carrier_audio_base64: str
|
||||||
|
passphrase: str = Field(description="Passphrase for key derivation")
|
||||||
|
pin: str = ""
|
||||||
|
rsa_key_base64: str | None = None
|
||||||
|
rsa_password: str | None = None
|
||||||
|
channel_key: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Channel key for deployment isolation. null=auto, ''=public, 'XXXX-...'=explicit",
|
||||||
|
)
|
||||||
|
embed_mode: AudioEmbedModeType = Field(
|
||||||
|
default="audio_lsb",
|
||||||
|
description="Embedding mode: 'audio_lsb' (default) or 'audio_spread' (DSSS)",
|
||||||
|
)
|
||||||
|
chip_tier: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Spread spectrum chip tier: 0=lossless(256), 1=high_lossy(512), 2=low_lossy(1024). Only for audio_spread.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioEncodeResponse(BaseModel):
|
||||||
|
"""Response from audio encode operations."""
|
||||||
|
|
||||||
|
stego_audio_base64: str
|
||||||
|
embed_mode: str = Field(description="Embedding mode used: 'audio_lsb' or 'audio_spread'")
|
||||||
|
stats: dict = Field(description="Embedding statistics (samples_modified, capacity_used, etc.)")
|
||||||
|
channel_mode: str = Field(default="public", description="Channel mode: 'public' or 'private'")
|
||||||
|
channel_fingerprint: str | None = Field(
|
||||||
|
default=None, description="Channel key fingerprint (if private mode)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioDecodeRequest(BaseModel):
|
||||||
|
"""Request to decode a message or file from stego audio."""
|
||||||
|
|
||||||
|
stego_audio_base64: str
|
||||||
|
reference_photo_base64: str
|
||||||
|
passphrase: str = Field(description="Passphrase for key derivation")
|
||||||
|
pin: str = ""
|
||||||
|
rsa_key_base64: str | None = None
|
||||||
|
rsa_password: str | None = None
|
||||||
|
channel_key: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Channel key for decryption. null=auto, ''=public, 'XXXX-...'=explicit",
|
||||||
|
)
|
||||||
|
embed_mode: AudioExtractModeType = Field(
|
||||||
|
default="audio_auto",
|
||||||
|
description="Extraction mode: 'audio_auto' (default), 'audio_lsb', or 'audio_spread'",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioInfoResponse(BaseModel):
|
||||||
|
"""Response with audio file metadata and capacity info."""
|
||||||
|
|
||||||
|
sample_rate: int
|
||||||
|
channels: int
|
||||||
|
duration_seconds: float
|
||||||
|
num_samples: int
|
||||||
|
format: str
|
||||||
|
bit_depth: int | None = None
|
||||||
|
bitrate: int | None = None
|
||||||
|
capacity_lsb: int = Field(description="LSB mode capacity in bytes")
|
||||||
|
capacity_spread: int = Field(description="Spread spectrum mode capacity in bytes")
|
||||||
|
|
||||||
|
|
||||||
|
class AudioCapacityRequest(BaseModel):
|
||||||
|
"""Request to check if a payload fits in audio carrier."""
|
||||||
|
|
||||||
|
carrier_audio_base64: str
|
||||||
|
payload_size: int = Field(ge=1, description="Payload size in bytes")
|
||||||
|
embed_mode: AudioEmbedModeType = Field(
|
||||||
|
default="audio_lsb", description="Embedding mode to check capacity for"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioCapacityResponse(BaseModel):
|
||||||
|
"""Response for audio capacity check."""
|
||||||
|
|
||||||
|
fits: bool
|
||||||
|
payload_size: int
|
||||||
|
capacity_bytes: int
|
||||||
|
usage_percent: float
|
||||||
|
embed_mode: str
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# HELPER: RESOLVE CHANNEL KEY
|
# HELPER: RESOLVE CHANNEL KEY
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -569,12 +722,18 @@ async def root():
|
|||||||
"source": channel_status.get("source"),
|
"source": channel_status.get("source"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Audio modes (v4.3.0)
|
||||||
|
if HAS_AUDIO_SUPPORT:
|
||||||
|
available_modes.append("audio_lsb")
|
||||||
|
available_modes.append("audio_spread")
|
||||||
|
|
||||||
return StatusResponse(
|
return StatusResponse(
|
||||||
version=__version__,
|
version=__version__,
|
||||||
has_argon2=has_argon2(),
|
has_argon2=has_argon2(),
|
||||||
has_qrcode_read=HAS_QR_READ,
|
has_qrcode_read=HAS_QR_READ,
|
||||||
has_qrcode_write=HAS_QR_WRITE,
|
has_qrcode_write=HAS_QR_WRITE,
|
||||||
has_dct=has_dct_support(),
|
has_dct=has_dct_support(),
|
||||||
|
has_audio=HAS_AUDIO_SUPPORT,
|
||||||
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
|
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
|
||||||
available_modes=available_modes,
|
available_modes=available_modes,
|
||||||
dct_features=dct_features,
|
dct_features=dct_features,
|
||||||
@@ -606,6 +765,28 @@ async def api_modes():
|
|||||||
"fingerprint": channel_status.get("fingerprint"),
|
"fingerprint": channel_status.get("fingerprint"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Audio modes (v4.3.0)
|
||||||
|
audio_info = None
|
||||||
|
if HAS_AUDIO_SUPPORT:
|
||||||
|
audio_info = {
|
||||||
|
"available": True,
|
||||||
|
"modes": {
|
||||||
|
"audio_lsb": {
|
||||||
|
"name": "Audio LSB",
|
||||||
|
"description": "Embed in audio sample LSBs, high capacity",
|
||||||
|
"output_format": "WAV",
|
||||||
|
},
|
||||||
|
"audio_spread": {
|
||||||
|
"name": "Spread Spectrum (DSSS)",
|
||||||
|
"description": "Direct-sequence spread spectrum with Reed-Solomon ECC, better stealth",
|
||||||
|
"output_format": "WAV",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"supported_formats": ["WAV", "FLAC", "MP3", "OGG", "AAC", "M4A"],
|
||||||
|
"output_format": "WAV",
|
||||||
|
"requires": "soundfile",
|
||||||
|
}
|
||||||
|
|
||||||
return ModesResponse(
|
return ModesResponse(
|
||||||
lsb={
|
lsb={
|
||||||
"available": True,
|
"available": True,
|
||||||
@@ -623,6 +804,7 @@ async def api_modes():
|
|||||||
capacity_ratio="~20% of LSB",
|
capacity_ratio="~20% of LSB",
|
||||||
requires="scipy",
|
requires="scipy",
|
||||||
),
|
),
|
||||||
|
audio=audio_info,
|
||||||
channel=channel_info,
|
channel=channel_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -723,7 +905,7 @@ async def api_channel_set(request: ChannelSetRequest, _: str = Depends(require_a
|
|||||||
@app.delete("/channel")
|
@app.delete("/channel")
|
||||||
async def api_channel_clear(
|
async def api_channel_clear(
|
||||||
_: str = Depends(require_api_key),
|
_: str = Depends(require_api_key),
|
||||||
location: str = Query("user", description="'user', 'project', or 'all'")
|
location: str = Query("user", description="'user', 'project', or 'all'"),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Clear/remove channel key from config.
|
Clear/remove channel key from config.
|
||||||
@@ -935,7 +1117,7 @@ async def api_will_fit(request: WillFitRequest, _: str = Depends(require_api_key
|
|||||||
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
|
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
|
||||||
async def api_extract_key_from_qr(
|
async def api_extract_key_from_qr(
|
||||||
_: str = Depends(require_api_key),
|
_: str = Depends(require_api_key),
|
||||||
qr_image: UploadFile = File(..., description="QR code image containing RSA key")
|
qr_image: UploadFile = File(..., description="QR code image containing RSA key"),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Extract RSA key from a QR code image.
|
Extract RSA key from a QR code image.
|
||||||
@@ -1607,6 +1789,454 @@ async def api_image_info(
|
|||||||
raise HTTPException(500, str(e))
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ROUTES - AUDIO STEGANOGRAPHY (v4.3.0)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _require_audio():
|
||||||
|
"""Check that audio support is available, raise 501 if not."""
|
||||||
|
if not HAS_AUDIO_SUPPORT:
|
||||||
|
raise HTTPException(
|
||||||
|
501, "Audio steganography not available. Install with: pip install stegasoo[audio]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/audio/encode", response_model=AudioEncodeResponse)
|
||||||
|
async def api_audio_encode(request: AudioEncodeRequest, _: str = Depends(require_api_key)):
|
||||||
|
"""
|
||||||
|
Encode a text message into audio.
|
||||||
|
|
||||||
|
Audio must be base64-encoded. Returns base64-encoded stego WAV.
|
||||||
|
|
||||||
|
v4.3.0: New endpoint for audio steganography.
|
||||||
|
"""
|
||||||
|
_require_audio()
|
||||||
|
|
||||||
|
resolved_channel_key = _resolve_channel_key(request.channel_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ref_photo = base64.b64decode(request.reference_photo_base64)
|
||||||
|
carrier = base64.b64decode(request.carrier_audio_base64)
|
||||||
|
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
||||||
|
|
||||||
|
stego_audio, stats = await run_in_thread(
|
||||||
|
encode_audio,
|
||||||
|
message=request.message,
|
||||||
|
reference_photo=ref_photo,
|
||||||
|
carrier_audio=carrier,
|
||||||
|
passphrase=request.passphrase,
|
||||||
|
pin=request.pin,
|
||||||
|
rsa_key_data=rsa_key,
|
||||||
|
rsa_password=request.rsa_password,
|
||||||
|
embed_mode=request.embed_mode,
|
||||||
|
channel_key=resolved_channel_key,
|
||||||
|
chip_tier=request.chip_tier,
|
||||||
|
)
|
||||||
|
|
||||||
|
stego_b64 = base64.b64encode(stego_audio).decode("utf-8")
|
||||||
|
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
|
||||||
|
|
||||||
|
return AudioEncodeResponse(
|
||||||
|
stego_audio_base64=stego_b64,
|
||||||
|
embed_mode=stats.embed_mode,
|
||||||
|
stats={
|
||||||
|
"samples_modified": stats.samples_modified,
|
||||||
|
"total_samples": stats.total_samples,
|
||||||
|
"capacity_used": round(stats.capacity_used * 100, 1),
|
||||||
|
"bytes_embedded": stats.bytes_embedded,
|
||||||
|
"sample_rate": stats.sample_rate,
|
||||||
|
"channels": stats.channels,
|
||||||
|
"duration_seconds": round(stats.duration_seconds, 2),
|
||||||
|
},
|
||||||
|
channel_mode=channel_mode,
|
||||||
|
channel_fingerprint=channel_fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
except CapacityError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
except StegasooError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/audio/encode/file", response_model=AudioEncodeResponse)
|
||||||
|
async def api_audio_encode_file(request: AudioEncodeFileRequest, _: str = Depends(require_api_key)):
|
||||||
|
"""
|
||||||
|
Encode a file into audio (JSON with base64).
|
||||||
|
|
||||||
|
v4.3.0: New endpoint for audio steganography.
|
||||||
|
"""
|
||||||
|
_require_audio()
|
||||||
|
|
||||||
|
resolved_channel_key = _resolve_channel_key(request.channel_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_data = base64.b64decode(request.file_data_base64)
|
||||||
|
ref_photo = base64.b64decode(request.reference_photo_base64)
|
||||||
|
carrier = base64.b64decode(request.carrier_audio_base64)
|
||||||
|
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
||||||
|
|
||||||
|
payload = FilePayload(
|
||||||
|
data=file_data, filename=request.filename, mime_type=request.mime_type
|
||||||
|
)
|
||||||
|
|
||||||
|
stego_audio, stats = await run_in_thread(
|
||||||
|
encode_audio,
|
||||||
|
message=payload,
|
||||||
|
reference_photo=ref_photo,
|
||||||
|
carrier_audio=carrier,
|
||||||
|
passphrase=request.passphrase,
|
||||||
|
pin=request.pin,
|
||||||
|
rsa_key_data=rsa_key,
|
||||||
|
rsa_password=request.rsa_password,
|
||||||
|
embed_mode=request.embed_mode,
|
||||||
|
channel_key=resolved_channel_key,
|
||||||
|
chip_tier=request.chip_tier,
|
||||||
|
)
|
||||||
|
|
||||||
|
stego_b64 = base64.b64encode(stego_audio).decode("utf-8")
|
||||||
|
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
|
||||||
|
|
||||||
|
return AudioEncodeResponse(
|
||||||
|
stego_audio_base64=stego_b64,
|
||||||
|
embed_mode=stats.embed_mode,
|
||||||
|
stats={
|
||||||
|
"samples_modified": stats.samples_modified,
|
||||||
|
"total_samples": stats.total_samples,
|
||||||
|
"capacity_used": round(stats.capacity_used * 100, 1),
|
||||||
|
"bytes_embedded": stats.bytes_embedded,
|
||||||
|
"sample_rate": stats.sample_rate,
|
||||||
|
"channels": stats.channels,
|
||||||
|
"duration_seconds": round(stats.duration_seconds, 2),
|
||||||
|
},
|
||||||
|
channel_mode=channel_mode,
|
||||||
|
channel_fingerprint=channel_fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
except CapacityError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
except StegasooError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/audio/encode/multipart")
|
||||||
|
async def api_audio_encode_multipart(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
passphrase: str = Form(..., description="Passphrase for key derivation"),
|
||||||
|
reference_photo: UploadFile = File(...),
|
||||||
|
carrier: UploadFile = File(...),
|
||||||
|
message: str = Form(""),
|
||||||
|
payload_file: UploadFile | None = File(None),
|
||||||
|
pin: str = Form(""),
|
||||||
|
rsa_key: UploadFile | None = File(None),
|
||||||
|
rsa_password: str = Form(""),
|
||||||
|
channel_key: str = Form(
|
||||||
|
"auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"
|
||||||
|
),
|
||||||
|
embed_mode: str = Form("audio_lsb"),
|
||||||
|
chip_tier: int | None = Form(
|
||||||
|
None,
|
||||||
|
description="Spread spectrum chip tier: 0=lossless, 1=high_lossy, 2=low_lossy. Only for audio_spread.",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Encode audio using multipart form data (file uploads).
|
||||||
|
|
||||||
|
Provide either 'message' (text) or 'payload_file' (binary file).
|
||||||
|
Returns the stego WAV directly with metadata headers.
|
||||||
|
|
||||||
|
v4.3.0: New endpoint for audio steganography.
|
||||||
|
"""
|
||||||
|
_require_audio()
|
||||||
|
|
||||||
|
if embed_mode not in ("audio_lsb", "audio_spread"):
|
||||||
|
raise HTTPException(400, "embed_mode must be 'audio_lsb' or 'audio_spread'")
|
||||||
|
|
||||||
|
# Resolve channel key
|
||||||
|
if channel_key.lower() == "auto":
|
||||||
|
resolved_channel_key = None
|
||||||
|
elif channel_key.lower() == "none":
|
||||||
|
resolved_channel_key = ""
|
||||||
|
else:
|
||||||
|
resolved_channel_key = _resolve_channel_key(channel_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ref_data = await reference_photo.read()
|
||||||
|
carrier_data = await carrier.read()
|
||||||
|
|
||||||
|
rsa_key_data = None
|
||||||
|
if rsa_key and rsa_key.filename:
|
||||||
|
rsa_key_data = await rsa_key.read()
|
||||||
|
|
||||||
|
effective_password = rsa_password if rsa_password else None
|
||||||
|
|
||||||
|
# Determine payload
|
||||||
|
if payload_file and payload_file.filename:
|
||||||
|
file_data = await payload_file.read()
|
||||||
|
payload = FilePayload(
|
||||||
|
data=file_data, filename=payload_file.filename, mime_type=payload_file.content_type
|
||||||
|
)
|
||||||
|
elif message:
|
||||||
|
payload = message
|
||||||
|
else:
|
||||||
|
raise HTTPException(400, "Must provide either 'message' or 'payload_file'")
|
||||||
|
|
||||||
|
stego_audio, stats = await run_in_thread(
|
||||||
|
encode_audio,
|
||||||
|
message=payload,
|
||||||
|
reference_photo=ref_data,
|
||||||
|
carrier_audio=carrier_data,
|
||||||
|
passphrase=passphrase,
|
||||||
|
pin=pin,
|
||||||
|
rsa_key_data=rsa_key_data,
|
||||||
|
rsa_password=effective_password,
|
||||||
|
embed_mode=embed_mode,
|
||||||
|
channel_key=resolved_channel_key,
|
||||||
|
chip_tier=chip_tier,
|
||||||
|
)
|
||||||
|
|
||||||
|
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": "attachment; filename=stego_audio.wav",
|
||||||
|
"X-Stegasoo-Embed-Mode": stats.embed_mode,
|
||||||
|
"X-Stegasoo-Capacity-Percent": f"{stats.capacity_used * 100:.1f}",
|
||||||
|
"X-Stegasoo-Samples-Modified": str(stats.samples_modified),
|
||||||
|
"X-Stegasoo-Duration": f"{stats.duration_seconds:.2f}",
|
||||||
|
"X-Stegasoo-Channel-Mode": channel_mode,
|
||||||
|
"X-Stegasoo-Version": __version__,
|
||||||
|
}
|
||||||
|
if channel_fingerprint:
|
||||||
|
headers["X-Stegasoo-Channel-Fingerprint"] = channel_fingerprint
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=stego_audio,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
except CapacityError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
except StegasooError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/audio/decode", response_model=DecodeResponse)
|
||||||
|
async def api_audio_decode(request: AudioDecodeRequest, _: str = Depends(require_api_key)):
|
||||||
|
"""
|
||||||
|
Decode a message or file from stego audio.
|
||||||
|
|
||||||
|
Returns payload_type to indicate if result is text or file.
|
||||||
|
|
||||||
|
v4.3.0: New endpoint for audio steganography.
|
||||||
|
"""
|
||||||
|
_require_audio()
|
||||||
|
|
||||||
|
resolved_channel_key = _resolve_channel_key(request.channel_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stego = base64.b64decode(request.stego_audio_base64)
|
||||||
|
ref_photo = base64.b64decode(request.reference_photo_base64)
|
||||||
|
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
||||||
|
|
||||||
|
result = await run_in_thread(
|
||||||
|
decode_audio,
|
||||||
|
stego_audio=stego,
|
||||||
|
reference_photo=ref_photo,
|
||||||
|
passphrase=request.passphrase,
|
||||||
|
pin=request.pin,
|
||||||
|
rsa_key_data=rsa_key,
|
||||||
|
rsa_password=request.rsa_password,
|
||||||
|
embed_mode=request.embed_mode,
|
||||||
|
channel_key=resolved_channel_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.is_file:
|
||||||
|
return DecodeResponse(
|
||||||
|
payload_type="file",
|
||||||
|
file_data_base64=base64.b64encode(result.file_data).decode("utf-8"),
|
||||||
|
filename=result.filename,
|
||||||
|
mime_type=result.mime_type,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return DecodeResponse(payload_type="text", message=result.message)
|
||||||
|
|
||||||
|
except DecryptionError as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
if "channel key" in error_msg.lower():
|
||||||
|
raise HTTPException(401, error_msg)
|
||||||
|
raise HTTPException(401, "Decryption failed. Check credentials.")
|
||||||
|
except StegasooError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/audio/decode/multipart", response_model=DecodeResponse)
|
||||||
|
async def api_audio_decode_multipart(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
passphrase: str = Form(..., description="Passphrase for key derivation"),
|
||||||
|
reference_photo: UploadFile = File(...),
|
||||||
|
stego_audio: UploadFile = File(...),
|
||||||
|
pin: str = Form(""),
|
||||||
|
rsa_key: UploadFile | None = File(None),
|
||||||
|
rsa_password: str = Form(""),
|
||||||
|
channel_key: str = Form(
|
||||||
|
"auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"
|
||||||
|
),
|
||||||
|
embed_mode: str = Form("audio_auto"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Decode audio using multipart form data (file uploads).
|
||||||
|
|
||||||
|
Returns JSON with payload_type indicating text or file.
|
||||||
|
|
||||||
|
v4.3.0: New endpoint for audio steganography.
|
||||||
|
"""
|
||||||
|
_require_audio()
|
||||||
|
|
||||||
|
if embed_mode not in ("audio_auto", "audio_lsb", "audio_spread"):
|
||||||
|
raise HTTPException(400, "embed_mode must be 'audio_auto', 'audio_lsb', or 'audio_spread'")
|
||||||
|
|
||||||
|
# Resolve channel key
|
||||||
|
if channel_key.lower() == "auto":
|
||||||
|
resolved_channel_key = None
|
||||||
|
elif channel_key.lower() == "none":
|
||||||
|
resolved_channel_key = ""
|
||||||
|
else:
|
||||||
|
resolved_channel_key = _resolve_channel_key(channel_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ref_data = await reference_photo.read()
|
||||||
|
stego_data = await stego_audio.read()
|
||||||
|
|
||||||
|
rsa_key_data = None
|
||||||
|
if rsa_key and rsa_key.filename:
|
||||||
|
rsa_key_data = await rsa_key.read()
|
||||||
|
|
||||||
|
effective_password = rsa_password if rsa_password else None
|
||||||
|
|
||||||
|
result = await run_in_thread(
|
||||||
|
decode_audio,
|
||||||
|
stego_audio=stego_data,
|
||||||
|
reference_photo=ref_data,
|
||||||
|
passphrase=passphrase,
|
||||||
|
pin=pin,
|
||||||
|
rsa_key_data=rsa_key_data,
|
||||||
|
rsa_password=effective_password,
|
||||||
|
embed_mode=embed_mode,
|
||||||
|
channel_key=resolved_channel_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.is_file:
|
||||||
|
return DecodeResponse(
|
||||||
|
payload_type="file",
|
||||||
|
file_data_base64=base64.b64encode(result.file_data).decode("utf-8"),
|
||||||
|
filename=result.filename,
|
||||||
|
mime_type=result.mime_type,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return DecodeResponse(payload_type="text", message=result.message)
|
||||||
|
|
||||||
|
except DecryptionError as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
if "channel key" in error_msg.lower():
|
||||||
|
raise HTTPException(401, error_msg)
|
||||||
|
raise HTTPException(401, "Decryption failed. Check credentials.")
|
||||||
|
except StegasooError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/audio/info", response_model=AudioInfoResponse)
|
||||||
|
async def api_audio_info(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
audio: UploadFile = File(...),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get audio file metadata and embedding capacity.
|
||||||
|
|
||||||
|
v4.3.0: New endpoint for audio steganography.
|
||||||
|
"""
|
||||||
|
_require_audio()
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio_data = await audio.read()
|
||||||
|
|
||||||
|
info = await run_in_thread(get_audio_info, audio_data)
|
||||||
|
|
||||||
|
# Calculate capacities for both modes
|
||||||
|
lsb_capacity = await run_in_thread(calculate_audio_lsb_capacity, audio_data)
|
||||||
|
try:
|
||||||
|
spread_info = await run_in_thread(calculate_audio_spread_capacity, audio_data)
|
||||||
|
spread_capacity = spread_info.usable_capacity_bytes
|
||||||
|
except Exception:
|
||||||
|
spread_capacity = 0
|
||||||
|
|
||||||
|
return AudioInfoResponse(
|
||||||
|
sample_rate=info.sample_rate,
|
||||||
|
channels=info.channels,
|
||||||
|
duration_seconds=round(info.duration_seconds, 2),
|
||||||
|
num_samples=info.num_samples,
|
||||||
|
format=info.format,
|
||||||
|
bit_depth=info.bit_depth,
|
||||||
|
bitrate=info.bitrate,
|
||||||
|
capacity_lsb=lsb_capacity,
|
||||||
|
capacity_spread=spread_capacity,
|
||||||
|
)
|
||||||
|
|
||||||
|
except StegasooError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/audio/capacity", response_model=AudioCapacityResponse)
|
||||||
|
async def api_audio_capacity(request: AudioCapacityRequest, _: str = Depends(require_api_key)):
|
||||||
|
"""
|
||||||
|
Check if a payload of a given size will fit in an audio carrier.
|
||||||
|
|
||||||
|
v4.3.0: New endpoint for audio steganography.
|
||||||
|
"""
|
||||||
|
_require_audio()
|
||||||
|
|
||||||
|
try:
|
||||||
|
carrier = base64.b64decode(request.carrier_audio_base64)
|
||||||
|
|
||||||
|
if request.embed_mode == "audio_lsb":
|
||||||
|
capacity = await run_in_thread(calculate_audio_lsb_capacity, carrier)
|
||||||
|
else:
|
||||||
|
spread_info = await run_in_thread(calculate_audio_spread_capacity, carrier)
|
||||||
|
capacity = spread_info.usable_capacity_bytes
|
||||||
|
|
||||||
|
fits = request.payload_size <= capacity
|
||||||
|
usage = (request.payload_size / capacity * 100) if capacity > 0 else 100.0
|
||||||
|
|
||||||
|
return AudioCapacityResponse(
|
||||||
|
fits=fits,
|
||||||
|
payload_size=request.payload_size,
|
||||||
|
capacity_bytes=capacity,
|
||||||
|
usage_percent=round(usage, 1),
|
||||||
|
embed_mode=request.embed_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
except StegasooError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ERROR HANDLERS
|
# ERROR HANDLERS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json, q
|
|||||||
|
|
||||||
qr_bytes = generate_qr_code(creds.rsa_key_pem, compress=True, output_format=fmt)
|
qr_bytes = generate_qr_code(creds.rsa_key_pem, compress=True, output_format=fmt)
|
||||||
qr_path.write_bytes(qr_bytes)
|
qr_path.write_bytes(qr_bytes)
|
||||||
click.secho(f"─── RSA KEY QR CODE ───", fg="green")
|
click.secho("─── RSA KEY QR CODE ───", fg="green")
|
||||||
click.secho(f" Saved to: {qr}", fg="bright_white")
|
click.secho(f" Saved to: {qr}", fg="bright_white")
|
||||||
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
|
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
|
||||||
click.echo()
|
click.echo()
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
|||||||
|
|
||||||
import stegasoo
|
import stegasoo
|
||||||
from stegasoo import (
|
from stegasoo import (
|
||||||
|
HAS_AUDIO_SUPPORT,
|
||||||
CapacityError,
|
CapacityError,
|
||||||
DecryptionError,
|
DecryptionError,
|
||||||
FilePayload,
|
FilePayload,
|
||||||
@@ -463,6 +464,9 @@ def inject_globals():
|
|||||||
"is_admin": is_admin(),
|
"is_admin": is_admin(),
|
||||||
# NEW in v4.2.0 - Saved channel keys
|
# NEW in v4.2.0 - Saved channel keys
|
||||||
"saved_channel_keys": saved_channel_keys,
|
"saved_channel_keys": saved_channel_keys,
|
||||||
|
# NEW in v4.3.0 - Audio support
|
||||||
|
"has_audio": HAS_AUDIO_SUPPORT,
|
||||||
|
"supported_audio_formats": "WAV, FLAC, MP3, OGG, AAC, M4A" if HAS_AUDIO_SUPPORT else "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -564,6 +568,14 @@ def allowed_image(filename: str) -> bool:
|
|||||||
return ext in {"png", "jpg", "jpeg", "bmp", "gif"}
|
return ext in {"png", "jpg", "jpeg", "bmp", "gif"}
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_audio(filename: str) -> bool:
|
||||||
|
"""Check if file has allowed audio extension."""
|
||||||
|
if not filename or "." not in filename:
|
||||||
|
return False
|
||||||
|
ext = filename.rsplit(".", 1)[1].lower()
|
||||||
|
return ext in {"wav", "flac", "mp3", "ogg", "aac", "m4a", "aiff", "aif"}
|
||||||
|
|
||||||
|
|
||||||
def format_size(size_bytes: int) -> str:
|
def format_size(size_bytes: int) -> str:
|
||||||
"""Format file size for display."""
|
"""Format file size for display."""
|
||||||
if size_bytes < 1024:
|
if size_bytes < 1024:
|
||||||
@@ -710,11 +722,15 @@ def generate():
|
|||||||
if not qr_too_large:
|
if not qr_too_large:
|
||||||
qr_token = secrets.token_urlsafe(16)
|
qr_token = secrets.token_urlsafe(16)
|
||||||
cleanup_temp_files()
|
cleanup_temp_files()
|
||||||
temp_storage.save_temp_file(qr_token, creds.rsa_key_pem.encode(), {
|
temp_storage.save_temp_file(
|
||||||
|
qr_token,
|
||||||
|
creds.rsa_key_pem.encode(),
|
||||||
|
{
|
||||||
"filename": "rsa_key.pem",
|
"filename": "rsa_key.pem",
|
||||||
"type": "rsa_key",
|
"type": "rsa_key",
|
||||||
"compress": qr_needs_compression,
|
"compress": qr_needs_compression,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# v3.2.0: Single passphrase instead of daily phrases
|
# v3.2.0: Single passphrase instead of daily phrases
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -1001,6 +1017,37 @@ def api_check_fit():
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/audio-capacity", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_audio_capacity():
|
||||||
|
"""Get audio file capacity for steganography (v4.3.0)."""
|
||||||
|
audio_file = request.files.get("carrier")
|
||||||
|
if not audio_file:
|
||||||
|
return jsonify({"error": "No audio file provided"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio_data = audio_file.read()
|
||||||
|
result = subprocess_stego.audio_info(audio_data)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
return jsonify({"error": result.error or "Audio analysis failed"}), 500
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"sample_rate": result.sample_rate,
|
||||||
|
"channels": result.channels,
|
||||||
|
"duration": round(result.duration_seconds, 2),
|
||||||
|
"format": result.format,
|
||||||
|
"bit_depth": result.bit_depth,
|
||||||
|
"lsb_capacity": result.capacity_lsb,
|
||||||
|
"spread_capacity": result.capacity_spread,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ENCODE
|
# ENCODE
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1078,7 +1125,10 @@ def _run_encode_job(job_id: str, encode_params: dict) -> None:
|
|||||||
|
|
||||||
# Store result
|
# Store result
|
||||||
file_id = secrets.token_urlsafe(16)
|
file_id = secrets.token_urlsafe(16)
|
||||||
temp_storage.save_temp_file(file_id, encode_result.stego_data, {
|
temp_storage.save_temp_file(
|
||||||
|
file_id,
|
||||||
|
encode_result.stego_data,
|
||||||
|
{
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"embed_mode": embed_mode,
|
"embed_mode": embed_mode,
|
||||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||||
@@ -1086,7 +1136,94 @@ def _run_encode_job(job_id: str, encode_params: dict) -> None:
|
|||||||
"mime_type": output_mime,
|
"mime_type": output_mime,
|
||||||
"channel_mode": encode_result.channel_mode,
|
"channel_mode": encode_result.channel_mode,
|
||||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
_store_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"status": "complete",
|
||||||
|
"file_id": file_id,
|
||||||
|
"created": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_store_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e),
|
||||||
|
"created": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
cleanup_progress_file(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_encode_audio_job(job_id: str, encode_params: dict) -> None:
|
||||||
|
"""Background thread function for async audio encode (v4.3.0)."""
|
||||||
|
progress_file = get_progress_file_path(job_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_store_job(job_id, {"status": "running", "created": time.time()})
|
||||||
|
|
||||||
|
if encode_params.get("file_data"):
|
||||||
|
encode_result = subprocess_stego.encode_audio(
|
||||||
|
carrier_data=encode_params["carrier_data"],
|
||||||
|
reference_data=encode_params["ref_data"],
|
||||||
|
file_data=encode_params["file_data"],
|
||||||
|
file_name=encode_params["file_name"],
|
||||||
|
file_mime=encode_params["file_mime"],
|
||||||
|
passphrase=encode_params["passphrase"],
|
||||||
|
pin=encode_params.get("pin"),
|
||||||
|
rsa_key_data=encode_params.get("rsa_key_data"),
|
||||||
|
rsa_password=encode_params.get("key_password"),
|
||||||
|
embed_mode=encode_params["embed_mode"],
|
||||||
|
channel_key=encode_params.get("channel_key"),
|
||||||
|
progress_file=progress_file,
|
||||||
|
chip_tier=encode_params.get("chip_tier"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
encode_result = subprocess_stego.encode_audio(
|
||||||
|
carrier_data=encode_params["carrier_data"],
|
||||||
|
reference_data=encode_params["ref_data"],
|
||||||
|
message=encode_params.get("message"),
|
||||||
|
passphrase=encode_params["passphrase"],
|
||||||
|
pin=encode_params.get("pin"),
|
||||||
|
rsa_key_data=encode_params.get("rsa_key_data"),
|
||||||
|
rsa_password=encode_params.get("key_password"),
|
||||||
|
embed_mode=encode_params["embed_mode"],
|
||||||
|
channel_key=encode_params.get("channel_key"),
|
||||||
|
progress_file=progress_file,
|
||||||
|
chip_tier=encode_params.get("chip_tier"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not encode_result.success:
|
||||||
|
_store_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"error": encode_result.error or "Audio encoding failed",
|
||||||
|
"created": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
filename = generate_filename("stego_audio", ".wav")
|
||||||
|
file_id = secrets.token_urlsafe(16)
|
||||||
|
temp_storage.save_temp_file(
|
||||||
|
file_id,
|
||||||
|
encode_result.stego_data,
|
||||||
|
{
|
||||||
|
"filename": filename,
|
||||||
|
"embed_mode": encode_params["embed_mode"],
|
||||||
|
"carrier_type": "audio",
|
||||||
|
"mime_type": "audio/wav",
|
||||||
|
"channel_mode": encode_result.channel_mode,
|
||||||
|
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
_store_job(
|
_store_job(
|
||||||
job_id,
|
job_id,
|
||||||
@@ -1131,6 +1268,196 @@ def encode_page():
|
|||||||
rsa_key_file = request.files.get("rsa_key")
|
rsa_key_file = request.files.get("rsa_key")
|
||||||
payload_file = request.files.get("payload_file")
|
payload_file = request.files.get("payload_file")
|
||||||
|
|
||||||
|
# Determine carrier type (v4.3.0)
|
||||||
|
carrier_type = request.form.get("carrier_type", "image")
|
||||||
|
|
||||||
|
if carrier_type == "audio":
|
||||||
|
# ========== AUDIO ENCODE PATH (v4.3.0) ==========
|
||||||
|
if not HAS_AUDIO_SUPPORT:
|
||||||
|
return _error_response(
|
||||||
|
"Audio steganography is not available. Install audio dependencies."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ref_photo or not carrier:
|
||||||
|
return _error_response("Both reference photo and audio carrier are required")
|
||||||
|
|
||||||
|
if not allowed_image(ref_photo.filename):
|
||||||
|
return _error_response("Reference must be an image (PNG, JPG, BMP)")
|
||||||
|
|
||||||
|
if not allowed_audio(carrier.filename):
|
||||||
|
return _error_response(
|
||||||
|
"Invalid audio format. Use WAV, FLAC, MP3, OGG, AAC, or M4A"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get form data
|
||||||
|
message = request.form.get("message", "")
|
||||||
|
passphrase = request.form.get("passphrase", "")
|
||||||
|
pin = request.form.get("pin", "").strip()
|
||||||
|
rsa_password = request.form.get("rsa_password", "")
|
||||||
|
payload_type = request.form.get("payload_type", "text")
|
||||||
|
|
||||||
|
embed_mode = request.form.get("embed_mode", "audio_lsb")
|
||||||
|
if embed_mode not in ("audio_lsb", "audio_spread"):
|
||||||
|
embed_mode = "audio_lsb"
|
||||||
|
|
||||||
|
# Chip tier for spread spectrum (None = default)
|
||||||
|
chip_tier_str = request.form.get("chip_tier")
|
||||||
|
chip_tier = None
|
||||||
|
if chip_tier_str and chip_tier_str.isdigit():
|
||||||
|
chip_tier = int(chip_tier_str)
|
||||||
|
if chip_tier not in (0, 1, 2):
|
||||||
|
chip_tier = None
|
||||||
|
|
||||||
|
channel_key = resolve_channel_key_form(request.form.get("channel_key", "auto"))
|
||||||
|
|
||||||
|
# Determine payload
|
||||||
|
if payload_type == "file" and payload_file and payload_file.filename:
|
||||||
|
file_data = payload_file.read()
|
||||||
|
result = validate_file_payload(file_data, payload_file.filename)
|
||||||
|
if not result.is_valid:
|
||||||
|
return _error_response(result.error_message)
|
||||||
|
mime_type, _ = mimetypes.guess_type(payload_file.filename)
|
||||||
|
payload = FilePayload(
|
||||||
|
data=file_data,
|
||||||
|
filename=payload_file.filename,
|
||||||
|
mime_type=mime_type,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = validate_message(message)
|
||||||
|
if not result.is_valid:
|
||||||
|
return _error_response(result.error_message)
|
||||||
|
payload = message
|
||||||
|
|
||||||
|
if not passphrase:
|
||||||
|
return _error_response("Passphrase is required")
|
||||||
|
|
||||||
|
result = validate_passphrase(passphrase)
|
||||||
|
if not result.is_valid:
|
||||||
|
return _error_response(result.error_message)
|
||||||
|
if result.warning:
|
||||||
|
flash(result.warning, "warning")
|
||||||
|
|
||||||
|
ref_data = ref_photo.read()
|
||||||
|
carrier_data = carrier.read()
|
||||||
|
|
||||||
|
# Handle RSA key (same as image path)
|
||||||
|
rsa_key_data = None
|
||||||
|
rsa_key_pem = request.form.get("rsa_key_pem", "").strip()
|
||||||
|
rsa_key_qr = request.files.get("rsa_key_qr")
|
||||||
|
rsa_key_from_qr = False
|
||||||
|
|
||||||
|
if rsa_key_pem:
|
||||||
|
if is_compressed(rsa_key_pem):
|
||||||
|
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||||
|
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||||
|
rsa_key_from_qr = True
|
||||||
|
elif rsa_key_file and rsa_key_file.filename:
|
||||||
|
rsa_key_data = rsa_key_file.read()
|
||||||
|
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
|
||||||
|
qr_image_data = rsa_key_qr.read()
|
||||||
|
key_pem = extract_key_from_qr(qr_image_data)
|
||||||
|
if key_pem:
|
||||||
|
rsa_key_data = key_pem.encode("utf-8")
|
||||||
|
rsa_key_from_qr = True
|
||||||
|
else:
|
||||||
|
return _error_response("Could not extract RSA key from QR code image.")
|
||||||
|
|
||||||
|
result = validate_security_factors(pin, rsa_key_data)
|
||||||
|
if not result.is_valid:
|
||||||
|
return _error_response(result.error_message)
|
||||||
|
|
||||||
|
if pin:
|
||||||
|
result = validate_pin(pin)
|
||||||
|
if not result.is_valid:
|
||||||
|
return _error_response(result.error_message)
|
||||||
|
|
||||||
|
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
||||||
|
|
||||||
|
if rsa_key_data:
|
||||||
|
result = validate_rsa_key(rsa_key_data, key_password)
|
||||||
|
if not result.is_valid:
|
||||||
|
return _error_response(result.error_message)
|
||||||
|
|
||||||
|
# Build audio encode params
|
||||||
|
encode_params = {
|
||||||
|
"carrier_data": carrier_data,
|
||||||
|
"ref_data": ref_data,
|
||||||
|
"passphrase": passphrase,
|
||||||
|
"pin": pin if pin else None,
|
||||||
|
"rsa_key_data": rsa_key_data,
|
||||||
|
"key_password": key_password,
|
||||||
|
"embed_mode": embed_mode,
|
||||||
|
"channel_key": channel_key,
|
||||||
|
"carrier_type": "audio",
|
||||||
|
"chip_tier": chip_tier,
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload_type == "file" and payload_file and payload_file.filename:
|
||||||
|
encode_params["file_data"] = payload.data
|
||||||
|
encode_params["file_name"] = payload.filename
|
||||||
|
encode_params["file_mime"] = payload.mime_type
|
||||||
|
else:
|
||||||
|
encode_params["message"] = payload
|
||||||
|
|
||||||
|
if is_async:
|
||||||
|
job_id = generate_job_id()
|
||||||
|
_store_job(job_id, {"status": "pending", "created": time.time()})
|
||||||
|
_executor.submit(_run_encode_audio_job, job_id, encode_params)
|
||||||
|
return jsonify({"job_id": job_id, "status": "pending"})
|
||||||
|
|
||||||
|
# Sync audio encode
|
||||||
|
if encode_params.get("file_data"):
|
||||||
|
encode_result = subprocess_stego.encode_audio(
|
||||||
|
carrier_data=carrier_data,
|
||||||
|
reference_data=ref_data,
|
||||||
|
file_data=encode_params["file_data"],
|
||||||
|
file_name=encode_params["file_name"],
|
||||||
|
file_mime=encode_params["file_mime"],
|
||||||
|
passphrase=passphrase,
|
||||||
|
pin=pin if pin else None,
|
||||||
|
rsa_key_data=rsa_key_data,
|
||||||
|
rsa_password=key_password,
|
||||||
|
embed_mode=embed_mode,
|
||||||
|
channel_key=channel_key,
|
||||||
|
chip_tier=chip_tier,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
encode_result = subprocess_stego.encode_audio(
|
||||||
|
carrier_data=carrier_data,
|
||||||
|
reference_data=ref_data,
|
||||||
|
message=payload,
|
||||||
|
passphrase=passphrase,
|
||||||
|
pin=pin if pin else None,
|
||||||
|
rsa_key_data=rsa_key_data,
|
||||||
|
rsa_password=key_password,
|
||||||
|
embed_mode=embed_mode,
|
||||||
|
channel_key=channel_key,
|
||||||
|
chip_tier=chip_tier,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not encode_result.success:
|
||||||
|
error_msg = encode_result.error or "Audio encoding failed"
|
||||||
|
return _error_response(error_msg)
|
||||||
|
|
||||||
|
filename = generate_filename("stego_audio", ".wav")
|
||||||
|
file_id = secrets.token_urlsafe(16)
|
||||||
|
cleanup_temp_files()
|
||||||
|
temp_storage.save_temp_file(
|
||||||
|
file_id,
|
||||||
|
encode_result.stego_data,
|
||||||
|
{
|
||||||
|
"filename": filename,
|
||||||
|
"embed_mode": embed_mode,
|
||||||
|
"carrier_type": "audio",
|
||||||
|
"mime_type": "audio/wav",
|
||||||
|
"channel_mode": encode_result.channel_mode,
|
||||||
|
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(url_for("encode_result", file_id=file_id))
|
||||||
|
|
||||||
|
# ========== IMAGE ENCODE PATH (original) ==========
|
||||||
if not ref_photo or not carrier:
|
if not ref_photo or not carrier:
|
||||||
return _error_response("Both reference photo and carrier image are required")
|
return _error_response("Both reference photo and carrier image are required")
|
||||||
|
|
||||||
@@ -1356,7 +1683,10 @@ def encode_page():
|
|||||||
# Store temporarily
|
# Store temporarily
|
||||||
file_id = secrets.token_urlsafe(16)
|
file_id = secrets.token_urlsafe(16)
|
||||||
cleanup_temp_files()
|
cleanup_temp_files()
|
||||||
temp_storage.save_temp_file(file_id, encode_result.stego_data, {
|
temp_storage.save_temp_file(
|
||||||
|
file_id,
|
||||||
|
encode_result.stego_data,
|
||||||
|
{
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"embed_mode": embed_mode,
|
"embed_mode": embed_mode,
|
||||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||||
@@ -1365,7 +1695,8 @@ def encode_page():
|
|||||||
# Channel info (v4.0.0)
|
# Channel info (v4.0.0)
|
||||||
"channel_mode": encode_result.channel_mode,
|
"channel_mode": encode_result.channel_mode,
|
||||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return redirect(url_for("encode_result", file_id=file_id))
|
return redirect(url_for("encode_result", file_id=file_id))
|
||||||
|
|
||||||
@@ -1434,10 +1765,13 @@ def encode_result(file_id):
|
|||||||
flash("File expired or not found. Please encode again.", "error")
|
flash("File expired or not found. Please encode again.", "error")
|
||||||
return redirect(url_for("encode_page"))
|
return redirect(url_for("encode_page"))
|
||||||
|
|
||||||
# Generate thumbnail
|
carrier_type = file_info.get("carrier_type", "image")
|
||||||
thumbnail_data = generate_thumbnail(file_info["data"])
|
|
||||||
thumbnail_id = None
|
|
||||||
|
|
||||||
|
# Generate thumbnail only for images
|
||||||
|
thumbnail_data = None
|
||||||
|
thumbnail_id = None
|
||||||
|
if carrier_type != "audio":
|
||||||
|
thumbnail_data = generate_thumbnail(file_info["data"])
|
||||||
if thumbnail_data:
|
if thumbnail_data:
|
||||||
thumbnail_id = f"{file_id}_thumb"
|
thumbnail_id = f"{file_id}_thumb"
|
||||||
temp_storage.save_thumbnail(thumbnail_id, thumbnail_data)
|
temp_storage.save_thumbnail(thumbnail_id, thumbnail_data)
|
||||||
@@ -1450,6 +1784,7 @@ def encode_result(file_id):
|
|||||||
embed_mode=file_info.get("embed_mode", "lsb"),
|
embed_mode=file_info.get("embed_mode", "lsb"),
|
||||||
output_format=file_info.get("output_format", "png"),
|
output_format=file_info.get("output_format", "png"),
|
||||||
color_mode=file_info.get("color_mode"),
|
color_mode=file_info.get("color_mode"),
|
||||||
|
carrier_type=carrier_type,
|
||||||
# Channel info (v4.0.0)
|
# Channel info (v4.0.0)
|
||||||
channel_mode=file_info.get("channel_mode", "public"),
|
channel_mode=file_info.get("channel_mode", "public"),
|
||||||
channel_fingerprint=file_info.get("channel_fingerprint"),
|
channel_fingerprint=file_info.get("channel_fingerprint"),
|
||||||
@@ -1464,9 +1799,7 @@ def encode_thumbnail(thumb_id):
|
|||||||
if not thumb_data:
|
if not thumb_data:
|
||||||
return "Thumbnail not found", 404
|
return "Thumbnail not found", 404
|
||||||
|
|
||||||
return send_file(
|
return send_file(io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False)
|
||||||
io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/encode/download/<file_id>")
|
@app.route("/encode/download/<file_id>")
|
||||||
@@ -1559,10 +1892,92 @@ def _run_decode_job(job_id: str, decode_params: dict) -> None:
|
|||||||
if decode_result.is_file:
|
if decode_result.is_file:
|
||||||
file_id = secrets.token_urlsafe(16)
|
file_id = secrets.token_urlsafe(16)
|
||||||
filename = decode_result.filename or "decoded_file"
|
filename = decode_result.filename or "decoded_file"
|
||||||
temp_storage.save_temp_file(file_id, decode_result.file_data, {
|
temp_storage.save_temp_file(
|
||||||
|
file_id,
|
||||||
|
decode_result.file_data,
|
||||||
|
{
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"mime_type": decode_result.mime_type,
|
"mime_type": decode_result.mime_type,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
_store_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"status": "complete",
|
||||||
|
"file_id": file_id,
|
||||||
|
"is_file": True,
|
||||||
|
"filename": filename,
|
||||||
|
"file_size": len(decode_result.file_data),
|
||||||
|
"mime_type": decode_result.mime_type,
|
||||||
|
"created": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_store_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"status": "complete",
|
||||||
|
"is_file": False,
|
||||||
|
"message": decode_result.message,
|
||||||
|
"created": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_store_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e),
|
||||||
|
"created": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
cleanup_progress_file(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_decode_audio_job(job_id: str, decode_params: dict) -> None:
|
||||||
|
"""Background thread function for async audio decode (v4.3.0)."""
|
||||||
|
progress_file = get_progress_file_path(job_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_store_job(job_id, {"status": "running", "created": time.time()})
|
||||||
|
|
||||||
|
decode_result = subprocess_stego.decode_audio(
|
||||||
|
stego_data=decode_params["stego_data"],
|
||||||
|
reference_data=decode_params["ref_data"],
|
||||||
|
passphrase=decode_params["passphrase"],
|
||||||
|
pin=decode_params.get("pin"),
|
||||||
|
rsa_key_data=decode_params.get("rsa_key_data"),
|
||||||
|
rsa_password=decode_params.get("rsa_password"),
|
||||||
|
embed_mode=decode_params.get("embed_mode", "audio_auto"),
|
||||||
|
channel_key=decode_params.get("channel_key"),
|
||||||
|
progress_file=progress_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not decode_result.success:
|
||||||
|
_store_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"error": decode_result.error or "Audio decoding failed",
|
||||||
|
"error_type": decode_result.error_type,
|
||||||
|
"created": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if decode_result.is_file:
|
||||||
|
file_id = secrets.token_urlsafe(16)
|
||||||
|
filename = decode_result.filename or "decoded_file"
|
||||||
|
temp_storage.save_temp_file(
|
||||||
|
file_id,
|
||||||
|
decode_result.file_data,
|
||||||
|
{
|
||||||
|
"filename": filename,
|
||||||
|
"mime_type": decode_result.mime_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
_store_job(
|
_store_job(
|
||||||
job_id,
|
job_id,
|
||||||
{
|
{
|
||||||
@@ -1609,6 +2024,163 @@ def decode_page():
|
|||||||
stego_image = request.files.get("stego_image")
|
stego_image = request.files.get("stego_image")
|
||||||
rsa_key_file = request.files.get("rsa_key")
|
rsa_key_file = request.files.get("rsa_key")
|
||||||
|
|
||||||
|
# Determine carrier type (v4.3.0)
|
||||||
|
carrier_type = request.form.get("carrier_type", "image")
|
||||||
|
|
||||||
|
if carrier_type == "audio":
|
||||||
|
# ========== AUDIO DECODE PATH (v4.3.0) ==========
|
||||||
|
if not HAS_AUDIO_SUPPORT:
|
||||||
|
flash("Audio steganography is not available.", "error")
|
||||||
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
if not ref_photo or not stego_image:
|
||||||
|
flash("Both reference photo and stego audio are required", "error")
|
||||||
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
if not allowed_image(ref_photo.filename):
|
||||||
|
flash("Reference must be an image", "error")
|
||||||
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
if not allowed_audio(stego_image.filename):
|
||||||
|
flash("Invalid audio format", "error")
|
||||||
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
passphrase = request.form.get("passphrase", "")
|
||||||
|
pin = request.form.get("pin", "").strip()
|
||||||
|
rsa_password = request.form.get("rsa_password", "")
|
||||||
|
|
||||||
|
embed_mode = request.form.get("embed_mode", "audio_auto")
|
||||||
|
if embed_mode not in ("audio_auto", "audio_lsb", "audio_spread"):
|
||||||
|
embed_mode = "audio_auto"
|
||||||
|
|
||||||
|
channel_key = resolve_channel_key_form(request.form.get("channel_key", "auto"))
|
||||||
|
|
||||||
|
if not passphrase:
|
||||||
|
flash("Passphrase is required", "error")
|
||||||
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
ref_data = ref_photo.read()
|
||||||
|
stego_data = stego_image.read()
|
||||||
|
|
||||||
|
# Handle RSA key (same as image path)
|
||||||
|
rsa_key_data = None
|
||||||
|
rsa_key_pem = request.form.get("rsa_key_pem", "").strip()
|
||||||
|
rsa_key_qr = request.files.get("rsa_key_qr")
|
||||||
|
rsa_key_from_qr = False
|
||||||
|
|
||||||
|
if rsa_key_pem:
|
||||||
|
if is_compressed(rsa_key_pem):
|
||||||
|
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||||
|
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||||
|
rsa_key_from_qr = True
|
||||||
|
elif rsa_key_file and rsa_key_file.filename:
|
||||||
|
rsa_key_data = rsa_key_file.read()
|
||||||
|
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
|
||||||
|
qr_image_data = rsa_key_qr.read()
|
||||||
|
key_pem = extract_key_from_qr(qr_image_data)
|
||||||
|
if key_pem:
|
||||||
|
rsa_key_data = key_pem.encode("utf-8")
|
||||||
|
rsa_key_from_qr = True
|
||||||
|
else:
|
||||||
|
flash("Could not extract RSA key from QR code image.", "error")
|
||||||
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
result = validate_security_factors(pin, rsa_key_data)
|
||||||
|
if not result.is_valid:
|
||||||
|
flash(result.error_message, "error")
|
||||||
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
if pin:
|
||||||
|
result = validate_pin(pin)
|
||||||
|
if not result.is_valid:
|
||||||
|
flash(result.error_message, "error")
|
||||||
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
||||||
|
|
||||||
|
if rsa_key_data:
|
||||||
|
result = validate_rsa_key(rsa_key_data, key_password)
|
||||||
|
if not result.is_valid:
|
||||||
|
flash(result.error_message, "error")
|
||||||
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
is_async = (
|
||||||
|
request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
decode_params = {
|
||||||
|
"stego_data": stego_data,
|
||||||
|
"ref_data": ref_data,
|
||||||
|
"passphrase": passphrase,
|
||||||
|
"pin": pin if pin else None,
|
||||||
|
"rsa_key_data": rsa_key_data,
|
||||||
|
"rsa_password": key_password,
|
||||||
|
"embed_mode": embed_mode,
|
||||||
|
"channel_key": channel_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_async:
|
||||||
|
job_id = generate_job_id()
|
||||||
|
_store_job(job_id, {"status": "pending", "created": time.time()})
|
||||||
|
_executor.submit(_run_decode_audio_job, job_id, decode_params)
|
||||||
|
return jsonify({"job_id": job_id, "status": "pending"})
|
||||||
|
|
||||||
|
# Sync audio decode
|
||||||
|
decode_result = subprocess_stego.decode_audio(
|
||||||
|
stego_data=stego_data,
|
||||||
|
reference_data=ref_data,
|
||||||
|
passphrase=passphrase,
|
||||||
|
pin=pin if pin else None,
|
||||||
|
rsa_key_data=rsa_key_data,
|
||||||
|
rsa_password=key_password,
|
||||||
|
embed_mode=embed_mode,
|
||||||
|
channel_key=channel_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not decode_result.success:
|
||||||
|
error_msg = decode_result.error or "Audio decoding failed"
|
||||||
|
if (
|
||||||
|
"decrypt" in error_msg.lower()
|
||||||
|
or decode_result.error_type == "DecryptionError"
|
||||||
|
):
|
||||||
|
flash(
|
||||||
|
"Wrong credentials. Double-check your reference photo, "
|
||||||
|
"passphrase, PIN, and channel key.",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash(error_msg, "error")
|
||||||
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
if decode_result.is_file:
|
||||||
|
file_id = secrets.token_urlsafe(16)
|
||||||
|
cleanup_temp_files()
|
||||||
|
filename = decode_result.filename or "decoded_file"
|
||||||
|
temp_storage.save_temp_file(
|
||||||
|
file_id,
|
||||||
|
decode_result.file_data,
|
||||||
|
{
|
||||||
|
"filename": filename,
|
||||||
|
"mime_type": decode_result.mime_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return render_template(
|
||||||
|
"decode.html",
|
||||||
|
decoded_file=True,
|
||||||
|
file_id=file_id,
|
||||||
|
filename=filename,
|
||||||
|
file_size=format_size(len(decode_result.file_data)),
|
||||||
|
mime_type=decode_result.mime_type,
|
||||||
|
has_qrcode_read=HAS_QRCODE_READ,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return render_template(
|
||||||
|
"decode.html",
|
||||||
|
decoded_message=decode_result.message,
|
||||||
|
has_qrcode_read=HAS_QRCODE_READ,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========== IMAGE DECODE PATH (original) ==========
|
||||||
if not ref_photo or not stego_image:
|
if not ref_photo or not stego_image:
|
||||||
flash("Both reference photo and stego image are required", "error")
|
flash("Both reference photo and stego image are required", "error")
|
||||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
@@ -1690,7 +2262,9 @@ def decode_page():
|
|||||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
# Check for async mode (v4.1.5)
|
# Check for async mode (v4.1.5)
|
||||||
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
is_async = (
|
||||||
|
request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||||
|
)
|
||||||
|
|
||||||
# Build decode params
|
# Build decode params
|
||||||
decode_params = {
|
decode_params = {
|
||||||
@@ -1742,10 +2316,14 @@ def decode_page():
|
|||||||
cleanup_temp_files()
|
cleanup_temp_files()
|
||||||
|
|
||||||
filename = decode_result.filename or "decoded_file"
|
filename = decode_result.filename or "decoded_file"
|
||||||
temp_storage.save_temp_file(file_id, decode_result.file_data, {
|
temp_storage.save_temp_file(
|
||||||
|
file_id,
|
||||||
|
decode_result.file_data,
|
||||||
|
{
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"mime_type": decode_result.mime_type,
|
"mime_type": decode_result.mime_type,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"decode.html",
|
"decode.html",
|
||||||
@@ -2101,11 +2679,12 @@ def api_tools_exif_clear():
|
|||||||
@login_required
|
@login_required
|
||||||
def api_tools_rotate():
|
def api_tools_rotate():
|
||||||
"""Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
|
"""Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
|
||||||
from PIL import Image
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
image_file = request.files.get("image")
|
image_file = request.files.get("image")
|
||||||
if not image_file:
|
if not image_file:
|
||||||
return jsonify({"success": False, "error": "No image provided"}), 400
|
return jsonify({"success": False, "error": "No image provided"}), 400
|
||||||
@@ -2136,9 +2715,18 @@ def api_tools_rotate():
|
|||||||
output_path = tempfile.mktemp(suffix=".jpg")
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["jpegtran", "-rotate", str(rotation), "-copy", "all",
|
[
|
||||||
"-outfile", output_path, input_path],
|
"jpegtran",
|
||||||
capture_output=True, timeout=30
|
"-rotate",
|
||||||
|
str(rotation),
|
||||||
|
"-copy",
|
||||||
|
"all",
|
||||||
|
"-outfile",
|
||||||
|
output_path,
|
||||||
|
input_path,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
with open(output_path, "rb") as f:
|
with open(output_path, "rb") as f:
|
||||||
@@ -2158,9 +2746,18 @@ def api_tools_rotate():
|
|||||||
output_path = tempfile.mktemp(suffix=".jpg")
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["jpegtran", "-flip", "horizontal", "-copy", "all",
|
[
|
||||||
"-outfile", output_path, input_path],
|
"jpegtran",
|
||||||
capture_output=True, timeout=30
|
"-flip",
|
||||||
|
"horizontal",
|
||||||
|
"-copy",
|
||||||
|
"all",
|
||||||
|
"-outfile",
|
||||||
|
output_path,
|
||||||
|
input_path,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
with open(output_path, "rb") as f:
|
with open(output_path, "rb") as f:
|
||||||
@@ -2180,9 +2777,18 @@ def api_tools_rotate():
|
|||||||
output_path = tempfile.mktemp(suffix=".jpg")
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["jpegtran", "-flip", "vertical", "-copy", "all",
|
[
|
||||||
"-outfile", output_path, input_path],
|
"jpegtran",
|
||||||
capture_output=True, timeout=30
|
"-flip",
|
||||||
|
"vertical",
|
||||||
|
"-copy",
|
||||||
|
"all",
|
||||||
|
"-outfile",
|
||||||
|
output_path,
|
||||||
|
input_path,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
with open(output_path, "rb") as f:
|
with open(output_path, "rb") as f:
|
||||||
@@ -2839,10 +3445,7 @@ def admin_settings_unlock():
|
|||||||
channel_status = get_channel_status()
|
channel_status = get_channel_status()
|
||||||
channel_key = channel_status.get("key") if channel_status["configured"] else ""
|
channel_key = channel_status.get("key") if channel_status["configured"] else ""
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({"success": True, "channel_key": channel_key})
|
||||||
"success": True,
|
|
||||||
"channel_key": channel_key
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/admin/users")
|
@app.route("/admin/users")
|
||||||
@@ -2976,6 +3579,7 @@ if __name__ == "__main__":
|
|||||||
ssl_context = None
|
ssl_context = None
|
||||||
if app.config.get("HTTPS_ENABLED", False):
|
if app.config.get("HTTPS_ENABLED", False):
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
hostname = os.environ.get("STEGASOO_HOSTNAME") or socket.gethostname()
|
hostname = os.environ.get("STEGASOO_HOSTNAME") or socket.gethostname()
|
||||||
try:
|
try:
|
||||||
cert_path, key_path = ensure_certs(base_dir, hostname)
|
cert_path, key_path = ensure_certs(base_dir, hostname)
|
||||||
|
|||||||
@@ -77,14 +77,10 @@ def init_db():
|
|||||||
db = get_db()
|
db = get_db()
|
||||||
|
|
||||||
# Check if we need to migrate from old single-user schema
|
# Check if we need to migrate from old single-user schema
|
||||||
cursor = db.execute(
|
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'")
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'"
|
|
||||||
)
|
|
||||||
has_old_table = cursor.fetchone() is not None
|
has_old_table = cursor.fetchone() is not None
|
||||||
|
|
||||||
cursor = db.execute(
|
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
|
||||||
)
|
|
||||||
has_new_table = cursor.fetchone() is not None
|
has_new_table = cursor.fetchone() is not None
|
||||||
|
|
||||||
if has_old_table and not has_new_table:
|
if has_old_table and not has_new_table:
|
||||||
@@ -189,9 +185,7 @@ def _ensure_channel_keys_table(db: sqlite3.Connection):
|
|||||||
|
|
||||||
def _ensure_app_settings_table(db: sqlite3.Connection):
|
def _ensure_app_settings_table(db: sqlite3.Connection):
|
||||||
"""Ensure app_settings table exists (v4.1.0 migration)."""
|
"""Ensure app_settings table exists (v4.1.0 migration)."""
|
||||||
cursor = db.execute(
|
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'")
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
|
|
||||||
)
|
|
||||||
if cursor.fetchone() is None:
|
if cursor.fetchone() is None:
|
||||||
db.executescript("""
|
db.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS app_settings (
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
@@ -212,9 +206,7 @@ def _ensure_app_settings_table(db: sqlite3.Connection):
|
|||||||
def get_app_setting(key: str) -> str | None:
|
def get_app_setting(key: str) -> str | None:
|
||||||
"""Get an app-level setting value."""
|
"""Get an app-level setting value."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
row = db.execute(
|
row = db.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone()
|
||||||
"SELECT value FROM app_settings WHERE key = ?", (key,)
|
|
||||||
).fetchone()
|
|
||||||
return row["value"] if row else None
|
return row["value"] if row else None
|
||||||
|
|
||||||
|
|
||||||
@@ -384,12 +376,10 @@ def get_user_by_username(username: str) -> User | None:
|
|||||||
def get_all_users() -> list[User]:
|
def get_all_users() -> list[User]:
|
||||||
"""Get all users, admins first, then by creation date."""
|
"""Get all users, admins first, then by creation date."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
rows = db.execute(
|
rows = db.execute("""
|
||||||
"""
|
|
||||||
SELECT id, username, role, created_at FROM users
|
SELECT id, username, role, created_at FROM users
|
||||||
ORDER BY role = 'admin' DESC, created_at ASC
|
ORDER BY role = 'admin' DESC, created_at ASC
|
||||||
"""
|
""").fetchall()
|
||||||
).fetchall()
|
|
||||||
return [
|
return [
|
||||||
User(
|
User(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
@@ -596,9 +586,7 @@ def create_admin_user(username: str, password: str) -> tuple[bool, str]:
|
|||||||
return success, msg
|
return success, msg
|
||||||
|
|
||||||
|
|
||||||
def change_password(
|
def change_password(user_id: int, current_password: str, new_password: str) -> tuple[bool, str]:
|
||||||
user_id: int, current_password: str, new_password: str
|
|
||||||
) -> tuple[bool, str]:
|
|
||||||
"""Change a user's password (requires current password)."""
|
"""Change a user's password (requires current password)."""
|
||||||
user = get_user_by_id(user_id)
|
user = get_user_by_id(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -667,9 +655,7 @@ def delete_user(user_id: int, current_user_id: int) -> tuple[bool, str]:
|
|||||||
# Check if this is the last admin
|
# Check if this is the last admin
|
||||||
if user.role == ROLE_ADMIN:
|
if user.role == ROLE_ADMIN:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
admin_count = db.execute(
|
admin_count = db.execute("SELECT COUNT(*) FROM users WHERE role = 'admin'").fetchone()[0]
|
||||||
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
|
|
||||||
).fetchone()[0]
|
|
||||||
if admin_count <= 1:
|
if admin_count <= 1:
|
||||||
return False, "Cannot delete the last admin"
|
return False, "Cannot delete the last admin"
|
||||||
|
|
||||||
@@ -848,9 +834,7 @@ def save_channel_key(
|
|||||||
return False, "This channel key is already saved", None
|
return False, "This channel key is already saved", None
|
||||||
|
|
||||||
|
|
||||||
def update_channel_key_name(
|
def update_channel_key_name(key_id: int, user_id: int, new_name: str) -> tuple[bool, str]:
|
||||||
key_id: int, user_id: int, new_name: str
|
|
||||||
) -> tuple[bool, str]:
|
|
||||||
"""Update the name of a saved channel key."""
|
"""Update the name of a saved channel key."""
|
||||||
new_name = new_name.strip()
|
new_name = new_name.strip()
|
||||||
if not new_name:
|
if not new_name:
|
||||||
|
|||||||
@@ -81,10 +81,12 @@ def generate_self_signed_cert(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create certificate
|
# Create certificate
|
||||||
subject = issuer = x509.Name([
|
subject = issuer = x509.Name(
|
||||||
|
[
|
||||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
|
||||||
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
||||||
])
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# Subject Alternative Names
|
# Subject Alternative Names
|
||||||
san_list = [
|
san_list = [
|
||||||
@@ -112,7 +114,7 @@ def generate_self_signed_cert(
|
|||||||
except (ipaddress.AddressValueError, ValueError):
|
except (ipaddress.AddressValueError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
now = datetime.datetime.now(datetime.timezone.utc)
|
now = datetime.datetime.now(datetime.UTC)
|
||||||
cert = (
|
cert = (
|
||||||
x509.CertificateBuilder()
|
x509.CertificateBuilder()
|
||||||
.subject_name(subject)
|
.subject_name(subject)
|
||||||
|
|||||||
@@ -95,7 +95,16 @@ const Stegasoo = {
|
|||||||
if (!isPayloadZone && !isQrZone) {
|
if (!isPayloadZone && !isQrZone) {
|
||||||
input.addEventListener('change', function() {
|
input.addEventListener('change', function() {
|
||||||
if (this.files && this.files[0]) {
|
if (this.files && this.files[0]) {
|
||||||
Stegasoo.showImagePreview(this.files[0], preview, label, zone);
|
const file = this.files[0];
|
||||||
|
if (file.type.startsWith('image/') && preview) {
|
||||||
|
Stegasoo.showImagePreview(file, preview, label, zone);
|
||||||
|
} else if (file.type.startsWith('audio/') || !file.type.startsWith('image/')) {
|
||||||
|
// Audio or non-image files: show file info instead of image preview
|
||||||
|
Stegasoo.showAudioFileInfo(file, zone);
|
||||||
|
if (label) {
|
||||||
|
label.classList.add('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -154,6 +163,20 @@ const Stegasoo = {
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format audio file info for display in drop zones (v4.3.0)
|
||||||
|
*/
|
||||||
|
showAudioFileInfo(file, zone) {
|
||||||
|
const filenameEl = zone.querySelector('.pixel-data-filename span, .scan-data-filename span');
|
||||||
|
const sizeEl = zone.querySelector('.pixel-data-value, .scan-data-value');
|
||||||
|
if (filenameEl) filenameEl.textContent = file.name;
|
||||||
|
if (sizeEl) {
|
||||||
|
const kb = file.size / 1024;
|
||||||
|
sizeEl.textContent = kb >= 1024 ? (kb / 1024).toFixed(1) + ' MB' : kb.toFixed(1) + ' KB';
|
||||||
|
}
|
||||||
|
zone.classList.add('has-file');
|
||||||
|
},
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// REFERENCE PHOTO SCAN ANIMATION
|
// REFERENCE PHOTO SCAN ANIMATION
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -1036,6 +1059,10 @@ const Stegasoo = {
|
|||||||
'saving': 'Saving image...',
|
'saving': 'Saving image...',
|
||||||
'finalizing': 'Finalizing...',
|
'finalizing': 'Finalizing...',
|
||||||
'complete': 'Complete!',
|
'complete': 'Complete!',
|
||||||
|
// Audio encode phases (v4.3.0)
|
||||||
|
'audio_transcoding': 'Transcoding audio...',
|
||||||
|
'audio_embedding': 'Embedding in audio...',
|
||||||
|
'spread_embedding': 'Spread spectrum embedding...',
|
||||||
};
|
};
|
||||||
return phases[phase] || phase;
|
return phases[phase] || phase;
|
||||||
},
|
},
|
||||||
@@ -1252,6 +1279,10 @@ const Stegasoo = {
|
|||||||
'verifying': 'Verifying...',
|
'verifying': 'Verifying...',
|
||||||
'finalizing': 'Finalizing...',
|
'finalizing': 'Finalizing...',
|
||||||
'complete': 'Complete!',
|
'complete': 'Complete!',
|
||||||
|
// Audio decode phases (v4.3.0)
|
||||||
|
'audio_transcoding': 'Transcoding audio...',
|
||||||
|
'audio_extracting': 'Extracting from audio...',
|
||||||
|
'spread_extracting': 'Spread spectrum extracting...',
|
||||||
};
|
};
|
||||||
return phases[phase] || phase;
|
return phases[phase] || phase;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ Usage:
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -27,6 +29,24 @@ from pathlib import Path
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
# Configure logging for worker subprocess
|
||||||
|
_log_level = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
|
||||||
|
if _log_level and hasattr(logging, _log_level):
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, _log_level),
|
||||||
|
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
stream=sys.stderr,
|
||||||
|
)
|
||||||
|
elif os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
stream=sys.stderr,
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("stegasoo.worker")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_channel_key(channel_key_param):
|
def _resolve_channel_key(channel_key_param):
|
||||||
"""
|
"""
|
||||||
@@ -73,6 +93,7 @@ def _get_channel_info(resolved_key):
|
|||||||
|
|
||||||
def encode_operation(params: dict) -> dict:
|
def encode_operation(params: dict) -> dict:
|
||||||
"""Handle encode operation."""
|
"""Handle encode operation."""
|
||||||
|
logger.debug("encode_operation: mode=%s", params.get("embed_mode", "lsb"))
|
||||||
from stegasoo import FilePayload, encode
|
from stegasoo import FilePayload, encode
|
||||||
|
|
||||||
# Decode base64 inputs
|
# Decode base64 inputs
|
||||||
@@ -142,6 +163,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
|
|
||||||
with open(progress_file, "w") as f:
|
with open(progress_file, "w") as f:
|
||||||
json.dump({"percent": percent, "phase": phase}, f)
|
json.dump({"percent": percent, "phase": phase}, f)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -150,6 +172,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
|
|||||||
|
|
||||||
def decode_operation(params: dict) -> dict:
|
def decode_operation(params: dict) -> dict:
|
||||||
"""Handle decode operation."""
|
"""Handle decode operation."""
|
||||||
|
logger.debug("decode_operation: mode=%s", params.get("embed_mode", "auto"))
|
||||||
from stegasoo import decode
|
from stegasoo import decode
|
||||||
|
|
||||||
progress_file = params.get("progress_file")
|
progress_file = params.get("progress_file")
|
||||||
@@ -233,6 +256,145 @@ def capacity_check_operation(params: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def encode_audio_operation(params: dict) -> dict:
|
||||||
|
"""Handle audio encode operation (v4.3.0)."""
|
||||||
|
logger.debug("encode_audio_operation: mode=%s", params.get("embed_mode", "audio_lsb"))
|
||||||
|
from stegasoo import FilePayload, encode_audio
|
||||||
|
|
||||||
|
carrier_data = base64.b64decode(params["carrier_b64"])
|
||||||
|
reference_data = base64.b64decode(params["reference_b64"])
|
||||||
|
|
||||||
|
# Optional RSA key
|
||||||
|
rsa_key_data = None
|
||||||
|
if params.get("rsa_key_b64"):
|
||||||
|
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
|
||||||
|
|
||||||
|
# Determine payload type
|
||||||
|
if params.get("file_b64"):
|
||||||
|
file_data = base64.b64decode(params["file_b64"])
|
||||||
|
payload = FilePayload(
|
||||||
|
data=file_data,
|
||||||
|
filename=params.get("file_name", "file"),
|
||||||
|
mime_type=params.get("file_mime", "application/octet-stream"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
payload = params.get("message", "")
|
||||||
|
|
||||||
|
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
||||||
|
|
||||||
|
# Resolve chip_tier from params (None means use default)
|
||||||
|
chip_tier_val = params.get("chip_tier")
|
||||||
|
if chip_tier_val is not None:
|
||||||
|
chip_tier_val = int(chip_tier_val)
|
||||||
|
|
||||||
|
stego_audio, stats = encode_audio(
|
||||||
|
message=payload,
|
||||||
|
reference_photo=reference_data,
|
||||||
|
carrier_audio=carrier_data,
|
||||||
|
passphrase=params.get("passphrase", ""),
|
||||||
|
pin=params.get("pin"),
|
||||||
|
rsa_key_data=rsa_key_data,
|
||||||
|
rsa_password=params.get("rsa_password"),
|
||||||
|
embed_mode=params.get("embed_mode", "audio_lsb"),
|
||||||
|
channel_key=resolved_channel_key,
|
||||||
|
progress_file=params.get("progress_file"),
|
||||||
|
chip_tier=chip_tier_val,
|
||||||
|
)
|
||||||
|
|
||||||
|
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"stego_b64": base64.b64encode(stego_audio).decode("ascii"),
|
||||||
|
"stats": {
|
||||||
|
"samples_modified": stats.samples_modified,
|
||||||
|
"total_samples": stats.total_samples,
|
||||||
|
"capacity_used": stats.capacity_used,
|
||||||
|
"bytes_embedded": stats.bytes_embedded,
|
||||||
|
"sample_rate": stats.sample_rate,
|
||||||
|
"channels": stats.channels,
|
||||||
|
"duration_seconds": stats.duration_seconds,
|
||||||
|
"embed_mode": stats.embed_mode,
|
||||||
|
},
|
||||||
|
"channel_mode": channel_mode,
|
||||||
|
"channel_fingerprint": channel_fingerprint,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_audio_operation(params: dict) -> dict:
|
||||||
|
"""Handle audio decode operation (v4.3.0)."""
|
||||||
|
logger.debug("decode_audio_operation: mode=%s", params.get("embed_mode", "audio_auto"))
|
||||||
|
from stegasoo import decode_audio
|
||||||
|
|
||||||
|
progress_file = params.get("progress_file")
|
||||||
|
_write_decode_progress(progress_file, 5, "reading")
|
||||||
|
|
||||||
|
stego_data = base64.b64decode(params["stego_b64"])
|
||||||
|
reference_data = base64.b64decode(params["reference_b64"])
|
||||||
|
|
||||||
|
_write_decode_progress(progress_file, 15, "reading")
|
||||||
|
|
||||||
|
rsa_key_data = None
|
||||||
|
if params.get("rsa_key_b64"):
|
||||||
|
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
|
||||||
|
|
||||||
|
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
||||||
|
|
||||||
|
result = decode_audio(
|
||||||
|
stego_audio=stego_data,
|
||||||
|
reference_photo=reference_data,
|
||||||
|
passphrase=params.get("passphrase", ""),
|
||||||
|
pin=params.get("pin"),
|
||||||
|
rsa_key_data=rsa_key_data,
|
||||||
|
rsa_password=params.get("rsa_password"),
|
||||||
|
embed_mode=params.get("embed_mode", "audio_auto"),
|
||||||
|
channel_key=resolved_channel_key,
|
||||||
|
progress_file=progress_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.is_file:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"is_file": True,
|
||||||
|
"file_b64": base64.b64encode(result.file_data).decode("ascii"),
|
||||||
|
"filename": result.filename,
|
||||||
|
"mime_type": result.mime_type,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"is_file": False,
|
||||||
|
"message": result.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def audio_info_operation(params: dict) -> dict:
|
||||||
|
"""Handle audio info operation (v4.3.0)."""
|
||||||
|
from stegasoo import get_audio_info
|
||||||
|
from stegasoo.audio_steganography import calculate_audio_lsb_capacity
|
||||||
|
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
||||||
|
|
||||||
|
audio_data = base64.b64decode(params["audio_b64"])
|
||||||
|
|
||||||
|
info = get_audio_info(audio_data)
|
||||||
|
lsb_capacity = calculate_audio_lsb_capacity(audio_data)
|
||||||
|
spread_capacity = calculate_audio_spread_capacity(audio_data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"info": {
|
||||||
|
"sample_rate": info.sample_rate,
|
||||||
|
"channels": info.channels,
|
||||||
|
"duration_seconds": round(info.duration_seconds, 2),
|
||||||
|
"num_samples": info.num_samples,
|
||||||
|
"format": info.format,
|
||||||
|
"bit_depth": info.bit_depth,
|
||||||
|
"capacity_lsb": lsb_capacity,
|
||||||
|
"capacity_spread": spread_capacity.usable_capacity_bytes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def channel_status_operation(params: dict) -> dict:
|
def channel_status_operation(params: dict) -> dict:
|
||||||
"""Handle channel status check (v4.0.0)."""
|
"""Handle channel status check (v4.0.0)."""
|
||||||
from stegasoo import get_channel_status
|
from stegasoo import get_channel_status
|
||||||
@@ -263,6 +425,7 @@ def main():
|
|||||||
else:
|
else:
|
||||||
params = json.loads(input_text)
|
params = json.loads(input_text)
|
||||||
operation = params.get("operation")
|
operation = params.get("operation")
|
||||||
|
logger.info("Worker handling operation: %s", operation)
|
||||||
|
|
||||||
if operation == "encode":
|
if operation == "encode":
|
||||||
output = encode_operation(params)
|
output = encode_operation(params)
|
||||||
@@ -274,6 +437,13 @@ def main():
|
|||||||
output = capacity_check_operation(params)
|
output = capacity_check_operation(params)
|
||||||
elif operation == "channel_status":
|
elif operation == "channel_status":
|
||||||
output = channel_status_operation(params)
|
output = channel_status_operation(params)
|
||||||
|
# Audio operations (v4.3.0)
|
||||||
|
elif operation == "encode_audio":
|
||||||
|
output = encode_audio_operation(params)
|
||||||
|
elif operation == "decode_audio":
|
||||||
|
output = decode_audio_operation(params)
|
||||||
|
elif operation == "audio_info":
|
||||||
|
output = audio_info_operation(params)
|
||||||
else:
|
else:
|
||||||
output = {"success": False, "error": f"Unknown operation: {operation}"}
|
output = {"success": False, "error": f"Unknown operation: {operation}"}
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,35 @@ class CapacityResult:
|
|||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioEncodeResult:
|
||||||
|
"""Result from audio encode operation (v4.3.0)."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
stego_data: bytes | None = None
|
||||||
|
stats: dict[str, Any] | None = None
|
||||||
|
channel_mode: str | None = None
|
||||||
|
channel_fingerprint: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
error_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioInfoResult:
|
||||||
|
"""Result from audio info operation (v4.3.0)."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
sample_rate: int = 0
|
||||||
|
channels: int = 0
|
||||||
|
duration_seconds: float = 0.0
|
||||||
|
num_samples: int = 0
|
||||||
|
format: str = ""
|
||||||
|
bit_depth: int | None = None
|
||||||
|
capacity_lsb: int = 0
|
||||||
|
capacity_spread: int = 0
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ChannelStatusResult:
|
class ChannelStatusResult:
|
||||||
"""Result from channel status check (v4.0.0)."""
|
"""Result from channel status check (v4.0.0)."""
|
||||||
@@ -456,6 +485,201 @@ class SubprocessStego:
|
|||||||
error=result.get("error", "Unknown error"),
|
error=result.get("error", "Unknown error"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Audio Steganography (v4.3.0)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def encode_audio(
|
||||||
|
self,
|
||||||
|
carrier_data: bytes,
|
||||||
|
reference_data: bytes,
|
||||||
|
message: str | None = None,
|
||||||
|
file_data: bytes | None = None,
|
||||||
|
file_name: str | None = None,
|
||||||
|
file_mime: str | None = None,
|
||||||
|
passphrase: str = "",
|
||||||
|
pin: str | None = None,
|
||||||
|
rsa_key_data: bytes | None = None,
|
||||||
|
rsa_password: str | None = None,
|
||||||
|
embed_mode: str = "audio_lsb",
|
||||||
|
channel_key: str | None = "auto",
|
||||||
|
timeout: int | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
chip_tier: int | None = None,
|
||||||
|
) -> AudioEncodeResult:
|
||||||
|
"""
|
||||||
|
Encode a message or file into an audio carrier.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
carrier_data: Carrier audio bytes (WAV, FLAC, MP3, etc.)
|
||||||
|
reference_data: Reference photo bytes
|
||||||
|
message: Text message to encode (if not file)
|
||||||
|
file_data: File bytes to encode (if not message)
|
||||||
|
file_name: Original filename (for file payload)
|
||||||
|
file_mime: MIME type (for file payload)
|
||||||
|
passphrase: Encryption passphrase
|
||||||
|
pin: Optional PIN
|
||||||
|
rsa_key_data: Optional RSA key PEM bytes
|
||||||
|
rsa_password: RSA key password if encrypted
|
||||||
|
embed_mode: 'audio_lsb' or 'audio_spread'
|
||||||
|
channel_key: 'auto', 'none', or explicit key
|
||||||
|
timeout: Operation timeout (default 300s for audio)
|
||||||
|
progress_file: Path to write progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AudioEncodeResult with stego audio data on success
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"operation": "encode_audio",
|
||||||
|
"carrier_b64": base64.b64encode(carrier_data).decode("ascii"),
|
||||||
|
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
|
||||||
|
"message": message,
|
||||||
|
"passphrase": passphrase,
|
||||||
|
"pin": pin,
|
||||||
|
"embed_mode": embed_mode,
|
||||||
|
"channel_key": channel_key,
|
||||||
|
"progress_file": progress_file,
|
||||||
|
"chip_tier": chip_tier,
|
||||||
|
}
|
||||||
|
|
||||||
|
if file_data:
|
||||||
|
params["file_b64"] = base64.b64encode(file_data).decode("ascii")
|
||||||
|
params["file_name"] = file_name
|
||||||
|
params["file_mime"] = file_mime
|
||||||
|
|
||||||
|
if rsa_key_data:
|
||||||
|
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
|
||||||
|
params["rsa_password"] = rsa_password
|
||||||
|
|
||||||
|
# Audio operations can be slower (especially spread spectrum)
|
||||||
|
result = self._run_worker(params, timeout or 300)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
return AudioEncodeResult(
|
||||||
|
success=True,
|
||||||
|
stego_data=base64.b64decode(result["stego_b64"]),
|
||||||
|
stats=result.get("stats"),
|
||||||
|
channel_mode=result.get("channel_mode"),
|
||||||
|
channel_fingerprint=result.get("channel_fingerprint"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return AudioEncodeResult(
|
||||||
|
success=False,
|
||||||
|
error=result.get("error", "Unknown error"),
|
||||||
|
error_type=result.get("error_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def decode_audio(
|
||||||
|
self,
|
||||||
|
stego_data: bytes,
|
||||||
|
reference_data: bytes,
|
||||||
|
passphrase: str = "",
|
||||||
|
pin: str | None = None,
|
||||||
|
rsa_key_data: bytes | None = None,
|
||||||
|
rsa_password: str | None = None,
|
||||||
|
embed_mode: str = "audio_auto",
|
||||||
|
channel_key: str | None = "auto",
|
||||||
|
timeout: int | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
) -> DecodeResult:
|
||||||
|
"""
|
||||||
|
Decode a message or file from stego audio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stego_data: Stego audio bytes
|
||||||
|
reference_data: Reference photo bytes
|
||||||
|
passphrase: Decryption passphrase
|
||||||
|
pin: Optional PIN
|
||||||
|
rsa_key_data: Optional RSA key PEM bytes
|
||||||
|
rsa_password: RSA key password if encrypted
|
||||||
|
embed_mode: 'audio_auto', 'audio_lsb', or 'audio_spread'
|
||||||
|
channel_key: 'auto', 'none', or explicit key
|
||||||
|
timeout: Operation timeout (default 300s for audio)
|
||||||
|
progress_file: Path to write progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DecodeResult with message or file_data on success
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"operation": "decode_audio",
|
||||||
|
"stego_b64": base64.b64encode(stego_data).decode("ascii"),
|
||||||
|
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
|
||||||
|
"passphrase": passphrase,
|
||||||
|
"pin": pin,
|
||||||
|
"embed_mode": embed_mode,
|
||||||
|
"channel_key": channel_key,
|
||||||
|
"progress_file": progress_file,
|
||||||
|
}
|
||||||
|
|
||||||
|
if rsa_key_data:
|
||||||
|
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
|
||||||
|
params["rsa_password"] = rsa_password
|
||||||
|
|
||||||
|
result = self._run_worker(params, timeout or 300)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
if result.get("is_file"):
|
||||||
|
return DecodeResult(
|
||||||
|
success=True,
|
||||||
|
is_file=True,
|
||||||
|
file_data=base64.b64decode(result["file_b64"]),
|
||||||
|
filename=result.get("filename"),
|
||||||
|
mime_type=result.get("mime_type"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return DecodeResult(
|
||||||
|
success=True,
|
||||||
|
is_file=False,
|
||||||
|
message=result.get("message"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return DecodeResult(
|
||||||
|
success=False,
|
||||||
|
error=result.get("error", "Unknown error"),
|
||||||
|
error_type=result.get("error_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def audio_info(
|
||||||
|
self,
|
||||||
|
audio_data: bytes,
|
||||||
|
timeout: int | None = None,
|
||||||
|
) -> AudioInfoResult:
|
||||||
|
"""
|
||||||
|
Get audio file information and steganographic capacity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Audio file bytes
|
||||||
|
timeout: Operation timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AudioInfoResult with metadata and capacity info
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"operation": "audio_info",
|
||||||
|
"audio_b64": base64.b64encode(audio_data).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = self._run_worker(params, timeout)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
info = result.get("info", {})
|
||||||
|
return AudioInfoResult(
|
||||||
|
success=True,
|
||||||
|
sample_rate=info.get("sample_rate", 0),
|
||||||
|
channels=info.get("channels", 0),
|
||||||
|
duration_seconds=info.get("duration_seconds", 0.0),
|
||||||
|
num_samples=info.get("num_samples", 0),
|
||||||
|
format=info.get("format", ""),
|
||||||
|
bit_depth=info.get("bit_depth"),
|
||||||
|
capacity_lsb=info.get("capacity_lsb", 0),
|
||||||
|
capacity_spread=info.get("capacity_spread", 0),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return AudioInfoResult(
|
||||||
|
success=False,
|
||||||
|
error=result.get("error", "Unknown error"),
|
||||||
|
)
|
||||||
|
|
||||||
def get_channel_status(
|
def get_channel_status(
|
||||||
self,
|
self,
|
||||||
reveal: bool = False,
|
reveal: bool = False,
|
||||||
|
|||||||
@@ -24,7 +24,11 @@
|
|||||||
border-left: 3px solid #ffe699;
|
border-left: 3px solid #ffe699;
|
||||||
}
|
}
|
||||||
.step-accordion .accordion-button::after {
|
.step-accordion .accordion-button::after {
|
||||||
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
|
filter: brightness(0) invert(1);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.step-accordion .accordion-button:not(.collapsed)::after {
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.step-accordion .accordion-body {
|
.step-accordion .accordion-body {
|
||||||
background: rgba(30, 40, 50, 0.4);
|
background: rgba(30, 40, 50, 0.4);
|
||||||
@@ -172,19 +176,51 @@
|
|||||||
<div class="accordion step-accordion" id="decodeAccordion">
|
<div class="accordion step-accordion" id="decodeAccordion">
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
STEP 1: IMAGES & MODE
|
STEP 1: CARRIER TYPE (v4.3.0)
|
||||||
|
================================================================ -->
|
||||||
|
<div class="accordion-item" id="carrierTypeStep">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepCarrierType">
|
||||||
|
<span class="step-title">
|
||||||
|
<span class="step-number" id="stepCarrierTypeNumber">1</span>
|
||||||
|
<i class="bi bi-collection me-1"></i> Carrier Type
|
||||||
|
</span>
|
||||||
|
<span class="step-summary" id="stepCarrierTypeSummary"></span>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="stepCarrierType" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<input type="hidden" name="carrier_type" id="carrierTypeInput" value="image">
|
||||||
|
<div class="btn-group w-100" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="carrier_type_select" id="typeImage" value="image" checked>
|
||||||
|
<label class="btn btn-outline-secondary" for="typeImage">
|
||||||
|
<i class="bi bi-image me-1"></i> Image
|
||||||
|
</label>
|
||||||
|
<input type="radio" class="btn-check" name="carrier_type_select" id="typeAudio" value="audio"
|
||||||
|
{% if not has_audio %}disabled{% endif %}>
|
||||||
|
<label class="btn btn-outline-secondary {% if not has_audio %}disabled text-muted{% endif %}" for="typeAudio">
|
||||||
|
<i class="bi bi-music-note-beamed me-1"></i> Audio
|
||||||
|
{% if not has_audio %}<small class="d-block" style="font-size: 0.65rem;">(not available)</small>{% endif %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
STEP 2: IMAGES & MODE
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
||||||
<span class="step-title">
|
<span class="step-title">
|
||||||
<span class="step-number" id="stepImagesNumber">1</span>
|
<span class="step-number" id="stepImagesNumber">2</span>
|
||||||
<i class="bi bi-images me-1"></i> Images & Mode
|
<i class="bi bi-images me-1"></i> Reference, Carrier, Mode
|
||||||
</span>
|
</span>
|
||||||
<span class="step-summary" id="stepImagesSummary">Select reference & stego</span>
|
<span class="step-summary" id="stepImagesSummary">Select reference & stego</span>
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
|
<div id="stepImages" class="accordion-collapse collapse" data-bs-parent="#decodeAccordion">
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -213,6 +249,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
|
<div id="imageStegoSection">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
||||||
</label>
|
</label>
|
||||||
@@ -237,10 +274,30 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-text">Image containing the hidden message</div>
|
<div class="form-text">Image containing the hidden message</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Audio Stego (hidden by default) -->
|
||||||
|
<div class="d-none" id="audioStegoSection">
|
||||||
|
<label class="form-label">
|
||||||
|
<i class="bi bi-file-earmark-music me-1"></i> Stego Audio
|
||||||
|
</label>
|
||||||
|
<div class="drop-zone pixel-container" id="audioStegoDropZone">
|
||||||
|
<input type="file" name="stego_image" accept="audio/*" id="audioStegoInput">
|
||||||
|
<div class="drop-zone-label">
|
||||||
|
<i class="bi bi-music-note-beamed fs-3 d-block mb-2 text-muted"></i>
|
||||||
|
<span class="text-muted">Drop audio or click</span>
|
||||||
|
</div>
|
||||||
|
<div class="pixel-data-panel">
|
||||||
|
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="audioStegoFileName">audio.wav</span></div>
|
||||||
|
<div class="pixel-data-row"><span class="pixel-status-badge">Audio Loaded</span><span class="pixel-data-value" id="audioStegoFileSize">--</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Audio file containing the hidden message</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Extraction Mode -->
|
<!-- Extraction Mode -->
|
||||||
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
||||||
|
<div id="imageModeGroup">
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
<input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
|
<input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
|
||||||
<label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label>
|
<label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label>
|
||||||
@@ -250,6 +307,18 @@
|
|||||||
<label class="btn btn-outline-secondary text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
|
<label class="btn btn-outline-secondary text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Audio Extraction Modes (hidden by default) -->
|
||||||
|
<div class="d-none" id="audioModeGroup">
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioAuto" value="audio_auto">
|
||||||
|
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioAuto"><i class="bi bi-magic me-1"></i>Auto</label>
|
||||||
|
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioLsb" value="audio_lsb">
|
||||||
|
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||||
|
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioSpread" value="audio_spread">
|
||||||
|
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioSpread"><i class="bi bi-broadcast me-1"></i>Spread</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-text" id="modeHint">
|
<div class="form-text" id="modeHint">
|
||||||
<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT
|
<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT
|
||||||
</div>
|
</div>
|
||||||
@@ -259,13 +328,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
STEP 2: SECURITY
|
STEP 3: SECURITY
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
|
||||||
<span class="step-title">
|
<span class="step-title">
|
||||||
<span class="step-number" id="stepSecurityNumber">2</span>
|
<span class="step-number" id="stepSecurityNumber">3</span>
|
||||||
<i class="bi bi-shield-lock me-1"></i> Security
|
<i class="bi bi-shield-lock me-1"></i> Security
|
||||||
</span>
|
</span>
|
||||||
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
|
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
|
||||||
@@ -425,7 +494,10 @@
|
|||||||
const modeHints = {
|
const modeHints = {
|
||||||
auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' },
|
auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' },
|
||||||
lsb: { icon: 'hdd', text: 'For email and direct transfers' },
|
lsb: { icon: 'hdd', text: 'For email and direct transfers' },
|
||||||
dct: { icon: 'phone', text: 'For social media images' }
|
dct: { icon: 'phone', text: 'For social media images' },
|
||||||
|
audio_auto: { icon: 'lightning', text: 'Tries LSB first, then Spread Spectrum' },
|
||||||
|
audio_lsb: { icon: 'grid-3x3-gap', text: 'Direct bit embedding in audio samples' },
|
||||||
|
audio_spread: { icon: 'broadcast', text: 'Noise-resistant spread spectrum encoding' }
|
||||||
};
|
};
|
||||||
|
|
||||||
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
||||||
@@ -442,9 +514,14 @@ document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
|||||||
// ACCORDION SUMMARY UPDATES
|
// ACCORDION SUMMARY UPDATES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
const carrierTypeInput = document.getElementById('carrierTypeInput');
|
||||||
|
|
||||||
function updateImagesSummary() {
|
function updateImagesSummary() {
|
||||||
const ref = document.getElementById('refPhotoInput')?.files[0];
|
const ref = document.getElementById('refPhotoInput')?.files[0];
|
||||||
const stego = document.getElementById('stegoInput')?.files[0];
|
const isAudio = carrierTypeInput?.value === 'audio';
|
||||||
|
const stego = isAudio
|
||||||
|
? document.getElementById('audioStegoInput')?.files[0]
|
||||||
|
: document.getElementById('stegoInput')?.files[0];
|
||||||
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'AUTO';
|
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'AUTO';
|
||||||
const summary = document.getElementById('stepImagesSummary');
|
const summary = document.getElementById('stepImagesSummary');
|
||||||
const stepNum = document.getElementById('stepImagesNumber');
|
const stepNum = document.getElementById('stepImagesNumber');
|
||||||
@@ -460,12 +537,12 @@ function updateImagesSummary() {
|
|||||||
summary.textContent = ref ? ref.name.slice(0, 15) : stego.name.slice(0, 15);
|
summary.textContent = ref ? ref.name.slice(0, 15) : stego.name.slice(0, 15);
|
||||||
summary.classList.remove('has-content');
|
summary.classList.remove('has-content');
|
||||||
stepNum.classList.remove('complete');
|
stepNum.classList.remove('complete');
|
||||||
stepNum.textContent = '1';
|
stepNum.textContent = '2';
|
||||||
} else {
|
} else {
|
||||||
summary.textContent = 'Select reference & stego';
|
summary.textContent = isAudio ? 'Select reference & audio' : 'Select reference & stego';
|
||||||
summary.classList.remove('has-content');
|
summary.classList.remove('has-content');
|
||||||
stepNum.classList.remove('complete');
|
stepNum.classList.remove('complete');
|
||||||
stepNum.textContent = '1';
|
stepNum.textContent = '2';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,19 +570,99 @@ function updateSecuritySummary() {
|
|||||||
summary.textContent = 'Passphrase & keys';
|
summary.textContent = 'Passphrase & keys';
|
||||||
summary.classList.remove('has-content');
|
summary.classList.remove('has-content');
|
||||||
stepNum.classList.remove('complete');
|
stepNum.classList.remove('complete');
|
||||||
stepNum.textContent = '2';
|
stepNum.textContent = '3';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach listeners
|
// Attach listeners
|
||||||
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
|
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
|
||||||
document.getElementById('stegoInput')?.addEventListener('change', updateImagesSummary);
|
document.getElementById('stegoInput')?.addEventListener('change', updateImagesSummary);
|
||||||
|
document.getElementById('audioStegoInput')?.addEventListener('change', updateImagesSummary);
|
||||||
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||||
|
document.querySelectorAll('#audioModeGroup input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||||
|
|
||||||
document.getElementById('passphraseInput')?.addEventListener('input', updateSecuritySummary);
|
document.getElementById('passphraseInput')?.addEventListener('input', updateSecuritySummary);
|
||||||
document.getElementById('pinInput')?.addEventListener('input', updateSecuritySummary);
|
document.getElementById('pinInput')?.addEventListener('input', updateSecuritySummary);
|
||||||
document.querySelector('input[name="rsa_key"]')?.addEventListener('change', updateSecuritySummary);
|
document.querySelector('input[name="rsa_key"]')?.addEventListener('change', updateSecuritySummary);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CARRIER TYPE TOGGLE (v4.3.0)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const carrierTypeRadios = document.querySelectorAll('input[name="carrier_type_select"]');
|
||||||
|
const imageStegoSection = document.getElementById('imageStegoSection');
|
||||||
|
const audioStegoSection = document.getElementById('audioStegoSection');
|
||||||
|
const imageModeGroup = document.getElementById('imageModeGroup');
|
||||||
|
const audioModeGroup = document.getElementById('audioModeGroup');
|
||||||
|
const stepCarrierTypeSummary = document.getElementById('stepCarrierTypeSummary');
|
||||||
|
|
||||||
|
carrierTypeRadios.forEach(radio => {
|
||||||
|
radio.addEventListener('change', function() {
|
||||||
|
const isAudio = this.value === 'audio';
|
||||||
|
carrierTypeInput.value = this.value;
|
||||||
|
|
||||||
|
// Toggle stego sections
|
||||||
|
if (imageStegoSection) imageStegoSection.classList.toggle('d-none', isAudio);
|
||||||
|
if (audioStegoSection) audioStegoSection.classList.toggle('d-none', !isAudio);
|
||||||
|
|
||||||
|
// Toggle required attribute so hidden inputs don't block form submission
|
||||||
|
const imgStego = document.getElementById('stegoInput');
|
||||||
|
const audStego = document.getElementById('audioStegoInput');
|
||||||
|
if (imgStego) { if (isAudio) imgStego.removeAttribute('required'); else imgStego.setAttribute('required', ''); }
|
||||||
|
if (audStego) { if (isAudio) audStego.setAttribute('required', ''); else audStego.removeAttribute('required'); }
|
||||||
|
|
||||||
|
// Toggle mode groups
|
||||||
|
if (imageModeGroup) imageModeGroup.classList.toggle('d-none', isAudio);
|
||||||
|
if (audioModeGroup) audioModeGroup.classList.toggle('d-none', !isAudio);
|
||||||
|
|
||||||
|
// Update summary
|
||||||
|
if (stepCarrierTypeSummary) {
|
||||||
|
stepCarrierTypeSummary.textContent = isAudio ? 'Audio' : 'Image';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select default mode
|
||||||
|
if (isAudio) {
|
||||||
|
const audioAuto = document.getElementById('modeAudioAuto');
|
||||||
|
if (audioAuto) audioAuto.checked = true;
|
||||||
|
} else {
|
||||||
|
const autoMode = document.getElementById('modeAuto');
|
||||||
|
if (autoMode) autoMode.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear stego file selections
|
||||||
|
const stegoInput = document.getElementById('stegoInput');
|
||||||
|
const audioStegoInput = document.getElementById('audioStegoInput');
|
||||||
|
if (stegoInput) stegoInput.value = '';
|
||||||
|
if (audioStegoInput) audioStegoInput.value = '';
|
||||||
|
|
||||||
|
// Reset previews
|
||||||
|
document.getElementById('stegoPreview')?.classList.add('d-none');
|
||||||
|
|
||||||
|
// Update mode hint
|
||||||
|
const hint = document.getElementById('modeHint');
|
||||||
|
if (hint) {
|
||||||
|
if (isAudio) {
|
||||||
|
hint.innerHTML = '<i class="bi bi-lightning me-1"></i>Tries LSB first, then Spread Spectrum';
|
||||||
|
} else {
|
||||||
|
hint.innerHTML = '<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImagesSummary();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audio stego file info display
|
||||||
|
const audioStegoInput = document.getElementById('audioStegoInput');
|
||||||
|
audioStegoInput?.addEventListener('change', function() {
|
||||||
|
if (this.files && this.files[0]) {
|
||||||
|
const file = this.files[0];
|
||||||
|
document.getElementById('audioStegoFileName').textContent = file.name;
|
||||||
|
document.getElementById('audioStegoFileSize').textContent = (file.size / 1024).toFixed(1) + ' KB';
|
||||||
|
updateImagesSummary();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MODE SWITCHING
|
// MODE SWITCHING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -24,7 +24,11 @@
|
|||||||
border-left: 3px solid #ffe699;
|
border-left: 3px solid #ffe699;
|
||||||
}
|
}
|
||||||
.step-accordion .accordion-button::after {
|
.step-accordion .accordion-button::after {
|
||||||
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
|
filter: brightness(0) invert(1);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.step-accordion .accordion-button:not(.collapsed)::after {
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.step-accordion .accordion-body {
|
.step-accordion .accordion-body {
|
||||||
background: rgba(30, 40, 50, 0.4);
|
background: rgba(30, 40, 50, 0.4);
|
||||||
@@ -126,19 +130,56 @@
|
|||||||
<div class="accordion step-accordion" id="encodeAccordion">
|
<div class="accordion step-accordion" id="encodeAccordion">
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
STEP 1: IMAGES
|
STEP 1: CARRIER TYPE (v4.3.0)
|
||||||
|
================================================================ -->
|
||||||
|
<div class="accordion-item" id="carrierTypeStep">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepCarrierType">
|
||||||
|
<span class="step-title">
|
||||||
|
<span class="step-number" id="stepCarrierTypeNumber">1</span>
|
||||||
|
<i class="bi bi-collection me-1"></i> Carrier Type
|
||||||
|
</span>
|
||||||
|
<span class="step-summary" id="stepCarrierTypeSummary"></span>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="stepCarrierType" class="accordion-collapse collapse show" data-bs-parent="#encodeAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<input type="hidden" name="carrier_type" id="carrierTypeInput" value="image">
|
||||||
|
<div class="btn-group w-100" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="carrier_type_select" id="typeImage" value="image" checked>
|
||||||
|
<label class="btn btn-outline-secondary" for="typeImage">
|
||||||
|
<i class="bi bi-image me-1"></i> Image
|
||||||
|
</label>
|
||||||
|
<input type="radio" class="btn-check" name="carrier_type_select" id="typeAudio" value="audio"
|
||||||
|
{% if not has_audio %}disabled{% endif %}>
|
||||||
|
<label class="btn btn-outline-secondary {% if not has_audio %}disabled text-muted{% endif %}" for="typeAudio">
|
||||||
|
<i class="bi bi-music-note-beamed me-1"></i> Audio
|
||||||
|
{% if not has_audio %}<small class="d-block" style="font-size: 0.65rem;">(not available)</small>{% endif %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% if not has_audio %}
|
||||||
|
<div class="form-text text-warning mt-2">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>Audio requires numpy and soundfile packages
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
STEP 2: IMAGES & MODE
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
||||||
<span class="step-title">
|
<span class="step-title">
|
||||||
<span class="step-number" id="stepImagesNumber">1</span>
|
<span class="step-number" id="stepImagesNumber">2</span>
|
||||||
<i class="bi bi-images me-1"></i> Images & Mode
|
<i class="bi bi-images me-1"></i> Reference, Carrier, Mode
|
||||||
</span>
|
</span>
|
||||||
<span class="step-summary" id="stepImagesSummary">Select reference & carrier</span>
|
<span class="step-summary" id="stepImagesSummary">Select reference & carrier</span>
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#encodeAccordion">
|
<div id="stepImages" class="accordion-collapse collapse" data-bs-parent="#encodeAccordion">
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -167,6 +208,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
|
<div id="imageCarrierSection">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
<i class="bi bi-file-earmark-image me-1"></i> Carrier Image
|
<i class="bi bi-file-earmark-image me-1"></i> Carrier Image
|
||||||
</label>
|
</label>
|
||||||
@@ -191,6 +233,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-text">Image to hide your message in</div>
|
<div class="form-text">Image to hide your message in</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio Carrier (hidden by default, shown when audio type selected) -->
|
||||||
|
<div class="d-none" id="audioCarrierSection">
|
||||||
|
<label class="form-label">
|
||||||
|
<i class="bi bi-file-earmark-music me-1"></i> Carrier Audio
|
||||||
|
</label>
|
||||||
|
<div class="drop-zone pixel-container" id="audioCarrierDropZone">
|
||||||
|
<input type="file" name="carrier" accept="audio/*" id="audioCarrierInput">
|
||||||
|
<div class="drop-zone-label">
|
||||||
|
<i class="bi bi-music-note-beamed fs-3 d-block mb-2 text-muted"></i>
|
||||||
|
<span class="text-muted">Drop audio or click</span>
|
||||||
|
</div>
|
||||||
|
<div class="pixel-data-panel">
|
||||||
|
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="audioCarrierFileName">audio.wav</span></div>
|
||||||
|
<div class="pixel-data-row"><span class="pixel-status-badge">Audio Loaded</span><span class="pixel-data-value" id="audioCarrierFileSize">--</span></div>
|
||||||
|
<div class="pixel-dimensions" id="audioCarrierDuration">--:-- duration</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Audio file to hide your message in</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Capacity Info -->
|
<!-- Capacity Info -->
|
||||||
@@ -204,7 +267,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio Capacity Info (v4.3.0) -->
|
||||||
|
<div class="alert alert-info small d-none mb-3" id="audioCapacityPanel">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-music-note-beamed me-1"></i><span id="audioInfo">-</span></span>
|
||||||
|
<span>
|
||||||
|
<span class="badge bg-primary me-1" id="lsbAudioCapacityBadge">LSB: -</span>
|
||||||
|
<span class="badge bg-warning text-dark" id="spreadCapacityBadge">Spread: -</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Embedding Mode (compact inline) -->
|
<!-- Embedding Mode (compact inline) -->
|
||||||
|
<div id="imageModeGroup">
|
||||||
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
|
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
|
||||||
@@ -228,6 +303,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio Modes (hidden by default) -->
|
||||||
|
<div class="d-none" id="audioModeGroup">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioLsb" value="audio_lsb">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeAudioLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||||
|
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioSpread" value="audio_spread">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeAudioSpread"><i class="bi bi-broadcast me-1"></i>Spread</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-text" id="modeHint">
|
<div class="form-text" id="modeHint">
|
||||||
<i class="bi bi-{% if has_dct %}phone{% else %}hdd{% endif %} me-1"></i>{% if has_dct %}Survives social media compression{% else %}Higher capacity for direct transfers{% endif %}
|
<i class="bi bi-{% if has_dct %}phone{% else %}hdd{% endif %} me-1"></i>{% if has_dct %}Survives social media compression{% else %}Higher capacity for direct transfers{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -237,13 +324,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
STEP 2: PAYLOAD
|
STEP 3: PAYLOAD
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepPayload">
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepPayload">
|
||||||
<span class="step-title">
|
<span class="step-title">
|
||||||
<span class="step-number" id="stepPayloadNumber">2</span>
|
<span class="step-number" id="stepPayloadNumber">3</span>
|
||||||
<i class="bi bi-box me-1"></i> Payload
|
<i class="bi bi-box me-1"></i> Payload
|
||||||
</span>
|
</span>
|
||||||
<span class="step-summary" id="stepPayloadSummary">Message or file to hide</span>
|
<span class="step-summary" id="stepPayloadSummary">Message or file to hide</span>
|
||||||
@@ -295,13 +382,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
STEP 3: SECURITY
|
STEP 4: SECURITY
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
|
||||||
<span class="step-title">
|
<span class="step-title">
|
||||||
<span class="step-number" id="stepSecurityNumber">3</span>
|
<span class="step-number" id="stepSecurityNumber">4</span>
|
||||||
<i class="bi bi-shield-lock me-1"></i> Security
|
<i class="bi bi-shield-lock me-1"></i> Security
|
||||||
</span>
|
</span>
|
||||||
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
|
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
|
||||||
@@ -462,13 +549,131 @@ document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CARRIER TYPE TOGGLE (v4.3.0)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const carrierTypeRadios = document.querySelectorAll('input[name="carrier_type_select"]');
|
||||||
|
const carrierTypeInput = document.getElementById('carrierTypeInput');
|
||||||
|
const imageCarrierSection = document.getElementById('imageCarrierSection');
|
||||||
|
const audioCarrierSection = document.getElementById('audioCarrierSection');
|
||||||
|
const imageModeGroup = document.getElementById('imageModeGroup');
|
||||||
|
const audioModeGroup = document.getElementById('audioModeGroup');
|
||||||
|
const capacityPanel = document.getElementById('capacityPanel');
|
||||||
|
const audioCapacityPanel = document.getElementById('audioCapacityPanel');
|
||||||
|
const stepCarrierTypeSummary = document.getElementById('stepCarrierTypeSummary');
|
||||||
|
|
||||||
|
carrierTypeRadios.forEach(radio => {
|
||||||
|
radio.addEventListener('change', function() {
|
||||||
|
const isAudio = this.value === 'audio';
|
||||||
|
carrierTypeInput.value = this.value;
|
||||||
|
|
||||||
|
// Toggle carrier sections
|
||||||
|
if (imageCarrierSection) imageCarrierSection.classList.toggle('d-none', isAudio);
|
||||||
|
if (audioCarrierSection) audioCarrierSection.classList.toggle('d-none', !isAudio);
|
||||||
|
|
||||||
|
// Toggle required attribute so hidden inputs don't block form submission
|
||||||
|
const imgCarrier = document.getElementById('carrierInput');
|
||||||
|
const audCarrier = document.getElementById('audioCarrierInput');
|
||||||
|
if (imgCarrier) { if (isAudio) imgCarrier.removeAttribute('required'); else imgCarrier.setAttribute('required', ''); }
|
||||||
|
if (audCarrier) { if (isAudio) audCarrier.setAttribute('required', ''); else audCarrier.removeAttribute('required'); }
|
||||||
|
|
||||||
|
// Toggle mode groups
|
||||||
|
if (imageModeGroup) imageModeGroup.classList.toggle('d-none', isAudio);
|
||||||
|
if (audioModeGroup) audioModeGroup.classList.toggle('d-none', !isAudio);
|
||||||
|
|
||||||
|
// Toggle capacity panels
|
||||||
|
if (capacityPanel) capacityPanel.classList.add('d-none');
|
||||||
|
if (audioCapacityPanel) audioCapacityPanel.classList.add('d-none');
|
||||||
|
|
||||||
|
// Update summary
|
||||||
|
if (stepCarrierTypeSummary) {
|
||||||
|
stepCarrierTypeSummary.textContent = isAudio ? 'Audio' : 'Image';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select default mode for the active type
|
||||||
|
if (isAudio) {
|
||||||
|
const audioLsb = document.getElementById('modeAudioLsb');
|
||||||
|
if (audioLsb) audioLsb.checked = true;
|
||||||
|
} else {
|
||||||
|
// Reset to DCT if available, else LSB
|
||||||
|
const dctRadio = document.getElementById('modeDct');
|
||||||
|
const lsbRadio = document.getElementById('modeLsb');
|
||||||
|
if (dctRadio && !dctRadio.disabled) {
|
||||||
|
dctRadio.checked = true;
|
||||||
|
} else if (lsbRadio) {
|
||||||
|
lsbRadio.checked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear carrier file selections
|
||||||
|
const carrierInput = document.getElementById('carrierInput');
|
||||||
|
const audioCarrierInput = document.getElementById('audioCarrierInput');
|
||||||
|
if (carrierInput) carrierInput.value = '';
|
||||||
|
if (audioCarrierInput) audioCarrierInput.value = '';
|
||||||
|
|
||||||
|
// Reset previews
|
||||||
|
document.getElementById('carrierPreview')?.classList.add('d-none');
|
||||||
|
|
||||||
|
// Update step title
|
||||||
|
const stepImagesTitle = document.querySelector('#stepImages')?.closest('.accordion-item')?.querySelector('.accordion-button .step-title');
|
||||||
|
if (stepImagesTitle) {
|
||||||
|
const icon = stepImagesTitle.querySelector('i:not(.step-number i)');
|
||||||
|
const textNode = stepImagesTitle.childNodes[stepImagesTitle.childNodes.length - 1];
|
||||||
|
if (icon) {
|
||||||
|
icon.className = isAudio ? 'bi bi-music-note-beamed me-1' : 'bi bi-images me-1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImagesSummary();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audio carrier file change handler
|
||||||
|
const audioCarrierInput = document.getElementById('audioCarrierInput');
|
||||||
|
audioCarrierInput?.addEventListener('change', function() {
|
||||||
|
if (this.files && this.files[0]) {
|
||||||
|
const file = this.files[0];
|
||||||
|
document.getElementById('audioCarrierFileName').textContent = file.name;
|
||||||
|
document.getElementById('audioCarrierFileSize').textContent = (file.size / 1024).toFixed(1) + ' KB';
|
||||||
|
|
||||||
|
// Fetch audio capacity
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('carrier', file);
|
||||||
|
fetch('/api/audio-capacity', { method: 'POST', body: formData })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) return;
|
||||||
|
const info = `${data.format || 'Audio'} · ${data.sample_rate}Hz · ${data.channels}ch · ${data.duration}s`;
|
||||||
|
document.getElementById('audioInfo').textContent = info;
|
||||||
|
document.getElementById('lsbAudioCapacityBadge').textContent = `LSB: ${(data.lsb_capacity / 1024).toFixed(1)} KB`;
|
||||||
|
document.getElementById('spreadCapacityBadge').textContent = `Spread: ${(data.spread_capacity / 1024).toFixed(1)} KB`;
|
||||||
|
document.getElementById('audioCapacityPanel')?.classList.remove('d-none');
|
||||||
|
if (data.duration) {
|
||||||
|
document.getElementById('audioCarrierDuration').textContent = data.duration + 's duration';
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Trigger the drop zone animation
|
||||||
|
const dropZone = document.getElementById('audioCarrierDropZone');
|
||||||
|
if (dropZone) {
|
||||||
|
dropZone.classList.add('has-file');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImagesSummary();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ACCORDION SUMMARY UPDATES
|
// ACCORDION SUMMARY UPDATES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function updateImagesSummary() {
|
function updateImagesSummary() {
|
||||||
const ref = document.getElementById('refPhotoInput')?.files[0];
|
const ref = document.getElementById('refPhotoInput')?.files[0];
|
||||||
const carrier = document.getElementById('carrierInput')?.files[0];
|
const isAudio = carrierTypeInput?.value === 'audio';
|
||||||
|
const carrier = isAudio
|
||||||
|
? document.getElementById('audioCarrierInput')?.files[0]
|
||||||
|
: document.getElementById('carrierInput')?.files[0];
|
||||||
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'LSB';
|
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'LSB';
|
||||||
const summary = document.getElementById('stepImagesSummary');
|
const summary = document.getElementById('stepImagesSummary');
|
||||||
const stepNum = document.getElementById('stepImagesNumber');
|
const stepNum = document.getElementById('stepImagesNumber');
|
||||||
@@ -484,12 +689,12 @@ function updateImagesSummary() {
|
|||||||
summary.textContent = ref ? ref.name.slice(0, 15) : carrier.name.slice(0, 15);
|
summary.textContent = ref ? ref.name.slice(0, 15) : carrier.name.slice(0, 15);
|
||||||
summary.classList.remove('has-content');
|
summary.classList.remove('has-content');
|
||||||
stepNum.classList.remove('complete');
|
stepNum.classList.remove('complete');
|
||||||
stepNum.textContent = '1';
|
stepNum.textContent = '2';
|
||||||
} else {
|
} else {
|
||||||
summary.textContent = 'Select reference & carrier';
|
summary.textContent = isAudio ? 'Select reference & audio' : 'Select reference & carrier';
|
||||||
summary.classList.remove('has-content');
|
summary.classList.remove('has-content');
|
||||||
stepNum.classList.remove('complete');
|
stepNum.classList.remove('complete');
|
||||||
stepNum.textContent = '1';
|
stepNum.textContent = '2';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,7 +720,7 @@ function updatePayloadSummary() {
|
|||||||
summary.textContent = 'Message or file to hide';
|
summary.textContent = 'Message or file to hide';
|
||||||
summary.classList.remove('has-content');
|
summary.classList.remove('has-content');
|
||||||
stepNum.classList.remove('complete');
|
stepNum.classList.remove('complete');
|
||||||
stepNum.textContent = '2';
|
stepNum.textContent = '3';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,14 +748,16 @@ function updateSecuritySummary() {
|
|||||||
summary.textContent = 'Passphrase & keys';
|
summary.textContent = 'Passphrase & keys';
|
||||||
summary.classList.remove('has-content');
|
summary.classList.remove('has-content');
|
||||||
stepNum.classList.remove('complete');
|
stepNum.classList.remove('complete');
|
||||||
stepNum.textContent = '3';
|
stepNum.textContent = '4';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach listeners
|
// Attach listeners
|
||||||
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
|
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
|
||||||
document.getElementById('carrierInput')?.addEventListener('change', updateImagesSummary);
|
document.getElementById('carrierInput')?.addEventListener('change', updateImagesSummary);
|
||||||
|
document.getElementById('audioCarrierInput')?.addEventListener('change', updateImagesSummary);
|
||||||
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||||
|
document.querySelectorAll('#audioModeGroup input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||||
|
|
||||||
document.getElementById('messageInput')?.addEventListener('input', updatePayloadSummary);
|
document.getElementById('messageInput')?.addEventListener('input', updatePayloadSummary);
|
||||||
document.getElementById('payloadFileInput')?.addEventListener('change', updatePayloadSummary);
|
document.getElementById('payloadFileInput')?.addEventListener('change', updatePayloadSummary);
|
||||||
|
|||||||
@@ -12,6 +12,20 @@
|
|||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
|
{% if carrier_type == 'audio' %}
|
||||||
|
<!-- Audio Preview -->
|
||||||
|
<div class="my-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="bi bi-music-note-beamed text-success" style="font-size: 4rem;"></i>
|
||||||
|
<div class="mt-2">
|
||||||
|
<audio controls src="{{ url_for('encode_file_route', file_id=file_id) }}" class="w-100" style="max-width: 400px;"></audio>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 small text-muted">
|
||||||
|
<i class="bi bi-music-note-beamed me-1"></i>Encoded Audio Preview
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
<div class="my-4">
|
<div class="my-4">
|
||||||
{% if thumbnail_url %}
|
{% if thumbnail_url %}
|
||||||
<!-- Thumbnail of the actual encoded image -->
|
<!-- Thumbnail of the actual encoded image -->
|
||||||
@@ -29,8 +43,9 @@
|
|||||||
<i class="bi bi-file-earmark-image text-success" style="font-size: 4rem;"></i>
|
<i class="bi bi-file-earmark-image text-success" style="font-size: 4rem;"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<p class="lead mb-4">Your secret has been hidden in the image.</p>
|
<p class="lead mb-4">Your secret has been hidden in the {{ 'audio file' if carrier_type == 'audio' else 'image' }}.</p>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<code class="fs-5">{{ filename }}</code>
|
<code class="fs-5">{{ filename }}</code>
|
||||||
@@ -38,7 +53,28 @@
|
|||||||
|
|
||||||
<!-- Mode and format badges -->
|
<!-- Mode and format badges -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
{% if embed_mode == 'dct' %}
|
{% if carrier_type == 'audio' %}
|
||||||
|
<!-- Audio mode badges -->
|
||||||
|
{% if embed_mode == 'audio_spread' %}
|
||||||
|
<span class="badge bg-warning text-dark fs-6">
|
||||||
|
<i class="bi bi-broadcast me-1"></i>Spread Spectrum
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-primary fs-6">
|
||||||
|
<i class="bi bi-grid-3x3-gap me-1"></i>Audio LSB
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="badge bg-info fs-6 ms-1">
|
||||||
|
<i class="bi bi-file-earmark-music me-1"></i>WAV
|
||||||
|
</span>
|
||||||
|
<div class="small text-muted mt-2">
|
||||||
|
{% if embed_mode == 'audio_spread' %}
|
||||||
|
Spread spectrum embedding in audio samples
|
||||||
|
{% else %}
|
||||||
|
LSB embedding in audio samples, WAV output
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif embed_mode == 'dct' %}
|
||||||
<span class="badge bg-info fs-6">
|
<span class="badge bg-info fs-6">
|
||||||
<i class="bi bi-soundwave me-1"></i>DCT Mode
|
<i class="bi bi-soundwave me-1"></i>DCT Mode
|
||||||
</span>
|
</span>
|
||||||
@@ -114,7 +150,7 @@
|
|||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<a href="{{ url_for('encode_download', file_id=file_id) }}"
|
<a href="{{ url_for('encode_download', file_id=file_id) }}"
|
||||||
class="btn btn-primary btn-lg" id="downloadBtn">
|
class="btn btn-primary btn-lg" id="downloadBtn">
|
||||||
<i class="bi bi-download me-2"></i>Download Image
|
<i class="bi bi-download me-2"></i>Download {{ 'Audio' if carrier_type == 'audio' else 'Image' }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button type="button" class="btn btn-outline-primary" id="shareBtn" style="display: none;">
|
<button type="button" class="btn btn-outline-primary" id="shareBtn" style="display: none;">
|
||||||
@@ -129,6 +165,11 @@
|
|||||||
<strong>Important:</strong>
|
<strong>Important:</strong>
|
||||||
<ul class="mb-0 mt-2">
|
<ul class="mb-0 mt-2">
|
||||||
<li>This file expires in <strong>10 minutes</strong></li>
|
<li>This file expires in <strong>10 minutes</strong></li>
|
||||||
|
{% if carrier_type == 'audio' %}
|
||||||
|
<li>Do <strong>not</strong> re-encode or convert the audio file</li>
|
||||||
|
<li>WAV format preserves your hidden data losslessly</li>
|
||||||
|
<li>Sharing via platforms that re-encode audio will destroy the hidden data</li>
|
||||||
|
{% else %}
|
||||||
<li>Do <strong>not</strong> resize or recompress the image</li>
|
<li>Do <strong>not</strong> resize or recompress the image</li>
|
||||||
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
|
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
|
||||||
<li>JPEG format is lossy - avoid re-saving or editing</li>
|
<li>JPEG format is lossy - avoid re-saving or editing</li>
|
||||||
@@ -141,6 +182,7 @@
|
|||||||
<li>Color preserved - extraction works on both color and grayscale</li>
|
<li>Color preserved - extraction works on both color and grayscale</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% if channel_mode == 'private' %}
|
{% if channel_mode == 'private' %}
|
||||||
<li><i class="bi bi-shield-lock text-warning me-1"></i>Recipient needs the <strong>same channel key</strong> to decode</li>
|
<li><i class="bi bi-shield-lock text-warning me-1"></i>Recipient needs the <strong>same channel key</strong> to decode</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -148,7 +190,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="{{ url_for('encode_page') }}" class="btn btn-outline-secondary">
|
<a href="{{ url_for('encode_page') }}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-repeat me-2"></i>Encode Another Message
|
<i class="bi bi-arrow-repeat me-2"></i>Encode Another
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,7 +204,7 @@
|
|||||||
const shareBtn = document.getElementById('shareBtn');
|
const shareBtn = document.getElementById('shareBtn');
|
||||||
const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}";
|
const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}";
|
||||||
const fileName = "{{ filename }}";
|
const fileName = "{{ filename }}";
|
||||||
const mimeType = "{{ 'image/jpeg' if embed_mode == 'dct' and output_format == 'jpeg' else 'image/png' }}";
|
const mimeType = "{{ 'audio/wav' if carrier_type == 'audio' else ('image/jpeg' if embed_mode == 'dct' and output_format == 'jpeg' else 'image/png') }}";
|
||||||
|
|
||||||
if (navigator.share && navigator.canShare) {
|
if (navigator.share && navigator.canShare) {
|
||||||
// Check if we can share files
|
// Check if we can share files
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "stegasoo"
|
name = "stegasoo"
|
||||||
version = "4.2.1"
|
version = "4.3.0"
|
||||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Changes in v4.0.0:
|
|||||||
- encode() and decode() now accept channel_key parameter
|
- encode() and decode() now accept channel_key parameter
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "4.2.1"
|
__version__ = "4.3.0"
|
||||||
|
|
||||||
# Core functionality
|
# Core functionality
|
||||||
# Channel key management (v4.0.0)
|
# Channel key management (v4.0.0)
|
||||||
@@ -24,8 +24,8 @@ from .channel import (
|
|||||||
|
|
||||||
# Crypto functions
|
# Crypto functions
|
||||||
from .crypto import get_active_channel_key, get_channel_fingerprint, has_argon2
|
from .crypto import get_active_channel_key, get_channel_fingerprint, has_argon2
|
||||||
from .decode import decode, decode_audio, decode_file, decode_text
|
from .decode import decode, decode_file, decode_text
|
||||||
from .encode import encode, encode_audio
|
from .encode import encode
|
||||||
|
|
||||||
# Credential generation
|
# Credential generation
|
||||||
from .generate import (
|
from .generate import (
|
||||||
@@ -54,22 +54,28 @@ from .steganography import (
|
|||||||
# Utilities
|
# Utilities
|
||||||
from .utils import generate_filename
|
from .utils import generate_filename
|
||||||
|
|
||||||
# Audio utilities - optional, may not be available (v4.3.0)
|
# Audio support — gated by STEGASOO_AUDIO env var and dependency availability
|
||||||
try:
|
from .constants import AUDIO_ENABLED, VIDEO_ENABLED
|
||||||
|
|
||||||
|
HAS_AUDIO_SUPPORT = AUDIO_ENABLED
|
||||||
|
HAS_VIDEO_SUPPORT = VIDEO_ENABLED
|
||||||
|
|
||||||
|
if AUDIO_ENABLED:
|
||||||
from .audio_utils import (
|
from .audio_utils import (
|
||||||
detect_audio_format,
|
detect_audio_format,
|
||||||
get_audio_info,
|
get_audio_info,
|
||||||
has_ffmpeg_support,
|
has_ffmpeg_support,
|
||||||
validate_audio,
|
validate_audio,
|
||||||
)
|
)
|
||||||
|
from .decode import decode_audio
|
||||||
HAS_AUDIO_SUPPORT = True
|
from .encode import encode_audio
|
||||||
except ImportError:
|
else:
|
||||||
HAS_AUDIO_SUPPORT = False
|
|
||||||
detect_audio_format = None
|
detect_audio_format = None
|
||||||
get_audio_info = None
|
get_audio_info = None
|
||||||
has_ffmpeg_support = None
|
has_ffmpeg_support = None
|
||||||
validate_audio = None
|
validate_audio = None
|
||||||
|
encode_audio = None
|
||||||
|
decode_audio = None
|
||||||
|
|
||||||
# QR Code utilities - optional, may not be available
|
# QR Code utilities - optional, may not be available
|
||||||
try:
|
try:
|
||||||
@@ -203,6 +209,7 @@ __all__ = [
|
|||||||
"has_ffmpeg_support",
|
"has_ffmpeg_support",
|
||||||
"validate_audio",
|
"validate_audio",
|
||||||
"HAS_AUDIO_SUPPORT",
|
"HAS_AUDIO_SUPPORT",
|
||||||
|
"HAS_VIDEO_SUPPORT",
|
||||||
"validate_audio_embed_mode",
|
"validate_audio_embed_mode",
|
||||||
"validate_audio_file",
|
"validate_audio_file",
|
||||||
# Generation
|
# Generation
|
||||||
|
|||||||
@@ -283,7 +283,9 @@ def embed_in_audio_lsb(
|
|||||||
# 2. Prepend magic + length prefix
|
# 2. Prepend magic + length prefix
|
||||||
header = AUDIO_MAGIC_LSB + struct.pack(">I", len(data))
|
header = AUDIO_MAGIC_LSB + struct.pack(">I", len(data))
|
||||||
payload = header + data
|
payload = header + data
|
||||||
debug.print(f"Payload with header: {len(payload)} bytes (magic 4 + len 4 + data {len(data)})")
|
debug.print(
|
||||||
|
f"Payload with header: {len(payload)} bytes (magic 4 + len 4 + data {len(data)})"
|
||||||
|
)
|
||||||
|
|
||||||
# 3. Check capacity
|
# 3. Check capacity
|
||||||
max_bytes = (num_samples * bits_per_sample) // 8
|
max_bytes = (num_samples * bits_per_sample) // 8
|
||||||
@@ -463,9 +465,7 @@ def extract_from_audio_lsb(
|
|||||||
total_samples_needed = (total_bits + bits_per_sample - 1) // bits_per_sample
|
total_samples_needed = (total_bits + bits_per_sample - 1) // bits_per_sample
|
||||||
|
|
||||||
if total_samples_needed > num_samples:
|
if total_samples_needed > num_samples:
|
||||||
debug.print(
|
debug.print(f"Need {total_samples_needed} samples but only {num_samples} available")
|
||||||
f"Need {total_samples_needed} samples but only {num_samples} available"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
debug.print(f"Need {total_samples_needed} samples to extract {data_length} bytes")
|
debug.print(f"Need {total_samples_needed} samples to extract {data_length} bytes")
|
||||||
@@ -483,14 +483,10 @@ def extract_from_audio_lsb(
|
|||||||
binary_data += str((val >> bit_pos) & 1)
|
binary_data += str((val >> bit_pos) & 1)
|
||||||
|
|
||||||
if progress_file and progress_idx % PROGRESS_INTERVAL == 0:
|
if progress_file and progress_idx % PROGRESS_INTERVAL == 0:
|
||||||
_write_progress(
|
_write_progress(progress_file, progress_idx, total_samples_needed, "extracting")
|
||||||
progress_file, progress_idx, total_samples_needed, "extracting"
|
|
||||||
)
|
|
||||||
|
|
||||||
if progress_file:
|
if progress_file:
|
||||||
_write_progress(
|
_write_progress(progress_file, total_samples_needed, total_samples_needed, "extracting")
|
||||||
progress_file, total_samples_needed, total_samples_needed, "extracting"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Skip the 8-byte header (magic + length) = 64 bits
|
# Skip the 8-byte header (magic + length) = 64 bits
|
||||||
data_bits = binary_data[64 : 64 + (data_length * 8)]
|
data_bits = binary_data[64 : 64 + (data_length * 8)]
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ Both are optional — functions degrade gracefully when unavailable.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import logging
|
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
@@ -24,10 +23,11 @@ from .constants import (
|
|||||||
MIN_AUDIO_SAMPLE_RATE,
|
MIN_AUDIO_SAMPLE_RATE,
|
||||||
VALID_AUDIO_EMBED_MODES,
|
VALID_AUDIO_EMBED_MODES,
|
||||||
)
|
)
|
||||||
|
from .debug import get_logger
|
||||||
from .exceptions import AudioTranscodeError, AudioValidationError, UnsupportedAudioFormatError
|
from .exceptions import AudioTranscodeError, AudioValidationError, UnsupportedAudioFormatError
|
||||||
from .models import AudioInfo, ValidationResult
|
from .models import AudioInfo, ValidationResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -69,10 +69,12 @@ def detect_audio_format(audio_data: bytes) -> str:
|
|||||||
Format string: "wav", "flac", "mp3", "ogg", "aac", "m4a", or "unknown".
|
Format string: "wav", "flac", "mp3", "ogg", "aac", "m4a", or "unknown".
|
||||||
"""
|
"""
|
||||||
if len(audio_data) < 12:
|
if len(audio_data) < 12:
|
||||||
|
logger.debug("detect_audio_format: data too short (%d bytes)", len(audio_data))
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
# WAV: RIFF....WAVE
|
# WAV: RIFF....WAVE
|
||||||
if audio_data[:4] == b"RIFF" and audio_data[8:12] == b"WAVE":
|
if audio_data[:4] == b"RIFF" and audio_data[8:12] == b"WAVE":
|
||||||
|
logger.debug("Detected WAV format (%d bytes)", len(audio_data))
|
||||||
return "wav"
|
return "wav"
|
||||||
|
|
||||||
# FLAC
|
# FLAC
|
||||||
@@ -124,6 +126,7 @@ def transcode_to_wav(audio_data: bytes) -> bytes:
|
|||||||
UnsupportedAudioFormatError: If the format cannot be detected.
|
UnsupportedAudioFormatError: If the format cannot be detected.
|
||||||
"""
|
"""
|
||||||
fmt = detect_audio_format(audio_data)
|
fmt = detect_audio_format(audio_data)
|
||||||
|
logger.info("transcode_to_wav: input format=%s, size=%d bytes", fmt, len(audio_data))
|
||||||
|
|
||||||
if fmt == "unknown":
|
if fmt == "unknown":
|
||||||
raise UnsupportedAudioFormatError(
|
raise UnsupportedAudioFormatError(
|
||||||
@@ -325,7 +328,9 @@ def _get_info_soundfile(audio_data: bytes, fmt: str) -> AudioInfo:
|
|||||||
try:
|
try:
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise AudioTranscodeError("soundfile package is required. Install with: pip install soundfile")
|
raise AudioTranscodeError(
|
||||||
|
"soundfile package is required. Install with: pip install soundfile"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
buf = io.BytesIO(audio_data)
|
buf = io.BytesIO(audio_data)
|
||||||
@@ -460,8 +465,7 @@ def validate_audio(
|
|||||||
fmt = detect_audio_format(audio_data)
|
fmt = detect_audio_format(audio_data)
|
||||||
if fmt == "unknown":
|
if fmt == "unknown":
|
||||||
return ValidationResult.error(
|
return ValidationResult.error(
|
||||||
f"Could not detect {name} format. "
|
f"Could not detect {name} format. " "Supported formats: WAV, FLAC, MP3, OGG, AAC, M4A."
|
||||||
"Supported formats: WAV, FLAC, MP3, OGG, AAC, M4A."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract metadata for further validation
|
# Extract metadata for further validation
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ def _get_machine_key() -> bytes:
|
|||||||
# Fallback to hostname
|
# Fallback to hostname
|
||||||
if not machine_id:
|
if not machine_id:
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
machine_id = socket.gethostname()
|
machine_id = socket.gethostname()
|
||||||
|
|
||||||
# Hash to get consistent 32 bytes
|
# Hash to get consistent 32 bytes
|
||||||
@@ -87,10 +88,7 @@ def _encrypt_for_storage(plaintext: str) -> str:
|
|||||||
plaintext_bytes = plaintext.encode()
|
plaintext_bytes = plaintext.encode()
|
||||||
|
|
||||||
# XOR with key (cycling if needed)
|
# XOR with key (cycling if needed)
|
||||||
encrypted = bytes(
|
encrypted = bytes(pb ^ key[i % len(key)] for i, pb in enumerate(plaintext_bytes))
|
||||||
pb ^ key[i % len(key)]
|
|
||||||
for i, pb in enumerate(plaintext_bytes)
|
|
||||||
)
|
|
||||||
|
|
||||||
return ENCRYPTED_PREFIX + base64.b64encode(encrypted).decode()
|
return ENCRYPTED_PREFIX + base64.b64encode(encrypted).decode()
|
||||||
|
|
||||||
@@ -108,14 +106,11 @@ def _decrypt_from_storage(stored: str) -> str | None:
|
|||||||
return stored
|
return stored
|
||||||
|
|
||||||
try:
|
try:
|
||||||
encrypted = base64.b64decode(stored[len(ENCRYPTED_PREFIX):])
|
encrypted = base64.b64decode(stored[len(ENCRYPTED_PREFIX) :])
|
||||||
key = _get_machine_key()
|
key = _get_machine_key()
|
||||||
|
|
||||||
# XOR to decrypt
|
# XOR to decrypt
|
||||||
decrypted = bytes(
|
decrypted = bytes(eb ^ key[i % len(key)] for i, eb in enumerate(encrypted))
|
||||||
eb ^ key[i % len(key)]
|
|
||||||
for i, eb in enumerate(encrypted)
|
|
||||||
)
|
|
||||||
|
|
||||||
return decrypted.decode()
|
return decrypted.decode()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -413,7 +408,11 @@ def get_channel_status() -> dict:
|
|||||||
try:
|
try:
|
||||||
stored = config_path.read_text().strip()
|
stored = config_path.read_text().strip()
|
||||||
file_key = _decrypt_from_storage(stored)
|
file_key = _decrypt_from_storage(stored)
|
||||||
if file_key and validate_channel_key(file_key) and format_channel_key(file_key) == key:
|
if (
|
||||||
|
file_key
|
||||||
|
and validate_channel_key(file_key)
|
||||||
|
and format_channel_key(file_key) == key
|
||||||
|
):
|
||||||
source = str(config_path)
|
source = str(config_path)
|
||||||
break
|
break
|
||||||
except (OSError, PermissionError, ValueError):
|
except (OSError, PermissionError, ValueError):
|
||||||
@@ -485,7 +484,9 @@ def resolve_channel_key(
|
|||||||
>>> resolve_channel_key("ABCD-1234-...") # -> "ABCD-1234-..."
|
>>> resolve_channel_key("ABCD-1234-...") # -> "ABCD-1234-..."
|
||||||
>>> resolve_channel_key(file_path="key.txt") # reads from file
|
>>> resolve_channel_key(file_path="key.txt") # reads from file
|
||||||
"""
|
"""
|
||||||
debug.print(f"resolve_channel_key: value={value}, file_path={file_path}, no_channel={no_channel}")
|
debug.print(
|
||||||
|
f"resolve_channel_key: value={value}, file_path={file_path}, no_channel={no_channel}"
|
||||||
|
)
|
||||||
|
|
||||||
# no_channel flag takes precedence
|
# no_channel flag takes precedence
|
||||||
if no_channel:
|
if no_channel:
|
||||||
|
|||||||
@@ -108,8 +108,9 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
|||||||
@click.group(context_settings=CONTEXT_SETTINGS)
|
@click.group(context_settings=CONTEXT_SETTINGS)
|
||||||
@click.version_option(__version__, "-v", "--version")
|
@click.version_option(__version__, "-v", "--version")
|
||||||
@click.option("--json", "json_output", is_flag=True, help="Output results as JSON")
|
@click.option("--json", "json_output", is_flag=True, help="Output results as JSON")
|
||||||
|
@click.option("--debug", "debug_mode", is_flag=True, help="Enable debug logging to stderr")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx, json_output):
|
def cli(ctx, json_output, debug_mode):
|
||||||
"""
|
"""
|
||||||
Stegasoo - Steganography with hybrid authentication.
|
Stegasoo - Steganography with hybrid authentication.
|
||||||
|
|
||||||
@@ -120,6 +121,11 @@ def cli(ctx, json_output):
|
|||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
ctx.obj["json"] = json_output
|
ctx.obj["json"] = json_output
|
||||||
|
|
||||||
|
if debug_mode:
|
||||||
|
from .debug import debug
|
||||||
|
|
||||||
|
debug.enable(True)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ENCODE COMMANDS
|
# ENCODE COMMANDS
|
||||||
@@ -179,9 +185,7 @@ def cli(ctx, json_output):
|
|||||||
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
|
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
|
||||||
@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
|
@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def encode(
|
def encode(ctx, carrier, reference, message, file_payload, output, passphrase, pin, dry_run):
|
||||||
ctx, carrier, reference, message, file_payload, output, passphrase, pin, dry_run
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Encode a message or file into an image.
|
Encode a message or file into an image.
|
||||||
|
|
||||||
@@ -245,14 +249,14 @@ def encode(
|
|||||||
# Default to JPEG for JPEG carriers (preserves DCT mode benefits)
|
# Default to JPEG for JPEG carriers (preserves DCT mode benefits)
|
||||||
carrier_ext = Path(carrier).suffix.lower()
|
carrier_ext = Path(carrier).suffix.lower()
|
||||||
if not output:
|
if not output:
|
||||||
if carrier_ext in ('.jpg', '.jpeg'):
|
if carrier_ext in (".jpg", ".jpeg"):
|
||||||
output = f"{Path(carrier).stem}_encoded.jpg"
|
output = f"{Path(carrier).stem}_encoded.jpg"
|
||||||
else:
|
else:
|
||||||
output = f"{Path(carrier).stem}_encoded.png"
|
output = f"{Path(carrier).stem}_encoded.png"
|
||||||
|
|
||||||
# Detect output format from extension
|
# Detect output format from extension
|
||||||
output_ext = Path(output).suffix.lower()
|
output_ext = Path(output).suffix.lower()
|
||||||
use_dct = output_ext in ('.jpg', '.jpeg')
|
use_dct = output_ext in (".jpg", ".jpeg")
|
||||||
|
|
||||||
from .steganography import EMBED_MODE_DCT, EMBED_MODE_LSB
|
from .steganography import EMBED_MODE_DCT, EMBED_MODE_LSB
|
||||||
|
|
||||||
@@ -442,8 +446,38 @@ def decode(ctx, image, reference, passphrase, pin, output):
|
|||||||
help="Passphrase (recommend 4+ words)",
|
help="Passphrase (recommend 4+ words)",
|
||||||
)
|
)
|
||||||
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
|
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
|
||||||
|
@click.option(
|
||||||
|
"--rsa-key",
|
||||||
|
type=click.Path(exists=True),
|
||||||
|
help="RSA private key PEM file",
|
||||||
|
)
|
||||||
|
@click.option("--rsa-password", default=None, help="Password for encrypted RSA key")
|
||||||
|
@click.option("--channel-key", default=None, help="Channel key for deployment isolation")
|
||||||
|
@click.option(
|
||||||
|
"--chip-tier",
|
||||||
|
"chip_tier",
|
||||||
|
default=None,
|
||||||
|
type=click.Choice(["lossless", "high", "low"]),
|
||||||
|
help="Spread spectrum chip tier (lossless=256, high=512, low=1024). Only for audio_spread.",
|
||||||
|
)
|
||||||
|
@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def audio_encode(ctx, carrier, reference, message, file_payload, output, embed_mode, passphrase, pin):
|
def audio_encode(
|
||||||
|
ctx,
|
||||||
|
carrier,
|
||||||
|
reference,
|
||||||
|
message,
|
||||||
|
file_payload,
|
||||||
|
output,
|
||||||
|
embed_mode,
|
||||||
|
passphrase,
|
||||||
|
pin,
|
||||||
|
rsa_key,
|
||||||
|
rsa_password,
|
||||||
|
channel_key,
|
||||||
|
chip_tier,
|
||||||
|
dry_run,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Encode a message or file into an audio carrier.
|
Encode a message or file into an audio carrier.
|
||||||
|
|
||||||
@@ -452,26 +486,100 @@ def audio_encode(ctx, carrier, reference, message, file_payload, output, embed_m
|
|||||||
stegasoo audio-encode carrier.wav -r ref.jpg -m "Secret" --mode audio_lsb
|
stegasoo audio-encode carrier.wav -r ref.jpg -m "Secret" --mode audio_lsb
|
||||||
|
|
||||||
stegasoo audio-encode carrier.wav -r ref.jpg -f secret.pdf --mode audio_spread
|
stegasoo audio-encode carrier.wav -r ref.jpg -f secret.pdf --mode audio_spread
|
||||||
|
|
||||||
|
stegasoo audio-encode carrier.wav -r ref.jpg -m "Secret" --dry-run
|
||||||
"""
|
"""
|
||||||
|
from .constants import AUDIO_ENABLED
|
||||||
|
|
||||||
|
if not AUDIO_ENABLED:
|
||||||
|
raise click.UsageError(
|
||||||
|
"Audio support is disabled. Install audio extras (pip install stegasoo[audio]) "
|
||||||
|
"or set STEGASOO_AUDIO=1 to force enable."
|
||||||
|
)
|
||||||
|
|
||||||
|
from .audio_steganography import calculate_audio_lsb_capacity
|
||||||
from .encode import encode_audio
|
from .encode import encode_audio
|
||||||
from .models import FilePayload
|
from .models import FilePayload
|
||||||
|
from .spread_steganography import calculate_audio_spread_capacity
|
||||||
|
|
||||||
if not message and not file_payload:
|
if not message and not file_payload:
|
||||||
raise click.UsageError("Either --message or --file is required")
|
raise click.UsageError("Either --message or --file is required")
|
||||||
|
|
||||||
|
# Read RSA key if provided
|
||||||
|
rsa_key_data = None
|
||||||
|
if rsa_key:
|
||||||
|
with open(rsa_key, "rb") as f:
|
||||||
|
rsa_key_data = f.read()
|
||||||
|
|
||||||
|
# Calculate payload size
|
||||||
|
if file_payload:
|
||||||
|
payload_size = Path(file_payload).stat().st_size
|
||||||
|
payload_type = "file"
|
||||||
|
else:
|
||||||
|
payload_size = len(message.encode("utf-8"))
|
||||||
|
payload_type = "text"
|
||||||
|
|
||||||
# Read input files
|
# Read input files
|
||||||
with open(reference, "rb") as f:
|
with open(reference, "rb") as f:
|
||||||
reference_data = f.read()
|
reference_data = f.read()
|
||||||
with open(carrier, "rb") as f:
|
with open(carrier, "rb") as f:
|
||||||
carrier_data = f.read()
|
carrier_data = f.read()
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
try:
|
||||||
|
from .audio_utils import get_audio_info
|
||||||
|
|
||||||
|
info = get_audio_info(carrier_data)
|
||||||
|
lsb_capacity = calculate_audio_lsb_capacity(carrier_data)
|
||||||
|
spread_capacity = calculate_audio_spread_capacity(carrier_data)
|
||||||
|
|
||||||
|
if embed_mode == "audio_lsb":
|
||||||
|
capacity = lsb_capacity
|
||||||
|
else:
|
||||||
|
capacity = spread_capacity.usable_capacity_bytes
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"carrier": carrier,
|
||||||
|
"reference": reference,
|
||||||
|
"format": info.format,
|
||||||
|
"sample_rate": info.sample_rate,
|
||||||
|
"channels": info.channels,
|
||||||
|
"duration_seconds": round(info.duration_seconds, 2),
|
||||||
|
"embed_mode": embed_mode,
|
||||||
|
"capacity_bytes": capacity,
|
||||||
|
"lsb_capacity_bytes": lsb_capacity,
|
||||||
|
"spread_capacity_bytes": spread_capacity.usable_capacity_bytes,
|
||||||
|
"payload_type": payload_type,
|
||||||
|
"payload_size": payload_size,
|
||||||
|
"usage_percent": round(payload_size / capacity * 100, 1) if capacity > 0 else 0,
|
||||||
|
"fits": payload_size < capacity,
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.obj.get("json"):
|
||||||
|
click.echo(json.dumps(result, indent=2))
|
||||||
|
else:
|
||||||
|
click.echo(
|
||||||
|
f"Carrier: {carrier} ({info.format}, {info.sample_rate}Hz, {info.channels}ch)"
|
||||||
|
)
|
||||||
|
click.echo(f"Duration: {info.duration_seconds:.1f}s")
|
||||||
|
click.echo(f"Reference: {reference}")
|
||||||
|
click.echo(f"Mode: {embed_mode}")
|
||||||
|
click.echo(f"LSB capacity: {lsb_capacity:,} bytes ({lsb_capacity // 1024} KB)")
|
||||||
|
click.echo(f"Spread capacity: {spread_capacity.usable_capacity_bytes:,} bytes")
|
||||||
|
click.echo(f"Payload: {payload_size:,} bytes ({payload_type})")
|
||||||
|
click.echo(f"Usage: {result['usage_percent']}%")
|
||||||
|
click.echo(f"Status: {'✓ Fits' if result['fits'] else '✗ Too large'}")
|
||||||
|
except Exception as e:
|
||||||
|
if ctx.obj.get("json"):
|
||||||
|
click.echo(json.dumps({"status": "error", "error": str(e)}, indent=2))
|
||||||
|
else:
|
||||||
|
click.echo(f"✗ Capacity check failed: {e}", err=True)
|
||||||
|
raise SystemExit(1)
|
||||||
|
return
|
||||||
|
|
||||||
# Determine output path
|
# Determine output path
|
||||||
if not output:
|
if not output:
|
||||||
carrier_path = Path(carrier)
|
output = f"{Path(carrier).stem}_encoded.wav"
|
||||||
if embed_mode == "audio_lsb":
|
|
||||||
output = f"{carrier_path.stem}_encoded.wav"
|
|
||||||
else:
|
|
||||||
output = f"{carrier_path.stem}_encoded.wav"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if file_payload:
|
if file_payload:
|
||||||
@@ -479,13 +587,24 @@ def audio_encode(ctx, carrier, reference, message, file_payload, output, embed_m
|
|||||||
else:
|
else:
|
||||||
payload = message
|
payload = message
|
||||||
|
|
||||||
|
# Resolve chip tier name to integer
|
||||||
|
resolved_chip_tier = None
|
||||||
|
if chip_tier is not None:
|
||||||
|
from .constants import AUDIO_SS_CHIP_TIER_NAMES
|
||||||
|
|
||||||
|
resolved_chip_tier = AUDIO_SS_CHIP_TIER_NAMES.get(chip_tier)
|
||||||
|
|
||||||
stego_audio, stats = encode_audio(
|
stego_audio, stats = encode_audio(
|
||||||
message=payload,
|
message=payload,
|
||||||
reference_photo=reference_data,
|
reference_photo=reference_data,
|
||||||
carrier_audio=carrier_data,
|
carrier_audio=carrier_data,
|
||||||
passphrase=passphrase,
|
passphrase=passphrase,
|
||||||
pin=pin,
|
pin=pin,
|
||||||
|
rsa_key_data=rsa_key_data,
|
||||||
|
rsa_password=rsa_password,
|
||||||
embed_mode=embed_mode,
|
embed_mode=embed_mode,
|
||||||
|
channel_key=channel_key,
|
||||||
|
chip_tier=resolved_chip_tier,
|
||||||
)
|
)
|
||||||
|
|
||||||
with open(output, "wb") as f:
|
with open(output, "wb") as f:
|
||||||
@@ -539,9 +658,18 @@ def audio_encode(ctx, carrier, reference, message, file_payload, output, embed_m
|
|||||||
)
|
)
|
||||||
@click.option("--passphrase", prompt=True, hide_input=True, help="Passphrase")
|
@click.option("--passphrase", prompt=True, hide_input=True, help="Passphrase")
|
||||||
@click.option("--pin", prompt=True, hide_input=True, help="PIN code")
|
@click.option("--pin", prompt=True, hide_input=True, help="PIN code")
|
||||||
|
@click.option(
|
||||||
|
"--rsa-key",
|
||||||
|
type=click.Path(exists=True),
|
||||||
|
help="RSA private key PEM file",
|
||||||
|
)
|
||||||
|
@click.option("--rsa-password", default=None, help="Password for encrypted RSA key")
|
||||||
|
@click.option("--channel-key", default=None, help="Channel key for deployment isolation")
|
||||||
@click.option("-o", "--output", type=click.Path(), help="Output path for file payloads")
|
@click.option("-o", "--output", type=click.Path(), help="Output path for file payloads")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def audio_decode(ctx, audio, reference, embed_mode, passphrase, pin, output):
|
def audio_decode(
|
||||||
|
ctx, audio, reference, embed_mode, passphrase, pin, rsa_key, rsa_password, channel_key, output
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Decode a message or file from stego audio.
|
Decode a message or file from stego audio.
|
||||||
|
|
||||||
@@ -551,8 +679,22 @@ def audio_decode(ctx, audio, reference, embed_mode, passphrase, pin, output):
|
|||||||
|
|
||||||
stegasoo audio-decode stego.wav -r ref.jpg --mode audio_lsb -o ./extracted/
|
stegasoo audio-decode stego.wav -r ref.jpg --mode audio_lsb -o ./extracted/
|
||||||
"""
|
"""
|
||||||
|
from .constants import AUDIO_ENABLED
|
||||||
|
|
||||||
|
if not AUDIO_ENABLED:
|
||||||
|
raise click.UsageError(
|
||||||
|
"Audio support is disabled. Install audio extras (pip install stegasoo[audio]) "
|
||||||
|
"or set STEGASOO_AUDIO=1 to force enable."
|
||||||
|
)
|
||||||
|
|
||||||
from .decode import decode_audio
|
from .decode import decode_audio
|
||||||
|
|
||||||
|
# Read RSA key if provided
|
||||||
|
rsa_key_data = None
|
||||||
|
if rsa_key:
|
||||||
|
with open(rsa_key, "rb") as f:
|
||||||
|
rsa_key_data = f.read()
|
||||||
|
|
||||||
with open(audio, "rb") as f:
|
with open(audio, "rb") as f:
|
||||||
audio_data = f.read()
|
audio_data = f.read()
|
||||||
with open(reference, "rb") as f:
|
with open(reference, "rb") as f:
|
||||||
@@ -564,7 +706,10 @@ def audio_decode(ctx, audio, reference, embed_mode, passphrase, pin, output):
|
|||||||
reference_photo=reference_data,
|
reference_photo=reference_data,
|
||||||
passphrase=passphrase,
|
passphrase=passphrase,
|
||||||
pin=pin,
|
pin=pin,
|
||||||
|
rsa_key_data=rsa_key_data,
|
||||||
|
rsa_password=rsa_password,
|
||||||
embed_mode=embed_mode,
|
embed_mode=embed_mode,
|
||||||
|
channel_key=channel_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.is_file:
|
if result.is_file:
|
||||||
@@ -617,6 +762,97 @@ def audio_decode(ctx, audio, reference, embed_mode, passphrase, pin, output):
|
|||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("audio-info")
|
||||||
|
@click.argument("audio", type=click.Path(exists=True))
|
||||||
|
@click.pass_context
|
||||||
|
def audio_info(ctx, audio):
|
||||||
|
"""
|
||||||
|
Show audio file information and steganographic capacity.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo audio-info carrier.wav
|
||||||
|
|
||||||
|
stegasoo --json audio-info carrier.wav
|
||||||
|
"""
|
||||||
|
from .constants import AUDIO_ENABLED
|
||||||
|
|
||||||
|
if not AUDIO_ENABLED:
|
||||||
|
raise click.UsageError(
|
||||||
|
"Audio support is disabled. Install audio extras (pip install stegasoo[audio]) "
|
||||||
|
"or set STEGASOO_AUDIO=1 to force enable."
|
||||||
|
)
|
||||||
|
|
||||||
|
from .audio_steganography import calculate_audio_lsb_capacity
|
||||||
|
from .audio_utils import get_audio_info
|
||||||
|
from .spread_steganography import calculate_audio_spread_capacity
|
||||||
|
|
||||||
|
with open(audio, "rb") as f:
|
||||||
|
audio_data = f.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = get_audio_info(audio_data)
|
||||||
|
lsb_capacity = calculate_audio_lsb_capacity(audio_data)
|
||||||
|
|
||||||
|
# Calculate spread capacity at each chip tier
|
||||||
|
spread_tiers = {}
|
||||||
|
for tier_name, tier_val in [("lossless", 0), ("high", 1), ("low", 2)]:
|
||||||
|
cap = calculate_audio_spread_capacity(audio_data, chip_tier=tier_val)
|
||||||
|
spread_tiers[tier_name] = {
|
||||||
|
"bytes": cap.usable_capacity_bytes,
|
||||||
|
"kb": round(cap.usable_capacity_bytes / 1024, 1),
|
||||||
|
"chip_length": cap.chip_length,
|
||||||
|
"embeddable_channels": cap.embeddable_channels,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"file": audio,
|
||||||
|
"format": info.format,
|
||||||
|
"sample_rate": info.sample_rate,
|
||||||
|
"channels": info.channels,
|
||||||
|
"duration_seconds": round(info.duration_seconds, 2),
|
||||||
|
"num_samples": info.num_samples,
|
||||||
|
"bit_depth": info.bit_depth,
|
||||||
|
"file_size": len(audio_data),
|
||||||
|
"capacity": {
|
||||||
|
"audio_lsb": {
|
||||||
|
"bytes": lsb_capacity,
|
||||||
|
"kb": round(lsb_capacity / 1024, 1),
|
||||||
|
},
|
||||||
|
"audio_spread": spread_tiers,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.obj.get("json"):
|
||||||
|
click.echo(json.dumps(result, indent=2))
|
||||||
|
else:
|
||||||
|
click.echo(f"File: {audio}")
|
||||||
|
click.echo(f"Format: {info.format}")
|
||||||
|
click.echo(f"Sample rate: {info.sample_rate} Hz")
|
||||||
|
click.echo(f"Channels: {info.channels}")
|
||||||
|
click.echo(f"Duration: {info.duration_seconds:.1f}s")
|
||||||
|
click.echo(f"Samples: {info.num_samples:,}")
|
||||||
|
if info.bit_depth:
|
||||||
|
click.echo(f"Bit depth: {info.bit_depth}-bit")
|
||||||
|
click.echo(f"File size: {len(audio_data):,} bytes")
|
||||||
|
click.echo()
|
||||||
|
click.echo("Steganographic capacity:")
|
||||||
|
click.echo(f" LSB: {lsb_capacity:,} bytes ({lsb_capacity // 1024} KB)")
|
||||||
|
for tier_name in ("lossless", "high", "low"):
|
||||||
|
t = spread_tiers[tier_name]
|
||||||
|
click.echo(
|
||||||
|
f" Spread ({tier_name:>8}, chip={t['chip_length']}): "
|
||||||
|
f"{t['bytes']:,} bytes ({t['kb']} KB)"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if ctx.obj.get("json"):
|
||||||
|
click.echo(json.dumps({"status": "error", "error": str(e)}, indent=2))
|
||||||
|
else:
|
||||||
|
click.echo(f"✗ Audio info failed: {e}", err=True)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# BATCH COMMANDS
|
# BATCH COMMANDS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -828,9 +1064,7 @@ def batch_check(ctx, images, recursive):
|
|||||||
@click.option(
|
@click.option(
|
||||||
"--pin-length", default=DEFAULT_PIN_LENGTH, help=f"PIN length (default: {DEFAULT_PIN_LENGTH})"
|
"--pin-length", default=DEFAULT_PIN_LENGTH, help=f"PIN length (default: {DEFAULT_PIN_LENGTH})"
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option("--channel-key", is_flag=True, help="Also generate a 256-bit channel key")
|
||||||
"--channel-key", is_flag=True, help="Also generate a 256-bit channel key"
|
|
||||||
)
|
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def generate(ctx, words, pin_length, channel_key):
|
def generate(ctx, words, pin_length, channel_key):
|
||||||
"""
|
"""
|
||||||
@@ -889,6 +1123,7 @@ def generate(ctx, words, pin_length, channel_key):
|
|||||||
# Generate channel key if requested
|
# Generate channel key if requested
|
||||||
if channel_key:
|
if channel_key:
|
||||||
from .channel import generate_channel_key
|
from .channel import generate_channel_key
|
||||||
|
|
||||||
result["channel_key"] = generate_channel_key()
|
result["channel_key"] = generate_channel_key()
|
||||||
|
|
||||||
if ctx.obj.get("json"):
|
if ctx.obj.get("json"):
|
||||||
@@ -912,6 +1147,7 @@ def info(ctx, full):
|
|||||||
# Check for DCT support
|
# Check for DCT support
|
||||||
try:
|
try:
|
||||||
from .dct_steganography import HAS_JPEGIO, HAS_SCIPY
|
from .dct_steganography import HAS_JPEGIO, HAS_SCIPY
|
||||||
|
|
||||||
has_dct = HAS_SCIPY and HAS_JPEGIO
|
has_dct = HAS_SCIPY and HAS_JPEGIO
|
||||||
except ImportError:
|
except ImportError:
|
||||||
has_dct = False
|
has_dct = False
|
||||||
@@ -954,6 +1190,7 @@ def info(ctx, full):
|
|||||||
channel_source = None
|
channel_source = None
|
||||||
try:
|
try:
|
||||||
from .channel import get_channel_fingerprint, get_channel_key, get_channel_status
|
from .channel import get_channel_fingerprint, get_channel_key, get_channel_status
|
||||||
|
|
||||||
key = get_channel_key()
|
key = get_channel_key()
|
||||||
if key:
|
if key:
|
||||||
channel_fingerprint = get_channel_fingerprint(key)
|
channel_fingerprint = get_channel_fingerprint(key)
|
||||||
@@ -986,7 +1223,7 @@ def info(ctx, full):
|
|||||||
try:
|
try:
|
||||||
# Disk free
|
# Disk free
|
||||||
st = os.statvfs("/")
|
st = os.statvfs("/")
|
||||||
disk_free = (st.f_bavail * st.f_frsize) / (1024 ** 3) # GB
|
disk_free = (st.f_bavail * st.f_frsize) / (1024**3) # GB
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -1005,20 +1242,28 @@ def info(ctx, full):
|
|||||||
"service": service_status,
|
"service": service_status,
|
||||||
"url": service_url,
|
"url": service_url,
|
||||||
"dct_support": has_dct,
|
"dct_support": has_dct,
|
||||||
"channel": {
|
"channel": (
|
||||||
|
{
|
||||||
"fingerprint": channel_fingerprint,
|
"fingerprint": channel_fingerprint,
|
||||||
"source": channel_source,
|
"source": channel_source,
|
||||||
} if channel_fingerprint else None,
|
}
|
||||||
|
if channel_fingerprint
|
||||||
|
else None
|
||||||
|
),
|
||||||
"limits": {
|
"limits": {
|
||||||
"max_message_bytes": MAX_MESSAGE_SIZE,
|
"max_message_bytes": MAX_MESSAGE_SIZE,
|
||||||
"max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE,
|
"max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE,
|
||||||
},
|
},
|
||||||
"system": {
|
"system": (
|
||||||
|
{
|
||||||
"cpu_mhz": cpu_freq,
|
"cpu_mhz": cpu_freq,
|
||||||
"temp_c": cpu_temp,
|
"temp_c": cpu_temp,
|
||||||
"disk_free_gb": round(disk_free, 1) if disk_free else None,
|
"disk_free_gb": round(disk_free, 1) if disk_free else None,
|
||||||
"uptime": uptime,
|
"uptime": uptime,
|
||||||
} if full else None,
|
}
|
||||||
|
if full
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.obj.get("json"):
|
if ctx.obj.get("json"):
|
||||||
@@ -1055,7 +1300,9 @@ def info(ctx, full):
|
|||||||
if cpu_freq:
|
if cpu_freq:
|
||||||
click.echo(f" CPU: {cpu_freq} MHz")
|
click.echo(f" CPU: {cpu_freq} MHz")
|
||||||
if cpu_temp:
|
if cpu_temp:
|
||||||
temp_color = "\033[32m" if cpu_temp < 60 else "\033[33m" if cpu_temp < 75 else "\033[31m"
|
temp_color = (
|
||||||
|
"\033[32m" if cpu_temp < 60 else "\033[33m" if cpu_temp < 75 else "\033[31m"
|
||||||
|
)
|
||||||
click.echo(f" Temp: {temp_color}{cpu_temp:.1f}°C\033[0m")
|
click.echo(f" Temp: {temp_color}{cpu_temp:.1f}°C\033[0m")
|
||||||
if uptime:
|
if uptime:
|
||||||
click.echo(f" Uptime: {uptime}")
|
click.echo(f" Uptime: {uptime}")
|
||||||
@@ -1384,7 +1631,7 @@ def tools_capacity(image, as_json):
|
|||||||
click.echo(f" Megapixels: {result['megapixels']} MP")
|
click.echo(f" Megapixels: {result['megapixels']} MP")
|
||||||
click.echo(f" {'─' * 40}")
|
click.echo(f" {'─' * 40}")
|
||||||
click.echo(f" LSB Capacity: {result['lsb']['capacity_kb']:.1f} KB")
|
click.echo(f" LSB Capacity: {result['lsb']['capacity_kb']:.1f} KB")
|
||||||
if result['dct']['available']:
|
if result["dct"]["available"]:
|
||||||
click.echo(f" DCT Capacity: {result['dct']['capacity_kb']:.1f} KB")
|
click.echo(f" DCT Capacity: {result['dct']['capacity_kb']:.1f} KB")
|
||||||
else:
|
else:
|
||||||
click.echo(" DCT Capacity: N/A (scipy required)")
|
click.echo(" DCT Capacity: N/A (scipy required)")
|
||||||
@@ -1394,7 +1641,9 @@ def tools_capacity(image, as_json):
|
|||||||
@tools.command("strip")
|
@tools.command("strip")
|
||||||
@click.argument("image", type=click.Path(exists=True))
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
@click.option("-o", "--output", type=click.Path(), help="Output file (default: <name>_clean.png)")
|
@click.option("-o", "--output", type=click.Path(), help="Output file (default: <name>_clean.png)")
|
||||||
@click.option("--format", "fmt", type=click.Choice(["png", "bmp"]), default="png", help="Output format")
|
@click.option(
|
||||||
|
"--format", "fmt", type=click.Choice(["png", "bmp"]), default="png", help="Output format"
|
||||||
|
)
|
||||||
def tools_strip(image, output, fmt):
|
def tools_strip(image, output, fmt):
|
||||||
"""Strip EXIF/metadata from an image.
|
"""Strip EXIF/metadata from an image.
|
||||||
|
|
||||||
@@ -1529,7 +1778,9 @@ def tools_exif(image, clear, set_fields, output, as_json):
|
|||||||
@tools.command("compress")
|
@tools.command("compress")
|
||||||
@click.argument("image", type=click.Path(exists=True))
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
@click.option("-q", "--quality", type=int, default=75, help="JPEG quality (1-100, default: 75)")
|
@click.option("-q", "--quality", type=int, default=75, help="JPEG quality (1-100, default: 75)")
|
||||||
@click.option("-o", "--output", type=click.Path(), help="Output file (default: <name>_q<quality>.jpg)")
|
@click.option(
|
||||||
|
"-o", "--output", type=click.Path(), help="Output file (default: <name>_q<quality>.jpg)"
|
||||||
|
)
|
||||||
def tools_compress(image, quality, output):
|
def tools_compress(image, quality, output):
|
||||||
"""Compress a JPEG image.
|
"""Compress a JPEG image.
|
||||||
|
|
||||||
@@ -1541,9 +1792,10 @@ def tools_compress(image, quality, output):
|
|||||||
stegasoo tools compress photo.jpg -q 60
|
stegasoo tools compress photo.jpg -q 60
|
||||||
stegasoo tools compress photo.jpg -q 80 -o smaller.jpg
|
stegasoo tools compress photo.jpg -q 80 -o smaller.jpg
|
||||||
"""
|
"""
|
||||||
from PIL import Image
|
|
||||||
import io
|
import io
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
if not 1 <= quality <= 100:
|
if not 1 <= quality <= 100:
|
||||||
raise click.UsageError("Quality must be between 1 and 100")
|
raise click.UsageError("Quality must be between 1 and 100")
|
||||||
|
|
||||||
@@ -1578,7 +1830,9 @@ def tools_compress(image, quality, output):
|
|||||||
|
|
||||||
@tools.command("rotate")
|
@tools.command("rotate")
|
||||||
@click.argument("image", type=click.Path(exists=True))
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
@click.option("-r", "--rotation", type=click.Choice(["90", "180", "270"]), help="Rotation degrees clockwise")
|
@click.option(
|
||||||
|
"-r", "--rotation", type=click.Choice(["90", "180", "270"]), help="Rotation degrees clockwise"
|
||||||
|
)
|
||||||
@click.option("--flip-h", is_flag=True, help="Flip horizontally")
|
@click.option("--flip-h", is_flag=True, help="Flip horizontally")
|
||||||
@click.option("--flip-v", is_flag=True, help="Flip vertically")
|
@click.option("--flip-v", is_flag=True, help="Flip vertically")
|
||||||
@click.option("-o", "--output", type=click.Path(), help="Output file")
|
@click.option("-o", "--output", type=click.Path(), help="Output file")
|
||||||
@@ -1593,10 +1847,11 @@ def tools_rotate(image, rotation, flip_h, flip_v, output):
|
|||||||
stegasoo tools rotate photo.jpg -r 90
|
stegasoo tools rotate photo.jpg -r 90
|
||||||
stegasoo tools rotate photo.jpg -r 180 --flip-h -o rotated.jpg
|
stegasoo tools rotate photo.jpg -r 180 --flip-h -o rotated.jpg
|
||||||
"""
|
"""
|
||||||
from PIL import Image
|
|
||||||
import io
|
import io
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
with open(image, "rb") as f:
|
with open(image, "rb") as f:
|
||||||
image_data = f.read()
|
image_data = f.read()
|
||||||
|
|
||||||
@@ -1622,9 +1877,9 @@ def tools_rotate(image, rotation, flip_h, flip_v, output):
|
|||||||
|
|
||||||
# Apply flips using jpegtran
|
# Apply flips using jpegtran
|
||||||
if flip_h or flip_v:
|
if flip_h or flip_v:
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
|
||||||
|
|
||||||
for flip_type in (["horizontal"] if flip_h else []) + (["vertical"] if flip_v else []):
|
for flip_type in (["horizontal"] if flip_h else []) + (["vertical"] if flip_v else []):
|
||||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||||
@@ -1633,9 +1888,19 @@ def tools_rotate(image, rotation, flip_h, flip_v, output):
|
|||||||
output_path = tempfile.mktemp(suffix=".jpg")
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["jpegtran", "-flip", flip_type, "-copy", "all",
|
[
|
||||||
"-outfile", output_path, input_path],
|
"jpegtran",
|
||||||
capture_output=True, timeout=30, check=True
|
"-flip",
|
||||||
|
flip_type,
|
||||||
|
"-copy",
|
||||||
|
"all",
|
||||||
|
"-outfile",
|
||||||
|
output_path,
|
||||||
|
input_path,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
|
check=True,
|
||||||
)
|
)
|
||||||
with open(output_path, "rb") as f:
|
with open(output_path, "rb") as f:
|
||||||
result_data = f.read()
|
result_data = f.read()
|
||||||
@@ -1680,8 +1945,17 @@ def tools_rotate(image, rotation, flip_h, flip_v, output):
|
|||||||
|
|
||||||
@tools.command("convert")
|
@tools.command("convert")
|
||||||
@click.argument("image", type=click.Path(exists=True))
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
@click.option("-f", "--format", "fmt", type=click.Choice(["png", "jpg", "bmp", "webp"]), required=True, help="Output format")
|
@click.option(
|
||||||
@click.option("-q", "--quality", type=int, default=95, help="Quality for lossy formats (default: 95)")
|
"-f",
|
||||||
|
"--format",
|
||||||
|
"fmt",
|
||||||
|
type=click.Choice(["png", "jpg", "bmp", "webp"]),
|
||||||
|
required=True,
|
||||||
|
help="Output format",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-q", "--quality", type=int, default=95, help="Quality for lossy formats (default: 95)"
|
||||||
|
)
|
||||||
@click.option("-o", "--output", type=click.Path(), help="Output file")
|
@click.option("-o", "--output", type=click.Path(), help="Output file")
|
||||||
def tools_convert(image, fmt, quality, output):
|
def tools_convert(image, fmt, quality, output):
|
||||||
"""Convert image to a different format.
|
"""Convert image to a different format.
|
||||||
@@ -1691,9 +1965,10 @@ def tools_convert(image, fmt, quality, output):
|
|||||||
stegasoo tools convert photo.png -f jpg
|
stegasoo tools convert photo.png -f jpg
|
||||||
stegasoo tools convert photo.jpg -f png -o lossless.png
|
stegasoo tools convert photo.jpg -f png -o lossless.png
|
||||||
"""
|
"""
|
||||||
from PIL import Image
|
|
||||||
import io
|
import io
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
with open(image, "rb") as f:
|
with open(image, "rb") as f:
|
||||||
image_data = f.read()
|
image_data = f.read()
|
||||||
|
|
||||||
@@ -1737,12 +2012,14 @@ def admin(ctx):
|
|||||||
|
|
||||||
@admin.command("recover")
|
@admin.command("recover")
|
||||||
@click.option(
|
@click.option(
|
||||||
"--db", "db_path",
|
"--db",
|
||||||
|
"db_path",
|
||||||
type=click.Path(exists=True),
|
type=click.Path(exists=True),
|
||||||
help="Path to stegasoo.db (default: frontends/web/instance/stegasoo.db)"
|
help="Path to stegasoo.db (default: frontends/web/instance/stegasoo.db)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--password", prompt=True, hide_input=True, confirmation_prompt=True, help="New admin password"
|
||||||
)
|
)
|
||||||
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True,
|
|
||||||
help="New admin password")
|
|
||||||
def admin_recover(db_path, password):
|
def admin_recover(db_path, password):
|
||||||
"""Reset admin password using recovery key.
|
"""Reset admin password using recovery key.
|
||||||
|
|
||||||
@@ -1772,9 +2049,7 @@ def admin_recover(db_path, password):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not db_path or not Path(db_path).exists():
|
if not db_path or not Path(db_path).exists():
|
||||||
raise click.UsageError(
|
raise click.UsageError("Database not found. Use --db to specify path to stegasoo.db")
|
||||||
"Database not found. Use --db to specify path to stegasoo.db"
|
|
||||||
)
|
|
||||||
|
|
||||||
click.echo(f"Database: {db_path}")
|
click.echo(f"Database: {db_path}")
|
||||||
|
|
||||||
@@ -1783,16 +2058,13 @@ def admin_recover(db_path, password):
|
|||||||
db.row_factory = sqlite3.Row
|
db.row_factory = sqlite3.Row
|
||||||
|
|
||||||
# Get recovery key hash from app_settings
|
# Get recovery key hash from app_settings
|
||||||
cursor = db.execute(
|
cursor = db.execute("SELECT value FROM app_settings WHERE key = 'recovery_key_hash'")
|
||||||
"SELECT value FROM app_settings WHERE key = 'recovery_key_hash'"
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
db.close()
|
db.close()
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
"No recovery key configured for this instance. "
|
"No recovery key configured for this instance. " "Password reset is not possible."
|
||||||
"Password reset is not possible."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
stored_hash = row["value"]
|
stored_hash = row["value"]
|
||||||
@@ -1869,6 +2141,7 @@ def admin_generate_key(show_qr):
|
|||||||
if show_qr:
|
if show_qr:
|
||||||
try:
|
try:
|
||||||
import qrcode
|
import qrcode
|
||||||
|
|
||||||
qr = qrcode.QRCode(box_size=1, border=1)
|
qr = qrcode.QRCode(box_size=1, border=1)
|
||||||
qr.add_data(key)
|
qr.add_data(key)
|
||||||
qr.make()
|
qr.make()
|
||||||
@@ -1920,8 +2193,12 @@ def api_keys():
|
|||||||
|
|
||||||
|
|
||||||
@api_keys.command("list")
|
@api_keys.command("list")
|
||||||
@click.option("--location", type=click.Choice(["user", "project", "all"]), default="all",
|
@click.option(
|
||||||
help="Config location to list keys from")
|
"--location",
|
||||||
|
type=click.Choice(["user", "project", "all"]),
|
||||||
|
default="all",
|
||||||
|
help="Config location to list keys from",
|
||||||
|
)
|
||||||
def api_keys_list(location):
|
def api_keys_list(location):
|
||||||
"""List configured API keys.
|
"""List configured API keys.
|
||||||
|
|
||||||
@@ -1935,7 +2212,7 @@ def api_keys_list(location):
|
|||||||
_setup_frontends_path()
|
_setup_frontends_path()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from api.auth import list_api_keys, get_api_key_status
|
from api.auth import get_api_key_status, list_api_keys
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise click.ClickException("API frontend not available")
|
raise click.ClickException("API frontend not available")
|
||||||
|
|
||||||
@@ -1959,8 +2236,12 @@ def api_keys_list(location):
|
|||||||
|
|
||||||
@api_keys.command("create")
|
@api_keys.command("create")
|
||||||
@click.argument("name")
|
@click.argument("name")
|
||||||
@click.option("--location", type=click.Choice(["user", "project"]), default="user",
|
@click.option(
|
||||||
help="Where to store the key")
|
"--location",
|
||||||
|
type=click.Choice(["user", "project"]),
|
||||||
|
default="user",
|
||||||
|
help="Where to store the key",
|
||||||
|
)
|
||||||
def api_keys_create(name, location):
|
def api_keys_create(name, location):
|
||||||
"""Create a new API key.
|
"""Create a new API key.
|
||||||
|
|
||||||
@@ -1993,8 +2274,9 @@ def api_keys_create(name, location):
|
|||||||
|
|
||||||
@api_keys.command("delete")
|
@api_keys.command("delete")
|
||||||
@click.argument("name")
|
@click.argument("name")
|
||||||
@click.option("--location", type=click.Choice(["user", "project"]), default="user",
|
@click.option(
|
||||||
help="Config location")
|
"--location", type=click.Choice(["user", "project"]), default="user", help="Config location"
|
||||||
|
)
|
||||||
def api_keys_delete(name, location):
|
def api_keys_delete(name, location):
|
||||||
"""Delete an API key by name.
|
"""Delete an API key by name.
|
||||||
|
|
||||||
@@ -2025,7 +2307,9 @@ def api_tls():
|
|||||||
@api_tls.command("generate")
|
@api_tls.command("generate")
|
||||||
@click.option("--hostname", default="localhost", help="Server hostname for certificate")
|
@click.option("--hostname", default="localhost", help="Server hostname for certificate")
|
||||||
@click.option("--days", default=365, help="Certificate validity in days")
|
@click.option("--days", default=365, help="Certificate validity in days")
|
||||||
@click.option("--output", "-o", type=click.Path(), help="Output directory (default: ~/.stegasoo/certs)")
|
@click.option(
|
||||||
|
"--output", "-o", type=click.Path(), help="Output directory (default: ~/.stegasoo/certs)"
|
||||||
|
)
|
||||||
def api_tls_generate(hostname, days, output):
|
def api_tls_generate(hostname, days, output):
|
||||||
"""Generate self-signed TLS certificate.
|
"""Generate self-signed TLS certificate.
|
||||||
|
|
||||||
@@ -2065,7 +2349,12 @@ def api_tls_generate(hostname, days, output):
|
|||||||
|
|
||||||
|
|
||||||
@api_tls.command("info")
|
@api_tls.command("info")
|
||||||
@click.option("--cert", "-c", type=click.Path(exists=True), help="Certificate file (default: ~/.stegasoo/certs/server.crt)")
|
@click.option(
|
||||||
|
"--cert",
|
||||||
|
"-c",
|
||||||
|
type=click.Path(exists=True),
|
||||||
|
help="Certificate file (default: ~/.stegasoo/certs/server.crt)",
|
||||||
|
)
|
||||||
def api_tls_info(cert):
|
def api_tls_info(cert):
|
||||||
"""Show information about a TLS certificate.
|
"""Show information about a TLS certificate.
|
||||||
|
|
||||||
@@ -2075,12 +2364,13 @@ def api_tls_info(cert):
|
|||||||
stegasoo api tls info --cert /path/to/server.crt
|
stegasoo api tls info --cert /path/to/server.crt
|
||||||
"""
|
"""
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
|
|
||||||
if not cert:
|
if not cert:
|
||||||
cert = Path.home() / ".stegasoo" / "certs" / "server.crt"
|
cert = Path.home() / ".stegasoo" / "certs" / "server.crt"
|
||||||
if not cert.exists():
|
if not cert.exists():
|
||||||
raise click.ClickException(f"No certificate found at {cert}. Generate one with: stegasoo api tls generate")
|
raise click.ClickException(
|
||||||
|
f"No certificate found at {cert}. Generate one with: stegasoo api tls generate"
|
||||||
|
)
|
||||||
|
|
||||||
cert_data = Path(cert).read_bytes()
|
cert_data = Path(cert).read_bytes()
|
||||||
certificate = x509.load_pem_x509_certificate(cert_data)
|
certificate = x509.load_pem_x509_certificate(cert_data)
|
||||||
@@ -2095,7 +2385,8 @@ def api_tls_info(cert):
|
|||||||
|
|
||||||
# Check expiry
|
# Check expiry
|
||||||
import datetime
|
import datetime
|
||||||
now = datetime.datetime.now(datetime.timezone.utc)
|
|
||||||
|
now = datetime.datetime.now(datetime.UTC)
|
||||||
if certificate.not_valid_after_utc < now:
|
if certificate.not_valid_after_utc < now:
|
||||||
click.echo("\nStatus: EXPIRED")
|
click.echo("\nStatus: EXPIRED")
|
||||||
elif certificate.not_valid_after_utc < now + datetime.timedelta(days=30):
|
elif certificate.not_valid_after_utc < now + datetime.timedelta(days=30):
|
||||||
@@ -2144,8 +2435,11 @@ def api_serve(host, port, ssl, cert, key, do_reload):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
from web.ssl_utils import ensure_certs
|
from web.ssl_utils import ensure_certs
|
||||||
|
|
||||||
base_dir = Path.home() / ".stegasoo"
|
base_dir = Path.home() / ".stegasoo"
|
||||||
cert_path, key_path = ensure_certs(base_dir, host if host != "0.0.0.0" else "localhost")
|
cert_path, key_path = ensure_certs(
|
||||||
|
base_dir, host if host != "0.0.0.0" else "localhost"
|
||||||
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise click.ClickException("ssl_utils not available")
|
raise click.ClickException("ssl_utils not available")
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import struct
|
|||||||
import zlib
|
import zlib
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
|
||||||
|
from .debug import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
# Optional LZ4 support (faster, slightly worse ratio)
|
# Optional LZ4 support (faster, slightly worse ratio)
|
||||||
try:
|
try:
|
||||||
import lz4.frame
|
import lz4.frame
|
||||||
|
|||||||
@@ -262,8 +262,7 @@ DCT_STEP_SIZE = 8 # QIM quantization step
|
|||||||
# SHA256("\x89ST3\x89DCT") - hardcoded so it never changes even if headers are added
|
# SHA256("\x89ST3\x89DCT") - hardcoded so it never changes even if headers are added
|
||||||
# Used to XOR recovery keys in QR codes so they scan as gibberish
|
# Used to XOR recovery keys in QR codes so they scan as gibberish
|
||||||
RECOVERY_OBFUSCATION_KEY = bytes.fromhex(
|
RECOVERY_OBFUSCATION_KEY = bytes.fromhex(
|
||||||
"d6c70bce27780db942562550e9fe1459"
|
"d6c70bce27780db942562550e9fe1459" "9dfdb8421f5acc79696b05db4e7afbd2"
|
||||||
"9dfdb8421f5acc79696b05db4e7afbd2"
|
|
||||||
) # 32 bytes
|
) # 32 bytes
|
||||||
|
|
||||||
# Valid embedding modes
|
# Valid embedding modes
|
||||||
@@ -297,6 +296,69 @@ def detect_stego_mode(encrypted_data: bytes) -> str:
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FEATURE TOGGLES (v4.3.1)
|
||||||
|
# =============================================================================
|
||||||
|
# Environment variables to enable/disable optional feature families.
|
||||||
|
# Values: "auto" (default — detect dependencies), "1"/"true" (force on),
|
||||||
|
# "0"/"false" (force off even if deps are installed).
|
||||||
|
# Pi builds or minimal installs can set STEGASOO_AUDIO=0 to stay image-only.
|
||||||
|
|
||||||
|
import os as _os
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_feature_toggle(env_var: str, default: str = "auto") -> str | bool:
|
||||||
|
"""Parse a feature toggle env var. Returns 'auto', True, or False."""
|
||||||
|
val = _os.environ.get(env_var, default).strip().lower()
|
||||||
|
if val in ("1", "true", "yes", "on"):
|
||||||
|
return True
|
||||||
|
if val in ("0", "false", "no", "off"):
|
||||||
|
return False
|
||||||
|
return "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_audio_deps() -> bool:
|
||||||
|
"""Check if audio dependencies (soundfile, numpy) are importable."""
|
||||||
|
try:
|
||||||
|
import numpy # noqa: F401
|
||||||
|
import soundfile # noqa: F401
|
||||||
|
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_video_deps() -> bool:
|
||||||
|
"""Check if video dependencies (ffmpeg binary + audio deps) are available."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
if not _check_audio_deps():
|
||||||
|
return False
|
||||||
|
return shutil.which("ffmpeg") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_feature(toggle: str | bool, dep_check: callable) -> bool:
|
||||||
|
"""Resolve a feature toggle to a final bool."""
|
||||||
|
if toggle is True:
|
||||||
|
if not dep_check():
|
||||||
|
raise ImportError(
|
||||||
|
f"Feature force-enabled but required dependencies are missing. "
|
||||||
|
f"Install the relevant extras (e.g. pip install stegasoo[audio])."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
if toggle is False:
|
||||||
|
return False
|
||||||
|
# auto
|
||||||
|
return dep_check()
|
||||||
|
|
||||||
|
|
||||||
|
_audio_toggle = _parse_feature_toggle("STEGASOO_AUDIO")
|
||||||
|
_video_toggle = _parse_feature_toggle("STEGASOO_VIDEO")
|
||||||
|
|
||||||
|
AUDIO_ENABLED: bool = _resolve_feature(_audio_toggle, _check_audio_deps)
|
||||||
|
VIDEO_ENABLED: bool = _resolve_feature(_video_toggle, _check_video_deps)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# AUDIO STEGANOGRAPHY (v4.3.0)
|
# AUDIO STEGANOGRAPHY (v4.3.0)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -319,10 +381,31 @@ MAX_AUDIO_SAMPLE_RATE = 192000 # Studio quality
|
|||||||
ALLOWED_AUDIO_EXTENSIONS = {"wav", "flac", "mp3", "ogg", "opus", "aac", "m4a", "wma"}
|
ALLOWED_AUDIO_EXTENSIONS = {"wav", "flac", "mp3", "ogg", "opus", "aac", "m4a", "wma"}
|
||||||
|
|
||||||
# Spread spectrum parameters
|
# Spread spectrum parameters
|
||||||
AUDIO_SS_CHIP_LENGTH = 1024 # Samples per chip (spreading factor)
|
AUDIO_SS_CHIP_LENGTH = 1024 # Samples per chip (spreading factor) — legacy/default
|
||||||
AUDIO_SS_AMPLITUDE = 0.05 # Per-sample embedding strength (~-26dB, masked by audio)
|
AUDIO_SS_AMPLITUDE = 0.05 # Per-sample embedding strength (~-26dB, masked by audio)
|
||||||
AUDIO_SS_RS_NSYM = 32 # Reed-Solomon parity symbols
|
AUDIO_SS_RS_NSYM = 32 # Reed-Solomon parity symbols
|
||||||
|
|
||||||
|
# Spread spectrum v2: per-channel hybrid embedding (v4.4.0)
|
||||||
|
AUDIO_SS_HEADER_VERSION = 2 # v2 header format identifier
|
||||||
|
|
||||||
|
# Chip tier system — trade capacity for robustness
|
||||||
|
AUDIO_SS_CHIP_TIER_LOSSLESS = 0 # 256 chips — lossless carriers (FLAC/WAV/ALAC)
|
||||||
|
AUDIO_SS_CHIP_TIER_HIGH_LOSSY = 1 # 512 chips — high-rate lossy (AAC 256k+)
|
||||||
|
AUDIO_SS_CHIP_TIER_LOW_LOSSY = 2 # 1024 chips — low-rate lossy (AAC 128k, Opus)
|
||||||
|
AUDIO_SS_DEFAULT_CHIP_TIER = 2 # Most robust, backward compatible
|
||||||
|
AUDIO_SS_CHIP_LENGTHS = {0: 256, 1: 512, 2: 1024}
|
||||||
|
|
||||||
|
# Chip tier name mapping (for CLI/UI)
|
||||||
|
AUDIO_SS_CHIP_TIER_NAMES = {
|
||||||
|
"lossless": AUDIO_SS_CHIP_TIER_LOSSLESS,
|
||||||
|
"high": AUDIO_SS_CHIP_TIER_HIGH_LOSSY,
|
||||||
|
"low": AUDIO_SS_CHIP_TIER_LOW_LOSSY,
|
||||||
|
}
|
||||||
|
|
||||||
|
# LFE channel skipping — LFE is bandlimited to ~120Hz, terrible carrier
|
||||||
|
AUDIO_LFE_CHANNEL_INDEX = 3 # Standard WAV/WAVEFORMATEXTENSIBLE ordering
|
||||||
|
AUDIO_LFE_MIN_CHANNELS = 6 # Only skip LFE for 5.1+ layouts
|
||||||
|
|
||||||
# Echo hiding parameters
|
# Echo hiding parameters
|
||||||
AUDIO_ECHO_DELAY_0 = 50 # Echo delay for bit 0 (samples at 44.1kHz ~ 1.1ms)
|
AUDIO_ECHO_DELAY_0 = 50 # Echo delay for bit 0 (samples at 44.1kHz ~ 1.1ms)
|
||||||
AUDIO_ECHO_DELAY_1 = 100 # Echo delay for bit 1 (samples at 44.1kHz ~ 2.3ms)
|
AUDIO_ECHO_DELAY_1 = 100 # Echo delay for bit 1 (samples at 44.1kHz ~ 2.3ms)
|
||||||
|
|||||||
@@ -46,9 +46,12 @@ from .constants import (
|
|||||||
SALT_SIZE,
|
SALT_SIZE,
|
||||||
TAG_SIZE,
|
TAG_SIZE,
|
||||||
)
|
)
|
||||||
|
from .debug import get_logger
|
||||||
from .exceptions import DecryptionError, EncryptionError, InvalidHeaderError, KeyDerivationError
|
from .exceptions import DecryptionError, EncryptionError, InvalidHeaderError, KeyDerivationError
|
||||||
from .models import DecodeResult, FilePayload
|
from .models import DecodeResult, FilePayload
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
# Check for Argon2 availability
|
# Check for Argon2 availability
|
||||||
try:
|
try:
|
||||||
from argon2.low_level import Type, hash_secret_raw
|
from argon2.low_level import Type, hash_secret_raw
|
||||||
@@ -201,6 +204,18 @@ def derive_hybrid_key(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
photo_hash = hash_photo(photo_data)
|
photo_hash = hash_photo(photo_data)
|
||||||
|
logger.debug(
|
||||||
|
"derive_hybrid_key: photo_hash=%s, pin=%s, rsa=%s, channel=%s, salt=%d bytes",
|
||||||
|
photo_hash[:4].hex(),
|
||||||
|
"set" if pin else "none",
|
||||||
|
"set" if rsa_key_data else "none",
|
||||||
|
(
|
||||||
|
"explicit"
|
||||||
|
if isinstance(channel_key, str) and channel_key
|
||||||
|
else "auto" if channel_key is None else "none"
|
||||||
|
),
|
||||||
|
len(salt),
|
||||||
|
)
|
||||||
|
|
||||||
# Resolve channel key (server-specific binding)
|
# Resolve channel key (server-specific binding)
|
||||||
channel_hash = _resolve_channel_key(channel_key)
|
channel_hash = _resolve_channel_key(channel_key)
|
||||||
@@ -217,8 +232,16 @@ def derive_hybrid_key(
|
|||||||
if channel_hash:
|
if channel_hash:
|
||||||
key_material += channel_hash
|
key_material += channel_hash
|
||||||
|
|
||||||
|
logger.debug("Key material: %d bytes", len(key_material))
|
||||||
|
|
||||||
# Run it all through the KDF
|
# Run it all through the KDF
|
||||||
if HAS_ARGON2:
|
if HAS_ARGON2:
|
||||||
|
logger.debug(
|
||||||
|
"KDF: Argon2id (memory=%dKB, time=%d, parallel=%d)",
|
||||||
|
ARGON2_MEMORY_COST,
|
||||||
|
ARGON2_TIME_COST,
|
||||||
|
ARGON2_PARALLELISM,
|
||||||
|
)
|
||||||
# Argon2id: the good stuff
|
# Argon2id: the good stuff
|
||||||
key = hash_secret_raw(
|
key = hash_secret_raw(
|
||||||
secret=key_material,
|
secret=key_material,
|
||||||
@@ -230,6 +253,9 @@ def derive_hybrid_key(
|
|||||||
type=Type.ID, # Hybrid mode: resists side-channel AND GPU attacks
|
type=Type.ID, # Hybrid mode: resists side-channel AND GPU attacks
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"KDF: PBKDF2 fallback (%d iterations) - argon2 not available", PBKDF2_ITERATIONS
|
||||||
|
)
|
||||||
# PBKDF2 fallback for systems without argon2-cffi
|
# PBKDF2 fallback for systems without argon2-cffi
|
||||||
# 600K iterations is slow but not memory-hard
|
# 600K iterations is slow but not memory-hard
|
||||||
kdf = PBKDF2HMAC(
|
kdf = PBKDF2HMAC(
|
||||||
@@ -241,6 +267,7 @@ def derive_hybrid_key(
|
|||||||
)
|
)
|
||||||
key = kdf.derive(key_material)
|
key = kdf.derive(key_material)
|
||||||
|
|
||||||
|
logger.debug("KDF complete, derived %d-byte key", len(key))
|
||||||
return key
|
return key
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -457,6 +484,13 @@ def encrypt_message(
|
|||||||
# Pack payload with type marker
|
# Pack payload with type marker
|
||||||
packed_payload, _ = _pack_payload(message)
|
packed_payload, _ = _pack_payload(message)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"encrypt_message: packed_payload=%d bytes, flags=0x%02x, format_version=%d",
|
||||||
|
len(packed_payload),
|
||||||
|
flags,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
)
|
||||||
|
|
||||||
# Random padding to hide message length
|
# Random padding to hide message length
|
||||||
padding_len = secrets.randbelow(256) + 64
|
padding_len = secrets.randbelow(256) + 64
|
||||||
padded_len = ((len(packed_payload) + padding_len + 255) // 256) * 256
|
padded_len = ((len(packed_payload) + padding_len + 255) // 256) * 256
|
||||||
@@ -464,6 +498,10 @@ def encrypt_message(
|
|||||||
padding = secrets.token_bytes(padding_needed - 4) + struct.pack(">I", len(packed_payload))
|
padding = secrets.token_bytes(padding_needed - 4) + struct.pack(">I", len(packed_payload))
|
||||||
padded_message = packed_payload + padding
|
padded_message = packed_payload + padding
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Padded message: %d bytes (payload + %d padding)", len(padded_message), padding_needed
|
||||||
|
)
|
||||||
|
|
||||||
# Build header for AAD
|
# Build header for AAD
|
||||||
header = MAGIC_HEADER + bytes([FORMAT_VERSION, flags])
|
header = MAGIC_HEADER + bytes([FORMAT_VERSION, flags])
|
||||||
|
|
||||||
@@ -473,10 +511,22 @@ def encrypt_message(
|
|||||||
encryptor.authenticate_additional_data(header)
|
encryptor.authenticate_additional_data(header)
|
||||||
ciphertext = encryptor.update(padded_message) + encryptor.finalize()
|
ciphertext = encryptor.update(padded_message) + encryptor.finalize()
|
||||||
|
|
||||||
|
total_size = len(header) + len(salt) + len(iv) + len(encryptor.tag) + len(ciphertext)
|
||||||
|
logger.debug(
|
||||||
|
"Encrypted output: %d bytes (header=%d, salt=%d, iv=%d, tag=%d, ciphertext=%d)",
|
||||||
|
total_size,
|
||||||
|
len(header),
|
||||||
|
len(salt),
|
||||||
|
len(iv),
|
||||||
|
len(encryptor.tag),
|
||||||
|
len(ciphertext),
|
||||||
|
)
|
||||||
|
|
||||||
# v4.0.0: Header with flags byte
|
# v4.0.0: Header with flags byte
|
||||||
return header + salt + iv + encryptor.tag + ciphertext
|
return header + salt + iv + encryptor.tag + ciphertext
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error("Encryption failed: %s", e)
|
||||||
raise EncryptionError(f"Encryption failed: {e}") from e
|
raise EncryptionError(f"Encryption failed: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
@@ -551,10 +601,21 @@ def decrypt_message(
|
|||||||
InvalidHeaderError: If data doesn't have valid Stegasoo header
|
InvalidHeaderError: If data doesn't have valid Stegasoo header
|
||||||
DecryptionError: If decryption fails (wrong credentials)
|
DecryptionError: If decryption fails (wrong credentials)
|
||||||
"""
|
"""
|
||||||
|
logger.debug("decrypt_message: %d bytes of encrypted data", len(encrypted_data))
|
||||||
|
|
||||||
header = parse_header(encrypted_data)
|
header = parse_header(encrypted_data)
|
||||||
if not header:
|
if not header:
|
||||||
|
logger.error("Invalid or missing Stegasoo header in %d bytes", len(encrypted_data))
|
||||||
raise InvalidHeaderError("Invalid or missing Stegasoo header")
|
raise InvalidHeaderError("Invalid or missing Stegasoo header")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Header: version=%d, flags=0x%02x, has_channel_key=%s, ciphertext=%d bytes",
|
||||||
|
header["version"],
|
||||||
|
header["flags"],
|
||||||
|
header["has_channel_key"],
|
||||||
|
len(header["ciphertext"]),
|
||||||
|
)
|
||||||
|
|
||||||
# Check for channel key mismatch and provide helpful error
|
# Check for channel key mismatch and provide helpful error
|
||||||
channel_hash = _resolve_channel_key(channel_key)
|
channel_hash = _resolve_channel_key(channel_key)
|
||||||
has_configured_key = channel_hash is not None
|
has_configured_key = channel_hash is not None
|
||||||
@@ -577,9 +638,16 @@ def decrypt_message(
|
|||||||
padded_plaintext = decryptor.update(header["ciphertext"]) + decryptor.finalize()
|
padded_plaintext = decryptor.update(header["ciphertext"]) + decryptor.finalize()
|
||||||
original_length = struct.unpack(">I", padded_plaintext[-4:])[0]
|
original_length = struct.unpack(">I", padded_plaintext[-4:])[0]
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Decrypted %d bytes, original payload length: %d",
|
||||||
|
len(padded_plaintext),
|
||||||
|
original_length,
|
||||||
|
)
|
||||||
|
|
||||||
payload_data = padded_plaintext[:original_length]
|
payload_data = padded_plaintext[:original_length]
|
||||||
result = _unpack_payload(payload_data)
|
result = _unpack_payload(payload_data)
|
||||||
|
|
||||||
|
logger.debug("Decryption successful: %s", result.payload_type)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -40,12 +40,12 @@ from PIL import Image, ImageOps
|
|||||||
# Check for scipy availability (for PNG/DCT mode)
|
# Check for scipy availability (for PNG/DCT mode)
|
||||||
# Prefer scipy.fft (newer, more stable) over scipy.fftpack
|
# Prefer scipy.fft (newer, more stable) over scipy.fftpack
|
||||||
try:
|
try:
|
||||||
from scipy.fft import dct, idct, dctn, idctn
|
from scipy.fft import dct, dctn, idct, idctn
|
||||||
|
|
||||||
HAS_SCIPY = True
|
HAS_SCIPY = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
try:
|
try:
|
||||||
from scipy.fftpack import dct, idct, dctn, idctn
|
from scipy.fftpack import dct, dctn, idct, idctn
|
||||||
|
|
||||||
HAS_SCIPY = True
|
HAS_SCIPY = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -287,6 +287,7 @@ def has_jpegio_support() -> bool:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from reedsolo import ReedSolomonError, RSCodec
|
from reedsolo import ReedSolomonError, RSCodec
|
||||||
|
|
||||||
HAS_REEDSOLO = True
|
HAS_REEDSOLO = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_REEDSOLO = False
|
HAS_REEDSOLO = False
|
||||||
@@ -1009,7 +1010,8 @@ def _embed_in_channel_safe(
|
|||||||
needs_adjust = (quantized % 2) != bit_array
|
needs_adjust = (quantized % 2) != bit_array
|
||||||
# Determine direction to nudge
|
# Determine direction to nudge
|
||||||
dct_blocks[i, embed_rows[needs_adjust], embed_cols[needs_adjust]] = (
|
dct_blocks[i, embed_rows[needs_adjust], embed_cols[needs_adjust]] = (
|
||||||
(quantized[needs_adjust] + (1 - 2 * (quantized[needs_adjust] % 2 == 1))) * QUANT_STEP
|
(quantized[needs_adjust] + (1 - 2 * (quantized[needs_adjust] % 2 == 1)))
|
||||||
|
* QUANT_STEP
|
||||||
).astype(np.float64)
|
).astype(np.float64)
|
||||||
# For bits that already match, just quantize
|
# For bits that already match, just quantize
|
||||||
dct_blocks[i, embed_rows[~needs_adjust], embed_cols[~needs_adjust]] = (
|
dct_blocks[i, embed_rows[~needs_adjust], embed_cols[~needs_adjust]] = (
|
||||||
@@ -1219,6 +1221,7 @@ def _embed_jpegio(
|
|||||||
def _jpegtran_available() -> bool:
|
def _jpegtran_available() -> bool:
|
||||||
"""Check if jpegtran is available on the system."""
|
"""Check if jpegtran is available on the system."""
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
return shutil.which("jpegtran") is not None
|
return shutil.which("jpegtran") is not None
|
||||||
|
|
||||||
|
|
||||||
@@ -1237,9 +1240,9 @@ def _jpegtran_rotate(image_data: bytes, rotation: int) -> bytes:
|
|||||||
Returns:
|
Returns:
|
||||||
Rotated JPEG bytes with DCT coefficients preserved
|
Rotated JPEG bytes with DCT coefficients preserved
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
|
||||||
|
|
||||||
if rotation not in (90, 180, 270):
|
if rotation not in (90, 180, 270):
|
||||||
raise ValueError(f"Invalid rotation: {rotation}")
|
raise ValueError(f"Invalid rotation: {rotation}")
|
||||||
@@ -1257,10 +1260,18 @@ def _jpegtran_rotate(image_data: bytes, rotation: int) -> bytes:
|
|||||||
# NOTE: Don't use -trim as it drops edge blocks and destroys stego data
|
# NOTE: Don't use -trim as it drops edge blocks and destroys stego data
|
||||||
# NOTE: Don't use -perfect as it fails on images with non-MCU-aligned edges
|
# NOTE: Don't use -perfect as it fails on images with non-MCU-aligned edges
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["jpegtran", "-rotate", str(rotation), "-copy", "all",
|
[
|
||||||
"-outfile", output_path, input_path],
|
"jpegtran",
|
||||||
|
"-rotate",
|
||||||
|
str(rotation),
|
||||||
|
"-copy",
|
||||||
|
"all",
|
||||||
|
"-outfile",
|
||||||
|
output_path,
|
||||||
|
input_path,
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=30
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
@@ -1367,6 +1378,7 @@ def _quick_validate_dct_header(image_data: bytes, seed: bytes) -> bool:
|
|||||||
copies.append(length_prefix_bytes[start:end])
|
copies.append(length_prefix_bytes[start:end])
|
||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
counter = Counter(copies)
|
counter = Counter(copies)
|
||||||
_, count = counter.most_common(1)[0]
|
_, count = counter.most_common(1)[0]
|
||||||
|
|
||||||
@@ -1437,6 +1449,7 @@ def extract_from_dct(
|
|||||||
if rotation != 0:
|
if rotation != 0:
|
||||||
try:
|
try:
|
||||||
from . import debug
|
from . import debug
|
||||||
|
|
||||||
debug.print(f"DCT decode succeeded after {rotation}° rotation")
|
debug.print(f"DCT decode succeeded after {rotation}° rotation")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Don't let debug logging break extraction
|
pass # Don't let debug logging break extraction
|
||||||
@@ -1450,6 +1463,7 @@ def extract_from_dct(
|
|||||||
if rotation != 0:
|
if rotation != 0:
|
||||||
try:
|
try:
|
||||||
from . import debug
|
from . import debug
|
||||||
|
|
||||||
debug.print(f"DCT decode succeeded after {rotation}° rotation")
|
debug.print(f"DCT decode succeeded after {rotation}° rotation")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Don't let debug logging break extraction
|
pass # Don't let debug logging break extraction
|
||||||
|
|||||||
@@ -2,27 +2,96 @@
|
|||||||
Stegasoo Debugging Utilities
|
Stegasoo Debugging Utilities
|
||||||
|
|
||||||
Debugging, logging, and performance monitoring tools.
|
Debugging, logging, and performance monitoring tools.
|
||||||
Can be disabled for production use.
|
|
||||||
|
Configuration:
|
||||||
|
STEGASOO_LOG_LEVEL env var controls log level:
|
||||||
|
- Not set or empty: logging disabled (production default)
|
||||||
|
- DEBUG: verbose debug output (encode/decode flow, crypto params, etc.)
|
||||||
|
- INFO: operational messages (format detection, mode selection)
|
||||||
|
- WARNING: potential issues (fallback KDF, format transcoding)
|
||||||
|
- ERROR: operation failures
|
||||||
|
|
||||||
|
STEGASOO_DEBUG=1 is a shorthand for STEGASOO_LOG_LEVEL=DEBUG
|
||||||
|
|
||||||
|
CLI: stegasoo --debug encode ... (sets DEBUG level for that invocation)
|
||||||
|
|
||||||
|
All output goes to Python's logging module under the 'stegasoo' logger hierarchy.
|
||||||
|
The legacy debug.print() API is preserved for backward compatibility.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
# Map string level names to logging levels
|
||||||
|
_LEVEL_MAP = {
|
||||||
|
"DEBUG": logging.DEBUG,
|
||||||
|
"INFO": logging.INFO,
|
||||||
|
"WARNING": logging.WARNING,
|
||||||
|
"ERROR": logging.ERROR,
|
||||||
|
"CRITICAL": logging.CRITICAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Root logger for the stegasoo package
|
||||||
|
logger = logging.getLogger("stegasoo")
|
||||||
|
|
||||||
# Global debug configuration
|
# Global debug configuration
|
||||||
DEBUG_ENABLED = False # Set to True to enable debug output
|
|
||||||
LOG_PERFORMANCE = True # Log function timing
|
LOG_PERFORMANCE = True # Log function timing
|
||||||
VALIDATION_ASSERTIONS = True # Enable runtime validation assertions
|
VALIDATION_ASSERTIONS = True # Enable runtime validation assertions
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_from_env() -> bool:
|
||||||
|
"""Configure logging from environment variables. Returns True if debug enabled."""
|
||||||
|
# STEGASOO_DEBUG=1 is shorthand for DEBUG level
|
||||||
|
if os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
|
||||||
|
_setup_logging(logging.DEBUG)
|
||||||
|
return True
|
||||||
|
|
||||||
|
level_str = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
|
||||||
|
if level_str and level_str in _LEVEL_MAP:
|
||||||
|
_setup_logging(_LEVEL_MAP[level_str])
|
||||||
|
return level_str == "DEBUG"
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_logging(level: int) -> None:
|
||||||
|
"""Configure the stegasoo logger with a stderr handler."""
|
||||||
|
logger.setLevel(level)
|
||||||
|
|
||||||
|
# Only add handler if none exist (avoid duplicates on re-init)
|
||||||
|
if not logger.handlers:
|
||||||
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
|
handler.setLevel(level)
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
else:
|
||||||
|
# Update existing handler level
|
||||||
|
for handler in logger.handlers:
|
||||||
|
handler.setLevel(level)
|
||||||
|
|
||||||
|
|
||||||
|
# Auto-configure on import
|
||||||
|
DEBUG_ENABLED = _configure_from_env()
|
||||||
|
|
||||||
|
|
||||||
def enable_debug(enable: bool = True) -> None:
|
def enable_debug(enable: bool = True) -> None:
|
||||||
"""Enable or disable debug mode globally."""
|
"""Enable or disable debug mode globally."""
|
||||||
global DEBUG_ENABLED
|
global DEBUG_ENABLED
|
||||||
DEBUG_ENABLED = enable
|
DEBUG_ENABLED = enable
|
||||||
|
if enable:
|
||||||
|
_setup_logging(logging.DEBUG)
|
||||||
|
else:
|
||||||
|
logger.setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
def enable_performance_logging(enable: bool = True) -> None:
|
def enable_performance_logging(enable: bool = True) -> None:
|
||||||
@@ -38,15 +107,14 @@ def enable_assertions(enable: bool = True) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def debug_print(message: str, level: str = "INFO") -> None:
|
def debug_print(message: str, level: str = "INFO") -> None:
|
||||||
"""Print debug message with timestamp if debugging is enabled."""
|
"""Log a message at the given level via the stegasoo logger."""
|
||||||
if DEBUG_ENABLED:
|
log_level = _LEVEL_MAP.get(level.upper(), logging.DEBUG)
|
||||||
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
logger.log(log_level, message)
|
||||||
print(f"[{timestamp}] [{level}] {message}", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str:
|
def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str:
|
||||||
"""Format bytes for debugging."""
|
"""Format bytes for debugging."""
|
||||||
if not DEBUG_ENABLED:
|
if not logger.isEnabledFor(logging.DEBUG):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
@@ -55,15 +123,17 @@ def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str:
|
|||||||
if len(data) <= max_bytes:
|
if len(data) <= max_bytes:
|
||||||
return f"{label} ({len(data)} bytes): {data.hex()}"
|
return f"{label} ({len(data)} bytes): {data.hex()}"
|
||||||
else:
|
else:
|
||||||
return f"{label} ({len(data)} bytes): {data[:max_bytes//2].hex()}...{data[-max_bytes//2:].hex()}"
|
return (
|
||||||
|
f"{label} ({len(data)} bytes): "
|
||||||
|
f"{data[:max_bytes // 2].hex()}...{data[-max_bytes // 2:].hex()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def debug_exception(e: Exception, context: str = "") -> None:
|
def debug_exception(e: Exception, context: str = "") -> None:
|
||||||
"""Log exception with context for debugging."""
|
"""Log exception with context for debugging."""
|
||||||
if DEBUG_ENABLED:
|
logger.error("Exception in %s: %s: %s", context, type(e).__name__, e)
|
||||||
debug_print(f"Exception in {context}: {type(e).__name__}: {e}", "ERROR")
|
if logger.isEnabledFor(logging.DEBUG):
|
||||||
if DEBUG_ENABLED:
|
logger.debug(traceback.format_exc())
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
|
|
||||||
def time_function(func: Callable) -> Callable:
|
def time_function(func: Callable) -> Callable:
|
||||||
@@ -71,7 +141,7 @@ def time_function(func: Callable) -> Callable:
|
|||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs) -> Any:
|
def wrapper(*args, **kwargs) -> Any:
|
||||||
if not (DEBUG_ENABLED and LOG_PERFORMANCE):
|
if not (logger.isEnabledFor(logging.DEBUG) and LOG_PERFORMANCE):
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
@@ -80,7 +150,7 @@ def time_function(func: Callable) -> Callable:
|
|||||||
return result
|
return result
|
||||||
finally:
|
finally:
|
||||||
end = time.perf_counter()
|
end = time.perf_counter()
|
||||||
debug_print(f"{func.__name__} took {end - start:.6f}s", "PERF")
|
logger.debug("%s took %.6fs", func.__name__, end - start)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
@@ -94,8 +164,6 @@ def validate_assertion(condition: bool, message: str) -> None:
|
|||||||
def memory_usage() -> dict[str, float | str]:
|
def memory_usage() -> dict[str, float | str]:
|
||||||
"""Get current memory usage (if psutil is available)."""
|
"""Get current memory usage (if psutil is available)."""
|
||||||
try:
|
try:
|
||||||
import os
|
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
process = psutil.Process(os.getpid())
|
process = psutil.Process(os.getpid())
|
||||||
@@ -131,8 +199,19 @@ def hexdump(data: bytes, offset: int = 0, length: int = 64) -> str:
|
|||||||
return "\n".join(result)
|
return "\n".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
"""Get a child logger under the stegasoo namespace.
|
||||||
|
|
||||||
|
Usage in modules:
|
||||||
|
from .debug import get_logger
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.debug("message")
|
||||||
|
"""
|
||||||
|
return logging.getLogger(name)
|
||||||
|
|
||||||
|
|
||||||
class Debug:
|
class Debug:
|
||||||
"""Debugging utility class."""
|
"""Debugging utility class (backward-compatible API)."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.enabled = DEBUG_ENABLED
|
self.enabled = DEBUG_ENABLED
|
||||||
|
|||||||
@@ -31,12 +31,15 @@ def _write_progress(progress_file: str | None, current: int, total: int, phase:
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
with open(progress_file, "w") as f:
|
with open(progress_file, "w") as f:
|
||||||
json.dump({
|
json.dump(
|
||||||
|
{
|
||||||
"current": current,
|
"current": current,
|
||||||
"total": total,
|
"total": total,
|
||||||
"percent": (current / total * 100) if total > 0 else 0,
|
"percent": (current / total * 100) if total > 0 else 0,
|
||||||
"phase": phase,
|
"phase": phase,
|
||||||
}, f)
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -291,16 +294,23 @@ def decode_audio(
|
|||||||
Returns:
|
Returns:
|
||||||
DecodeResult with message or file data
|
DecodeResult with message or file data
|
||||||
"""
|
"""
|
||||||
from .audio_utils import detect_audio_format, transcode_to_wav
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
|
AUDIO_ENABLED,
|
||||||
EMBED_MODE_AUDIO_AUTO,
|
EMBED_MODE_AUDIO_AUTO,
|
||||||
EMBED_MODE_AUDIO_LSB,
|
EMBED_MODE_AUDIO_LSB,
|
||||||
EMBED_MODE_AUDIO_SPREAD,
|
EMBED_MODE_AUDIO_SPREAD,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not AUDIO_ENABLED:
|
||||||
|
raise ExtractionError(
|
||||||
|
"Audio support is disabled. Install audio extras (pip install stegasoo[audio]) "
|
||||||
|
"or set STEGASOO_AUDIO=1 to force enable."
|
||||||
|
)
|
||||||
|
|
||||||
|
from .audio_utils import detect_audio_format, transcode_to_wav
|
||||||
|
|
||||||
debug.print(
|
debug.print(
|
||||||
f"decode_audio: mode={embed_mode}, "
|
f"decode_audio: mode={embed_mode}, " f"passphrase length={len(passphrase.split())} words"
|
||||||
f"passphrase length={len(passphrase.split())} words"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
@@ -358,9 +368,7 @@ def decode_audio(
|
|||||||
elif embed_mode == EMBED_MODE_AUDIO_SPREAD:
|
elif embed_mode == EMBED_MODE_AUDIO_SPREAD:
|
||||||
from .spread_steganography import extract_from_audio_spread
|
from .spread_steganography import extract_from_audio_spread
|
||||||
|
|
||||||
encrypted = extract_from_audio_spread(
|
encrypted = extract_from_audio_spread(wav_audio, pixel_key, progress_file=progress_file)
|
||||||
wav_audio, pixel_key, progress_file=progress_file
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid audio embed mode: {embed_mode}")
|
raise ValueError(f"Invalid audio embed mode: {embed_mode}")
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
|
|||||||
from .constants import EMBED_MODE_LSB
|
from .constants import EMBED_MODE_LSB
|
||||||
from .crypto import derive_pixel_key, encrypt_message
|
from .crypto import derive_pixel_key, encrypt_message
|
||||||
from .debug import debug
|
from .debug import debug
|
||||||
|
from .exceptions import AudioError
|
||||||
from .models import EncodeResult, FilePayload
|
from .models import EncodeResult, FilePayload
|
||||||
from .steganography import embed_in_image
|
from .steganography import embed_in_image
|
||||||
from .utils import generate_filename
|
from .utils import generate_filename
|
||||||
@@ -280,6 +281,7 @@ def encode_audio(
|
|||||||
embed_mode: str = "audio_lsb",
|
embed_mode: str = "audio_lsb",
|
||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
progress_file: str | None = None,
|
progress_file: str | None = None,
|
||||||
|
chip_tier: int | None = None,
|
||||||
) -> tuple[bytes, AudioEmbedStats]:
|
) -> tuple[bytes, AudioEmbedStats]:
|
||||||
"""
|
"""
|
||||||
Encode a message or file into an audio carrier.
|
Encode a message or file into an audio carrier.
|
||||||
@@ -295,12 +297,21 @@ def encode_audio(
|
|||||||
embed_mode: 'audio_lsb' or 'audio_spread'
|
embed_mode: 'audio_lsb' or 'audio_spread'
|
||||||
channel_key: Channel key for deployment/group isolation
|
channel_key: Channel key for deployment/group isolation
|
||||||
progress_file: Optional path to write progress JSON
|
progress_file: Optional path to write progress JSON
|
||||||
|
chip_tier: Spread spectrum chip tier (0=lossless, 1=high_lossy, 2=low_lossy).
|
||||||
|
Only used for audio_spread mode. Default None → uses constant default.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (stego audio bytes, AudioEmbedStats)
|
Tuple of (stego audio bytes, AudioEmbedStats)
|
||||||
"""
|
"""
|
||||||
|
from .constants import AUDIO_ENABLED, EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_SPREAD
|
||||||
|
|
||||||
|
if not AUDIO_ENABLED:
|
||||||
|
raise AudioError(
|
||||||
|
"Audio support is disabled. Install audio extras (pip install stegasoo[audio]) "
|
||||||
|
"or set STEGASOO_AUDIO=1 to force enable."
|
||||||
|
)
|
||||||
|
|
||||||
from .audio_utils import detect_audio_format, transcode_to_wav
|
from .audio_utils import detect_audio_format, transcode_to_wav
|
||||||
from .constants import EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_SPREAD
|
|
||||||
|
|
||||||
debug.print(
|
debug.print(
|
||||||
f"encode_audio: mode={embed_mode}, "
|
f"encode_audio: mode={embed_mode}, "
|
||||||
@@ -343,10 +354,12 @@ def encode_audio(
|
|||||||
encrypted, carrier_audio, pixel_key, progress_file=progress_file
|
encrypted, carrier_audio, pixel_key, progress_file=progress_file
|
||||||
)
|
)
|
||||||
elif embed_mode == EMBED_MODE_AUDIO_SPREAD:
|
elif embed_mode == EMBED_MODE_AUDIO_SPREAD:
|
||||||
|
from .constants import AUDIO_SS_DEFAULT_CHIP_TIER
|
||||||
from .spread_steganography import embed_in_audio_spread
|
from .spread_steganography import embed_in_audio_spread
|
||||||
|
|
||||||
|
tier = chip_tier if chip_tier is not None else AUDIO_SS_DEFAULT_CHIP_TIER
|
||||||
stego_audio, stats = embed_in_audio_spread(
|
stego_audio, stats = embed_in_audio_spread(
|
||||||
encrypted, carrier_audio, pixel_key, progress_file=progress_file
|
encrypted, carrier_audio, pixel_key, chip_tier=tier, progress_file=progress_file
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid audio embed mode: {embed_mode}")
|
raise ValueError(f"Invalid audio embed mode: {embed_mode}")
|
||||||
|
|||||||
@@ -300,6 +300,9 @@ class AudioEmbedStats:
|
|||||||
channels: int
|
channels: int
|
||||||
duration_seconds: float
|
duration_seconds: float
|
||||||
embed_mode: str # "audio_lsb" or "audio_spread"
|
embed_mode: str # "audio_lsb" or "audio_spread"
|
||||||
|
chip_tier: int | None = None # v4.4.0: spread spectrum chip tier (0/1/2)
|
||||||
|
chip_length: int | None = None # v4.4.0: samples per chip
|
||||||
|
embeddable_channels: int | None = None # v4.4.0: channels used (excl. LFE)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def modification_percent(self) -> float:
|
def modification_percent(self) -> float:
|
||||||
@@ -329,3 +332,7 @@ class AudioCapacityInfo:
|
|||||||
embed_mode: str
|
embed_mode: str
|
||||||
sample_rate: int
|
sample_rate: int
|
||||||
duration_seconds: float
|
duration_seconds: float
|
||||||
|
chip_tier: int | None = None # v4.4.0: spread spectrum chip tier
|
||||||
|
chip_length: int | None = None # v4.4.0: samples per chip
|
||||||
|
embeddable_channels: int | None = None # v4.4.0: channels used (excl. LFE)
|
||||||
|
total_channels: int | None = None # v4.4.0: total channels in carrier
|
||||||
|
|||||||
@@ -105,14 +105,14 @@ def decompress_data(data: str) -> str:
|
|||||||
"Data compressed with zstd but zstandard package not installed. "
|
"Data compressed with zstd but zstandard package not installed. "
|
||||||
"Run: pip install zstandard"
|
"Run: pip install zstandard"
|
||||||
)
|
)
|
||||||
encoded = data[len(COMPRESSION_PREFIX_ZSTD):]
|
encoded = data[len(COMPRESSION_PREFIX_ZSTD) :]
|
||||||
compressed = base64.b64decode(encoded)
|
compressed = base64.b64decode(encoded)
|
||||||
dctx = zstd.ZstdDecompressor()
|
dctx = zstd.ZstdDecompressor()
|
||||||
return dctx.decompress(compressed).decode("utf-8")
|
return dctx.decompress(compressed).decode("utf-8")
|
||||||
|
|
||||||
elif data.startswith(COMPRESSION_PREFIX_ZLIB):
|
elif data.startswith(COMPRESSION_PREFIX_ZLIB):
|
||||||
# Legacy zlib compression
|
# Legacy zlib compression
|
||||||
encoded = data[len(COMPRESSION_PREFIX_ZLIB):]
|
encoded = data[len(COMPRESSION_PREFIX_ZLIB) :]
|
||||||
compressed = base64.b64decode(encoded)
|
compressed = base64.b64decode(encoded)
|
||||||
return zlib.decompress(compressed).decode("utf-8")
|
return zlib.decompress(compressed).decode("utf-8")
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ def extract_stego_backup(
|
|||||||
debug.print(f"Stego backup extraction failed: {e}")
|
debug.print(f"Stego backup extraction failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Recovery key format: same as channel key (32 chars, 8 groups of 4)
|
# Recovery key format: same as channel key (32 chars, 8 groups of 4)
|
||||||
RECOVERY_KEY_LENGTH = 32
|
RECOVERY_KEY_LENGTH = 32
|
||||||
RECOVERY_KEY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
RECOVERY_KEY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
@@ -205,16 +206,10 @@ def generate_recovery_key() -> str:
|
|||||||
7
|
7
|
||||||
"""
|
"""
|
||||||
# Generate 32 random alphanumeric characters
|
# Generate 32 random alphanumeric characters
|
||||||
raw_key = "".join(
|
raw_key = "".join(secrets.choice(RECOVERY_KEY_ALPHABET) for _ in range(RECOVERY_KEY_LENGTH))
|
||||||
secrets.choice(RECOVERY_KEY_ALPHABET)
|
|
||||||
for _ in range(RECOVERY_KEY_LENGTH)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Format with dashes every 4 characters
|
# Format with dashes every 4 characters
|
||||||
formatted = "-".join(
|
formatted = "-".join(raw_key[i : i + 4] for i in range(0, RECOVERY_KEY_LENGTH, 4))
|
||||||
raw_key[i:i + 4]
|
|
||||||
for i in range(0, RECOVERY_KEY_LENGTH, 4)
|
|
||||||
)
|
|
||||||
|
|
||||||
debug.print(f"Generated recovery key: {formatted[:4]}-••••-...-{formatted[-4:]}")
|
debug.print(f"Generated recovery key: {formatted[:4]}-••••-...-{formatted[-4:]}")
|
||||||
return formatted
|
return formatted
|
||||||
@@ -245,15 +240,12 @@ def normalize_recovery_key(key: str) -> str:
|
|||||||
# Validate length
|
# Validate length
|
||||||
if len(clean) != RECOVERY_KEY_LENGTH:
|
if len(clean) != RECOVERY_KEY_LENGTH:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Recovery key must be {RECOVERY_KEY_LENGTH} characters "
|
f"Recovery key must be {RECOVERY_KEY_LENGTH} characters " f"(got {len(clean)})"
|
||||||
f"(got {len(clean)})"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate characters
|
# Validate characters
|
||||||
if not all(c in RECOVERY_KEY_ALPHABET for c in clean):
|
if not all(c in RECOVERY_KEY_ALPHABET for c in clean):
|
||||||
raise ValueError(
|
raise ValueError("Recovery key must contain only letters A-Z and digits 0-9")
|
||||||
"Recovery key must contain only letters A-Z and digits 0-9"
|
|
||||||
)
|
|
||||||
|
|
||||||
return clean
|
return clean
|
||||||
|
|
||||||
@@ -273,7 +265,7 @@ def format_recovery_key(key: str) -> str:
|
|||||||
"ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
|
"ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
|
||||||
"""
|
"""
|
||||||
clean = normalize_recovery_key(key)
|
clean = normalize_recovery_key(key)
|
||||||
return "-".join(clean[i:i + 4] for i in range(0, RECOVERY_KEY_LENGTH, 4))
|
return "-".join(clean[i : i + 4] for i in range(0, RECOVERY_KEY_LENGTH, 4))
|
||||||
|
|
||||||
|
|
||||||
def hash_recovery_key(key: str) -> str:
|
def hash_recovery_key(key: str) -> str:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -54,8 +54,7 @@ def read_image_exif(image_data: bytes) -> dict:
|
|||||||
gps[gps_tag] = float(gps_value)
|
gps[gps_tag] = float(gps_value)
|
||||||
elif isinstance(gps_value, tuple):
|
elif isinstance(gps_value, tuple):
|
||||||
gps[gps_tag] = [
|
gps[gps_tag] = [
|
||||||
float(v) if hasattr(v, "numerator") else v
|
float(v) if hasattr(v, "numerator") else v for v in gps_value
|
||||||
for v in gps_value
|
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
gps[gps_tag] = gps_value
|
gps[gps_tag] = gps_value
|
||||||
@@ -69,7 +68,9 @@ def read_image_exif(image_data: bytes) -> dict:
|
|||||||
# Try to decode as ASCII/UTF-8 text
|
# Try to decode as ASCII/UTF-8 text
|
||||||
decoded = value.decode("utf-8", errors="strict").strip("\x00")
|
decoded = value.decode("utf-8", errors="strict").strip("\x00")
|
||||||
# Only keep if it looks like printable text
|
# Only keep if it looks like printable text
|
||||||
if decoded.isprintable() or all(c.isspace() or c.isprintable() for c in decoded):
|
if decoded.isprintable() or all(
|
||||||
|
c.isspace() or c.isprintable() for c in decoded
|
||||||
|
):
|
||||||
result[tag] = decoded
|
result[tag] = decoded
|
||||||
else:
|
else:
|
||||||
result[tag] = f"<{len(value)} bytes binary>"
|
result[tag] = f"<{len(value)} bytes binary>"
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import io
|
|||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from .debug import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
ALLOWED_AUDIO_EXTENSIONS,
|
ALLOWED_AUDIO_EXTENSIONS,
|
||||||
ALLOWED_IMAGE_EXTENSIONS,
|
ALLOWED_IMAGE_EXTENSIONS,
|
||||||
|
|||||||
@@ -3,25 +3,37 @@ Tests for Stegasoo audio steganography.
|
|||||||
|
|
||||||
Tests cover:
|
Tests cover:
|
||||||
- Audio LSB roundtrip (encode + decode)
|
- Audio LSB roundtrip (encode + decode)
|
||||||
- Audio MDCT roundtrip (encode + decode)
|
- Audio spread spectrum roundtrip (v0 legacy + v2 per-channel)
|
||||||
- Wrong credentials fail to decode
|
- Wrong credentials fail to decode
|
||||||
- Capacity calculations
|
- Capacity calculations (per-tier)
|
||||||
- Format detection
|
- Format detection
|
||||||
- Audio validation
|
- Audio validation
|
||||||
|
- Per-channel stereo/multichannel embedding (v4.4.0)
|
||||||
|
- Chip tier roundtrips (v4.4.0)
|
||||||
|
- LFE channel skipping (v4.4.0)
|
||||||
|
- Backward compat: v0 decode from v2 code
|
||||||
|
- Header v2 build/parse roundtrip
|
||||||
|
- Round-robin bit distribution
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
|
|
||||||
from stegasoo.constants import (
|
from stegasoo.constants import AUDIO_ENABLED, EMBED_MODE_AUDIO_LSB, EMBED_MODE_AUDIO_SPREAD
|
||||||
EMBED_MODE_AUDIO_LSB,
|
|
||||||
EMBED_MODE_AUDIO_SPREAD,
|
|
||||||
)
|
|
||||||
from stegasoo.models import AudioCapacityInfo, AudioEmbedStats, AudioInfo
|
from stegasoo.models import AudioCapacityInfo, AudioEmbedStats, AudioInfo
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(not AUDIO_ENABLED, reason="Audio support disabled (STEGASOO_AUDIO)")
|
||||||
|
|
||||||
|
# Path to real test data files
|
||||||
|
_TEST_DATA = Path(__file__).parent.parent / "test_data"
|
||||||
|
_REFERENCE_PNG = _TEST_DATA / "reference.png"
|
||||||
|
_SPEECH_WAV = _TEST_DATA / "stupid_elitist_speech.wav"
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# FIXTURES
|
# FIXTURES
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -33,7 +45,6 @@ def carrier_wav() -> bytes:
|
|||||||
sample_rate = 44100
|
sample_rate = 44100
|
||||||
duration = 1.0
|
duration = 1.0
|
||||||
num_samples = int(sample_rate * duration)
|
num_samples = int(sample_rate * duration)
|
||||||
# Generate a simple sine wave
|
|
||||||
t = np.linspace(0, duration, num_samples, endpoint=False)
|
t = np.linspace(0, duration, num_samples, endpoint=False)
|
||||||
samples = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16)
|
samples = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16)
|
||||||
|
|
||||||
@@ -45,9 +56,9 @@ def carrier_wav() -> bytes:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def carrier_wav_stereo() -> bytes:
|
def carrier_wav_stereo() -> bytes:
|
||||||
"""Generate a stereo test WAV file."""
|
"""Generate a stereo test WAV file (5 seconds for spread spectrum capacity)."""
|
||||||
sample_rate = 44100
|
sample_rate = 44100
|
||||||
duration = 1.0
|
duration = 5.0
|
||||||
num_samples = int(sample_rate * duration)
|
num_samples = int(sample_rate * duration)
|
||||||
t = np.linspace(0, duration, num_samples, endpoint=False)
|
t = np.linspace(0, duration, num_samples, endpoint=False)
|
||||||
left = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16)
|
left = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16)
|
||||||
@@ -67,7 +78,6 @@ def carrier_wav_long() -> bytes:
|
|||||||
duration = 15.0
|
duration = 15.0
|
||||||
num_samples = int(sample_rate * duration)
|
num_samples = int(sample_rate * duration)
|
||||||
t = np.linspace(0, duration, num_samples, endpoint=False)
|
t = np.linspace(0, duration, num_samples, endpoint=False)
|
||||||
# Mix of frequencies for better MDCT embedding
|
|
||||||
samples = (
|
samples = (
|
||||||
(np.sin(2 * np.pi * 440 * t) + np.sin(2 * np.pi * 880 * t) + np.sin(2 * np.pi * 1320 * t))
|
(np.sin(2 * np.pi * 440 * t) + np.sin(2 * np.pi * 880 * t) + np.sin(2 * np.pi * 1320 * t))
|
||||||
* 5000
|
* 5000
|
||||||
@@ -80,12 +90,47 @@ def carrier_wav_long() -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def carrier_wav_spread_integration() -> bytes:
|
def carrier_wav_stereo_long() -> bytes:
|
||||||
"""Generate a very long WAV (150 seconds) for spread spectrum integration tests.
|
"""Generate a stereo WAV (15 seconds) for per-channel spread tests."""
|
||||||
|
sample_rate = 48000
|
||||||
|
duration = 15.0
|
||||||
|
num_samples = int(sample_rate * duration)
|
||||||
|
t = np.linspace(0, duration, num_samples, endpoint=False)
|
||||||
|
left = (np.sin(2 * np.pi * 440 * t) * 10000).astype(np.float64) / 32768.0
|
||||||
|
right = (np.sin(2 * np.pi * 660 * t) * 10000).astype(np.float64) / 32768.0
|
||||||
|
samples = np.column_stack([left, right])
|
||||||
|
|
||||||
Spread spectrum needs 1024 samples per bit. With encryption + RS overhead (~690 bytes),
|
buf = io.BytesIO()
|
||||||
we need at least 690*8*1024 = 5.7M samples ~ 130 seconds at 44.1kHz.
|
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
|
||||||
"""
|
buf.seek(0)
|
||||||
|
return buf.read()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def carrier_wav_5_1() -> bytes:
|
||||||
|
"""Generate a 6-channel (5.1) WAV for LFE skip tests."""
|
||||||
|
sample_rate = 48000
|
||||||
|
duration = 15.0
|
||||||
|
num_samples = int(sample_rate * duration)
|
||||||
|
t = np.linspace(0, duration, num_samples, endpoint=False)
|
||||||
|
|
||||||
|
# 6 channels with different frequencies
|
||||||
|
freqs = [440, 554, 660, 80, 880, 1100] # ch3 = LFE (low freq)
|
||||||
|
channels = []
|
||||||
|
for freq in freqs:
|
||||||
|
ch = (np.sin(2 * np.pi * freq * t) * 8000).astype(np.float64) / 32768.0
|
||||||
|
channels.append(ch)
|
||||||
|
samples = np.column_stack(channels)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
sf.write(buf, samples, sample_rate, format="WAV", subtype="PCM_16")
|
||||||
|
buf.seek(0)
|
||||||
|
return buf.read()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def carrier_wav_spread_integration() -> bytes:
|
||||||
|
"""Generate a very long WAV (150 seconds) for spread spectrum integration tests."""
|
||||||
sample_rate = 44100
|
sample_rate = 44100
|
||||||
duration = 150.0
|
duration = 150.0
|
||||||
num_samples = int(sample_rate * duration)
|
num_samples = int(sample_rate * duration)
|
||||||
@@ -103,7 +148,9 @@ def carrier_wav_spread_integration() -> bytes:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def reference_photo() -> bytes:
|
def reference_photo() -> bytes:
|
||||||
"""Generate a small reference photo (PNG)."""
|
"""Load real reference photo from test_data, or generate a small one."""
|
||||||
|
if _REFERENCE_PNG.exists():
|
||||||
|
return _REFERENCE_PNG.read_bytes()
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
img = Image.new("RGB", (100, 100), color=(128, 64, 32))
|
img = Image.new("RGB", (100, 100), color=(128, 64, 32))
|
||||||
@@ -113,6 +160,14 @@ def reference_photo() -> bytes:
|
|||||||
return buf.read()
|
return buf.read()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def speech_wav() -> bytes:
|
||||||
|
"""Load real speech WAV from test_data (48kHz mono, ~68s)."""
|
||||||
|
if not _SPEECH_WAV.exists():
|
||||||
|
pytest.skip("test_data/stupid_elitist_speech.wav not found")
|
||||||
|
return _SPEECH_WAV.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# AUDIO LSB TESTS
|
# AUDIO LSB TESTS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -134,7 +189,6 @@ class TestAudioLSB:
|
|||||||
from stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb
|
from stegasoo.audio_steganography import embed_in_audio_lsb, extract_from_audio_lsb
|
||||||
|
|
||||||
payload = b"Hello, audio steganography!"
|
payload = b"Hello, audio steganography!"
|
||||||
# Prepend with magic header to simulate real usage pattern
|
|
||||||
key = b"\x42" * 32
|
key = b"\x42" * 32
|
||||||
|
|
||||||
stego_audio, stats = embed_in_audio_lsb(payload, carrier_wav, key)
|
stego_audio, stats = embed_in_audio_lsb(payload, carrier_wav, key)
|
||||||
@@ -145,7 +199,6 @@ class TestAudioLSB:
|
|||||||
assert stats.samples_modified > 0
|
assert stats.samples_modified > 0
|
||||||
assert 0 < stats.capacity_used <= 1.0
|
assert 0 < stats.capacity_used <= 1.0
|
||||||
|
|
||||||
# Extract
|
|
||||||
extracted = extract_from_audio_lsb(stego_audio, key)
|
extracted = extract_from_audio_lsb(stego_audio, key)
|
||||||
assert extracted is not None
|
assert extracted is not None
|
||||||
assert extracted == payload
|
assert extracted == payload
|
||||||
@@ -174,7 +227,6 @@ class TestAudioLSB:
|
|||||||
stego_audio, _ = embed_in_audio_lsb(payload, carrier_wav, correct_key)
|
stego_audio, _ = embed_in_audio_lsb(payload, carrier_wav, correct_key)
|
||||||
|
|
||||||
extracted = extract_from_audio_lsb(stego_audio, wrong_key)
|
extracted = extract_from_audio_lsb(stego_audio, wrong_key)
|
||||||
# Should return None or garbage (not the original message)
|
|
||||||
assert extracted is None or extracted != payload
|
assert extracted is None or extracted != payload
|
||||||
|
|
||||||
def test_two_bits_per_sample(self, carrier_wav):
|
def test_two_bits_per_sample(self, carrier_wav):
|
||||||
@@ -197,46 +249,97 @@ class TestAudioLSB:
|
|||||||
indices1 = generate_sample_indices(key, 10000, 100)
|
indices1 = generate_sample_indices(key, 10000, 100)
|
||||||
indices2 = generate_sample_indices(key, 10000, 100)
|
indices2 = generate_sample_indices(key, 10000, 100)
|
||||||
|
|
||||||
# Same key should produce same indices
|
|
||||||
assert indices1 == indices2
|
assert indices1 == indices2
|
||||||
|
|
||||||
# All indices should be valid
|
|
||||||
assert all(0 <= i < 10000 for i in indices1)
|
assert all(0 <= i < 10000 for i in indices1)
|
||||||
|
|
||||||
# No duplicates
|
|
||||||
assert len(set(indices1)) == len(indices1)
|
assert len(set(indices1)) == len(indices1)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# AUDIO SPREAD SPECTRUM TESTS
|
# AUDIO SPREAD SPECTRUM TESTS (v2 per-channel)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestAudioSpread:
|
class TestAudioSpread:
|
||||||
"""Tests for audio spread spectrum steganography."""
|
"""Tests for audio spread spectrum steganography (v2 per-channel)."""
|
||||||
|
|
||||||
def test_calculate_capacity(self, carrier_wav_long):
|
def test_calculate_capacity_default_tier(self, carrier_wav_long):
|
||||||
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
||||||
|
|
||||||
capacity = calculate_audio_spread_capacity(carrier_wav_long)
|
capacity = calculate_audio_spread_capacity(carrier_wav_long)
|
||||||
assert isinstance(capacity, AudioCapacityInfo)
|
assert isinstance(capacity, AudioCapacityInfo)
|
||||||
assert capacity.usable_capacity_bytes > 0
|
assert capacity.usable_capacity_bytes > 0
|
||||||
assert capacity.embed_mode == EMBED_MODE_AUDIO_SPREAD
|
assert capacity.embed_mode == EMBED_MODE_AUDIO_SPREAD
|
||||||
|
assert capacity.chip_tier == 2 # default
|
||||||
|
assert capacity.chip_length == 1024
|
||||||
|
|
||||||
def test_spread_roundtrip(self, carrier_wav_long):
|
def test_calculate_capacity_per_tier(self, carrier_wav_long):
|
||||||
"""Test spread spectrum embed/extract roundtrip."""
|
"""Capacity should increase as chip length decreases."""
|
||||||
|
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
||||||
|
|
||||||
|
cap_lossless = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=0)
|
||||||
|
cap_high = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=1)
|
||||||
|
cap_low = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=2)
|
||||||
|
|
||||||
|
assert cap_lossless.chip_length == 256
|
||||||
|
assert cap_high.chip_length == 512
|
||||||
|
assert cap_low.chip_length == 1024
|
||||||
|
|
||||||
|
# Smaller chip = more capacity
|
||||||
|
assert cap_lossless.usable_capacity_bytes > cap_high.usable_capacity_bytes
|
||||||
|
assert cap_high.usable_capacity_bytes > cap_low.usable_capacity_bytes
|
||||||
|
|
||||||
|
def test_spread_roundtrip_default_tier(self, carrier_wav_long):
|
||||||
|
"""Test spread spectrum embed/extract roundtrip (default tier 2)."""
|
||||||
from stegasoo.spread_steganography import (
|
from stegasoo.spread_steganography import (
|
||||||
embed_in_audio_spread,
|
embed_in_audio_spread,
|
||||||
extract_from_audio_spread,
|
extract_from_audio_spread,
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = b"Spread test"
|
payload = b"Spread test v2"
|
||||||
seed = b"\x42" * 32
|
seed = b"\x42" * 32
|
||||||
|
|
||||||
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed)
|
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed)
|
||||||
|
|
||||||
assert isinstance(stats, AudioEmbedStats)
|
assert isinstance(stats, AudioEmbedStats)
|
||||||
assert stats.embed_mode == EMBED_MODE_AUDIO_SPREAD
|
assert stats.embed_mode == EMBED_MODE_AUDIO_SPREAD
|
||||||
|
assert stats.chip_tier == 2
|
||||||
|
assert stats.chip_length == 1024
|
||||||
|
|
||||||
|
extracted = extract_from_audio_spread(stego_audio, seed)
|
||||||
|
assert extracted is not None
|
||||||
|
assert extracted == payload
|
||||||
|
|
||||||
|
def test_spread_roundtrip_tier_0(self, carrier_wav_long):
|
||||||
|
"""Test spread spectrum at tier 0 (chip=256, lossless)."""
|
||||||
|
from stegasoo.spread_steganography import (
|
||||||
|
embed_in_audio_spread,
|
||||||
|
extract_from_audio_spread,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = b"Lossless tier test with more data to embed for coverage"
|
||||||
|
seed = b"\x42" * 32
|
||||||
|
|
||||||
|
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed, chip_tier=0)
|
||||||
|
assert stats.chip_tier == 0
|
||||||
|
assert stats.chip_length == 256
|
||||||
|
|
||||||
|
extracted = extract_from_audio_spread(stego_audio, seed)
|
||||||
|
assert extracted is not None
|
||||||
|
assert extracted == payload
|
||||||
|
|
||||||
|
def test_spread_roundtrip_tier_1(self, carrier_wav_long):
|
||||||
|
"""Test spread spectrum at tier 1 (chip=512, high lossy)."""
|
||||||
|
from stegasoo.spread_steganography import (
|
||||||
|
embed_in_audio_spread,
|
||||||
|
extract_from_audio_spread,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = b"High lossy tier test"
|
||||||
|
seed = b"\x42" * 32
|
||||||
|
|
||||||
|
stego_audio, stats = embed_in_audio_spread(payload, carrier_wav_long, seed, chip_tier=1)
|
||||||
|
assert stats.chip_tier == 1
|
||||||
|
assert stats.chip_length == 512
|
||||||
|
|
||||||
extracted = extract_from_audio_spread(stego_audio, seed)
|
extracted = extract_from_audio_spread(stego_audio, seed)
|
||||||
assert extracted is not None
|
assert extracted is not None
|
||||||
@@ -258,6 +361,258 @@ class TestAudioSpread:
|
|||||||
extracted = extract_from_audio_spread(stego_audio, wrong_seed)
|
extracted = extract_from_audio_spread(stego_audio, wrong_seed)
|
||||||
assert extracted is None or extracted != payload
|
assert extracted is None or extracted != payload
|
||||||
|
|
||||||
|
def test_per_channel_stereo_roundtrip(self, carrier_wav_stereo_long):
|
||||||
|
"""Test that stereo per-channel embedding/extraction works."""
|
||||||
|
from stegasoo.spread_steganography import (
|
||||||
|
embed_in_audio_spread,
|
||||||
|
extract_from_audio_spread,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = b"Stereo per-channel test"
|
||||||
|
seed = b"\xAB" * 32
|
||||||
|
|
||||||
|
stego_audio, stats = embed_in_audio_spread(
|
||||||
|
payload, carrier_wav_stereo_long, seed, chip_tier=0
|
||||||
|
)
|
||||||
|
assert stats.channels == 2
|
||||||
|
assert stats.embeddable_channels == 2
|
||||||
|
|
||||||
|
extracted = extract_from_audio_spread(stego_audio, seed)
|
||||||
|
assert extracted is not None
|
||||||
|
assert extracted == payload
|
||||||
|
|
||||||
|
def test_per_channel_preserves_spatial_mix(self, carrier_wav_stereo_long):
|
||||||
|
"""Verify that per-channel embedding doesn't destroy the spatial mix.
|
||||||
|
|
||||||
|
The difference between left and right channels should be preserved
|
||||||
|
(not zeroed out as the old mono-broadcast approach would do).
|
||||||
|
"""
|
||||||
|
from stegasoo.spread_steganography import embed_in_audio_spread
|
||||||
|
|
||||||
|
payload = b"Spatial preservation test"
|
||||||
|
seed = b"\xCD" * 32
|
||||||
|
|
||||||
|
# Read original
|
||||||
|
orig_samples, _ = sf.read(io.BytesIO(carrier_wav_stereo_long), dtype="float64", always_2d=True)
|
||||||
|
orig_diff = orig_samples[:, 0] - orig_samples[:, 1]
|
||||||
|
|
||||||
|
# Embed
|
||||||
|
stego_bytes, _ = embed_in_audio_spread(
|
||||||
|
payload, carrier_wav_stereo_long, seed, chip_tier=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read stego
|
||||||
|
stego_samples, _ = sf.read(io.BytesIO(stego_bytes), dtype="float64", always_2d=True)
|
||||||
|
stego_diff = stego_samples[:, 0] - stego_samples[:, 1]
|
||||||
|
|
||||||
|
# The channel difference should not be identical (embedding adds different
|
||||||
|
# noise per channel), but should be very close (embedding is subtle)
|
||||||
|
# With the old mono-broadcast approach, stego_diff would equal orig_diff
|
||||||
|
# exactly in unmodified regions but differ where data was embedded.
|
||||||
|
# With per-channel, both channels get independent modifications.
|
||||||
|
correlation = np.corrcoef(orig_diff, stego_diff)[0, 1]
|
||||||
|
assert correlation > 0.95, f"Spatial mix correlation too low: {correlation}"
|
||||||
|
|
||||||
|
def test_capacity_scales_with_channels(self, carrier_wav_long, carrier_wav_stereo_long):
|
||||||
|
"""Stereo should have roughly double the capacity of mono."""
|
||||||
|
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
||||||
|
|
||||||
|
mono_cap = calculate_audio_spread_capacity(carrier_wav_long, chip_tier=0)
|
||||||
|
stereo_cap = calculate_audio_spread_capacity(carrier_wav_stereo_long, chip_tier=0)
|
||||||
|
|
||||||
|
# Stereo should be ~1.5-2.2x mono (not exact because header is ch0 only
|
||||||
|
# and the files have slightly different durations/sample rates)
|
||||||
|
ratio = stereo_cap.usable_capacity_bytes / mono_cap.usable_capacity_bytes
|
||||||
|
assert ratio > 1.3, f"Stereo/mono capacity ratio too low: {ratio}"
|
||||||
|
|
||||||
|
def test_lfe_skip_5_1(self, carrier_wav_5_1):
|
||||||
|
"""LFE channel (index 3) should be unmodified in 6-channel audio."""
|
||||||
|
from stegasoo.spread_steganography import embed_in_audio_spread
|
||||||
|
|
||||||
|
payload = b"LFE skip test"
|
||||||
|
seed = b"\xEE" * 32
|
||||||
|
|
||||||
|
# Read original LFE channel
|
||||||
|
orig_samples, _ = sf.read(io.BytesIO(carrier_wav_5_1), dtype="float64", always_2d=True)
|
||||||
|
orig_lfe = orig_samples[:, 3].copy()
|
||||||
|
|
||||||
|
stego_bytes, stats = embed_in_audio_spread(
|
||||||
|
payload, carrier_wav_5_1, seed, chip_tier=0
|
||||||
|
)
|
||||||
|
assert stats.embeddable_channels == 5 # 6 channels - 1 LFE = 5
|
||||||
|
|
||||||
|
stego_samples, _ = sf.read(io.BytesIO(stego_bytes), dtype="float64", always_2d=True)
|
||||||
|
stego_lfe = stego_samples[:, 3]
|
||||||
|
|
||||||
|
# LFE channel should be completely unmodified
|
||||||
|
np.testing.assert_array_equal(orig_lfe, stego_lfe)
|
||||||
|
|
||||||
|
def test_lfe_skip_roundtrip(self, carrier_wav_5_1):
|
||||||
|
"""5.1 audio embed/extract roundtrip with LFE skipping."""
|
||||||
|
from stegasoo.spread_steganography import (
|
||||||
|
embed_in_audio_spread,
|
||||||
|
extract_from_audio_spread,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = b"5.1 surround test"
|
||||||
|
seed = b"\xEE" * 32
|
||||||
|
|
||||||
|
stego_bytes, stats = embed_in_audio_spread(
|
||||||
|
payload, carrier_wav_5_1, seed, chip_tier=0
|
||||||
|
)
|
||||||
|
assert stats.channels == 6
|
||||||
|
assert stats.embeddable_channels == 5
|
||||||
|
|
||||||
|
extracted = extract_from_audio_spread(stego_bytes, seed)
|
||||||
|
assert extracted is not None
|
||||||
|
assert extracted == payload
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HEADER V2 TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeaderV2:
|
||||||
|
"""Tests for v2 header construction and parsing."""
|
||||||
|
|
||||||
|
def test_header_v2_build_parse_roundtrip(self):
|
||||||
|
from stegasoo.spread_steganography import _build_header_v2, _parse_header
|
||||||
|
|
||||||
|
data_length = 12345
|
||||||
|
chip_tier = 1
|
||||||
|
num_ch = 2
|
||||||
|
lfe_skipped = False
|
||||||
|
|
||||||
|
header = _build_header_v2(data_length, chip_tier, num_ch, lfe_skipped)
|
||||||
|
assert len(header) == 20
|
||||||
|
|
||||||
|
magic_valid, version, length, tier, nch, lfe = _parse_header(header)
|
||||||
|
assert magic_valid
|
||||||
|
assert version == 2
|
||||||
|
assert length == data_length
|
||||||
|
assert tier == chip_tier
|
||||||
|
assert nch == num_ch
|
||||||
|
assert lfe is False
|
||||||
|
|
||||||
|
def test_header_v2_with_lfe_flag(self):
|
||||||
|
from stegasoo.spread_steganography import _build_header_v2, _parse_header
|
||||||
|
|
||||||
|
header = _build_header_v2(999, 0, 5, lfe_skipped=True)
|
||||||
|
magic_valid, version, length, tier, nch, lfe = _parse_header(header)
|
||||||
|
assert magic_valid
|
||||||
|
assert version == 2
|
||||||
|
assert length == 999
|
||||||
|
assert tier == 0
|
||||||
|
assert nch == 5
|
||||||
|
assert lfe is True
|
||||||
|
|
||||||
|
def test_header_v0_build_parse(self):
|
||||||
|
from stegasoo.spread_steganography import _build_header_v0, _parse_header
|
||||||
|
|
||||||
|
header = _build_header_v0(4567)
|
||||||
|
assert len(header) == 16
|
||||||
|
|
||||||
|
magic_valid, version, length, tier, nch, lfe = _parse_header(header)
|
||||||
|
assert magic_valid
|
||||||
|
assert version == 0
|
||||||
|
assert length == 4567
|
||||||
|
assert tier is None
|
||||||
|
assert nch is None
|
||||||
|
|
||||||
|
def test_header_bad_magic(self):
|
||||||
|
from stegasoo.spread_steganography import _parse_header
|
||||||
|
|
||||||
|
bad_header = b"XXXX" + b"\x00" * 16
|
||||||
|
magic_valid, version, length, tier, nch, lfe = _parse_header(bad_header)
|
||||||
|
assert not magic_valid
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ROUND-ROBIN BIT DISTRIBUTION TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoundRobin:
|
||||||
|
"""Tests for round-robin bit distribution."""
|
||||||
|
|
||||||
|
def test_distribute_and_collect_identity(self):
|
||||||
|
from stegasoo.spread_steganography import (
|
||||||
|
_collect_bits_round_robin,
|
||||||
|
_distribute_bits_round_robin,
|
||||||
|
)
|
||||||
|
|
||||||
|
bits = [1, 0, 1, 1, 0, 0, 1, 0, 1, 1]
|
||||||
|
for num_ch in [1, 2, 3, 4, 5]:
|
||||||
|
per_ch = _distribute_bits_round_robin(bits, num_ch)
|
||||||
|
assert len(per_ch) == num_ch
|
||||||
|
reassembled = _collect_bits_round_robin(per_ch)
|
||||||
|
assert reassembled == bits, f"Failed for {num_ch} channels"
|
||||||
|
|
||||||
|
def test_distribute_round_robin_ordering(self):
|
||||||
|
from stegasoo.spread_steganography import _distribute_bits_round_robin
|
||||||
|
|
||||||
|
bits = [0, 1, 2, 3, 4, 5] # using ints for clarity
|
||||||
|
per_ch = _distribute_bits_round_robin(bits, 3)
|
||||||
|
# ch0: bits 0, 3 ch1: bits 1, 4 ch2: bits 2, 5
|
||||||
|
assert per_ch[0] == [0, 3]
|
||||||
|
assert per_ch[1] == [1, 4]
|
||||||
|
assert per_ch[2] == [2, 5]
|
||||||
|
|
||||||
|
def test_distribute_uneven(self):
|
||||||
|
from stegasoo.spread_steganography import (
|
||||||
|
_collect_bits_round_robin,
|
||||||
|
_distribute_bits_round_robin,
|
||||||
|
)
|
||||||
|
|
||||||
|
bits = [0, 1, 2, 3, 4] # 5 bits across 3 channels
|
||||||
|
per_ch = _distribute_bits_round_robin(bits, 3)
|
||||||
|
assert per_ch[0] == [0, 3]
|
||||||
|
assert per_ch[1] == [1, 4]
|
||||||
|
assert per_ch[2] == [2]
|
||||||
|
|
||||||
|
reassembled = _collect_bits_round_robin(per_ch)
|
||||||
|
assert reassembled == bits
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CHANNEL MANAGEMENT TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelManagement:
|
||||||
|
"""Tests for embeddable channel selection."""
|
||||||
|
|
||||||
|
def test_mono(self):
|
||||||
|
from stegasoo.spread_steganography import _embeddable_channels
|
||||||
|
|
||||||
|
assert _embeddable_channels(1) == [0]
|
||||||
|
|
||||||
|
def test_stereo(self):
|
||||||
|
from stegasoo.spread_steganography import _embeddable_channels
|
||||||
|
|
||||||
|
assert _embeddable_channels(2) == [0, 1]
|
||||||
|
|
||||||
|
def test_5_1_skips_lfe(self):
|
||||||
|
from stegasoo.spread_steganography import _embeddable_channels
|
||||||
|
|
||||||
|
channels = _embeddable_channels(6)
|
||||||
|
assert channels == [0, 1, 2, 4, 5]
|
||||||
|
assert 3 not in channels # LFE skipped
|
||||||
|
|
||||||
|
def test_7_1_skips_lfe(self):
|
||||||
|
from stegasoo.spread_steganography import _embeddable_channels
|
||||||
|
|
||||||
|
channels = _embeddable_channels(8)
|
||||||
|
assert 3 not in channels
|
||||||
|
assert len(channels) == 7
|
||||||
|
|
||||||
|
def test_quad_no_skip(self):
|
||||||
|
from stegasoo.spread_steganography import _embeddable_channels
|
||||||
|
|
||||||
|
# 4 channels < 6, so no LFE skip
|
||||||
|
assert _embeddable_channels(4) == [0, 1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# FORMAT DETECTION TESTS
|
# FORMAT DETECTION TESTS
|
||||||
@@ -423,6 +778,36 @@ class TestIntegration:
|
|||||||
|
|
||||||
assert result.message == "Spread integration test"
|
assert result.message == "Spread integration test"
|
||||||
|
|
||||||
|
def test_spread_encode_decode_with_chip_tier(
|
||||||
|
self, carrier_wav_spread_integration, reference_photo
|
||||||
|
):
|
||||||
|
"""Test spread spectrum with explicit chip tier."""
|
||||||
|
from stegasoo.decode import decode_audio
|
||||||
|
from stegasoo.encode import encode_audio
|
||||||
|
|
||||||
|
stego_audio, stats = encode_audio(
|
||||||
|
message="Tier 0 integration",
|
||||||
|
reference_photo=reference_photo,
|
||||||
|
carrier_audio=carrier_wav_spread_integration,
|
||||||
|
passphrase="test words here now",
|
||||||
|
pin="123456",
|
||||||
|
embed_mode="audio_spread",
|
||||||
|
chip_tier=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stats.chip_tier == 0
|
||||||
|
assert stats.chip_length == 256
|
||||||
|
|
||||||
|
result = decode_audio(
|
||||||
|
stego_audio=stego_audio,
|
||||||
|
reference_photo=reference_photo,
|
||||||
|
passphrase="test words here now",
|
||||||
|
pin="123456",
|
||||||
|
embed_mode="audio_spread",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.message == "Tier 0 integration"
|
||||||
|
|
||||||
def test_auto_detect_lsb(self, carrier_wav, reference_photo):
|
def test_auto_detect_lsb(self, carrier_wav, reference_photo):
|
||||||
"""Test auto-detection finds LSB encoded audio."""
|
"""Test auto-detection finds LSB encoded audio."""
|
||||||
from stegasoo.decode import decode_audio
|
from stegasoo.decode import decode_audio
|
||||||
@@ -446,3 +831,32 @@ class TestIntegration:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.message == "Auto-detect test"
|
assert result.message == "Auto-detect test"
|
||||||
|
|
||||||
|
def test_spread_with_real_speech(self, speech_wav, reference_photo):
|
||||||
|
"""Test spread spectrum with real speech audio from test_data."""
|
||||||
|
from stegasoo.decode import decode_audio
|
||||||
|
from stegasoo.encode import encode_audio
|
||||||
|
|
||||||
|
message = "Hidden in a speech about elitism"
|
||||||
|
|
||||||
|
stego_audio, stats = encode_audio(
|
||||||
|
message=message,
|
||||||
|
reference_photo=reference_photo,
|
||||||
|
carrier_audio=speech_wav,
|
||||||
|
passphrase="test words here now",
|
||||||
|
pin="123456",
|
||||||
|
embed_mode="audio_spread",
|
||||||
|
chip_tier=0, # lossless tier for max capacity
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stats.chip_tier == 0
|
||||||
|
|
||||||
|
result = decode_audio(
|
||||||
|
stego_audio=stego_audio,
|
||||||
|
reference_photo=reference_photo,
|
||||||
|
passphrase="test words here now",
|
||||||
|
pin="123456",
|
||||||
|
embed_mode="audio_spread",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.message == message
|
||||||
|
|||||||
Reference in New Issue
Block a user