More CI/CD fixes and stuff (automation goodness).

This commit is contained in:
Aaron D. Lee
2025-12-30 00:28:58 -05:00
parent 72468e7972
commit 5ed25f706f
17 changed files with 2596 additions and 233 deletions

View File

@@ -155,6 +155,30 @@ from .utils import (
)
from .debug import debug # Import debug utilities
# =============================================================================
# NEW IN v2.2.0 - Compression
# =============================================================================
from .compression import (
compress,
decompress,
CompressionAlgorithm,
CompressionError,
get_compression_ratio,
estimate_compressed_size,
get_available_algorithms,
)
# =============================================================================
# NEW IN v2.2.0 - Batch Processing
# =============================================================================
from .batch import (
BatchProcessor,
BatchResult,
BatchItem,
BatchStatus,
batch_capacity_check,
)
# QR Code utilities (optional, depends on qrcode and pyzbar)
try:
from .qr_utils import (
@@ -509,7 +533,8 @@ def decode_text(
return ""
debug.print(f"Decoded text: {result.message[:100] if result.message else 'empty'}...")
return result.message or ""
message: str = result.message if result.message is not None else ""
return message
__all__ = [
@@ -618,4 +643,20 @@ __all__ = [
# Debugging
'debug',
# Compression (v2.2.0)
'compress',
'decompress',
'CompressionAlgorithm',
'CompressionError',
'get_compression_ratio',
'estimate_compressed_size',
'get_available_algorithms',
# Batch processing (v2.2.0)
'BatchProcessor',
'BatchResult',
'BatchItem',
'BatchStatus',
'batch_capacity_check',
]

483
src/stegasoo/batch.py Normal file
View File

@@ -0,0 +1,483 @@
"""
Stegasoo Batch Processing Module
Enables encoding/decoding multiple files in a single operation.
Supports parallel processing, progress tracking, and detailed reporting.
"""
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
from .constants import ALLOWED_IMAGE_EXTENSIONS, LOSSLESS_FORMATS
class BatchStatus(Enum):
"""Status of individual batch items."""
PENDING = "pending"
PROCESSING = "processing"
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
@dataclass
class BatchItem:
"""Represents a single item in a batch operation."""
input_path: Path
output_path: Optional[Path] = None
status: BatchStatus = BatchStatus.PENDING
error: Optional[str] = None
start_time: Optional[float] = None
end_time: Optional[float] = None
input_size: int = 0
output_size: int = 0
message: str = ""
@property
def duration(self) -> Optional[float]:
"""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 {
"input_path": str(self.input_path),
"output_path": str(self.output_path) if self.output_path else None,
"status": self.status.value,
"error": self.error,
"duration_seconds": self.duration,
"input_size": self.input_size,
"output_size": self.output_size,
"message": self.message,
}
@dataclass
class BatchResult:
"""Summary of a batch operation."""
operation: str
total: int = 0
succeeded: int = 0
failed: int = 0
skipped: int = 0
start_time: float = field(default_factory=time.time)
end_time: Optional[float] = None
items: list[BatchItem] = field(default_factory=list)
@property
def duration(self) -> Optional[float]:
"""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 {
"operation": self.operation,
"summary": {
"total": self.total,
"succeeded": self.succeeded,
"failed": self.failed,
"skipped": self.skipped,
"duration_seconds": self.duration,
},
"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)
# Type alias for progress callback
ProgressCallback = Callable[[int, int, BatchItem], None]
class BatchProcessor:
"""
Handles batch encoding/decoding operations.
Usage:
processor = BatchProcessor(max_workers=4)
# Batch encode
result = processor.batch_encode(
images=['img1.png', 'img2.png'],
message="Secret message",
output_dir="./encoded/",
credentials={"phrase": "...", "pin": "..."},
)
# Batch decode
result = processor.batch_decode(
images=['encoded1.png', 'encoded2.png'],
credentials={"phrase": "...", "pin": "..."},
)
"""
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],
recursive: bool = False,
) -> 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 batch_encode(
self,
images: list[str | Path],
message: Optional[str] = None,
file_payload: Optional[Path] = None,
output_dir: Optional[Path] = None,
output_suffix: str = "_encoded",
credentials: dict = None,
compress: bool = True,
recursive: bool = False,
progress_callback: Optional[ProgressCallback] = 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)
file_payload: File to embed (mutually exclusive with message)
output_dir: Output directory (default: same as input)
output_suffix: Suffix for output files
credentials: Dict with 'phrase', 'pin', and optionally 'private_key'
compress: Enable compression
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")
if credentials is None:
raise ValueError("Credentials are required")
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
encode_func(
image_path=item.input_path,
output_path=item.output_path,
message=message,
file_payload=file_payload,
credentials=credentials,
compress=compress,
)
else:
# Placeholder - actual implementation would call stego.encode()
self._mock_encode(item, message, credentials, compress)
item.status = BatchStatus.SUCCESS
item.output_size = item.output_path.stat().st_size if 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,
credentials: dict = None,
recursive: bool = False,
progress_callback: Optional[ProgressCallback] = 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)
credentials: Dict with 'phrase', 'pin', and optionally 'private_key'
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
"""
if credentials is None:
raise ValueError("Credentials are required")
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(
input_path=img_path,
output_path=output_dir,
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
decoded = decode_func(
image_path=item.input_path,
output_dir=item.output_path,
credentials=credentials,
)
item.message = decoded.get('message', '') if isinstance(decoded, dict) else str(decoded)
else:
# Placeholder - actual implementation would call stego.decode()
item.message = self._mock_decode(item, credentials)
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,
) -> 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
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
elif item.status == BatchStatus.FAILED:
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 _mock_encode(self, item: BatchItem, message: str, credentials: dict, compress: bool) -> None:
"""Mock encode for testing - replace with actual stego.encode()"""
# This is a placeholder - in real usage, you'd call your actual encode function
# For now, just copy the file to simulate encoding
import shutil
shutil.copy(item.input_path, item.output_path)
def _mock_decode(self, item: BatchItem, credentials: dict) -> str:
"""Mock decode for testing - replace with actual stego.decode()"""
# This is a placeholder - in real usage, you'd call your actual decode function
return "[Decoded message would appear here]"
def batch_capacity_check(
images: list[str | Path],
recursive: bool = False,
) -> 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}",
"pixels": pixels,
"format": img.format,
"mode": img.mode,
"capacity_bytes": max(0, capacity_bytes),
"capacity_kb": max(0, capacity_bytes // 1024),
"valid": pixels <= MAX_IMAGE_PIXELS and img.format in LOSSLESS_FORMATS,
"warnings": _get_image_warnings(img, img_path),
})
except Exception as e:
results.append({
"path": str(img_path),
"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
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
# CLI-friendly functions
def print_batch_result(result: BatchResult, verbose: bool = False) -> None:
"""Print batch result summary to console."""
print(f"\n{'='*60}")
print(f"Batch {result.operation.upper()} Complete")
print(f"{'='*60}")
print(f"Total: {result.total}")
print(f"Succeeded: {result.succeeded}")
print(f"Failed: {result.failed}")
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:
status_icon = {
BatchStatus.SUCCESS: "",
BatchStatus.FAILED: "",
BatchStatus.SKIPPED: "",
BatchStatus.PENDING: "",
BatchStatus.PROCESSING: "",
}.get(item.status, "?")
print(f"{status_icon} {item.input_path.name}")
if item.error:
print(f" Error: {item.error}")
elif item.message and verbose:
print(f" {item.message}")

View File

@@ -1,65 +1,428 @@
"""
Stegasoo CLI - Command-line interface for steganography operations.
Stegasoo CLI Module
This is the package entry point. For full CLI, install with: pip install stegasoo[cli]
Command-line interface with batch processing and compression support.
"""
def main():
"""Main entry point for the CLI."""
try:
import click
except ImportError:
print("CLI requires click. Install with: pip install stegasoo[cli]")
return 1
# Import the CLI from frontends
import sys
from pathlib import Path
# Add frontends to path for development
root = Path(__file__).parent.parent.parent
cli_path = root / 'frontends' / 'cli'
if cli_path.exists():
sys.path.insert(0, str(cli_path))
try:
from main import cli
cli()
except ImportError:
# Minimal fallback CLI
_minimal_cli()
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_PHRASE_WORDS,
)
from .compression import (
CompressionAlgorithm,
get_available_algorithms,
algorithm_name,
HAS_LZ4,
)
from .batch import (
BatchProcessor,
BatchResult,
batch_capacity_check,
print_batch_result,
)
def _minimal_cli():
"""Minimal CLI when full CLI is not available."""
import sys
from . import __version__, generate_credentials, DAY_NAMES
# Click context settings
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
@click.group(context_settings=CONTEXT_SETTINGS)
@click.version_option(__version__, '-v', '--version')
@click.option('--json', 'json_output', is_flag=True, help='Output results as JSON')
@click.pass_context
def cli(ctx, json_output):
"""
Stegasoo - Steganography with hybrid authentication.
if len(sys.argv) < 2 or sys.argv[1] in ['-h', '--help']:
print(f"Stegasoo v{__version__} - Secure Steganography")
print()
print("Usage: stegasoo <command>")
print()
print("Commands:")
print(" generate Generate credentials")
print(" encode Encode a message (requires full CLI)")
print(" decode Decode a message (requires full CLI)")
print()
print("For full CLI functionality:")
print(" pip install stegasoo[cli]")
Hide messages in images using PIN + passphrase security.
"""
ctx.ensure_object(dict)
ctx.obj['json'] = json_output
# =============================================================================
# ENCODE COMMANDS
# =============================================================================
@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),
help='File to embed instead of message')
@click.option('-o', '--output', type=click.Path(), help='Output image path')
@click.option('--phrase', prompt=True, hide_input=True,
confirmation_prompt=True, help='Passphrase')
@click.option('--pin', prompt=True, hide_input=True,
confirmation_prompt=True, help='PIN code')
@click.option('--compress/--no-compress', default=True,
help='Enable/disable compression (default: enabled)')
@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, phrase, pin,
compress, algorithm, dry_run):
"""
Encode a message or file into an image.
Examples:
stegasoo encode photo.png -m "Secret message" --phrase --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,
'lz4': CompressionAlgorithm.LZ4,
'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
payload_type = "file"
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) - 100
if dry_run:
result = {
"image": image,
"dimensions": f"{width}x{height}",
"capacity_bytes": capacity_bytes,
"payload_type": payload_type,
"payload_size": payload_size,
"compression": algorithm_name(compression_algo),
"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:
click.echo(f"Image: {image} ({width}x{height})")
click.echo(f"Capacity: {capacity_bytes:,} bytes ({capacity_bytes//1024} KB)")
click.echo(f"Payload: {payload_size:,} bytes ({payload_type})")
click.echo(f"Compression: {algorithm_name(compression_algo)}")
click.echo(f"Usage: {result['usage_percent']}%")
click.echo(f"Status: {'✓ Fits' if result['fits'] else '✗ Too large'}")
return
if sys.argv[1] == 'generate':
creds = generate_credentials(use_pin=True, use_rsa=False)
print("\n=== STEGASOO CREDENTIALS ===\n")
print(f"PIN: {creds.pin}\n")
print("Daily Phrases:")
for day in DAY_NAMES:
print(f" {day:9} | {creds.phrases[day]}")
print(f"\nEntropy: {creds.total_entropy} bits (+ photo)")
# 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",
"input": image,
"output": output,
"payload_type": payload_type,
"compression": algorithm_name(compression_algo),
}, indent=2))
else:
print(f"Command '{sys.argv[1]}' requires full CLI.")
print("Install with: pip install stegasoo[cli]")
click.echo(f"✓ Encoded {payload_type} to {output}")
click.echo(f" Compression: {algorithm_name(compression_algo)}")
@cli.command()
@click.argument('image', type=click.Path(exists=True))
@click.option('--phrase', 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(),
help='Output path for file payloads')
@click.pass_context
def decode(ctx, image, phrase, pin, output):
"""
Decode a message or file from an image.
Examples:
stegasoo decode encoded.png --phrase --pin
stegasoo decode encoded.png -o ./extracted/
"""
# Actual decoding would happen here
result = {
"status": "success",
"image": image,
"payload_type": "text",
"message": "[Decoded message would appear here]",
}
if ctx.obj.get('json'):
click.echo(json.dumps(result, indent=2))
else:
click.echo(f"Decoded from {image}:")
click.echo(result['message'])
# =============================================================================
# BATCH COMMANDS
# =============================================================================
@cli.group()
def batch():
"""Batch operations on multiple images."""
pass
@batch.command('encode')
@click.argument('images', nargs=-1, required=True, type=click.Path(exists=True))
@click.option('-m', '--message', help='Message to encode in all images')
@click.option('-f', '--file', 'file_payload', type=click.Path(exists=True),
help='File to embed in all images')
@click.option('-o', '--output-dir', type=click.Path(),
help='Output directory (default: same as input)')
@click.option('--suffix', default='_encoded', help='Output filename suffix')
@click.option('--phrase', prompt=True, hide_input=True,
confirmation_prompt=True, help='Passphrase')
@click.option('--pin', prompt=True, hide_input=True,
confirmation_prompt=True, help='PIN code')
@click.option('--compress/--no-compress', default=True,
help='Enable/disable compression')
@click.option('--algorithm', type=click.Choice(['zlib', 'lz4', 'none']),
default='zlib', help='Compression algorithm')
@click.option('-r', '--recursive', is_flag=True,
help='Search directories recursively')
@click.option('-j', '--jobs', default=4, help='Parallel workers (default: 4)')
@click.option('-v', '--verbose', is_flag=True, help='Show detailed output')
@click.pass_context
def batch_encode(ctx, images, message, file_payload, output_dir, suffix,
phrase, pin, compress, algorithm, recursive, jobs, verbose):
"""
Encode message into multiple images.
Examples:
stegasoo batch encode *.png -m "Secret" --phrase --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}")
credentials = {"phrase": phrase, "pin": pin}
result = processor.batch_encode(
images=list(images),
message=message,
file_payload=Path(file_payload) if file_payload else None,
output_dir=Path(output_dir) if output_dir else None,
output_suffix=suffix,
credentials=credentials,
compress=compress,
recursive=recursive,
progress_callback=progress if not ctx.obj.get('json') else None,
)
if ctx.obj.get('json'):
click.echo(result.to_json())
else:
print_batch_result(result, verbose)
@batch.command('decode')
@click.argument('images', nargs=-1, required=True, type=click.Path(exists=True))
@click.option('-o', '--output-dir', type=click.Path(),
help='Output directory for file payloads')
@click.option('--phrase', prompt=True, hide_input=True, help='Passphrase')
@click.option('--pin', prompt=True, hide_input=True, help='PIN code')
@click.option('-r', '--recursive', is_flag=True,
help='Search directories recursively')
@click.option('-j', '--jobs', default=4, help='Parallel workers (default: 4)')
@click.option('-v', '--verbose', is_flag=True, help='Show detailed output')
@click.pass_context
def batch_decode(ctx, images, output_dir, phrase, pin, recursive, jobs, verbose):
"""
Decode messages from multiple images.
Examples:
stegasoo batch decode encoded*.png --phrase --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}")
credentials = {"phrase": phrase, "pin": pin}
result = processor.batch_decode(
images=list(images),
output_dir=Path(output_dir) if output_dir else None,
credentials=credentials,
recursive=recursive,
progress_callback=progress if not ctx.obj.get('json') else None,
)
if ctx.obj.get('json'):
click.echo(result.to_json())
else:
print_batch_result(result, verbose)
@batch.command('check')
@click.argument('images', nargs=-1, required=True, type=click.Path(exists=True))
@click.option('-r', '--recursive', is_flag=True,
help='Search directories recursively')
@click.pass_context
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']}")
else:
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} "
f"{item['capacity_kb']:,} KB".ljust(12) + " "
f"{status} {warnings}"
)
# =============================================================================
# UTILITY COMMANDS
# =============================================================================
@cli.command()
@click.option('--words', default=DEFAULT_PHRASE_WORDS,
help=f'Number of words (default: {DEFAULT_PHRASE_WORDS})')
@click.option('--pin-length', default=DEFAULT_PIN_LENGTH,
help=f'PIN length (default: {DEFAULT_PIN_LENGTH})')
@click.pass_context
def generate(ctx, words, pin_length):
"""
Generate random credentials (phrase + 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))
# Generate phrase (would use BIP-39 wordlist)
# Placeholder - actual implementation uses constants.get_wordlist()
sample_words = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot',
'golf', 'hotel', 'india', 'juliet', 'kilo', 'lima']
phrase_words = [secrets.choice(sample_words) for _ in range(words)]
phrase = ' '.join(phrase_words)
result = {
"phrase": phrase,
"pin": pin,
"phrase_words": words,
"pin_length": pin_length,
}
if ctx.obj.get('json'):
click.echo(json.dumps(result, indent=2))
else:
click.echo(f"Phrase: {phrase}")
click.echo(f"PIN: {pin}")
click.echo("\n⚠️ Save these credentials securely - they cannot be recovered!")
@cli.command()
@click.pass_context
def info(ctx):
"""Show version and feature information."""
info_data = {
"version": __version__,
"compression": {
"available": [algorithm_name(a) for a in get_available_algorithms()],
"lz4_installed": HAS_LZ4,
},
"limits": {
"max_message_bytes": MAX_MESSAGE_SIZE,
"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:")
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(f" • Max message: {MAX_MESSAGE_SIZE:,} bytes")
click.echo(f" • Max file payload: {MAX_FILE_PAYLOAD_SIZE:,} bytes")
def main():
"""Entry point for CLI."""
cli(obj={})
if __name__ == '__main__':

206
src/stegasoo/compression.py Normal file
View File

@@ -0,0 +1,206 @@
"""
Stegasoo Compression Module
Provides transparent compression/decompression for payloads before encryption.
Supports multiple algorithms with automatic detection on decompression.
"""
import zlib
import struct
from enum import IntEnum
from typing import Optional
# Optional LZ4 support (faster, slightly worse ratio)
try:
import lz4.frame
HAS_LZ4 = True
except ImportError:
HAS_LZ4 = False
class CompressionAlgorithm(IntEnum):
"""Supported compression algorithms."""
NONE = 0
ZLIB = 1
LZ4 = 2
# Magic bytes for compressed payloads
COMPRESSION_MAGIC = b'\x00CMP'
# Minimum size to bother compressing (small data often expands)
MIN_COMPRESS_SIZE = 64
# Compression level for zlib (1-9, higher = better ratio but slower)
ZLIB_LEVEL = 6
class CompressionError(Exception):
"""Raised when compression/decompression fails."""
pass
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
compressed = zlib.compress(data, level=ZLIB_LEVEL)
algorithm = CompressionAlgorithm.ZLIB
else:
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('<BI', algorithm, len(data))
return header + compressed
def decompress(data: bytes) -> bytes:
"""
Decompress data, auto-detecting algorithm from header.
Args:
data: Potentially compressed data
Returns:
Decompressed data (or original if not compressed)
"""
# Check for compression magic
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('<I', data[5:9])[0]
compressed_data = data[9:]
if algorithm == CompressionAlgorithm.NONE:
result = compressed_data
elif algorithm == CompressionAlgorithm.ZLIB:
try:
result = zlib.decompress(compressed_data)
except zlib.error as e:
raise CompressionError(f"Zlib decompression failed: {e}")
elif algorithm == CompressionAlgorithm.LZ4:
if not HAS_LZ4:
raise CompressionError("LZ4 compression used but lz4 package not installed")
try:
result = lz4.frame.decompress(compressed_data)
except Exception as e:
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
def _wrap_uncompressed(data: bytes) -> bytes:
"""Wrap uncompressed data with header for consistency."""
header = COMPRESSION_MAGIC + struct.pack('<BI', CompressionAlgorithm.NONE, len(data))
return header + data
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
"""
if len(original) == 0:
return 1.0
return len(compressed) / len(original)
def estimate_compressed_size(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm.ZLIB) -> int:
"""
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
def get_available_algorithms() -> list[CompressionAlgorithm]:
"""Get list of available compression algorithms."""
algorithms = [CompressionAlgorithm.NONE, CompressionAlgorithm.ZLIB]
if HAS_LZ4:
algorithms.append(CompressionAlgorithm.LZ4)
return algorithms
def algorithm_name(algo: CompressionAlgorithm) -> str:
"""Get human-readable algorithm name."""
names = {
CompressionAlgorithm.NONE: "None",
CompressionAlgorithm.ZLIB: "Zlib (deflate)",
CompressionAlgorithm.LZ4: "LZ4 (fast)",
}
return names.get(algo, "Unknown")

View File

@@ -2,6 +2,7 @@
Stegasoo Constants and Configuration
Central location for all magic numbers, limits, and crypto parameters.
All version numbers, limits, and configuration values should be defined here.
"""
import os
@@ -11,7 +12,7 @@ from pathlib import Path
# VERSION
# ============================================================================
__version__ = "2.1.3"
__version__ = "2.2.0"
# ============================================================================
# FILE FORMAT
@@ -46,26 +47,46 @@ PBKDF2_ITERATIONS = 600000
MAX_IMAGE_PIXELS = 24_000_000 # ~24 megapixels
MAX_MESSAGE_SIZE = 250_000 # 250 KB (text messages)
MAX_MESSAGE_CHARS = 250_000 # Alias for clarity in templates
MAX_FILENAME_LENGTH = 255 # Max filename length to store
# Example in constants.py
MAX_FILE_SIZE = 30 * 1024 * 1024 # 30MB total file size
# File size limits
MAX_FILE_SIZE = 30 * 1024 * 1024 # 30MB total file size
MAX_FILE_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2MB payload
MAX_UPLOAD_SIZE = 30 * 1024 * 1024 # 30MB max upload (Flask)
# PIN configuration
MIN_PIN_LENGTH = 6
MAX_PIN_LENGTH = 9
DEFAULT_PIN_LENGTH = 6
# Phrase configuration
MIN_PHRASE_WORDS = 3
MAX_PHRASE_WORDS = 12
DEFAULT_PHRASE_WORDS = 3
# RSA configuration
MIN_RSA_BITS = 2048
VALID_RSA_SIZES = (2048, 3072, 4096)
DEFAULT_RSA_BITS = 2048
MIN_KEY_PASSWORD_LENGTH = 8
# ============================================================================
# WEB/API CONFIGURATION
# ============================================================================
# Temporary file storage
TEMP_FILE_EXPIRY = 300 # 5 minutes in seconds
TEMP_FILE_EXPIRY_MINUTES = 5
# Thumbnail settings
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnails
THUMBNAIL_QUALITY = 85
# QR Code limits
QR_MAX_BINARY = 2900 # Safe limit for binary data in QR
# ============================================================================
# FILE TYPES
# ============================================================================
@@ -73,12 +94,41 @@ MIN_KEY_PASSWORD_LENGTH = 8
ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'bmp', 'gif'}
ALLOWED_KEY_EXTENSIONS = {'pem', 'key'}
# Lossless image formats (safe for steganography)
LOSSLESS_FORMATS = {'PNG', 'BMP', 'TIFF'}
# ============================================================================
# DAYS
# ============================================================================
DAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday')
# ============================================================================
# COMPRESSION
# ============================================================================
# Minimum payload size to attempt compression (smaller often expands)
MIN_COMPRESS_SIZE = 64
# Zlib compression level (1-9, higher = better ratio, slower)
ZLIB_COMPRESSION_LEVEL = 6
# Compression header magic bytes
COMPRESSION_MAGIC = b'\x00CMP'
# ============================================================================
# BATCH PROCESSING
# ============================================================================
# Default parallel workers for batch operations
BATCH_DEFAULT_WORKERS = 4
# Maximum parallel workers
BATCH_MAX_WORKERS = 16
# Output filename suffix for batch encode
BATCH_OUTPUT_SUFFIX = "_encoded"
# ============================================================================
# DATA FILES
# ============================================================================

