""" Subprocess Steganography Wrapper (v4.0.0) Runs stegasoo operations in isolated subprocesses to prevent crashes from taking down the Flask server. CHANGES in v4.0.0: - Added channel_key parameter to encode() and decode() methods - Channel keys enable deployment/group isolation Usage: from subprocess_stego import SubprocessStego stego = SubprocessStego() # Encode with channel key result = stego.encode( carrier_data=carrier_bytes, reference_data=ref_bytes, message="secret message", passphrase="my passphrase", pin="123456", embed_mode="dct", channel_key="auto", # or "none", or explicit key ) if result.success: stego_bytes = result.stego_data extension = result.extension else: error_message = result.error # Decode result = stego.decode( stego_data=stego_bytes, reference_data=ref_bytes, passphrase="my passphrase", pin="123456", channel_key="auto", ) # Compare modes (capacity) result = stego.compare_modes(carrier_bytes) """ import base64 import json import subprocess import sys import tempfile import uuid from dataclasses import dataclass from pathlib import Path from typing import Any # Default timeout for operations (seconds) DEFAULT_TIMEOUT = 120 # Path to worker script - adjust if needed WORKER_SCRIPT = Path(__file__).parent / "stego_worker.py" @dataclass class EncodeResult: """Result from encode operation.""" success: bool stego_data: bytes | None = None filename: str | None = None stats: dict[str, Any] | None = None # Channel info (v4.0.0) channel_mode: str | None = None channel_fingerprint: str | None = None error: str | None = None error_type: str | None = None @dataclass class DecodeResult: """Result from decode operation.""" success: bool is_file: bool = False message: str | None = None file_data: bytes | None = None filename: str | None = None mime_type: str | None = None error: str | None = None error_type: str | None = None @dataclass class CompareResult: """Result from compare_modes operation.""" success: bool width: int = 0 height: int = 0 lsb: dict[str, Any] | None = None dct: dict[str, Any] | None = None error: str | None = None @dataclass class CapacityResult: """Result from capacity check operation.""" success: bool fits: bool = False payload_size: int = 0 capacity: int = 0 usage_percent: float = 0.0 headroom: int = 0 mode: str = "" error: str | None = None @dataclass class AudioEncodeResult: """Result from audio encode operation (v4.3.0).""" success: bool stego_data: bytes | None = None stats: dict[str, Any] | None = None channel_mode: str | None = None channel_fingerprint: str | None = None error: str | None = None error_type: str | None = None @dataclass class AudioInfoResult: """Result from audio info operation (v4.3.0).""" success: bool sample_rate: int = 0 channels: int = 0 duration_seconds: float = 0.0 num_samples: int = 0 format: str = "" bit_depth: int | None = None capacity_lsb: int = 0 capacity_spread: int = 0 error: str | None = None @dataclass class ChannelStatusResult: """Result from channel status check (v4.0.0).""" success: bool mode: str = "public" configured: bool = False fingerprint: str | None = None source: str | None = None key: str | None = None error: str | None = None class SubprocessStego: """ Subprocess-isolated steganography operations. All operations run in a separate Python process. If jpeglib or scipy crashes, only the subprocess dies - Flask keeps running. """ def __init__( self, worker_path: Path | None = None, python_executable: str | None = None, timeout: int = DEFAULT_TIMEOUT, ): """ Initialize subprocess wrapper. Args: worker_path: Path to stego_worker.py (default: same directory) python_executable: Python interpreter to use (default: same as current) timeout: Default timeout in seconds """ self.worker_path = worker_path or WORKER_SCRIPT self.python = python_executable or sys.executable self.timeout = timeout if not self.worker_path.exists(): raise FileNotFoundError(f"Worker script not found: {self.worker_path}") def _run_worker(self, params: dict[str, Any], timeout: int | None = None) -> dict[str, Any]: """ Run the worker subprocess with given parameters. Args: params: Dictionary of parameters (will be JSON-encoded) timeout: Operation timeout in seconds Returns: Dictionary with results from worker """ timeout = timeout or self.timeout input_json = json.dumps(params) try: result = subprocess.run( [self.python, str(self.worker_path)], input=input_json, capture_output=True, text=True, timeout=timeout, cwd=str(self.worker_path.parent), ) if result.returncode != 0: # Worker crashed return { "success": False, "error": f"Worker crashed (exit code {result.returncode})", "stderr": result.stderr, } if not result.stdout.strip(): return { "success": False, "error": "Worker returned empty output", "stderr": result.stderr, } return json.loads(result.stdout) except subprocess.TimeoutExpired: return { "success": False, "error": f"Operation timed out after {timeout} seconds", "error_type": "TimeoutError", } except json.JSONDecodeError as e: return { "success": False, "error": f"Invalid JSON from worker: {e}", "raw_output": result.stdout if "result" in dir() else None, } except Exception as e: return { "success": False, "error": str(e), "error_type": type(e).__name__, } def encode( 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 = "lsb", dct_output_format: str = "png", dct_color_mode: str = "color", # Channel key (v4.0.0) channel_key: str | None = "auto", timeout: int | None = None, # Progress file (v4.1.2) progress_file: str | None = None, ) -> EncodeResult: """ Encode a message or file into an image. Args: carrier_data: Carrier image bytes 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: 'lsb' or 'dct' dct_output_format: 'png' or 'jpeg' (for DCT mode) dct_color_mode: 'grayscale' or 'color' (for DCT mode) channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0) timeout: Operation timeout in seconds Returns: EncodeResult with stego_data and extension on success """ params = { "operation": "encode", "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, "dct_output_format": dct_output_format, "dct_color_mode": dct_color_mode, "channel_key": channel_key, # v4.0.0 "progress_file": progress_file, # v4.1.2 } 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 result = self._run_worker(params, timeout) if result.get("success"): return EncodeResult( success=True, stego_data=base64.b64decode(result["stego_b64"]), filename=result.get("filename"), stats=result.get("stats"), channel_mode=result.get("channel_mode"), channel_fingerprint=result.get("channel_fingerprint"), ) else: return EncodeResult( success=False, error=result.get("error", "Unknown error"), error_type=result.get("error_type"), ) def decode( 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 = "auto", # Channel key (v4.0.0) channel_key: str | None = "auto", timeout: int | None = None, # Progress tracking (v4.1.5) progress_file: str | None = None, ) -> DecodeResult: """ Decode a message or file from a stego image. Args: stego_data: Stego image 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: 'auto', 'lsb', or 'dct' channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0) timeout: Operation timeout in seconds progress_file: Path to write progress updates (v4.1.5) Returns: DecodeResult with message or file_data on success """ params = { "operation": "decode", "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, # v4.0.0 "progress_file": progress_file, # v4.1.5 } 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) 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 compare_modes( self, carrier_data: bytes, timeout: int | None = None, ) -> CompareResult: """ Compare LSB and DCT capacity for a carrier image. Args: carrier_data: Carrier image bytes timeout: Operation timeout in seconds Returns: CompareResult with capacity information """ params = { "operation": "compare", "carrier_b64": base64.b64encode(carrier_data).decode("ascii"), } result = self._run_worker(params, timeout) if result.get("success"): comparison = result.get("comparison", {}) return CompareResult( success=True, width=comparison.get("width", 0), height=comparison.get("height", 0), lsb=comparison.get("lsb"), dct=comparison.get("dct"), ) else: return CompareResult( success=False, error=result.get("error", "Unknown error"), ) def check_capacity( self, carrier_data: bytes, payload_size: int, embed_mode: str = "lsb", timeout: int | None = None, ) -> CapacityResult: """ Check if a payload will fit in the carrier. Args: carrier_data: Carrier image bytes payload_size: Size of payload in bytes embed_mode: 'lsb' or 'dct' timeout: Operation timeout in seconds Returns: CapacityResult with fit information """ params = { "operation": "capacity", "carrier_b64": base64.b64encode(carrier_data).decode("ascii"), "payload_size": payload_size, "embed_mode": embed_mode, } result = self._run_worker(params, timeout) if result.get("success"): r = result.get("result", {}) return CapacityResult( success=True, fits=r.get("fits", False), payload_size=r.get("payload_size", 0), capacity=r.get("capacity", 0), usage_percent=r.get("usage_percent", 0.0), headroom=r.get("headroom", 0), mode=r.get("mode", embed_mode), ) else: return CapacityResult( success=False, error=result.get("error", "Unknown error"), ) # ========================================================================= # Audio Steganography (v4.3.0) # ========================================================================= def encode_audio( self, carrier_data: bytes, reference_data: bytes, message: str | None = None, file_data: bytes | None = None, file_name: str | None = None, file_mime: str | None = None, passphrase: str = "", pin: str | None = None, rsa_key_data: bytes | None = None, rsa_password: str | None = None, embed_mode: str = "audio_lsb", channel_key: str | None = "auto", timeout: int | None = None, progress_file: str | None = None, chip_tier: int | None = None, ) -> AudioEncodeResult: """ Encode a message or file into an audio carrier. Args: carrier_data: Carrier audio bytes (WAV, FLAC, MP3, etc.) reference_data: Reference photo bytes message: Text message to encode (if not file) file_data: File bytes to encode (if not message) file_name: Original filename (for file payload) file_mime: MIME type (for file payload) passphrase: Encryption passphrase pin: Optional PIN rsa_key_data: Optional RSA key PEM bytes rsa_password: RSA key password if encrypted embed_mode: 'audio_lsb' or 'audio_spread' channel_key: 'auto', 'none', or explicit key timeout: Operation timeout (default 300s for audio) progress_file: Path to write progress updates Returns: AudioEncodeResult with stego audio data on success """ params = { "operation": "encode_audio", "carrier_b64": base64.b64encode(carrier_data).decode("ascii"), "reference_b64": base64.b64encode(reference_data).decode("ascii"), "message": message, "passphrase": passphrase, "pin": pin, "embed_mode": embed_mode, "channel_key": channel_key, "progress_file": progress_file, "chip_tier": chip_tier, } if file_data: params["file_b64"] = base64.b64encode(file_data).decode("ascii") params["file_name"] = file_name params["file_mime"] = file_mime if rsa_key_data: params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii") params["rsa_password"] = rsa_password # Audio operations can be slower (especially spread spectrum) result = self._run_worker(params, timeout or 300) if result.get("success"): return AudioEncodeResult( success=True, stego_data=base64.b64decode(result["stego_b64"]), stats=result.get("stats"), channel_mode=result.get("channel_mode"), channel_fingerprint=result.get("channel_fingerprint"), ) else: return AudioEncodeResult( success=False, error=result.get("error", "Unknown error"), error_type=result.get("error_type"), ) def decode_audio( self, stego_data: bytes, reference_data: bytes, passphrase: str = "", pin: str | None = None, rsa_key_data: bytes | None = None, rsa_password: str | None = None, embed_mode: str = "audio_auto", channel_key: str | None = "auto", timeout: int | None = None, progress_file: str | None = None, ) -> DecodeResult: """ Decode a message or file from stego audio. Args: stego_data: Stego audio bytes reference_data: Reference photo bytes passphrase: Decryption passphrase pin: Optional PIN rsa_key_data: Optional RSA key PEM bytes rsa_password: RSA key password if encrypted embed_mode: 'audio_auto', 'audio_lsb', or 'audio_spread' channel_key: 'auto', 'none', or explicit key timeout: Operation timeout (default 300s for audio) progress_file: Path to write progress updates Returns: DecodeResult with message or file_data on success """ params = { "operation": "decode_audio", "stego_b64": base64.b64encode(stego_data).decode("ascii"), "reference_b64": base64.b64encode(reference_data).decode("ascii"), "passphrase": passphrase, "pin": pin, "embed_mode": embed_mode, "channel_key": channel_key, "progress_file": progress_file, } if rsa_key_data: params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii") params["rsa_password"] = rsa_password result = self._run_worker(params, timeout or 300) if result.get("success"): if result.get("is_file"): return DecodeResult( success=True, is_file=True, file_data=base64.b64decode(result["file_b64"]), filename=result.get("filename"), mime_type=result.get("mime_type"), ) else: return DecodeResult( success=True, is_file=False, message=result.get("message"), ) else: return DecodeResult( success=False, error=result.get("error", "Unknown error"), error_type=result.get("error_type"), ) def audio_info( self, audio_data: bytes, timeout: int | None = None, ) -> AudioInfoResult: """ Get audio file information and steganographic capacity. Args: audio_data: Audio file bytes timeout: Operation timeout in seconds Returns: AudioInfoResult with metadata and capacity info """ params = { "operation": "audio_info", "audio_b64": base64.b64encode(audio_data).decode("ascii"), } result = self._run_worker(params, timeout) if result.get("success"): info = result.get("info", {}) return AudioInfoResult( success=True, sample_rate=info.get("sample_rate", 0), channels=info.get("channels", 0), duration_seconds=info.get("duration_seconds", 0.0), num_samples=info.get("num_samples", 0), format=info.get("format", ""), bit_depth=info.get("bit_depth"), capacity_lsb=info.get("capacity_lsb", 0), capacity_spread=info.get("capacity_spread", 0), ) else: return AudioInfoResult( success=False, error=result.get("error", "Unknown error"), ) def get_channel_status( self, reveal: bool = False, timeout: int | None = None, ) -> ChannelStatusResult: """ Get current channel key status (v4.0.0). Args: reveal: Include full key in response timeout: Operation timeout in seconds Returns: ChannelStatusResult with channel info """ params = { "operation": "channel_status", "reveal": reveal, } result = self._run_worker(params, timeout) if result.get("success"): status = result.get("status", {}) return ChannelStatusResult( success=True, mode=status.get("mode", "public"), configured=status.get("configured", False), fingerprint=status.get("fingerprint"), source=status.get("source"), key=status.get("key") if reveal else None, ) else: return ChannelStatusResult( success=False, error=result.get("error", "Unknown error"), ) # Convenience function for quick usage _default_stego: SubprocessStego | None = None def get_subprocess_stego() -> SubprocessStego: """Get or create default SubprocessStego instance.""" global _default_stego if _default_stego is None: _default_stego = SubprocessStego() return _default_stego # ============================================================================= # Progress File Utilities (v4.1.2) # ============================================================================= def generate_job_id() -> str: """Generate a unique job ID for tracking encode/decode operations.""" return str(uuid.uuid4())[:8] def get_progress_file_path(job_id: str) -> str: """Get the progress file path for a job ID.""" return str(Path(tempfile.gettempdir()) / f"stegasoo_progress_{job_id}.json") def read_progress(job_id: str) -> dict | None: """ Read progress from file for a job ID. Returns: Progress dict with current, total, percent, phase, or None if not found """ progress_file = get_progress_file_path(job_id) try: with open(progress_file) as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return None def cleanup_progress_file(job_id: str) -> None: """Remove progress file for a completed job.""" progress_file = get_progress_file_path(job_id) try: Path(progress_file).unlink(missing_ok=True) except Exception: pass