From 6b21190f9768e4c534799687a08611ef3f0c1118 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 2 Jan 2026 17:17:38 -0500 Subject: [PATCH] Lint cleanup: ruff fixes across entire codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip trailing whitespace from all Python files - Fix import sorting (I001) across all modules - Convert Optional[X] to X | None syntax (UP045) - Remove unused imports (F401) - Convert lambda assignments to def functions (E731) - Add TYPE_CHECKING import for forward references - Update pyproject.toml ruff config: - Move select/ignore to [tool.ruff.lint] section - Add per-file ignores for DCT colorspace naming (N803/N806) - Add per-file ignores for __init__.py import structure (E402) - Exclude defunct test_routes.py - Remove frontends/web/test_routes.py (defunct debug snippet) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- check_scipy.py | 38 +-- debug_jpegio.py | 34 +-- frontends/api/main.py | 368 ++++++++++++----------- frontends/cli/main.py | 474 ++++++++++++++---------------- frontends/web/app.py | 345 +++++++++++----------- frontends/web/stego_worker.py | 70 ++--- frontends/web/subprocess_stego.py | 183 ++++++------ frontends/web/test_routes.py | 90 ------ minimal_flask_crash.py | 76 ++--- pyproject.toml | 9 + src/main.py | 2 +- src/stegasoo/__init__.py | 174 ++++++----- src/stegasoo/batch.py | 240 +++++++-------- src/stegasoo/channel.py | 145 +++++---- src/stegasoo/cli.py | 144 +++++---- src/stegasoo/compression.py | 53 ++-- src/stegasoo/constants.py | 21 +- src/stegasoo/crypto.py | 232 +++++++-------- src/stegasoo/dct_steganography.py | 267 +++++++++-------- src/stegasoo/debug.py | 52 ++-- src/stegasoo/decode.py | 81 +++-- src/stegasoo/encode.py | 85 +++--- src/stegasoo/exceptions.py | 6 +- src/stegasoo/generate.py | 74 ++--- src/stegasoo/image_utils.py | 58 ++-- src/stegasoo/keygen.py | 163 +++++----- src/stegasoo/models.py | 108 ++++--- src/stegasoo/qr_utils.py | 178 ++++++----- src/stegasoo/steganography.py | 286 +++++++++--------- src/stegasoo/utils.py | 137 +++++---- src/stegasoo/validation.py | 182 ++++++------ test_compare_capacity_flow.py | 26 +- test_dct_crash.py | 52 ++-- tests/test_batch.py | 111 +++---- tests/test_compression.py | 59 ++-- tests/test_stegasoo.py | 35 ++- 36 files changed, 2275 insertions(+), 2383 deletions(-) delete mode 100644 frontends/web/test_routes.py diff --git a/check_scipy.py b/check_scipy.py index 75abb7d..e073d0b 100644 --- a/check_scipy.py +++ b/check_scipy.py @@ -51,30 +51,30 @@ print("Testing scipy DCT...") try: from scipy.fftpack import dct, idct import numpy as np - + # Create test array test = np.random.rand(8, 8).astype(np.float64) print(f"Input array shape: {test.shape}, dtype: {test.dtype}") - + # Test 1D DCT row = test[0, :] result = dct(row, norm='ortho') print(f"1D DCT result shape: {result.shape}, dtype: {result.dtype}") - + # Test 2D DCT (the potentially problematic operation) result2d = dct(dct(test.T, norm='ortho').T, norm='ortho') print(f"2D DCT result shape: {result2d.shape}, dtype: {result2d.dtype}") - + # Test inverse recovered = idct(idct(result2d.T, norm='ortho').T, norm='ortho') error = np.max(np.abs(test - recovered)) print(f"Round-trip error: {error}") - + if error < 1e-10: print("βœ“ scipy DCT working correctly") else: print("⚠ scipy DCT has precision issues") - + except Exception as e: print(f"βœ— scipy DCT failed: {e}") import traceback @@ -90,11 +90,11 @@ try: from scipy.fftpack import dct, idct import numpy as np import gc - + # Simulate processing many 8x8 blocks large_array = np.random.rand(512, 512).astype(np.float64) print(f"Large array shape: {large_array.shape}, size: {large_array.nbytes} bytes") - + count = 0 for y in range(0, 512, 8): for x in range(0, 512, 8): @@ -103,14 +103,14 @@ try: recovered = idct(idct(dct_block.T, norm='ortho').T, norm='ortho') large_array[y:y+8, x:x+8] = recovered count += 1 - + print(f"Processed {count} blocks successfully") - + del large_array gc.collect() - + print("βœ“ Large array processing completed") - + except Exception as e: print(f"βœ— Large array processing failed: {e}") import traceback @@ -125,26 +125,26 @@ print("Testing PIL with large image...") try: from PIL import Image import io - + # Create a large test image img = Image.new('RGB', (4000, 3000), color=(128, 128, 128)) - + # Save to bytes buffer = io.BytesIO() img.save(buffer, format='PNG') img_bytes = buffer.getvalue() print(f"Test image size: {len(img_bytes)} bytes") - + # Re-open and process buffer2 = io.BytesIO(img_bytes) img2 = Image.open(buffer2) print(f"Re-opened image: {img2.size}, mode: {img2.mode}") - + # Convert to numpy array import numpy as np arr = np.array(img2) print(f"NumPy array: {arr.shape}, dtype: {arr.dtype}") - + # Clean up img.close() img2.close() @@ -152,9 +152,9 @@ try: buffer2.close() del arr gc.collect() - + print("βœ“ PIL large image test completed") - + except Exception as e: print(f"βœ— PIL test failed: {e}") import traceback diff --git a/debug_jpegio.py b/debug_jpegio.py index 2a7ab89..1ba9a3d 100644 --- a/debug_jpegio.py +++ b/debug_jpegio.py @@ -69,13 +69,13 @@ def main(): print("\nOptional: add passphrase, pin, key path") print(" python debug_jpegio.py stego.jpg ref.jpg 'passphrase' '123456' key.pem") sys.exit(1) - + stego_path = sys.argv[1] ref_path = sys.argv[2] passphrase = sys.argv[3] if len(sys.argv) > 3 else "test" pin = sys.argv[4] if len(sys.argv) > 4 else "" key_path = sys.argv[5] if len(sys.argv) > 5 else None - + print(f"\n{'='*60}") print("JPEGIO DCT EXTRACTION DEBUG") print(f"{'='*60}") @@ -84,7 +84,7 @@ def main(): print(f"Passphrase: '{passphrase}'") print(f"PIN: '{pin}'") print(f"Key: {key_path}") - + # Load stego image with jpegio print(f"\n[1] Loading stego image with jpegio...") try: @@ -96,7 +96,7 @@ def main(): except Exception as e: print(f" βœ— Failed: {e}") sys.exit(1) - + # Get coefficient array (channel 0) coef_array = jpeg.coef_arrays[0] print(f"\n[2] Coefficient array analysis...") @@ -104,21 +104,21 @@ def main(): print(f" Non-zero coefficients: {np.count_nonzero(coef_array)}") print(f" Min value: {coef_array.min()}") print(f" Max value: {coef_array.max()}") - + # Get usable positions print(f"\n[3] Finding usable positions (|coef| >= 2, non-DC)...") positions = get_usable_positions(coef_array) print(f" Usable positions: {len(positions)}") print(f" Capacity: ~{len(positions) // 8} bytes") - + # Generate seed (this needs to match the encode seed!) print(f"\n[4] Generating seed...") - + # Load reference photo ref_data = Path(ref_path).read_bytes() ref_hash = hashlib.sha256(ref_data).digest() print(f" Reference hash: {ref_hash[:8].hex()}...") - + # Load RSA key if provided rsa_component = b"" if key_path: @@ -130,7 +130,7 @@ def main(): rsa_key = load_rsa_key(key_data, password=None) except: rsa_key = load_rsa_key(key_data, password="testpass") - + # Get public key bytes for seed from cryptography.hazmat.primitives import serialization pub_bytes = rsa_key.public_key().public_bytes( @@ -141,7 +141,7 @@ def main(): print(f" RSA key loaded, hash: {rsa_component[:8].hex()}...") except Exception as e: print(f" βœ— Could not load RSA key: {e}") - + # Build seed like stegasoo does # This is the critical part - must match encoding! seed_parts = [ @@ -152,12 +152,12 @@ def main(): ] seed = hashlib.sha256(b"".join(seed_parts)).digest() print(f" Combined seed: {seed[:8].hex()}...") - + # Generate order print(f"\n[5] Generating coefficient order...") order = generate_order(len(positions), seed) print(f" First 10 indices: {order[:10]}") - + # Try to extract header print(f"\n[6] Extracting header (first 80 bits = 10 bytes)...") HEADER_SIZE = 10 @@ -165,7 +165,7 @@ def main(): header_bytes = bits_to_bytes(header_bits) print(f" Raw header bytes: {header_bytes.hex()}") print(f" As ASCII (if printable): {repr(header_bytes)}") - + # Check for JPGS magic JPEGIO_MAGIC = b'JPGS' if header_bytes[:4] == JPEGIO_MAGIC: @@ -176,7 +176,7 @@ def main(): print(f" Version: {version}") print(f" Flags: {flags}") print(f" Data length: {data_length} bytes") - + if data_length > 0 and data_length < len(positions) // 8: print(f"\n[7] Extracting payload ({data_length} bytes)...") total_bits = (HEADER_SIZE + data_length) * 8 @@ -191,10 +191,10 @@ def main(): print(f" βœ— No JPEGIO magic found") print(f" Expected: {JPEGIO_MAGIC.hex()} ('JPGS')") print(f" Got: {header_bytes[:4].hex()} ('{header_bytes[:4]}')") - + # Try alternate interpretations print(f"\n[7] Trying alternate header interpretations...") - + # Maybe it's scipy DCT format? DCT_MAGIC = b'DCTS' if header_bytes[:4] == DCT_MAGIC: @@ -202,7 +202,7 @@ def main(): else: # Show bit distribution print(f" First 32 extracted bits: {header_bits[:32]}") - + # Check if bits look random or patterned ones = sum(header_bits[:80]) print(f" Bit distribution: {ones}/80 ones ({100*ones/80:.1f}%)") diff --git a/frontends/api/main.py b/frontends/api/main.py index f175c7d..fbf0e8b 100644 --- a/frontends/api/main.py +++ b/frontends/api/main.py @@ -21,53 +21,47 @@ NEW in v3.0: LSB and DCT embedding modes. NEW in v3.0.1: DCT color mode and JPEG output format. """ -import io -import sys import base64 +import sys from pathlib import Path -from typing import Optional, Literal -from datetime import date +from typing import Literal -from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Query -from fastapi.responses import Response, JSONResponse +from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile +from fastapi.responses import JSONResponse, Response from pydantic import BaseModel, Field # Add parent to path for development sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src')) -import stegasoo from stegasoo import ( - encode, decode, generate_credentials, - validate_image, - __version__, - StegasooError, DecryptionError, CapacityError, - has_argon2, - FilePayload, MAX_FILE_PAYLOAD_SIZE, - # Embedding modes - EMBED_MODE_LSB, - EMBED_MODE_DCT, - EMBED_MODE_AUTO, - has_dct_support, - compare_modes, - will_fit_by_mode, + CapacityError, + DecryptionError, + FilePayload, + StegasooError, + __version__, calculate_capacity_by_mode, - # Channel key functions (v4.0.0) - generate_channel_key, - get_channel_key, - set_channel_key, clear_channel_key, - has_channel_key, + compare_modes, + decode, + encode, + generate_channel_key, + generate_credentials, get_channel_status, + has_argon2, + has_channel_key, + has_dct_support, + set_channel_key, validate_channel_key, - format_channel_key, - get_active_channel_key, - get_channel_fingerprint, + validate_image, + will_fit_by_mode, ) from stegasoo.constants import ( - MIN_PIN_LENGTH, MAX_PIN_LENGTH, - MIN_PASSPHRASE_WORDS, MAX_PASSPHRASE_WORDS, DEFAULT_PASSPHRASE_WORDS, + MAX_PASSPHRASE_WORDS, + MAX_PIN_LENGTH, + MIN_PASSPHRASE_WORDS, + MIN_PIN_LENGTH, VALID_RSA_SIZES, ) @@ -151,11 +145,11 @@ class GenerateRequest(BaseModel): class GenerateResponse(BaseModel): passphrase: str = Field(description="Single passphrase (v3.2.0: no daily rotation)") - pin: Optional[str] = None - rsa_key_pem: Optional[str] = None + pin: str | None = None + rsa_key_pem: str | None = None entropy: dict[str, int] # Legacy field for compatibility - phrases: Optional[dict[str, str]] = Field( + phrases: dict[str, str] | None = Field( default=None, description="Deprecated: Use 'passphrase' instead" ) @@ -167,10 +161,10 @@ class EncodeRequest(BaseModel): carrier_image_base64: str passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)") pin: str = "" - rsa_key_base64: Optional[str] = None - rsa_password: Optional[str] = None + rsa_key_base64: str | None = None + rsa_password: str | None = None # Channel key (v4.0.0) - channel_key: Optional[str] = Field( + channel_key: str | None = Field( default=None, description="Channel key for deployment isolation. null=auto (use server config), ''=public mode, 'XXXX-...'=explicit key" ) @@ -192,15 +186,15 @@ class EncodeFileRequest(BaseModel): """Request for embedding a file (base64-encoded).""" file_data_base64: str filename: str - mime_type: Optional[str] = None + mime_type: str | None = None reference_photo_base64: str carrier_image_base64: str passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)") pin: str = "" - rsa_key_base64: Optional[str] = None - rsa_password: Optional[str] = None + rsa_key_base64: str | None = None + rsa_password: str | None = None # Channel key (v4.0.0) - channel_key: Optional[str] = Field( + channel_key: str | None = Field( default=None, description="Channel key for deployment isolation. null=auto (use server config), ''=public mode, 'XXXX-...'=explicit key" ) @@ -236,16 +230,16 @@ class EncodeResponse(BaseModel): default="public", description="Channel mode: 'public' or 'private'" ) - channel_fingerprint: Optional[str] = Field( + channel_fingerprint: str | None = Field( default=None, description="Channel key fingerprint (if private mode)" ) # Legacy fields (v3.2.0: no longer used in crypto) - date_used: Optional[str] = Field( + date_used: str | None = Field( default=None, description="Deprecated: Date no longer used in v3.2.0" ) - day_of_week: Optional[str] = Field( + day_of_week: str | None = Field( default=None, description="Deprecated: Date no longer used in v3.2.0" ) @@ -256,10 +250,10 @@ class DecodeRequest(BaseModel): reference_photo_base64: str passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)") pin: str = "" - rsa_key_base64: Optional[str] = None - rsa_password: Optional[str] = None + rsa_key_base64: str | None = None + rsa_password: str | None = None # Channel key (v4.0.0) - channel_key: Optional[str] = Field( + channel_key: str | None = Field( default=None, description="Channel key for decryption. null=auto (use server config), ''=public mode, 'XXXX-...'=explicit key" ) @@ -272,10 +266,10 @@ class DecodeRequest(BaseModel): class DecodeResponse(BaseModel): """Response for decode - can be text or file.""" payload_type: str # 'text' or 'file' - message: Optional[str] = None # For text - file_data_base64: Optional[str] = None # For file (base64-encoded) - filename: Optional[str] = None # For file - mime_type: Optional[str] = None # For file + message: str | None = None # For text + file_data_base64: str | None = None # For file (base64-encoded) + filename: str | None = None # For file + mime_type: str | None = None # For file class ModeCapacity(BaseModel): @@ -292,7 +286,7 @@ class ImageInfoResponse(BaseModel): pixels: int capacity_bytes: int = Field(description="LSB mode capacity (for backwards compatibility)") capacity_kb: int = Field(description="LSB mode capacity in KB") - modes: Optional[dict[str, ModeCapacity]] = Field( + modes: dict[str, ModeCapacity] | None = Field( default=None, description="Capacity by embedding mode (v3.0+)" ) @@ -301,7 +295,7 @@ class ImageInfoResponse(BaseModel): class CompareModesRequest(BaseModel): """Request for comparing embedding modes.""" carrier_image_base64: str - payload_size: Optional[int] = Field( + payload_size: int | None = Field( default=None, description="Optional payload size to check if it fits" ) @@ -313,7 +307,7 @@ class CompareModesResponse(BaseModel): height: int lsb: dict dct: dict - payload_check: Optional[dict] = None + payload_check: dict | None = None recommendation: str @@ -332,9 +326,9 @@ class ChannelStatusResponse(BaseModel): """Response for channel key status (v4.0.0).""" mode: str = Field(description="'public' or 'private'") configured: bool = Field(description="Whether a channel key is configured") - fingerprint: Optional[str] = Field(default=None, description="Key fingerprint (partial)") - source: Optional[str] = Field(default=None, description="Where the key comes from") - key: Optional[str] = Field(default=None, description="Full key (only if reveal=true)") + fingerprint: str | None = Field(default=None, description="Key fingerprint (partial)") + source: str | None = Field(default=None, description="Where the key comes from") + key: str | None = Field(default=None, description="Full key (only if reveal=true)") class ChannelGenerateResponse(BaseModel): @@ -342,7 +336,7 @@ class ChannelGenerateResponse(BaseModel): key: str = Field(description="Generated channel key") fingerprint: str = Field(description="Key fingerprint") saved: bool = Field(default=False, description="Whether key was saved to config") - save_location: Optional[str] = Field(default=None, description="Where key was saved") + save_location: str | None = Field(default=None, description="Where key was saved") class ChannelSetRequest(BaseModel): @@ -356,7 +350,7 @@ class ModesResponse(BaseModel): lsb: dict dct: DctModeInfo # Channel key status (v4.0.0) - channel: Optional[dict] = Field( + channel: dict | None = Field( default=None, description="Channel key status (v4.0.0)" ) @@ -369,12 +363,12 @@ class StatusResponse(BaseModel): has_dct: bool max_payload_kb: int available_modes: list[str] - dct_features: Optional[dict] = Field( + dct_features: dict | None = Field( default=None, description="DCT mode features (v3.0.1+)" ) # Channel key status (v4.0.0) - channel: Optional[dict] = Field( + channel: dict | None = Field( default=None, description="Channel key status (v4.0.0)" ) @@ -385,8 +379,8 @@ class StatusResponse(BaseModel): class QrExtractResponse(BaseModel): success: bool - key_pem: Optional[str] = None - error: Optional[str] = None + key_pem: str | None = None + error: str | None = None class WillFitRequest(BaseModel): @@ -408,67 +402,67 @@ class WillFitResponse(BaseModel): class ErrorResponse(BaseModel): error: str - detail: Optional[str] = None + detail: str | None = None # ============================================================================ # HELPER: RESOLVE CHANNEL KEY # ============================================================================ -def _resolve_channel_key(channel_key: Optional[str]) -> Optional[str]: +def _resolve_channel_key(channel_key: str | None) -> str | None: """ Resolve channel key from API parameter. - + Args: channel_key: API parameter value - None: Use server-configured key (auto mode) - "": Public mode (no channel key) - "XXXX-...": Explicit key - + Returns: Resolved channel key to pass to encode/decode - + Raises: HTTPException: If key format is invalid """ if channel_key is None: # Auto mode - use server config return None - + if channel_key == "": # Public mode return "" - + # Explicit key - validate format if not validate_channel_key(channel_key): raise HTTPException( 400, - f"Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" + "Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" ) - + return channel_key -def _get_channel_info(channel_key: Optional[str]) -> tuple[str, Optional[str]]: +def _get_channel_info(channel_key: str | None) -> tuple[str, str | None]: """ Get channel mode and fingerprint for response. - + Returns: (mode, fingerprint) tuple """ if channel_key == "": return "public", None - + if channel_key is not None: # Explicit key fingerprint = f"{channel_key[:4]}-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-{channel_key[-4:]}" return "private", fingerprint - + # Auto mode - check server config if has_channel_key(): status = get_channel_status() return "private", status.get('fingerprint') - + return "public", None @@ -481,7 +475,7 @@ async def root(): """Get API status and configuration.""" available_modes = ["lsb"] dct_features = None - + if has_dct_support(): available_modes.append("dct") dct_features = { @@ -490,7 +484,7 @@ async def root(): "default_output_format": "png", "default_color_mode": "grayscale", } - + # Channel key status (v4.0.0) channel_status = get_channel_status() channel_info = { @@ -499,7 +493,7 @@ async def root(): "fingerprint": channel_status.get('fingerprint'), "source": channel_status.get('source'), } - + return StatusResponse( version=__version__, has_argon2=has_argon2(), @@ -525,7 +519,7 @@ async def root(): async def api_modes(): """ Get available embedding modes and their status. - + v4.0.0: Also includes channel key status. """ # Channel status @@ -535,7 +529,7 @@ async def api_modes(): "configured": channel_status['configured'], "fingerprint": channel_status.get('fingerprint'), } - + return ModesResponse( lsb={ "available": True, @@ -567,14 +561,14 @@ async def api_channel_status( ): """ Get current channel key status. - + v4.0.0: New endpoint for channel key management. - + Returns mode (public/private), fingerprint, and source. Use reveal=true to include the full key. """ status = get_channel_status() - + return ChannelStatusResponse( mode=status['mode'], configured=status['configured'], @@ -591,21 +585,21 @@ async def api_channel_generate( ): """ Generate a new channel key. - + v4.0.0: New endpoint for channel key management. - + Optionally saves to user config (~/.stegasoo/channel.key) or project config (./config/channel.key). """ if save and save_project: raise HTTPException(400, "Cannot use both save and save_project") - + key = generate_channel_key() fingerprint = f"{key[:4]}-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-{key[-4:]}" - + saved = False save_location = None - + if save: set_channel_key(key, location='user') saved = True @@ -614,7 +608,7 @@ async def api_channel_generate( set_channel_key(key, location='project') saved = True save_location = "./config/channel.key" - + return ChannelGenerateResponse( key=key, fingerprint=fingerprint, @@ -627,7 +621,7 @@ async def api_channel_generate( async def api_channel_set(request: ChannelSetRequest): """ Set/save a channel key to config. - + v4.0.0: New endpoint for channel key management. """ if not validate_channel_key(request.key): @@ -635,12 +629,12 @@ async def api_channel_set(request: ChannelSetRequest): 400, "Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" ) - + if request.location not in ('user', 'project'): raise HTTPException(400, "location must be 'user' or 'project'") - + set_channel_key(request.key, location=request.location) - + status = get_channel_status() return { "success": True, @@ -655,9 +649,9 @@ async def api_channel_clear( ): """ Clear/remove channel key from config. - + v4.0.0: New endpoint for channel key management. - + Note: Does not affect environment variables. """ if location == "all": @@ -667,7 +661,7 @@ async def api_channel_clear( clear_channel_key(location=location) else: raise HTTPException(400, "location must be 'user', 'project', or 'all'") - + status = get_channel_status() return { "success": True, @@ -681,14 +675,14 @@ async def api_channel_clear( async def api_compare_modes(request: CompareModesRequest): """ Compare LSB and DCT embedding modes for a carrier image. - + Returns capacity for both modes and recommendation. Optionally checks if a specific payload size would fit. """ try: carrier = base64.b64decode(request.carrier_image_base64) comparison = compare_modes(carrier) - + response = CompareModesResponse( width=comparison['width'], height=comparison['height'], @@ -708,17 +702,17 @@ async def api_compare_modes(request: CompareModesRequest): }, recommendation="lsb" if not comparison['dct']['available'] else "dct for stealth, lsb for capacity" ) - + if request.payload_size: fits_lsb = request.payload_size <= comparison['lsb']['capacity_bytes'] fits_dct = request.payload_size <= comparison['dct']['capacity_bytes'] - + response.payload_check = { "size_bytes": request.payload_size, "fits_lsb": fits_lsb, "fits_dct": fits_dct, } - + # Update recommendation based on payload if fits_dct and comparison['dct']['available']: response.recommendation = "dct (payload fits, better stealth)" @@ -726,9 +720,9 @@ async def api_compare_modes(request: CompareModesRequest): response.recommendation = "lsb (payload too large for dct)" else: response.recommendation = "none (payload too large for both modes)" - + return response - + except Exception as e: raise HTTPException(500, str(e)) @@ -737,17 +731,17 @@ async def api_compare_modes(request: CompareModesRequest): async def api_will_fit(request: WillFitRequest): """ Check if a payload of given size will fit in the carrier image. - + Supports both LSB and DCT modes. """ try: # Validate mode if request.embed_mode == "dct" and not has_dct_support(): raise HTTPException(400, "DCT mode requires scipy. Install with: pip install scipy") - + carrier = base64.b64decode(request.carrier_image_base64) result = will_fit_by_mode(request.payload_size, carrier, embed_mode=request.embed_mode) - + return WillFitResponse( fits=result['fits'], payload_size=result['payload_size'], @@ -756,7 +750,7 @@ async def api_will_fit(request: WillFitRequest): headroom=result['headroom'], mode=request.embed_mode ) - + except HTTPException: raise except Exception as e: @@ -773,7 +767,7 @@ async def api_extract_key_from_qr( ): """ Extract RSA key from a QR code image. - + Supports both compressed (STEGASOO-Z: prefix) and uncompressed keys. Returns the PEM-encoded key if found. """ @@ -782,11 +776,11 @@ async def api_extract_key_from_qr( 501, "QR code reading not available. Install pyzbar and libzbar." ) - + try: image_data = await qr_image.read() key_pem = extract_key_from_qr(image_data) - + if key_pem: return QrExtractResponse(success=True, key_pem=key_pem) else: @@ -806,18 +800,18 @@ async def api_extract_key_from_qr( async def api_generate(request: GenerateRequest): """ Generate credentials for encoding/decoding. - + At least one of use_pin or use_rsa must be True. - + v3.2.0: Generates single passphrase (no daily rotation). Default increased to 4 words for better security. """ if not request.use_pin and not request.use_rsa: raise HTTPException(400, "Must enable at least one of use_pin or use_rsa") - + if request.rsa_bits not in VALID_RSA_SIZES: raise HTTPException(400, f"rsa_bits must be one of {VALID_RSA_SIZES}") - + try: creds = generate_credentials( use_pin=request.use_pin, @@ -826,7 +820,7 @@ async def api_generate(request: GenerateRequest): rsa_bits=request.rsa_bits, passphrase_words=request.words_per_passphrase, ) - + return GenerateResponse( passphrase=creds.passphrase, pin=creds.pin, @@ -854,7 +848,7 @@ def _get_dct_params(embed_mode: str, dct_output_format: str, dct_color_mode: str """ if embed_mode != "dct": return {} - + return { "dct_output_format": dct_output_format, "dct_color_mode": dct_color_mode, @@ -874,7 +868,7 @@ def _get_output_info(embed_mode: str, dct_output_format: str, dct_color_mode: st output_format = "png" color_mode = "color" mime_type = "image/png" - + return output_format, color_mode, mime_type @@ -886,31 +880,31 @@ def _get_output_info(embed_mode: str, dct_output_format: str, dct_color_mode: st async def api_encode(request: EncodeRequest): """ Encode a text message into an image. - + Images must be base64-encoded. Returns base64-encoded stego image. - + v4.0.0: Added channel_key parameter for deployment isolation. v3.2.0: No date_str parameter needed - encode anytime! """ # Validate mode if request.embed_mode == "dct" and not has_dct_support(): raise HTTPException(400, "DCT mode requires scipy. Install with: pip install scipy") - + # Resolve channel key resolved_channel_key = _resolve_channel_key(request.channel_key) - + try: ref_photo = base64.b64decode(request.reference_photo_base64) carrier = base64.b64decode(request.carrier_image_base64) rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None - + # Get DCT parameters dct_params = _get_dct_params( request.embed_mode, request.dct_output_format, request.dct_color_mode ) - + # v4.0.0: Include channel_key result = encode( message=request.message, @@ -924,18 +918,18 @@ async def api_encode(request: EncodeRequest): channel_key=resolved_channel_key, **dct_params, ) - + stego_b64 = base64.b64encode(result.stego_image).decode('utf-8') - + output_format, color_mode, _ = _get_output_info( request.embed_mode, request.dct_output_format, request.dct_color_mode ) - + # Get channel info for response channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key) - + return EncodeResponse( stego_image_base64=stego_b64, filename=result.filename, @@ -948,7 +942,7 @@ async def api_encode(request: EncodeRequest): date_used=None, day_of_week=None, ) - + except CapacityError as e: raise HTTPException(400, str(e)) except StegasooError as e: @@ -961,38 +955,38 @@ async def api_encode(request: EncodeRequest): async def api_encode_file(request: EncodeFileRequest): """ Encode a file into an image (JSON with base64). - + File data must be base64-encoded. - + v4.0.0: Added channel_key parameter for deployment isolation. v3.2.0: No date_str parameter needed - encode anytime! """ # Validate mode if request.embed_mode == "dct" and not has_dct_support(): raise HTTPException(400, "DCT mode requires scipy. Install with: pip install scipy") - + # Resolve channel key 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_image_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 ) - + # Get DCT parameters dct_params = _get_dct_params( request.embed_mode, request.dct_output_format, request.dct_color_mode ) - + # v4.0.0: Include channel_key result = encode( message=payload, @@ -1006,18 +1000,18 @@ async def api_encode_file(request: EncodeFileRequest): channel_key=resolved_channel_key, **dct_params, ) - + stego_b64 = base64.b64encode(result.stego_image).decode('utf-8') - + output_format, color_mode, _ = _get_output_info( request.embed_mode, request.dct_output_format, request.dct_color_mode ) - + # Get channel info for response channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key) - + return EncodeResponse( stego_image_base64=stego_b64, filename=result.filename, @@ -1030,7 +1024,7 @@ async def api_encode_file(request: EncodeFileRequest): date_used=None, day_of_week=None, ) - + except CapacityError as e: raise HTTPException(400, str(e)) except StegasooError as e: @@ -1047,24 +1041,24 @@ async def api_encode_file(request: EncodeFileRequest): async def api_decode(request: DecodeRequest): """ Decode a message or file from a stego image. - + Returns payload_type to indicate if result is text or file. - + v4.0.0: Added channel_key parameter - must match encoding key. v3.2.0: No date_str parameter needed - decode anytime! """ # Validate mode if request.embed_mode == "dct" and not has_dct_support(): raise HTTPException(400, "DCT mode requires scipy. Install with: pip install scipy") - + # Resolve channel key resolved_channel_key = _resolve_channel_key(request.channel_key) - + try: stego = base64.b64decode(request.stego_image_base64) ref_photo = base64.b64decode(request.reference_photo_base64) rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None - + # v4.0.0: Include channel_key result = decode( stego_image=stego, @@ -1076,7 +1070,7 @@ async def api_decode(request: DecodeRequest): embed_mode=request.embed_mode, channel_key=resolved_channel_key, ) - + if result.is_file: return DecodeResponse( payload_type='file', @@ -1089,7 +1083,7 @@ async def api_decode(request: DecodeRequest): payload_type='text', message=result.message ) - + except DecryptionError as e: # Provide helpful error message for channel key issues error_msg = str(e) @@ -1112,10 +1106,10 @@ async def api_encode_multipart( reference_photo: UploadFile = File(...), carrier: UploadFile = File(...), message: str = Form(""), - payload_file: Optional[UploadFile] = File(None), + payload_file: UploadFile | None = File(None), pin: str = Form(""), - rsa_key: Optional[UploadFile] = File(None), - rsa_key_qr: Optional[UploadFile] = File(None), + rsa_key: UploadFile | None = File(None), + rsa_key_qr: UploadFile | None = File(None), rsa_password: str = Form(""), # Channel key (v4.0.0) channel_key: str = Form("auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"), @@ -1125,11 +1119,11 @@ async def api_encode_multipart( ): """ Encode using multipart form data (file uploads). - + Provide either 'message' (text) or 'payload_file' (binary file). RSA key can be provided as 'rsa_key' (.pem file) or 'rsa_key_qr' (QR code image). Returns the stego image directly with metadata headers. - + v4.0.0: Added channel_key parameter for deployment isolation. Use 'auto' for server config, 'none' for public mode. v3.2.0: No date_str parameter needed - encode anytime! @@ -1139,13 +1133,13 @@ async def api_encode_multipart( raise HTTPException(400, "embed_mode must be 'lsb' or 'dct'") if embed_mode == "dct" and not has_dct_support(): raise HTTPException(400, "DCT mode requires scipy. Install with: pip install scipy") - + # Validate DCT options if dct_output_format not in ("png", "jpeg"): raise HTTPException(400, "dct_output_format must be 'png' or 'jpeg'") if dct_color_mode not in ("grayscale", "color"): raise HTTPException(400, "dct_color_mode must be 'grayscale' or 'color'") - + # Resolve channel key (v4.0.0) # Form data: "auto" = use server config, "none" = public, otherwise explicit key if channel_key.lower() == "auto": @@ -1154,15 +1148,15 @@ async def api_encode_multipart( resolved_channel_key = "" # Public mode else: resolved_channel_key = _resolve_channel_key(channel_key) - + try: ref_data = await reference_photo.read() carrier_data = await carrier.read() - + # Handle RSA key from .pem file or QR code image rsa_key_data = None rsa_key_from_qr = False - + if rsa_key and rsa_key.filename: rsa_key_data = await rsa_key.read() elif rsa_key_qr and rsa_key_qr.filename: @@ -1177,10 +1171,10 @@ async def api_encode_multipart( raise HTTPException(400, "Could not extract RSA key from QR code image") rsa_key_data = key_pem.encode('utf-8') rsa_key_from_qr = True - + # QR code keys are never password-protected effective_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) - + # Determine payload if payload_file and payload_file.filename: file_data = await payload_file.read() @@ -1193,10 +1187,10 @@ async def api_encode_multipart( payload = message else: raise HTTPException(400, "Must provide either 'message' or 'payload_file'") - + # Get DCT parameters dct_params = _get_dct_params(embed_mode, dct_output_format, dct_color_mode) - + # v4.0.0: Include channel_key result = encode( message=payload, @@ -1210,14 +1204,14 @@ async def api_encode_multipart( channel_key=resolved_channel_key, **dct_params, ) - + output_format, color_mode, mime_type = _get_output_info( embed_mode, dct_output_format, dct_color_mode ) - + # Get channel info for headers channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key) - + headers = { "Content-Disposition": f"attachment; filename={result.filename}", "X-Stegasoo-Capacity-Percent": f"{result.capacity_percent:.1f}", @@ -1227,16 +1221,16 @@ async def api_encode_multipart( "X-Stegasoo-Channel-Mode": channel_mode, "X-Stegasoo-Version": __version__, } - + if channel_fingerprint: headers["X-Stegasoo-Channel-Fingerprint"] = channel_fingerprint - + return Response( content=result.stego_image, media_type=mime_type, headers=headers, ) - + except CapacityError as e: raise HTTPException(400, str(e)) except StegasooError as e: @@ -1253,8 +1247,8 @@ async def api_decode_multipart( reference_photo: UploadFile = File(...), stego_image: UploadFile = File(...), pin: str = Form(""), - rsa_key: Optional[UploadFile] = File(None), - rsa_key_qr: Optional[UploadFile] = File(None), + rsa_key: UploadFile | None = File(None), + rsa_key_qr: UploadFile | None = File(None), rsa_password: str = Form(""), # Channel key (v4.0.0) channel_key: str = Form("auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"), @@ -1262,10 +1256,10 @@ async def api_decode_multipart( ): """ Decode using multipart form data (file uploads). - + RSA key can be provided as 'rsa_key' (.pem file) or 'rsa_key_qr' (QR code image). Returns JSON with payload_type indicating text or file. - + v4.0.0: Added channel_key parameter - must match what was used for encoding. Use 'auto' for server config, 'none' for public mode. v3.2.0: No date_str parameter needed - decode anytime! @@ -1275,7 +1269,7 @@ async def api_decode_multipart( raise HTTPException(400, "embed_mode must be 'auto', 'lsb', or 'dct'") if embed_mode == "dct" and not has_dct_support(): raise HTTPException(400, "DCT mode requires scipy. Install with: pip install scipy") - + # Resolve channel key (v4.0.0) if channel_key.lower() == "auto": resolved_channel_key = None # Auto mode @@ -1283,15 +1277,15 @@ async def api_decode_multipart( resolved_channel_key = "" # Public mode else: resolved_channel_key = _resolve_channel_key(channel_key) - + try: ref_data = await reference_photo.read() stego_data = await stego_image.read() - + # Handle RSA key from .pem file or QR code image rsa_key_data = None rsa_key_from_qr = False - + if rsa_key and rsa_key.filename: rsa_key_data = await rsa_key.read() elif rsa_key_qr and rsa_key_qr.filename: @@ -1306,10 +1300,10 @@ async def api_decode_multipart( raise HTTPException(400, "Could not extract RSA key from QR code image") rsa_key_data = key_pem.encode('utf-8') rsa_key_from_qr = True - + # QR code keys are never password-protected effective_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) - + # v4.0.0: Include channel_key result = decode( stego_image=stego_data, @@ -1321,7 +1315,7 @@ async def api_decode_multipart( embed_mode=embed_mode, channel_key=resolved_channel_key, ) - + if result.is_file: return DecodeResponse( payload_type='file', @@ -1334,7 +1328,7 @@ async def api_decode_multipart( payload_type='text', message=result.message ) - + except DecryptionError as e: error_msg = str(e) if 'channel key' in error_msg.lower(): @@ -1359,18 +1353,18 @@ async def api_image_info( ): """ Get information about an image's capacity. - + Optionally includes capacity for both LSB and DCT modes. """ try: image_data = await image.read() - + result = validate_image(image_data, check_size=False) if not result.is_valid: raise HTTPException(400, result.error_message) - + capacity = calculate_capacity_by_mode(image_data, 'lsb') - + response = ImageInfoResponse( width=result.details['width'], height=result.details['height'], @@ -1378,7 +1372,7 @@ async def api_image_info( capacity_bytes=capacity, capacity_kb=capacity // 1024 ) - + if include_modes: comparison = compare_modes(image_data) response.modes = { @@ -1395,9 +1389,9 @@ async def api_image_info( output_format="PNG/JPEG (grayscale or color)", ), } - + return response - + except HTTPException: raise except Exception as e: diff --git a/frontends/cli/main.py b/frontends/cli/main.py index 875ea13..1c2ebac 100644 --- a/frontends/cli/main.py +++ b/frontends/cli/main.py @@ -26,85 +26,59 @@ Usage: import sys from pathlib import Path -from typing import Optional import click # Add parent to path for development sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src')) -import stegasoo from stegasoo import ( - # Core operations - encode, decode, - - # Credential generation - generate_credentials, - generate_passphrase, - generate_pin, - export_rsa_key_pem, - load_rsa_key, - - # Validation - validate_image, - - # Image utilities - get_image_info, - compare_capacity, - - # Steganography functions - has_dct_support, - compare_modes, - will_fit_by_mode, - - # Utilities - generate_filename, - - # Version - __version__, - - # Exceptions - StegasooError, - DecryptionError, + DecryptionError, ExtractionError, - # Models FilePayload, - + # Exceptions + StegasooError, + # Utilities + __version__, + clear_channel_key, + compare_modes, + decode, + # Core operations + encode, + export_rsa_key_pem, # Channel key functions (v4.0.0) generate_channel_key, - get_channel_key, - set_channel_key, - clear_channel_key, - has_channel_key, + # Credential generation + generate_credentials, get_channel_status, + # Validation + get_image_info, + has_channel_key, + has_dct_support, + load_rsa_key, + set_channel_key, validate_channel_key, - format_channel_key, - get_active_channel_key, - get_channel_fingerprint, + will_fit_by_mode, ) # Import constants - try main module first, then constants submodule try: - from stegasoo import ( - EMBED_MODE_LSB, - EMBED_MODE_DCT, + from stegasoo import ( # noqa: F401 EMBED_MODE_AUTO, + EMBED_MODE_DCT, + EMBED_MODE_LSB, ) except ImportError: - from stegasoo.constants import ( - EMBED_MODE_LSB, - EMBED_MODE_DCT, - EMBED_MODE_AUTO, - ) + pass # Import constants that may not be in main __init__ try: from stegasoo.constants import ( DEFAULT_PASSPHRASE_WORDS, DEFAULT_PIN_LENGTH, - MIN_PIN_LENGTH, MAX_PIN_LENGTH, + MIN_PIN_LENGTH, ) except ImportError: # Fallback defaults if constants not available @@ -122,17 +96,23 @@ except ImportError: # QR Code utilities try: - from stegasoo.qr_utils import ( + from stegasoo.qr_utils import ( # noqa: F401 + can_fit_in_qr, extract_key_from_qr_file, generate_qr_code, - has_qr_read, has_qr_write, - can_fit_in_qr, needs_compression, + has_qr_read, + has_qr_write, + needs_compression, ) HAS_QR = True except ImportError: HAS_QR = False - has_qr_read = lambda: False - has_qr_write = lambda: False + + def has_qr_read() -> bool: + return False + + def has_qr_write() -> bool: + return False # ============================================================================ @@ -147,26 +127,26 @@ CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) def cli(): """ Stegasoo - Secure steganography with hybrid authentication. - + Hide encrypted messages or files in images using a combination of: - + \b - Reference photo (something you have) - Passphrase (something you know) - Static PIN or RSA key (additional security) - Channel key (deployment/group isolation) [v4.0.0] - + \b Version 4.0.0 Changes: - Channel key support for group/deployment isolation - Messages encoded with a channel key require the same key to decode - New `stegasoo channel` command for key management - + \b Embedding Modes: - LSB mode (default): Full color output, higher capacity - DCT mode: Frequency domain, ~20% capacity, better stealth - + \b DCT Options: - Color mode: grayscale (default) or color (preserves colors) @@ -179,11 +159,11 @@ def cli(): # CHANNEL KEY HELPERS # ============================================================================ -def resolve_channel_key_option(channel: Optional[str], channel_file: Optional[str], - no_channel: bool) -> Optional[str]: +def resolve_channel_key_option(channel: str | None, channel_file: str | None, + no_channel: bool) -> str | None: """ Resolve channel key from CLI options. - + Returns: None: Use server-configured key (auto mode) "": Public mode (no channel key) @@ -191,7 +171,7 @@ def resolve_channel_key_option(channel: Optional[str], channel_file: Optional[st """ if no_channel: return "" # Public mode - + if channel_file: # Load from file path = Path(channel_file) @@ -201,31 +181,31 @@ def resolve_channel_key_option(channel: Optional[str], channel_file: Optional[st if not validate_channel_key(key): raise click.ClickException(f"Invalid channel key format in file: {channel_file}") return key - + if channel: if channel.lower() == 'auto': return None # Use server config # Explicit key provided if not validate_channel_key(channel): raise click.ClickException( - f"Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX\n" - f"Generate a new key with: stegasoo channel generate" + "Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX\n" + "Generate a new key with: stegasoo channel generate" ) return channel - + # Default: use server-configured key (auto mode) return None -def format_channel_status_line(quiet: bool = False) -> Optional[str]: +def format_channel_status_line(quiet: bool = False) -> str | None: """Get a one-line status for channel key configuration.""" if quiet: return None - + status = get_channel_status() if status['mode'] == 'public': return None - + return f"Channel: {status['fingerprint']} ({status['source']})" @@ -236,11 +216,11 @@ def format_channel_status_line(quiet: bool = False) -> Optional[str]: @cli.command() @click.option('--pin/--no-pin', default=True, help='Generate a PIN (default: yes)') @click.option('--rsa/--no-rsa', default=False, help='Generate an RSA key') -@click.option('--pin-length', type=click.IntRange(6, 9), default=DEFAULT_PIN_LENGTH, +@click.option('--pin-length', type=click.IntRange(6, 9), default=DEFAULT_PIN_LENGTH, help=f'PIN length (6-9, default: {DEFAULT_PIN_LENGTH})') -@click.option('--rsa-bits', type=click.Choice(['2048', '3072', '4096']), default='2048', +@click.option('--rsa-bits', type=click.Choice(['2048', '3072', '4096']), default='2048', help='RSA key size') -@click.option('--words', type=click.IntRange(3, 12), default=DEFAULT_PASSPHRASE_WORDS, +@click.option('--words', type=click.IntRange(3, 12), default=DEFAULT_PASSPHRASE_WORDS, help=f'Words per passphrase (default: {DEFAULT_PASSPHRASE_WORDS})') @click.option('--output', '-o', type=click.Path(), help='Save RSA key to file (requires password)') @click.option('--password', '-p', help='Password for RSA key file') @@ -248,13 +228,13 @@ def format_channel_status_line(quiet: bool = False) -> Optional[str]: def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): """ Generate credentials for encoding/decoding. - + Creates a passphrase and optionally a PIN and/or RSA key. At least one of --pin or --rsa must be enabled. - + v3.2.0: Single passphrase (no more daily rotation!) Default increased to 4 words for better security. - + \b Examples: stegasoo generate @@ -265,13 +245,13 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): """ if not pin and not rsa: raise click.UsageError("Must enable at least one of --pin or --rsa") - + if output and not password: raise click.UsageError("--password is required when saving RSA key to file") - + if password and len(password) < 8: raise click.UsageError("Password must be at least 8 characters") - + try: creds = generate_credentials( use_pin=pin, @@ -281,7 +261,7 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): passphrase_words=words, # v3.2.0: renamed parameter rsa_password=password if output else None, ) - + if as_json: import json data = { @@ -297,27 +277,27 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): } click.echo(json.dumps(data, indent=2)) return - + # Pretty output click.echo() click.secho("=" * 60, fg='cyan') click.secho(" STEGASOO CREDENTIALS (v4.0.0)", fg='cyan', bold=True) click.secho("=" * 60, fg='cyan') click.echo() - + click.secho("⚠️ MEMORIZE THESE AND CLOSE THIS WINDOW", fg='yellow', bold=True) click.secho(" Do not screenshot or save to file!", fg='yellow') click.echo() - + if creds.pin: click.secho("─── STATIC PIN ───", fg='green') click.secho(f" {creds.pin}", fg='bright_yellow', bold=True) click.echo() - + click.secho("─── PASSPHRASE ───", fg='green') click.secho(f" {creds.passphrase}", fg='bright_white', bold=True) click.echo() - + if creds.rsa_key_pem: click.secho("─── RSA KEY ───", fg='green') if output: @@ -330,7 +310,7 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): else: click.echo(creds.rsa_key_pem) click.echo() - + click.secho("─── SECURITY ───", fg='green') click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)") if creds.pin: @@ -338,21 +318,21 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): if creds.rsa_key_pem: click.echo(f" RSA entropy: {creds.rsa_entropy} bits") click.echo(f" Combined: {creds.total_entropy} bits") - click.secho(f" + photo entropy: 80-256 bits", dim=True) + click.secho(" + photo entropy: 80-256 bits", dim=True) click.echo() - + # Show channel key status if has_channel_key(): status = get_channel_status() click.secho("─── CHANNEL KEY ───", fg='magenta') - click.echo(f" Status: Private mode") + click.echo(" Status: Private mode") click.echo(f" Fingerprint: {status['fingerprint']}") click.secho(f" (configured via {status['source']})", dim=True) click.echo() - + click.secho("βœ“ v4.0.0: Use this passphrase anytime - no date needed!", fg='cyan') click.echo() - + except Exception as e: raise click.ClickException(str(e)) @@ -365,24 +345,24 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): def channel(): """ Manage channel keys for deployment/group isolation. - + Channel keys allow different deployments or groups to use Stegasoo without being able to read each other's messages, even with identical credentials. - + \b Key Storage (checked in order): 1. Environment variable: STEGASOO_CHANNEL_KEY 2. Project config: ./config/channel.key 3. User config: ~/.stegasoo/channel.key - + \b Subcommands: generate Create a new channel key show Display current channel key status set Save a channel key to config file clear Remove channel key from config - + \b Examples: stegasoo channel generate @@ -401,41 +381,41 @@ def channel(): def channel_generate(save, save_project, env, quiet): """ Generate a new channel key. - + Creates a cryptographically secure 256-bit channel key in the format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX - + \b Examples: # Just display a new key stegasoo channel generate - + # Save to user config stegasoo channel generate --save - + # Output for .env file stegasoo channel generate --env >> .env - + # For scripts KEY=$(stegasoo channel generate -q) """ key = generate_channel_key() - + if save and save_project: raise click.UsageError("Cannot use both --save and --save-project") - + if save: set_channel_key(key, location='user') if not quiet: click.secho("βœ“ Channel key saved to ~/.stegasoo/channel.key", fg='green') click.echo() - + if save_project: set_channel_key(key, location='project') if not quiet: click.secho("βœ“ Channel key saved to ./config/channel.key", fg='green') click.echo() - + if env: click.echo(f"STEGASOO_CHANNEL_KEY={key}") elif quiet: @@ -446,11 +426,11 @@ def channel_generate(save, save_project, env, quiet): click.echo() click.secho(f" {key}", fg='bright_yellow', bold=True) click.echo() - + fingerprint = f"{key[:4]}-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-{key[-4:]}" click.echo(f" Fingerprint: {fingerprint}") click.echo() - + click.secho("Usage:", dim=True) click.echo(" # Environment variable (recommended)") click.echo(f" export STEGASOO_CHANNEL_KEY={key}") @@ -469,10 +449,10 @@ def channel_generate(save, save_project, env, quiet): def channel_show(reveal, as_json): """ Display current channel key status. - + Shows whether a channel key is configured and where it comes from. By default shows only fingerprint; use --reveal to see full key. - + \b Examples: stegasoo channel show @@ -480,7 +460,7 @@ def channel_show(reveal, as_json): stegasoo channel show --json """ status = get_channel_status() - + if as_json: import json output = { @@ -493,11 +473,11 @@ def channel_show(reveal, as_json): output['key'] = status.get('key') click.echo(json.dumps(output, indent=2)) return - + click.echo() click.secho("─── CHANNEL KEY STATUS ───", fg='cyan', bold=True) click.echo() - + if status['mode'] == 'public': click.secho(" Mode: PUBLIC", fg='yellow', bold=True) click.echo(" No channel key configured.") @@ -508,14 +488,14 @@ def channel_show(reveal, as_json): click.secho(" Mode: PRIVATE", fg='green', bold=True) click.echo(f" Fingerprint: {status['fingerprint']}") click.echo(f" Source: {status['source']}") - + if reveal: click.echo() click.secho(f" Full key: {status['key']}", fg='bright_yellow') - + click.echo() click.secho(" Messages require this channel key to decode.", dim=True) - + click.echo() @@ -526,10 +506,10 @@ def channel_show(reveal, as_json): def channel_set(key, key_file, project): """ Save a channel key to config file. - + Saves to user config (~/.stegasoo/channel.key) by default, or project config (./config/channel.key) with --project. - + \b Examples: stegasoo channel set XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX @@ -538,25 +518,25 @@ def channel_set(key, key_file, project): """ if not key and not key_file: raise click.UsageError("Must provide KEY argument or --file option") - + if key and key_file: raise click.UsageError("Cannot use both KEY argument and --file option") - + if key_file: key = Path(key_file).read_text().strip() - + if not validate_channel_key(key): raise click.ClickException( - f"Invalid channel key format.\n" - f"Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX\n" - f"Generate a new key with: stegasoo channel generate" + "Invalid channel key format.\n" + "Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX\n" + "Generate a new key with: stegasoo channel generate" ) - + location = 'project' if project else 'user' set_channel_key(key, location=location) - + status = get_channel_status() - click.secho(f"βœ“ Channel key saved", fg='green') + click.secho("βœ“ Channel key saved", fg='green') click.echo(f" Location: {status['source']}") click.echo(f" Fingerprint: {status['fingerprint']}") @@ -568,12 +548,12 @@ def channel_set(key, key_file, project): def channel_clear(project, clear_all, force): """ Remove channel key from config. - + Clears user config by default. Use --project for project config, or --all to clear both. - + Note: This does not affect environment variables. - + \b Examples: stegasoo channel clear @@ -587,11 +567,11 @@ def channel_clear(project, clear_all, force): msg = "Clear channel key from project config (./config/channel.key)?" else: msg = "Clear channel key from user config (~/.stegasoo/channel.key)?" - + if not click.confirm(msg): click.echo("Cancelled.") return - + if clear_all: clear_channel_key(location='user') clear_channel_key(location='project') @@ -602,7 +582,7 @@ def channel_clear(project, clear_all, force): else: clear_channel_key(location='user') click.secho("βœ“ Cleared channel key from user config", fg='green') - + # Show current status status = get_channel_status() if status['configured']: @@ -639,45 +619,45 @@ def channel_clear(project, clear_all, force): @click.option('--dct-color', 'dct_color_mode', type=click.Choice(['grayscale', 'color']), default='grayscale', help='DCT color mode: grayscale (default) or color (preserves original colors)') @click.option('--quiet', '-q', is_flag=True, help='Suppress output except errors') -def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, key, key_qr, - key_password, channel_key, channel_file, no_channel, output, embed_mode, +def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, key, key_qr, + key_password, channel_key, channel_file, no_channel, output, embed_mode, dct_output_format, dct_color_mode, quiet): """ Encode a secret message or file into an image. - + Requires a reference photo, carrier image, and passphrase. Must provide either --pin or --key/--key-qr (or both). - + v4.0.0: Channel key support for deployment isolation. v3.2.0: No --date parameter needed! Encode and decode anytime. - + \b Channel Key Options: (no option) Use server-configured key (auto mode) --channel KEY Use explicit channel key --channel-file F Read channel key from file --no-channel Force public mode (no isolation) - + \b Embedding Modes: --mode lsb Spatial LSB embedding (default) - Full color output (PNG/BMP) - Higher capacity (~375 KB/megapixel) - + --mode dct DCT domain embedding (requires scipy) - Configurable color/grayscale output - Lower capacity (~75 KB/megapixel) - Better resistance to visual analysis - + \b Examples: # Text message with PIN (auto channel key) stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -m "secret" - + # Explicit channel key stegasoo encode -r photo.jpg -c meme.png -p "words here" --pin 123456 -m "msg" \\ --channel ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456 - + # Public mode (no channel key) stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "msg" --no-channel """ @@ -686,22 +666,22 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, raise click.ClickException( "DCT mode requires scipy. Install with: pip install scipy" ) - + # Warn if DCT options used with LSB mode if embed_mode == 'lsb': if dct_output_format != 'png' or dct_color_mode != 'grayscale': if not quiet: click.secho("Note: --dct-format and --dct-color only apply to DCT mode", fg='yellow', dim=True) - + # Resolve channel key try: resolved_channel_key = resolve_channel_key_option(channel_key, channel_file, no_channel) except click.ClickException: raise - + # Determine what to encode payload = None - + if embed_file: # Binary file embedding payload = FilePayload.from_file(embed_file) @@ -715,14 +695,14 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, payload = sys.stdin.read() else: raise click.UsageError("Must provide message via -m, -f, -e, or stdin") - + # Load key if provided (from .pem file or QR code image) rsa_key_data = None rsa_key_from_qr = False - + if key and key_qr: raise click.UsageError("Cannot use both --key and --key-qr. Choose one.") - + if key: rsa_key_data = Path(key).read_bytes() elif key_qr: @@ -738,29 +718,29 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, rsa_key_from_qr = True if not quiet: click.echo(f"Loaded RSA key from QR code: {key_qr}") - + # QR code keys are never password-protected effective_key_password = None if rsa_key_from_qr else key_password - + # Validate security factors if not pin and not rsa_key_data: raise click.UsageError("Must provide --pin or --key/--key-qr (or both)") - + try: ref_photo = Path(ref).read_bytes() carrier_image = Path(carrier).read_bytes() - + # Pre-check capacity with selected mode fit_check = will_fit_by_mode(payload, carrier_image, embed_mode=embed_mode) if not fit_check['fits']: # Suggest alternative mode if it would fit alt_mode = 'lsb' if embed_mode == 'dct' else 'dct' alt_check = will_fit_by_mode(payload, carrier_image, embed_mode=alt_mode) - + suggestion = "" if alt_mode == 'lsb' and alt_check['fits']: - suggestion = f"\n Tip: Payload would fit in LSB mode (--mode lsb)" - + suggestion = "\n Tip: Payload would fit in LSB mode (--mode lsb)" + raise click.ClickException( f"Payload too large for {embed_mode.upper()} mode.\n" f" Payload: {fit_check['payload_size']:,} bytes\n" @@ -768,13 +748,13 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, f" Shortfall: {-fit_check['headroom']:,} bytes" f"{suggestion}" ) - + if not quiet: mode_desc = embed_mode.upper() if embed_mode == 'dct': mode_desc += f" ({dct_color_mode}, {dct_output_format.upper()})" click.echo(f"Mode: {mode_desc} ({fit_check['usage_percent']:.1f}% capacity)") - + # Show channel status channel_status = format_channel_status_line() if resolved_channel_key == "": @@ -784,7 +764,7 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, click.echo(f"Channel: {fingerprint} (explicit)") elif channel_status: click.echo(channel_status) - + # v4.0.0: Include channel_key parameter result = encode( message=payload, @@ -799,18 +779,18 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, dct_color_mode=dct_color_mode, channel_key=resolved_channel_key, ) - + # Determine output path if output: out_path = Path(output) else: out_path = Path(result.filename) - + # Write output out_path.write_bytes(result.stego_image) - + if not quiet: - click.secho(f"βœ“ Encoded successfully!", fg='green') + click.secho("βœ“ Encoded successfully!", fg='green') click.echo(f" Output: {out_path}") click.echo(f" Size: {len(result.stego_image):,} bytes") click.echo(f" Capacity used: {result.capacity_percent:.1f}%") @@ -818,7 +798,7 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, color_note = "color preserved" if dct_color_mode == 'color' else "grayscale" format_note = dct_output_format.upper() click.secho(f" DCT output: {format_note} ({color_note})", dim=True) - + except StegasooError as e: raise click.ClickException(str(e)) except click.ClickException: @@ -851,29 +831,29 @@ def decode_cmd(ref, stego, passphrase, pin, key, key_qr, key_password, channel_k no_channel, output, embed_mode, quiet, force): """ Decode a secret message or file from a stego image. - + Must use the same credentials that were used for encoding. Automatically detects whether content is text or a file. - + v4.0.0: Channel key support - must match what was used for encoding. v3.2.0: No --date parameter needed! Just use your passphrase. - + \b Channel Key Options: (no option) Use server-configured key (auto mode) --channel KEY Use explicit channel key --channel-file F Read channel key from file --no-channel Force public mode (for images encoded without channel key) - + \b Examples: # Decode with auto channel key stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456 - + # Decode with explicit channel key stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 \\ --channel ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456 - + # Decode public image (no channel key was used) stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 --no-channel """ @@ -882,20 +862,20 @@ def decode_cmd(ref, stego, passphrase, pin, key, key_qr, key_password, channel_k raise click.ClickException( "DCT mode requires scipy. Install with: pip install scipy" ) - + # Resolve channel key try: resolved_channel_key = resolve_channel_key_option(channel_key, channel_file, no_channel) except click.ClickException: raise - + # Load key if provided (from .pem file or QR code image) rsa_key_data = None rsa_key_from_qr = False - + if key and key_qr: raise click.UsageError("Cannot use both --key and --key-qr. Choose one.") - + if key: rsa_key_data = Path(key).read_bytes() elif key_qr: @@ -911,18 +891,18 @@ def decode_cmd(ref, stego, passphrase, pin, key, key_qr, key_password, channel_k rsa_key_from_qr = True if not quiet: click.echo(f"Loaded RSA key from QR code: {key_qr}") - + # QR code keys are never password-protected effective_key_password = None if rsa_key_from_qr else key_password - + # Validate security factors if not pin and not rsa_key_data: raise click.UsageError("Must provide --pin or --key/--key-qr (or both)") - + try: ref_photo = Path(ref).read_bytes() stego_image = Path(stego).read_bytes() - + # v4.0.0: Include channel_key parameter result = decode( stego_image=stego_image, @@ -934,7 +914,7 @@ def decode_cmd(ref, stego, passphrase, pin, key, key_qr, key_password, channel_k embed_mode=embed_mode, channel_key=resolved_channel_key, ) - + if result.is_file: # File content if output: @@ -943,14 +923,14 @@ def decode_cmd(ref, stego, passphrase, pin, key, key_qr, key_password, channel_k out_path = Path(result.filename) else: out_path = Path("decoded_file") - + if out_path.exists() and not force: raise click.ClickException( f"Output file '{out_path}' exists. Use --force to overwrite." ) - + out_path.write_bytes(result.file_data) - + if not quiet: click.secho("βœ“ Decoded file successfully!", fg='green') click.echo(f" Saved to: {out_path}") @@ -971,7 +951,7 @@ def decode_cmd(ref, stego, passphrase, pin, key, key_qr, key_password, channel_k click.secho("βœ“ Decoded successfully!", fg='green') click.echo() click.echo(result.message) - + except (DecryptionError, ExtractionError) as e: # Provide helpful hints for channel key mismatches error_msg = str(e) @@ -1006,18 +986,18 @@ def verify(ref, stego, passphrase, pin, key, key_qr, key_password, channel_key, no_channel, embed_mode, as_json): """ Verify that a stego image can be decoded without extracting the message. - + Quick check to validate credentials are correct and data is intact. Does NOT output the actual message content. - + v4.0.0: Also verifies channel key matches. - + \b Examples: stegasoo verify -r photo.jpg -s stego.png -p "my passphrase" --pin 123456 - + stegasoo verify -r photo.jpg -s stego.png -p "words here" -k mykey.pem --json - + stegasoo verify -r photo.jpg -s stego.png -p "test phrase" --pin 123456 --no-channel """ # Check DCT mode availability @@ -1025,20 +1005,20 @@ def verify(ref, stego, passphrase, pin, key, key_qr, key_password, channel_key, raise click.ClickException( "DCT mode requires scipy. Install with: pip install scipy" ) - + # Resolve channel key try: resolved_channel_key = resolve_channel_key_option(channel_key, channel_file, no_channel) except click.ClickException: raise - + # Load key if provided rsa_key_data = None rsa_key_from_qr = False - + if key and key_qr: raise click.UsageError("Cannot use both --key and --key-qr. Choose one.") - + if key: rsa_key_data = Path(key).read_bytes() elif key_qr: @@ -1052,16 +1032,16 @@ def verify(ref, stego, passphrase, pin, key, key_qr, key_password, channel_key, raise click.ClickException(f"Could not extract RSA key from QR code: {key_qr}") rsa_key_data = key_pem.encode('utf-8') rsa_key_from_qr = True - + effective_key_password = None if rsa_key_from_qr else key_password - + if not pin and not rsa_key_data: raise click.UsageError("Must provide --pin or --key/--key-qr (or both)") - + try: ref_photo = Path(ref).read_bytes() stego_image = Path(stego).read_bytes() - + # Attempt to decode (v4.0.0: with channel_key) result = decode( stego_image=stego_image, @@ -1073,7 +1053,7 @@ def verify(ref, stego, passphrase, pin, key, key_qr, key_password, channel_key, embed_mode=embed_mode, channel_key=resolved_channel_key, ) - + # Calculate payload size if result.is_file: payload_size = len(result.file_data) @@ -1081,7 +1061,7 @@ def verify(ref, stego, passphrase, pin, key, key_qr, key_password, channel_key, else: payload_size = len(result.message.encode('utf-8')) content_type = 'text' - + if as_json: import json output = { @@ -1097,7 +1077,7 @@ def verify(ref, stego, passphrase, pin, key, key_qr, key_password, channel_key, click.echo(f" Payload size: {payload_size:,} bytes") if result.is_file and result.filename: click.echo(f" Filename: {result.filename}") - + except (DecryptionError, ExtractionError) as e: if as_json: import json @@ -1125,10 +1105,10 @@ def verify(ref, stego, passphrase, pin, key, key_qr, key_password, channel_key, def info(image, as_json): """ Show information about an image file. - + Displays dimensions, format, capacity estimates for different modes, and whether the image appears suitable as a carrier. - + \b Examples: stegasoo info photo.png @@ -1137,28 +1117,28 @@ def info(image, as_json): try: image_data = Path(image).read_bytes() img_info = get_image_info(image_data) - + if as_json: import json click.echo(json.dumps(img_info, indent=2)) return - + click.echo() click.secho(f"=== Image Info: {image} ===", fg='cyan', bold=True) click.echo(f" Format: {img_info.get('format', 'Unknown')}") click.echo(f" Dimensions: {img_info.get('width', '?')} Γ— {img_info.get('height', '?')}") click.echo(f" Mode: {img_info.get('mode', '?')}") click.echo(f" Size: {len(image_data):,} bytes") - + if 'lsb_capacity' in img_info: click.echo() click.secho(" Capacity Estimates:", fg='green') click.echo(f" LSB mode: {img_info['lsb_capacity']:,} bytes") if 'dct_capacity' in img_info: click.echo(f" DCT mode: {img_info['dct_capacity']:,} bytes") - + click.echo() - + except Exception as e: raise click.ClickException(str(e)) @@ -1175,10 +1155,10 @@ def info(image, as_json): def compare(image, payload, size, as_json): """ Compare embedding mode capacities for an image. - + Shows LSB vs DCT capacity and helps choose the right mode. Optionally checks if a specific payload would fit. - + \b Examples: stegasoo compare carrier.png @@ -1187,16 +1167,16 @@ def compare(image, payload, size, as_json): """ try: image_data = Path(image).read_bytes() - + # Get payload size if provided payload_size = None if payload: payload_size = len(Path(payload).read_bytes()) elif size: payload_size = size - + comparison = compare_modes(image_data) - + if as_json: import json output_data = { @@ -1220,60 +1200,60 @@ def compare(image, payload, size, as_json): }, }, } - + if payload_size: output_data["payload_check"] = { "size_bytes": payload_size, "fits_lsb": payload_size <= comparison['lsb']['capacity_bytes'], "fits_dct": payload_size <= comparison['dct']['capacity_bytes'], } - + click.echo(json.dumps(output_data, indent=2)) return - + click.echo() click.secho(f"=== Mode Comparison: {image} ===", fg='cyan', bold=True) click.echo(f" Dimensions: {comparison['width']} Γ— {comparison['height']}") click.echo() - + # LSB mode click.secho(" β”Œβ”€β”€β”€ LSB Mode ───", fg='green') click.echo(f" β”‚ Capacity: {comparison['lsb']['capacity_bytes']:,} bytes ({comparison['lsb']['capacity_kb']:.1f} KB)") click.echo(f" β”‚ Output: {comparison['lsb']['output']}") - click.echo(f" β”‚ Status: βœ“ Available") + click.echo(" β”‚ Status: βœ“ Available") click.echo(" β”‚") - + # DCT mode click.secho(" β”œβ”€β”€β”€ DCT Mode ───", fg='blue') click.echo(f" β”‚ Capacity: {comparison['dct']['capacity_bytes']:,} bytes ({comparison['dct']['capacity_kb']:.1f} KB)") click.echo(f" β”‚ Ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB capacity") if comparison['dct']['available']: - click.echo(f" β”‚ Status: βœ“ Available") - click.echo(f" β”‚ Formats: PNG (lossless), JPEG (smaller)") - click.echo(f" β”‚ Colors: Grayscale (default), Color") + click.echo(" β”‚ Status: βœ“ Available") + click.echo(" β”‚ Formats: PNG (lossless), JPEG (smaller)") + click.echo(" β”‚ Colors: Grayscale (default), Color") else: - click.secho(f" β”‚ Status: βœ— Requires scipy (pip install scipy)", fg='yellow') + click.secho(" β”‚ Status: βœ— Requires scipy (pip install scipy)", fg='yellow') click.echo(" β”‚") - + # Payload check if payload_size: click.secho(" β”œβ”€β”€β”€ Payload Check ───", fg='magenta') click.echo(f" β”‚ Size: {payload_size:,} bytes") - + fits_lsb = payload_size <= comparison['lsb']['capacity_bytes'] fits_dct = payload_size <= comparison['dct']['capacity_bytes'] - + lsb_icon = "βœ“" if fits_lsb else "βœ—" dct_icon = "βœ“" if fits_dct else "βœ—" lsb_color = 'green' if fits_lsb else 'red' dct_color = 'green' if fits_dct else 'red' - - click.echo(f" β”‚ LSB mode: ", nl=False) + + click.echo(" β”‚ LSB mode: ", nl=False) click.secho(f"{lsb_icon} {'Fits' if fits_lsb else 'Too large'}", fg=lsb_color) - click.echo(f" β”‚ DCT mode: ", nl=False) + click.echo(" β”‚ DCT mode: ", nl=False) click.secho(f"{dct_icon} {'Fits' if fits_dct else 'Too large'}", fg=dct_color) click.echo(" β”‚") - + # Recommendation click.secho(" └─── Recommendation ───", fg='yellow') if not comparison['dct']['available']: @@ -1289,9 +1269,9 @@ def compare(image, payload, size, as_json): else: click.echo(" LSB for larger payloads, DCT for better stealth") click.echo(" DCT supports color output with --dct-color color") - + click.echo() - + except Exception as e: raise click.ClickException(str(e)) @@ -1303,16 +1283,16 @@ def compare(image, payload, size, as_json): @cli.command('strip-metadata') @click.argument('image', type=click.Path(exists=True)) @click.option('--output', '-o', type=click.Path(), help='Output file (default: overwrites input)') -@click.option('--format', '-f', 'output_format', type=click.Choice(['PNG', 'BMP']), default='PNG', +@click.option('--format', '-f', 'output_format', type=click.Choice(['PNG', 'BMP']), default='PNG', help='Output format') @click.option('--quiet', '-q', is_flag=True, help='Suppress output') def strip_metadata_cmd(image, output, output_format, quiet): """ Remove all metadata (EXIF, GPS, etc.) from an image. - + Creates a clean image with only pixel data - no camera info, location data, timestamps, or other potentially sensitive metadata. - + \b Examples: stegasoo strip-metadata photo.jpg -o clean.png @@ -1320,26 +1300,26 @@ def strip_metadata_cmd(image, output, output_format, quiet): """ if not HAS_STRIP_METADATA: raise click.ClickException("strip_image_metadata not available") - + try: image_data = Path(image).read_bytes() original_size = len(image_data) - + clean_data = strip_image_metadata(image_data, output_format) - + if output: out_path = Path(output) else: # Replace extension with output format out_path = Path(image).with_suffix(f'.{output_format.lower()}') - + out_path.write_bytes(clean_data) - + if not quiet: click.secho("βœ“ Metadata stripped", fg='green') click.echo(f" Input: {image} ({original_size:,} bytes)") click.echo(f" Output: {out_path} ({len(clean_data):,} bytes)") - + except Exception as e: raise click.ClickException(str(e)) @@ -1352,13 +1332,13 @@ def strip_metadata_cmd(image, output, output_format, quiet): def modes(): """ Show available embedding modes and their status. - + Displays which modes are available and their characteristics. """ click.echo() click.secho("=== Stegasoo Embedding Modes (v4.0.0) ===", fg='cyan', bold=True) click.echo() - + # LSB Mode click.secho(" LSB Mode (Spatial LSB)", fg='green', bold=True) click.echo(" Status: βœ“ Always available") @@ -1367,7 +1347,7 @@ def modes(): click.echo(" Use case: Larger payloads, color preservation") click.echo(" CLI flag: --mode lsb (default)") click.echo() - + # DCT Mode click.secho(" DCT Mode (Frequency Domain)", fg='blue', bold=True) if has_dct_support(): @@ -1379,7 +1359,7 @@ def modes(): click.echo(" Use case: Better stealth, frequency domain hiding") click.echo(" CLI flag: --mode dct") click.echo() - + # DCT Options click.secho(" DCT Options", fg='magenta', bold=True) click.echo(" Output format:") @@ -1390,7 +1370,7 @@ def modes(): click.echo(" --dct-color grayscale Traditional DCT (default)") click.echo(" --dct-color color Preserves original colors") click.echo() - + # Channel Key Status (v4.0.0) click.secho(" Channel Key (v4.0.0)", fg='cyan', bold=True) status = get_channel_status() @@ -1408,14 +1388,14 @@ def modes(): click.echo(" --channel-file F Read key from file") click.echo(" --no-channel Force public mode") click.echo() - + # v4.0.0 Changes click.secho(" v4.0.0 Changes:", fg='cyan', bold=True) click.echo(" βœ“ Channel key support for deployment isolation") click.echo(" βœ“ New `stegasoo channel` command group") click.echo(" βœ“ Messages encoded with channel key require same key to decode") click.echo() - + # Examples click.secho(" Examples:", dim=True) click.echo(" # Generate channel key") diff --git a/frontends/web/app.py b/frontends/web/app.py index 99c17d8..d47c6bd 100644 --- a/frontends/web/app.py +++ b/frontends/web/app.py @@ -22,20 +22,16 @@ NEW in v3.0.1: DCT output format selection (PNG or JPEG) and color mode (graysca """ import io +import mimetypes +import os +import secrets import sys import time -import secrets -import mimetypes from pathlib import Path -from datetime import datetime + +from flask import Flask, flash, jsonify, redirect, render_template, request, send_file, url_for from PIL import Image -from flask import ( - Flask, render_template, request, send_file, - jsonify, flash, redirect, url_for -) - -import os os.environ['NUMPY_MADVISE_HUGEPAGE'] = '0' os.environ['OMP_NUM_THREADS'] = '1' @@ -44,75 +40,76 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src')) import stegasoo from stegasoo import ( - generate_credentials, - export_rsa_key_pem, load_rsa_key, - validate_pin, validate_message, validate_image, - validate_rsa_key, validate_security_factors, - validate_file_payload, validate_passphrase, - generate_filename, - StegasooError, DecryptionError, CapacityError, - has_argon2, + CapacityError, + DecryptionError, FilePayload, - # Embedding modes - EMBED_MODE_LSB, - EMBED_MODE_DCT, - EMBED_MODE_AUTO, - has_dct_support, - # Channel key functions (v4.0.0) - has_channel_key, + StegasooError, + export_rsa_key_pem, + generate_credentials, + generate_filename, get_channel_status, + has_argon2, + # Channel key functions (v4.0.0) + has_dct_support, + load_rsa_key, validate_channel_key, - generate_channel_key, - # NOTE: encode, decode, compare_modes, will_fit_by_mode now use subprocess isolation + validate_file_payload, + validate_image, + validate_message, + validate_passphrase, + validate_pin, + validate_rsa_key, + validate_security_factors, ) from stegasoo.constants import ( - __version__, - MAX_MESSAGE_SIZE, MAX_MESSAGE_CHARS, - MIN_PIN_LENGTH, MAX_PIN_LENGTH, - MIN_PASSPHRASE_WORDS, RECOMMENDED_PASSPHRASE_WORDS, DEFAULT_PASSPHRASE_WORDS, - VALID_RSA_SIZES, MAX_FILE_SIZE, - MAX_FILE_PAYLOAD_SIZE, MAX_UPLOAD_SIZE, - TEMP_FILE_EXPIRY, TEMP_FILE_EXPIRY_MINUTES, - THUMBNAIL_SIZE, THUMBNAIL_QUALITY, + MAX_FILE_PAYLOAD_SIZE, + MAX_FILE_SIZE, + MAX_MESSAGE_CHARS, + MAX_PIN_LENGTH, + MAX_UPLOAD_SIZE, + MIN_PASSPHRASE_WORDS, + MIN_PIN_LENGTH, + RECOMMENDED_PASSPHRASE_WORDS, + TEMP_FILE_EXPIRY, + TEMP_FILE_EXPIRY_MINUTES, + THUMBNAIL_QUALITY, + THUMBNAIL_SIZE, + VALID_RSA_SIZES, + __version__, ) # QR Code support try: - import qrcode - from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M + import qrcode # noqa: F401 + from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M # noqa: F401 HAS_QRCODE = True except ImportError: HAS_QRCODE = False # QR Code reading try: - from pyzbar.pyzbar import decode as pyzbar_decode + from pyzbar.pyzbar import decode as pyzbar_decode # noqa: F401 HAS_QRCODE_READ = True except ImportError: HAS_QRCODE_READ = False -import zlib -import base64 # Import QR utilities -from stegasoo.qr_utils import ( - compress_data, decompress_data, auto_decompress, - is_compressed, can_fit_in_qr, needs_compression, - generate_qr_code, read_qr_code, extract_key_from_qr, - detect_and_crop_qr, - has_qr_write, has_qr_read, - QR_MAX_BINARY, COMPRESSION_PREFIX -) - # ============================================================================ # SUBPROCESS ISOLATION FOR STEGASOO OPERATIONS # ============================================================================ # Runs encode/decode/compare in subprocesses to prevent jpegio/scipy crashes # from taking down the Flask server. - from subprocess_stego import SubprocessStego +from stegasoo.qr_utils import ( + can_fit_in_qr, + detect_and_crop_qr, + extract_key_from_qr, + generate_qr_code, +) + # Initialize subprocess wrapper (worker script must be in same directory) subprocess_stego = SubprocessStego(timeout=180) # 3 minute timeout for large images @@ -139,7 +136,7 @@ def inject_globals(): """Inject global variables into all templates.""" # Get channel status (v4.0.0) channel_status = get_channel_status() - + return { 'version': __version__, 'max_message_chars': MAX_MESSAGE_CHARS, @@ -172,20 +169,20 @@ try: print(f"Current MAX_FILE_PAYLOAD_SIZE: {MAX_FILE_PAYLOAD_SIZE}") print(f"DCT support: {has_dct_support()}") print(f"QR code support: write={HAS_QRCODE}, read={HAS_QRCODE_READ}") - + # Channel key status (v4.0.0) channel_status = get_channel_status() print(f"Channel key: {channel_status['mode']} mode") if channel_status['configured']: print(f" Fingerprint: {channel_status.get('fingerprint')}") print(f" Source: {channel_status.get('source')}") - + DESIRED_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2MB - + if hasattr(stegasoo, 'MAX_FILE_PAYLOAD_SIZE'): print(f"Overriding MAX_FILE_PAYLOAD_SIZE to {DESIRED_PAYLOAD_SIZE}") stegasoo.MAX_FILE_PAYLOAD_SIZE = DESIRED_PAYLOAD_SIZE - + except Exception as e: print(f"Could not override stegasoo limits: {e}") @@ -197,10 +194,10 @@ except Exception as e: def resolve_channel_key_form(channel_key_value: str) -> str: """ Resolve channel key from form input. - + Args: channel_key_value: Form value ('auto', 'none', or explicit key) - + Returns: Value to pass to subprocess_stego ('auto', 'none', or explicit key) """ @@ -234,10 +231,10 @@ def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes img = img.convert('RGB') elif img.mode != 'RGB': img = img.convert('RGB') - + # Create thumbnail img.thumbnail(size, Image.Resampling.LANCZOS) - + # Save to bytes buffer = io.BytesIO() img.save(buffer, format='JPEG', quality=THUMBNAIL_QUALITY, optimize=True) @@ -251,7 +248,7 @@ def cleanup_temp_files(): """Remove expired temporary files.""" now = time.time() expired = [fid for fid, info in TEMP_FILES.items() if now - info['timestamp'] > TEMP_FILE_EXPIRY] - + for fid in expired: TEMP_FILES.pop(fid, None) # Also clean up corresponding thumbnail @@ -294,12 +291,12 @@ def index(): def api_channel_status(): """ Get current channel key status (v4.0.0). - + Returns JSON with mode, fingerprint, and source. """ # Use subprocess for isolation result = subprocess_stego.get_channel_status(reveal=False) - + if result.success: return jsonify({ 'success': True, @@ -324,16 +321,16 @@ def api_channel_status(): def api_channel_validate(): """ Validate a channel key format (v4.0.0). - + Returns JSON with validation result. """ key = request.form.get('key', '') or request.json.get('key', '') if request.is_json else '' - + if not key: return jsonify({'valid': False, 'error': 'No key provided'}) - + is_valid = validate_channel_key(key) - + if is_valid: fingerprint = f"{key[:4]}-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-{key[-4:]}" return jsonify({ @@ -358,20 +355,20 @@ def generate(): words_per_passphrase = int(request.form.get('words_per_passphrase', DEFAULT_PASSPHRASE_WORDS)) use_pin = request.form.get('use_pin') == 'on' use_rsa = request.form.get('use_rsa') == 'on' - + if not use_pin and not use_rsa: flash('You must select at least one security factor (PIN or RSA Key)', 'error') return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) - + pin_length = int(request.form.get('pin_length', 6)) rsa_bits = int(request.form.get('rsa_bits', 2048)) - + # Clamp values words_per_passphrase = max(MIN_PASSPHRASE_WORDS, min(12, words_per_passphrase)) pin_length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, pin_length)) if rsa_bits not in VALID_RSA_SIZES: rsa_bits = 2048 - + try: # v3.2.0 FIX: Use correct parameter name 'passphrase_words' creds = generate_credentials( @@ -381,19 +378,19 @@ def generate(): rsa_bits=rsa_bits, passphrase_words=words_per_passphrase, # FIX: was words_per_passphrase= ) - + # Store RSA key temporarily for QR generation qr_token = None qr_needs_compression = False qr_too_large = False - + if creds.rsa_key_pem and HAS_QRCODE: # Check if key fits in QR code if can_fit_in_qr(creds.rsa_key_pem, compress=True): qr_needs_compression = True else: qr_too_large = True - + if not qr_too_large: qr_token = secrets.token_urlsafe(16) cleanup_temp_files() @@ -404,7 +401,7 @@ def generate(): 'type': 'rsa_key', 'compress': qr_needs_compression } - + # v3.2.0: Single passphrase instead of daily phrases return render_template('generate.html', passphrase=creds.passphrase, # v3.2.0: Single passphrase @@ -428,7 +425,7 @@ def generate(): except Exception as e: flash(f'Error generating credentials: {e}', 'error') return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) - + return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) @@ -437,19 +434,19 @@ def generate_qr(token): """Generate QR code for RSA key.""" if not HAS_QRCODE: return "QR code support not available", 501 - + if token not in TEMP_FILES: return "Token expired or invalid", 404 - + file_info = TEMP_FILES[token] if file_info.get('type') != 'rsa_key': return "Invalid token type", 400 - + try: key_pem = file_info['data'].decode('utf-8') compress = file_info.get('compress', False) qr_png = generate_qr_code(key_pem, compress=compress) - + return send_file( io.BytesIO(qr_png), mimetype='image/png', @@ -464,19 +461,19 @@ def generate_qr_download(token): """Download QR code as PNG file.""" if not HAS_QRCODE: return "QR code support not available", 501 - + if token not in TEMP_FILES: return "Token expired or invalid", 404 - + file_info = TEMP_FILES[token] if file_info.get('type') != 'rsa_key': return "Invalid token type", 400 - + try: key_pem = file_info['data'].decode('utf-8') compress = file_info.get('compress', False) qr_png = generate_qr_code(key_pem, compress=compress) - + return send_file( io.BytesIO(qr_png), mimetype='image/png', @@ -491,29 +488,29 @@ def generate_qr_download(token): def qr_crop(): """ Detect and crop QR code from an image. - + Useful for extracting QR codes from photos taken at an angle, with extra background, etc. Returns the cropped QR as PNG. """ if not HAS_QRCODE_READ: return jsonify({'error': 'QR code reading not available (install pyzbar)'}), 501 - + image_file = request.files.get('image') if not image_file: return jsonify({'error': 'No image provided'}), 400 - + try: image_data = image_file.read() - + # Use the new crop function cropped = detect_and_crop_qr(image_data) - + if cropped is None: return jsonify({'error': 'No QR code detected in image'}), 404 - + # Return as downloadable PNG or inline based on query param as_attachment = request.args.get('download', '').lower() in ('1', 'true', 'yes') - + return send_file( io.BytesIO(cropped), mimetype='image/png', @@ -567,18 +564,18 @@ def extract_key_from_qr_route(): 'success': False, 'error': 'QR code reading not available. Install pyzbar and libzbar.' }), 501 - + qr_image = request.files.get('qr_image') if not qr_image: return jsonify({ 'success': False, 'error': 'No QR image provided' }), 400 - + try: image_data = qr_image.read() key_pem = extract_key_from_qr(image_data) - + if key_pem: return jsonify({ 'success': True, @@ -589,7 +586,7 @@ def extract_key_from_qr_route(): 'success': False, 'error': 'No valid RSA key found in QR code' }), 400 - + except Exception as e: return jsonify({ 'success': False, @@ -611,16 +608,16 @@ def api_compare_capacity(): carrier = request.files.get('carrier') if not carrier: return jsonify({'error': 'No carrier image provided'}), 400 - + try: carrier_data = carrier.read() - + # Use subprocess-isolated compare_modes result = subprocess_stego.compare_modes(carrier_data) - + if not result.success: return jsonify({'error': result.error or 'Comparison failed'}), 500 - + return jsonify({ 'success': True, 'width': result.width, @@ -652,29 +649,29 @@ def api_check_fit(): carrier = request.files.get('carrier') payload_size = request.form.get('payload_size', type=int) embed_mode = request.form.get('embed_mode', 'lsb') - + if not carrier or payload_size is None: return jsonify({'error': 'Missing carrier or payload_size'}), 400 - + if embed_mode not in ('lsb', 'dct'): return jsonify({'error': 'Invalid embed_mode'}), 400 - + if embed_mode == 'dct' and not has_dct_support(): return jsonify({'error': 'DCT mode requires scipy'}), 400 - + try: carrier_data = carrier.read() - + # Use subprocess-isolated capacity check result = subprocess_stego.check_capacity( carrier_data=carrier_data, payload_size=payload_size, embed_mode=embed_mode, ) - + if not result.success: return jsonify({'error': result.error or 'Capacity check failed'}), 500 - + return jsonify({ 'success': True, 'fits': result.fits, @@ -701,55 +698,55 @@ def encode_page(): carrier = request.files.get('carrier') rsa_key_file = request.files.get('rsa_key') payload_file = request.files.get('payload_file') - + if not ref_photo or not carrier: flash('Both reference photo and carrier image are required', 'error') return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) - + if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename): flash('Invalid file type. Use PNG, JPG, or BMP', 'error') return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) - + # Get form data - v3.2.0: renamed from day_phrase to passphrase message = request.form.get('message', '') passphrase = request.form.get('passphrase', '') # v3.2.0: Renamed pin = request.form.get('pin', '').strip() rsa_password = request.form.get('rsa_password', '') payload_type = request.form.get('payload_type', 'text') - + # NEW in v3.0 - Embedding mode embed_mode = request.form.get('embed_mode', 'lsb') if embed_mode not in ('lsb', 'dct'): embed_mode = 'lsb' - + # NEW in v3.0.1 - DCT output format dct_output_format = request.form.get('dct_output_format', 'png') if dct_output_format not in ('png', 'jpeg'): dct_output_format = 'png' - + # NEW in v3.0.1 - DCT color mode dct_color_mode = request.form.get('dct_color_mode', 'color') if dct_color_mode not in ('grayscale', 'color'): dct_color_mode = 'color' - + # NEW in v4.0.0 - Channel key channel_key = resolve_channel_key_form(request.form.get('channel_key', 'auto')) - + # Check DCT availability if embed_mode == 'dct' and not has_dct_support(): flash('DCT mode requires scipy. Install with: pip install scipy', 'error') return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) - + # Determine payload if payload_type == 'file' and payload_file and payload_file.filename: # File payload file_data = payload_file.read() - + result = validate_file_payload(file_data, payload_file.filename) if not result.is_valid: flash(result.error_message, 'error') return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) - + mime_type, _ = mimetypes.guess_type(payload_file.filename) payload = FilePayload( data=file_data, @@ -763,31 +760,31 @@ def encode_page(): flash(result.error_message, 'error') return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) payload = message - + # v3.2.0: Renamed from day_phrase if not passphrase: flash('Passphrase is required', 'error') return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) - + # v3.2.0: Validate passphrase result = validate_passphrase(passphrase) if not result.is_valid: flash(result.error_message, 'error') return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) - + # Show warning if passphrase is short if result.warning: flash(result.warning, 'warning') - + # Read files ref_data = ref_photo.read() carrier_data = carrier.read() - + # Handle RSA key - can come from .pem file or QR code image rsa_key_data = None rsa_key_qr = request.files.get('rsa_key_qr') rsa_key_from_qr = False - + if 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: @@ -799,36 +796,36 @@ def encode_page(): else: flash('Could not extract RSA key from QR code image.', 'error') return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) - + # Validate security factors result = validate_security_factors(pin, rsa_key_data) if not result.is_valid: flash(result.error_message, 'error') return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) - + # Validate PIN if provided if pin: result = validate_pin(pin) if not result.is_valid: flash(result.error_message, 'error') return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) - + # Determine key password key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) - + # Validate RSA key if provided 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('encode.html', has_qrcode_read=HAS_QRCODE_READ) - + # Validate carrier image result = validate_image(carrier_data, "Carrier image") if not result.is_valid: flash(result.error_message, 'error') return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) - + # v4.0.0: Include channel_key parameter # Use subprocess-isolated encode to prevent crashes if payload_type == 'file' and payload_file and payload_file.filename: @@ -861,14 +858,14 @@ def encode_page(): dct_color_mode=dct_color_mode if embed_mode == 'dct' else 'color', channel_key=channel_key, # v4.0.0 ) - + # Check for subprocess errors if not encode_result.success: error_msg = encode_result.error or 'Encoding failed' if 'capacity' in error_msg.lower(): raise CapacityError(error_msg) raise StegasooError(error_msg) - + # Determine actual output format for filename and storage if embed_mode == 'dct' and dct_output_format == 'jpeg': output_ext = '.jpg' @@ -876,14 +873,14 @@ def encode_page(): else: output_ext = '.png' output_mime = 'image/png' - + # Use filename from result or generate one filename = encode_result.filename if not filename: filename = generate_filename('stego', output_ext) elif embed_mode == 'dct' and dct_output_format == 'jpeg' and filename.endswith('.png'): filename = filename[:-4] + '.jpg' - + # Store temporarily file_id = secrets.token_urlsafe(16) cleanup_temp_files() @@ -899,9 +896,9 @@ def encode_page(): 'channel_mode': encode_result.channel_mode, 'channel_fingerprint': encode_result.channel_fingerprint, } - + return redirect(url_for('encode_result', file_id=file_id)) - + except CapacityError as e: flash(str(e), 'error') return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) @@ -911,7 +908,7 @@ def encode_page(): except Exception as e: flash(f'Error: {e}', 'error') return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) - + return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) @@ -920,17 +917,17 @@ def encode_result(file_id): if file_id not in TEMP_FILES: flash('File expired or not found. Please encode again.', 'error') return redirect(url_for('encode_page')) - + file_info = TEMP_FILES[file_id] - + # Generate thumbnail thumbnail_data = generate_thumbnail(file_info['data']) thumbnail_id = None - + if thumbnail_data: thumbnail_id = f"{file_id}_thumb" THUMBNAIL_FILES[thumbnail_id] = thumbnail_data - + return render_template('encode_result.html', file_id=file_id, filename=file_info['filename'], @@ -949,7 +946,7 @@ def encode_thumbnail(thumb_id): """Serve thumbnail image.""" if thumb_id not in THUMBNAIL_FILES: return "Thumbnail not found", 404 - + return send_file( io.BytesIO(THUMBNAIL_FILES[thumb_id]), mimetype='image/jpeg', @@ -962,10 +959,10 @@ def encode_download(file_id): if file_id not in TEMP_FILES: flash('File expired or not found.', 'error') return redirect(url_for('encode_page')) - + file_info = TEMP_FILES[file_id] mime_type = file_info.get('mime_type', 'image/png') - + return send_file( io.BytesIO(file_info['data']), mimetype=mime_type, @@ -979,10 +976,10 @@ def encode_file_route(file_id): """Serve file for Web Share API.""" if file_id not in TEMP_FILES: return "Not found", 404 - + file_info = TEMP_FILES[file_id] mime_type = file_info.get('mime_type', 'image/png') - + return send_file( io.BytesIO(file_info['data']), mimetype=mime_type, @@ -995,11 +992,11 @@ def encode_file_route(file_id): def encode_cleanup(file_id): """Manually cleanup a file after sharing.""" TEMP_FILES.pop(file_id, None) - + # Also cleanup thumbnail if exists thumb_id = f"{file_id}_thumb" THUMBNAIL_FILES.pop(thumb_id, None) - + return jsonify({'status': 'ok'}) @@ -1015,45 +1012,45 @@ def decode_page(): ref_photo = request.files.get('reference_photo') stego_image = request.files.get('stego_image') rsa_key_file = request.files.get('rsa_key') - + if not ref_photo or not stego_image: flash('Both reference photo and stego image are required', 'error') return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - + # Get form data - v3.2.0: renamed from day_phrase to passphrase passphrase = request.form.get('passphrase', '') # v3.2.0: Renamed pin = request.form.get('pin', '').strip() rsa_password = request.form.get('rsa_password', '') - + # NEW in v3.0 - Extraction mode embed_mode = request.form.get('embed_mode', 'auto') if embed_mode not in ('auto', 'lsb', 'dct'): embed_mode = 'auto' - + # NEW in v4.0.0 - Channel key channel_key = resolve_channel_key_form(request.form.get('channel_key', 'auto')) - + # Check DCT availability if embed_mode == 'dct' and not has_dct_support(): flash('DCT mode requires scipy. Install with: pip install scipy', 'error') return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - + # v3.2.0: Removed date handling (no stego_date needed) - + # v3.2.0: Renamed from day_phrase if not passphrase: flash('Passphrase is required', 'error') return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - + # Read files ref_data = ref_photo.read() stego_data = stego_image.read() - + # Handle RSA key - can come from .pem file or QR code image rsa_key_data = None rsa_key_qr = request.files.get('rsa_key_qr') rsa_key_from_qr = False - + if 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: @@ -1065,30 +1062,30 @@ def decode_page(): else: flash('Could not extract RSA key from QR code image.', 'error') return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - + # Validate security factors 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) - + # Validate PIN if provided 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) - + # Determine key password key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) - + # Validate RSA key if provided 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) - + # v4.0.0: Include channel_key parameter # Use subprocess-isolated decode to prevent crashes decode_result = subprocess_stego.decode( @@ -1101,7 +1098,7 @@ def decode_page(): embed_mode=embed_mode, channel_key=channel_key, # v4.0.0 ) - + # Check for subprocess errors if not decode_result.success: error_msg = decode_result.error or 'Decoding failed' @@ -1112,12 +1109,12 @@ def decode_page(): if 'decrypt' in error_msg.lower() or decode_result.error_type == 'DecryptionError': raise DecryptionError(error_msg) raise StegasooError(error_msg) - + if decode_result.is_file: # File content - store temporarily for download file_id = secrets.token_urlsafe(16) cleanup_temp_files() - + filename = decode_result.filename or 'decoded_file' TEMP_FILES[file_id] = { 'data': decode_result.file_data, @@ -1125,7 +1122,7 @@ def decode_page(): 'mime_type': decode_result.mime_type, 'timestamp': time.time() } - + return render_template('decode.html', decoded_file=True, file_id=file_id, @@ -1136,11 +1133,11 @@ def decode_page(): ) else: # Text content - return render_template('decode.html', + return render_template('decode.html', decoded_message=decode_result.message, has_qrcode_read=HAS_QRCODE_READ ) - + except DecryptionError: flash('Decryption failed. Check your passphrase, PIN, RSA key, reference photo, and channel key.', 'error') return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) @@ -1150,7 +1147,7 @@ def decode_page(): except Exception as e: flash(f'Error: {e}', 'error') return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) @@ -1160,10 +1157,10 @@ def decode_download(file_id): if file_id not in TEMP_FILES: flash('File expired or not found.', 'error') return redirect(url_for('decode_page')) - + file_info = TEMP_FILES[file_id] mime_type = file_info.get('mime_type', 'application/octet-stream') - + return send_file( io.BytesIO(file_info['data']), mimetype=mime_type, @@ -1174,7 +1171,7 @@ def decode_download(file_id): @app.route('/about') def about(): - return render_template('about.html', + return render_template('about.html', has_argon2=has_argon2(), has_qrcode_read=HAS_QRCODE_READ ) @@ -1188,7 +1185,7 @@ def test_capacity(): carrier = request.files.get('carrier') if not carrier: return jsonify({'error': 'No carrier image provided'}), 400 - + try: carrier_data = carrier.read() buffer = io.BytesIO(carrier_data) @@ -1197,11 +1194,11 @@ def test_capacity(): fmt = img.format img.close() buffer.close() - + pixels = width * height lsb_bytes = (pixels * 3) // 8 dct_bytes = ((width // 8) * (height // 8) * 16) // 8 - 10 - + return jsonify({ 'success': True, 'width': width, @@ -1220,7 +1217,7 @@ def test_capacity_nopil(): carrier = request.files.get('carrier') if not carrier: return jsonify({'error': 'No carrier image provided'}), 400 - + carrier_data = carrier.read() return jsonify({ 'success': True, diff --git a/frontends/web/stego_worker.py b/frontends/web/stego_worker.py index 0de2084..6e1adfb 100644 --- a/frontends/web/stego_worker.py +++ b/frontends/web/stego_worker.py @@ -17,9 +17,9 @@ Usage: echo '{"operation": "encode", ...}' | python stego_worker.py """ -import sys -import json import base64 +import json +import sys import traceback from pathlib import Path @@ -31,10 +31,10 @@ sys.path.insert(0, str(Path(__file__).parent)) def _resolve_channel_key(channel_key_param): """ Resolve channel_key parameter to value for stegasoo. - + Args: channel_key_param: 'auto', 'none', explicit key, or None - + Returns: None (auto), "" (public), or explicit key string """ @@ -49,41 +49,41 @@ def _resolve_channel_key(channel_key_param): def _get_channel_info(resolved_key): """ Get channel mode and fingerprint for response. - + Returns: (mode, fingerprint) tuple """ - from stegasoo import has_channel_key, get_channel_status - + from stegasoo import get_channel_status, has_channel_key + if resolved_key == "": return "public", None - + if resolved_key is not None: # Explicit key fingerprint = f"{resolved_key[:4]}-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-{resolved_key[-4:]}" return "private", fingerprint - + # Auto mode - check server config if has_channel_key(): status = get_channel_status() return "private", status.get('fingerprint') - + return "public", None def encode_operation(params: dict) -> dict: """Handle encode operation.""" - from stegasoo import encode, FilePayload - + from stegasoo import FilePayload, encode + # Decode base64 inputs 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']) @@ -94,10 +94,10 @@ def encode_operation(params: dict) -> dict: ) else: payload = params.get('message', '') - + # Resolve channel key (v4.0.0) resolved_channel_key = _resolve_channel_key(params.get('channel_key', 'auto')) - + # Call encode with correct parameter names result = encode( message=payload, @@ -112,7 +112,7 @@ def encode_operation(params: dict) -> dict: dct_color_mode=params.get('dct_color_mode', 'color'), channel_key=resolved_channel_key, # v4.0.0 ) - + # Build stats dict if available stats = None if hasattr(result, 'stats') and result.stats: @@ -121,10 +121,10 @@ def encode_operation(params: dict) -> dict: 'capacity_used': getattr(result.stats, 'capacity_used', 0), 'bytes_embedded': getattr(result.stats, 'bytes_embedded', 0), } - + # Get channel info for response (v4.0.0) channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key) - + return { 'success': True, 'stego_b64': base64.b64encode(result.stego_image).decode('ascii'), @@ -138,19 +138,19 @@ def encode_operation(params: dict) -> dict: def decode_operation(params: dict) -> dict: """Handle decode operation.""" from stegasoo import decode - + # Decode base64 inputs stego_data = base64.b64decode(params['stego_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']) - + # Resolve channel key (v4.0.0) resolved_channel_key = _resolve_channel_key(params.get('channel_key', 'auto')) - + # Call decode with correct parameter names result = decode( stego_image=stego_data, @@ -162,7 +162,7 @@ def decode_operation(params: dict) -> dict: embed_mode=params.get('embed_mode', 'auto'), channel_key=resolved_channel_key, # v4.0.0 ) - + if result.is_file: return { 'success': True, @@ -182,10 +182,10 @@ def decode_operation(params: dict) -> dict: def compare_operation(params: dict) -> dict: """Handle compare_modes operation.""" from stegasoo import compare_modes - + carrier_data = base64.b64decode(params['carrier_b64']) result = compare_modes(carrier_data) - + return { 'success': True, 'comparison': result, @@ -195,15 +195,15 @@ def compare_operation(params: dict) -> dict: def capacity_check_operation(params: dict) -> dict: """Handle will_fit_by_mode operation.""" from stegasoo import will_fit_by_mode - + carrier_data = base64.b64decode(params['carrier_b64']) - + result = will_fit_by_mode( payload=params['payload_size'], carrier_image=carrier_data, embed_mode=params.get('embed_mode', 'lsb'), ) - + return { 'success': True, 'result': result, @@ -213,10 +213,10 @@ def capacity_check_operation(params: dict) -> dict: def channel_status_operation(params: dict) -> dict: """Handle channel status check (v4.0.0).""" from stegasoo import get_channel_status - + status = get_channel_status() reveal = params.get('reveal', False) - + return { 'success': True, 'status': { @@ -234,13 +234,13 @@ def main(): try: # Read all input input_text = sys.stdin.read() - + if not input_text.strip(): output = {'success': False, 'error': 'No input provided'} else: params = json.loads(input_text) operation = params.get('operation') - + if operation == 'encode': output = encode_operation(params) elif operation == 'decode': @@ -253,7 +253,7 @@ def main(): output = channel_status_operation(params) else: output = {'success': False, 'error': f'Unknown operation: {operation}'} - + except json.JSONDecodeError as e: output = {'success': False, 'error': f'Invalid JSON: {e}'} except Exception as e: @@ -263,7 +263,7 @@ def main(): 'error_type': type(e).__name__, 'traceback': traceback.format_exc(), } - + # Write output as JSON print(json.dumps(output), flush=True) diff --git a/frontends/web/subprocess_stego.py b/frontends/web/subprocess_stego.py index b436f36..49c4ac2 100644 --- a/frontends/web/subprocess_stego.py +++ b/frontends/web/subprocess_stego.py @@ -10,9 +10,9 @@ CHANGES in v4.0.0: Usage: from subprocess_stego import SubprocessStego - + stego = SubprocessStego() - + # Encode with channel key result = stego.encode( carrier_data=carrier_bytes, @@ -23,13 +23,13 @@ Usage: 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, @@ -38,19 +38,18 @@ Usage: pin="123456", channel_key="auto", ) - + # Compare modes (capacity) result = stego.compare_modes(carrier_bytes) """ -import json import base64 +import json import subprocess import sys -from pathlib import Path from dataclasses import dataclass -from typing import Optional, Dict, Any, Union - +from pathlib import Path +from typing import Any # Default timeout for operations (seconds) DEFAULT_TIMEOUT = 120 @@ -63,14 +62,14 @@ WORKER_SCRIPT = Path(__file__).parent / 'stego_worker.py' class EncodeResult: """Result from encode operation.""" success: bool - stego_data: Optional[bytes] = None - filename: Optional[str] = None - stats: Optional[Dict[str, Any]] = None + stego_data: bytes | None = None + filename: str | None = None + stats: dict[str, Any] | None = None # Channel info (v4.0.0) - channel_mode: Optional[str] = None - channel_fingerprint: Optional[str] = None - error: Optional[str] = None - error_type: Optional[str] = None + channel_mode: str | None = None + channel_fingerprint: str | None = None + error: str | None = None + error_type: str | None = None @dataclass @@ -78,12 +77,12 @@ class DecodeResult: """Result from decode operation.""" success: bool is_file: bool = False - message: Optional[str] = None - file_data: Optional[bytes] = None - filename: Optional[str] = None - mime_type: Optional[str] = None - error: Optional[str] = None - error_type: Optional[str] = None + 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 @@ -92,9 +91,9 @@ class CompareResult: success: bool width: int = 0 height: int = 0 - lsb: Optional[Dict[str, Any]] = None - dct: Optional[Dict[str, Any]] = None - error: Optional[str] = None + lsb: dict[str, Any] | None = None + dct: dict[str, Any] | None = None + error: str | None = None @dataclass @@ -107,38 +106,38 @@ class CapacityResult: usage_percent: float = 0.0 headroom: int = 0 mode: str = "" - error: Optional[str] = None + error: str | None = None -@dataclass +@dataclass class ChannelStatusResult: """Result from channel status check (v4.0.0).""" success: bool mode: str = "public" configured: bool = False - fingerprint: Optional[str] = None - source: Optional[str] = None - key: Optional[str] = None - error: Optional[str] = None + 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 jpegio or scipy crashes, only the subprocess dies - Flask keeps running. """ - + def __init__( self, - worker_path: Optional[Path] = None, - python_executable: Optional[str] = None, + 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) @@ -147,24 +146,24 @@ class SubprocessStego: 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: Optional[int] = None) -> Dict[str, Any]: + + 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)], @@ -174,7 +173,7 @@ class SubprocessStego: timeout=timeout, cwd=str(self.worker_path.parent), ) - + if result.returncode != 0: # Worker crashed return { @@ -182,16 +181,16 @@ class SubprocessStego: '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, @@ -210,29 +209,29 @@ class SubprocessStego: 'error': str(e), 'error_type': type(e).__name__, } - + def encode( self, carrier_data: bytes, reference_data: bytes, - message: Optional[str] = None, - file_data: Optional[bytes] = None, - file_name: Optional[str] = None, - file_mime: Optional[str] = None, + message: str | None = None, + file_data: bytes | None = None, + file_name: str | None = None, + file_mime: str | None = None, passphrase: str = "", - pin: Optional[str] = None, - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, + 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: Optional[str] = "auto", - timeout: Optional[int] = None, + channel_key: str | None = "auto", + timeout: int | None = None, ) -> EncodeResult: """ Encode a message or file into an image. - + Args: carrier_data: Carrier image bytes reference_data: Reference photo bytes @@ -249,7 +248,7 @@ class SubprocessStego: 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 """ @@ -265,18 +264,18 @@ class SubprocessStego: 'dct_color_mode': dct_color_mode, 'channel_key': channel_key, # v4.0.0 } - + 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, @@ -292,23 +291,23 @@ class SubprocessStego: error=result.get('error', 'Unknown error'), error_type=result.get('error_type'), ) - + def decode( self, stego_data: bytes, reference_data: bytes, passphrase: str = "", - pin: Optional[str] = None, - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, + 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: Optional[str] = "auto", - timeout: Optional[int] = None, + channel_key: str | None = "auto", + timeout: int | None = None, ) -> DecodeResult: """ Decode a message or file from a stego image. - + Args: stego_data: Stego image bytes reference_data: Reference photo bytes @@ -319,7 +318,7 @@ class SubprocessStego: embed_mode: 'auto', 'lsb', or 'dct' channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0) timeout: Operation timeout in seconds - + Returns: DecodeResult with message or file_data on success """ @@ -332,13 +331,13 @@ class SubprocessStego: 'embed_mode': embed_mode, 'channel_key': channel_key, # v4.0.0 } - + 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( @@ -360,19 +359,19 @@ class SubprocessStego: error=result.get('error', 'Unknown error'), error_type=result.get('error_type'), ) - + def compare_modes( self, carrier_data: bytes, - timeout: Optional[int] = None, + 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 """ @@ -380,9 +379,9 @@ class SubprocessStego: '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( @@ -397,23 +396,23 @@ class SubprocessStego: success=False, error=result.get('error', 'Unknown error'), ) - + def check_capacity( self, carrier_data: bytes, payload_size: int, embed_mode: str = "lsb", - timeout: Optional[int] = None, + 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 """ @@ -423,9 +422,9 @@ class SubprocessStego: 'payload_size': payload_size, 'embed_mode': embed_mode, } - + result = self._run_worker(params, timeout) - + if result.get('success'): r = result.get('result', {}) return CapacityResult( @@ -442,19 +441,19 @@ class SubprocessStego: success=False, error=result.get('error', 'Unknown error'), ) - + def get_channel_status( self, reveal: bool = False, - timeout: Optional[int] = None, + 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 """ @@ -462,9 +461,9 @@ class SubprocessStego: 'operation': 'channel_status', 'reveal': reveal, } - + result = self._run_worker(params, timeout) - + if result.get('success'): status = result.get('status', {}) return ChannelStatusResult( @@ -483,7 +482,7 @@ class SubprocessStego: # Convenience function for quick usage -_default_stego: Optional[SubprocessStego] = None +_default_stego: SubprocessStego | None = None def get_subprocess_stego() -> SubprocessStego: diff --git a/frontends/web/test_routes.py b/frontends/web/test_routes.py deleted file mode 100644 index ae0c4c9..0000000 --- a/frontends/web/test_routes.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -Minimal test to isolate the memory corruption crash. - -Add this route to your app.py temporarily to test if the crash -is in Flask/Pillow or in stegasoo code. - -Usage: -1. Add this code to app.py -2. Restart the server -3. Use the /test-capacity endpoint instead of /api/compare-capacity -4. If it crashes: Flask or Pillow issue -5. If it works: Stegasoo code issue -""" - -# Add these imports at the top of app.py if not present: -# from PIL import Image -# import io - -# Add this route to app.py: - -@app.route('/test-capacity', methods=['POST']) -def test_capacity(): - """ - Minimal capacity test - no stegasoo code, just PIL. - """ - carrier = request.files.get('carrier') - if not carrier: - return jsonify({'error': 'No carrier image provided'}), 400 - - try: - # Read the file data - carrier_data = carrier.read() - - # Method 1: Just get size from PIL - buffer = io.BytesIO(carrier_data) - img = Image.open(buffer) - width, height = img.size - fmt = img.format - mode = img.mode - img.close() - buffer.close() - - # Simple capacity calculation (no scipy, no numpy) - pixels = width * height - lsb_bytes = (pixels * 3) // 8 - blocks = (width // 8) * (height // 8) - dct_bytes = (blocks * 16) // 8 - 10 - - return jsonify({ - 'success': True, - 'width': width, - 'height': height, - 'format': fmt, - 'mode': mode, - 'lsb': { - 'capacity_bytes': lsb_bytes, - 'capacity_kb': round(lsb_bytes / 1024, 1), - }, - 'dct': { - 'capacity_bytes': dct_bytes, - 'capacity_kb': round(dct_bytes / 1024, 1), - } - }) - except Exception as e: - import traceback - return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500 - - -# Alternative: completely bypass PIL too -@app.route('/test-capacity-nopil', methods=['POST']) -def test_capacity_nopil(): - """ - Ultra-minimal test - no PIL, no stegasoo. - """ - carrier = request.files.get('carrier') - if not carrier: - return jsonify({'error': 'No carrier image provided'}), 400 - - try: - carrier_data = carrier.read() - - # Just return size info, no image processing at all - return jsonify({ - 'success': True, - 'data_size': len(carrier_data), - 'first_bytes': carrier_data[:20].hex() if len(carrier_data) >= 20 else carrier_data.hex(), - }) - except Exception as e: - import traceback - return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500 diff --git a/minimal_flask_crash.py b/minimal_flask_crash.py index e4e5eb1..8ab880e 100644 --- a/minimal_flask_crash.py +++ b/minimal_flask_crash.py @@ -39,19 +39,19 @@ def test1_pil_only(): carrier = request.files.get('carrier') if not carrier: return jsonify({'error': 'No carrier'}), 400 - + data = carrier.read() print(f"[test1] Read {len(data)} bytes") - + img = Image.open(io.BytesIO(data)) width, height = img.size fmt = img.format img.close() print(f"[test1] Image: {width}x{height} {fmt}") - + gc.collect() print("[test1] Returning response...") - + return jsonify({ 'test': 'pil_only', 'width': width, @@ -66,31 +66,31 @@ def test2_multiple_opens(): carrier = request.files.get('carrier') if not carrier: return jsonify({'error': 'No carrier'}), 400 - + data = carrier.read() print(f"[test2] Read {len(data)} bytes") - + # First open img1 = Image.open(io.BytesIO(data)) width, height = img1.size img1.close() print(f"[test2] Open 1: {width}x{height}") - + # Second open img2 = Image.open(io.BytesIO(data)) pixels = img2.size[0] * img2.size[1] img2.close() print(f"[test2] Open 2: {pixels} pixels") - + # Third open img3 = Image.open(io.BytesIO(data)) blocks = (img3.size[0] // 8) * (img3.size[1] // 8) img3.close() print(f"[test2] Open 3: {blocks} blocks") - + gc.collect() print("[test2] Returning response...") - + return jsonify({ 'test': 'multiple_opens', 'width': width, @@ -105,39 +105,39 @@ def test3_with_jpegio(): """Test 3: Include jpegio operations""" if not HAS_JPEGIO: return jsonify({'error': 'jpegio not available'}), 501 - + carrier = request.files.get('carrier') if not carrier: return jsonify({'error': 'No carrier'}), 400 - + data = carrier.read() print(f"[test3] Read {len(data)} bytes") - + # Check if JPEG img = Image.open(io.BytesIO(data)) is_jpeg = img.format == 'JPEG' width, height = img.size img.close() print(f"[test3] Image: {width}x{height}, JPEG: {is_jpeg}") - + if not is_jpeg: return jsonify({'error': 'Not a JPEG'}), 400 - + # Write to temp file fd, temp_path = tempfile.mkstemp(suffix='.jpg') os.write(fd, data) os.close(fd) print(f"[test3] Temp file: {temp_path}") - + try: # Read with jpegio jpeg = jio.read(temp_path) print(f"[test3] jpegio.read() OK") - + coef = jpeg.coef_arrays[0] coef_shape = coef.shape print(f"[test3] Coef shape: {coef_shape}") - + # Count positions like the real code does positions = 0 h, w = coef.shape @@ -148,19 +148,19 @@ def test3_with_jpegio(): if abs(coef[row, col]) >= 2: positions += 1 print(f"[test3] Usable positions: {positions}") - + # Cleanup del coef del jpeg print(f"[test3] Deleted jpegio objects") - + finally: os.unlink(temp_path) print(f"[test3] Removed temp file") - + gc.collect() print("[test3] Returning response...") - + return jsonify({ 'test': 'with_jpegio', 'width': width, @@ -176,34 +176,34 @@ def test4_numpy_array_from_pil(): carrier = request.files.get('carrier') if not carrier: return jsonify({'error': 'No carrier'}), 400 - + data = carrier.read() print(f"[test4] Read {len(data)} bytes") - + img = Image.open(io.BytesIO(data)) width, height = img.size print(f"[test4] Image: {width}x{height}") - + # Convert to grayscale and numpy array gray = img.convert('L') arr = np.array(gray, dtype=np.float64, copy=True) print(f"[test4] Array: {arr.shape} {arr.dtype}") - + # Close PIL images gray.close() img.close() print(f"[test4] PIL closed") - + # Do some numpy operations mean_val = float(np.mean(arr)) std_val = float(np.std(arr)) print(f"[test4] Stats: mean={mean_val:.2f}, std={std_val:.2f}") - + # Clear array del arr gc.collect() print("[test4] Returning response...") - + return jsonify({ 'test': 'numpy_from_pil', 'width': width, @@ -219,32 +219,32 @@ def test5_file_read_keep_reference(): carrier = request.files.get('carrier') if not carrier: return jsonify({'error': 'No carrier'}), 400 - + # Don't read into local variable - read directly each time # This mimics potential issues with Flask's file handling - + print(f"[test5] File object: {carrier}") - + # Read once carrier.seek(0) data1 = carrier.read() print(f"[test5] First read: {len(data1)} bytes") - + img = Image.open(io.BytesIO(data1)) width, height = img.size img.close() - + # Try to read again (should be empty or need seek) data2 = carrier.read() print(f"[test5] Second read (no seek): {len(data2)} bytes") - + carrier.seek(0) data3 = carrier.read() print(f"[test5] Third read (after seek): {len(data3)} bytes") - + gc.collect() print("[test5] Returning response...") - + return jsonify({ 'test': 'file_handling', 'width': width, @@ -285,5 +285,5 @@ if __name__ == '__main__': print("\nUsage:") print(' curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test1') print("=" * 60 + "\n") - + app.run(host='0.0.0.0', port=5001, debug=False, threaded=False) diff --git a/pyproject.toml b/pyproject.toml index 585f135..04d2196 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,9 +114,18 @@ target-version = ["py310", "py311", "py312"] [tool.ruff] line-length = 100 +exclude = ["frontends/web/test_routes.py"] # Debug snippet, not a real module + +[tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] ignore = ["E501"] +[tool.ruff.lint.per-file-ignores] +# YCbCr colorspace variables (R, G, B, Y, Cb, Cr) are standard names +"src/stegasoo/dct_steganography.py" = ["N803", "N806"] +# Package __init__.py has imports after try/except and aliases - intentional structure +"src/stegasoo/__init__.py" = ["E402"] + [tool.mypy] python_version = "3.10" warn_return_any = true diff --git a/src/main.py b/src/main.py index 4809e26..1f395c1 100644 --- a/src/main.py +++ b/src/main.py @@ -17,7 +17,7 @@ import sys def main(): """ Main entry point for Stegasoo CLI. - + Delegates to the CLI module for command parsing and execution. """ try: diff --git a/src/stegasoo/__init__.py b/src/stegasoo/__init__.py index 5e25d3d..3199ada 100644 --- a/src/stegasoo/__init__.py +++ b/src/stegasoo/__init__.py @@ -10,56 +10,55 @@ Changes in v4.0.0: __version__ = "4.0.1" # Core functionality -from .encode import encode +# Channel key management (v4.0.0) +from .channel import ( + clear_channel_key, + format_channel_key, + generate_channel_key, + get_channel_key, + get_channel_status, + has_channel_key, + set_channel_key, + validate_channel_key, +) + +# Crypto functions +from .crypto import get_active_channel_key, get_channel_fingerprint, has_argon2 from .decode import decode, decode_file, decode_text +from .encode import encode # Credential generation from .generate import ( - generate_pin, - generate_passphrase, - generate_rsa_key, - generate_credentials, export_rsa_key_pem, + generate_credentials, + generate_passphrase, + generate_pin, + generate_rsa_key, load_rsa_key, ) # Image utilities from .image_utils import ( - get_image_info, compare_capacity, + get_image_info, +) + +# Steganography functions +from .steganography import ( + compare_modes, + has_dct_support, + will_fit_by_mode, ) # Utilities from .utils import generate_filename -# Crypto functions -from .crypto import has_argon2, get_active_channel_key, get_channel_fingerprint - -# Channel key management (v4.0.0) -from .channel import ( - generate_channel_key, - get_channel_key, - set_channel_key, - clear_channel_key, - has_channel_key, - get_channel_status, - validate_channel_key, - format_channel_key, -) - -# Steganography functions -from .steganography import ( - has_dct_support, - compare_modes, - will_fit_by_mode, -) - # QR Code utilities - optional, may not be available try: from .qr_utils import ( - generate_qr_code, - extract_key_from_qr, detect_and_crop_qr, + extract_key_from_qr, + generate_qr_code, ) HAS_QR_UTILS = True except ImportError: @@ -70,12 +69,12 @@ except ImportError: # Validation from .validation import ( + validate_file_payload, + validate_image, + validate_message, validate_passphrase, validate_pin, validate_rsa_key, - validate_message, - validate_file_payload, - validate_image, validate_security_factors, ) @@ -84,62 +83,61 @@ validate_reference_photo = validate_image validate_carrier = validate_image # Additional validators -from .validation import ( - validate_embed_mode, - validate_dct_output_format, - validate_dct_color_mode, -) - -# Models -from .models import ( - ImageInfo, - CapacityComparison, - GenerateResult, - EncodeResult, - DecodeResult, - FilePayload, - Credentials, - ValidationResult, +# Constants +from .constants import ( + DEFAULT_PASSPHRASE_WORDS, + EMBED_MODE_AUTO, + EMBED_MODE_DCT, + EMBED_MODE_LSB, + FORMAT_VERSION, + LOSSLESS_FORMATS, + MAX_IMAGE_PIXELS, + MAX_MESSAGE_SIZE, + MAX_PASSPHRASE_WORDS, + MAX_PIN_LENGTH, + MIN_IMAGE_PIXELS, + MIN_PASSPHRASE_WORDS, + MIN_PIN_LENGTH, + RECOMMENDED_PASSPHRASE_WORDS, ) # Exceptions from .exceptions import ( - StegasooError, - ValidationError, - PinValidationError, - MessageValidationError, - ImageValidationError, - KeyValidationError, - SecurityFactorError, + CapacityError, CryptoError, - EncryptionError, DecryptionError, + EmbeddingError, + EncryptionError, + ExtractionError, + ImageValidationError, + InvalidHeaderError, KeyDerivationError, KeyGenerationError, KeyPasswordError, + KeyValidationError, + MessageValidationError, + PinValidationError, + SecurityFactorError, SteganographyError, - CapacityError, - ExtractionError, - EmbeddingError, - InvalidHeaderError, + StegasooError, + ValidationError, ) -# Constants -from .constants import ( - FORMAT_VERSION, - MIN_PASSPHRASE_WORDS, - RECOMMENDED_PASSPHRASE_WORDS, - DEFAULT_PASSPHRASE_WORDS, - MAX_PASSPHRASE_WORDS, - MIN_PIN_LENGTH, - MAX_PIN_LENGTH, - MAX_MESSAGE_SIZE, - MIN_IMAGE_PIXELS, - MAX_IMAGE_PIXELS, - LOSSLESS_FORMATS, - EMBED_MODE_LSB, - EMBED_MODE_DCT, - EMBED_MODE_AUTO, +# Models +from .models import ( + CapacityComparison, + Credentials, + DecodeResult, + EncodeResult, + FilePayload, + GenerateResult, + ImageInfo, + ValidationResult, +) +from .validation import ( + validate_dct_color_mode, + validate_dct_output_format, + validate_embed_mode, ) # Aliases for backward compatibility @@ -159,7 +157,7 @@ __all__ = [ "decode", "decode_file", "decode_text", - + # Generation "generate_pin", "generate_passphrase", @@ -167,7 +165,7 @@ __all__ = [ "generate_credentials", "export_rsa_key_pem", "load_rsa_key", - + # Channel key management (v4.0.0) "generate_channel_key", "get_channel_key", @@ -179,28 +177,28 @@ __all__ = [ "format_channel_key", "get_active_channel_key", "get_channel_fingerprint", - + # Image utilities "get_image_info", "compare_capacity", - + # Utilities "generate_filename", - + # Crypto "has_argon2", - + # Steganography "has_dct_support", "compare_modes", "will_fit_by_mode", - + # QR utilities "generate_qr_code", "extract_key_from_qr", "detect_and_crop_qr", "HAS_QR_UTILS", - + # Validation "validate_reference_photo", "validate_carrier", @@ -214,7 +212,7 @@ __all__ = [ "validate_dct_output_format", "validate_dct_color_mode", "validate_channel_key", - + # Models "ImageInfo", "CapacityComparison", @@ -224,7 +222,7 @@ __all__ = [ "FilePayload", "Credentials", "ValidationResult", - + # Exceptions "StegasooError", "ValidationError", @@ -244,7 +242,7 @@ __all__ = [ "ExtractionError", "EmbeddingError", "InvalidHeaderError", - + # Constants "FORMAT_VERSION", "MIN_PASSPHRASE_WORDS", diff --git a/src/stegasoo/batch.py b/src/stegasoo/batch.py index 5c7e06d..74a162c 100644 --- a/src/stegasoo/batch.py +++ b/src/stegasoo/batch.py @@ -9,15 +9,14 @@ Changes in v3.2.0: - Updated all credential handling to use v3.2.0 API """ -import os import json -import time -from pathlib import Path -from dataclasses import dataclass, field, asdict -from typing import Optional, Callable, Iterator -from enum import Enum -from concurrent.futures import ThreadPoolExecutor, as_completed import threading +import time +from collections.abc import Callable, Iterator +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path from .constants import ALLOWED_IMAGE_EXTENSIONS, LOSSLESS_FORMATS @@ -35,22 +34,22 @@ class BatchStatus(Enum): class BatchItem: """Represents a single item in a batch operation.""" input_path: Path - output_path: Optional[Path] = None + output_path: Path | None = None status: BatchStatus = BatchStatus.PENDING - error: Optional[str] = None - start_time: Optional[float] = None - end_time: Optional[float] = None + error: str | None = None + start_time: float | None = None + end_time: float | None = None input_size: int = 0 output_size: int = 0 message: str = "" - + @property - def duration(self) -> Optional[float]: + def duration(self) -> float | None: """Processing duration in seconds.""" if self.start_time and self.end_time: return self.end_time - self.start_time return None - + def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { @@ -69,14 +68,14 @@ class BatchItem: class BatchCredentials: """ Credentials for batch encode/decode operations (v3.2.0). - + Provides a structured way to pass authentication factors for batch processing instead of using plain dicts. - + Changes in v3.2.0: - Renamed day_phrase β†’ passphrase - Removed date_str (no longer used in cryptographic operations) - + Example: creds = BatchCredentials( reference_photo=ref_bytes, @@ -88,9 +87,9 @@ class BatchCredentials: reference_photo: bytes passphrase: str # v3.2.0: renamed from day_phrase pin: str = "" - rsa_key_data: Optional[bytes] = None - rsa_password: Optional[str] = None - + rsa_key_data: bytes | None = None + rsa_password: str | None = None + def to_dict(self) -> dict: """Convert to dictionary for API compatibility.""" return { @@ -100,17 +99,17 @@ class BatchCredentials: "rsa_key_data": self.rsa_key_data, "rsa_password": self.rsa_password, } - + @classmethod def from_dict(cls, data: dict) -> 'BatchCredentials': """ Create BatchCredentials from a dictionary. - + Handles both v3.2.0 format (passphrase) and legacy format (day_phrase). """ # Handle legacy 'day_phrase' key passphrase = data.get('passphrase') or data.get('day_phrase', '') - + return cls( reference_photo=data['reference_photo'], passphrase=passphrase, @@ -129,16 +128,16 @@ class BatchResult: failed: int = 0 skipped: int = 0 start_time: float = field(default_factory=time.time) - end_time: Optional[float] = None + end_time: float | None = None items: list[BatchItem] = field(default_factory=list) - + @property - def duration(self) -> Optional[float]: + def duration(self) -> float | None: """Total batch duration in seconds.""" if self.end_time: return self.end_time - self.start_time return None - + def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { @@ -152,7 +151,7 @@ class BatchResult: }, "items": [item.to_dict() for item in self.items], } - + def to_json(self, indent: int = 2) -> str: """Serialize to JSON string.""" return json.dumps(self.to_dict(), indent=indent) @@ -165,10 +164,10 @@ ProgressCallback = Callable[[int, int, BatchItem], None] class BatchProcessor: """ Handles batch encoding/decoding operations (v3.2.0). - + Usage: processor = BatchProcessor(max_workers=4) - + # Batch encode with BatchCredentials creds = BatchCredentials( reference_photo=ref_bytes, @@ -181,7 +180,7 @@ class BatchProcessor: output_dir="./encoded/", credentials=creds, ) - + # Batch encode with dict credentials result = processor.batch_encode( images=['img1.png', 'img2.png'], @@ -192,24 +191,24 @@ class BatchProcessor: "pin": "123456" }, ) - + # Batch decode result = processor.batch_decode( images=['encoded1.png', 'encoded2.png'], credentials=creds, ) """ - + def __init__(self, max_workers: int = 4): """ Initialize batch processor. - + Args: max_workers: Maximum parallel workers (default 4) """ self.max_workers = max_workers self._lock = threading.Lock() - + def find_images( self, paths: list[str | Path], @@ -217,67 +216,67 @@ class BatchProcessor: ) -> Iterator[Path]: """ Find all valid image files from paths. - + Args: paths: List of files or directories recursive: Search directories recursively - + Yields: Path objects for each valid image """ for path in paths: path = Path(path) - + if path.is_file(): if self._is_valid_image(path): yield path - + elif path.is_dir(): pattern = '**/*' if recursive else '*' for file_path in path.glob(pattern): if file_path.is_file() and self._is_valid_image(file_path): yield file_path - + def _is_valid_image(self, path: Path) -> bool: """Check if path is a valid image file.""" return path.suffix.lower().lstrip('.') in ALLOWED_IMAGE_EXTENSIONS - + def _normalize_credentials( - self, + self, credentials: dict | BatchCredentials | None ) -> BatchCredentials: """ Normalize credentials to BatchCredentials object. - + Handles both dict and BatchCredentials input, and legacy 'day_phrase' key. """ if credentials is None: raise ValueError("Credentials are required") - + if isinstance(credentials, BatchCredentials): return credentials - + if isinstance(credentials, dict): return BatchCredentials.from_dict(credentials) - + raise ValueError(f"Invalid credentials type: {type(credentials)}") - + def batch_encode( self, images: list[str | Path], - message: Optional[str] = None, - file_payload: Optional[Path] = None, - output_dir: Optional[Path] = None, + message: str | None = None, + file_payload: Path | None = None, + output_dir: Path | None = None, output_suffix: str = "_encoded", credentials: dict | BatchCredentials | None = None, compress: bool = True, recursive: bool = False, - progress_callback: Optional[ProgressCallback] = None, + progress_callback: ProgressCallback | None = None, encode_func: Callable = None, ) -> BatchResult: """ Encode message into multiple images. - + Args: images: List of image paths or directories message: Text message to encode (mutually exclusive with file_payload) @@ -289,43 +288,43 @@ class BatchProcessor: recursive: Search directories recursively progress_callback: Called for each item: callback(current, total, item) encode_func: Custom encode function (for integration) - + Returns: BatchResult with operation summary """ if message is None and file_payload is None: raise ValueError("Either message or file_payload must be provided") - + # Normalize credentials to BatchCredentials creds = self._normalize_credentials(credentials) - + result = BatchResult(operation="encode") image_paths = list(self.find_images(images, recursive)) result.total = len(image_paths) - + if output_dir: output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) - + # Prepare batch items for img_path in image_paths: if output_dir: out_path = output_dir / f"{img_path.stem}{output_suffix}.png" else: out_path = img_path.parent / f"{img_path.stem}{output_suffix}.png" - + item = BatchItem( input_path=img_path, output_path=out_path, input_size=img_path.stat().st_size if img_path.exists() else 0, ) result.items.append(item) - + # Process items def process_encode(item: BatchItem) -> BatchItem: item.status = BatchStatus.PROCESSING item.start_time = time.time() - + try: if encode_func: # Use provided encode function @@ -340,35 +339,35 @@ class BatchProcessor: else: # Use stegasoo encode self._do_encode(item, message, file_payload, creds, compress) - + item.status = BatchStatus.SUCCESS item.output_size = item.output_path.stat().st_size if item.output_path and item.output_path.exists() else 0 item.message = f"Encoded to {item.output_path.name}" - + except Exception as e: item.status = BatchStatus.FAILED item.error = str(e) - + item.end_time = time.time() return item - + # Execute with thread pool self._execute_batch(result, process_encode, progress_callback) - + return result - + def batch_decode( self, images: list[str | Path], - output_dir: Optional[Path] = None, + output_dir: Path | None = None, credentials: dict | BatchCredentials | None = None, recursive: bool = False, - progress_callback: Optional[ProgressCallback] = None, + progress_callback: ProgressCallback | None = None, decode_func: Callable = None, ) -> BatchResult: """ Decode messages from multiple images. - + Args: images: List of image paths or directories output_dir: Output directory for file payloads (default: same as input) @@ -376,21 +375,21 @@ class BatchProcessor: recursive: Search directories recursively progress_callback: Called for each item: callback(current, total, item) decode_func: Custom decode function (for integration) - + Returns: BatchResult with decoded messages in item.message fields """ # Normalize credentials to BatchCredentials creds = self._normalize_credentials(credentials) - + result = BatchResult(operation="decode") image_paths = list(self.find_images(images, recursive)) result.total = len(image_paths) - + if output_dir: output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) - + # Prepare batch items for img_path in image_paths: item = BatchItem( @@ -399,12 +398,12 @@ class BatchProcessor: input_size=img_path.stat().st_size if img_path.exists() else 0, ) result.items.append(item) - + # Process items def process_decode(item: BatchItem) -> BatchItem: item.status = BatchStatus.PROCESSING item.start_time = time.time() - + try: if decode_func: # Use provided decode function @@ -417,40 +416,40 @@ class BatchProcessor: else: # Use stegasoo decode item.message = self._do_decode(item, creds) - + item.status = BatchStatus.SUCCESS - + except Exception as e: item.status = BatchStatus.FAILED item.error = str(e) - + item.end_time = time.time() return item - + # Execute with thread pool self._execute_batch(result, process_decode, progress_callback) - + return result - + def _execute_batch( self, result: BatchResult, process_func: Callable[[BatchItem], BatchItem], - progress_callback: Optional[ProgressCallback] = None, + progress_callback: ProgressCallback | None = None, ) -> None: """Execute batch processing with thread pool.""" completed = 0 - + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: futures = { - executor.submit(process_func, item): item + executor.submit(process_func, item): item for item in result.items } - + for future in as_completed(futures): item = future.result() completed += 1 - + with self._lock: if item.status == BatchStatus.SUCCESS: result.succeeded += 1 @@ -458,32 +457,32 @@ class BatchProcessor: result.failed += 1 elif item.status == BatchStatus.SKIPPED: result.skipped += 1 - + if progress_callback: progress_callback(completed, result.total, item) - + result.end_time = time.time() - + def _do_encode( self, item: BatchItem, - message: Optional[str], - file_payload: Optional[Path], + message: str | None, + file_payload: Path | None, creds: BatchCredentials, compress: bool ) -> None: """ Perform actual encoding using stegasoo.encode. - + Override this method to customize encoding behavior. """ try: - from .encode import encode, encode_file + from .encode import encode from .models import FilePayload - + # Read carrier image carrier_image = item.input_path.read_bytes() - + if file_payload: # Encode file payload = FilePayload.from_file(str(file_payload)) @@ -507,15 +506,15 @@ class BatchProcessor: rsa_key_data=creds.rsa_key_data, rsa_password=creds.rsa_password, ) - + # Write output if item.output_path: item.output_path.write_bytes(result.stego_image) - + except ImportError: # Fallback to mock if stegasoo.encode not available self._mock_encode(item, message, creds, compress) - + def _do_decode( self, item: BatchItem, @@ -523,15 +522,15 @@ class BatchProcessor: ) -> str: """ Perform actual decoding using stegasoo.decode. - + Override this method to customize decoding behavior. """ try: from .decode import decode - + # Read stego image stego_image = item.input_path.read_bytes() - + result = decode( stego_image=stego_image, reference_photo=creds.reference_photo, @@ -540,7 +539,7 @@ class BatchProcessor: rsa_key_data=creds.rsa_key_data, rsa_password=creds.rsa_password, ) - + if result.is_text: return result.message or "" else: @@ -550,11 +549,11 @@ class BatchProcessor: output_file.write_bytes(result.file_data) return f"File extracted: {result.filename or 'extracted_file'}" return f"[File: {result.filename or 'binary data'}]" - + except ImportError: # Fallback to mock if stegasoo.decode not available return self._mock_decode(item, creds) - + def _mock_encode( self, item: BatchItem, @@ -568,7 +567,7 @@ class BatchProcessor: import shutil if item.output_path: shutil.copy(item.input_path, item.output_path) - + def _mock_decode(self, item: BatchItem, creds: BatchCredentials) -> str: """Mock decode for testing - replace with actual stego.decode()""" # This is a placeholder - in real usage, you'd call your actual decode function @@ -581,30 +580,31 @@ def batch_capacity_check( ) -> list[dict]: """ Check capacity of multiple images without encoding. - + Args: images: List of image paths or directories recursive: Search directories recursively - + Returns: List of dicts with path, dimensions, and estimated capacity """ from PIL import Image + from .constants import MAX_IMAGE_PIXELS - + processor = BatchProcessor() results = [] - + for img_path in processor.find_images(images, recursive): try: with Image.open(img_path) as img: width, height = img.size pixels = width * height - + # Estimate: 3 bits per pixel (RGB LSB), minus header overhead capacity_bits = pixels * 3 capacity_bytes = (capacity_bits // 8) - 100 # Header overhead - + results.append({ "path": str(img_path), "dimensions": f"{width}x{height}", @@ -622,25 +622,25 @@ def batch_capacity_check( "error": str(e), "valid": False, }) - + return results def _get_image_warnings(img, path: Path) -> list[str]: """Generate warnings for an image.""" - from .constants import MAX_IMAGE_PIXELS, LOSSLESS_FORMATS - + from .constants import LOSSLESS_FORMATS, MAX_IMAGE_PIXELS + warnings = [] - + if img.format not in LOSSLESS_FORMATS: warnings.append(f"Lossy format ({img.format}) - quality will degrade on re-save") - + if img.size[0] * img.size[1] > MAX_IMAGE_PIXELS: warnings.append(f"Image exceeds {MAX_IMAGE_PIXELS:,} pixel limit") - + if img.mode not in ('RGB', 'RGBA'): warnings.append(f"Non-RGB mode ({img.mode}) - will be converted") - + return warnings @@ -657,7 +657,7 @@ def print_batch_result(result: BatchResult, verbose: bool = False) -> None: print(f"Skipped: {result.skipped}") if result.duration: print(f"Duration: {result.duration:.2f}s") - + if verbose or result.failed > 0: print(f"\n{'─'*60}") for item in result.items: @@ -668,7 +668,7 @@ def print_batch_result(result: BatchResult, verbose: bool = False) -> None: BatchStatus.PENDING: "…", BatchStatus.PROCESSING: "⟳", }.get(item.status, "?") - + print(f"{status_icon} {item.input_path.name}") if item.error: print(f" Error: {item.error}") diff --git a/src/stegasoo/channel.py b/src/stegasoo/channel.py index df79fcf..45c0bb7 100644 --- a/src/stegasoo/channel.py +++ b/src/stegasoo/channel.py @@ -24,12 +24,11 @@ INTEGRATION STATUS (v4.0.0): - βœ… Helpful error messages for channel key mismatches """ -import os -import secrets import hashlib +import os import re +import secrets from pathlib import Path -from typing import Optional, List from .debug import debug @@ -52,10 +51,10 @@ CONFIG_LOCATIONS = [ def generate_channel_key() -> str: """ Generate a new random channel key. - + Returns: Formatted channel key (e.g., "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456") - + Example: >>> key = generate_channel_key() >>> len(key) @@ -64,7 +63,7 @@ def generate_channel_key() -> str: # Generate 32 random alphanumeric characters alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' raw_key = ''.join(secrets.choice(alphabet) for _ in range(CHANNEL_KEY_LENGTH)) - + formatted = format_channel_key(raw_key) debug.print(f"Generated channel key: {get_channel_fingerprint(formatted)}") return formatted @@ -73,32 +72,32 @@ def generate_channel_key() -> str: def format_channel_key(raw_key: str) -> str: """ Format a raw key string into the standard format. - + Args: raw_key: Raw key string (with or without dashes) - + Returns: Formatted key with dashes (XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX) - + Raises: ValueError: If key is invalid length or contains invalid characters - + Example: >>> format_channel_key("ABCD1234EFGH5678IJKL9012MNOP3456") "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456" """ # Remove any existing dashes, spaces, and convert to uppercase clean = raw_key.replace('-', '').replace(' ', '').upper() - + if len(clean) != CHANNEL_KEY_LENGTH: raise ValueError( f"Channel key must be {CHANNEL_KEY_LENGTH} characters (got {len(clean)})" ) - + # Validate characters if not all(c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' for c in clean): raise ValueError("Channel key must contain only letters A-Z and digits 0-9") - + # Format with dashes every 4 characters return '-'.join(clean[i:i+4] for i in range(0, CHANNEL_KEY_LENGTH, 4)) @@ -106,13 +105,13 @@ def format_channel_key(raw_key: str) -> str: def validate_channel_key(key: str) -> bool: """ Validate a channel key format. - + Args: key: Channel key to validate - + Returns: True if valid format, False otherwise - + Example: >>> validate_channel_key("ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456") True @@ -121,7 +120,7 @@ def validate_channel_key(key: str) -> bool: """ if not key: return False - + try: formatted = format_channel_key(key) return bool(CHANNEL_KEY_PATTERN.match(formatted)) @@ -129,18 +128,18 @@ def validate_channel_key(key: str) -> bool: return False -def get_channel_key() -> Optional[str]: +def get_channel_key() -> str | None: """ Get the current channel key from environment or config. - + Checks in order: 1. STEGASOO_CHANNEL_KEY environment variable 2. ./config/channel.key file 3. ~/.stegasoo/channel.key file - + Returns: Channel key if configured, None if in public mode - + Example: >>> key = get_channel_key() >>> if key: @@ -156,7 +155,7 @@ def get_channel_key() -> Optional[str]: return format_channel_key(env_key) else: debug.print(f"Warning: Invalid {CHANNEL_KEY_ENV_VAR} format, ignoring") - + # 2. Check config files for config_path in CONFIG_LOCATIONS: if config_path.exists(): @@ -165,10 +164,10 @@ def get_channel_key() -> Optional[str]: if key and validate_channel_key(key): debug.print(f"Channel key from {config_path}: {get_channel_fingerprint(key)}") return format_channel_key(key) - except (IOError, PermissionError) as e: + except (OSError, PermissionError) as e: debug.print(f"Could not read {config_path}: {e}") continue - + # 3. No channel key configured (public mode) debug.print("No channel key configured (public mode)") return None @@ -177,92 +176,92 @@ def get_channel_key() -> Optional[str]: def set_channel_key(key: str, location: str = 'project') -> Path: """ Save a channel key to config file. - + Args: key: Channel key to save (will be formatted) location: 'project' for ./config/ or 'user' for ~/.stegasoo/ - + Returns: Path where key was saved - + Raises: ValueError: If key format is invalid - + Example: >>> path = set_channel_key("ABCD1234EFGH5678IJKL9012MNOP3456") >>> print(path) ./config/channel.key """ formatted = format_channel_key(key) - + if location == 'user': config_path = Path.home() / '.stegasoo' / 'channel.key' else: config_path = Path('./config/channel.key') - + # Create directory if needed config_path.parent.mkdir(parents=True, exist_ok=True) - + # Write key with newline config_path.write_text(formatted + '\n') - + # Set restrictive permissions (owner read/write only) try: config_path.chmod(0o600) except (OSError, AttributeError): pass # Windows doesn't support chmod the same way - + debug.print(f"Channel key saved to {config_path}") return config_path -def clear_channel_key(location: str = 'all') -> List[Path]: +def clear_channel_key(location: str = 'all') -> list[Path]: """ Remove channel key configuration. - + Args: location: 'project', 'user', or 'all' - + Returns: List of paths that were deleted - + Example: >>> deleted = clear_channel_key('all') >>> print(f"Removed {len(deleted)} files") """ deleted = [] - + paths_to_check = [] if location in ('project', 'all'): paths_to_check.append(Path('./config/channel.key')) if location in ('user', 'all'): paths_to_check.append(Path.home() / '.stegasoo' / 'channel.key') - + for path in paths_to_check: if path.exists(): try: path.unlink() deleted.append(path) debug.print(f"Removed channel key: {path}") - except (IOError, PermissionError) as e: + except (OSError, PermissionError) as e: debug.print(f"Could not remove {path}: {e}") - + return deleted -def get_channel_key_hash(key: Optional[str] = None) -> Optional[bytes]: +def get_channel_key_hash(key: str | None = None) -> bytes | None: """ Get the channel key as a 32-byte hash suitable for key derivation. - + This hash is mixed into the Argon2 key derivation to bind encryption to a specific channel. - + Args: key: Channel key (if None, reads from config) - + Returns: 32-byte SHA-256 hash of channel key, or None if no channel key - + Example: >>> hash_bytes = get_channel_key_hash() >>> if hash_bytes: @@ -270,39 +269,39 @@ def get_channel_key_hash(key: Optional[str] = None) -> Optional[bytes]: """ if key is None: key = get_channel_key() - + if not key: return None - + # Hash the formatted key to get consistent 32 bytes formatted = format_channel_key(key) return hashlib.sha256(formatted.encode('utf-8')).digest() -def get_channel_fingerprint(key: Optional[str] = None) -> Optional[str]: +def get_channel_fingerprint(key: str | None = None) -> str | None: """ Get a short fingerprint for display purposes. Shows first and last 4 chars with masked middle. - + Args: key: Channel key (if None, reads from config) - + Returns: Fingerprint like "ABCD-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-3456" or None - + Example: >>> print(get_channel_fingerprint()) ABCD-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-β€’β€’β€’β€’-3456 """ if key is None: key = get_channel_key() - + if not key: return None - + formatted = format_channel_key(key) parts = formatted.split('-') - + # Show first and last group, mask the rest masked = [parts[0]] + ['β€’β€’β€’β€’'] * 6 + [parts[-1]] return '-'.join(masked) @@ -311,7 +310,7 @@ def get_channel_fingerprint(key: Optional[str] = None) -> Optional[str]: def get_channel_status() -> dict: """ Get comprehensive channel key status. - + Returns: Dictionary with: - mode: 'private' or 'public' @@ -319,14 +318,14 @@ def get_channel_status() -> dict: - fingerprint: masked key or None - source: where key came from or None - key: full key (for export) or None - + Example: >>> status = get_channel_status() >>> print(f"Mode: {status['mode']}") Mode: private """ key = get_channel_key() - + if key: # Find which source provided the key source = 'unknown' @@ -341,9 +340,9 @@ def get_channel_status() -> dict: if file_key and format_channel_key(file_key) == key: source = str(config_path) break - except (IOError, PermissionError): + except (OSError, PermissionError): continue - + return { 'mode': 'private', 'configured': True, @@ -364,10 +363,10 @@ def get_channel_status() -> dict: def has_channel_key() -> bool: """ Quick check if a channel key is configured. - + Returns: True if channel key is set, False for public mode - + Example: >>> if has_channel_key(): ... print("Private channel active") @@ -381,7 +380,7 @@ def has_channel_key() -> bool: if __name__ == '__main__': import sys - + def print_status(): """Print current channel status.""" status = get_channel_status() @@ -391,7 +390,7 @@ if __name__ == '__main__': print(f"Source: {status['source']}") else: print("No channel key configured (public mode)") - + if len(sys.argv) < 2: print("Channel Key Manager") print("=" * 40) @@ -404,24 +403,24 @@ if __name__ == '__main__': print(" python -m stegasoo.channel clear - Remove channel key") print(" python -m stegasoo.channel status - Show status") sys.exit(0) - + cmd = sys.argv[1].lower() - + if cmd == 'generate': key = generate_channel_key() - print(f"Generated channel key:") + print("Generated channel key:") print(f" {key}") print() save = input("Save to config? [y/N]: ").strip().lower() if save == 'y': path = set_channel_key(key) print(f"Saved to: {path}") - + elif cmd == 'set': if len(sys.argv) < 3: print("Usage: python -m stegasoo.channel set ") sys.exit(1) - + try: key = sys.argv[2] formatted = format_channel_key(key) @@ -431,7 +430,7 @@ if __name__ == '__main__': except ValueError as e: print(f"Error: {e}") sys.exit(1) - + elif cmd == 'show': status = get_channel_status() if status['configured']: @@ -439,17 +438,17 @@ if __name__ == '__main__': print(f"Source: {status['source']}") else: print("No channel key configured") - + elif cmd == 'clear': deleted = clear_channel_key('all') if deleted: print(f"Removed channel key from: {', '.join(str(p) for p in deleted)}") else: print("No channel key files found") - + elif cmd == 'status': print_status() - + else: print(f"Unknown command: {cmd}") sys.exit(1) diff --git a/src/stegasoo/cli.py b/src/stegasoo/cli.py index a840b12..eb7ac59 100644 --- a/src/stegasoo/cli.py +++ b/src/stegasoo/cli.py @@ -8,33 +8,29 @@ Changes in v3.2.0: - Updated help text to use 'passphrase' terminology """ -import sys import json from pathlib import Path -from typing import Optional import click -from .constants import ( - __version__, - MAX_MESSAGE_SIZE, - MAX_FILE_PAYLOAD_SIZE, - DEFAULT_PIN_LENGTH, - DEFAULT_PASSPHRASE_WORDS, # v3.2.0: renamed from DEFAULT_PHRASE_WORDS -) -from .compression import ( - CompressionAlgorithm, - get_available_algorithms, - algorithm_name, - HAS_LZ4, -) from .batch import ( BatchProcessor, - BatchResult, batch_capacity_check, print_batch_result, ) - +from .compression import ( + HAS_LZ4, + CompressionAlgorithm, + algorithm_name, + get_available_algorithms, +) +from .constants import ( + DEFAULT_PASSPHRASE_WORDS, # v3.2.0: renamed from DEFAULT_PHRASE_WORDS + DEFAULT_PIN_LENGTH, + MAX_FILE_PAYLOAD_SIZE, + MAX_MESSAGE_SIZE, + __version__, +) # Click context settings CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) @@ -47,7 +43,7 @@ CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) def cli(ctx, json_output): """ Stegasoo - Steganography with hybrid authentication. - + Hide messages in images using PIN + passphrase security. """ ctx.ensure_object(dict) @@ -61,35 +57,35 @@ def cli(ctx, json_output): @cli.command() @click.argument('image', type=click.Path(exists=True)) @click.option('-m', '--message', help='Message to encode') -@click.option('-f', '--file', 'file_payload', type=click.Path(exists=True), +@click.option('-f', '--file', 'file_payload', type=click.Path(exists=True), help='File to embed instead of message') @click.option('-o', '--output', type=click.Path(), help='Output image path') -@click.option('--passphrase', prompt=True, hide_input=True, +@click.option('--passphrase', prompt=True, hide_input=True, confirmation_prompt=True, help='Passphrase (recommend 4+ words)') @click.option('--pin', prompt=True, hide_input=True, confirmation_prompt=True, help='PIN code') -@click.option('--compress/--no-compress', default=True, +@click.option('--compress/--no-compress', default=True, help='Enable/disable compression (default: enabled)') -@click.option('--algorithm', type=click.Choice(['zlib', 'lz4', 'none']), +@click.option('--algorithm', type=click.Choice(['zlib', 'lz4', 'none']), default='zlib', help='Compression algorithm') @click.option('--dry-run', is_flag=True, help='Show capacity usage without encoding') @click.pass_context -def encode(ctx, image, message, file_payload, output, passphrase, pin, +def encode(ctx, image, message, file_payload, output, passphrase, pin, compress, algorithm, dry_run): """ Encode a message or file into an image. - + Examples: - + stegasoo encode photo.png -m "Secret message" --passphrase --pin - + stegasoo encode photo.png -f secret.pdf -o encoded.png """ from PIL import Image - + if not message and not file_payload: raise click.UsageError("Either --message or --file is required") - + # Parse compression algorithm algo_map = { 'zlib': CompressionAlgorithm.ZLIB, @@ -97,11 +93,11 @@ def encode(ctx, image, message, file_payload, output, passphrase, pin, 'none': CompressionAlgorithm.NONE, } compression_algo = algo_map[algorithm] if compress else CompressionAlgorithm.NONE - + if algorithm == 'lz4' and not HAS_LZ4: click.echo("Warning: LZ4 not available, falling back to zlib", err=True) compression_algo = CompressionAlgorithm.ZLIB - + # Calculate payload size if file_payload: payload_size = Path(file_payload).stat().st_size @@ -109,12 +105,12 @@ def encode(ctx, image, message, file_payload, output, passphrase, pin, else: payload_size = len(message.encode('utf-8')) payload_type = "text" - + # Get image capacity with Image.open(image) as img: width, height = img.size capacity_bytes = (width * height * 3 // 8) - 69 # v3.2.0: corrected overhead - + if dry_run: result = { "image": image, @@ -126,7 +122,7 @@ def encode(ctx, image, message, file_payload, output, passphrase, pin, "usage_percent": round(payload_size / capacity_bytes * 100, 1), "fits": payload_size < capacity_bytes, } - + if ctx.obj.get('json'): click.echo(json.dumps(result, indent=2)) else: @@ -137,11 +133,11 @@ def encode(ctx, image, message, file_payload, output, passphrase, pin, click.echo(f"Usage: {result['usage_percent']}%") click.echo(f"Status: {'βœ“ Fits' if result['fits'] else 'βœ— Too large'}") return - + # Actual encoding would happen here # For now, show what would be done output = output or f"{Path(image).stem}_encoded.png" - + if ctx.obj.get('json'): click.echo(json.dumps({ "status": "success", @@ -159,17 +155,17 @@ def encode(ctx, image, message, file_payload, output, passphrase, pin, @click.argument('image', type=click.Path(exists=True)) @click.option('--passphrase', prompt=True, hide_input=True, help='Passphrase') @click.option('--pin', prompt=True, hide_input=True, help='PIN code') -@click.option('-o', '--output', type=click.Path(), +@click.option('-o', '--output', type=click.Path(), help='Output path for file payloads') @click.pass_context def decode(ctx, image, passphrase, pin, output): """ Decode a message or file from an image. - + Examples: - + stegasoo decode encoded.png --passphrase --pin - + stegasoo decode encoded.png -o ./extracted/ """ # Actual decoding would happen here @@ -179,7 +175,7 @@ def decode(ctx, image, passphrase, pin, output): "payload_type": "text", "message": "[Decoded message would appear here]", } - + if ctx.obj.get('json'): click.echo(json.dumps(result, indent=2)) else: @@ -222,27 +218,27 @@ def batch_encode(ctx, images, message, file_payload, output_dir, suffix, passphrase, pin, compress, algorithm, recursive, jobs, verbose): """ Encode message into multiple images. - + Examples: - + stegasoo batch encode *.png -m "Secret" --passphrase --pin - + stegasoo batch encode ./photos/ -r -o ./encoded/ """ if not message and not file_payload: raise click.UsageError("Either --message or --file is required") - + processor = BatchProcessor(max_workers=jobs) - + # Progress callback def progress(current, total, item): if not ctx.obj.get('json'): status = "βœ“" if item.status.value == "success" else "βœ—" click.echo(f"[{current}/{total}] {status} {item.input_path.name}") - + # v3.2.0: Use 'passphrase' key instead of 'phrase' credentials = {"passphrase": passphrase, "pin": pin} - + result = processor.batch_encode( images=list(images), message=message, @@ -254,7 +250,7 @@ def batch_encode(ctx, images, message, file_payload, output_dir, suffix, recursive=recursive, progress_callback=progress if not ctx.obj.get('json') else None, ) - + if ctx.obj.get('json'): click.echo(result.to_json()) else: @@ -275,24 +271,24 @@ def batch_encode(ctx, images, message, file_payload, output_dir, suffix, def batch_decode(ctx, images, output_dir, passphrase, pin, recursive, jobs, verbose): """ Decode messages from multiple images. - + Examples: - + stegasoo batch decode encoded*.png --passphrase --pin - + stegasoo batch decode ./encoded/ -r -o ./extracted/ """ processor = BatchProcessor(max_workers=jobs) - + # Progress callback def progress(current, total, item): if not ctx.obj.get('json'): status = "βœ“" if item.status.value == "success" else "βœ—" click.echo(f"[{current}/{total}] {status} {item.input_path.name}") - + # v3.2.0: Use 'passphrase' key instead of 'phrase' credentials = {"passphrase": passphrase, "pin": pin} - + result = processor.batch_decode( images=list(images), output_dir=Path(output_dir) if output_dir else None, @@ -300,7 +296,7 @@ def batch_decode(ctx, images, output_dir, passphrase, pin, recursive, jobs, verb recursive=recursive, progress_callback=progress if not ctx.obj.get('json') else None, ) - + if ctx.obj.get('json'): click.echo(result.to_json()) else: @@ -315,21 +311,21 @@ def batch_decode(ctx, images, output_dir, passphrase, pin, recursive, jobs, verb def batch_check(ctx, images, recursive): """ Check capacity of multiple images. - + Examples: - + stegasoo batch check *.png - + stegasoo batch check ./photos/ -r """ results = batch_capacity_check(list(images), recursive) - + if ctx.obj.get('json'): click.echo(json.dumps(results, indent=2)) else: click.echo(f"{'Image':<40} {'Size':<12} {'Capacity':<12} {'Status'}") click.echo("─" * 80) - + for item in results: if 'error' in item: click.echo(f"{Path(item['path']).name:<40} {'ERROR':<12} {'':<12} {item['error']}") @@ -337,10 +333,10 @@ def batch_check(ctx, images, recursive): name = Path(item['path']).name if len(name) > 38: name = name[:35] + "..." - + status = "βœ“" if item['valid'] else "⚠" warnings = ", ".join(item.get('warnings', [])) - + click.echo( f"{name:<40} " f"{item['dimensions']:<12} " @@ -354,7 +350,7 @@ def batch_check(ctx, images, recursive): # ============================================================================= @cli.command() -@click.option('--words', default=DEFAULT_PASSPHRASE_WORDS, +@click.option('--words', default=DEFAULT_PASSPHRASE_WORDS, help=f'Number of words in passphrase (default: {DEFAULT_PASSPHRASE_WORDS})') @click.option('--pin-length', default=DEFAULT_PIN_LENGTH, help=f'PIN length (default: {DEFAULT_PIN_LENGTH})') @@ -362,21 +358,21 @@ def batch_check(ctx, images, recursive): def generate(ctx, words, pin_length): """ Generate random credentials (passphrase + PIN). - + Examples: - + stegasoo generate - + stegasoo generate --words 6 --pin-length 8 """ import secrets - + # Generate PIN pin = ''.join(str(secrets.randbelow(10)) for _ in range(pin_length)) # Ensure PIN doesn't start with 0 if pin[0] == '0': pin = str(secrets.randbelow(9) + 1) + pin[1:] - + # Generate passphrase (would use BIP-39 wordlist) # Placeholder - actual implementation uses constants.get_wordlist() try: @@ -388,16 +384,16 @@ def generate(ctx, words, pin_length): sample_words = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', 'golf', 'hotel', 'india', 'juliet', 'kilo', 'lima'] phrase_words = [secrets.choice(sample_words) for _ in range(words)] - + passphrase = ' '.join(phrase_words) - + result = { "passphrase": passphrase, "pin": pin, "passphrase_words": words, "pin_length": pin_length, } - + if ctx.obj.get('json'): click.echo(json.dumps(result, indent=2)) else: @@ -421,17 +417,17 @@ def info(ctx): "max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE, }, } - + if ctx.obj.get('json'): click.echo(json.dumps(info_data, indent=2)) else: click.echo(f"Stegasoo v{__version__}") - click.echo(f"\nCompression algorithms:") + click.echo("\nCompression algorithms:") for algo in get_available_algorithms(): click.echo(f" β€’ {algorithm_name(algo)}") if not HAS_LZ4: click.echo(" (install 'lz4' for LZ4 support)") - click.echo(f"\nLimits:") + click.echo("\nLimits:") click.echo(f" β€’ Max message: {MAX_MESSAGE_SIZE:,} bytes") click.echo(f" β€’ Max file payload: {MAX_FILE_PAYLOAD_SIZE:,} bytes") diff --git a/src/stegasoo/compression.py b/src/stegasoo/compression.py index 1883a6e..b76757d 100644 --- a/src/stegasoo/compression.py +++ b/src/stegasoo/compression.py @@ -5,10 +5,9 @@ Provides transparent compression/decompression for payloads before encryption. Supports multiple algorithms with automatic detection on decompression. """ -import zlib import struct +import zlib from enum import IntEnum -from typing import Optional # Optional LZ4 support (faster, slightly worse ratio) try: @@ -43,26 +42,26 @@ class CompressionError(Exception): def compress(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm.ZLIB) -> bytes: """ Compress data with specified algorithm. - + Format: MAGIC (4) + ALGORITHM (1) + ORIGINAL_SIZE (4) + COMPRESSED_DATA - + Args: data: Raw bytes to compress algorithm: Compression algorithm to use - + Returns: Compressed data with header, or original data if compression didn't help """ if len(data) < MIN_COMPRESS_SIZE: # Too small to benefit from compression return _wrap_uncompressed(data) - + if algorithm == CompressionAlgorithm.NONE: return _wrap_uncompressed(data) - + elif algorithm == CompressionAlgorithm.ZLIB: compressed = zlib.compress(data, level=ZLIB_LEVEL) - + elif algorithm == CompressionAlgorithm.LZ4: if not HAS_LZ4: # Fall back to zlib if LZ4 not available @@ -72,11 +71,11 @@ def compress(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm compressed = lz4.frame.compress(data) else: raise CompressionError(f"Unknown compression algorithm: {algorithm}") - + # Only use compression if it actually reduced size if len(compressed) >= len(data): return _wrap_uncompressed(data) - + # Build header: MAGIC + algorithm + original_size + compressed_data header = COMPRESSION_MAGIC + struct.pack(' bytes: """ Decompress data, auto-detecting algorithm from header. - + Args: data: Potentially compressed data - + Returns: Decompressed data (or original if not compressed) """ @@ -96,24 +95,24 @@ def decompress(data: bytes) -> bytes: if not data.startswith(COMPRESSION_MAGIC): # Not compressed by us, return as-is return data - + if len(data) < 9: # MAGIC(4) + ALGO(1) + SIZE(4) raise CompressionError("Truncated compression header") - + # Parse header algorithm = CompressionAlgorithm(data[4]) original_size = struct.unpack(' bytes: raise CompressionError(f"LZ4 decompression failed: {e}") else: raise CompressionError(f"Unknown compression algorithm: {algorithm}") - + # Verify size if len(result) != original_size: raise CompressionError( f"Size mismatch: expected {original_size}, got {len(result)}" ) - + return result @@ -142,7 +141,7 @@ def _wrap_uncompressed(data: bytes) -> bytes: def get_compression_ratio(original: bytes, compressed: bytes) -> float: """ Calculate compression ratio. - + Returns: Ratio where < 1.0 means compression helped, > 1.0 means it expanded """ @@ -155,36 +154,36 @@ def estimate_compressed_size(data: bytes, algorithm: CompressionAlgorithm = Comp """ Estimate compressed size without full compression. Uses sampling for large data. - + Args: data: Data to estimate algorithm: Algorithm to estimate for - + Returns: Estimated compressed size in bytes """ if len(data) < MIN_COMPRESS_SIZE: return len(data) + 9 # Header overhead - + # For small data, just compress it if len(data) < 10000: compressed = compress(data, algorithm) return len(compressed) - + # For large data, sample and extrapolate sample_size = 8192 sample = data[:sample_size] - + if algorithm == CompressionAlgorithm.ZLIB: compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL) elif algorithm == CompressionAlgorithm.LZ4 and HAS_LZ4: compressed_sample = lz4.frame.compress(sample) else: compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL) - + ratio = len(compressed_sample) / len(sample) estimated = int(len(data) * ratio) + 9 # Add header - + return estimated diff --git a/src/stegasoo/constants.py b/src/stegasoo/constants.py index 220befe..2d537b2 100644 --- a/src/stegasoo/constants.py +++ b/src/stegasoo/constants.py @@ -14,7 +14,6 @@ BREAKING CHANGES in v3.2.0: - Renamed day_phrase β†’ passphrase throughout codebase """ -import os from pathlib import Path # ============================================================================ @@ -89,7 +88,7 @@ RECOMMENDED_PASSPHRASE_WORDS = 4 # Best practice guideline # Legacy aliases for backward compatibility during transition MIN_PHRASE_WORDS = MIN_PASSPHRASE_WORDS -MAX_PHRASE_WORDS = MAX_PASSPHRASE_WORDS +MAX_PHRASE_WORDS = MAX_PASSPHRASE_WORDS DEFAULT_PHRASE_WORDS = DEFAULT_PASSPHRASE_WORDS # RSA configuration @@ -180,11 +179,11 @@ def get_data_dir() -> Path: Path.cwd().parent / 'data', # One level up from cwd Path.cwd().parent.parent / 'data', # Two levels up from cwd ] - + for path in candidates: if path.exists(): return path - + # Default to first candidate return candidates[0] @@ -192,14 +191,14 @@ def get_data_dir() -> Path: def get_bip39_words() -> list[str]: """Load BIP-39 wordlist.""" wordlist_path = get_data_dir() / 'bip39-words.txt' - + if not wordlist_path.exists(): raise FileNotFoundError( f"BIP-39 wordlist not found at {wordlist_path}. " "Please ensure bip39-words.txt is in the data directory." ) - - with open(wordlist_path, 'r') as f: + + with open(wordlist_path) as f: return [line.strip() for line in f if line.strip()] @@ -240,18 +239,18 @@ DCT_BYTES_PER_PIXEL = 0.125 # Approximate for DCT mode (varies by implementatio def detect_stego_mode(encrypted_data: bytes) -> str: """ Detect embedding mode from encrypted payload header. - + Args: encrypted_data: First few bytes of extracted payload - + Returns: 'lsb' or 'dct' or 'unknown' """ if len(encrypted_data) < 4: return 'unknown' - + header = encrypted_data[:4] - + if header == b'\x89ST3': return EMBED_MODE_LSB elif header == b'\x89DCT': diff --git a/src/stegasoo/crypto.py b/src/stegasoo/crypto.py index 34ec264..abb9698 100644 --- a/src/stegasoo/crypto.py +++ b/src/stegasoo/crypto.py @@ -15,38 +15,40 @@ BREAKING CHANGES in v3.2.0: - Renamed day_phrase β†’ passphrase (no daily rotation needed) """ -import io import hashlib +import io import secrets import struct -import json -from typing import Optional, Union -from PIL import Image -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from PIL import Image from .constants import ( - MAGIC_HEADER, FORMAT_VERSION, - SALT_SIZE, IV_SIZE, TAG_SIZE, - ARGON2_TIME_COST, ARGON2_MEMORY_COST, ARGON2_PARALLELISM, - PBKDF2_ITERATIONS, - PAYLOAD_TEXT, PAYLOAD_FILE, + ARGON2_MEMORY_COST, + ARGON2_PARALLELISM, + ARGON2_TIME_COST, + FORMAT_VERSION, + IV_SIZE, + MAGIC_HEADER, MAX_FILENAME_LENGTH, + PAYLOAD_FILE, + PAYLOAD_TEXT, + PBKDF2_ITERATIONS, + SALT_SIZE, + TAG_SIZE, ) -from .models import FilePayload, DecodeResult -from .exceptions import ( - EncryptionError, DecryptionError, KeyDerivationError, InvalidHeaderError -) +from .exceptions import DecryptionError, EncryptionError, InvalidHeaderError, KeyDerivationError +from .models import DecodeResult, FilePayload # Check for Argon2 availability try: - from argon2.low_level import hash_secret_raw, Type + from argon2.low_level import Type, hash_secret_raw HAS_ARGON2 = True except ImportError: HAS_ARGON2 = False - from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC # ============================================================================= @@ -57,28 +59,28 @@ except ImportError: CHANNEL_KEY_AUTO = "auto" -def _resolve_channel_key(channel_key: Optional[Union[str, bool]]) -> Optional[bytes]: +def _resolve_channel_key(channel_key: str | bool | None) -> bytes | None: """ Resolve channel key parameter to actual key hash. - + Args: channel_key: Channel key parameter with these behaviors: - None or "auto": Use server's configured key (from env/config) - str (valid key): Use this specific key - "" or False: Explicitly use NO channel key (public mode) - + Returns: 32-byte channel key hash, or None for public mode """ # Explicit public mode if channel_key == "" or channel_key is False: return None - + # Auto-detect from environment/config if channel_key is None or channel_key == CHANNEL_KEY_AUTO: from .channel import get_channel_key_hash return get_channel_key_hash() - + # Explicit key provided - validate and hash it if isinstance(channel_key, str): from .channel import format_channel_key, validate_channel_key @@ -86,7 +88,7 @@ def _resolve_channel_key(channel_key: Optional[Union[str, bool]]) -> Optional[by raise ValueError(f"Invalid channel key format: {channel_key}") formatted = format_channel_key(channel_key) return hashlib.sha256(formatted.encode('utf-8')).digest() - + raise ValueError(f"Invalid channel_key type: {type(channel_key)}") @@ -97,19 +99,19 @@ def _resolve_channel_key(channel_key: Optional[Union[str, bool]]) -> Optional[by def hash_photo(image_data: bytes) -> bytes: """ Compute deterministic hash of photo pixel content. - + This normalizes the image to RGB and hashes the raw pixel data, making it resistant to metadata changes. - + Args: image_data: Raw image file bytes - + Returns: 32-byte SHA-256 hash """ img: Image.Image = Image.open(io.BytesIO(image_data)).convert('RGB') pixels = img.tobytes() - + # Double-hash with prefix for additional mixing h = hashlib.sha256(pixels).digest() h = hashlib.sha256(h + pixels[:1024]).digest() @@ -121,12 +123,12 @@ def derive_hybrid_key( passphrase: str, salt: bytes, pin: str = "", - rsa_key_data: Optional[bytes] = None, - channel_key: Optional[Union[str, bool]] = None, + rsa_key_data: bytes | None = None, + channel_key: str | bool | None = None, ) -> bytes: """ Derive encryption key from multiple factors. - + Combines: - Photo hash (something you have) - Passphrase (something you know) @@ -134,9 +136,9 @@ def derive_hybrid_key( - RSA key (something you have) - Channel key (deployment/group binding) - Salt (random per message) - + Uses Argon2id if available, falls back to PBKDF2. - + Args: photo_data: Reference photo bytes passphrase: Shared passphrase (recommend 4+ words) @@ -147,19 +149,19 @@ def derive_hybrid_key( - None or "auto": Use configured key - str: Use this specific key - "" or False: No channel key (public mode) - + Returns: 32-byte derived key - + Raises: KeyDerivationError: If key derivation fails """ try: photo_hash = hash_photo(photo_data) - + # Resolve channel key channel_hash = _resolve_channel_key(channel_key) - + # Build key material key_material = ( photo_hash + @@ -167,15 +169,15 @@ def derive_hybrid_key( pin.encode() + salt ) - + # Add RSA key hash if provided if rsa_key_data: key_material += hashlib.sha256(rsa_key_data).digest() - + # Add channel key hash if configured (v4.0.0) if channel_hash: key_material += channel_hash - + if HAS_ARGON2: key = hash_secret_raw( secret=key_material, @@ -195,9 +197,9 @@ def derive_hybrid_key( backend=default_backend() ) key = kdf.derive(key_material) - + return key - + except Exception as e: raise KeyDerivationError(f"Failed to derive key: {e}") from e @@ -206,61 +208,61 @@ def derive_pixel_key( photo_data: bytes, passphrase: str, pin: str = "", - rsa_key_data: Optional[bytes] = None, - channel_key: Optional[Union[str, bool]] = None, + rsa_key_data: bytes | None = None, + channel_key: str | bool | None = None, ) -> bytes: """ Derive key for pseudo-random pixel selection. - + This key determines which pixels are used for embedding, making the message location unpredictable without the correct inputs. - + Args: photo_data: Reference photo bytes passphrase: Shared passphrase pin: Optional static PIN rsa_key_data: Optional RSA key bytes channel_key: Channel key parameter (see derive_hybrid_key) - + Returns: 32-byte key for pixel selection """ photo_hash = hash_photo(photo_data) - + # Resolve channel key channel_hash = _resolve_channel_key(channel_key) - + material = ( photo_hash + passphrase.lower().encode() + pin.encode() ) - + if rsa_key_data: material += hashlib.sha256(rsa_key_data).digest() - + # Add channel key hash if configured (v4.0.0) if channel_hash: material += channel_hash - + return hashlib.sha256(material + b"pixel_selection").digest() def _pack_payload( - content: Union[str, bytes, FilePayload], + content: str | bytes | FilePayload, ) -> tuple[bytes, int]: """ Pack payload with type marker and metadata. - + Format for text: [type:1][data] - + Format for file: [type:1][filename_len:2][filename][mime_len:2][mime][data] - + Args: content: Text string, raw bytes, or FilePayload - + Returns: Tuple of (packed bytes, payload type) """ @@ -268,12 +270,12 @@ def _pack_payload( # Text message data = content.encode('utf-8') return bytes([PAYLOAD_TEXT]) + data, PAYLOAD_TEXT - + elif isinstance(content, FilePayload): # File with metadata filename = content.filename[:MAX_FILENAME_LENGTH].encode('utf-8') mime = (content.mime_type or '')[:100].encode('utf-8') - + packed = ( bytes([PAYLOAD_FILE]) + struct.pack('>H', len(filename)) + @@ -283,7 +285,7 @@ def _pack_payload( content.data ) return packed, PAYLOAD_FILE - + else: # Raw bytes - treat as file with no name packed = ( @@ -298,49 +300,49 @@ def _pack_payload( def _unpack_payload(data: bytes) -> DecodeResult: """ Unpack payload and extract content with metadata. - + Args: data: Packed payload bytes - + Returns: DecodeResult with appropriate content """ if len(data) < 1: raise DecryptionError("Empty payload") - + payload_type = data[0] - + if payload_type == PAYLOAD_TEXT: # Text message text = data[1:].decode('utf-8') return DecodeResult(payload_type='text', message=text) - + elif payload_type == PAYLOAD_FILE: # File with metadata offset = 1 - + # Read filename filename_len = struct.unpack('>H', data[offset:offset+2])[0] offset += 2 filename = data[offset:offset+filename_len].decode('utf-8') if filename_len else None offset += filename_len - + # Read mime type mime_len = struct.unpack('>H', data[offset:offset+2])[0] offset += 2 mime_type = data[offset:offset+mime_len].decode('utf-8') if mime_len else None offset += mime_len - + # Rest is file data file_data = data[offset:] - + return DecodeResult( payload_type='file', file_data=file_data, filename=filename, mime_type=mime_type ) - + else: # Unknown type - try to decode as text (backward compatibility) try: @@ -359,16 +361,16 @@ FLAG_CHANNEL_KEY = 0x01 # Set if encoded with a channel key def encrypt_message( - message: Union[str, bytes, FilePayload], + message: str | bytes | FilePayload, photo_data: bytes, passphrase: str, pin: str = "", - rsa_key_data: Optional[bytes] = None, - channel_key: Optional[Union[str, bool]] = None, + rsa_key_data: bytes | None = None, + channel_key: str | bool | None = None, ) -> bytes: """ Encrypt message or file using AES-256-GCM with hybrid key derivation. - + Message format (v4.0.0 - with channel key support): - Magic header (4 bytes) - Version (1 byte) = 5 @@ -377,7 +379,7 @@ def encrypt_message( - IV (12 bytes) - Auth tag (16 bytes) - Ciphertext (variable, padded) - + Args: message: Message string, raw bytes, or FilePayload to encrypt photo_data: Reference photo bytes @@ -386,12 +388,12 @@ def encrypt_message( rsa_key_data: Optional RSA key bytes channel_key: Channel key parameter: - None or "auto": Use configured key - - str: Use this specific key + - str: Use this specific key - "" or False: No channel key (public mode) - + Returns: Encrypted message bytes - + Raises: EncryptionError: If encryption fails """ @@ -399,32 +401,32 @@ def encrypt_message( salt = secrets.token_bytes(SALT_SIZE) key = derive_hybrid_key(photo_data, passphrase, salt, pin, rsa_key_data, channel_key) iv = secrets.token_bytes(IV_SIZE) - + # Determine flags flags = 0 channel_hash = _resolve_channel_key(channel_key) if channel_hash: flags |= FLAG_CHANNEL_KEY - + # Pack payload with type marker packed_payload, _ = _pack_payload(message) - + # Random padding to hide message length padding_len = secrets.randbelow(256) + 64 padded_len = ((len(packed_payload) + padding_len + 255) // 256) * 256 padding_needed = padded_len - len(packed_payload) padding = secrets.token_bytes(padding_needed - 4) + struct.pack('>I', len(packed_payload)) padded_message = packed_payload + padding - + # Build header for AAD header = MAGIC_HEADER + bytes([FORMAT_VERSION, flags]) - + # Encrypt with AES-256-GCM cipher = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend()) encryptor = cipher.encryptor() encryptor.authenticate_additional_data(header) ciphertext = encryptor.update(padded_message) + encryptor.finalize() - + # v4.0.0: Header with flags byte return ( header + @@ -433,34 +435,34 @@ def encrypt_message( encryptor.tag + ciphertext ) - + except Exception as e: raise EncryptionError(f"Encryption failed: {e}") from e -def parse_header(encrypted_data: bytes) -> Optional[dict]: +def parse_header(encrypted_data: bytes) -> dict | None: """ Parse the header from encrypted data. - + v4.0.0: Includes flags byte for channel key indicator. - + Args: encrypted_data: Raw encrypted bytes - + Returns: Dict with salt, iv, tag, ciphertext, flags or None if invalid """ # Min size: Magic(4) + Version(1) + Flags(1) + Salt(32) + IV(12) + Tag(16) = 66 bytes if len(encrypted_data) < 66 or encrypted_data[:4] != MAGIC_HEADER: return None - + try: version = encrypted_data[4] if version != FORMAT_VERSION: return None - + flags = encrypted_data[5] - + offset = 6 salt = encrypted_data[offset:offset + SALT_SIZE] offset += SALT_SIZE @@ -469,7 +471,7 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]: tag = encrypted_data[offset:offset + TAG_SIZE] offset += TAG_SIZE ciphertext = encrypted_data[offset:] - + return { 'version': version, 'flags': flags, @@ -488,12 +490,12 @@ def decrypt_message( photo_data: bytes, passphrase: str, pin: str = "", - rsa_key_data: Optional[bytes] = None, - channel_key: Optional[Union[str, bool]] = None, + rsa_key_data: bytes | None = None, + channel_key: str | bool | None = None, ) -> DecodeResult: """ Decrypt message (v4.0.0 - with channel key support). - + Args: encrypted_data: Encrypted message bytes photo_data: Reference photo bytes @@ -501,10 +503,10 @@ def decrypt_message( pin: Optional static PIN rsa_key_data: Optional RSA key bytes channel_key: Channel key parameter (see encrypt_message) - + Returns: DecodeResult with decrypted content - + Raises: InvalidHeaderError: If data doesn't have valid Stegasoo header DecryptionError: If decryption fails (wrong credentials) @@ -512,20 +514,20 @@ def decrypt_message( header = parse_header(encrypted_data) if not header: raise InvalidHeaderError("Invalid or missing Stegasoo header") - + # Check for channel key mismatch and provide helpful error channel_hash = _resolve_channel_key(channel_key) has_configured_key = channel_hash is not None message_has_key = header['has_channel_key'] - + try: key = derive_hybrid_key( photo_data, passphrase, header['salt'], pin, rsa_key_data, channel_key ) - + # Reconstruct header for AAD verification aad_header = MAGIC_HEADER + bytes([FORMAT_VERSION, header['flags']]) - + cipher = Cipher( algorithms.AES(key), modes.GCM(header['iv'], header['tag']), @@ -533,15 +535,15 @@ def decrypt_message( ) decryptor = cipher.decryptor() decryptor.authenticate_additional_data(aad_header) - + padded_plaintext = decryptor.update(header['ciphertext']) + decryptor.finalize() original_length = struct.unpack('>I', padded_plaintext[-4:])[0] - + payload_data = padded_plaintext[:original_length] result = _unpack_payload(payload_data) - + return result - + except Exception as e: # Provide more helpful error message for channel key issues if message_has_key and not has_configured_key: @@ -566,14 +568,14 @@ def decrypt_message_text( photo_data: bytes, passphrase: str, pin: str = "", - rsa_key_data: Optional[bytes] = None, - channel_key: Optional[Union[str, bool]] = None, + rsa_key_data: bytes | None = None, + channel_key: str | bool | None = None, ) -> str: """ Decrypt message and return as text string. - + For backward compatibility - returns text content or raises error for files. - + Args: encrypted_data: Encrypted message bytes photo_data: Reference photo bytes @@ -581,15 +583,15 @@ def decrypt_message_text( pin: Optional static PIN rsa_key_data: Optional RSA key bytes channel_key: Channel key parameter - + Returns: Decrypted message string - + Raises: DecryptionError: If decryption fails or content is a file """ result = decrypt_message(encrypted_data, photo_data, passphrase, pin, rsa_key_data, channel_key) - + if result.is_file: if result.file_data: # Try to decode as text @@ -600,7 +602,7 @@ def decrypt_message_text( f"Content is a binary file ({result.filename or 'unnamed'}), not text" ) return "" - + return result.message or "" @@ -613,10 +615,10 @@ def has_argon2() -> bool: # CHANNEL KEY UTILITIES (exposed for convenience) # ============================================================================= -def get_active_channel_key() -> Optional[str]: +def get_active_channel_key() -> str | None: """ Get the currently configured channel key (if any). - + Returns: Formatted channel key string, or None if not configured """ @@ -624,7 +626,7 @@ def get_active_channel_key() -> Optional[str]: return get_channel_key() -def get_channel_fingerprint(key: Optional[str] = None) -> Optional[str]: +def get_channel_fingerprint(key: str | None = None) -> str | None: """ Get a display-safe fingerprint of a channel key. diff --git a/src/stegasoo/dct_steganography.py b/src/stegasoo/dct_steganography.py index 12a9762..9e59e16 100644 --- a/src/stegasoo/dct_steganography.py +++ b/src/stegasoo/dct_steganography.py @@ -14,12 +14,11 @@ v3.2.0-patch2 Changes: Requires: scipy (for PNG mode), optionally jpegio (for JPEG mode) """ +import gc +import hashlib import io import struct -import hashlib -import gc from dataclasses import dataclass -from typing import Optional, Tuple from enum import Enum import numpy as np @@ -103,7 +102,7 @@ class DCTEmbedStats: color_mode: str = 'grayscale' -@dataclass +@dataclass class DCTCapacityInfo: width: int height: int @@ -147,19 +146,19 @@ def _safe_dct2(block: np.ndarray) -> np.ndarray: """ # Create a brand new array (not a view) safe_block = np.array(block, dtype=np.float64, copy=True, order='C') - + # First DCT on columns (transpose -> DCT rows -> transpose back) temp = np.zeros_like(safe_block, dtype=np.float64, order='C') for i in range(BLOCK_SIZE): col = np.array(safe_block[:, i], dtype=np.float64, copy=True) temp[:, i] = dct(col, norm='ortho') - + # Second DCT on rows result = np.zeros_like(temp, dtype=np.float64, order='C') for i in range(BLOCK_SIZE): row = np.array(temp[i, :], dtype=np.float64, copy=True) result[i, :] = dct(row, norm='ortho') - + return result @@ -170,19 +169,19 @@ def _safe_idct2(block: np.ndarray) -> np.ndarray: """ # Create a brand new array (not a view) safe_block = np.array(block, dtype=np.float64, copy=True, order='C') - + # First IDCT on rows temp = np.zeros_like(safe_block, dtype=np.float64, order='C') for i in range(BLOCK_SIZE): row = np.array(safe_block[i, :], dtype=np.float64, copy=True) temp[i, :] = idct(row, norm='ortho') - + # Second IDCT on columns result = np.zeros_like(temp, dtype=np.float64, order='C') for i in range(BLOCK_SIZE): col = np.array(temp[:, i], dtype=np.float64, copy=True) result[:, i] = idct(col, norm='ortho') - + return result @@ -200,23 +199,23 @@ def _extract_y_channel(image_data: bytes) -> np.ndarray: img = Image.open(io.BytesIO(image_data)) if img.mode != 'RGB': img = img.convert('RGB') - + rgb = np.array(img, dtype=np.float64, copy=True, order='C') Y = 0.299 * rgb[:, :, 0] + 0.587 * rgb[:, :, 1] + 0.114 * rgb[:, :, 2] return np.array(Y, dtype=np.float64, copy=True, order='C') -def _pad_to_blocks(image: np.ndarray) -> Tuple[np.ndarray, Tuple[int, int]]: +def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]: h, w = image.shape new_h = ((h + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE new_w = ((w + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE - + if new_h == h and new_w == w: return np.array(image, dtype=np.float64, copy=True, order='C'), (h, w) - + padded = np.zeros((new_h, new_w), dtype=np.float64, order='C') padded[:h, :w] = image - + # Simple edge replication for padding if new_h > h: for i in range(h, new_h): @@ -226,11 +225,11 @@ def _pad_to_blocks(image: np.ndarray) -> Tuple[np.ndarray, Tuple[int, int]]: padded[:h, j] = padded[:h, w-1] if new_h > h and new_w > w: padded[h:, w:] = padded[h-1, w-1] - + return padded, (h, w) -def _unpad_image(image: np.ndarray, original_size: Tuple[int, int]) -> np.ndarray: +def _unpad_image(image: np.ndarray, original_size: tuple[int, int]) -> np.ndarray: h, w = original_size return np.array(image[:h, :w], dtype=np.float64, copy=True, order='C') @@ -263,7 +262,7 @@ def _save_stego_image(image: np.ndarray, output_format: str = OUTPUT_FORMAT_PNG) img = Image.fromarray(clipped, mode='L') buffer = io.BytesIO() if output_format == OUTPUT_FORMAT_JPEG: - img.save(buffer, format='JPEG', quality=JPEG_OUTPUT_QUALITY, + img.save(buffer, format='JPEG', quality=JPEG_OUTPUT_QUALITY, subsampling=0, optimize=True) else: img.save(buffer, format='PNG', optimize=True) @@ -282,15 +281,15 @@ def _save_color_image(rgb_array: np.ndarray, output_format: str = OUTPUT_FORMAT_ return buffer.getvalue() -def _rgb_to_ycbcr(rgb: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: +def _rgb_to_ycbcr(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: R = rgb[:, :, 0].astype(np.float64) G = rgb[:, :, 1].astype(np.float64) B = rgb[:, :, 2].astype(np.float64) - + Y = np.array(0.299 * R + 0.587 * G + 0.114 * B, dtype=np.float64, copy=True, order='C') Cb = np.array(128 - 0.168736 * R - 0.331264 * G + 0.5 * B, dtype=np.float64, copy=True, order='C') Cr = np.array(128 + 0.5 * R - 0.418688 * G - 0.081312 * B, dtype=np.float64, copy=True, order='C') - + return Y, Cb, Cr @@ -298,7 +297,7 @@ def _ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray) -> np.ndarray: R = Y + 1.402 * (Cr - 128) G = Y - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 128) B = Y + 1.772 * (Cb - 128) - + rgb = np.zeros((Y.shape[0], Y.shape[1], 3), dtype=np.float64, order='C') rgb[:, :, 0] = R rgb[:, :, 1] = G @@ -310,20 +309,20 @@ def _create_header(data_length: int, flags: int = 0) -> bytes: return struct.pack('>4sBBI', DCT_MAGIC, 1, flags, data_length) -def _parse_header(header_bits: list) -> Tuple[int, int, int]: +def _parse_header(header_bits: list) -> tuple[int, int, int]: if len(header_bits) < HEADER_SIZE * 8: raise ValueError("Insufficient header data") - + header_bytes = bytes([ sum(header_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8)) for i in range(HEADER_SIZE) ]) - + magic, version, flags, length = struct.unpack('>4sBBI', header_bytes) - + if magic != DCT_MAGIC: raise ValueError("Invalid DCT stego magic bytes") - + return version, flags, length @@ -332,8 +331,8 @@ def _parse_header(header_bits: list) -> Tuple[int, int, int]: # ============================================================================ def _jpegio_bytes_to_file(data: bytes, suffix: str = '.jpg') -> str: - import tempfile import os + import tempfile fd, path = tempfile.mkstemp(suffix=suffix) try: os.write(fd, data) @@ -366,7 +365,7 @@ def _jpegio_create_header(data_length: int, flags: int = 0) -> bytes: return struct.pack('>4sBBI', JPEGIO_MAGIC, 1, flags, data_length) -def _jpegio_parse_header(header_bytes: bytes) -> Tuple[int, int, int]: +def _jpegio_parse_header(header_bytes: bytes) -> tuple[int, int, int]: if len(header_bytes) < HEADER_SIZE: raise ValueError("Insufficient header data") magic, version, flags, length = struct.unpack('>4sBBI', header_bytes[:HEADER_SIZE]) @@ -382,21 +381,21 @@ def _jpegio_parse_header(header_bytes: bytes) -> Tuple[int, int, int]: def calculate_dct_capacity(image_data: bytes) -> DCTCapacityInfo: """Calculate DCT embedding capacity of an image.""" _check_scipy() - + # Just get dimensions, don't process anything img = Image.open(io.BytesIO(image_data)) width, height = img.size img.close() # Explicitly close - + blocks_x = width // BLOCK_SIZE blocks_y = height // BLOCK_SIZE total_blocks = blocks_x * blocks_y - + bits_per_block = len(DEFAULT_EMBED_POSITIONS) total_bits = total_blocks * bits_per_block total_bytes = total_bits // 8 usable_bytes = max(0, total_bytes - HEADER_SIZE) - + return DCTCapacityInfo( width=width, height=height, @@ -420,13 +419,13 @@ def estimate_capacity_comparison(image_data: bytes) -> dict: img = Image.open(io.BytesIO(image_data)) width, height = img.size img.close() - + pixels = width * height lsb_bytes = (pixels * 3) // 8 - + blocks = (width // 8) * (height // 8) dct_bytes = (blocks * 16) // 8 - HEADER_SIZE - + return { 'width': width, 'height': height, @@ -455,17 +454,17 @@ def embed_in_dct( seed: bytes, output_format: str = OUTPUT_FORMAT_PNG, color_mode: str = 'color', -) -> Tuple[bytes, DCTEmbedStats]: +) -> tuple[bytes, DCTEmbedStats]: """Embed data using DCT coefficient modification.""" if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG): raise ValueError(f"Invalid output format: {output_format}") - + if color_mode not in ('color', 'grayscale'): color_mode = 'color' - + if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO: return _embed_jpegio(data, carrier_image, seed, color_mode) - + _check_scipy() return _embed_scipy_dct_safe(data, carrier_image, seed, output_format, color_mode) @@ -476,27 +475,27 @@ def _embed_scipy_dct_safe( seed: bytes, output_format: str, color_mode: str = 'color', -) -> Tuple[bytes, DCTEmbedStats]: +) -> tuple[bytes, DCTEmbedStats]: """ Embed using scipy DCT with safe memory handling. - + Uses row-by-row 1D DCT operations instead of 2D arrays to avoid scipy memory corruption issues with large images. """ capacity_info = calculate_dct_capacity(carrier_image) - + if len(data) > capacity_info.usable_capacity_bytes: raise ValueError( f"Data too large ({len(data)} bytes) for carrier " f"(capacity: {capacity_info.usable_capacity_bytes} bytes)" ) - + # Load image img = Image.open(io.BytesIO(carrier_image)) width, height = img.size - + flags = FLAG_COLOR_MODE if color_mode == 'color' else 0 - + # Prepare payload bits header = _create_header(len(data), flags) payload = header + data @@ -504,41 +503,41 @@ def _embed_scipy_dct_safe( for byte in payload: for i in range(7, -1, -1): bits.append((byte >> i) & 1) - + # Generate block order num_blocks = capacity_info.total_blocks block_order = _generate_block_order(num_blocks, seed) blocks_x = width // BLOCK_SIZE - + if color_mode == 'color' and img.mode in ('RGB', 'RGBA'): if img.mode == 'RGBA': img = img.convert('RGB') - + # Process color image rgb = np.array(img, dtype=np.float64, copy=True, order='C') img.close() - + Y, Cb, Cr = _rgb_to_ycbcr(rgb) del rgb gc.collect() - + Y_padded, original_size = _pad_to_blocks(Y) del Y gc.collect() - + # Embed in Y channel Y_embedded = _embed_in_channel_safe(Y_padded, bits, block_order, blocks_x) del Y_padded gc.collect() - + Y_result = _unpad_image(Y_embedded, original_size) del Y_embedded gc.collect() - + result_rgb = _ycbcr_to_rgb(Y_result, Cb, Cr) del Y_result, Cb, Cr gc.collect() - + stego_bytes = _save_color_image(result_rgb, output_format) del result_rgb gc.collect() @@ -546,23 +545,23 @@ def _embed_scipy_dct_safe( # Grayscale mode image = _to_grayscale(carrier_image) img.close() - + padded, original_size = _pad_to_blocks(image) del image gc.collect() - + embedded = _embed_in_channel_safe(padded, bits, block_order, blocks_x) del padded gc.collect() - + result = _unpad_image(embedded, original_size) del embedded gc.collect() - + stego_bytes = _save_stego_image(result, output_format) del result gc.collect() - + stats = DCTEmbedStats( blocks_used=(len(bits) + len(DEFAULT_EMBED_POSITIONS) - 1) // len(DEFAULT_EMBED_POSITIONS), blocks_available=capacity_info.total_blocks, @@ -575,7 +574,7 @@ def _embed_scipy_dct_safe( jpeg_native=False, color_mode=color_mode, ) - + return stego_bytes, stats @@ -587,78 +586,78 @@ def _embed_in_channel_safe( ) -> np.ndarray: """ Embed bits in channel using safe DCT operations. - + Processes one block at a time with fresh array allocations. """ h, w = channel.shape - + # Create result with explicit new memory result = np.array(channel, dtype=np.float64, copy=True, order='C') - + bit_idx = 0 - + for block_num in block_order: if bit_idx >= len(bits): break - + by = (block_num // blocks_x) * BLOCK_SIZE bx = (block_num % blocks_x) * BLOCK_SIZE - + # Extract block - create brand new array block = np.array( result[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE], dtype=np.float64, copy=True, order='C' ) - + # Apply safe DCT (row-by-row) dct_block = _safe_dct2(block) - + # Embed bits for pos in DEFAULT_EMBED_POSITIONS: if bit_idx >= len(bits): break dct_block[pos[0], pos[1]] = _embed_bit_in_coeff( - float(dct_block[pos[0], pos[1]]), + float(dct_block[pos[0], pos[1]]), bits[bit_idx] ) bit_idx += 1 - + # Apply safe inverse DCT modified_block = _safe_idct2(dct_block) - + # Copy back result[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE] = modified_block - + # Clean up this iteration del block, dct_block, modified_block - + # Force garbage collection gc.collect() - + return result def _normalize_jpeg_for_jpegio(image_data: bytes) -> bytes: """ Normalize a JPEG image to ensure jpegio can process it safely. - + JPEGs saved with quality=100 have quantization tables with all values = 1, which causes jpegio to crash due to huge coefficient magnitudes. This function detects such images and re-saves them at a safe quality level. - + Args: image_data: Raw JPEG bytes - + Returns: Normalized JPEG bytes (may be unchanged if already safe) """ img = Image.open(io.BytesIO(image_data)) - + # Only process JPEGs if img.format != 'JPEG': img.close() return image_data - + # Check quantization tables needs_normalization = False if hasattr(img, 'quantization') and img.quantization: @@ -667,19 +666,19 @@ def _normalize_jpeg_for_jpegio(image_data: bytes) -> bytes: if max(table) <= JPEGIO_MAX_QUANT_VALUE_THRESHOLD: needs_normalization = True break - + if not needs_normalization: img.close() return image_data - + # Re-save at safe quality level if img.mode != 'RGB': img = img.convert('RGB') - + buffer = io.BytesIO() img.save(buffer, format='JPEG', quality=JPEGIO_NORMALIZE_QUALITY, subsampling=0) img.close() - + return buffer.getvalue() @@ -688,17 +687,17 @@ def _embed_jpegio( carrier_image: bytes, seed: bytes, color_mode: str = 'color', -) -> Tuple[bytes, DCTEmbedStats]: +) -> tuple[bytes, DCTEmbedStats]: """Embed using jpegio for proper JPEG coefficient modification.""" - import tempfile import os - + import tempfile + # Normalize JPEG to avoid crashes with quality=100 images carrier_image = _normalize_jpeg_for_jpegio(carrier_image) - + img = Image.open(io.BytesIO(carrier_image)) width, height = img.size - + if img.format != 'JPEG': buffer = io.BytesIO() if img.mode != 'RGB': @@ -706,54 +705,54 @@ def _embed_jpegio( img.save(buffer, format='JPEG', quality=95, subsampling=0) carrier_image = buffer.getvalue() img.close() - + input_path = _jpegio_bytes_to_file(carrier_image, suffix='.jpg') output_path = tempfile.mktemp(suffix='.jpg') - + flags = FLAG_COLOR_MODE if color_mode == 'color' else 0 - + try: jpeg = jio.read(input_path) coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL] - + all_positions = _jpegio_get_usable_positions(coef_array) order = _jpegio_generate_order(len(all_positions), seed) - + header = _jpegio_create_header(len(data), flags) payload = header + data - + bits = [] for byte in payload: for i in range(7, -1, -1): bits.append((byte >> i) & 1) - + if len(bits) > len(all_positions): raise ValueError( f"Payload too large: {len(bits)} bits, " f"only {len(all_positions)} usable coefficients" ) - + coefs_used = 0 for bit_idx, pos_idx in enumerate(order): if bit_idx >= len(bits): break - + row, col = all_positions[pos_idx] coef = coef_array[row, col] - + if (coef & 1) != bits[bit_idx]: if coef > 0: coef_array[row, col] = coef - 1 if (coef & 1) else coef + 1 else: coef_array[row, col] = coef + 1 if (coef & 1) else coef - 1 - + coefs_used += 1 - + jio.write(jpeg, output_path) - + with open(output_path, 'rb') as f: stego_bytes = f.read() - + stats = DCTEmbedStats( blocks_used=coefs_used // 63, blocks_available=len(all_positions) // 63, @@ -766,9 +765,9 @@ def _embed_jpegio( jpeg_native=True, color_mode=color_mode, ) - + return stego_bytes, stats - + finally: for path in [input_path, output_path]: try: @@ -782,13 +781,13 @@ def extract_from_dct(stego_image: bytes, seed: bytes) -> bytes: img = Image.open(io.BytesIO(stego_image)) fmt = img.format img.close() - + if fmt == 'JPEG' and HAS_JPEGIO: try: return _extract_jpegio(stego_image, seed) except ValueError: pass - + _check_scipy() return _extract_scipy_dct_safe(stego_image, seed) @@ -798,41 +797,41 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes: img = Image.open(io.BytesIO(stego_image)) width, height = img.size mode = img.mode - + if mode in ('RGB', 'RGBA'): channel = _extract_y_channel(stego_image) else: channel = _to_grayscale(stego_image) img.close() - + padded, _ = _pad_to_blocks(channel) del channel gc.collect() - + h, w = padded.shape blocks_x = w // BLOCK_SIZE num_blocks = (h // BLOCK_SIZE) * blocks_x - + block_order = _generate_block_order(num_blocks, seed) - + all_bits = [] - + for block_num in block_order: by = (block_num // blocks_x) * BLOCK_SIZE bx = (block_num % blocks_x) * BLOCK_SIZE - + block = np.array( padded[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE], dtype=np.float64, copy=True, order='C' ) dct_block = _safe_dct2(block) - + for pos in DEFAULT_EMBED_POSITIONS: bit = _extract_bit_from_coeff(float(dct_block[pos[0], pos[1]])) all_bits.append(bit) - + del block, dct_block - + if len(all_bits) >= HEADER_SIZE * 8: try: _, flags, data_length = _parse_header(all_bits[:HEADER_SIZE * 8]) @@ -841,53 +840,53 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes: break except ValueError: pass - + del padded gc.collect() - + _, flags, data_length = _parse_header(all_bits) data_bits = all_bits[HEADER_SIZE * 8:(HEADER_SIZE + data_length) * 8] - + data = bytes([ sum(data_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8)) for i in range(data_length) ]) - + return data def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes: """Extract using jpegio for JPEG images.""" import os - + # Normalize JPEG to avoid crashes with quality=100 images # (shouldn't happen with stego images, but be defensive) stego_image = _normalize_jpeg_for_jpegio(stego_image) - + temp_path = _jpegio_bytes_to_file(stego_image, suffix='.jpg') - + try: jpeg = jio.read(temp_path) coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL] - + all_positions = _jpegio_get_usable_positions(coef_array) order = _jpegio_generate_order(len(all_positions), seed) - + header_bits = [] for pos_idx in order[:HEADER_SIZE * 8]: row, col = all_positions[pos_idx] coef = coef_array[row, col] header_bits.append(coef & 1) - + header_bytes = bytes([ sum(header_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8)) for i in range(HEADER_SIZE) ]) - + _, flags, data_length = _jpegio_parse_header(header_bytes) - + total_bits_needed = (HEADER_SIZE + data_length) * 8 - + all_bits = [] for bit_idx, pos_idx in enumerate(order): if bit_idx >= total_bits_needed: @@ -895,16 +894,16 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes: row, col = all_positions[pos_idx] coef = coef_array[row, col] all_bits.append(coef & 1) - + data_bits = all_bits[HEADER_SIZE * 8:] - + data = bytes([ sum(data_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8)) for i in range(data_length) ]) - + return data - + finally: try: os.unlink(temp_path) diff --git a/src/stegasoo/debug.py b/src/stegasoo/debug.py index e1a43f4..e18aae4 100644 --- a/src/stegasoo/debug.py +++ b/src/stegasoo/debug.py @@ -5,12 +5,13 @@ Debugging, logging, and performance monitoring tools. Can be disabled for production use. """ +import sys import time import traceback +from collections.abc import Callable from datetime import datetime from functools import wraps -from typing import Callable, Any, Optional, Dict, Union -import sys +from typing import Any # Global debug configuration DEBUG_ENABLED = False # Set to True to enable debug output @@ -47,10 +48,10 @@ def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str: """Format bytes for debugging.""" if not DEBUG_ENABLED: return "" - + if not data: return f"{label}: Empty" - + if len(data) <= max_bytes: return f"{label} ({len(data)} bytes): {data.hex()}" else: @@ -71,7 +72,7 @@ def time_function(func: Callable) -> Callable: def wrapper(*args, **kwargs) -> Any: if not (DEBUG_ENABLED and LOG_PERFORMANCE): return func(*args, **kwargs) - + start = time.perf_counter() try: result = func(*args, **kwargs) @@ -79,7 +80,7 @@ def time_function(func: Callable) -> Callable: finally: end = time.perf_counter() debug_print(f"{func.__name__} took {end - start:.6f}s", "PERF") - + return wrapper @@ -89,14 +90,15 @@ def validate_assertion(condition: bool, message: str) -> None: raise AssertionError(f"Validation failed: {message}") -def memory_usage() -> Dict[str, Union[float, str]]: +def memory_usage() -> dict[str, float | str]: """Get current memory usage (if psutil is available).""" try: - import psutil import os + + import psutil process = psutil.Process(os.getpid()) mem_info = process.memory_info() - + return { 'rss_mb': mem_info.rss / 1024 / 1024, 'vms_mb': mem_info.vms / 1024 / 1024, @@ -110,66 +112,66 @@ def hexdump(data: bytes, offset: int = 0, length: int = 64) -> str: """Create hexdump string for debugging binary data.""" if not data: return "Empty" - + result = [] data_to_dump = data[:length] - + for i in range(0, len(data_to_dump), 16): chunk = data_to_dump[i:i+16] hex_str = ' '.join(f'{b:02x}' for b in chunk) hex_str = hex_str.ljust(47) ascii_str = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk) result.append(f"{offset + i:08x}: {hex_str} {ascii_str}") - + if len(data) > length: result.append(f"... ({len(data) - length} more bytes)") - + return '\n'.join(result) class Debug: """Debugging utility class.""" - + def __init__(self): self.enabled = DEBUG_ENABLED - + def print(self, message: str, level: str = "INFO") -> None: """Print debug message.""" debug_print(message, level) - + def data(self, data: bytes, label: str = "Data", max_bytes: int = 32) -> str: """Format bytes for debugging.""" return debug_data(data, label, max_bytes) - + def exception(self, e: Exception, context: str = "") -> None: """Log exception with context.""" debug_exception(e, context) - + def time(self, func: Callable) -> Callable: """Decorator to time function execution.""" return time_function(func) - + def validate(self, condition: bool, message: str) -> None: """Runtime validation assertion.""" validate_assertion(condition, message) - - def memory(self) -> Dict[str, Union[float, str]]: + + def memory(self) -> dict[str, float | str]: """Get current memory usage.""" return memory_usage() - + def hexdump(self, data: bytes, offset: int = 0, length: int = 64) -> str: """Create hexdump string.""" return hexdump(data, offset, length) - + def enable(self, enable: bool = True) -> None: """Enable or disable debug mode.""" enable_debug(enable) self.enabled = enable - + def enable_performance(self, enable: bool = True) -> None: """Enable or disable performance logging.""" enable_performance_logging(enable) - + def enable_assertions(self, enable: bool = True) -> None: """Enable or disable validation assertions.""" enable_assertions(enable) diff --git a/src/stegasoo/decode.py b/src/stegasoo/decode.py index 9f1650d..7252870 100644 --- a/src/stegasoo/decode.py +++ b/src/stegasoo/decode.py @@ -8,21 +8,20 @@ Changes in v4.0.0: - Improved error messages for channel key mismatches """ -from typing import Optional, Union from pathlib import Path -from .models import DecodeInput, DecodeResult +from .constants import EMBED_MODE_AUTO from .crypto import decrypt_message +from .debug import debug +from .exceptions import DecryptionError, ExtractionError +from .models import DecodeResult from .steganography import extract_from_image from .validation import ( - require_valid_image, require_security_factors, + require_valid_image, require_valid_pin, require_valid_rsa_key, ) -from .constants import EMBED_MODE_AUTO -from .exceptions import ExtractionError, DecryptionError -from .debug import debug def decode( @@ -30,14 +29,14 @@ def decode( reference_photo: bytes, passphrase: str, pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, + rsa_key_data: bytes | None = None, + rsa_password: str | None = None, embed_mode: str = EMBED_MODE_AUTO, - channel_key: Optional[Union[str, bool]] = None, + channel_key: str | bool | None = None, ) -> DecodeResult: """ Decode a message or file from a stego image. - + Args: stego_image: Stego image bytes reference_photo: Shared reference photo bytes @@ -50,10 +49,10 @@ def decode( - None or "auto": Use server's configured key - str: Use this specific channel key - "" or False: No channel key (public mode) - + Returns: DecodeResult with message or file data - + Example: >>> result = decode( ... stego_image=stego_bytes, @@ -66,7 +65,7 @@ def decode( ... else: ... with open(result.filename, 'wb') as f: ... f.write(result.file_data) - + Example with explicit channel key: >>> result = decode( ... stego_image=stego_bytes, @@ -79,41 +78,41 @@ def decode( debug.print(f"decode: passphrase length={len(passphrase.split())} words, " f"mode={embed_mode}, " f"channel_key={'explicit' if isinstance(channel_key, str) and channel_key else 'auto' if channel_key is None else 'none'}") - + # Validate inputs require_valid_image(stego_image, "Stego image") require_valid_image(reference_photo, "Reference photo") require_security_factors(pin, rsa_key_data) - + if pin: require_valid_pin(pin) if rsa_key_data: require_valid_rsa_key(rsa_key_data, rsa_password) - + # Derive pixel/coefficient selection key (with channel key) from .crypto import derive_pixel_key pixel_key = derive_pixel_key( reference_photo, passphrase, pin, rsa_key_data, channel_key ) - + # Extract encrypted data encrypted = extract_from_image( stego_image, pixel_key, embed_mode=embed_mode, ) - + if not encrypted: debug.print("No data extracted from image") raise ExtractionError("Could not extract data. Check your credentials and image.") - + debug.print(f"Extracted {len(encrypted)} bytes from image") - + # Decrypt (with channel key) result = decrypt_message( encrypted, reference_photo, passphrase, pin, rsa_key_data, channel_key ) - + debug.print(f"Decryption successful: {result.payload_type}") return result @@ -122,16 +121,16 @@ def decode_file( stego_image: bytes, reference_photo: bytes, passphrase: str, - output_path: Optional[Path] = None, + output_path: Path | None = None, pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, + rsa_key_data: bytes | None = None, + rsa_password: str | None = None, embed_mode: str = EMBED_MODE_AUTO, - channel_key: Optional[Union[str, bool]] = None, + channel_key: str | bool | None = None, ) -> Path: """ Decode a file from a stego image and save it. - + Args: stego_image: Stego image bytes reference_photo: Shared reference photo bytes @@ -142,10 +141,10 @@ def decode_file( rsa_password: Optional RSA key password embed_mode: 'auto', 'lsb', or 'dct' channel_key: Channel key parameter (see decode()) - + Returns: Path where file was saved - + Raises: DecryptionError: If payload is text, not a file """ @@ -159,20 +158,20 @@ def decode_file( embed_mode, channel_key, ) - + if not result.is_file: raise DecryptionError("Payload is a text message, not a file") - + if output_path is None: output_path = Path(result.filename or "extracted_file") else: output_path = Path(output_path) if output_path.is_dir(): output_path = output_path / (result.filename or "extracted_file") - + # Write file output_path.write_bytes(result.file_data or b"") - + debug.print(f"File saved to: {output_path}") return output_path @@ -182,16 +181,16 @@ def decode_text( reference_photo: bytes, passphrase: str, pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, + rsa_key_data: bytes | None = None, + rsa_password: str | None = None, embed_mode: str = EMBED_MODE_AUTO, - channel_key: Optional[Union[str, bool]] = None, + channel_key: str | bool | None = None, ) -> str: """ Decode a text message from a stego image. - + Convenience function that returns just the message string. - + Args: stego_image: Stego image bytes reference_photo: Shared reference photo bytes @@ -201,10 +200,10 @@ def decode_text( rsa_password: Optional RSA key password embed_mode: 'auto', 'lsb', or 'dct' channel_key: Channel key parameter (see decode()) - + Returns: Decoded message string - + Raises: DecryptionError: If payload is a file, not text """ @@ -218,7 +217,7 @@ def decode_text( embed_mode, channel_key, ) - + if result.is_file: # Try to decode as text if result.file_data: @@ -229,5 +228,5 @@ def decode_text( f"Payload is a binary file ({result.filename or 'unnamed'}), not text" ) return "" - + return result.message or "" diff --git a/src/stegasoo/encode.py b/src/stegasoo/encode.py index 5f98407..957212d 100644 --- a/src/stegasoo/encode.py +++ b/src/stegasoo/encode.py @@ -7,41 +7,40 @@ Changes in v4.0.0: - Added channel_key parameter for deployment/group isolation """ -from typing import Optional, Union from pathlib import Path -from .models import EncodeInput, EncodeResult, FilePayload -from .crypto import encrypt_message, derive_pixel_key +from .constants import EMBED_MODE_LSB +from .crypto import derive_pixel_key, encrypt_message +from .debug import debug +from .models import EncodeResult, FilePayload from .steganography import embed_in_image +from .utils import generate_filename from .validation import ( - require_valid_payload, - require_valid_image, require_security_factors, + require_valid_image, + require_valid_payload, require_valid_pin, require_valid_rsa_key, ) -from .utils import generate_filename -from .constants import EMBED_MODE_LSB -from .debug import debug def encode( - message: Union[str, bytes, FilePayload], + message: str | bytes | FilePayload, reference_photo: bytes, carrier_image: bytes, passphrase: str, pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, - output_format: Optional[str] = None, + rsa_key_data: bytes | None = None, + rsa_password: str | None = None, + output_format: str | None = None, embed_mode: str = EMBED_MODE_LSB, dct_output_format: str = "png", dct_color_mode: str = "grayscale", - channel_key: Optional[Union[str, bool]] = None, + channel_key: str | bool | None = None, ) -> EncodeResult: """ Encode a message or file into an image. - + Args: message: Text message, raw bytes, or FilePayload to hide reference_photo: Shared reference photo bytes @@ -58,10 +57,10 @@ def encode( - None or "auto": Use server's configured key - str: Use this specific channel key - "" or False: No channel key (public mode) - + Returns: EncodeResult with stego image and metadata - + Example: >>> result = encode( ... message="Secret message", @@ -72,7 +71,7 @@ def encode( ... ) >>> with open('stego.png', 'wb') as f: ... f.write(result.stego_image) - + Example with explicit channel key: >>> result = encode( ... message="Secret message", @@ -86,30 +85,30 @@ def encode( debug.print(f"encode: passphrase length={len(passphrase.split())} words, " f"pin={'set' if pin else 'none'}, mode={embed_mode}, " f"channel_key={'explicit' if isinstance(channel_key, str) and channel_key else 'auto' if channel_key is None else 'none'}") - + # Validate inputs require_valid_payload(message) require_valid_image(reference_photo, "Reference photo") require_valid_image(carrier_image, "Carrier image") require_security_factors(pin, rsa_key_data) - + if pin: require_valid_pin(pin) if rsa_key_data: require_valid_rsa_key(rsa_key_data, rsa_password) - + # Encrypt message (with channel key) encrypted = encrypt_message( message, reference_photo, passphrase, pin, rsa_key_data, channel_key ) - + debug.print(f"Encrypted payload: {len(encrypted)} bytes") - + # Derive pixel/coefficient selection key (with channel key) pixel_key = derive_pixel_key( reference_photo, passphrase, pin, rsa_key_data, channel_key ) - + # Embed in image stego_data, stats, extension = embed_in_image( encrypted, @@ -120,10 +119,10 @@ def encode( dct_output_format=dct_output_format, dct_color_mode=dct_color_mode, ) - + # Generate filename filename = generate_filename(extension=extension) - + # Create result if hasattr(stats, 'pixels_modified'): # LSB mode stats @@ -148,25 +147,25 @@ def encode( def encode_file( - filepath: Union[str, Path], + filepath: str | Path, reference_photo: bytes, carrier_image: bytes, passphrase: str, pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, - output_format: Optional[str] = None, - filename_override: Optional[str] = None, + rsa_key_data: bytes | None = None, + rsa_password: str | None = None, + output_format: str | None = None, + filename_override: str | None = None, embed_mode: str = EMBED_MODE_LSB, dct_output_format: str = "png", dct_color_mode: str = "grayscale", - channel_key: Optional[Union[str, bool]] = None, + channel_key: str | bool | None = None, ) -> EncodeResult: """ Encode a file into an image. - + Convenience wrapper that loads a file and encodes it. - + Args: filepath: Path to file to embed reference_photo: Shared reference photo bytes @@ -181,12 +180,12 @@ def encode_file( dct_output_format: 'png' or 'jpeg' dct_color_mode: 'grayscale' or 'color' channel_key: Channel key parameter (see encode()) - + Returns: EncodeResult """ payload = FilePayload.from_file(str(filepath), filename_override) - + return encode( message=payload, reference_photo=reference_photo, @@ -210,18 +209,18 @@ def encode_bytes( carrier_image: bytes, passphrase: str, pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, - output_format: Optional[str] = None, - mime_type: Optional[str] = None, + rsa_key_data: bytes | None = None, + rsa_password: str | None = None, + output_format: str | None = None, + mime_type: str | None = None, embed_mode: str = EMBED_MODE_LSB, dct_output_format: str = "png", dct_color_mode: str = "grayscale", - channel_key: Optional[Union[str, bool]] = None, + channel_key: str | bool | None = None, ) -> EncodeResult: """ Encode raw bytes with metadata into an image. - + Args: data: Raw bytes to embed filename: Filename to associate with data @@ -237,12 +236,12 @@ def encode_bytes( dct_output_format: 'png' or 'jpeg' dct_color_mode: 'grayscale' or 'color' channel_key: Channel key parameter (see encode()) - + Returns: EncodeResult """ payload = FilePayload(data=data, filename=filename, mime_type=mime_type) - + return encode( message=payload, reference_photo=reference_photo, diff --git a/src/stegasoo/exceptions.py b/src/stegasoo/exceptions.py index 324e58c..b001f32 100644 --- a/src/stegasoo/exceptions.py +++ b/src/stegasoo/exceptions.py @@ -89,7 +89,7 @@ class SteganographyError(StegasooError): class CapacityError(SteganographyError): """Carrier image too small for message.""" - + def __init__(self, needed: int, available: int): self.needed = needed self.available = available @@ -129,7 +129,7 @@ class FileNotFoundError(FileError): class FileTooLargeError(FileError): """File exceeds size limit.""" - + def __init__(self, size: int, limit: int, filename: str = "File"): self.size = size self.limit = limit @@ -141,7 +141,7 @@ class FileTooLargeError(FileError): class UnsupportedFileTypeError(FileError): """File type not supported.""" - + def __init__(self, extension: str, allowed: set[str]): self.extension = extension self.allowed = allowed diff --git a/src/stegasoo/generate.py b/src/stegasoo/generate.py index 3d81f54..44571de 100644 --- a/src/stegasoo/generate.py +++ b/src/stegasoo/generate.py @@ -4,28 +4,30 @@ Stegasoo Generate Module (v3.2.0) Public API for generating credentials (PINs, passphrases, RSA keys). """ -from typing import Optional -from .keygen import ( - generate_pin as _generate_pin, - generate_phrase, - generate_rsa_key as _generate_rsa_key, - export_rsa_key_pem, - load_rsa_key, -) -from .models import Credentials from .constants import ( - DEFAULT_PIN_LENGTH, DEFAULT_PASSPHRASE_WORDS, + DEFAULT_PIN_LENGTH, DEFAULT_RSA_BITS, ) from .debug import debug - +from .keygen import ( + export_rsa_key_pem, + generate_phrase, + load_rsa_key, +) +from .keygen import ( + generate_pin as _generate_pin, +) +from .keygen import ( + generate_rsa_key as _generate_rsa_key, +) +from .models import Credentials # Re-export from keygen for convenience __all__ = [ 'generate_pin', - 'generate_passphrase', + 'generate_passphrase', 'generate_rsa_key', 'generate_credentials', 'export_rsa_key_pem', @@ -36,15 +38,15 @@ __all__ = [ def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str: """ Generate a random PIN. - + PINs never start with zero for usability. - + Args: length: PIN length (6-9 digits, default 6) - + Returns: PIN string - + Example: >>> pin = generate_pin() >>> len(pin) @@ -58,16 +60,16 @@ def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str: def generate_passphrase(words: int = DEFAULT_PASSPHRASE_WORDS) -> str: """ Generate a random passphrase from BIP-39 wordlist. - + In v3.2.0, this generates a single passphrase (not daily phrases). Default is 4 words for good security (increased from 3 in v3.1.0). - + Args: words: Number of words (3-12, default 4) - + Returns: Space-separated passphrase - + Example: >>> passphrase = generate_passphrase(4) >>> len(passphrase.split()) @@ -78,18 +80,18 @@ def generate_passphrase(words: int = DEFAULT_PASSPHRASE_WORDS) -> str: def generate_rsa_key( bits: int = DEFAULT_RSA_BITS, - password: Optional[str] = None + password: str | None = None ) -> str: """ Generate an RSA private key in PEM format. - + Args: bits: Key size (2048, 3072, or 4096, default 2048) password: Optional password to encrypt the key - + Returns: PEM-encoded key string - + Example: >>> key_pem = generate_rsa_key(2048) >>> '-----BEGIN PRIVATE KEY-----' in key_pem @@ -106,14 +108,14 @@ def generate_credentials( pin_length: int = DEFAULT_PIN_LENGTH, rsa_bits: int = DEFAULT_RSA_BITS, passphrase_words: int = DEFAULT_PASSPHRASE_WORDS, - rsa_password: Optional[str] = None, + rsa_password: str | None = None, ) -> Credentials: """ Generate a complete set of credentials. - + In v3.2.0, this generates a single passphrase (not daily phrases). At least one of use_pin or use_rsa must be True. - + Args: use_pin: Whether to generate a PIN use_rsa: Whether to generate an RSA key @@ -121,13 +123,13 @@ def generate_credentials( rsa_bits: RSA key size (default 2048) passphrase_words: Number of words in passphrase (default 4) rsa_password: Optional password for RSA key - + Returns: Credentials object with passphrase, PIN, and/or RSA key - + Raises: ValueError: If neither PIN nor RSA is selected - + Example: >>> creds = generate_credentials(use_pin=True, use_rsa=False) >>> len(creds.passphrase.split()) @@ -137,23 +139,23 @@ def generate_credentials( """ if not use_pin and not use_rsa: raise ValueError("Must select at least one security factor (PIN or RSA key)") - + debug.print(f"Generating credentials: PIN={use_pin}, RSA={use_rsa}, " f"passphrase_words={passphrase_words}") - + # Generate passphrase (single, not daily) passphrase = generate_phrase(passphrase_words) - + # Generate PIN if requested pin = _generate_pin(pin_length) if use_pin else None - + # Generate RSA key if requested rsa_key_pem = None if use_rsa: rsa_key_obj = _generate_rsa_key(rsa_bits) rsa_key_bytes = export_rsa_key_pem(rsa_key_obj, rsa_password) rsa_key_pem = rsa_key_bytes.decode('utf-8') - + # Create Credentials object (v3.2.0 format) creds = Credentials( passphrase=passphrase, @@ -162,6 +164,6 @@ def generate_credentials( rsa_bits=rsa_bits if use_rsa else None, words_per_passphrase=passphrase_words, ) - + debug.print(f"Credentials generated: {creds.total_entropy} bits total entropy") return creds diff --git a/src/stegasoo/image_utils.py b/src/stegasoo/image_utils.py index 7577572..877b236 100644 --- a/src/stegasoo/image_utils.py +++ b/src/stegasoo/image_utils.py @@ -4,40 +4,40 @@ Stegasoo Image Utilities (v3.2.0) Functions for analyzing images and comparing capacity. """ -from typing import Optional import io + from PIL import Image -from .models import ImageInfo, CapacityComparison -from .steganography import calculate_capacity, has_dct_support -from .constants import EMBED_MODE_LSB, EMBED_MODE_DCT +from .constants import EMBED_MODE_LSB from .debug import debug +from .models import CapacityComparison, ImageInfo +from .steganography import calculate_capacity, has_dct_support def get_image_info(image_data: bytes) -> ImageInfo: """ Get detailed information about an image. - + Args: image_data: Image file bytes - + Returns: ImageInfo with dimensions, format, capacity estimates - + Example: >>> info = get_image_info(carrier_bytes) >>> print(f"{info.width}x{info.height}, {info.lsb_capacity_kb} KB capacity") """ img = Image.open(io.BytesIO(image_data)) - + width, height = img.size pixels = width * height format_str = img.format or "Unknown" mode = img.mode - + # Calculate LSB capacity lsb_capacity = calculate_capacity(image_data, bits_per_channel=1) - + # Calculate DCT capacity if available dct_capacity = None if has_dct_support(): @@ -47,7 +47,7 @@ def get_image_info(image_data: bytes) -> ImageInfo: dct_capacity = dct_info.usable_capacity_bytes except Exception as e: debug.print(f"Could not calculate DCT capacity: {e}") - + info = ImageInfo( width=width, height=height, @@ -60,27 +60,27 @@ def get_image_info(image_data: bytes) -> ImageInfo: dct_capacity_bytes=dct_capacity, dct_capacity_kb=dct_capacity / 1024 if dct_capacity else None, ) - + debug.print(f"Image info: {width}x{height}, LSB={lsb_capacity} bytes, " f"DCT={dct_capacity or 'N/A'} bytes") - + return info def compare_capacity( carrier_image: bytes, - reference_photo: Optional[bytes] = None, + reference_photo: bytes | None = None, ) -> CapacityComparison: """ Compare embedding capacity between LSB and DCT modes. - + Args: carrier_image: Carrier image bytes reference_photo: Optional reference photo (not used in v3.2.0, kept for API compatibility) - + Returns: CapacityComparison with capacity info for both modes - + Example: >>> comparison = compare_capacity(carrier_bytes) >>> print(f"LSB: {comparison.lsb_kb:.1f} KB") @@ -88,16 +88,16 @@ def compare_capacity( """ img = Image.open(io.BytesIO(carrier_image)) width, height = img.size - + # LSB capacity lsb_bytes = calculate_capacity(carrier_image, bits_per_channel=1) lsb_kb = lsb_bytes / 1024 - + # DCT capacity dct_available = has_dct_support() dct_bytes = None dct_kb = None - + if dct_available: try: from .dct_steganography import calculate_dct_capacity @@ -107,7 +107,7 @@ def compare_capacity( except Exception as e: debug.print(f"DCT capacity calculation failed: {e}") dct_available = False - + comparison = CapacityComparison( image_width=width, image_height=height, @@ -121,9 +121,9 @@ def compare_capacity( dct_output_formats=["PNG (grayscale)", "JPEG (grayscale)"] if dct_available else None, dct_ratio_vs_lsb=(dct_bytes / lsb_bytes * 100) if dct_bytes else None, ) - + debug.print(f"Capacity comparison: LSB={lsb_kb:.1f}KB, DCT={dct_kb or 'N/A'}KB") - + return comparison @@ -134,27 +134,27 @@ def validate_carrier_capacity( ) -> dict: """ Check if a payload will fit in a carrier image. - + Args: carrier_image: Carrier image bytes payload_size: Size of payload in bytes embed_mode: 'lsb' or 'dct' - + Returns: Dict with 'fits', 'capacity', 'usage_percent', 'headroom' """ from .steganography import calculate_capacity_by_mode - + capacity_info = calculate_capacity_by_mode(carrier_image, embed_mode) capacity = capacity_info['capacity_bytes'] - + # Add encryption overhead estimate estimated_size = payload_size + 200 # Approximate overhead - + fits = estimated_size <= capacity usage_percent = (estimated_size / capacity * 100) if capacity > 0 else 100.0 headroom = capacity - estimated_size - + return { 'fits': fits, 'capacity': capacity, diff --git a/src/stegasoo/keygen.py b/src/stegasoo/keygen.py index edad6eb..4affbc5 100644 --- a/src/stegasoo/keygen.py +++ b/src/stegasoo/keygen.py @@ -10,53 +10,57 @@ Changes in v3.2.0: """ import secrets -from typing import Optional, Dict, Union +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes -from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.serialization import load_pem_private_key -from cryptography.hazmat.backends import default_backend from .constants import ( DAY_NAMES, - MIN_PIN_LENGTH, MAX_PIN_LENGTH, DEFAULT_PIN_LENGTH, - MIN_PASSPHRASE_WORDS, MAX_PASSPHRASE_WORDS, DEFAULT_PASSPHRASE_WORDS, - MIN_RSA_BITS, VALID_RSA_SIZES, DEFAULT_RSA_BITS, + DEFAULT_PASSPHRASE_WORDS, + DEFAULT_PIN_LENGTH, + DEFAULT_RSA_BITS, + MAX_PASSPHRASE_WORDS, + MAX_PIN_LENGTH, + MIN_PASSPHRASE_WORDS, + MIN_PIN_LENGTH, + VALID_RSA_SIZES, get_wordlist, ) -from .models import Credentials, KeyInfo -from .exceptions import KeyGenerationError, KeyPasswordError from .debug import debug +from .exceptions import KeyGenerationError, KeyPasswordError +from .models import Credentials, KeyInfo def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str: """ Generate a random PIN. - + PINs never start with zero for usability. - + Args: length: PIN length (6-9 digits) - + Returns: PIN string - + Example: >>> generate_pin(6) "812345" """ debug.validate(MIN_PIN_LENGTH <= length <= MAX_PIN_LENGTH, f"PIN length must be between {MIN_PIN_LENGTH} and {MAX_PIN_LENGTH}") - + length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, length)) - + # First digit: 1-9 (no leading zero) first_digit = str(secrets.randbelow(9) + 1) - + # Remaining digits: 0-9 rest = ''.join(str(secrets.randbelow(10)) for _ in range(length - 1)) - + pin = first_digit + rest debug.print(f"Generated PIN: {pin}") return pin @@ -65,23 +69,23 @@ def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str: def generate_phrase(words_per_phrase: int = DEFAULT_PASSPHRASE_WORDS) -> str: """ Generate a random passphrase from BIP-39 wordlist. - + Args: words_per_phrase: Number of words (3-12) - + Returns: Space-separated phrase - + Example: >>> generate_phrase(4) "apple forest thunder mountain" """ debug.validate(MIN_PASSPHRASE_WORDS <= words_per_phrase <= MAX_PASSPHRASE_WORDS, f"Words per phrase must be between {MIN_PASSPHRASE_WORDS} and {MAX_PASSPHRASE_WORDS}") - + words_per_phrase = max(MIN_PASSPHRASE_WORDS, min(MAX_PASSPHRASE_WORDS, words_per_phrase)) wordlist = get_wordlist() - + words = [secrets.choice(wordlist) for _ in range(words_per_phrase)] phrase = ' '.join(words) debug.print(f"Generated phrase: {phrase}") @@ -92,19 +96,19 @@ def generate_phrase(words_per_phrase: int = DEFAULT_PASSPHRASE_WORDS) -> str: generate_passphrase = generate_phrase -def generate_day_phrases(words_per_phrase: int = DEFAULT_PASSPHRASE_WORDS) -> Dict[str, str]: +def generate_day_phrases(words_per_phrase: int = DEFAULT_PASSPHRASE_WORDS) -> dict[str, str]: """ Generate phrases for all days of the week. - + DEPRECATED in v3.2.0: Use generate_phrase() for single passphrase. Kept for legacy compatibility and organizational use cases. - + Args: words_per_phrase: Number of words per phrase (3-12) - + Returns: Dict mapping day names to phrases - + Example: >>> generate_day_phrases(3) {'Monday': 'apple forest thunder', 'Tuesday': 'banana river lightning', ...} @@ -116,7 +120,7 @@ def generate_day_phrases(words_per_phrase: int = DEFAULT_PASSPHRASE_WORDS) -> Di DeprecationWarning, stacklevel=2 ) - + phrases = {day: generate_phrase(words_per_phrase) for day in DAY_NAMES} debug.print(f"Generated phrases for {len(phrases)} days") return phrases @@ -125,16 +129,16 @@ def generate_day_phrases(words_per_phrase: int = DEFAULT_PASSPHRASE_WORDS) -> Di def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey: """ Generate an RSA private key. - + Args: bits: Key size (2048, 3072, or 4096) - + Returns: RSA private key object - + Raises: KeyGenerationError: If generation fails - + Example: >>> key = generate_rsa_key(2048) >>> key.key_size @@ -142,10 +146,10 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey: """ debug.validate(bits in VALID_RSA_SIZES, f"RSA key size must be one of {VALID_RSA_SIZES}") - + if bits not in VALID_RSA_SIZES: bits = DEFAULT_RSA_BITS - + debug.print(f"Generating {bits}-bit RSA key...") try: key = rsa.generate_private_key( @@ -162,18 +166,18 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey: def export_rsa_key_pem( private_key: rsa.RSAPrivateKey, - password: Optional[str] = None + password: str | None = None ) -> bytes: """ Export RSA key to PEM format. - + Args: private_key: RSA private key object password: Optional password for encryption - + Returns: PEM-encoded key bytes - + Example: >>> key = generate_rsa_key() >>> pem = export_rsa_key_pem(key) @@ -181,19 +185,16 @@ def export_rsa_key_pem( b'-----BEGIN PRIVATE KEY-----\\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYw' """ debug.validate(private_key is not None, "Private key cannot be None") - - encryption_algorithm: Union[ - serialization.BestAvailableEncryption, - serialization.NoEncryption - ] - + + encryption_algorithm: serialization.BestAvailableEncryption | serialization.NoEncryption + if password: encryption_algorithm = serialization.BestAvailableEncryption(password.encode()) debug.print("Exporting RSA key with encryption") else: encryption_algorithm = serialization.NoEncryption() debug.print("Exporting RSA key without encryption") - + return private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, @@ -203,39 +204,39 @@ def export_rsa_key_pem( def load_rsa_key( key_data: bytes, - password: Optional[str] = None + password: str | None = None ) -> rsa.RSAPrivateKey: """ Load RSA private key from PEM data. - + Args: key_data: PEM-encoded key bytes password: Password if key is encrypted - + Returns: RSA private key object - + Raises: KeyPasswordError: If password is wrong or missing KeyGenerationError: If key is invalid - + Example: >>> key = load_rsa_key(pem_data, "my_password") """ debug.validate(key_data is not None and len(key_data) > 0, "Key data cannot be empty") - + try: pwd_bytes = password.encode() if password else None debug.print(f"Loading RSA key (encrypted: {bool(password)})") key: PrivateKeyTypes = load_pem_private_key( key_data, password=pwd_bytes, backend=default_backend() ) - + # Verify it's an RSA key if not isinstance(key, rsa.RSAPrivateKey): raise KeyGenerationError(f"Expected RSA key, got {type(key).__name__}") - + debug.print(f"RSA key loaded: {key.key_size} bits") return key except TypeError: @@ -253,17 +254,17 @@ def load_rsa_key( raise KeyGenerationError(f"Could not load RSA key: {e}") from e -def get_key_info(key_data: bytes, password: Optional[str] = None) -> KeyInfo: +def get_key_info(key_data: bytes, password: str | None = None) -> KeyInfo: """ Get information about an RSA key. - + Args: key_data: PEM-encoded key bytes password: Password if key is encrypted - + Returns: KeyInfo with key size and encryption status - + Example: >>> info = get_key_info(pem_data) >>> info.key_size @@ -274,15 +275,15 @@ def get_key_info(key_data: bytes, password: Optional[str] = None) -> KeyInfo: debug.print("Getting RSA key info") # Check if encrypted is_encrypted = b'ENCRYPTED' in key_data - + private_key = load_rsa_key(key_data, password) - + info = KeyInfo( key_size=private_key.key_size, is_encrypted=is_encrypted, pem_data=key_data ) - + debug.print(f"Key info: {info.key_size} bits, encrypted: {info.is_encrypted}") return info @@ -293,14 +294,14 @@ def generate_credentials( pin_length: int = DEFAULT_PIN_LENGTH, rsa_bits: int = DEFAULT_RSA_BITS, passphrase_words: int = DEFAULT_PASSPHRASE_WORDS, - rsa_password: Optional[str] = None, + rsa_password: str | None = None, ) -> Credentials: """ Generate a complete set of credentials. - + v3.2.0: Now generates a single passphrase instead of daily phrases. At least one of use_pin or use_rsa must be True. - + Args: use_pin: Whether to generate a PIN use_rsa: Whether to generate an RSA key @@ -308,13 +309,13 @@ def generate_credentials( rsa_bits: RSA key size if generating (default 2048) passphrase_words: Words in passphrase (default 4) rsa_password: Optional password for RSA key encryption - + Returns: Credentials object with passphrase, PIN, and/or RSA key - + Raises: ValueError: If neither PIN nor RSA is selected - + Example: >>> creds = generate_credentials(use_pin=True, use_rsa=False) >>> creds.passphrase @@ -324,25 +325,25 @@ def generate_credentials( """ debug.validate(use_pin or use_rsa, "Must select at least one security factor (PIN or RSA key)") - + if not use_pin and not use_rsa: raise ValueError("Must select at least one security factor (PIN or RSA key)") - + debug.print(f"Generating credentials: PIN={use_pin}, RSA={use_rsa}, " f"passphrase_words={passphrase_words}") - + # Generate single passphrase (v3.2.0 - no daily rotation) passphrase = generate_phrase(passphrase_words) - + # Generate PIN if requested pin = generate_pin(pin_length) if use_pin else None - + # Generate RSA key if requested rsa_key_pem = None if use_rsa: rsa_key_obj = generate_rsa_key(rsa_bits) rsa_key_pem = export_rsa_key_pem(rsa_key_obj, rsa_password).decode('utf-8') - + # Create Credentials object (v3.2.0 format with single passphrase) creds = Credentials( passphrase=passphrase, @@ -351,7 +352,7 @@ def generate_credentials( rsa_bits=rsa_bits if use_rsa else None, words_per_passphrase=passphrase_words, ) - + debug.print(f"Credentials generated: {creds.total_entropy} bits total entropy") return creds @@ -369,19 +370,19 @@ def generate_credentials_legacy( ) -> dict: """ Generate credentials in legacy format (v3.1.0 style with daily phrases). - + DEPRECATED: Use generate_credentials() for v3.2.0 format. - + This function exists only for migration tools that need to work with old-format credentials. - + Args: use_pin: Whether to generate a PIN use_rsa: Whether to generate an RSA key pin_length: PIN length if generating rsa_bits: RSA key size if generating words_per_phrase: Words per daily phrase - + Returns: Dict with 'phrases' (dict), 'pin', 'rsa_key_pem', etc. """ @@ -392,20 +393,20 @@ def generate_credentials_legacy( DeprecationWarning, stacklevel=2 ) - + if not use_pin and not use_rsa: raise ValueError("Must select at least one security factor (PIN or RSA key)") - + # Generate daily phrases (old format) phrases = {day: generate_phrase(words_per_phrase) for day in DAY_NAMES} - + pin = generate_pin(pin_length) if use_pin else None - + rsa_key_pem = None if use_rsa: rsa_key_obj = generate_rsa_key(rsa_bits) rsa_key_pem = export_rsa_key_pem(rsa_key_obj).decode('utf-8') - + return { 'phrases': phrases, 'pin': pin, diff --git a/src/stegasoo/models.py b/src/stegasoo/models.py index 57d7e50..96168d3 100644 --- a/src/stegasoo/models.py +++ b/src/stegasoo/models.py @@ -12,50 +12,48 @@ Changes in v3.2.0: """ from dataclasses import dataclass, field -from datetime import date -from typing import Optional, Union, List @dataclass class Credentials: """ Generated credentials for encoding/decoding. - + v3.2.0: Simplified to use single passphrase instead of daily rotation. """ passphrase: str # Single passphrase (no daily rotation) - pin: Optional[str] = None - rsa_key_pem: Optional[str] = None - rsa_bits: Optional[int] = None + pin: str | None = None + rsa_key_pem: str | None = None + rsa_bits: int | None = None words_per_passphrase: int = 4 # Increased from 3 in v3.1.0 - + # Optional: backup passphrases for multi-factor or rotation - backup_passphrases: Optional[list[str]] = None - + backup_passphrases: list[str] | None = None + @property def passphrase_entropy(self) -> int: """Entropy in bits from passphrase (~11 bits per BIP-39 word).""" return self.words_per_passphrase * 11 - + @property def pin_entropy(self) -> int: """Entropy in bits from PIN (~3.32 bits per digit).""" if self.pin: return int(len(self.pin) * 3.32) return 0 - + @property def rsa_entropy(self) -> int: """Effective entropy from RSA key.""" if self.rsa_key_pem and self.rsa_bits: return min(self.rsa_bits // 16, 128) return 0 - + @property def total_entropy(self) -> int: """Total entropy in bits (excluding reference photo).""" return self.passphrase_entropy + self.pin_entropy + self.rsa_entropy - + # Legacy property for compatibility @property def phrase_entropy(self) -> int: @@ -68,23 +66,23 @@ class FilePayload: """Represents a file to be embedded.""" data: bytes filename: str - mime_type: Optional[str] = None - + mime_type: str | None = None + @property def size(self) -> int: return len(self.data) - + @classmethod - def from_file(cls, filepath: str, filename: Optional[str] = None) -> 'FilePayload': + def from_file(cls, filepath: str, filename: str | None = None) -> 'FilePayload': """Create FilePayload from a file path.""" - from pathlib import Path import mimetypes - + from pathlib import Path + path = Path(filepath) data = path.read_bytes() name = filename or path.name mime, _ = mimetypes.guess_type(name) - + return cls(data=data, filename=name, mime_type=mime) @@ -92,23 +90,23 @@ class FilePayload: class EncodeInput: """ Input parameters for encoding a message. - + v3.2.0: Removed date_str (date no longer used in crypto). """ - message: Union[str, bytes, FilePayload] # Text, raw bytes, or file + message: str | bytes | FilePayload # Text, raw bytes, or file reference_photo: bytes carrier_image: bytes passphrase: str # Renamed from day_phrase pin: str = "" - rsa_key_data: Optional[bytes] = None - rsa_password: Optional[str] = None + rsa_key_data: bytes | None = None + rsa_password: str | None = None @dataclass class EncodeResult: """ Result of encoding operation. - + v3.2.0: date_used is now optional/cosmetic (not used in crypto). """ stego_image: bytes @@ -116,8 +114,8 @@ class EncodeResult: pixels_modified: int total_pixels: int capacity_used: float # 0.0 - 1.0 - date_used: Optional[str] = None # Cosmetic only (for filename organization) - + date_used: str | None = None # Cosmetic only (for filename organization) + @property def capacity_percent(self) -> float: """Capacity used as percentage.""" @@ -128,54 +126,54 @@ class EncodeResult: class DecodeInput: """ Input parameters for decoding a message. - + v3.2.0: Renamed day_phrase β†’ passphrase, no date needed. """ stego_image: bytes reference_photo: bytes passphrase: str # Renamed from day_phrase pin: str = "" - rsa_key_data: Optional[bytes] = None - rsa_password: Optional[str] = None + rsa_key_data: bytes | None = None + rsa_password: str | None = None @dataclass class DecodeResult: """ Result of decoding operation. - + v3.2.0: date_encoded is always None (date removed from crypto). """ payload_type: str # 'text' or 'file' - message: Optional[str] = None # For text payloads - file_data: Optional[bytes] = None # For file payloads - filename: Optional[str] = None # Original filename for file payloads - mime_type: Optional[str] = None # MIME type hint - date_encoded: Optional[str] = None # Always None in v3.2.0 (kept for compatibility) - + message: str | None = None # For text payloads + file_data: bytes | None = None # For file payloads + filename: str | None = None # Original filename for file payloads + mime_type: str | None = None # MIME type hint + date_encoded: str | None = None # Always None in v3.2.0 (kept for compatibility) + @property def is_file(self) -> bool: return self.payload_type == 'file' - + @property def is_text(self) -> bool: return self.payload_type == 'text' - - def get_content(self) -> Union[str, bytes]: + + def get_content(self) -> str | bytes: """Get the decoded content (text or bytes).""" if self.is_text: return self.message or "" return self.file_data or b"" -@dataclass +@dataclass class EmbedStats: """Statistics from image embedding.""" pixels_modified: int total_pixels: int capacity_used: float bytes_embedded: int - + @property def modification_percent(self) -> float: """Percentage of pixels modified.""" @@ -196,16 +194,16 @@ class ValidationResult: is_valid: bool error_message: str = "" details: dict = field(default_factory=dict) - warning: Optional[str] = None # v3.2.0: Added for passphrase length warnings - + warning: str | None = None # v3.2.0: Added for passphrase length warnings + @classmethod - def ok(cls, warning: Optional[str] = None, **details) -> 'ValidationResult': + def ok(cls, warning: str | None = None, **details) -> 'ValidationResult': """Create a successful validation result.""" result = cls(is_valid=True, details=details) if warning: result.warning = warning return result - + @classmethod def error(cls, message: str, **details) -> 'ValidationResult': """Create a failed validation result.""" @@ -227,8 +225,8 @@ class ImageInfo: file_size: int lsb_capacity_bytes: int lsb_capacity_kb: float - dct_capacity_bytes: Optional[int] = None - dct_capacity_kb: Optional[float] = None + dct_capacity_bytes: int | None = None + dct_capacity_kb: float | None = None @dataclass @@ -241,24 +239,24 @@ class CapacityComparison: lsb_kb: float lsb_output_format: str dct_available: bool - dct_bytes: Optional[int] = None - dct_kb: Optional[float] = None - dct_output_formats: Optional[List[str]] = None - dct_ratio_vs_lsb: Optional[float] = None + dct_bytes: int | None = None + dct_kb: float | None = None + dct_output_formats: list[str] | None = None + dct_ratio_vs_lsb: float | None = None @dataclass class GenerateResult: """Result of credential generation.""" passphrase: str - pin: Optional[str] = None - rsa_key_pem: Optional[str] = None + pin: str | None = None + rsa_key_pem: str | None = None passphrase_words: int = 4 passphrase_entropy: int = 0 pin_entropy: int = 0 rsa_entropy: int = 0 total_entropy: int = 0 - + def __str__(self) -> str: lines = [ "Generated Credentials:", diff --git a/src/stegasoo/qr_utils.py b/src/stegasoo/qr_utils.py index 95fdb08..23cbc69 100644 --- a/src/stegasoo/qr_utils.py +++ b/src/stegasoo/qr_utils.py @@ -10,10 +10,9 @@ IMPROVEMENTS IN THIS VERSION: - Improved error messages """ +import base64 import io import zlib -import base64 -from typing import Optional, Tuple from PIL import Image @@ -27,20 +26,19 @@ except ImportError: # QR code reading try: - from pyzbar.pyzbar import decode as pyzbar_decode from pyzbar.pyzbar import ZBarSymbol + from pyzbar.pyzbar import decode as pyzbar_decode HAS_QRCODE_READ = True except ImportError: HAS_QRCODE_READ = False from .constants import ( - QR_MAX_BINARY, - QR_CROP_PADDING_PERCENT, QR_CROP_MIN_PADDING_PX, + QR_CROP_PADDING_PERCENT, + QR_MAX_BINARY, ) - # Constants COMPRESSION_PREFIX = "STEGASOO-Z:" @@ -48,10 +46,10 @@ COMPRESSION_PREFIX = "STEGASOO-Z:" def compress_data(data: str) -> str: """ Compress string data for QR code storage. - + Args: data: String to compress - + Returns: Compressed string with STEGASOO-Z: prefix """ @@ -63,19 +61,19 @@ def compress_data(data: str) -> str: def decompress_data(data: str) -> str: """ Decompress data from QR code. - + Args: data: Compressed string with STEGASOO-Z: prefix - + Returns: Original uncompressed string - + Raises: ValueError: If data is not valid compressed format """ if not data.startswith(COMPRESSION_PREFIX): raise ValueError("Data is not in compressed format") - + encoded = data[len(COMPRESSION_PREFIX):] compressed = base64.b64decode(encoded) return zlib.decompress(compressed).decode('utf-8') @@ -84,7 +82,7 @@ def decompress_data(data: str) -> str: def normalize_pem(pem_data: str) -> str: """ Normalize PEM data to ensure proper formatting for cryptography library. - + The cryptography library is very particular about PEM formatting. This function handles all common issues from QR code extraction: - Inconsistent line endings (CRLF, LF, CR) @@ -93,24 +91,24 @@ def normalize_pem(pem_data: str) -> str: - Non-ASCII characters - Incorrect base64 padding - Malformed headers/footers - + Args: pem_data: Raw PEM string from QR code - + Returns: Properly formatted PEM string that cryptography library will accept """ import re - + # Step 1: Normalize ALL line endings to \n pem_data = pem_data.replace('\r\n', '\n').replace('\r', '\n') - + # Step 2: Remove leading/trailing whitespace pem_data = pem_data.strip() - + # Step 3: Remove any non-ASCII characters (QR artifacts) pem_data = ''.join(char for char in pem_data if ord(char) < 128) - + # Step 4: Extract header, content, and footer with flexible regex # This handles variations like: # - "PRIVATE KEY" vs "RSA PRIVATE KEY" @@ -118,51 +116,51 @@ def normalize_pem(pem_data: str) -> str: # - Missing spaces pattern = r'(-----BEGIN[^-]*-----)(.*?)(-----END[^-]*-----)' match = re.search(pattern, pem_data, re.DOTALL | re.IGNORECASE) - + if not match: # Fallback: try even more permissive pattern pattern = r'(-+BEGIN[^-]+-+)(.*?)(-+END[^-]+-+)' match = re.search(pattern, pem_data, re.DOTALL | re.IGNORECASE) - + if not match: # Last resort: return original if can't parse return pem_data - + header_raw = match.group(1).strip() content_raw = match.group(2) footer_raw = match.group(3).strip() - + # Step 5: Normalize header and footer # Standardize spacing and ensure proper format header = re.sub(r'\s+', ' ', header_raw) footer = re.sub(r'\s+', ' ', footer_raw) - + # Ensure exactly 5 dashes on each side header = re.sub(r'^-+', '-----', header) header = re.sub(r'-+$', '-----', header) footer = re.sub(r'^-+', '-----', footer) footer = re.sub(r'-+$', '-----', footer) - + # Step 6: Clean the base64 content THOROUGHLY # Remove ALL whitespace: spaces, tabs, newlines # Keep only valid base64 characters: A-Z, a-z, 0-9, +, /, = content_clean = ''.join( - char for char in content_raw + char for char in content_raw if char.isalnum() or char in '+/=' ) - + # Double-check: remove any remaining invalid characters content_clean = re.sub(r'[^A-Za-z0-9+/=]', '', content_clean) - + # Step 7: Fix base64 padding # Base64 strings must be divisible by 4 remainder = len(content_clean) % 4 if remainder: content_clean += '=' * (4 - remainder) - + # Step 8: Split into 64-character lines (PEM standard) lines = [content_clean[i:i+64] for i in range(0, len(content_clean), 64)] - + # Step 9: Reconstruct with EXACT PEM formatting # Format: header\ncontent_line1\ncontent_line2\n...\nfooter\n return header + '\n' + '\n'.join(lines) + '\n' + footer + '\n' @@ -176,10 +174,10 @@ def is_compressed(data: str) -> bool: def auto_decompress(data: str) -> str: """ Automatically decompress data if compressed, otherwise return as-is. - + Args: data: Possibly compressed string - + Returns: Decompressed string """ @@ -196,11 +194,11 @@ def get_compressed_size(data: str) -> int: def can_fit_in_qr(data: str, compress: bool = False) -> bool: """ Check if data can fit in a QR code. - + Args: data: String data compress: Whether compression will be used - + Returns: True if data fits """ @@ -223,39 +221,39 @@ def generate_qr_code( ) -> bytes: """ Generate a QR code PNG from string data. - + Args: data: String data to encode compress: Whether to compress data first error_correction: QR error correction level (default: auto) - + Returns: PNG image bytes - + Raises: RuntimeError: If qrcode library not available ValueError: If data too large for QR code """ if not HAS_QRCODE_WRITE: raise RuntimeError("qrcode library not installed. Run: pip install qrcode[pil]") - + qr_data = data - + # Compress if requested if compress: qr_data = compress_data(data) - + # Check size if len(qr_data.encode('utf-8')) > QR_MAX_BINARY: raise ValueError( f"Data too large for QR code ({len(qr_data)} bytes). " f"Maximum: {QR_MAX_BINARY} bytes" ) - + # Use lower error correction for larger data if error_correction is None: error_correction = ERROR_CORRECT_L if len(qr_data) > 1000 else ERROR_CORRECT_M - + qr = qrcode.QRCode( version=None, error_correction=error_correction, @@ -264,25 +262,25 @@ def generate_qr_code( ) qr.add_data(qr_data) qr.make(fit=True) - + img = qr.make_image(fill_color="black", back_color="white") - + buf = io.BytesIO() img.save(buf, format='PNG') buf.seek(0) return buf.getvalue() -def read_qr_code(image_data: bytes) -> Optional[str]: +def read_qr_code(image_data: bytes) -> str | None: """ Read QR code from image data. - + Args: image_data: Image bytes (PNG, JPG, etc.) - + Returns: Decoded string, or None if no QR code found - + Raises: RuntimeError: If pyzbar library not available """ @@ -291,35 +289,35 @@ def read_qr_code(image_data: bytes) -> Optional[str]: "pyzbar library not installed. Run: pip install pyzbar\n" "Also requires system library: sudo apt-get install libzbar0" ) - + try: img: Image.Image = Image.open(io.BytesIO(image_data)) - + # Convert to RGB if necessary (pyzbar works best with RGB/grayscale) if img.mode not in ('RGB', 'L'): img = img.convert('RGB') - + # Decode QR codes decoded = pyzbar_decode(img, symbols=[ZBarSymbol.QRCODE]) - + if not decoded: return None - + # Return first QR code found result: str = decoded[0].data.decode('utf-8') return result - + except Exception: return None -def read_qr_code_from_file(filepath: str) -> Optional[str]: +def read_qr_code_from_file(filepath: str) -> str | None: """ Read QR code from image file. - + Args: filepath: Path to image file - + Returns: Decoded string, or None if no QR code found """ @@ -327,25 +325,25 @@ def read_qr_code_from_file(filepath: str) -> Optional[str]: return read_qr_code(f.read()) -def extract_key_from_qr(image_data: bytes) -> Optional[str]: +def extract_key_from_qr(image_data: bytes) -> str | None: """ Extract RSA key from QR code image, auto-decompressing if needed. - + This function is more robust than the original, with better error handling and PEM normalization. - + Args: image_data: Image bytes containing QR code - + Returns: PEM-encoded RSA key string, or None if not found/invalid """ # Step 1: Read QR code qr_data = read_qr_code(image_data) - + if not qr_data: return None - + # Step 2: Auto-decompress if needed try: if is_compressed(qr_data): @@ -355,11 +353,11 @@ def extract_key_from_qr(image_data: bytes) -> Optional[str]: except Exception: # If decompression fails, try using data as-is key_pem = qr_data - + # Step 3: Validate it looks like a PEM key if '-----BEGIN' not in key_pem or '-----END' not in key_pem: return None - + # Step 4: Aggressively normalize PEM format # This is crucial - QR codes can introduce subtle formatting issues try: @@ -367,21 +365,21 @@ def extract_key_from_qr(image_data: bytes) -> Optional[str]: except Exception: # If normalization fails, return None rather than broken PEM return None - + # Step 5: Final validation - ensure it still looks like PEM if '-----BEGIN' in key_pem and '-----END' in key_pem: return key_pem - + return None -def extract_key_from_qr_file(filepath: str) -> Optional[str]: +def extract_key_from_qr_file(filepath: str) -> str | None: """ Extract RSA key from QR code image file. - + Args: filepath: Path to image file containing QR code - + Returns: PEM-encoded RSA key string, or None if not found/invalid """ @@ -393,21 +391,21 @@ def detect_and_crop_qr( image_data: bytes, padding_percent: float = QR_CROP_PADDING_PERCENT, min_padding_px: int = QR_CROP_MIN_PADDING_PX -) -> Optional[bytes]: +) -> bytes | None: """ Detect QR code in image and crop to it, handling rotation. - + Uses the QR code's corner coordinates to compute an axis-aligned bounding box, then adds padding to ensure rotated QR codes aren't clipped. - + Args: image_data: Input image bytes (PNG, JPG, etc.) padding_percent: Padding as fraction of QR size (default 10%) min_padding_px: Minimum padding in pixels (default 10) - + Returns: Cropped PNG image bytes, or None if no QR code found - + Raises: RuntimeError: If pyzbar library not available """ @@ -416,27 +414,27 @@ def detect_and_crop_qr( "pyzbar library not installed. Run: pip install pyzbar\n" "Also requires system library: sudo apt-get install libzbar0" ) - + try: img: Image.Image = Image.open(io.BytesIO(image_data)) original_mode = img.mode - + # Convert for pyzbar detection if img.mode not in ('RGB', 'L'): detect_img = img.convert('RGB') else: detect_img = img - + # Decode QR codes to get corner positions decoded = pyzbar_decode(detect_img, symbols=[ZBarSymbol.QRCODE]) - + if not decoded: return None - + # Get the polygon corners of the first QR code # pyzbar returns a Polygon with Point objects (x, y attributes) polygon = decoded[0].polygon - + if len(polygon) < 4: # Fallback to rect if polygon not available rect = decoded[0].rect @@ -448,25 +446,25 @@ def detect_and_crop_qr( ys = [p.y for p in polygon] min_x, max_x = min(xs), max(xs) min_y, max_y = min(ys), max(ys) - + # Calculate QR dimensions and padding qr_width = max_x - min_x qr_height = max_y - min_y - + # Use larger dimension for padding calculation (handles rotation) qr_size = max(qr_width, qr_height) padding = max(int(qr_size * padding_percent), min_padding_px) - + # Calculate crop box with padding, clamped to image bounds img_width, img_height = img.size crop_left = max(0, min_x - padding) crop_top = max(0, min_y - padding) crop_right = min(img_width, max_x + padding) crop_bottom = min(img_height, max_y + padding) - + # Crop the original image (preserves original mode/quality) cropped = img.crop((crop_left, crop_top, crop_right, crop_bottom)) - + # Convert to PNG bytes buf = io.BytesIO() # Preserve transparency if present @@ -476,7 +474,7 @@ def detect_and_crop_qr( cropped.save(buf, format='PNG') buf.seek(0) return buf.getvalue() - + except Exception as e: # Log for debugging but return None for clean API import sys @@ -488,15 +486,15 @@ def detect_and_crop_qr_file( filepath: str, padding_percent: float = QR_CROP_PADDING_PERCENT, min_padding_px: int = QR_CROP_MIN_PADDING_PX -) -> Optional[bytes]: +) -> bytes | None: """ Detect QR code in image file and crop to it. - + Args: filepath: Path to image file padding_percent: Padding as fraction of QR size (default 10%) min_padding_px: Minimum padding in pixels (default 10) - + Returns: Cropped PNG image bytes, or None if no QR code found """ diff --git a/src/stegasoo/steganography.py b/src/stegasoo/steganography.py index 580f705..8681159 100644 --- a/src/stegasoo/steganography.py +++ b/src/stegasoo/steganography.py @@ -20,22 +20,24 @@ Changes in v3.2.0: import io import struct -from typing import Optional, Tuple, List, Union +from typing import TYPE_CHECKING, Union -from PIL import Image -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms +from PIL import Image + +if TYPE_CHECKING: + from .dct_steganography import DCTEmbedStats -from .models import EmbedStats, FilePayload -from .exceptions import CapacityError, ExtractionError, EmbeddingError -from .debug import debug from .constants import ( - EMBED_MODE_LSB, - EMBED_MODE_DCT, EMBED_MODE_AUTO, + EMBED_MODE_DCT, + EMBED_MODE_LSB, VALID_EMBED_MODES, ) - +from .debug import debug +from .exceptions import CapacityError, EmbeddingError +from .models import EmbedStats, FilePayload # Lossless formats that preserve LSB data LOSSLESS_FORMATS = {'PNG', 'BMP', 'TIFF'} @@ -103,10 +105,10 @@ def _get_dct_module(): def has_dct_support() -> bool: """ Check if DCT steganography mode is available. - + Returns: True if scipy is installed and DCT functions work - + Example: >>> if has_dct_support(): ... result = encode(..., embed_mode='dct') @@ -122,26 +124,26 @@ def has_dct_support() -> bool: # FORMAT UTILITIES # ============================================================================= -def get_output_format(input_format: Optional[str]) -> Tuple[str, str]: +def get_output_format(input_format: str | None) -> tuple[str, str]: """ Determine the output format based on input format. - + Args: input_format: PIL format string of input image (e.g., 'JPEG', 'PNG') - + Returns: Tuple of (PIL format string, file extension) for output Falls back to PNG for lossy or unknown formats. """ debug.validate(input_format is None or isinstance(input_format, str), "Input format must be string or None") - + if input_format and input_format.upper() in LOSSLESS_FORMATS: fmt = input_format.upper() ext = FORMAT_TO_EXT.get(fmt, 'png') debug.print(f"Using lossless format: {fmt} -> .{ext}") return fmt, ext - + debug.print(f"Input format {input_format} is lossy or unknown, defaulting to PNG") return 'PNG', 'png' @@ -151,20 +153,20 @@ def get_output_format(input_format: Optional[str]) -> Tuple[str, str]: # ============================================================================= def will_fit( - payload: Union[str, bytes, FilePayload, int], + payload: str | bytes | FilePayload | int, carrier_image: bytes, bits_per_channel: int = 1, include_compression_estimate: bool = True, ) -> dict: """ Check if a payload will fit in a carrier image (LSB mode). - + Args: payload: Message string, raw bytes, FilePayload, or size in bytes carrier_image: Carrier image bytes bits_per_channel: Bits to use per color channel (1-2) include_compression_estimate: Estimate compressed size - + Returns: Dict with fits, capacity, usage info """ @@ -183,15 +185,15 @@ def will_fit( else: payload_data = payload payload_size = len(payload) - + capacity = calculate_capacity(carrier_image, bits_per_channel) - + # Estimate encrypted size with padding # Padding adds 64-319 bytes, rounded up to 256-byte boundary # Average case: ~190 bytes padding estimated_padding = 190 estimated_encrypted_size = payload_size + estimated_padding + ENCRYPTION_OVERHEAD - + compressed_estimate = None if include_compression_estimate and payload_data is not None and len(payload_data) >= 64: try: @@ -203,11 +205,11 @@ def will_fit( estimated_encrypted_size = compressed_size + estimated_padding + ENCRYPTION_OVERHEAD except Exception: pass - + headroom = capacity - estimated_encrypted_size fits = headroom >= 0 usage_percent = (estimated_encrypted_size / capacity * 100) if capacity > 0 else 100.0 - + return { 'fits': fits, 'payload_size': payload_size, @@ -223,23 +225,23 @@ def will_fit( def calculate_capacity(image_data: bytes, bits_per_channel: int = 1) -> int: """ Calculate the maximum message capacity of an image (LSB mode). - + Args: image_data: Image bytes bits_per_channel: Bits to use per color channel - + Returns: Maximum bytes that can be embedded (minus overhead) """ debug.validate(bits_per_channel in (1, 2), f"bits_per_channel must be 1 or 2, got {bits_per_channel}") - + img_file = Image.open(io.BytesIO(image_data)) try: num_pixels = img_file.size[0] * img_file.size[1] bits_per_pixel = 3 * bits_per_channel max_bytes = (num_pixels * bits_per_pixel) // 8 - + capacity = max(0, max_bytes - ENCRYPTION_OVERHEAD) debug.print(f"LSB capacity: {capacity} bytes at {bits_per_channel} bit(s)/channel") return capacity @@ -248,28 +250,28 @@ def calculate_capacity(image_data: bytes, bits_per_channel: int = 1) -> int: def calculate_capacity_by_mode( - image_data: bytes, + image_data: bytes, embed_mode: str = EMBED_MODE_LSB, bits_per_channel: int = 1, ) -> dict: """ Calculate capacity for specified embedding mode. - + Args: image_data: Carrier image bytes embed_mode: 'lsb' or 'dct' bits_per_channel: Bits per channel for LSB mode - + Returns: Dict with capacity information """ if embed_mode == EMBED_MODE_DCT: if not has_dct_support(): raise ImportError("scipy required for DCT mode. Install: pip install scipy") - + dct_mod = _get_dct_module() dct_info = dct_mod.calculate_dct_capacity(image_data) - + return { 'mode': EMBED_MODE_DCT, 'capacity_bytes': dct_info.usable_capacity_bytes, @@ -285,7 +287,7 @@ def calculate_capacity_by_mode( width, height = img.size finally: img.close() - + return { 'mode': EMBED_MODE_LSB, 'capacity_bytes': capacity, @@ -297,27 +299,27 @@ def calculate_capacity_by_mode( def will_fit_by_mode( - payload: Union[str, bytes, FilePayload, int], + payload: str | bytes | FilePayload | int, carrier_image: bytes, embed_mode: str = EMBED_MODE_LSB, bits_per_channel: int = 1, ) -> dict: """ Check if payload fits in specified mode. - + Args: payload: Message, bytes, FilePayload, or size in bytes carrier_image: Carrier image bytes embed_mode: 'lsb' or 'dct' bits_per_channel: For LSB mode - + Returns: Dict with fits, capacity, usage info """ if embed_mode == EMBED_MODE_DCT: if not has_dct_support(): return {'fits': False, 'error': 'scipy not available', 'mode': EMBED_MODE_DCT} - + if isinstance(payload, int): payload_size = payload elif isinstance(payload, str): @@ -326,16 +328,16 @@ def will_fit_by_mode( payload_size = len(payload.data) else: payload_size = len(payload) - + estimated_size = payload_size + ENCRYPTION_OVERHEAD + 190 # padding estimate - + dct_mod = _get_dct_module() fits = dct_mod.will_fit_dct(estimated_size, carrier_image) capacity_info = dct_mod.calculate_dct_capacity(carrier_image) capacity = capacity_info.usable_capacity_bytes - + usage_percent = (estimated_size / capacity * 100) if capacity > 0 else 100.0 - + return { 'fits': fits, 'payload_size': payload_size, @@ -351,7 +353,7 @@ def will_fit_by_mode( def get_available_modes() -> dict: """ Get available embedding modes and their status. - + Returns: Dict mapping mode name to availability info """ @@ -375,10 +377,10 @@ def get_available_modes() -> dict: def compare_modes(image_data: bytes) -> dict: """ Compare embedding modes for a carrier image. - + Args: image_data: Carrier image bytes - + Returns: Dict with comparison of LSB vs DCT modes """ @@ -387,9 +389,9 @@ def compare_modes(image_data: bytes) -> dict: width, height = img.size finally: img.close() - + lsb_bytes = calculate_capacity(image_data, 1) - + if has_dct_support(): dct_mod = _get_dct_module() dct_info = dct_mod.calculate_dct_capacity(image_data) @@ -399,7 +401,7 @@ def compare_modes(image_data: bytes) -> dict: safe_blocks = (height // 8) * (width // 8) dct_bytes = (safe_blocks * 16) // 8 # Estimated dct_available = False - + return { 'width': width, 'height': height, @@ -424,62 +426,62 @@ def compare_modes(image_data: bytes) -> dict: # ============================================================================= @debug.time -def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> List[int]: +def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list[int]: """ Generate pseudo-random pixel indices for embedding. - + Uses ChaCha20 as a CSPRNG seeded by the key to deterministically select which pixels will hold hidden data. """ debug.validate(len(key) == 32, f"Pixel key must be 32 bytes, got {len(key)}") debug.validate(num_pixels > 0, f"Number of pixels must be positive, got {num_pixels}") debug.validate(num_needed > 0, f"Number needed must be positive, got {num_needed}") - debug.validate(num_needed <= num_pixels, + debug.validate(num_needed <= num_pixels, f"Cannot select {num_needed} pixels from {num_pixels} available") - + debug.print(f"Generating {num_needed} pixel indices from {num_pixels} total pixels") - + if num_needed >= num_pixels // 2: debug.print(f"Using full shuffle (needed {num_needed}/{num_pixels} pixels)") nonce = b'\x00' * 16 cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend()) encryptor = cipher.encryptor() - + indices = list(range(num_pixels)) random_bytes = encryptor.update(b'\x00' * (num_pixels * 4)) - + for i in range(num_pixels - 1, 0, -1): j_bytes = random_bytes[(num_pixels - 1 - i) * 4:(num_pixels - i) * 4] j = int.from_bytes(j_bytes, 'big') % (i + 1) indices[i], indices[j] = indices[j], indices[i] - + selected = indices[:num_needed] debug.print(f"Generated {len(selected)} indices via shuffle") return selected - + debug.print(f"Using optimized selection (needed {num_needed}/{num_pixels} pixels)") selected = [] used = set() - + nonce = b'\x00' * 16 cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend()) encryptor = cipher.encryptor() - + bytes_needed = (num_needed * 2) * 4 random_bytes = encryptor.update(b'\x00' * bytes_needed) - + byte_offset = 0 collisions = 0 while len(selected) < num_needed and byte_offset < len(random_bytes) - 4: idx = int.from_bytes(random_bytes[byte_offset:byte_offset + 4], 'big') % num_pixels byte_offset += 4 - + if idx not in used: used.add(idx) selected.append(idx) else: collisions += 1 - + if len(selected) < num_needed: debug.print(f"Need {num_needed - len(selected)} more indices, generating...") extra_needed = num_needed - len(selected) @@ -491,7 +493,7 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> List selected.append(idx) if len(selected) == num_needed: break - + debug.print(f"Generated {len(selected)} indices with {collisions} collisions") debug.validate(len(selected) == num_needed, f"Failed to generate enough indices: {len(selected)}/{num_needed}") @@ -508,14 +510,14 @@ def embed_in_image( image_data: bytes, pixel_key: bytes, bits_per_channel: int = 1, - output_format: Optional[str] = None, + output_format: str | None = None, embed_mode: str = EMBED_MODE_LSB, dct_output_format: str = DCT_OUTPUT_PNG, dct_color_mode: str = 'grayscale', -) -> Tuple[bytes, Union[EmbedStats, 'DCTEmbedStats'], str]: +) -> tuple[bytes, Union[EmbedStats, 'DCTEmbedStats'], str]: """ Embed data into an image using specified mode. - + Args: data: Data to embed (encrypted payload) image_data: Carrier image bytes @@ -525,19 +527,19 @@ def embed_in_image( embed_mode: 'lsb' (default) or 'dct' dct_output_format: For DCT mode - 'png' (lossless) or 'jpeg' (smaller) dct_color_mode: For DCT mode - 'grayscale' (default) or 'color' (preserves colors) - + Returns: Tuple of (stego image bytes, stats, file extension) - + Raises: CapacityError: If data won't fit EmbeddingError: If embedding fails ImportError: If DCT mode requested but scipy unavailable """ debug.print(f"embed_in_image: mode={embed_mode}, data={len(data)} bytes") - debug.validate(embed_mode in VALID_EMBED_MODES, + debug.validate(embed_mode in VALID_EMBED_MODES, f"Invalid embed_mode: {embed_mode}. Use 'lsb' or 'dct'") - + # DCT MODE if embed_mode == EMBED_MODE_DCT: if not has_dct_support(): @@ -545,38 +547,38 @@ def embed_in_image( "scipy is required for DCT embedding mode. " "Install with: pip install scipy" ) - + # Validate DCT output format if dct_output_format not in (DCT_OUTPUT_PNG, DCT_OUTPUT_JPEG): debug.print(f"Invalid dct_output_format '{dct_output_format}', defaulting to PNG") dct_output_format = DCT_OUTPUT_PNG - + # Validate DCT color mode (v3.0.1) if dct_color_mode not in ('grayscale', 'color'): debug.print(f"Invalid dct_color_mode '{dct_color_mode}', defaulting to grayscale") dct_color_mode = 'grayscale' - + dct_mod = _get_dct_module() - + # Pass output_format and color_mode to DCT module (v3.0.1) stego_bytes, dct_stats = dct_mod.embed_in_dct( - data, - image_data, + data, + image_data, pixel_key, output_format=dct_output_format, color_mode=dct_color_mode, ) - + # Determine extension based on output format if dct_output_format == DCT_OUTPUT_JPEG: ext = 'jpg' else: ext = 'png' - + debug.print(f"DCT embedding complete: {dct_output_format.upper()} output, " f"color_mode={dct_color_mode}, ext={ext}") return stego_bytes, dct_stats, ext - + # LSB MODE return _embed_lsb(data, image_data, pixel_key, bits_per_channel, output_format) @@ -586,75 +588,75 @@ def _embed_lsb( image_data: bytes, pixel_key: bytes, bits_per_channel: int = 1, - output_format: Optional[str] = None, -) -> Tuple[bytes, EmbedStats, str]: + output_format: str | None = None, +) -> tuple[bytes, EmbedStats, str]: """ Embed data using LSB steganography (internal implementation). """ debug.print(f"LSB embedding {len(data)} bytes into image") debug.data(pixel_key, "Pixel key for embedding") - debug.validate(bits_per_channel in (1, 2), + debug.validate(bits_per_channel in (1, 2), f"bits_per_channel must be 1 or 2, got {bits_per_channel}") debug.validate(len(pixel_key) == 32, f"Pixel key must be 32 bytes, got {len(pixel_key)}") - + img_file = None img = None stego_img = None - + try: img_file = Image.open(io.BytesIO(image_data)) input_format = img_file.format - + debug.print(f"Carrier image: {img_file.size[0]}x{img_file.size[1]}, format: {input_format}") - + img = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file.copy() if img_file.mode != 'RGB': debug.print(f"Converting image from {img_file.mode} to RGB") - + pixels = list(img.getdata()) num_pixels = len(pixels) - + bits_per_pixel = 3 * bits_per_channel max_bytes = (num_pixels * bits_per_pixel) // 8 - + debug.print(f"Image capacity: {max_bytes} bytes at {bits_per_channel} bit(s)/channel") - + data_with_len = struct.pack('>I', len(data)) + data - + if len(data_with_len) > max_bytes: debug.print(f"Capacity error: need {len(data_with_len)}, have {max_bytes}") raise CapacityError(len(data_with_len), max_bytes) - + debug.print(f"Total data to embed: {len(data_with_len)} bytes " f"({len(data_with_len)/max_bytes*100:.1f}% of capacity)") - + binary_data = ''.join(format(b, '08b') for b in data_with_len) pixels_needed = (len(binary_data) + bits_per_pixel - 1) // bits_per_pixel - + debug.print(f"Need {pixels_needed} pixels to embed {len(binary_data)} bits") - + selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed) - + new_pixels = list(pixels) clear_mask = 0xFF ^ ((1 << bits_per_channel) - 1) - + bit_idx = 0 modified_pixels = 0 - + for pixel_idx in selected_indices: if bit_idx >= len(binary_data): break - + r, g, b = new_pixels[pixel_idx] modified = False - + for channel_idx, channel_val in enumerate([r, g, b]): if bit_idx >= len(binary_data): break bits = binary_data[bit_idx:bit_idx + bits_per_channel].ljust(bits_per_channel, '0') new_val = (channel_val & clear_mask) | int(bits, 2) - + if channel_val != new_val: modified = True if channel_idx == 0: @@ -663,18 +665,18 @@ def _embed_lsb( g = new_val else: b = new_val - + bit_idx += bits_per_channel - + if modified: new_pixels[pixel_idx] = (r, g, b) modified_pixels += 1 - + debug.print(f"Modified {modified_pixels} pixels (out of {len(selected_indices)} selected)") - + stego_img = Image.new('RGB', img.size) stego_img.putdata(new_pixels) - + if output_format: out_fmt = output_format.upper() out_ext = FORMAT_TO_EXT.get(out_fmt, 'png') @@ -682,21 +684,21 @@ def _embed_lsb( else: out_fmt, out_ext = get_output_format(input_format) debug.print(f"Auto-selected output format: {out_fmt}") - + output = io.BytesIO() stego_img.save(output, out_fmt) output.seek(0) - + stats = EmbedStats( pixels_modified=modified_pixels, total_pixels=num_pixels, capacity_used=len(data_with_len) / max_bytes, bytes_embedded=len(data_with_len) ) - + debug.print(f"LSB embedding complete: {out_fmt} image, {len(output.getvalue())} bytes") return output.getvalue(), stats, out_ext - + except CapacityError: raise except Exception as e: @@ -722,50 +724,50 @@ def extract_from_image( pixel_key: bytes, bits_per_channel: int = 1, embed_mode: str = EMBED_MODE_AUTO, -) -> Optional[bytes]: +) -> bytes | None: """ Extract hidden data from a stego image. - + Args: image_data: Stego image bytes pixel_key: Key for pixel/coefficient selection (must match encoding) bits_per_channel: Bits per channel (LSB mode only) embed_mode: 'auto' (try both), 'lsb', or 'dct' - + Returns: Extracted data bytes, or None if extraction fails """ debug.print(f"extract_from_image: mode={embed_mode}") - + # AUTO MODE: Try LSB first, then DCT if embed_mode == EMBED_MODE_AUTO: result = _extract_lsb(image_data, pixel_key, bits_per_channel) if result is not None: debug.print("Auto-detect: LSB extraction succeeded") return result - + if has_dct_support(): debug.print("Auto-detect: LSB failed, trying DCT") result = _extract_dct(image_data, pixel_key) if result is not None: debug.print("Auto-detect: DCT extraction succeeded") return result - + debug.print("Auto-detect: All modes failed") return None - + # EXPLICIT DCT MODE elif embed_mode == EMBED_MODE_DCT: if not has_dct_support(): raise ImportError("scipy required for DCT mode") return _extract_dct(image_data, pixel_key) - + # EXPLICIT LSB MODE else: return _extract_lsb(image_data, pixel_key, bits_per_channel) -def _extract_dct(image_data: bytes, pixel_key: bytes) -> Optional[bytes]: +def _extract_dct(image_data: bytes, pixel_key: bytes) -> bytes | None: """Extract using DCT mode.""" try: dct_mod = _get_dct_module() @@ -779,7 +781,7 @@ def _extract_lsb( image_data: bytes, pixel_key: bytes, bits_per_channel: int = 1 -) -> Optional[bytes]: +) -> bytes | None: """ Extract using LSB mode (internal implementation). """ @@ -787,82 +789,82 @@ def _extract_lsb( debug.data(pixel_key, "Pixel key for extraction") debug.validate(bits_per_channel in (1, 2), f"bits_per_channel must be 1 or 2, got {bits_per_channel}") - + img_file = None img = None - + try: img_file = Image.open(io.BytesIO(image_data)) debug.print(f"Image: {img_file.size[0]}x{img_file.size[1]}, format: {img_file.format}") - + img = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file.copy() if img_file.mode != 'RGB': debug.print(f"Converting image from {img_file.mode} to RGB") - + pixels = list(img.getdata()) num_pixels = len(pixels) bits_per_pixel = 3 * bits_per_channel - + debug.print(f"Image has {num_pixels} pixels, {bits_per_pixel} bits/pixel") - + initial_pixels = (32 + bits_per_pixel - 1) // bits_per_pixel + 10 debug.print(f"Extracting initial {initial_pixels} pixels to find length") - + initial_indices = generate_pixel_indices(pixel_key, num_pixels, initial_pixels) - + binary_data = '' for pixel_idx in initial_indices: r, g, b = pixels[pixel_idx] for channel in [r, g, b]: for bit_pos in range(bits_per_channel - 1, -1, -1): binary_data += str((channel >> bit_pos) & 1) - + try: length_bits = binary_data[:32] if len(length_bits) < 32: debug.print(f"Not enough bits for length: {len(length_bits)}/32") return None - + data_length = struct.unpack('>I', int(length_bits, 2).to_bytes(4, 'big'))[0] debug.print(f"Extracted length: {data_length} bytes") except Exception as e: debug.print(f"Failed to parse length: {e}") return None - + max_possible = (num_pixels * bits_per_pixel) // 8 - 4 if data_length > max_possible or data_length < 10: debug.print(f"Invalid data length: {data_length} (max possible: {max_possible})") return None - + total_bits = (4 + data_length) * 8 pixels_needed = (total_bits + bits_per_pixel - 1) // bits_per_pixel - + debug.print(f"Need {pixels_needed} pixels to extract {data_length} bytes") - + selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed) - + binary_data = '' for pixel_idx in selected_indices: r, g, b = pixels[pixel_idx] for channel in [r, g, b]: for bit_pos in range(bits_per_channel - 1, -1, -1): binary_data += str((channel >> bit_pos) & 1) - + data_bits = binary_data[32:32 + (data_length * 8)] - + if len(data_bits) < data_length * 8: debug.print(f"Insufficient bits: {len(data_bits)} < {data_length * 8}") return None - + data_bytes = bytearray() for i in range(0, len(data_bits), 8): byte_bits = data_bits[i:i + 8] if len(byte_bits) == 8: data_bytes.append(int(byte_bits, 2)) - + debug.print(f"LSB successfully extracted {len(data_bytes)} bytes") return bytes(data_bytes) - + except Exception as e: debug.exception(e, "extract_lsb") return None @@ -878,7 +880,7 @@ def _extract_lsb( # UTILITY FUNCTIONS # ============================================================================= -def get_image_dimensions(image_data: bytes) -> Tuple[int, int]: +def get_image_dimensions(image_data: bytes) -> tuple[int, int]: """Get image dimensions without loading full image.""" debug.validate(len(image_data) > 0, "Image data cannot be empty") img = Image.open(io.BytesIO(image_data)) @@ -890,7 +892,7 @@ def get_image_dimensions(image_data: bytes) -> Tuple[int, int]: img.close() -def get_image_format(image_data: bytes) -> Optional[str]: +def get_image_format(image_data: bytes) -> str | None: """Get image format (PIL format string like 'PNG', 'JPEG').""" try: img = Image.open(io.BytesIO(image_data)) diff --git a/src/stegasoo/utils.py b/src/stegasoo/utils.py index de8f476..6129776 100644 --- a/src/stegasoo/utils.py +++ b/src/stegasoo/utils.py @@ -9,9 +9,8 @@ import os import random import secrets import shutil -from datetime import date, datetime +from datetime import date from pathlib import Path -from typing import Optional, Union from PIL import Image @@ -22,98 +21,98 @@ from .debug import debug def strip_image_metadata(image_data: bytes, output_format: str = 'PNG') -> bytes: """ Remove all metadata (EXIF, ICC profiles, etc.) from an image. - + Creates a fresh image with only pixel data - no EXIF, GPS coordinates, camera info, timestamps, or other potentially sensitive metadata. - + Args: image_data: Raw image bytes output_format: Output format ('PNG', 'BMP', 'TIFF') - + Returns: Clean image bytes with no metadata - + Example: >>> clean = strip_image_metadata(photo_bytes) >>> # EXIF data is now removed """ debug.print(f"Stripping metadata, output format: {output_format}") - + img = Image.open(io.BytesIO(image_data)) - + # Convert to RGB if needed (handles RGBA, P, L, etc.) if img.mode not in ('RGB', 'RGBA'): img = img.convert('RGB') - + # Create fresh image - this discards all metadata clean = Image.new(img.mode, img.size) clean.putdata(list(img.getdata())) - + output = io.BytesIO() clean.save(output, output_format.upper()) output.seek(0) - + debug.print(f"Metadata stripped: {len(image_data)} -> {len(output.getvalue())} bytes") return output.getvalue() def generate_filename( - date_str: Optional[str] = None, + date_str: str | None = None, prefix: str = "", extension: str = "png" ) -> str: """ Generate a filename for stego images. - + Format: {prefix}{random}_{YYYYMMDD}.{extension} - + Args: date_str: Date string (YYYY-MM-DD), defaults to today prefix: Optional prefix extension: File extension without dot (default: 'png') - + Returns: Filename string - + Example: >>> generate_filename("2023-12-25", "secret_", "png") "secret_a1b2c3d4_20231225.png" """ debug.validate(bool(extension) and '.' not in extension, f"Extension must not contain dot, got '{extension}'") - + if date_str is None: date_str = date.today().isoformat() - + date_compact = date_str.replace('-', '') random_hex = secrets.token_hex(4) - + # Ensure extension doesn't have a leading dot extension = extension.lstrip('.') - + filename = f"{prefix}{random_hex}_{date_compact}.{extension}" debug.print(f"Generated filename: {filename}") return filename -def parse_date_from_filename(filename: str) -> Optional[str]: +def parse_date_from_filename(filename: str) -> str | None: """ Extract date from a stego filename. - + Looks for patterns like _20251227 or _2025-12-27 - + Args: filename: Filename to parse - + Returns: Date string (YYYY-MM-DD) or None - + Example: >>> parse_date_from_filename("secret_a1b2c3d4_20231225.png") "2023-12-25" """ import re - + # Try YYYYMMDD format match = re.search(r'_(\d{4})(\d{2})(\d{2})(?:\.|$)', filename) if match: @@ -121,7 +120,7 @@ def parse_date_from_filename(filename: str) -> Optional[str]: date_str = f"{year}-{month}-{day}" debug.print(f"Parsed date (compact): {date_str}") return date_str - + # Try YYYY-MM-DD format match = re.search(r'_(\d{4})-(\d{2})-(\d{2})(?:\.|$)', filename) if match: @@ -129,7 +128,7 @@ def parse_date_from_filename(filename: str) -> Optional[str]: date_str = f"{year}-{month}-{day}" debug.print(f"Parsed date (dashed): {date_str}") return date_str - + debug.print(f"No date found in filename: {filename}") return None @@ -137,20 +136,20 @@ def parse_date_from_filename(filename: str) -> Optional[str]: def get_day_from_date(date_str: str) -> str: """ Get day of week name from date string. - + Args: date_str: Date string (YYYY-MM-DD) - + Returns: Day name (e.g., "Monday") - + Example: >>> get_day_from_date("2023-12-25") "Monday" """ debug.validate(len(date_str) == 10 and date_str[4] == '-' and date_str[7] == '-', f"Invalid date format: {date_str}, expected YYYY-MM-DD") - + try: year, month, day = map(int, date_str.split('-')) d = date(year, month, day) @@ -165,10 +164,10 @@ def get_day_from_date(date_str: str) -> str: def get_today_date() -> str: """ Get today's date as YYYY-MM-DD. - + Returns: Today's date string - + Example: >>> get_today_date() "2023-12-25" @@ -181,10 +180,10 @@ def get_today_date() -> str: def get_today_day() -> str: """ Get today's day name. - + Returns: Today's day name - + Example: >>> get_today_day() "Monday" @@ -197,43 +196,43 @@ def get_today_day() -> str: class SecureDeleter: """ Securely delete files by overwriting with random data. - + Implements multi-pass overwriting before deletion. - + Example: >>> deleter = SecureDeleter("secret.txt", passes=3) >>> deleter.execute() """ - - def __init__(self, path: Union[str, Path], passes: int = 7): + + def __init__(self, path: str | Path, passes: int = 7): """ Initialize secure deleter. - + Args: path: Path to file or directory passes: Number of overwrite passes """ debug.validate(passes > 0, f"Passes must be positive, got {passes}") - + self.path = Path(path) self.passes = passes debug.print(f"SecureDeleter initialized for {self.path} with {passes} passes") - + def _overwrite_file(self, file_path: Path) -> None: """Overwrite file with random data multiple times.""" if not file_path.exists() or not file_path.is_file(): debug.print(f"File does not exist or is not a file: {file_path}") return - + length = file_path.stat().st_size debug.print(f"Overwriting file {file_path} ({length} bytes)") - + if length == 0: debug.print("File is empty, nothing to overwrite") return - + patterns = [b'\x00', b'\xFF', bytes([random.randint(0, 255)])] - + for pass_num in range(self.passes): debug.print(f"Overwrite pass {pass_num + 1}/{self.passes}") with open(file_path, 'r+b') as f: @@ -245,13 +244,13 @@ class SecureDeleter: chunk = min(chunk_size, length - offset) f.write(pattern * (chunk // len(pattern))) f.write(pattern[:chunk % len(pattern)]) - + # Final pass with random data f.seek(0) f.write(os.urandom(length)) - + debug.print(f"Completed {self.passes} overwrite passes") - + def delete_file(self) -> None: """Securely delete a single file.""" if self.path.is_file(): @@ -261,28 +260,28 @@ class SecureDeleter: debug.print(f"File deleted: {self.path}") else: debug.print(f"Not a file: {self.path}") - + def delete_directory(self) -> None: """Securely delete a directory and all contents.""" if not self.path.is_dir(): debug.print(f"Not a directory: {self.path}") return - + debug.print(f"Securely deleting directory: {self.path}") - + # First, securely overwrite all files file_count = 0 for file_path in self.path.rglob('*'): if file_path.is_file(): self._overwrite_file(file_path) file_count += 1 - + debug.print(f"Overwrote {file_count} files") - + # Then remove the directory tree shutil.rmtree(self.path) debug.print(f"Directory deleted: {self.path}") - + def execute(self) -> None: """Securely delete the path (file or directory).""" debug.print(f"Executing secure deletion: {self.path}") @@ -294,14 +293,14 @@ class SecureDeleter: debug.print(f"Path does not exist: {self.path}") -def secure_delete(path: Union[str, Path], passes: int = 7) -> None: +def secure_delete(path: str | Path, passes: int = 7) -> None: """ Convenience function for secure deletion. - + Args: path: Path to file or directory passes: Number of overwrite passes - + Example: >>> secure_delete("secret.txt", passes=3) """ @@ -312,19 +311,19 @@ def secure_delete(path: Union[str, Path], passes: int = 7) -> None: def format_file_size(size_bytes: int) -> str: """ Format file size for display. - + Args: size_bytes: Size in bytes - + Returns: Human-readable string (e.g., "1.5 MB") - + Example: >>> format_file_size(1500000) "1.5 MB" """ debug.validate(size_bytes >= 0, f"File size cannot be negative: {size_bytes}") - + size: float = float(size_bytes) for unit in ['B', 'KB', 'MB', 'GB']: if size < 1024: @@ -338,13 +337,13 @@ def format_file_size(size_bytes: int) -> str: def format_number(n: int) -> str: """ Format number with commas. - + Args: n: Integer to format - + Returns: Formatted string - + Example: >>> format_number(1234567) "1,234,567" @@ -356,15 +355,15 @@ def format_number(n: int) -> str: def clamp(value: int, min_val: int, max_val: int) -> int: """ Clamp value to range. - + Args: value: Value to clamp min_val: Minimum allowed value max_val: Maximum allowed value - + Returns: Clamped value - + Example: >>> clamp(15, 0, 10) 10 diff --git a/src/stegasoo/validation.py b/src/stegasoo/validation.py index 8c67026..862b903 100644 --- a/src/stegasoo/validation.py +++ b/src/stegasoo/validation.py @@ -10,40 +10,50 @@ Changes in v3.2.0: """ import io -from typing import Optional, Union from PIL import Image from .constants import ( - MIN_PIN_LENGTH, MAX_PIN_LENGTH, - MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE, MAX_IMAGE_PIXELS, MAX_FILE_SIZE, - MIN_RSA_BITS, MIN_KEY_PASSWORD_LENGTH, - ALLOWED_IMAGE_EXTENSIONS, ALLOWED_KEY_EXTENSIONS, - MIN_PASSPHRASE_WORDS, RECOMMENDED_PASSPHRASE_WORDS, - EMBED_MODE_LSB, EMBED_MODE_DCT, EMBED_MODE_AUTO, + ALLOWED_IMAGE_EXTENSIONS, + ALLOWED_KEY_EXTENSIONS, + EMBED_MODE_AUTO, + EMBED_MODE_DCT, + EMBED_MODE_LSB, + MAX_FILE_PAYLOAD_SIZE, + MAX_FILE_SIZE, + MAX_IMAGE_PIXELS, + MAX_MESSAGE_SIZE, + MAX_PIN_LENGTH, + MIN_KEY_PASSWORD_LENGTH, + MIN_PASSPHRASE_WORDS, + MIN_PIN_LENGTH, + MIN_RSA_BITS, + RECOMMENDED_PASSPHRASE_WORDS, ) -from .models import ValidationResult, FilePayload from .exceptions import ( - ValidationError, PinValidationError, MessageValidationError, - ImageValidationError, KeyValidationError, SecurityFactorError, - FileTooLargeError, UnsupportedFileTypeError, + ImageValidationError, + KeyValidationError, + MessageValidationError, + PinValidationError, + SecurityFactorError, ) from .keygen import load_rsa_key +from .models import FilePayload, ValidationResult def validate_pin(pin: str, required: bool = False) -> ValidationResult: """ Validate PIN format. - + Rules: - 6-9 digits only - Cannot start with zero - Empty is OK if not required - + Args: pin: PIN string to validate required: Whether PIN is required - + Returns: ValidationResult """ @@ -51,83 +61,83 @@ def validate_pin(pin: str, required: bool = False) -> ValidationResult: if required: return ValidationResult.error("PIN is required") return ValidationResult.ok() - + if not pin.isdigit(): return ValidationResult.error("PIN must contain only digits") - + if len(pin) < MIN_PIN_LENGTH or len(pin) > MAX_PIN_LENGTH: return ValidationResult.error( f"PIN must be {MIN_PIN_LENGTH}-{MAX_PIN_LENGTH} digits" ) - + if pin[0] == '0': return ValidationResult.error("PIN cannot start with zero") - + return ValidationResult.ok(length=len(pin)) def validate_message(message: str) -> ValidationResult: """ Validate text message content and size. - + Args: message: Message text - + Returns: ValidationResult """ if not message: return ValidationResult.error("Message is required") - + if len(message) > MAX_MESSAGE_SIZE: return ValidationResult.error( f"Message too long ({len(message):,} chars). Maximum: {MAX_MESSAGE_SIZE:,} characters" ) - + return ValidationResult.ok(length=len(message)) -def validate_payload(payload: Union[str, bytes, FilePayload]) -> ValidationResult: +def validate_payload(payload: str | bytes | FilePayload) -> ValidationResult: """ Validate a payload (text message, bytes, or file). - + Args: payload: Text string, raw bytes, or FilePayload - + Returns: ValidationResult """ if isinstance(payload, str): return validate_message(payload) - + elif isinstance(payload, FilePayload): if not payload.data: return ValidationResult.error("File is empty") - + if len(payload.data) > MAX_FILE_PAYLOAD_SIZE: return ValidationResult.error( f"File too large ({len(payload.data):,} bytes). " f"Maximum: {MAX_FILE_PAYLOAD_SIZE:,} bytes ({MAX_FILE_PAYLOAD_SIZE // 1024} KB)" ) - + return ValidationResult.ok( size=len(payload.data), filename=payload.filename, mime_type=payload.mime_type ) - + elif isinstance(payload, bytes): if not payload: return ValidationResult.error("Payload is empty") - + if len(payload) > MAX_FILE_PAYLOAD_SIZE: return ValidationResult.error( f"Payload too large ({len(payload):,} bytes). " f"Maximum: {MAX_FILE_PAYLOAD_SIZE:,} bytes ({MAX_FILE_PAYLOAD_SIZE // 1024} KB)" ) - + return ValidationResult.ok(size=len(payload)) - + else: return ValidationResult.error(f"Invalid payload type: {type(payload)}") @@ -139,18 +149,18 @@ def validate_file_payload( ) -> ValidationResult: """ Validate a file for embedding. - + Args: file_data: Raw file bytes filename: Original filename (for display in errors) max_size: Maximum allowed size in bytes - + Returns: ValidationResult """ if not file_data: return ValidationResult.error("File is empty") - + if len(file_data) > max_size: size_kb = len(file_data) / 1024 max_kb = max_size / 1024 @@ -158,7 +168,7 @@ def validate_file_payload( f"File '{filename or 'unnamed'}' too large ({size_kb:.1f} KB). " f"Maximum: {max_kb:.0f} KB" ) - + return ValidationResult.ok(size=len(file_data), filename=filename) @@ -169,35 +179,35 @@ def validate_image( ) -> ValidationResult: """ Validate image data and dimensions. - + Args: image_data: Raw image bytes name: Name for error messages check_size: Whether to check pixel dimensions - + Returns: ValidationResult with width, height, pixels """ if not image_data: return ValidationResult.error(f"{name} is required") - + if len(image_data) > MAX_FILE_SIZE: return ValidationResult.error( f"{name} too large ({len(image_data):,} bytes). Maximum: {MAX_FILE_SIZE:,} bytes" ) - + try: img = Image.open(io.BytesIO(image_data)) width, height = img.size num_pixels = width * height - + if check_size and num_pixels > MAX_IMAGE_PIXELS: max_dim = int(MAX_IMAGE_PIXELS ** 0.5) return ValidationResult.error( f"{name} too large ({width}Γ—{height} = {num_pixels:,} pixels). " f"Maximum: ~{MAX_IMAGE_PIXELS:,} pixels ({max_dim}Γ—{max_dim})" ) - + return ValidationResult.ok( width=width, height=height, @@ -205,24 +215,24 @@ def validate_image( mode=img.mode, format=img.format ) - + except Exception as e: return ValidationResult.error(f"Could not read {name}: {e}") def validate_rsa_key( key_data: bytes, - password: Optional[str] = None, + password: str | None = None, required: bool = False ) -> ValidationResult: """ Validate RSA private key. - + Args: key_data: PEM-encoded key bytes password: Password if key is encrypted required: Whether key is required - + Returns: ValidationResult with key_size """ @@ -230,44 +240,44 @@ def validate_rsa_key( if required: return ValidationResult.error("RSA key is required") return ValidationResult.ok() - + try: private_key = load_rsa_key(key_data, password) key_size = private_key.key_size - + if key_size < MIN_RSA_BITS: return ValidationResult.error( f"RSA key must be at least {MIN_RSA_BITS} bits (got {key_size})" ) - + return ValidationResult.ok(key_size=key_size) - + except Exception as e: return ValidationResult.error(str(e)) def validate_security_factors( pin: str, - rsa_key_data: Optional[bytes] + rsa_key_data: bytes | None ) -> ValidationResult: """ Validate that at least one security factor is provided. - + Args: pin: PIN string (may be empty) rsa_key_data: RSA key bytes (may be None/empty) - + Returns: ValidationResult """ has_pin = bool(pin and pin.strip()) has_key = bool(rsa_key_data and len(rsa_key_data) > 0) - + if not has_pin and not has_key: return ValidationResult.error( "You must provide at least a PIN or RSA Key" ) - + return ValidationResult.ok(has_pin=has_pin, has_key=has_key) @@ -278,26 +288,26 @@ def validate_file_extension( ) -> ValidationResult: """ Validate file extension. - + Args: filename: Filename to check allowed: Set of allowed extensions (lowercase, no dot) file_type: Name for error messages - + Returns: ValidationResult with extension """ if not filename or '.' not in filename: return ValidationResult.error(f"{file_type} must have a file extension") - + ext = filename.rsplit('.', 1)[1].lower() - + if ext not in allowed: return ValidationResult.error( f"Unsupported {file_type.lower()} type: .{ext}. " f"Allowed: {', '.join(sorted('.' + e for e in allowed))}" ) - + return ValidationResult.ok(extension=ext) @@ -314,53 +324,53 @@ def validate_key_file(filename: str) -> ValidationResult: def validate_key_password(password: str) -> ValidationResult: """ Validate password for key encryption. - + Args: password: Password string - + Returns: ValidationResult """ if not password: return ValidationResult.error("Password is required") - + if len(password) < MIN_KEY_PASSWORD_LENGTH: return ValidationResult.error( f"Password must be at least {MIN_KEY_PASSWORD_LENGTH} characters" ) - + return ValidationResult.ok(length=len(password)) def validate_passphrase(passphrase: str) -> ValidationResult: """ Validate passphrase. - + v3.2.0: Recommend 4+ words for good entropy (since date is no longer used). - + Args: passphrase: Passphrase string - + Returns: ValidationResult with word_count and optional warning """ if not passphrase or not passphrase.strip(): return ValidationResult.error("Passphrase is required") - + words = passphrase.strip().split() - + if len(words) < MIN_PASSPHRASE_WORDS: return ValidationResult.error( f"Passphrase should have at least {MIN_PASSPHRASE_WORDS} words" ) - + # Provide warning if below recommended length if len(words) < RECOMMENDED_PASSPHRASE_WORDS: return ValidationResult.ok( word_count=len(words), warning=f"Recommend {RECOMMENDED_PASSPHRASE_WORDS}+ words for better security" ) - + return ValidationResult.ok(word_count=len(words)) @@ -381,60 +391,60 @@ def validate_carrier(carrier_data: bytes) -> ValidationResult: def validate_embed_mode(mode: str) -> ValidationResult: """ Validate embedding mode. - + Args: mode: Embedding mode string - + Returns: ValidationResult """ valid_modes = {EMBED_MODE_LSB, EMBED_MODE_DCT, EMBED_MODE_AUTO} - + if mode not in valid_modes: return ValidationResult.error( f"Invalid embed_mode: '{mode}'. Valid options: {', '.join(sorted(valid_modes))}" ) - + return ValidationResult.ok(mode=mode) def validate_dct_output_format(format_str: str) -> ValidationResult: """ Validate DCT output format. - + Args: format_str: Output format ('png' or 'jpeg') - + Returns: ValidationResult """ valid_formats = {'png', 'jpeg'} - + if format_str.lower() not in valid_formats: return ValidationResult.error( f"Invalid DCT output format: '{format_str}'. Valid options: {', '.join(sorted(valid_formats))}" ) - + return ValidationResult.ok(format=format_str.lower()) def validate_dct_color_mode(mode: str) -> ValidationResult: """ Validate DCT color mode. - + Args: mode: Color mode ('grayscale' or 'color') - + Returns: ValidationResult """ valid_modes = {'grayscale', 'color'} - + if mode.lower() not in valid_modes: return ValidationResult.error( f"Invalid DCT color mode: '{mode}'. Valid options: {', '.join(sorted(valid_modes))}" ) - + return ValidationResult.ok(mode=mode.lower()) @@ -456,7 +466,7 @@ def require_valid_message(message: str) -> None: raise MessageValidationError(result.error_message) -def require_valid_payload(payload: Union[str, bytes, FilePayload]) -> None: +def require_valid_payload(payload: str | bytes | FilePayload) -> None: """Validate payload (text, bytes, or file), raising exception on failure.""" result = validate_payload(payload) if not result.is_valid: @@ -472,7 +482,7 @@ def require_valid_image(image_data: bytes, name: str = "Image") -> None: def require_valid_rsa_key( key_data: bytes, - password: Optional[str] = None, + password: str | None = None, required: bool = False ) -> None: """Validate RSA key, raising exception on failure.""" @@ -481,7 +491,7 @@ def require_valid_rsa_key( raise KeyValidationError(result.error_message) -def require_security_factors(pin: str, rsa_key_data: Optional[bytes]) -> None: +def require_security_factors(pin: str, rsa_key_data: bytes | None) -> None: """Validate security factors, raising exception on failure.""" result = validate_security_factors(pin, rsa_key_data) if not result.is_valid: diff --git a/test_compare_capacity_flow.py b/test_compare_capacity_flow.py index 742e0c2..28bbab3 100644 --- a/test_compare_capacity_flow.py +++ b/test_compare_capacity_flow.py @@ -131,36 +131,36 @@ if HAS_JPEGIO: print("\n" + "=" * 60) print("JPEGIO SPECIFIC TEST") print("=" * 60) - + import tempfile import os - + # Reload image data with open(image_path, 'rb') as f: carrier_data = f.read() - + print("\n[J1] Checking if image is JPEG...") img = Image.open(io.BytesIO(carrier_data)) is_jpeg = img.format == 'JPEG' img.close() print(f" Is JPEG: {is_jpeg}") - + if is_jpeg: print("\n[J2] Writing to temp file...") fd, temp_path = tempfile.mkstemp(suffix='.jpg') os.write(fd, carrier_data) os.close(fd) print(f" Temp file: {temp_path}") - + print("\n[J3] Reading with jpegio...") try: jpeg = jio.read(temp_path) print(f" jpegio.read() OK") - + print("\n[J4] Accessing coefficient arrays...") coef = jpeg.coef_arrays[0] print(f" Coef shape: {coef.shape}, dtype: {coef.dtype}") - + print("\n[J5] Counting usable positions...") positions = [] h, w = coef.shape @@ -171,31 +171,31 @@ if HAS_JPEGIO: if abs(coef[row, col]) >= 2: positions.append((row, col)) print(f" Usable positions: {len(positions)}") - + print("\n[J6] Cleaning up jpegio object...") del coef del jpeg gc.collect() print(" Deleted jpeg object") - + print("\n[J7] Removing temp file...") os.unlink(temp_path) print(" Temp file removed") - + gc.collect() print("\n[J8] Final GC...") - + except Exception as e: print(f" ERROR: {e}") import traceback traceback.print_exc() - + print("\n[J9] Waiting for delayed crash...") for i in range(3): time.sleep(1) print(f" {i+1}s...") gc.collect() - + print("\n" + "=" * 60) print("JPEGIO TEST PASSED - No crash detected") print("=" * 60) diff --git a/test_dct_crash.py b/test_dct_crash.py index b84123a..16a3362 100644 --- a/test_dct_crash.py +++ b/test_dct_crash.py @@ -61,16 +61,16 @@ except ImportError: print("\n[3] BASIC DCT TEST (8x8 block)") try: test_block = np.random.rand(8, 8).astype(np.float64) - + # 1D DCT on rows result = dct(test_block[0, :], norm='ortho') print(f" 1D DCT: OK (output shape: {result.shape})") - + # 1D IDCT recovered = idct(result, norm='ortho') error = np.max(np.abs(test_block[0, :] - recovered)) print(f" 1D IDCT: OK (roundtrip error: {error:.2e})") - + # 2D via separable temp = np.zeros_like(test_block) for i in range(8): @@ -79,10 +79,10 @@ try: for i in range(8): result2d[i, :] = dct(temp[i, :], norm='ortho') print(f" 2D DCT: OK") - + gc.collect() print(" GC after basic test: OK") - + except Exception as e: print(f" FAILED: {e}") traceback.print_exc() @@ -92,10 +92,10 @@ print("\n[4] STRESS TEST (many 8x8 blocks)") try: NUM_BLOCKS = 10000 print(f" Processing {NUM_BLOCKS} blocks...") - + for i in range(NUM_BLOCKS): block = np.random.rand(8, 8).astype(np.float64) - + # Forward DCT temp = np.zeros_like(block) for j in range(8): @@ -103,7 +103,7 @@ try: result = np.zeros_like(temp) for j in range(8): result[j, :] = dct(temp[j, :], norm='ortho') - + # Inverse DCT temp2 = np.zeros_like(result) for j in range(8): @@ -111,14 +111,14 @@ try: recovered = np.zeros_like(temp2) for j in range(8): recovered[:, j] = idct(temp2[:, j], norm='ortho') - + if i % 1000 == 0: gc.collect() print(f" {i}/{NUM_BLOCKS} blocks processed...") - + gc.collect() print(f" Stress test PASSED") - + except Exception as e: print(f" FAILED at block {i}: {e}") traceback.print_exc() @@ -127,18 +127,18 @@ except Exception as e: if len(sys.argv) > 1: image_path = sys.argv[1] print(f"\n[5] IMAGE TEST: {image_path}") - + try: with open(image_path, 'rb') as f: image_data = f.read() print(f" File size: {len(image_data) / 1024 / 1024:.2f} MB") - + img = Image.open(io.BytesIO(image_data)) width, height = img.size print(f" Dimensions: {width}x{height}") print(f" Format: {img.format}") print(f" Mode: {img.mode}") - + # Convert to grayscale float array gray = img.convert('L') arr = np.array(gray, dtype=np.float64) @@ -146,35 +146,35 @@ if len(sys.argv) > 1: gray.close() print(f" Array shape: {arr.shape}") print(f" Array dtype: {arr.dtype}") - + # Pad to block boundary BLOCK_SIZE = 8 h, w = arr.shape new_h = ((h + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE new_w = ((w + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE - + if new_h != h or new_w != w: padded = np.zeros((new_h, new_w), dtype=np.float64) padded[:h, :w] = arr arr = padded print(f" Padded to: {arr.shape}") - + blocks_y = arr.shape[0] // BLOCK_SIZE blocks_x = arr.shape[1] // BLOCK_SIZE total_blocks = blocks_y * blocks_x print(f" Total 8x8 blocks: {total_blocks}") - + # Process ALL blocks print(f" Processing all blocks with DCT...") - + processed = 0 for by in range(blocks_y): for bx in range(blocks_x): y = by * BLOCK_SIZE x = bx * BLOCK_SIZE - + block = arr[y:y+BLOCK_SIZE, x:x+BLOCK_SIZE].copy() - + # Forward DCT temp = np.zeros((8, 8), dtype=np.float64) for i in range(8): @@ -182,7 +182,7 @@ if len(sys.argv) > 1: dct_block = np.zeros((8, 8), dtype=np.float64) for i in range(8): dct_block[i, :] = dct(temp[i, :], norm='ortho') - + # Inverse DCT temp2 = np.zeros((8, 8), dtype=np.float64) for i in range(8): @@ -190,17 +190,17 @@ if len(sys.argv) > 1: recovered = np.zeros((8, 8), dtype=np.float64) for i in range(8): recovered[:, i] = idct(temp2[:, i], norm='ortho') - + processed += 1 - + # GC after each row of blocks if by % 50 == 0: gc.collect() print(f" Row {by}/{blocks_y} ({processed}/{total_blocks} blocks)") - + gc.collect() print(f" Image DCT test PASSED ({processed} blocks)") - + except Exception as e: print(f" FAILED: {e}") traceback.print_exc() diff --git a/tests/test_batch.py b/tests/test_batch.py index e961fb8..9cee68f 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -7,18 +7,19 @@ Updated for v4.0.0: - BatchCredentials.passphrase is a single string """ -import pytest -import tempfile import shutil +import tempfile from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import Mock + +import pytest from stegasoo.batch import ( + BatchCredentials, + BatchItem, BatchProcessor, BatchResult, - BatchItem, BatchStatus, - BatchCredentials, batch_capacity_check, print_batch_result, ) @@ -36,14 +37,14 @@ def temp_dir(): def sample_images(temp_dir): """Create sample PNG images for testing.""" from PIL import Image - + images = [] for i in range(3): img_path = temp_dir / f"test_image_{i}.png" img = Image.new('RGB', (100, 100), color=(i * 50, i * 50, i * 50)) img.save(img_path, 'PNG') images.append(img_path) - + return images @@ -58,19 +59,19 @@ def sample_credentials(): class TestBatchItem: """Tests for BatchItem dataclass.""" - + def test_duration_calculation(self): """Duration should be calculated from start/end times.""" item = BatchItem(input_path=Path("test.png")) item.start_time = 100.0 item.end_time = 105.5 assert item.duration == 5.5 - + def test_duration_none_without_times(self): """Duration should be None if times not set.""" item = BatchItem(input_path=Path("test.png")) assert item.duration is None - + def test_to_dict(self): """to_dict should serialize all fields.""" item = BatchItem( @@ -87,7 +88,7 @@ class TestBatchItem: class TestBatchResult: """Tests for BatchResult dataclass.""" - + def test_to_json(self): """Should serialize to valid JSON.""" import json @@ -96,7 +97,7 @@ class TestBatchResult: parsed = json.loads(json_str) assert parsed['operation'] == "encode" assert parsed['summary']['total'] == 5 - + def test_duration_with_end_time(self): """Duration should work when end_time is set.""" result = BatchResult(operation="test") @@ -107,7 +108,7 @@ class TestBatchResult: class TestBatchCredentials: """Tests for BatchCredentials dataclass (v3.2.0).""" - + def test_from_dict_new_format(self): """Should parse v3.2.0 format with 'passphrase' key.""" data = { @@ -117,7 +118,7 @@ class TestBatchCredentials: creds = BatchCredentials.from_dict(data) assert creds.passphrase == "test phrase four words" assert creds.pin == "123456" - + def test_from_dict_legacy_format(self): """Should parse legacy format with 'day_phrase' key for migration.""" data = { @@ -128,7 +129,7 @@ class TestBatchCredentials: # Should accept old key and map to passphrase assert creds.passphrase == "legacy phrase here" assert creds.pin == "123456" - + def test_to_dict(self): """Should serialize to v3.2.0 format.""" creds = BatchCredentials( @@ -139,7 +140,7 @@ class TestBatchCredentials: assert result['passphrase'] == "test phrase four words" assert result['pin'] == "123456" assert 'day_phrase' not in result # Old key should not be present - + def test_passphrase_is_string(self): """Passphrase should be a string, not a dict.""" creds = BatchCredentials( @@ -151,59 +152,59 @@ class TestBatchCredentials: class TestBatchProcessor: """Tests for BatchProcessor class.""" - + def test_init_default_workers(self): """Should default to 4 workers.""" processor = BatchProcessor() assert processor.max_workers == 4 - + def test_init_custom_workers(self): """Should accept custom worker count.""" processor = BatchProcessor(max_workers=8) assert processor.max_workers == 8 - + def test_is_valid_image_png(self, temp_dir): """Should recognize PNG as valid.""" processor = BatchProcessor() png_path = temp_dir / "test.png" png_path.touch() assert processor._is_valid_image(png_path) - + def test_is_valid_image_txt(self, temp_dir): """Should reject non-image files.""" processor = BatchProcessor() txt_path = temp_dir / "test.txt" txt_path.touch() assert not processor._is_valid_image(txt_path) - + def test_find_images_file(self, sample_images): """Should find single image file.""" processor = BatchProcessor() results = list(processor.find_images([sample_images[0]])) assert len(results) == 1 assert results[0] == sample_images[0] - + def test_find_images_directory(self, sample_images, temp_dir): """Should find images in directory.""" processor = BatchProcessor() results = list(processor.find_images([temp_dir])) assert len(results) == 3 - + def test_find_images_recursive(self, temp_dir): """Should find images recursively.""" from PIL import Image - + # Create nested directory nested = temp_dir / "nested" nested.mkdir() img_path = nested / "nested.png" img = Image.new('RGB', (50, 50)) img.save(img_path) - + processor = BatchProcessor() results = list(processor.find_images([temp_dir], recursive=True)) assert any(p.name == "nested.png" for p in results) - + def test_batch_encode_requires_message_or_file(self, sample_images, sample_credentials): """Should raise if neither message nor file provided.""" processor = BatchProcessor() @@ -212,7 +213,7 @@ class TestBatchProcessor: images=sample_images, credentials=sample_credentials, ) - + def test_batch_encode_requires_credentials(self, sample_images): """Should raise if credentials not provided.""" processor = BatchProcessor() @@ -221,7 +222,7 @@ class TestBatchProcessor: images=sample_images, message="test", ) - + def test_batch_encode_accepts_passphrase_credentials(self, sample_images, temp_dir, sample_credentials): """Should accept v3.2.0 format credentials with passphrase.""" processor = BatchProcessor() @@ -231,11 +232,11 @@ class TestBatchProcessor: output_dir=temp_dir / "output", credentials=sample_credentials, # Uses 'passphrase' key ) - + assert isinstance(result, BatchResult) assert result.operation == "encode" assert result.total == 3 - + def test_batch_encode_creates_result(self, sample_images, temp_dir, sample_credentials): """Should return BatchResult with correct structure.""" processor = BatchProcessor() @@ -245,18 +246,18 @@ class TestBatchProcessor: output_dir=temp_dir / "output", credentials=sample_credentials, ) - + assert isinstance(result, BatchResult) assert result.operation == "encode" assert result.total == 3 assert len(result.items) == 3 - + def test_batch_decode_requires_credentials(self, sample_images): """Should raise if credentials not provided.""" processor = BatchProcessor() with pytest.raises(ValueError, match="Credentials"): processor.batch_decode(images=sample_images) - + def test_batch_decode_accepts_passphrase_credentials(self, sample_images, sample_credentials): """Should accept v3.2.0 format credentials with passphrase.""" processor = BatchProcessor() @@ -264,11 +265,11 @@ class TestBatchProcessor: images=sample_images, credentials=sample_credentials, # Uses 'passphrase' key ) - + assert isinstance(result, BatchResult) assert result.operation == "decode" assert result.total == 3 - + def test_batch_decode_creates_result(self, sample_images, sample_credentials): """Should return BatchResult with correct structure.""" processor = BatchProcessor() @@ -276,30 +277,30 @@ class TestBatchProcessor: images=sample_images, credentials=sample_credentials, ) - + assert isinstance(result, BatchResult) assert result.operation == "decode" assert result.total == 3 - + def test_progress_callback_called(self, sample_images, sample_credentials): """Progress callback should be called for each item.""" processor = BatchProcessor() callback = Mock() - + processor.batch_encode( images=sample_images, message="Test", credentials=sample_credentials, progress_callback=callback, ) - + assert callback.call_count == 3 - + def test_custom_encode_func(self, sample_images, temp_dir, sample_credentials): """Should use custom encode function if provided.""" processor = BatchProcessor() encode_mock = Mock() - + processor.batch_encode( images=sample_images, message="Test", @@ -307,19 +308,19 @@ class TestBatchProcessor: credentials=sample_credentials, encode_func=encode_mock, ) - + assert encode_mock.call_count == 3 class TestBatchCapacityCheck: """Tests for batch_capacity_check function.""" - + def test_returns_list(self, sample_images): """Should return list of results.""" results = batch_capacity_check(sample_images) assert isinstance(results, list) assert len(results) == 3 - + def test_includes_capacity(self, sample_images): """Results should include capacity info.""" results = batch_capacity_check(sample_images) @@ -327,12 +328,12 @@ class TestBatchCapacityCheck: assert 'capacity_bytes' in item assert 'dimensions' in item assert 'valid' in item - + def test_handles_invalid_files(self, temp_dir): """Should handle non-image files gracefully.""" bad_file = temp_dir / "not_an_image.png" bad_file.write_bytes(b"not a png") - + results = batch_capacity_check([bad_file]) assert len(results) == 1 assert 'error' in results[0] @@ -340,7 +341,7 @@ class TestBatchCapacityCheck: class TestPrintBatchResult: """Tests for print_batch_result function.""" - + def test_prints_summary(self, capsys, sample_images): """Should print summary without errors.""" result = BatchResult( @@ -350,14 +351,14 @@ class TestPrintBatchResult: failed=1, ) result.end_time = result.start_time + 5.0 - + print_batch_result(result) - + captured = capsys.readouterr() assert "ENCODE" in captured.out assert "3" in captured.out # total assert "2" in captured.out # succeeded - + def test_verbose_shows_items(self, capsys): """Verbose mode should show individual items.""" result = BatchResult(operation="decode", total=1, succeeded=1) @@ -369,16 +370,16 @@ class TestPrintBatchResult: ) ] result.end_time = result.start_time + 1.0 - + print_batch_result(result, verbose=True) - + captured = capsys.readouterr() assert "test.png" in captured.out class TestCredentialsMigration: """Tests for v3.1.x to v3.2.0 credentials migration.""" - + def test_old_phrase_key_accepted(self): """Old 'phrase' key should be accepted for migration.""" old_format = { @@ -388,7 +389,7 @@ class TestCredentialsMigration: # Should not raise creds = BatchCredentials.from_dict(old_format) assert creds.passphrase == "old style phrase" - + def test_old_day_phrase_key_accepted(self): """Old 'day_phrase' key should be accepted for migration.""" old_format = { @@ -397,7 +398,7 @@ class TestCredentialsMigration: } creds = BatchCredentials.from_dict(old_format) assert creds.passphrase == "old day phrase" - + def test_new_passphrase_key_preferred(self): """New 'passphrase' key should take precedence if both present.""" mixed_format = { diff --git a/tests/test_compression.py b/tests/test_compression.py index fe0eef7..d38bfe2 100644 --- a/tests/test_compression.py +++ b/tests/test_compression.py @@ -3,24 +3,25 @@ Tests for Stegasoo compression module. """ import pytest + from stegasoo.compression import ( - compress, - decompress, - CompressionAlgorithm, - CompressionError, - get_compression_ratio, - estimate_compressed_size, - get_available_algorithms, - algorithm_name, - MIN_COMPRESS_SIZE, COMPRESSION_MAGIC, HAS_LZ4, + MIN_COMPRESS_SIZE, + CompressionAlgorithm, + CompressionError, + algorithm_name, + compress, + decompress, + estimate_compressed_size, + get_available_algorithms, + get_compression_ratio, ) class TestCompress: """Tests for compress function.""" - + def test_compress_small_data_not_compressed(self): """Small data should not be compressed (overhead not worth it).""" small_data = b"hello" @@ -28,7 +29,7 @@ class TestCompress: # Should have magic header but NONE algorithm assert result.startswith(COMPRESSION_MAGIC) assert result[4] == CompressionAlgorithm.NONE - + def test_compress_zlib_reduces_size(self): """Zlib should reduce size for compressible data.""" # Highly compressible data @@ -37,7 +38,7 @@ class TestCompress: assert len(result) < len(data) assert result.startswith(COMPRESSION_MAGIC) assert result[4] == CompressionAlgorithm.ZLIB - + def test_compress_incompressible_data(self): """Incompressible data should be stored uncompressed.""" import os @@ -46,7 +47,7 @@ class TestCompress: result = compress(data, CompressionAlgorithm.ZLIB) # Should fall back to NONE if compression didn't help assert result.startswith(COMPRESSION_MAGIC) - + def test_compress_none_algorithm(self): """NONE algorithm should just wrap data.""" data = b"Test data" * 100 @@ -55,7 +56,7 @@ class TestCompress: assert result[4] == CompressionAlgorithm.NONE # Data should be after 9-byte header assert result[9:] == data - + @pytest.mark.skipif(not HAS_LZ4, reason="LZ4 not installed") def test_compress_lz4(self): """LZ4 compression should work if available.""" @@ -68,33 +69,33 @@ class TestCompress: class TestDecompress: """Tests for decompress function.""" - + def test_decompress_zlib(self): """Decompression should restore original data.""" original = b"Hello, World! " * 100 compressed = compress(original, CompressionAlgorithm.ZLIB) result = decompress(compressed) assert result == original - + def test_decompress_none(self): """Uncompressed wrapped data should decompress correctly.""" original = b"Small data" wrapped = compress(original, CompressionAlgorithm.NONE) result = decompress(wrapped) assert result == original - + def test_decompress_no_magic(self): """Data without magic header should be returned as-is.""" data = b"Not compressed at all" result = decompress(data) assert result == data - + def test_decompress_truncated_header(self): """Truncated header should raise CompressionError.""" bad_data = COMPRESSION_MAGIC + b"\x01" # Too short with pytest.raises(CompressionError, match="Truncated"): decompress(bad_data) - + @pytest.mark.skipif(not HAS_LZ4, reason="LZ4 not installed") def test_decompress_lz4(self): """LZ4 decompression should work.""" @@ -102,7 +103,7 @@ class TestDecompress: compressed = compress(original, CompressionAlgorithm.LZ4) result = decompress(compressed) assert result == original - + def test_roundtrip_large_data(self): """Large data should survive compress/decompress roundtrip.""" import os @@ -114,19 +115,19 @@ class TestDecompress: class TestUtilities: """Tests for utility functions.""" - + def test_compression_ratio_compressed(self): """Ratio should be < 1 for well-compressed data.""" original = b"X" * 1000 compressed = compress(original) ratio = get_compression_ratio(original, compressed) assert ratio < 1.0 - + def test_compression_ratio_empty(self): """Empty data should return ratio of 1.0.""" ratio = get_compression_ratio(b"", b"") assert ratio == 1.0 - + def test_estimate_compressed_size_small(self): """Small data estimation should be accurate.""" data = b"Test " * 100 @@ -134,13 +135,13 @@ class TestUtilities: actual = len(compress(data)) # Should be within 20% for small data assert abs(estimate - actual) / actual < 0.2 - + def test_available_algorithms(self): """Should always include NONE and ZLIB.""" algos = get_available_algorithms() assert CompressionAlgorithm.NONE in algos assert CompressionAlgorithm.ZLIB in algos - + def test_algorithm_name(self): """Algorithm names should be human-readable.""" assert "Zlib" in algorithm_name(CompressionAlgorithm.ZLIB) @@ -150,25 +151,25 @@ class TestUtilities: class TestEdgeCases: """Edge case tests.""" - + def test_empty_data(self): """Empty data should be handled gracefully.""" result = compress(b"") assert decompress(result) == b"" - + def test_exact_min_size(self): """Data at exactly MIN_COMPRESS_SIZE should be compressed.""" data = b"x" * MIN_COMPRESS_SIZE result = compress(data, CompressionAlgorithm.ZLIB) assert result.startswith(COMPRESSION_MAGIC) assert decompress(result) == data - + def test_binary_data(self): """Binary data with null bytes should work.""" data = b"\x00\x01\x02\x03" * 500 compressed = compress(data) assert decompress(compressed) == data - + def test_unicode_after_encoding(self): """UTF-8 encoded Unicode should compress correctly.""" text = "Hello, δΈ–η•Œ! πŸŽ‰ " * 100 diff --git a/tests/test_stegasoo.py b/tests/test_stegasoo.py index f8ae03b..4a2dba2 100644 --- a/tests/test_stegasoo.py +++ b/tests/test_stegasoo.py @@ -11,29 +11,28 @@ Updated for v4.0.0: - Python 3.12 recommended (3.13 not supported) """ +import io + import pytest from PIL import Image -import io import stegasoo from stegasoo import ( - generate_pin, - generate_passphrase, - generate_credentials, - validate_pin, - validate_message, - validate_passphrase, - validate_channel_key, - encode, decode, decode_text, + encode, generate_channel_key, + generate_credentials, + generate_passphrase, + generate_pin, get_channel_fingerprint, - __version__, + validate_channel_key, + validate_message, + validate_passphrase, + validate_pin, ) from stegasoo.steganography import get_output_format - # ============================================================================= # Fixtures # ============================================================================= @@ -94,7 +93,7 @@ def gif_image(): class TestKeygen: """Tests for key generation functions.""" - + def test_generate_pin_default(self): """Default PIN should be 6 digits, no leading zero.""" pin = generate_pin() @@ -183,7 +182,7 @@ class TestKeygen: class TestValidation: """Tests for validation functions.""" - + def test_validate_pin_valid(self): """Valid PIN should pass validation.""" result = validate_pin("123456") @@ -253,7 +252,7 @@ class TestValidation: class TestOutputFormat: """Tests for output format handling.""" - + def test_png_stays_png(self): """PNG input should produce PNG output.""" fmt, ext = get_output_format('PNG') @@ -310,7 +309,7 @@ class TestConstants: class TestEncodeDecode: """Tests for encoding and decoding functions.""" - + def test_encode_decode_roundtrip(self, png_image): """Full encode/decode cycle should work.""" message = "Secret message!" @@ -501,7 +500,7 @@ class TestEncodeDecode: class TestDCTMode: """Tests for DCT steganography mode.""" - + @pytest.fixture def skip_if_no_dct(self): """Skip test if DCT support not available.""" @@ -567,7 +566,7 @@ class TestDCTMode: class TestVersion: """Tests for version information.""" - + def test_version_exists(self): """Version string should exist and be valid.""" assert hasattr(stegasoo, '__version__') @@ -588,7 +587,7 @@ class TestVersion: class TestBackwardCompatibility: """Tests for backward compatibility handling.""" - + def test_old_day_phrase_parameter_raises(self, png_image): """Using old day_phrase parameter should raise TypeError.""" with pytest.raises(TypeError):