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