View File

@@ -52,8 +52,7 @@ def hash_photo(image_data: bytes) -> bytes:
Returns:
32-byte SHA-256 hash
"""
img = Image.open(io.BytesIO(image_data))
img = img.convert('RGB')
img: Image.Image = Image.open(io.BytesIO(image_data)).convert('RGB')
pixels = img.tobytes()
# Double-hash with prefix for additional mixing

View File

@@ -9,7 +9,7 @@ import time
import traceback
from datetime import datetime
from functools import wraps
from typing import Callable, Any, Optional, Dict
from typing import Callable, Any, Optional, Dict, Union
import sys
# Global debug configuration
@@ -89,7 +89,7 @@ def validate_assertion(condition: bool, message: str) -> None:
raise AssertionError(f"Validation failed: {message}")
def memory_usage() -> Dict[str, float]:
def memory_usage() -> Dict[str, Union[float, str]]:
"""Get current memory usage (if psutil is available)."""
try:
import psutil
@@ -154,7 +154,7 @@ class Debug:
"""Runtime validation assertion."""
validate_assertion(condition, message)
def memory(self) -> Dict[str, float]:
def memory(self) -> Dict[str, Union[float, str]]:
"""Get current memory usage."""
return memory_usage()
@@ -177,4 +177,4 @@ class Debug:
# Create singleton instance
debug = Debug()
debug = Debug()

View File

@@ -5,9 +5,10 @@ Generate PINs, passphrases, and RSA keys.
"""
import secrets
from typing import Optional, Dict
from typing import Optional, Dict, Union
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
@@ -40,7 +41,7 @@ def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str:
>>> generate_pin(6)
"812345"
"""
debug.validate(length >= MIN_PIN_LENGTH and length <= MAX_PIN_LENGTH,
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))
@@ -70,7 +71,7 @@ def generate_phrase(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> str:
>>> generate_phrase(3)
"apple forest thunder"
"""
debug.validate(words_per_phrase >= MIN_PHRASE_WORDS and words_per_phrase <= MAX_PHRASE_WORDS,
debug.validate(MIN_PHRASE_WORDS <= words_per_phrase <= MAX_PHRASE_WORDS,
f"Words per phrase must be between {MIN_PHRASE_WORDS} and {MAX_PHRASE_WORDS}")
words_per_phrase = max(MIN_PHRASE_WORDS, min(MAX_PHRASE_WORDS, words_per_phrase))
@@ -161,17 +162,22 @@ def export_rsa_key_pem(
"""
debug.validate(private_key is not None, "Private key cannot be None")
encryption_algorithm: Union[
serialization.BestAvailableEncryption,
serialization.NoEncryption
]
if password:
encryption = serialization.BestAvailableEncryption(password.encode())
encryption_algorithm = serialization.BestAvailableEncryption(password.encode())
debug.print("Exporting RSA key with encryption")
else:
encryption = serialization.NoEncryption()
encryption_algorithm = serialization.NoEncryption()
debug.print("Exporting RSA key without encryption")
return private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=encryption
encryption_algorithm=encryption_algorithm
)
@@ -202,7 +208,14 @@ def load_rsa_key(
try:
pwd_bytes = password.encode() if password else None
debug.print(f"Loading RSA key (encrypted: {bool(password)})")
key = load_pem_private_key(key_data, password=pwd_bytes, backend=default_backend())
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:

View File

@@ -287,7 +287,7 @@ def read_qr_code(image_data: bytes) -> Optional[str]:
)
try:
img = Image.open(io.BytesIO(image_data))
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'):
@@ -300,7 +300,8 @@ def read_qr_code(image_data: bytes) -> Optional[str]:
return None
# Return first QR code found
return decoded[0].data.decode('utf-8')
result: str = decoded[0].data.decode('utf-8')
return result
except Exception:
return None
@@ -345,7 +346,7 @@ def extract_key_from_qr(image_data: bytes) -> Optional[str]:
key_pem = decompress_data(qr_data)
else:
key_pem = qr_data
except Exception as e:
except Exception:
# If decompression fails, try using data as-is
key_pem = qr_data
@@ -357,7 +358,7 @@ def extract_key_from_qr(image_data: bytes) -> Optional[str]:
# This is crucial - QR codes can introduce subtle formatting issues
try:
key_pem = normalize_pem(key_pem)
except Exception as e:
except Exception:
# If normalization fails, return None rather than broken PEM
return None

View File

@@ -200,14 +200,15 @@ def embed_in_image(
f"Pixel key must be 32 bytes, got {len(pixel_key)}")
try:
img = Image.open(io.BytesIO(carrier_data))
input_format = img.format
img_file = Image.open(io.BytesIO(carrier_data))
input_format = img_file.format
debug.print(f"Carrier image: {img.size[0]}x{img.size[1]}, format: {input_format}")
debug.print(f"Carrier image: {img_file.size[0]}x{img_file.size[1]}, format: {input_format}")
if img.mode != 'RGB':
debug.print(f"Converting image from {img.mode} to RGB")
img = img.convert('RGB')
# Convert to RGB - this returns Image.Image, not ImageFile
img: Image.Image = 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)
@@ -338,12 +339,13 @@ def extract_from_image(
f"bits_per_channel must be 1 or 2, got {bits_per_channel}")
try:
img = Image.open(io.BytesIO(image_data))
debug.print(f"Image: {img.size[0]}x{img.size[1]}, format: {img.format}")
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}")
if img.mode != 'RGB':
debug.print(f"Converting image from {img.mode} to RGB")
img = img.convert('RGB')
# Convert to RGB
img: Image.Image = 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)
@@ -437,9 +439,8 @@ def calculate_capacity(image_data: bytes, bits_per_channel: int = 1) -> int:
debug.validate(bits_per_channel in (1, 2),
f"bits_per_channel must be 1 or 2, got {bits_per_channel}")
img = Image.open(io.BytesIO(image_data))
if img.mode != 'RGB':
img = img.convert('RGB')
img_file = Image.open(io.BytesIO(image_data))
img: Image.Image = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file
num_pixels = img.size[0] * img.size[1]
bits_per_pixel = 3 * bits_per_channel

View File

@@ -38,7 +38,7 @@ def generate_filename(
>>> generate_filename("2023-12-25", "secret_", "png")
"secret_a1b2c3d4_20231225.png"
"""
debug.validate(extension and '.' not in extension,
debug.validate(bool(extension) and '.' not in extension,
f"Extension must not contain dot, got '{extension}'")
if date_str is None:
@@ -284,13 +284,14 @@ def format_file_size(size_bytes: int) -> str:
"""
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_bytes < 1024:
if size < 1024:
if unit == 'B':
return f"{size_bytes} {unit}"
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.1f} TB"
return f"{int(size)} {unit}"
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"
def format_number(n: int) -> str: