diff --git a/Dockerfile b/Dockerfile index edcb203..04fc73b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,15 @@ # Stegasoo Docker Image # Multi-stage build for smaller image size -FROM python:3.11-slim as base +# Pin the base image digest for reproducibility +# To update: docker manifest inspect python:3.11-slim -v | jq -r '.[0].Descriptor.digest' +FROM python:3.11-slim@sha256:5501a4fe605abe24de87c2f3d6cf9fd760354416a0cad0296cf284fddcdca9e2 as base # Set environment variables ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 +# Suppress pip "running as root" warnings during build +ENV PIP_ROOT_USER_ACTION=ignore # Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/frontends/cli/main.py b/frontends/cli/main.py index ce27a14..687f01e 100644 --- a/frontends/cli/main.py +++ b/frontends/cli/main.py @@ -6,6 +6,7 @@ Usage: stegasoo generate [OPTIONS] stegasoo encode [OPTIONS] stegasoo decode [OPTIONS] + stegasoo verify [OPTIONS] stegasoo info [OPTIONS] """ @@ -28,6 +29,9 @@ from stegasoo import ( DAY_NAMES, __version__, StegasooError, DecryptionError, ExtractionError, FilePayload, + # New in 2.2.1 + will_fit, + strip_image_metadata, ) # QR Code utilities @@ -273,6 +277,16 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key ref_photo = Path(ref).read_bytes() carrier_image = Path(carrier).read_bytes() + # Pre-check capacity + fit_check = will_fit(payload, carrier_image) + if not fit_check['fits']: + raise click.ClickException( + f"Payload too large for carrier image.\n" + f" Payload: {fit_check['payload_size']:,} bytes\n" + f" Capacity: {fit_check['capacity']:,} bytes\n" + f" Shortfall: {-fit_check['headroom']:,} bytes" + ) + result = encode( message=payload, reference_photo=ref_photo, @@ -302,6 +316,8 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key except StegasooError as e: raise click.ClickException(str(e)) + except click.ClickException: + raise except Exception as e: raise click.ClickException(f"Error: {e}") @@ -431,6 +447,129 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, quiet raise click.ClickException(f"Error: {e}") +# ============================================================================ +# VERIFY COMMAND +# ============================================================================ + +@cli.command() +@click.option('--ref', '-r', required=True, type=click.Path(exists=True), help='Reference photo') +@click.option('--stego', '-s', required=True, type=click.Path(exists=True), help='Stego image') +@click.option('--phrase', '-p', required=True, help='Day phrase') +@click.option('--pin', help='Static PIN') +@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)') +@click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image') +@click.option('--key-password', help='RSA key password (for encrypted .pem files)') +@click.option('--json', 'as_json', is_flag=True, help='Output as JSON') +def verify(ref, stego, phrase, pin, key, key_qr, key_password, 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. + + \b + Examples: + stegasoo verify -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456 + + stegasoo verify -r photo.jpg -s stego.png -p "words" -k mykey.pem --json + """ + # 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: + if not HAS_QR or not has_qr_read(): + raise click.ClickException( + "QR code reading not available. Install: pip install pyzbar\n" + "Also requires system library: sudo apt-get install libzbar0" + ) + key_pem = extract_key_from_qr_file(key_qr) + if not key_pem: + 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 + result = decode( + stego_image=stego_image, + reference_photo=ref_photo, + day_phrase=phrase, + pin=pin or "", + rsa_key_data=rsa_key_data, + rsa_password=effective_key_password, + ) + + # Calculate payload size + if result.is_file: + payload_size = len(result.file_data) if result.file_data else 0 + payload_type = "file" + payload_desc = result.filename or "unnamed file" + if result.mime_type: + payload_desc += f" ({result.mime_type})" + else: + payload_size = len(result.message.encode('utf-8')) if result.message else 0 + payload_type = "text" + payload_desc = f"{payload_size} bytes" + + # Get date info + date_encoded = result.date_encoded + day_name = get_day_from_date(date_encoded) if date_encoded else None + + if as_json: + import json + output = { + "valid": True, + "stego_file": stego, + "payload_type": payload_type, + "payload_size": payload_size, + "date_encoded": date_encoded, + "day_encoded": day_name, + } + if result.is_file: + output["filename"] = result.filename + output["mime_type"] = result.mime_type + click.echo(json.dumps(output, indent=2)) + else: + click.secho("✓ Valid stego image", fg='green', bold=True) + click.echo(f" Payload: {payload_type} ({payload_desc})") + click.echo(f" Size: {payload_size:,} bytes") + if date_encoded: + click.echo(f" Encoded: {date_encoded} ({day_name})") + + except (DecryptionError, ExtractionError) as e: + if as_json: + import json + output = { + "valid": False, + "stego_file": stego, + "error": str(e), + } + click.echo(json.dumps(output, indent=2)) + sys.exit(1) + else: + click.secho("✗ Verification failed", fg='red', bold=True) + click.echo(f" Error: {e}") + sys.exit(1) + except StegasooError as e: + raise click.ClickException(str(e)) + except Exception as e: + raise click.ClickException(f"Error: {e}") + + # ============================================================================ # INFO COMMAND # ============================================================================ @@ -473,6 +612,50 @@ def info(image): raise click.ClickException(str(e)) +# ============================================================================ +# STRIP-METADATA COMMAND +# ============================================================================ + +@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', 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 + stegasoo strip-metadata photo.jpg # Overwrites as PNG + """ + 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)) + + # ============================================================================ # MAIN # ============================================================================ diff --git a/quick_web.sh b/quick_web.sh new file mode 100755 index 0000000..2dd083d --- /dev/null +++ b/quick_web.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd ./frontends/web/ +python app.py diff --git a/rbld_containers.sh b/rbld_containers.sh new file mode 100755 index 0000000..60c135a --- /dev/null +++ b/rbld_containers.sh @@ -0,0 +1,4 @@ +#!/bin/bash +sudo docker-compose down +sudo docker-compose build +sudo docker-compose up -d diff --git a/src/stegasoo/__init__.py b/src/stegasoo/__init__.py index b1163ea..f67fc50 100644 --- a/src/stegasoo/__init__.py +++ b/src/stegasoo/__init__.py @@ -58,6 +58,16 @@ File Embedding: else: print(decoded.message) +Capacity Pre-check (v2.2.1): + from stegasoo import will_fit + + # Check if payload will fit before encoding + result = will_fit("My secret message", carrier_image) + if result['fits']: + print(f"Will use {result['usage_percent']:.1f}% capacity") + else: + print(f"Need {-result['headroom']} more bytes") + Debugging: from stegasoo.debug import debug debug.enable(True) # Enable debug output @@ -142,6 +152,8 @@ from .steganography import ( get_image_format, is_lossless_format, LOSSLESS_FORMATS, + # NEW in v2.2.1 + will_fit, ) from .utils import ( generate_filename, @@ -152,6 +164,8 @@ from .utils import ( secure_delete, SecureDeleter, format_file_size, + # NEW in v2.2.1 + strip_image_metadata, ) from .debug import debug # Import debug utilities @@ -177,6 +191,8 @@ from .batch import ( BatchItem, BatchStatus, batch_capacity_check, + # NEW in v2.2.1 + BatchCredentials, ) # QR Code utilities (optional, depends on qrcode and pyzbar) @@ -630,6 +646,7 @@ __all__ = [ 'get_image_dimensions', 'get_image_format', 'is_lossless_format', + 'will_fit', # NEW in v2.2.1 # Utilities 'generate_filename', @@ -640,6 +657,7 @@ __all__ = [ 'secure_delete', 'SecureDeleter', 'format_file_size', + 'strip_image_metadata', # NEW in v2.2.1 # Debugging 'debug', @@ -659,4 +677,5 @@ __all__ = [ 'BatchItem', 'BatchStatus', 'batch_capacity_check', + 'BatchCredentials', # NEW in v2.2.1 ] diff --git a/src/stegasoo/batch.py b/src/stegasoo/batch.py index d8b785b..b198bfa 100644 --- a/src/stegasoo/batch.py +++ b/src/stegasoo/batch.py @@ -61,6 +61,41 @@ class BatchItem: } +@dataclass +class BatchCredentials: + """ + Credentials for batch encode/decode operations. + + Provides a structured way to pass authentication factors + for batch processing instead of using plain dicts. + + Example: + creds = BatchCredentials( + reference_photo=ref_bytes, + day_phrase="apple forest thunder", + pin="123456" + ) + result = processor.batch_encode(images, creds, message="secret") + """ + reference_photo: bytes + day_phrase: str + pin: str = "" + rsa_key_data: Optional[bytes] = None + rsa_password: Optional[str] = None + date_str: Optional[str] = None # YYYY-MM-DD, defaults to today + + def to_dict(self) -> dict: + """Convert to dictionary for legacy API compatibility.""" + return { + "reference_photo": self.reference_photo, + "day_phrase": self.day_phrase, + "pin": self.pin, + "rsa_key_data": self.rsa_key_data, + "rsa_password": self.rsa_password, + "date_str": self.date_str, + } + + @dataclass class BatchResult: """Summary of a batch operation.""" diff --git a/src/stegasoo/constants.py b/src/stegasoo/constants.py index fdf73ce..2fea0e3 100644 --- a/src/stegasoo/constants.py +++ b/src/stegasoo/constants.py @@ -12,7 +12,7 @@ from pathlib import Path # VERSION # ============================================================================ -__version__ = "2.2.0" +__version__ = "2.2.1" # ============================================================================ # FILE FORMAT diff --git a/src/stegasoo/steganography.py b/src/stegasoo/steganography.py index c02950a..bfa7fbd 100644 --- a/src/stegasoo/steganography.py +++ b/src/stegasoo/steganography.py @@ -6,13 +6,13 @@ LSB embedding and extraction with pseudo-random pixel selection. import io import struct -from typing import Optional, Tuple, List +from typing import Optional, Tuple, List, Union from PIL import Image from cryptography.hazmat.primitives.ciphers import Cipher, algorithms from cryptography.hazmat.backends import default_backend -from .models import EmbedStats +from .models import EmbedStats, FilePayload from .exceptions import CapacityError, ExtractionError, EmbeddingError from .debug import debug @@ -35,6 +35,11 @@ EXT_TO_FORMAT = { 'tif': 'TIFF', } +# Overhead constants for capacity estimation +HEADER_OVERHEAD = 104 # Magic + version + date + salt + iv + tag +LENGTH_PREFIX = 4 # 4 bytes for payload length +ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX + def get_output_format(input_format: Optional[str]) -> Tuple[str, str]: """ @@ -67,6 +72,106 @@ def get_output_format(input_format: Optional[str]) -> Tuple[str, str]: return 'PNG', 'png' +def will_fit( + payload: Union[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 without performing encryption. + + This is a lightweight pre-check to avoid wasted work on payloads that + are too large. For accurate results with compression, the actual compressed + size may vary. + + 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 (requires payload data) + + Returns: + Dict with: + - fits: bool - Whether payload will fit + - payload_size: int - Raw payload size in bytes + - estimated_encrypted_size: int - Estimated size after encryption + overhead + - capacity: int - Available capacity in bytes + - usage_percent: float - Estimated capacity usage (0-100) + - headroom: int - Bytes remaining (negative if won't fit) + - compressed_estimate: int | None - Estimated compressed size (if applicable) + + Example: + >>> result = will_fit("Hello world", carrier_bytes) + >>> result['fits'] + True + >>> result['usage_percent'] + 0.5 + + >>> result = will_fit(50000, carrier_bytes) # Check if 50KB would fit + >>> result['fits'] + False + """ + # Determine payload size + if isinstance(payload, int): + payload_size = payload + payload_data = None + elif isinstance(payload, str): + payload_data = payload.encode('utf-8') + payload_size = len(payload_data) + elif isinstance(payload, FilePayload): + payload_data = payload.data + # Account for filename/mime metadata + filename_overhead = len(payload.filename.encode('utf-8')) if payload.filename else 0 + mime_overhead = len(payload.mime_type.encode('utf-8')) if payload.mime_type else 0 + payload_size = len(payload.data) + filename_overhead + mime_overhead + 5 # +5 for length prefixes + type byte + else: + payload_data = payload + payload_size = len(payload) + + # Calculate capacity + capacity = calculate_capacity(carrier_image, bits_per_channel) + + # Estimate encrypted size (payload + random padding + overhead) + # Padding adds 64-319 bytes, averaging ~190 + estimated_padding = 190 + estimated_encrypted_size = payload_size + estimated_padding + ENCRYPTION_OVERHEAD + + # Compression estimate + compressed_estimate = None + if include_compression_estimate and payload_data is not None and len(payload_data) >= 64: + try: + import zlib + compressed = zlib.compress(payload_data, level=6) + # Add compression header overhead (9 bytes) + compressed_size = len(compressed) + 9 + if compressed_size < payload_size: + compressed_estimate = compressed_size + # Use compressed size for fit calculation + estimated_encrypted_size = compressed_size + estimated_padding + ENCRYPTION_OVERHEAD + except Exception: + pass # Ignore compression errors + + headroom = capacity - estimated_encrypted_size + fits = headroom >= 0 + usage_percent = (estimated_encrypted_size / capacity * 100) if capacity > 0 else 100.0 + + result = { + 'fits': fits, + 'payload_size': payload_size, + 'estimated_encrypted_size': estimated_encrypted_size, + 'capacity': capacity, + 'usage_percent': min(usage_percent, 100.0), + 'headroom': headroom, + 'compressed_estimate': compressed_estimate, + } + + debug.print(f"will_fit: payload={payload_size}, encrypted~={estimated_encrypted_size}, " + f"capacity={capacity}, fits={fits}") + + return result + + @debug.time def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> List[int]: """ @@ -172,6 +277,8 @@ def embed_in_image( Uses pseudo-random pixel selection based on pixel_key to scatter the data across the image, defeating statistical analysis. + Note: Output images have all metadata (EXIF, etc.) stripped automatically. + Args: carrier_data: Carrier image bytes encrypted_data: Data to embed @@ -274,7 +381,7 @@ def embed_in_image( debug.print(f"Modified {modified_pixels} pixels (out of {len(selected_indices)} selected)") - # Create output image + # Create output image (fresh image = no metadata/EXIF carried over) stego_img = Image.new('RGB', img.size) stego_img.putdata(new_pixels) @@ -447,7 +554,7 @@ def calculate_capacity(image_data: bytes, bits_per_channel: int = 1) -> int: max_bytes = (num_pixels * bits_per_pixel) // 8 # Subtract overhead: 4 bytes length + ~100 bytes header - capacity = max(0, max_bytes - 104) + capacity = max(0, max_bytes - ENCRYPTION_OVERHEAD) debug.print(f"Image capacity: {capacity} bytes at {bits_per_channel} bit(s)/channel") return capacity diff --git a/src/stegasoo/utils.py b/src/stegasoo/utils.py index 412c54e..de8f476 100644 --- a/src/stegasoo/utils.py +++ b/src/stegasoo/utils.py @@ -4,6 +4,7 @@ Stegasoo Utilities Secure deletion, filename generation, and other helpers. """ +import io import os import random import secrets @@ -12,10 +13,50 @@ from datetime import date, datetime from pathlib import Path from typing import Optional, Union +from PIL import Image + from .constants import DAY_NAMES 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, prefix: str = "",