Pinned the container, some other resiliancy stuff.
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
# Stegasoo Docker Image
|
# Stegasoo Docker Image
|
||||||
# Multi-stage build for smaller image size
|
# 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
|
# Set environment variables
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
# Suppress pip "running as root" warnings during build
|
||||||
|
ENV PIP_ROOT_USER_ACTION=ignore
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Usage:
|
|||||||
stegasoo generate [OPTIONS]
|
stegasoo generate [OPTIONS]
|
||||||
stegasoo encode [OPTIONS]
|
stegasoo encode [OPTIONS]
|
||||||
stegasoo decode [OPTIONS]
|
stegasoo decode [OPTIONS]
|
||||||
|
stegasoo verify [OPTIONS]
|
||||||
stegasoo info [OPTIONS]
|
stegasoo info [OPTIONS]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -28,6 +29,9 @@ from stegasoo import (
|
|||||||
DAY_NAMES, __version__,
|
DAY_NAMES, __version__,
|
||||||
StegasooError, DecryptionError, ExtractionError,
|
StegasooError, DecryptionError, ExtractionError,
|
||||||
FilePayload,
|
FilePayload,
|
||||||
|
# New in 2.2.1
|
||||||
|
will_fit,
|
||||||
|
strip_image_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
# QR Code utilities
|
# 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()
|
ref_photo = Path(ref).read_bytes()
|
||||||
carrier_image = Path(carrier).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(
|
result = encode(
|
||||||
message=payload,
|
message=payload,
|
||||||
reference_photo=ref_photo,
|
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:
|
except StegasooError as e:
|
||||||
raise click.ClickException(str(e))
|
raise click.ClickException(str(e))
|
||||||
|
except click.ClickException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise click.ClickException(f"Error: {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}")
|
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
|
# INFO COMMAND
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -473,6 +612,50 @@ def info(image):
|
|||||||
raise click.ClickException(str(e))
|
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
|
# MAIN
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
3
quick_web.sh
Executable file
3
quick_web.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd ./frontends/web/
|
||||||
|
python app.py
|
||||||
4
rbld_containers.sh
Executable file
4
rbld_containers.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
sudo docker-compose down
|
||||||
|
sudo docker-compose build
|
||||||
|
sudo docker-compose up -d
|
||||||
@@ -58,6 +58,16 @@ File Embedding:
|
|||||||
else:
|
else:
|
||||||
print(decoded.message)
|
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:
|
Debugging:
|
||||||
from stegasoo.debug import debug
|
from stegasoo.debug import debug
|
||||||
debug.enable(True) # Enable debug output
|
debug.enable(True) # Enable debug output
|
||||||
@@ -142,6 +152,8 @@ from .steganography import (
|
|||||||
get_image_format,
|
get_image_format,
|
||||||
is_lossless_format,
|
is_lossless_format,
|
||||||
LOSSLESS_FORMATS,
|
LOSSLESS_FORMATS,
|
||||||
|
# NEW in v2.2.1
|
||||||
|
will_fit,
|
||||||
)
|
)
|
||||||
from .utils import (
|
from .utils import (
|
||||||
generate_filename,
|
generate_filename,
|
||||||
@@ -152,6 +164,8 @@ from .utils import (
|
|||||||
secure_delete,
|
secure_delete,
|
||||||
SecureDeleter,
|
SecureDeleter,
|
||||||
format_file_size,
|
format_file_size,
|
||||||
|
# NEW in v2.2.1
|
||||||
|
strip_image_metadata,
|
||||||
)
|
)
|
||||||
from .debug import debug # Import debug utilities
|
from .debug import debug # Import debug utilities
|
||||||
|
|
||||||
@@ -177,6 +191,8 @@ from .batch import (
|
|||||||
BatchItem,
|
BatchItem,
|
||||||
BatchStatus,
|
BatchStatus,
|
||||||
batch_capacity_check,
|
batch_capacity_check,
|
||||||
|
# NEW in v2.2.1
|
||||||
|
BatchCredentials,
|
||||||
)
|
)
|
||||||
|
|
||||||
# QR Code utilities (optional, depends on qrcode and pyzbar)
|
# QR Code utilities (optional, depends on qrcode and pyzbar)
|
||||||
@@ -630,6 +646,7 @@ __all__ = [
|
|||||||
'get_image_dimensions',
|
'get_image_dimensions',
|
||||||
'get_image_format',
|
'get_image_format',
|
||||||
'is_lossless_format',
|
'is_lossless_format',
|
||||||
|
'will_fit', # NEW in v2.2.1
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
'generate_filename',
|
'generate_filename',
|
||||||
@@ -640,6 +657,7 @@ __all__ = [
|
|||||||
'secure_delete',
|
'secure_delete',
|
||||||
'SecureDeleter',
|
'SecureDeleter',
|
||||||
'format_file_size',
|
'format_file_size',
|
||||||
|
'strip_image_metadata', # NEW in v2.2.1
|
||||||
|
|
||||||
# Debugging
|
# Debugging
|
||||||
'debug',
|
'debug',
|
||||||
@@ -659,4 +677,5 @@ __all__ = [
|
|||||||
'BatchItem',
|
'BatchItem',
|
||||||
'BatchStatus',
|
'BatchStatus',
|
||||||
'batch_capacity_check',
|
'batch_capacity_check',
|
||||||
|
'BatchCredentials', # NEW in v2.2.1
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
@dataclass
|
||||||
class BatchResult:
|
class BatchResult:
|
||||||
"""Summary of a batch operation."""
|
"""Summary of a batch operation."""
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
|||||||
# VERSION
|
# VERSION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
__version__ = "2.2.0"
|
__version__ = "2.2.1"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# FILE FORMAT
|
# FILE FORMAT
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ LSB embedding and extraction with pseudo-random pixel selection.
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional, Tuple, List
|
from typing import Optional, Tuple, List, Union
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
|
||||||
from .models import EmbedStats
|
from .models import EmbedStats, FilePayload
|
||||||
from .exceptions import CapacityError, ExtractionError, EmbeddingError
|
from .exceptions import CapacityError, ExtractionError, EmbeddingError
|
||||||
from .debug import debug
|
from .debug import debug
|
||||||
|
|
||||||
@@ -35,6 +35,11 @@ EXT_TO_FORMAT = {
|
|||||||
'tif': 'TIFF',
|
'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]:
|
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'
|
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
|
@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]:
|
||||||
"""
|
"""
|
||||||
@@ -172,6 +277,8 @@ def embed_in_image(
|
|||||||
Uses pseudo-random pixel selection based on pixel_key to scatter
|
Uses pseudo-random pixel selection based on pixel_key to scatter
|
||||||
the data across the image, defeating statistical analysis.
|
the data across the image, defeating statistical analysis.
|
||||||
|
|
||||||
|
Note: Output images have all metadata (EXIF, etc.) stripped automatically.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
carrier_data: Carrier image bytes
|
carrier_data: Carrier image bytes
|
||||||
encrypted_data: Data to embed
|
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)")
|
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 = Image.new('RGB', img.size)
|
||||||
stego_img.putdata(new_pixels)
|
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
|
max_bytes = (num_pixels * bits_per_pixel) // 8
|
||||||
|
|
||||||
# Subtract overhead: 4 bytes length + ~100 bytes header
|
# 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")
|
debug.print(f"Image capacity: {capacity} bytes at {bits_per_channel} bit(s)/channel")
|
||||||
return capacity
|
return capacity
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Stegasoo Utilities
|
|||||||
Secure deletion, filename generation, and other helpers.
|
Secure deletion, filename generation, and other helpers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
@@ -12,10 +13,50 @@ from datetime import date, datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from .constants import DAY_NAMES
|
from .constants import DAY_NAMES
|
||||||
from .debug import debug
|
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(
|
def generate_filename(
|
||||||
date_str: Optional[str] = None,
|
date_str: Optional[str] = None,
|
||||||
prefix: str = "",
|
prefix: str = "",
|
||||||
|
|||||||
Reference in New Issue
Block a user