Version 3.1.0 now with experimental DCT support.

This commit is contained in:
Aaron D. Lee
2025-12-31 13:11:34 -05:00
parent e4a4a5e074
commit 4eefc946c4
10 changed files with 2520 additions and 299 deletions

View File

@@ -1,19 +1,20 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Stegasoo REST API Stegasoo REST API (v3.0)
FastAPI-based REST API for steganography operations. FastAPI-based REST API for steganography operations.
Supports both text messages and file embedding. Supports both text messages and file embedding.
NEW in v3.0: LSB and DCT embedding modes.
""" """
import io import io
import sys import sys
import base64 import base64
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, Literal
from datetime import date from datetime import date
from fastapi import FastAPI, HTTPException, UploadFile, File, Form from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Query
from fastapi.responses import Response, JSONResponse from fastapi.responses import Response, JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -30,6 +31,14 @@ from stegasoo import (
has_argon2, has_argon2,
FilePayload, FilePayload,
MAX_FILE_PAYLOAD_SIZE, MAX_FILE_PAYLOAD_SIZE,
# NEW in v3.0 - Embedding modes
EMBED_MODE_LSB,
EMBED_MODE_DCT,
EMBED_MODE_AUTO,
has_dct_support,
compare_modes,
will_fit_by_mode,
calculate_capacity_by_mode,
) )
from stegasoo.constants import ( from stegasoo.constants import (
MIN_PIN_LENGTH, MAX_PIN_LENGTH, MIN_PIN_LENGTH, MAX_PIN_LENGTH,
@@ -55,13 +64,30 @@ except ImportError:
app = FastAPI( app = FastAPI(
title="Stegasoo API", title="Stegasoo API",
description="Secure steganography with hybrid authentication. Supports text messages and file embedding.", description="""
Secure steganography with hybrid authentication. Supports text messages and file embedding.
## Embedding Modes (v3.0)
- **LSB mode** (default): Spatial LSB embedding, full color output, higher capacity
- **DCT mode**: Frequency domain embedding, grayscale output, ~20% capacity, better stealth
Use the `/modes` endpoint to check availability and `/compare` to compare capacities.
""",
version=__version__, version=__version__,
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
) )
# ============================================================================
# TYPE ALIASES
# ============================================================================
EmbedModeType = Literal["lsb", "dct"]
ExtractModeType = Literal["auto", "lsb", "dct"]
# ============================================================================ # ============================================================================
# MODELS # MODELS
# ============================================================================ # ============================================================================
@@ -90,6 +116,10 @@ class EncodeRequest(BaseModel):
rsa_key_base64: Optional[str] = None rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None rsa_password: Optional[str] = None
date_str: Optional[str] = None date_str: Optional[str] = None
embed_mode: EmbedModeType = Field(
default="lsb",
description="Embedding mode: 'lsb' (default, color) or 'dct' (grayscale, requires scipy)"
)
class EncodeFileRequest(BaseModel): class EncodeFileRequest(BaseModel):
@@ -104,6 +134,10 @@ class EncodeFileRequest(BaseModel):
rsa_key_base64: Optional[str] = None rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None rsa_password: Optional[str] = None
date_str: Optional[str] = None date_str: Optional[str] = None
embed_mode: EmbedModeType = Field(
default="lsb",
description="Embedding mode: 'lsb' (default, color) or 'dct' (grayscale, requires scipy)"
)
class EncodeResponse(BaseModel): class EncodeResponse(BaseModel):
@@ -112,6 +146,7 @@ class EncodeResponse(BaseModel):
capacity_used_percent: float capacity_used_percent: float
date_used: str date_used: str
day_of_week: str day_of_week: str
embed_mode: str = Field(description="Embedding mode used: 'lsb' or 'dct'")
class DecodeRequest(BaseModel): class DecodeRequest(BaseModel):
@@ -121,6 +156,10 @@ class DecodeRequest(BaseModel):
pin: str = "" pin: str = ""
rsa_key_base64: Optional[str] = None rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None rsa_password: Optional[str] = None
embed_mode: ExtractModeType = Field(
default="auto",
description="Extraction mode: 'auto' (default), 'lsb', or 'dct'"
)
class DecodeResponse(BaseModel): class DecodeResponse(BaseModel):
@@ -132,20 +171,60 @@ class DecodeResponse(BaseModel):
mime_type: Optional[str] = None # For file mime_type: Optional[str] = None # For file
class ModeCapacity(BaseModel):
"""Capacity info for a single mode."""
capacity_bytes: int
capacity_kb: float
available: bool
output_format: str
class ImageInfoResponse(BaseModel): class ImageInfoResponse(BaseModel):
width: int width: int
height: int height: int
pixels: int pixels: int
capacity_bytes: int capacity_bytes: int = Field(description="LSB mode capacity (for backwards compatibility)")
capacity_kb: int capacity_kb: int = Field(description="LSB mode capacity in KB")
# NEW in v3.0
modes: Optional[dict[str, ModeCapacity]] = Field(
default=None,
description="Capacity by embedding mode (v3.0+)"
)
class CompareModesRequest(BaseModel):
"""Request for comparing embedding modes."""
carrier_image_base64: str
payload_size: Optional[int] = Field(
default=None,
description="Optional payload size to check if it fits"
)
class CompareModesResponse(BaseModel):
"""Response comparing LSB and DCT modes."""
width: int
height: int
lsb: dict
dct: dict
payload_check: Optional[dict] = None
recommendation: str
class ModesResponse(BaseModel):
"""Response showing available embedding modes."""
lsb: dict
dct: dict
class StatusResponse(BaseModel): class StatusResponse(BaseModel):
version: str version: str
has_argon2: bool has_argon2: bool
has_qrcode_read: bool has_qrcode_read: bool
has_dct: bool # NEW in v3.0
day_names: list[str] day_names: list[str]
max_payload_kb: int max_payload_kb: int
available_modes: list[str] # NEW in v3.0
class QrExtractResponse(BaseModel): class QrExtractResponse(BaseModel):
@@ -154,27 +233,165 @@ class QrExtractResponse(BaseModel):
error: Optional[str] = None error: Optional[str] = None
class WillFitRequest(BaseModel):
"""Request to check if payload will fit."""
carrier_image_base64: str
payload_size: int
embed_mode: EmbedModeType = "lsb"
class WillFitResponse(BaseModel):
"""Response for will_fit check."""
fits: bool
payload_size: int
capacity: int
usage_percent: float
headroom: int
mode: str
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
error: str error: str
detail: Optional[str] = None detail: Optional[str] = None
# ============================================================================ # ============================================================================
# ROUTES # ROUTES - STATUS & INFO
# ============================================================================ # ============================================================================
@app.get("/", response_model=StatusResponse) @app.get("/", response_model=StatusResponse)
async def root(): async def root():
"""Get API status and configuration.""" """Get API status and configuration."""
available_modes = ["lsb"]
if has_dct_support():
available_modes.append("dct")
return StatusResponse( return StatusResponse(
version=__version__, version=__version__,
has_argon2=has_argon2(), has_argon2=has_argon2(),
has_qrcode_read=HAS_QR_READ, has_qrcode_read=HAS_QR_READ,
has_dct=has_dct_support(),
day_names=list(DAY_NAMES), day_names=list(DAY_NAMES),
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024 max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
available_modes=available_modes
) )
@app.get("/modes", response_model=ModesResponse)
async def api_modes():
"""
Get available embedding modes and their status.
NEW in v3.0: Shows LSB and DCT mode availability.
"""
return ModesResponse(
lsb={
"available": True,
"name": "Spatial LSB",
"description": "Embed in pixel LSBs, outputs PNG/BMP",
"output_format": "PNG (color)",
"capacity_ratio": "100%",
},
dct={
"available": has_dct_support(),
"name": "DCT Domain",
"description": "Embed in DCT coefficients, outputs grayscale PNG",
"output_format": "PNG (grayscale)",
"capacity_ratio": "~20% of LSB",
"requires": "scipy",
}
)
@app.post("/compare", response_model=CompareModesResponse)
async def api_compare_modes(request: CompareModesRequest):
"""
Compare LSB and DCT embedding modes for a carrier image.
NEW in v3.0: 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'],
lsb={
"capacity_bytes": comparison['lsb']['capacity_bytes'],
"capacity_kb": round(comparison['lsb']['capacity_kb'], 1),
"available": True,
"output_format": comparison['lsb']['output'],
},
dct={
"capacity_bytes": comparison['dct']['capacity_bytes'],
"capacity_kb": round(comparison['dct']['capacity_kb'], 1),
"available": comparison['dct']['available'],
"output_format": comparison['dct']['output'],
"ratio_vs_lsb_percent": round(comparison['dct']['ratio_vs_lsb'], 1),
},
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)"
elif fits_lsb:
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))
@app.post("/will-fit", response_model=WillFitResponse)
async def api_will_fit(request: WillFitRequest):
"""
Check if a payload of given size will fit in the carrier image.
NEW in v3.0: 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'],
capacity=result['capacity'],
usage_percent=round(result['usage_percent'], 1),
headroom=result['headroom'],
mode=request.embed_mode
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, str(e))
# ============================================================================
# ROUTES - QR CODE
# ============================================================================
@app.post("/extract-key-from-qr", response_model=QrExtractResponse) @app.post("/extract-key-from-qr", response_model=QrExtractResponse)
async def api_extract_key_from_qr( async def api_extract_key_from_qr(
qr_image: UploadFile = File(..., description="QR code image containing RSA key") qr_image: UploadFile = File(..., description="QR code image containing RSA key")
@@ -206,6 +423,10 @@ async def api_extract_key_from_qr(
return QrExtractResponse(success=False, error=str(e)) return QrExtractResponse(success=False, error=str(e))
# ============================================================================
# ROUTES - GENERATE
# ============================================================================
@app.post("/generate", response_model=GenerateResponse) @app.post("/generate", response_model=GenerateResponse)
async def api_generate(request: GenerateRequest): async def api_generate(request: GenerateRequest):
""" """
@@ -243,13 +464,23 @@ async def api_generate(request: GenerateRequest):
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
# ============================================================================
# ROUTES - ENCODE (JSON)
# ============================================================================
@app.post("/encode", response_model=EncodeResponse) @app.post("/encode", response_model=EncodeResponse)
async def api_encode(request: EncodeRequest): async def api_encode(request: EncodeRequest):
""" """
Encode a text message into an image. Encode a text message into an image.
Images must be base64-encoded. Returns base64-encoded stego image. Images must be base64-encoded. Returns base64-encoded stego image.
NEW in v3.0: Supports embed_mode parameter ('lsb' or 'dct').
""" """
# Validate mode
if request.embed_mode == "dct" and not has_dct_support():
raise HTTPException(400, "DCT mode requires scipy. Install with: pip install scipy")
try: try:
ref_photo = base64.b64decode(request.reference_photo_base64) ref_photo = base64.b64decode(request.reference_photo_base64)
carrier = base64.b64decode(request.carrier_image_base64) carrier = base64.b64decode(request.carrier_image_base64)
@@ -263,7 +494,8 @@ async def api_encode(request: EncodeRequest):
pin=request.pin, pin=request.pin,
rsa_key_data=rsa_key, rsa_key_data=rsa_key,
rsa_password=request.rsa_password, rsa_password=request.rsa_password,
date_str=request.date_str date_str=request.date_str,
embed_mode=request.embed_mode, # NEW in v3.0
) )
stego_b64 = base64.b64encode(result.stego_image).decode('utf-8') stego_b64 = base64.b64encode(result.stego_image).decode('utf-8')
@@ -274,7 +506,8 @@ async def api_encode(request: EncodeRequest):
filename=result.filename, filename=result.filename,
capacity_used_percent=result.capacity_percent, capacity_used_percent=result.capacity_percent,
date_used=result.date_used, date_used=result.date_used,
day_of_week=day_of_week day_of_week=day_of_week,
embed_mode=request.embed_mode,
) )
except CapacityError as e: except CapacityError as e:
@@ -291,7 +524,13 @@ async def api_encode_file(request: EncodeFileRequest):
Encode a file into an image (JSON with base64). Encode a file into an image (JSON with base64).
File data must be base64-encoded. File data must be base64-encoded.
NEW in v3.0: Supports embed_mode parameter ('lsb' or 'dct').
""" """
# Validate mode
if request.embed_mode == "dct" and not has_dct_support():
raise HTTPException(400, "DCT mode requires scipy. Install with: pip install scipy")
try: try:
file_data = base64.b64decode(request.file_data_base64) file_data = base64.b64decode(request.file_data_base64)
ref_photo = base64.b64decode(request.reference_photo_base64) ref_photo = base64.b64decode(request.reference_photo_base64)
@@ -312,7 +551,8 @@ async def api_encode_file(request: EncodeFileRequest):
pin=request.pin, pin=request.pin,
rsa_key_data=rsa_key, rsa_key_data=rsa_key,
rsa_password=request.rsa_password, rsa_password=request.rsa_password,
date_str=request.date_str date_str=request.date_str,
embed_mode=request.embed_mode, # NEW in v3.0
) )
stego_b64 = base64.b64encode(result.stego_image).decode('utf-8') stego_b64 = base64.b64encode(result.stego_image).decode('utf-8')
@@ -323,7 +563,8 @@ async def api_encode_file(request: EncodeFileRequest):
filename=result.filename, filename=result.filename,
capacity_used_percent=result.capacity_percent, capacity_used_percent=result.capacity_percent,
date_used=result.date_used, date_used=result.date_used,
day_of_week=day_of_week day_of_week=day_of_week,
embed_mode=request.embed_mode,
) )
except CapacityError as e: except CapacityError as e:
@@ -334,13 +575,24 @@ async def api_encode_file(request: EncodeFileRequest):
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
# ============================================================================
# ROUTES - DECODE (JSON)
# ============================================================================
@app.post("/decode", response_model=DecodeResponse) @app.post("/decode", response_model=DecodeResponse)
async def api_decode(request: DecodeRequest): async def api_decode(request: DecodeRequest):
""" """
Decode a message or file from a stego image. Decode a message or file from a stego image.
Returns payload_type to indicate if result is text or file. Returns payload_type to indicate if result is text or file.
NEW in v3.0: Supports embed_mode parameter ('auto', 'lsb', or 'dct').
With 'auto' (default), tries LSB first then DCT.
""" """
# Validate mode
if request.embed_mode == "dct" and not has_dct_support():
raise HTTPException(400, "DCT mode requires scipy. Install with: pip install scipy")
try: try:
stego = base64.b64decode(request.stego_image_base64) stego = base64.b64decode(request.stego_image_base64)
ref_photo = base64.b64decode(request.reference_photo_base64) ref_photo = base64.b64decode(request.reference_photo_base64)
@@ -352,7 +604,8 @@ async def api_decode(request: DecodeRequest):
day_phrase=request.day_phrase, day_phrase=request.day_phrase,
pin=request.pin, pin=request.pin,
rsa_key_data=rsa_key, rsa_key_data=rsa_key,
rsa_password=request.rsa_password rsa_password=request.rsa_password,
embed_mode=request.embed_mode, # NEW in v3.0
) )
if result.is_file: if result.is_file:
@@ -376,6 +629,10 @@ async def api_decode(request: DecodeRequest):
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
# ============================================================================
# ROUTES - ENCODE/DECODE (MULTIPART)
# ============================================================================
@app.post("/encode/multipart") @app.post("/encode/multipart")
async def api_encode_multipart( async def api_encode_multipart(
day_phrase: str = Form(...), day_phrase: str = Form(...),
@@ -387,7 +644,8 @@ async def api_encode_multipart(
rsa_key: Optional[UploadFile] = File(None), rsa_key: Optional[UploadFile] = File(None),
rsa_key_qr: Optional[UploadFile] = File(None), rsa_key_qr: Optional[UploadFile] = File(None),
rsa_password: str = Form(""), rsa_password: str = Form(""),
date_str: str = Form("") date_str: str = Form(""),
embed_mode: str = Form("lsb"), # NEW in v3.0
): ):
""" """
Encode using multipart form data (file uploads). Encode using multipart form data (file uploads).
@@ -395,7 +653,15 @@ async def api_encode_multipart(
Provide either 'message' (text) or 'payload_file' (binary file). 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). RSA key can be provided as 'rsa_key' (.pem file) or 'rsa_key_qr' (QR code image).
Returns the stego image directly as PNG with metadata headers. Returns the stego image directly as PNG with metadata headers.
NEW in v3.0: Supports embed_mode parameter ('lsb' or 'dct').
""" """
# Validate mode
if embed_mode not in ("lsb", "dct"):
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")
try: try:
ref_data = await reference_photo.read() ref_data = await reference_photo.read()
carrier_data = await carrier.read() carrier_data = await carrier.read()
@@ -443,7 +709,8 @@ async def api_encode_multipart(
pin=pin, pin=pin,
rsa_key_data=rsa_key_data, rsa_key_data=rsa_key_data,
rsa_password=effective_password, rsa_password=effective_password,
date_str=date_str if date_str else None date_str=date_str if date_str else None,
embed_mode=embed_mode, # NEW in v3.0
) )
day_of_week = get_day_from_date(result.date_used) day_of_week = get_day_from_date(result.date_used)
@@ -455,7 +722,8 @@ async def api_encode_multipart(
"Content-Disposition": f"attachment; filename={result.filename}", "Content-Disposition": f"attachment; filename={result.filename}",
"X-Stegasoo-Date": result.date_used, "X-Stegasoo-Date": result.date_used,
"X-Stegasoo-Day": day_of_week, "X-Stegasoo-Day": day_of_week,
"X-Stegasoo-Capacity-Percent": f"{result.capacity_percent:.1f}" "X-Stegasoo-Capacity-Percent": f"{result.capacity_percent:.1f}",
"X-Stegasoo-Embed-Mode": embed_mode, # NEW in v3.0
} }
) )
@@ -463,6 +731,8 @@ async def api_encode_multipart(
raise HTTPException(400, str(e)) raise HTTPException(400, str(e))
except StegasooError as e: except StegasooError as e:
raise HTTPException(400, str(e)) raise HTTPException(400, str(e))
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@@ -475,14 +745,23 @@ async def api_decode_multipart(
pin: str = Form(""), pin: str = Form(""),
rsa_key: Optional[UploadFile] = File(None), rsa_key: Optional[UploadFile] = File(None),
rsa_key_qr: Optional[UploadFile] = File(None), rsa_key_qr: Optional[UploadFile] = File(None),
rsa_password: str = Form("") rsa_password: str = Form(""),
embed_mode: str = Form("auto"), # NEW in v3.0
): ):
""" """
Decode using multipart form data (file uploads). Decode using multipart form data (file uploads).
RSA key can be provided as 'rsa_key' (.pem file) or 'rsa_key_qr' (QR code image). 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. Returns JSON with payload_type indicating text or file.
NEW in v3.0: Supports embed_mode parameter ('auto', 'lsb', or 'dct').
""" """
# Validate mode
if embed_mode not in ("auto", "lsb", "dct"):
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")
try: try:
ref_data = await reference_photo.read() ref_data = await reference_photo.read()
stego_data = await stego_image.read() stego_data = await stego_image.read()
@@ -515,7 +794,8 @@ async def api_decode_multipart(
day_phrase=day_phrase, day_phrase=day_phrase,
pin=pin, pin=pin,
rsa_key_data=rsa_key_data, rsa_key_data=rsa_key_data,
rsa_password=effective_password rsa_password=effective_password,
embed_mode=embed_mode, # NEW in v3.0
) )
if result.is_file: if result.is_file:
@@ -535,13 +815,26 @@ async def api_decode_multipart(
raise HTTPException(401, "Decryption failed. Check credentials.") raise HTTPException(401, "Decryption failed. Check credentials.")
except StegasooError as e: except StegasooError as e:
raise HTTPException(400, str(e)) raise HTTPException(400, str(e))
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
# ============================================================================
# ROUTES - IMAGE INFO
# ============================================================================
@app.post("/image/info", response_model=ImageInfoResponse) @app.post("/image/info", response_model=ImageInfoResponse)
async def api_image_info(image: UploadFile = File(...)): async def api_image_info(
"""Get information about an image's capacity.""" image: UploadFile = File(...),
include_modes: bool = Query(True, description="Include capacity by mode (v3.0+)")
):
"""
Get information about an image's capacity.
NEW in v3.0: Optionally includes capacity for both LSB and DCT modes.
"""
try: try:
image_data = await image.read() image_data = await image.read()
@@ -551,7 +844,7 @@ async def api_image_info(image: UploadFile = File(...)):
capacity = calculate_capacity(image_data) capacity = calculate_capacity(image_data)
return ImageInfoResponse( response = ImageInfoResponse(
width=result.details['width'], width=result.details['width'],
height=result.details['height'], height=result.details['height'],
pixels=result.details['pixels'], pixels=result.details['pixels'],
@@ -559,6 +852,26 @@ async def api_image_info(image: UploadFile = File(...)):
capacity_kb=capacity // 1024 capacity_kb=capacity // 1024
) )
# NEW in v3.0 - include mode comparison
if include_modes:
comparison = compare_modes(image_data)
response.modes = {
"lsb": ModeCapacity(
capacity_bytes=comparison['lsb']['capacity_bytes'],
capacity_kb=round(comparison['lsb']['capacity_kb'], 1),
available=True,
output_format=comparison['lsb']['output'],
),
"dct": ModeCapacity(
capacity_bytes=comparison['dct']['capacity_bytes'],
capacity_kb=round(comparison['dct']['capacity_kb'], 1),
available=comparison['dct']['available'],
output_format=comparison['dct']['output'],
),
}
return response
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@@ -8,6 +8,7 @@ Usage:
stegasoo decode [OPTIONS] stegasoo decode [OPTIONS]
stegasoo verify [OPTIONS] stegasoo verify [OPTIONS]
stegasoo info [OPTIONS] stegasoo info [OPTIONS]
stegasoo compare [OPTIONS] # NEW in v3.0
""" """
import sys import sys
@@ -29,9 +30,16 @@ from stegasoo import (
DAY_NAMES, __version__, DAY_NAMES, __version__,
StegasooError, DecryptionError, ExtractionError, StegasooError, DecryptionError, ExtractionError,
FilePayload, FilePayload,
# New in 2.2.1
will_fit, will_fit,
strip_image_metadata, strip_image_metadata,
# NEW in v3.0 - Embedding modes
EMBED_MODE_LSB,
EMBED_MODE_DCT,
EMBED_MODE_AUTO,
has_dct_support,
compare_modes,
will_fit_by_mode,
calculate_capacity_by_mode,
) )
# QR Code utilities # QR Code utilities
@@ -68,6 +76,11 @@ def cli():
• Reference photo (something you have) • Reference photo (something you have)
• Daily passphrase (something you know) • Daily passphrase (something you know)
• Static PIN or RSA key (additional security) • Static PIN or RSA key (additional security)
\b
NEW in v3.0 - Embedding Modes:
• LSB mode (default): Full color output, higher capacity
• DCT mode: Grayscale output, ~20% capacity, better stealth
""" """
pass pass
@@ -200,8 +213,10 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
@click.option('--key-password', help='RSA key password (for encrypted .pem files)') @click.option('--key-password', help='RSA key password (for encrypted .pem files)')
@click.option('--output', '-o', type=click.Path(), help='Output file (default: auto-generated)') @click.option('--output', '-o', type=click.Path(), help='Output file (default: auto-generated)')
@click.option('--date', 'date_str', help='Date override (YYYY-MM-DD)') @click.option('--date', 'date_str', help='Date override (YYYY-MM-DD)')
@click.option('--mode', 'embed_mode', type=click.Choice(['lsb', 'dct']), default='lsb',
help='Embedding mode: lsb (default, color) or dct (grayscale, requires scipy)')
@click.option('--quiet', '-q', is_flag=True, help='Suppress output except errors') @click.option('--quiet', '-q', is_flag=True, help='Suppress output except errors')
def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key, key_qr, key_password, output, date_str, quiet): def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key, key_qr, key_password, output, date_str, embed_mode, quiet):
""" """
Encode a secret message or file into an image. Encode a secret message or file into an image.
@@ -212,20 +227,37 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key
For binary files, use -e/--embed-file. For binary files, use -e/--embed-file.
RSA key can be provided as a .pem file (--key) or QR code image (--key-qr). RSA key can be provided as a .pem file (--key) or QR code image (--key-qr).
\b
Embedding Modes (v3.0):
--mode lsb Spatial LSB embedding (default)
• Full color output (PNG/BMP)
• Higher capacity (~375 KB/megapixel)
--mode dct DCT domain embedding (requires scipy)
• Grayscale output only
• Lower capacity (~75 KB/megapixel)
• Better resistance to visual analysis
\b \b
Examples: Examples:
# Text message with PIN # Text message with PIN (LSB mode, default)
stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -m "secret" stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -m "secret"
# DCT mode for better stealth
stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "secret" --mode dct
# With RSA key file # With RSA key file
stegasoo encode -r photo.jpg -c meme.png -p "words" -k mykey.pem -m "secret" stegasoo encode -r photo.jpg -c meme.png -p "words" -k mykey.pem -m "secret"
# With RSA key from QR code image
stegasoo encode -r photo.jpg -c meme.png -p "words" --key-qr keyqr.png -m "secret"
# Embed a binary file # Embed a binary file
stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -e secret.pdf stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -e secret.pdf
""" """
# Check DCT mode availability
if embed_mode == 'dct' and not has_dct_support():
raise click.ClickException(
"DCT mode requires scipy. Install with: pip install scipy"
)
# Determine what to encode # Determine what to encode
payload = None payload = None
@@ -277,16 +309,28 @@ 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 # Pre-check capacity with selected mode
fit_check = will_fit(payload, carrier_image) fit_check = will_fit_by_mode(payload, carrier_image, embed_mode=embed_mode)
if not fit_check['fits']: 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)"
raise click.ClickException( raise click.ClickException(
f"Payload too large for carrier image.\n" f"Payload too large for {embed_mode.upper()} mode.\n"
f" Payload: {fit_check['payload_size']:,} bytes\n" f" Payload: {fit_check['payload_size']:,} bytes\n"
f" Capacity: {fit_check['capacity']:,} bytes\n" f" Capacity: {fit_check['capacity']:,} bytes\n"
f" Shortfall: {-fit_check['headroom']:,} bytes" f" Shortfall: {-fit_check['headroom']:,} bytes"
f"{suggestion}"
) )
if not quiet:
click.echo(f"Mode: {embed_mode.upper()} ({fit_check['usage_percent']:.1f}% capacity)")
result = encode( result = encode(
message=payload, message=payload,
reference_photo=ref_photo, reference_photo=ref_photo,
@@ -296,6 +340,7 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key
rsa_key_data=rsa_key_data, rsa_key_data=rsa_key_data,
rsa_password=effective_key_password, rsa_password=effective_key_password,
date_str=date_str, date_str=date_str,
embed_mode=embed_mode, # NEW in v3.0
) )
# Determine output path # Determine output path
@@ -313,6 +358,8 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key
click.echo(f" Size: {len(result.stego_image):,} bytes") click.echo(f" Size: {len(result.stego_image):,} bytes")
click.echo(f" Capacity used: {result.capacity_percent:.1f}%") click.echo(f" Capacity used: {result.capacity_percent:.1f}%")
click.echo(f" Date: {result.date_used}") click.echo(f" Date: {result.date_used}")
if embed_mode == 'dct':
click.secho(f" Note: Output is grayscale (DCT mode)", dim=True)
except StegasooError as e: except StegasooError as e:
raise click.ClickException(str(e)) raise click.ClickException(str(e))
@@ -335,9 +382,11 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key
@click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image') @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('--key-password', help='RSA key password (for encrypted .pem files)')
@click.option('--output', '-o', type=click.Path(), help='Save decoded content to file') @click.option('--output', '-o', type=click.Path(), help='Save decoded content to file')
@click.option('--mode', 'embed_mode', type=click.Choice(['auto', 'lsb', 'dct']), default='auto',
help='Extraction mode: auto (default), lsb, or dct')
@click.option('--quiet', '-q', is_flag=True, help='Output only the content (for text) or suppress messages (for files)') @click.option('--quiet', '-q', is_flag=True, help='Output only the content (for text) or suppress messages (for files)')
@click.option('--force', is_flag=True, help='Overwrite existing output file') @click.option('--force', is_flag=True, help='Overwrite existing output file')
def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, quiet, force): def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed_mode, quiet, force):
""" """
Decode a secret message or file from a stego image. Decode a secret message or file from a stego image.
@@ -345,20 +394,32 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, quiet
Automatically detects whether content is text or a file. Automatically detects whether content is text or a file.
RSA key can be provided as a .pem file (--key) or QR code image (--key-qr). RSA key can be provided as a .pem file (--key) or QR code image (--key-qr).
\b
Extraction Modes (v3.0):
--mode auto Auto-detect (default) - tries LSB first, then DCT
--mode lsb Only try LSB extraction
--mode dct Only try DCT extraction (requires scipy)
\b \b
Examples: Examples:
# Decode with PIN # Decode with PIN (auto-detect mode)
stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456 stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456
# Explicitly specify DCT mode
stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 --mode dct
# Decode with RSA key file # Decode with RSA key file
stegasoo decode -r photo.jpg -s stego.png -p "words" -k mykey.pem stegasoo decode -r photo.jpg -s stego.png -p "words" -k mykey.pem
# Decode with RSA key from QR code image
stegasoo decode -r photo.jpg -s stego.png -p "words" --key-qr keyqr.png
# Save output to file # Save output to file
stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -o output.txt stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -o output.txt
""" """
# Check DCT mode availability
if embed_mode == 'dct' and not has_dct_support():
raise click.ClickException(
"DCT mode requires scipy. Install with: pip install scipy"
)
# Load key if provided (from .pem file or QR code image) # Load key if provided (from .pem file or QR code image)
rsa_key_data = None rsa_key_data = None
rsa_key_from_qr = False rsa_key_from_qr = False
@@ -400,6 +461,7 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, quiet
pin=pin or "", pin=pin or "",
rsa_key_data=rsa_key_data, rsa_key_data=rsa_key_data,
rsa_password=effective_key_password, rsa_password=effective_key_password,
embed_mode=embed_mode, # NEW in v3.0
) )
if result.is_file: if result.is_file:
@@ -459,8 +521,10 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, quiet
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)') @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-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('--key-password', help='RSA key password (for encrypted .pem files)')
@click.option('--mode', 'embed_mode', type=click.Choice(['auto', 'lsb', 'dct']), default='auto',
help='Extraction mode: auto (default), lsb, or dct')
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON') @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): def verify(ref, stego, phrase, pin, key, key_qr, key_password, embed_mode, as_json):
""" """
Verify that a stego image can be decoded without extracting the message. Verify that a stego image can be decoded without extracting the message.
@@ -472,7 +536,15 @@ def verify(ref, stego, phrase, pin, key, key_qr, key_password, as_json):
stegasoo verify -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456 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 stegasoo verify -r photo.jpg -s stego.png -p "words" -k mykey.pem --json
stegasoo verify -r photo.jpg -s stego.png -p "words" --pin 123456 --mode dct
""" """
# Check DCT mode availability
if embed_mode == 'dct' and not has_dct_support():
raise click.ClickException(
"DCT mode requires scipy. Install with: pip install scipy"
)
# Load key if provided # Load key if provided
rsa_key_data = None rsa_key_data = None
rsa_key_from_qr = False rsa_key_from_qr = False
@@ -511,6 +583,7 @@ def verify(ref, stego, phrase, pin, key, key_qr, key_password, as_json):
pin=pin or "", pin=pin or "",
rsa_key_data=rsa_key_data, rsa_key_data=rsa_key_data,
rsa_password=effective_key_password, rsa_password=effective_key_password,
embed_mode=embed_mode, # NEW in v3.0
) )
# Calculate payload size # Calculate payload size
@@ -576,11 +649,13 @@ def verify(ref, stego, phrase, pin, key, key_qr, key_password, as_json):
@cli.command() @cli.command()
@click.argument('image', type=click.Path(exists=True)) @click.argument('image', type=click.Path(exists=True))
def info(image): @click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
def info(image, as_json):
""" """
Show information about an image. Show information about an image.
Displays dimensions, capacity, and attempts to detect date from filename. Displays dimensions, capacity for both LSB and DCT modes,
and attempts to detect date from filename.
""" """
try: try:
image_data = Path(image).read_bytes() image_data = Path(image).read_bytes()
@@ -589,21 +664,58 @@ def info(image):
if not result.is_valid: if not result.is_valid:
raise click.ClickException(result.error_message) raise click.ClickException(result.error_message)
capacity = calculate_capacity(image_data) # Get capacity comparison
comparison = compare_modes(image_data)
# Try to get date from filename # Try to get date from filename
date_str = parse_date_from_filename(image) date_str = parse_date_from_filename(image)
day_name = get_day_from_date(date_str) if date_str else None day_name = get_day_from_date(date_str) if date_str else None
if as_json:
import json
output = {
"file": image,
"width": result.details['width'],
"height": result.details['height'],
"pixels": result.details['pixels'],
"mode": result.details['mode'],
"format": result.details['format'],
"capacity": {
"lsb": {
"bytes": comparison['lsb']['capacity_bytes'],
"kb": round(comparison['lsb']['capacity_kb'], 1),
},
"dct": {
"bytes": comparison['dct']['capacity_bytes'],
"kb": round(comparison['dct']['capacity_kb'], 1),
"available": comparison['dct']['available'],
"ratio_vs_lsb": round(comparison['dct']['ratio_vs_lsb'], 1),
},
},
}
if date_str:
output["embed_date"] = date_str
output["embed_day"] = day_name
click.echo(json.dumps(output, indent=2))
return
click.echo() click.echo()
click.secho(f"Image: {image}", bold=True) click.secho(f"Image: {image}", bold=True)
click.echo(f" Dimensions: {result.details['width']} × {result.details['height']}") click.echo(f" Dimensions: {result.details['width']} × {result.details['height']}")
click.echo(f" Pixels: {result.details['pixels']:,}") click.echo(f" Pixels: {result.details['pixels']:,}")
click.echo(f" Mode: {result.details['mode']}") click.echo(f" Mode: {result.details['mode']}")
click.echo(f" Format: {result.details['format']}") click.echo(f" Format: {result.details['format']}")
click.echo(f" Capacity: ~{capacity:,} bytes ({capacity // 1024} KB)") click.echo()
click.secho(" Capacity:", bold=True)
click.echo(f" LSB mode: ~{comparison['lsb']['capacity_bytes']:,} bytes ({comparison['lsb']['capacity_kb']:.1f} KB)")
dct_status = "" if comparison['dct']['available'] else "✗ (scipy not installed)"
click.echo(f" DCT mode: ~{comparison['dct']['capacity_bytes']:,} bytes ({comparison['dct']['capacity_kb']:.1f} KB) {dct_status}")
click.echo(f" DCT ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB")
if date_str: if date_str:
click.echo()
click.echo(f" Embed date: {date_str} ({day_name})") click.echo(f" Embed date: {date_str} ({day_name})")
click.echo() click.echo()
@@ -612,6 +724,127 @@ def info(image):
raise click.ClickException(str(e)) raise click.ClickException(str(e))
# ============================================================================
# COMPARE COMMAND (NEW in v3.0)
# ============================================================================
@cli.command()
@click.argument('image', type=click.Path(exists=True))
@click.option('--payload-size', '-s', type=int, help='Check if specific payload size fits')
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
def compare(image, payload_size, as_json):
"""
Compare LSB and DCT embedding modes for an image.
Shows capacity for each mode and recommends which to use.
Optionally checks if a specific payload size would fit.
\b
Examples:
stegasoo compare carrier.png
stegasoo compare carrier.png --payload-size 50000
stegasoo compare carrier.png --json
"""
try:
image_data = Path(image).read_bytes()
comparison = compare_modes(image_data)
if as_json:
import json
output = {
"file": image,
"width": comparison['width'],
"height": comparison['height'],
"modes": {
"lsb": {
"capacity_bytes": comparison['lsb']['capacity_bytes'],
"capacity_kb": round(comparison['lsb']['capacity_kb'], 1),
"available": True,
"output_format": comparison['lsb']['output'],
},
"dct": {
"capacity_bytes": comparison['dct']['capacity_bytes'],
"capacity_kb": round(comparison['dct']['capacity_kb'], 1),
"available": comparison['dct']['available'],
"output_format": comparison['dct']['output'],
"ratio_vs_lsb_percent": round(comparison['dct']['ratio_vs_lsb'], 1),
},
},
}
if payload_size:
output["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, 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("")
# 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" │ Output: {comparison['dct']['output']}")
click.echo(f" │ Ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB capacity")
if comparison['dct']['available']:
click.echo(f" │ Status: ✓ Available")
else:
click.secho(f" │ 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.secho(f"{lsb_icon} {'Fits' if fits_lsb else 'Too large'}", fg=lsb_color)
click.echo(f" │ 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']:
click.echo(" Use LSB mode (DCT unavailable)")
elif payload_size:
if fits_dct:
click.echo(" DCT mode for better stealth (payload fits both modes)")
elif fits_lsb:
click.echo(" LSB mode (payload too large for DCT)")
else:
click.secho(" ✗ Payload too large for both modes!", fg='red')
else:
click.echo(" LSB for larger payloads, DCT for better stealth")
click.echo()
except Exception as e:
raise click.ClickException(str(e))
# ============================================================================ # ============================================================================
# STRIP-METADATA COMMAND # STRIP-METADATA COMMAND
# ============================================================================ # ============================================================================
@@ -656,6 +889,48 @@ def strip_metadata_cmd(image, output, output_format, quiet):
raise click.ClickException(str(e)) raise click.ClickException(str(e))
# ============================================================================
# MODES COMMAND (NEW in v3.0)
# ============================================================================
@cli.command()
def modes():
"""
Show available embedding modes and their status.
Displays which modes are available and their characteristics.
"""
click.echo()
click.secho("═══ Stegasoo Embedding Modes ═══", fg='cyan', bold=True)
click.echo()
# LSB Mode
click.secho(" LSB Mode (Spatial LSB)", fg='green', bold=True)
click.echo(" Status: ✓ Always available")
click.echo(" Output: PNG/BMP (full color)")
click.echo(" Capacity: ~375 KB per megapixel")
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():
click.echo(" Status: ✓ Available")
else:
click.secho(" Status: ✗ Requires scipy", fg='yellow')
click.echo(" Install: pip install scipy")
click.echo(" Output: PNG (grayscale only)")
click.echo(" Capacity: ~75 KB per megapixel (~20% of LSB)")
click.echo(" Use case: Better stealth, smaller messages")
click.echo(" CLI flag: --mode dct")
click.echo()
click.secho(" Tip:", dim=True)
click.echo(" Use 'stegasoo compare <image>' to see capacity for both modes")
click.echo()
# ============================================================================ # ============================================================================
# MAIN # MAIN
# ============================================================================ # ============================================================================

View File

@@ -1,9 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Stegasoo Web Frontend Stegasoo Web Frontend (v3.0.1)
Flask-based web UI for steganography operations. Flask-based web UI for steganography operations.
Supports both text messages and file embedding. Supports both text messages and file embedding.
NEW in v3.0: LSB and DCT embedding modes with advanced options.
NEW in v3.0.1: DCT output format selection (PNG or JPEG).
""" """
import io import io
@@ -35,6 +37,13 @@ from stegasoo import (
StegasooError, DecryptionError, CapacityError, StegasooError, DecryptionError, CapacityError,
has_argon2, has_argon2,
FilePayload, FilePayload,
# NEW in v3.0 - Embedding modes
EMBED_MODE_LSB,
EMBED_MODE_DCT,
EMBED_MODE_AUTO,
has_dct_support,
compare_modes,
will_fit_by_mode,
) )
from stegasoo.constants import ( from stegasoo.constants import (
__version__, __version__,
@@ -102,6 +111,8 @@ def inject_globals():
'temp_file_expiry_minutes': TEMP_FILE_EXPIRY_MINUTES, 'temp_file_expiry_minutes': TEMP_FILE_EXPIRY_MINUTES,
'min_pin_length': MIN_PIN_LENGTH, 'min_pin_length': MIN_PIN_LENGTH,
'max_pin_length': MAX_PIN_LENGTH, 'max_pin_length': MAX_PIN_LENGTH,
# NEW in v3.0
'has_dct': has_dct_support(),
} }
@@ -114,6 +125,7 @@ try:
# Check current limits # Check current limits
print(f"Current MAX_FILE_SIZE from constants: {MAX_FILE_SIZE}") print(f"Current MAX_FILE_SIZE from constants: {MAX_FILE_SIZE}")
print(f"Current MAX_FILE_PAYLOAD_SIZE: {MAX_FILE_PAYLOAD_SIZE}") print(f"Current MAX_FILE_PAYLOAD_SIZE: {MAX_FILE_PAYLOAD_SIZE}")
print(f"DCT support available: {has_dct_support()}")
DESIRED_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2MB DESIRED_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2MB
@@ -131,7 +143,7 @@ def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes
"""Generate thumbnail from image data.""" """Generate thumbnail from image data."""
try: try:
with Image.open(io.BytesIO(image_data)) as img: with Image.open(io.BytesIO(image_data)) as img:
# Convert to RGB if necessary # Convert to RGB if necessary (handle grayscale too)
if img.mode in ('RGBA', 'LA', 'P'): if img.mode in ('RGBA', 'LA', 'P'):
# Create white background for transparent images # Create white background for transparent images
background = Image.new('RGB', img.size, (255, 255, 255)) background = Image.new('RGB', img.size, (255, 255, 255))
@@ -139,6 +151,9 @@ def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes
img = img.convert('RGBA') img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
img = background img = background
elif img.mode == 'L':
# Convert grayscale to RGB for thumbnail
img = img.convert('RGB')
elif img.mode != 'RGB': elif img.mode != 'RGB':
img = img.convert('RGB') img = img.convert('RGB')
@@ -401,6 +416,85 @@ def extract_key_from_qr_route():
}), 500 }), 500
# ============================================================================
# NEW in v3.0 - CAPACITY COMPARISON API
# ============================================================================
@app.route('/api/compare-capacity', methods=['POST'])
def api_compare_capacity():
"""
Compare LSB and DCT capacity for an uploaded carrier image.
Returns JSON with capacity info for both modes.
"""
carrier = request.files.get('carrier')
if not carrier:
return jsonify({'error': 'No carrier image provided'}), 400
try:
carrier_data = carrier.read()
comparison = compare_modes(carrier_data)
return jsonify({
'success': True,
'width': comparison['width'],
'height': comparison['height'],
'lsb': {
'capacity_bytes': comparison['lsb']['capacity_bytes'],
'capacity_kb': round(comparison['lsb']['capacity_kb'], 1),
'output': comparison['lsb']['output'],
},
'dct': {
'capacity_bytes': comparison['dct']['capacity_bytes'],
'capacity_kb': round(comparison['dct']['capacity_kb'], 1),
'output': comparison['dct']['output'],
'available': comparison['dct']['available'],
'ratio': round(comparison['dct']['ratio_vs_lsb'], 1),
}
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/check-fit', methods=['POST'])
def api_check_fit():
"""
Check if a payload will fit in the carrier with selected mode.
Returns JSON with fit status and details.
"""
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()
result = will_fit_by_mode(payload_size, carrier_data, embed_mode=embed_mode)
return jsonify({
'success': True,
'fits': result['fits'],
'payload_size': result['payload_size'],
'capacity': result['capacity'],
'usage_percent': round(result['usage_percent'], 1),
'headroom': result['headroom'],
'mode': embed_mode,
})
except Exception as e:
return jsonify({'error': str(e)}), 500
# ============================================================================
# ENCODE
# ============================================================================
@app.route('/encode', methods=['GET', 'POST']) @app.route('/encode', methods=['GET', 'POST'])
def encode_page(): def encode_page():
day_of_week = get_today_day() day_of_week = get_today_day()
@@ -428,6 +522,21 @@ def encode_page():
rsa_password = request.form.get('rsa_password', '') rsa_password = request.form.get('rsa_password', '')
payload_type = request.form.get('payload_type', 'text') 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'
# 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', day_of_week=day_of_week, has_qrcode_read=HAS_QRCODE_READ)
# Determine payload # Determine payload
if payload_type == 'file' and payload_file and payload_file.filename: if payload_type == 'file' and payload_file and payload_file.filename:
# File payload # File payload
@@ -515,7 +624,7 @@ def encode_page():
else: else:
date_str = datetime.now().strftime('%Y-%m-%d') date_str = datetime.now().strftime('%Y-%m-%d')
# Encode # Encode with selected mode and output format
encode_result = encode( encode_result = encode(
message=payload, message=payload,
reference_photo=ref_data, reference_photo=ref_data,
@@ -524,16 +633,34 @@ def encode_page():
pin=pin, pin=pin,
rsa_key_data=rsa_key_data, rsa_key_data=rsa_key_data,
rsa_password=key_password, rsa_password=key_password,
date_str=date_str date_str=date_str,
embed_mode=embed_mode, # NEW in v3.0
dct_output_format=dct_output_format if embed_mode == 'dct' else None, # NEW in v3.0.1
) )
# Determine actual output format for filename and storage
if embed_mode == 'dct' and dct_output_format == 'jpeg':
output_ext = '.jpg'
output_mime = 'image/jpeg'
# Modify filename extension if needed
filename = encode_result.filename
if filename.endswith('.png'):
filename = filename[:-4] + '.jpg'
else:
output_ext = '.png'
output_mime = 'image/png'
filename = encode_result.filename
# Store temporarily # Store temporarily
file_id = secrets.token_urlsafe(16) file_id = secrets.token_urlsafe(16)
cleanup_temp_files() cleanup_temp_files()
TEMP_FILES[file_id] = { TEMP_FILES[file_id] = {
'data': encode_result.stego_image, 'data': encode_result.stego_image,
'filename': encode_result.filename, 'filename': filename,
'timestamp': time.time() 'timestamp': time.time(),
'embed_mode': embed_mode,
'output_format': dct_output_format if embed_mode == 'dct' else 'png',
'mime_type': output_mime,
} }
return redirect(url_for('encode_result', file_id=file_id)) return redirect(url_for('encode_result', file_id=file_id))
@@ -570,7 +697,9 @@ def encode_result(file_id):
return render_template('encode_result.html', return render_template('encode_result.html',
file_id=file_id, file_id=file_id,
filename=file_info['filename'], filename=file_info['filename'],
thumbnail_url=url_for('encode_thumbnail', thumb_id=thumbnail_id) if thumbnail_id else None thumbnail_url=url_for('encode_thumbnail', thumb_id=thumbnail_id) if thumbnail_id else None,
embed_mode=file_info.get('embed_mode', 'lsb'),
output_format=file_info.get('output_format', 'png'), # NEW in v3.0.1
) )
@@ -594,9 +723,11 @@ def encode_download(file_id):
return redirect(url_for('encode_page')) return redirect(url_for('encode_page'))
file_info = TEMP_FILES[file_id] file_info = TEMP_FILES[file_id]
mime_type = file_info.get('mime_type', 'image/png')
return send_file( return send_file(
io.BytesIO(file_info['data']), io.BytesIO(file_info['data']),
mimetype='image/png', mimetype=mime_type,
as_attachment=True, as_attachment=True,
download_name=file_info['filename'] download_name=file_info['filename']
) )
@@ -609,9 +740,11 @@ def encode_file_route(file_id):
return "Not found", 404 return "Not found", 404
file_info = TEMP_FILES[file_id] file_info = TEMP_FILES[file_id]
mime_type = file_info.get('mime_type', 'image/png')
return send_file( return send_file(
io.BytesIO(file_info['data']), io.BytesIO(file_info['data']),
mimetype='image/png', mimetype=mime_type,
as_attachment=False, as_attachment=False,
download_name=file_info['filename'] download_name=file_info['filename']
) )
@@ -629,6 +762,10 @@ def encode_cleanup(file_id):
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
# ============================================================================
# DECODE
# ============================================================================
@app.route('/decode', methods=['GET', 'POST']) @app.route('/decode', methods=['GET', 'POST'])
def decode_page(): def decode_page():
if request.method == 'POST': if request.method == 'POST':
@@ -647,6 +784,16 @@ def decode_page():
pin = request.form.get('pin', '').strip() pin = request.form.get('pin', '').strip()
rsa_password = request.form.get('rsa_password', '') 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'
# 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)
# Get encoding date from form (detected from filename in JS) # Get encoding date from form (detected from filename in JS)
stego_date = request.form.get('stego_date', '').strip() stego_date = request.form.get('stego_date', '').strip()
@@ -700,7 +847,7 @@ def decode_page():
flash(result.error_message, 'error') flash(result.error_message, 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Decode # Decode with selected mode
decode_result = decode( decode_result = decode(
stego_image=stego_data, stego_image=stego_data,
reference_photo=ref_data, reference_photo=ref_data,
@@ -708,7 +855,8 @@ def decode_page():
pin=pin, pin=pin,
rsa_key_data=rsa_key_data, rsa_key_data=rsa_key_data,
rsa_password=key_password, rsa_password=key_password,
date_str=stego_date if stego_date else None date_str=stego_date if stego_date else None,
embed_mode=embed_mode, # NEW in v3.0
) )
if decode_result.is_file: if decode_result.is_file:

View File

@@ -181,6 +181,78 @@
</div> </div>
</div> </div>
<!-- ================================================================
ADVANCED OPTIONS (v3.0) - Extraction Mode
================================================================ -->
<div class="mb-4">
<a class="btn btn-sm btn-outline-secondary w-100" data-bs-toggle="collapse" href="#advancedOptionsDec" role="button" aria-expanded="false">
<i class="bi bi-gear me-1"></i> Advanced Options
<i class="bi bi-chevron-down ms-1" id="advancedChevronDec"></i>
</a>
<div class="collapse" id="advancedOptionsDec">
<div class="card card-body mt-2 bg-dark border-secondary">
<!-- Extraction Mode Selection -->
<div class="mb-0">
<label class="form-label">
<i class="bi bi-cpu me-1"></i> Extraction Mode
<span class="badge bg-info ms-1">v3.0</span>
</label>
<div class="row g-2">
<!-- Auto Mode -->
<div class="col-4">
<div class="form-check card p-2 text-center h-100" id="autoModeCard">
<input class="form-check-input mx-auto" type="radio" name="embed_mode" id="modeAuto" value="auto" checked>
<label class="form-check-label w-100" for="modeAuto">
<i class="bi bi-magic text-success fs-4 d-block mb-1"></i>
<strong>Auto</strong>
<div class="small text-muted">Try both</div>
</label>
</div>
</div>
<!-- LSB Mode -->
<div class="col-4">
<div class="form-check card p-2 text-center h-100" id="lsbModeCardDec">
<input class="form-check-input mx-auto" type="radio" name="embed_mode" id="modeLsbDec" value="lsb">
<label class="form-check-label w-100" for="modeLsbDec">
<i class="bi bi-grid-3x3-gap text-primary fs-4 d-block mb-1"></i>
<strong>LSB</strong>
<div class="small text-muted">Spatial only</div>
</label>
</div>
</div>
<!-- DCT Mode -->
<div class="col-4">
<div class="form-check card p-2 text-center h-100 {% if not has_dct %}opacity-50{% endif %}" id="dctModeCardDec">
<input class="form-check-input mx-auto" type="radio" name="embed_mode" id="modeDctDec" value="dct" {% if not has_dct %}disabled{% endif %}>
<label class="form-check-label w-100" for="modeDctDec">
<i class="bi bi-soundwave text-info fs-4 d-block mb-1"></i>
<strong>DCT</strong>
<div class="small text-muted">
{% if has_dct %}Frequency only{% else %}N/A{% endif %}
</div>
</label>
</div>
</div>
</div>
<div class="form-text mt-2">
<i class="bi bi-lightbulb me-1"></i>
<strong>Auto</strong> tries LSB first, then DCT. Use specific mode if you know how it was encoded.
{% if not has_dct %}
<br><span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>DCT requires scipy: <code>pip install scipy</code></span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn"> <button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn">
<i class="bi bi-unlock me-2"></i>Decode <i class="bi bi-unlock me-2"></i>Decode
</button> </button>
@@ -211,10 +283,14 @@
<i class="bi bi-dot"></i> <i class="bi bi-dot"></i>
Ensure the stego image hasn't been <strong>resized or recompressed</strong> Ensure the stego image hasn't been <strong>resized or recompressed</strong>
</li> </li>
<li class="mb-0"> <li class="mb-2">
<i class="bi bi-dot"></i> <i class="bi bi-dot"></i>
If using an RSA key, make sure the <strong>password is correct</strong> If using an RSA key, make sure the <strong>password is correct</strong>
</li> </li>
<li class="mb-0">
<i class="bi bi-dot"></i>
<strong>v3.0:</strong> If auto-detection fails, try specifying <strong>LSB or DCT mode</strong> in Advanced Options
</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -228,7 +304,8 @@
// Form submit loading state // Form submit loading state
document.getElementById('decodeForm')?.addEventListener('submit', function() { document.getElementById('decodeForm')?.addEventListener('submit', function() {
const btn = document.getElementById('decodeBtn'); const btn = document.getElementById('decodeBtn');
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Decoding...'; const selectedMode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'auto';
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Decoding (${selectedMode.toUpperCase()})...`;
btn.disabled = true; btn.disabled = true;
}); });
@@ -323,6 +400,38 @@ document.getElementById('togglePin')?.addEventListener('click', function() {
} }
}); });
// Mode card highlighting
const autoModeCard = document.getElementById('autoModeCard');
const lsbModeCardDec = document.getElementById('lsbModeCardDec');
const dctModeCardDec = document.getElementById('dctModeCardDec');
const modeAuto = document.getElementById('modeAuto');
const modeLsbDec = document.getElementById('modeLsbDec');
const modeDctDec = document.getElementById('modeDctDec');
function updateModeCardHighlightDec() {
autoModeCard?.classList.toggle('border-success', modeAuto?.checked);
autoModeCard?.classList.toggle('border-2', modeAuto?.checked);
lsbModeCardDec?.classList.toggle('border-primary', modeLsbDec?.checked);
lsbModeCardDec?.classList.toggle('border-2', modeLsbDec?.checked);
dctModeCardDec?.classList.toggle('border-info', modeDctDec?.checked);
dctModeCardDec?.classList.toggle('border-2', modeDctDec?.checked);
}
modeAuto?.addEventListener('change', updateModeCardHighlightDec);
modeLsbDec?.addEventListener('change', updateModeCardHighlightDec);
modeDctDec?.addEventListener('change', updateModeCardHighlightDec);
updateModeCardHighlightDec(); // Initial state
// Advanced options chevron rotation
document.getElementById('advancedOptionsDec')?.addEventListener('show.bs.collapse', function() {
document.getElementById('advancedChevronDec').classList.add('bi-chevron-up');
document.getElementById('advancedChevronDec').classList.remove('bi-chevron-down');
});
document.getElementById('advancedOptionsDec')?.addEventListener('hide.bs.collapse', function() {
document.getElementById('advancedChevronDec').classList.remove('bi-chevron-up');
document.getElementById('advancedChevronDec').classList.add('bi-chevron-down');
});
// Paste from Clipboard // Paste from Clipboard
document.addEventListener('paste', function(e) { document.addEventListener('paste', function(e) {
if (!document.getElementById('decodeForm')) return; if (!document.getElementById('decodeForm')) return;

View File

@@ -36,7 +36,7 @@
<i class="bi bi-file-image me-1"></i> Carrier Image <i class="bi bi-file-image me-1"></i> Carrier Image
</label> </label>
<div class="drop-zone" id="carrierDropZone"> <div class="drop-zone" id="carrierDropZone">
<input type="file" name="carrier" accept="image/*" required> <input type="file" name="carrier" accept="image/*" required id="carrierInput">
<div class="drop-zone-label"> <div class="drop-zone-label">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i> <i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop image or click to browse</span> <span class="text-muted">Drop image or click to browse</span>
@@ -49,6 +49,20 @@
</div> </div>
</div> </div>
<!-- Capacity Info Panel (shown when carrier loaded) -->
<div class="alert alert-info small d-none" id="capacityPanel">
<div class="row align-items-center">
<div class="col">
<i class="bi bi-rulers me-1"></i>
<strong>Carrier:</strong> <span id="carrierDimensions">-</span>
</div>
<div class="col-auto">
<span class="badge bg-primary me-1" id="lsbCapacityBadge">LSB: -</span>
<span class="badge bg-secondary" id="dctCapacityBadge">DCT: -</span>
</div>
</div>
</div>
<!-- Payload Type Selector --> <!-- Payload Type Selector -->
<div class="mb-3"> <div class="mb-3">
<label class="form-label"> <label class="form-label">
@@ -179,6 +193,141 @@
</div> </div>
</div> </div>
<!-- ================================================================
ADVANCED OPTIONS (v3.0) - Collapsible Section
================================================================ -->
<div class="mb-4">
<a class="btn btn-sm btn-outline-secondary w-100" data-bs-toggle="collapse" href="#advancedOptions" role="button" aria-expanded="false">
<i class="bi bi-gear me-1"></i> Advanced Options
<i class="bi bi-chevron-down ms-1" id="advancedChevron"></i>
</a>
<div class="collapse" id="advancedOptions">
<div class="card card-body mt-2 bg-dark border-secondary">
<!-- Embedding Mode Selection -->
<div class="mb-3">
<label class="form-label">
<i class="bi bi-cpu me-1"></i> Embedding Mode
<span class="badge bg-info ms-1">v3.0</span>
</label>
<div class="row g-2">
<!-- LSB Mode Card -->
<div class="col-md-6">
<div class="form-check card p-3 h-100 border-primary border-2" id="lsbModeCard">
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsb" value="lsb" checked>
<label class="form-check-label w-100" for="modeLsb">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-grid-3x3-gap text-primary fs-4 me-2"></i>
<strong>LSB Mode</strong>
<span class="badge bg-success ms-auto">Default</span>
</div>
<ul class="small text-muted mb-0 ps-3">
<li>Full color PNG output</li>
<li>Higher capacity (~375 KB/MP)</li>
<li>Faster processing</li>
</ul>
</label>
</div>
</div>
<!-- DCT Mode Card -->
<div class="col-md-6">
<div class="form-check card p-3 h-100 {% if not has_dct %}opacity-50{% endif %}" id="dctModeCard">
<input class="form-check-input" type="radio" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
<label class="form-check-label w-100" for="modeDct">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-soundwave text-info fs-4 me-2"></i>
<strong>DCT Mode</strong>
{% if has_dct %}
<span class="badge bg-info ms-auto">Stealth</span>
{% else %}
<span class="badge bg-secondary ms-auto">Unavailable</span>
{% endif %}
</div>
<ul class="small text-muted mb-0 ps-3">
<li>Grayscale output (PNG/JPEG)</li>
<li>Lower capacity (~75 KB/MP)</li>
<li>Better detection resistance</li>
</ul>
{% if not has_dct %}
<div class="alert alert-warning small mt-2 mb-0 py-1 px-2">
<i class="bi bi-exclamation-triangle me-1"></i>
Requires scipy: <code>pip install scipy</code>
</div>
{% endif %}
</label>
</div>
</div>
</div>
<!-- Mode comparison hint -->
<div class="form-text mt-2" id="modeHint">
<i class="bi bi-lightbulb me-1"></i>
<strong>LSB</strong> is best for most uses.
<strong>DCT</strong> provides better stealth but smaller capacity and grayscale output.
</div>
</div>
<!-- DCT Output Format (shown only when DCT selected) -->
<div class="mb-3 d-none" id="dctOutputFormatGroup">
<label class="form-label">
<i class="bi bi-file-image me-1"></i> DCT Output Format
</label>
<div class="row g-2">
<div class="col-6">
<div class="form-check card p-2 text-center" id="dctPngCard">
<input class="form-check-input mx-auto" type="radio" name="dct_output_format" id="dctFormatPng" value="png" checked>
<label class="form-check-label w-100" for="dctFormatPng">
<i class="bi bi-file-earmark-image text-success fs-5 d-block"></i>
<strong>PNG</strong>
<div class="small text-muted">Lossless, larger</div>
</label>
</div>
</div>
<div class="col-6">
<div class="form-check card p-2 text-center" id="dctJpegCard">
<input class="form-check-input mx-auto" type="radio" name="dct_output_format" id="dctFormatJpeg" value="jpeg">
<label class="form-check-label w-100" for="dctFormatJpeg">
<i class="bi bi-file-earmark-richtext text-warning fs-5 d-block"></i>
<strong>JPEG</strong>
<div class="small text-muted">Smaller, natural</div>
</label>
</div>
</div>
</div>
<div class="form-text mt-2">
<i class="bi bi-info-circle me-1"></i>
<strong>PNG</strong> is 100% reliable. <strong>JPEG</strong> produces smaller, more natural-looking files but uses lossy compression (Q=95).
</div>
</div>
<!-- Capacity Comparison (populated by JS) -->
<div class="d-none" id="modeCapacityComparison">
<div class="alert alert-secondary small mb-0">
<div class="row text-center">
<div class="col-6 border-end">
<div class="text-muted">LSB Capacity</div>
<div class="fs-5 text-primary" id="lsbCapacityDetail">-</div>
</div>
<div class="col-6">
<div class="text-muted">DCT Capacity</div>
<div class="fs-5 text-info" id="dctCapacityDetail">-</div>
</div>
</div>
<div class="text-center mt-2 small text-muted" id="capacityRatio">
DCT is ~20% of LSB capacity
</div>
</div>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100" id="encodeBtn"> <button type="submit" class="btn btn-primary btn-lg w-100" id="encodeBtn">
<i class="bi bi-lock me-2"></i>Encode <i class="bi bi-lock me-2"></i>Encode
</button> </button>
@@ -317,7 +466,15 @@ if (rsaKeyQrInput) {
// Form submit loading state // Form submit loading state
document.getElementById('encodeForm').addEventListener('submit', function(e) { document.getElementById('encodeForm').addEventListener('submit', function(e) {
const btn = document.getElementById('encodeBtn'); const btn = document.getElementById('encodeBtn');
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Encoding...'; const selectedMode = document.querySelector('input[name="embed_mode"]:checked').value;
let modeLabel = selectedMode.toUpperCase();
if (selectedMode === 'dct') {
const outputFormat = document.querySelector('input[name="dct_output_format"]:checked')?.value || 'png';
modeLabel += `${outputFormat.toUpperCase()}`;
}
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Encoding (${modeLabel})...`;
btn.disabled = true; btn.disabled = true;
}); });
@@ -338,12 +495,147 @@ messageInput.addEventListener('input', function() {
charCount.classList.toggle('text-danger', len > maxChars * 0.95); charCount.classList.toggle('text-danger', len > maxChars * 0.95);
}); });
// ============================================================================
// v3.0 - Capacity Comparison API
// ============================================================================
const carrierInput = document.getElementById('carrierInput');
const capacityPanel = document.getElementById('capacityPanel');
const carrierDimensions = document.getElementById('carrierDimensions');
const lsbCapacityBadge = document.getElementById('lsbCapacityBadge');
const dctCapacityBadge = document.getElementById('dctCapacityBadge');
const lsbCapacityDetail = document.getElementById('lsbCapacityDetail');
const dctCapacityDetail = document.getElementById('dctCapacityDetail');
const modeCapacityComparison = document.getElementById('modeCapacityComparison');
const capacityRatio = document.getElementById('capacityRatio');
let currentCapacity = null;
async function fetchCapacityComparison(file) {
const formData = new FormData();
formData.append('carrier', file);
try {
const response = await fetch('/api/compare-capacity', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
if (data.success) {
currentCapacity = data;
updateCapacityDisplay(data);
}
}
} catch (err) {
console.error('Capacity comparison failed:', err);
}
}
function updateCapacityDisplay(data) {
// Update top panel
carrierDimensions.textContent = `${data.width} × ${data.height}`;
lsbCapacityBadge.textContent = `LSB: ${data.lsb.capacity_kb} KB`;
if (data.dct.available) {
dctCapacityBadge.textContent = `DCT: ${data.dct.capacity_kb} KB`;
dctCapacityBadge.classList.remove('bg-secondary');
dctCapacityBadge.classList.add('bg-info');
} else {
dctCapacityBadge.textContent = `DCT: N/A`;
dctCapacityBadge.classList.remove('bg-info');
dctCapacityBadge.classList.add('bg-secondary');
}
capacityPanel.classList.remove('d-none');
// Update advanced options panel
lsbCapacityDetail.textContent = `${data.lsb.capacity_kb} KB`;
dctCapacityDetail.textContent = data.dct.available ? `${data.dct.capacity_kb} KB` : 'N/A';
capacityRatio.textContent = data.dct.available
? `DCT is ${data.dct.ratio}% of LSB capacity`
: 'DCT mode not available';
modeCapacityComparison.classList.remove('d-none');
}
// Listen for carrier file selection
if (carrierInput) {
carrierInput.addEventListener('change', function() {
if (this.files && this.files[0]) {
fetchCapacityComparison(this.files[0]);
} else {
capacityPanel.classList.add('d-none');
modeCapacityComparison.classList.add('d-none');
currentCapacity = null;
}
});
}
// ============================================================================
// Mode card highlighting & DCT output format visibility
// ============================================================================
const lsbModeCard = document.getElementById('lsbModeCard');
const dctModeCard = document.getElementById('dctModeCard');
const modeLsb = document.getElementById('modeLsb');
const modeDct = document.getElementById('modeDct');
const dctOutputFormatGroup = document.getElementById('dctOutputFormatGroup');
const dctPngCard = document.getElementById('dctPngCard');
const dctJpegCard = document.getElementById('dctJpegCard');
const dctFormatPng = document.getElementById('dctFormatPng');
const dctFormatJpeg = document.getElementById('dctFormatJpeg');
function updateModeCardHighlight() {
// Mode cards
lsbModeCard.classList.toggle('border-primary', modeLsb.checked);
lsbModeCard.classList.toggle('border-2', modeLsb.checked);
dctModeCard.classList.toggle('border-info', modeDct.checked);
dctModeCard.classList.toggle('border-2', modeDct.checked);
// Show/hide DCT output format selector
if (dctOutputFormatGroup) {
dctOutputFormatGroup.classList.toggle('d-none', !modeDct.checked);
}
}
function updateDctFormatCardHighlight() {
if (dctPngCard && dctJpegCard) {
dctPngCard.classList.toggle('border-success', dctFormatPng.checked);
dctPngCard.classList.toggle('border-2', dctFormatPng.checked);
dctJpegCard.classList.toggle('border-warning', dctFormatJpeg.checked);
dctJpegCard.classList.toggle('border-2', dctFormatJpeg.checked);
}
}
modeLsb.addEventListener('change', updateModeCardHighlight);
modeDct.addEventListener('change', updateModeCardHighlight);
dctFormatPng?.addEventListener('change', updateDctFormatCardHighlight);
dctFormatJpeg?.addEventListener('change', updateDctFormatCardHighlight);
updateModeCardHighlight(); // Initial state
updateDctFormatCardHighlight(); // Initial state
// Advanced options chevron rotation
document.getElementById('advancedOptions').addEventListener('show.bs.collapse', function() {
document.getElementById('advancedChevron').classList.add('bi-chevron-up');
document.getElementById('advancedChevron').classList.remove('bi-chevron-down');
});
document.getElementById('advancedOptions').addEventListener('hide.bs.collapse', function() {
document.getElementById('advancedChevron').classList.remove('bi-chevron-up');
document.getElementById('advancedChevron').classList.add('bi-chevron-down');
});
// ============================================================================
// Drag & drop with preview for images // Drag & drop with preview for images
// ============================================================================
document.querySelectorAll('.drop-zone').forEach(zone => { document.querySelectorAll('.drop-zone').forEach(zone => {
const input = zone.querySelector('input[type="file"]'); const input = zone.querySelector('input[type="file"]');
const label = zone.querySelector('.drop-zone-label'); const label = zone.querySelector('.drop-zone-label');
const preview = zone.querySelector('.drop-zone-preview'); const preview = zone.querySelector('.drop-zone-preview');
const isPayloadZone = zone.id === 'payloadDropZone'; const isPayloadZone = zone.id === 'payloadDropZone';
const isCarrierZone = zone.id === 'carrierDropZone';
['dragenter', 'dragover'].forEach(evt => { ['dragenter', 'dragover'].forEach(evt => {
zone.addEventListener(evt, e => { zone.addEventListener(evt, e => {
@@ -367,6 +659,11 @@ document.querySelectorAll('.drop-zone').forEach(zone => {
if (!isPayloadZone) { if (!isPayloadZone) {
showPreview(e.dataTransfer.files[0]); showPreview(e.dataTransfer.files[0]);
} }
// Trigger capacity check for carrier
if (isCarrierZone && e.dataTransfer.files[0]) {
fetchCapacityComparison(e.dataTransfer.files[0]);
}
} }
}); });
@@ -420,6 +717,7 @@ function checkDuplicateFiles() {
document.querySelector('#carrierDropZone .drop-zone-label').innerHTML = document.querySelector('#carrierDropZone .drop-zone-label').innerHTML =
'<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>' + '<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>' +
'<span class="text-muted">Drop image or click to browse</span>'; '<span class="text-muted">Drop image or click to browse</span>';
capacityPanel.classList.add('d-none');
} }
} }
} }
@@ -443,6 +741,11 @@ document.addEventListener('paste', function(e) {
targetInput.files = container.files; targetInput.files = container.files;
targetInput.dispatchEvent(new Event('change')); targetInput.dispatchEvent(new Event('change'));
// Trigger capacity check if pasted to carrier
if (targetInput === carrierInput) {
fetchCapacityComparison(blob);
}
break; break;
} }
} }

View File

@@ -34,6 +34,34 @@
<code class="fs-5">{{ filename }}</code> <code class="fs-5">{{ filename }}</code>
</div> </div>
<!-- Mode and format badges (v3.0) -->
<div class="mb-4">
{% if embed_mode == 'dct' %}
<span class="badge bg-info fs-6">
<i class="bi bi-soundwave me-1"></i>DCT Mode
</span>
{% if output_format == 'jpeg' %}
<span class="badge bg-warning text-dark fs-6 ms-1">
<i class="bi bi-file-earmark-richtext me-1"></i>JPEG
</span>
<div class="small text-muted mt-1">Grayscale JPEG, frequency domain embedding (Q=95)</div>
{% else %}
<span class="badge bg-success fs-6 ms-1">
<i class="bi bi-file-earmark-image me-1"></i>PNG
</span>
<div class="small text-muted mt-1">Grayscale PNG, frequency domain embedding (lossless)</div>
{% endif %}
{% else %}
<span class="badge bg-primary fs-6">
<i class="bi bi-grid-3x3-gap me-1"></i>LSB Mode
</span>
<span class="badge bg-success fs-6 ms-1">
<i class="bi bi-file-earmark-image me-1"></i>PNG
</span>
<div class="small text-muted mt-1">Full color PNG, spatial LSB embedding</div>
{% endif %}
</div>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<a href="{{ url_for('encode_download', file_id=file_id) }}" <a href="{{ url_for('encode_download', file_id=file_id) }}"
class="btn btn-primary btn-lg" id="downloadBtn"> class="btn btn-primary btn-lg" id="downloadBtn">
@@ -53,7 +81,14 @@
<ul class="mb-0 mt-2"> <ul class="mb-0 mt-2">
<li>This file expires in <strong>5 minutes</strong></li> <li>This file expires in <strong>5 minutes</strong></li>
<li>Do <strong>not</strong> resize or recompress the image</li> <li>Do <strong>not</strong> resize or recompress the image</li>
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
<li>JPEG format is lossy - avoid re-saving or editing</li>
{% else %}
<li>PNG format preserves your hidden data</li> <li>PNG format preserves your hidden data</li>
{% endif %}
{% if embed_mode == 'dct' %}
<li>Recipient needs <strong>DCT mode</strong> or <strong>Auto</strong> detection to decode</li>
{% endif %}
</ul> </ul>
</div> </div>
@@ -72,13 +107,14 @@
const shareBtn = document.getElementById('shareBtn'); const shareBtn = document.getElementById('shareBtn');
const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}"; const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}";
const fileName = "{{ filename }}"; const fileName = "{{ filename }}";
const mimeType = "{{ 'image/jpeg' if embed_mode == 'dct' and output_format == 'jpeg' else 'image/png' }}";
if (navigator.share && navigator.canShare) { if (navigator.share && navigator.canShare) {
// Check if we can share files // Check if we can share files
fetch(fileUrl) fetch(fileUrl)
.then(response => response.blob()) .then(response => response.blob())
.then(blob => { .then(blob => {
const file = new File([blob], fileName, { type: 'image/png' }); const file = new File([blob], fileName, { type: mimeType });
if (navigator.canShare({ files: [file] })) { if (navigator.canShare({ files: [file] })) {
shareBtn.style.display = 'block'; shareBtn.style.display = 'block';
shareBtn.addEventListener('click', async () => { shareBtn.addEventListener('click', async () => {
@@ -106,4 +142,4 @@ document.getElementById('downloadBtn').addEventListener('click', function() {
}, 2000); }, 2000);
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,5 @@
""" """
Stegasoo - Secure Steganography Library Stegasoo - Secure Steganography Library (v3.0.1)
A Python library for hiding encrypted messages and files in images using A Python library for hiding encrypted messages and files in images using
hybrid photo + passphrase + PIN authentication. hybrid photo + passphrase + PIN authentication.
@@ -58,7 +58,7 @@ File Embedding:
else: else:
print(decoded.message) print(decoded.message)
Capacity Pre-check (v2.2.1): Capacity Pre-check:
from stegasoo import will_fit from stegasoo import will_fit
# Check if payload will fit before encoding # Check if payload will fit before encoding
@@ -68,13 +68,52 @@ Capacity Pre-check (v2.2.1):
else: else:
print(f"Need {-result['headroom']} more bytes") print(f"Need {-result['headroom']} more bytes")
NEW in v3.0 - DCT Embedding Mode:
from stegasoo import encode, has_dct_support, compare_modes
# Check if DCT mode is available (requires scipy)
if has_dct_support():
# DCT mode: smaller capacity, grayscale output, frequency domain
result = encode(
message="Secret",
reference_photo=ref_photo,
carrier_image=carrier,
day_phrase="apple forest thunder",
pin="123456",
embed_mode='dct', # NEW parameter
)
# Compare mode capacities
info = compare_modes(carrier_image)
print(f"LSB capacity: {info['lsb']['capacity_kb']:.1f} KB")
print(f"DCT capacity: {info['dct']['capacity_kb']:.1f} KB")
NEW in v3.0.1 - DCT Output Format:
# DCT mode can output PNG (lossless) or JPEG (smaller, natural)
result = encode(
message="Secret",
...,
embed_mode='dct',
dct_output_format='jpeg', # 'png' (default) or 'jpeg'
)
Debugging: Debugging:
from stegasoo.debug import debug from stegasoo.debug import debug
debug.enable(True) # Enable debug output debug.enable(True) # Enable debug output
debug.enable_performance(True) # Enable timing debug.enable_performance(True) # Enable timing
""" """
from .constants import __version__, DAY_NAMES, MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE from .constants import (
__version__,
DAY_NAMES,
MAX_MESSAGE_SIZE,
MAX_FILE_PAYLOAD_SIZE,
# NEW in v3.0 - Embedding modes
EMBED_MODE_LSB,
EMBED_MODE_DCT,
EMBED_MODE_AUTO,
detect_stego_mode,
)
from .models import ( from .models import (
Credentials, Credentials,
EncodeInput, EncodeInput,
@@ -152,8 +191,13 @@ 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, will_fit,
# NEW in v3.0
has_dct_support,
calculate_capacity_by_mode,
will_fit_by_mode,
get_available_modes,
compare_modes,
) )
from .utils import ( from .utils import (
generate_filename, generate_filename,
@@ -164,7 +208,6 @@ from .utils import (
secure_delete, secure_delete,
SecureDeleter, SecureDeleter,
format_file_size, format_file_size,
# NEW in v2.2.1
strip_image_metadata, strip_image_metadata,
) )
from .debug import debug # Import debug utilities from .debug import debug # Import debug utilities
@@ -183,7 +226,7 @@ from .compression import (
) )
# ============================================================================= # =============================================================================
# NEW IN v2.2.0 - Batch Processing # Batch Processing
# ============================================================================= # =============================================================================
from .batch import ( from .batch import (
BatchProcessor, BatchProcessor,
@@ -191,10 +234,43 @@ from .batch import (
BatchItem, BatchItem,
BatchStatus, BatchStatus,
batch_capacity_check, batch_capacity_check,
# NEW in v2.2.1
BatchCredentials, BatchCredentials,
) )
# =============================================================================
# NEW in v3.0 - DCT Steganography (optional, requires scipy)
# =============================================================================
try:
from .dct_steganography import (
embed_in_dct,
extract_from_dct,
calculate_dct_capacity,
will_fit_dct,
estimate_capacity_comparison,
DCTEmbedStats,
DCTCapacityInfo,
)
HAS_DCT = True
except ImportError:
HAS_DCT = False
# Provide stub functions that raise helpful errors
def embed_in_dct(*args, **kwargs):
raise ImportError("DCT mode requires scipy. Install: pip install scipy")
def extract_from_dct(*args, **kwargs):
raise ImportError("DCT mode requires scipy. Install: pip install scipy")
def calculate_dct_capacity(*args, **kwargs):
raise ImportError("DCT mode requires scipy. Install: pip install scipy")
def will_fit_dct(*args, **kwargs):
raise ImportError("DCT mode requires scipy. Install: pip install scipy")
def estimate_capacity_comparison(*args, **kwargs):
raise ImportError("DCT mode requires scipy. Install: pip install scipy")
# Stub classes
class DCTEmbedStats:
pass
class DCTCapacityInfo:
pass
# QR Code utilities (optional, depends on qrcode and pyzbar) # QR Code utilities (optional, depends on qrcode and pyzbar)
try: try:
from .qr_utils import ( from .qr_utils import (
@@ -223,16 +299,22 @@ from pathlib import Path
from typing import Optional, Union, Dict, Any from typing import Optional, Union, Dict, Any
# =============================================================================
# ENCODE FUNCTION (v3.0.1 - with dct_output_format)
# =============================================================================
def encode( def encode(
message: Union[str, bytes, FilePayload], message, # Union[str, bytes, FilePayload]
reference_photo: bytes, reference_photo: bytes,
carrier_image: bytes, carrier_image: bytes,
day_phrase: str, day_phrase: str,
pin: str = "", pin: str = "",
rsa_key_data: Optional[bytes] = None, rsa_key_data = None, # Optional[bytes]
rsa_password: Optional[str] = None, rsa_password = None, # Optional[str]
date_str: Optional[str] = None, date_str = None, # Optional[str]
output_format: Optional[str] = None, output_format = None, # Optional[str]
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = "png", # NEW in v3.0.1: 'png' or 'jpeg'
) -> EncodeResult: ) -> EncodeResult:
""" """
Encode a secret message or file into an image. Encode a secret message or file into an image.
@@ -249,8 +331,9 @@ def encode(
rsa_key_data: RSA private key PEM bytes (optional if using PIN) rsa_key_data: RSA private key PEM bytes (optional if using PIN)
rsa_password: Password for RSA key if encrypted rsa_password: Password for RSA key if encrypted
date_str: Date string YYYY-MM-DD (defaults to today) date_str: Date string YYYY-MM-DD (defaults to today)
output_format: Force output format ('PNG', 'BMP'). If None, preserves output_format: Force output format ('PNG', 'BMP') - LSB mode only
carrier format for lossless types, defaults to PNG for lossy. embed_mode: Embedding mode - 'lsb' (default) or 'dct' (v3.0+)
dct_output_format: For DCT mode - 'png' (lossless) or 'jpeg' (smaller)
Returns: Returns:
EncodeResult with stego image and metadata EncodeResult with stego image and metadata
@@ -260,14 +343,37 @@ def encode(
SecurityFactorError: If no PIN or RSA key provided SecurityFactorError: If no PIN or RSA key provided
CapacityError: If carrier is too small CapacityError: If carrier is too small
EncryptionError: If encryption fails EncryptionError: If encryption fails
ImportError: If DCT mode requested but scipy unavailable
Note: Example:
Output format is always lossless (PNG or BMP) to preserve hidden data. # Default LSB mode
If carrier is JPEG/GIF, output will be PNG to maintain data integrity. >>> result = encode(message="Secret", ...)
# DCT mode with PNG output (lossless)
>>> result = encode(message="Secret", ..., embed_mode='dct')
# DCT mode with JPEG output (smaller, natural)
>>> result = encode(message="Secret", ..., embed_mode='dct', dct_output_format='jpeg')
""" """
# Debug logging # Debug logging
debug.print(f"encode called: message type={type(message).__name__}, " debug.print(f"encode called: message type={type(message).__name__}, "
f"day_phrase='{day_phrase[:20]}...', pin_length={len(pin)}") f"day_phrase='{day_phrase[:20]}...', pin_length={len(pin)}, "
f"embed_mode={embed_mode}, dct_output_format={dct_output_format}")
# Validate embed_mode
if embed_mode not in (EMBED_MODE_LSB, EMBED_MODE_DCT):
raise ValidationError(f"Invalid embed_mode: {embed_mode}. Use 'lsb' or 'dct'")
if embed_mode == EMBED_MODE_DCT and not has_dct_support():
raise ImportError(
"DCT embedding mode requires scipy. "
"Install with: pip install scipy"
)
# Validate dct_output_format
if dct_output_format not in ('png', 'jpeg'):
debug.print(f"Invalid dct_output_format '{dct_output_format}', defaulting to 'png'")
dct_output_format = 'png'
# Validate inputs # Validate inputs
require_valid_payload(message) require_valid_payload(message)
@@ -301,27 +407,54 @@ def encode(
debug.data(pixel_key, "Pixel key") debug.data(pixel_key, "Pixel key")
# Embed in image (returns extension too) # Embed in image (returns extension too)
# CRITICAL: Pass dct_output_format to embed_in_image
stego_data, stats, extension = embed_in_image( stego_data, stats, extension = embed_in_image(
carrier_image, encrypted, pixel_key, output_format=output_format encrypted,
carrier_image,
pixel_key,
output_format=output_format,
embed_mode=embed_mode,
dct_output_format=dct_output_format, # NEW in v3.0.1
) )
# Generate filename with correct extension # Generate filename with correct extension
filename = generate_filename(date_str, extension=extension) filename = generate_filename(date_str, extension=extension)
debug.print(f"Encoding complete: {filename}, " # Handle stats from either LSB or DCT mode
f"modified {stats.pixels_modified}/{stats.total_pixels} pixels " if hasattr(stats, 'pixels_modified'):
f"({stats.modification_percent:.2f}%)") # LSB mode stats
debug.print(f"Encoding complete: {filename}, "
return EncodeResult( f"modified {stats.pixels_modified}/{stats.total_pixels} pixels "
stego_image=stego_data, f"({stats.modification_percent:.2f}%)")
filename=filename,
pixels_modified=stats.pixels_modified, return EncodeResult(
total_pixels=stats.total_pixels, stego_image=stego_data,
capacity_used=stats.capacity_used, filename=filename,
date_used=date_str pixels_modified=stats.pixels_modified,
) total_pixels=stats.total_pixels,
capacity_used=stats.capacity_used,
date_used=date_str
)
else:
# DCT mode stats
debug.print(f"Encoding complete (DCT): {filename}, "
f"embedded {stats.bits_embedded // 8} bytes "
f"({stats.usage_percent:.2f}% capacity)")
return EncodeResult(
stego_image=stego_data,
filename=filename,
pixels_modified=stats.blocks_used * 64, # Approximate
total_pixels=stats.blocks_available * 64,
capacity_used=stats.usage_percent / 100.0,
date_used=date_str
)
# =============================================================================
# ENCODE_FILE FUNCTION (v3.0.1 - with dct_output_format)
# =============================================================================
def encode_file( def encode_file(
filepath: Union[str, Path], filepath: Union[str, Path],
reference_photo: bytes, reference_photo: bytes,
@@ -333,6 +466,8 @@ def encode_file(
date_str: Optional[str] = None, date_str: Optional[str] = None,
output_format: Optional[str] = None, output_format: Optional[str] = None,
filename_override: Optional[str] = None, filename_override: Optional[str] = None,
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = "png", # NEW in v3.0.1
) -> EncodeResult: ) -> EncodeResult:
""" """
Encode a file into an image. Encode a file into an image.
@@ -348,13 +483,16 @@ def encode_file(
rsa_key_data: RSA private key PEM bytes (optional if using PIN) rsa_key_data: RSA private key PEM bytes (optional if using PIN)
rsa_password: Password for RSA key if encrypted rsa_password: Password for RSA key if encrypted
date_str: Date string YYYY-MM-DD (defaults to today) date_str: Date string YYYY-MM-DD (defaults to today)
output_format: Force output format ('PNG', 'BMP') output_format: Force output format ('PNG', 'BMP') - LSB mode only
filename_override: Override the stored filename filename_override: Override the stored filename
embed_mode: 'lsb' (default) or 'dct' (v3.0+)
dct_output_format: For DCT mode - 'png' or 'jpeg' (v3.0.1+)
Returns: Returns:
EncodeResult with stego image and metadata EncodeResult with stego image and metadata
""" """
debug.print(f"encode_file called: filepath={filepath}") debug.print(f"encode_file called: filepath={filepath}, embed_mode={embed_mode}, "
f"dct_output_format={dct_output_format}")
payload = FilePayload.from_file(str(filepath), filename_override) payload = FilePayload.from_file(str(filepath), filename_override)
return encode( return encode(
@@ -367,9 +505,15 @@ def encode_file(
rsa_password=rsa_password, rsa_password=rsa_password,
date_str=date_str, date_str=date_str,
output_format=output_format, output_format=output_format,
embed_mode=embed_mode,
dct_output_format=dct_output_format, # NEW in v3.0.1
) )
# =============================================================================
# ENCODE_BYTES FUNCTION (v3.0.1 - with dct_output_format)
# =============================================================================
def encode_bytes( def encode_bytes(
data: bytes, data: bytes,
filename: str, filename: str,
@@ -382,6 +526,8 @@ def encode_bytes(
date_str: Optional[str] = None, date_str: Optional[str] = None,
output_format: Optional[str] = None, output_format: Optional[str] = None,
mime_type: Optional[str] = None, mime_type: Optional[str] = None,
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = "png", # NEW in v3.0.1
) -> EncodeResult: ) -> EncodeResult:
""" """
Encode raw bytes with a filename into an image. Encode raw bytes with a filename into an image.
@@ -398,13 +544,16 @@ def encode_bytes(
rsa_key_data: RSA private key PEM bytes (optional if using PIN) rsa_key_data: RSA private key PEM bytes (optional if using PIN)
rsa_password: Password for RSA key if encrypted rsa_password: Password for RSA key if encrypted
date_str: Date string YYYY-MM-DD (defaults to today) date_str: Date string YYYY-MM-DD (defaults to today)
output_format: Force output format ('PNG', 'BMP') output_format: Force output format ('PNG', 'BMP') - LSB mode only
mime_type: MIME type of the data mime_type: MIME type of the data
embed_mode: 'lsb' (default) or 'dct' (v3.0+)
dct_output_format: For DCT mode - 'png' or 'jpeg' (v3.0.1+)
Returns: Returns:
EncodeResult with stego image and metadata EncodeResult with stego image and metadata
""" """
debug.print(f"encode_bytes called: filename={filename}, data_size={len(data)}") debug.print(f"encode_bytes called: filename={filename}, data_size={len(data)}, "
f"embed_mode={embed_mode}, dct_output_format={dct_output_format}")
payload = FilePayload(data=data, filename=filename, mime_type=mime_type) payload = FilePayload(data=data, filename=filename, mime_type=mime_type)
return encode( return encode(
@@ -417,9 +566,15 @@ def encode_bytes(
rsa_password=rsa_password, rsa_password=rsa_password,
date_str=date_str, date_str=date_str,
output_format=output_format, output_format=output_format,
embed_mode=embed_mode,
dct_output_format=dct_output_format, # NEW in v3.0.1
) )
# =============================================================================
# DECODE FUNCTION
# =============================================================================
@debug.time @debug.time
def decode( def decode(
stego_image: bytes, stego_image: bytes,
@@ -429,6 +584,7 @@ def decode(
rsa_key_data: Optional[bytes] = None, rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None, rsa_password: Optional[str] = None,
date_str: Optional[str] = None, date_str: Optional[str] = None,
embed_mode: str = EMBED_MODE_AUTO,
) -> DecodeResult: ) -> DecodeResult:
""" """
Decode a secret message or file from a stego image. Decode a secret message or file from a stego image.
@@ -443,6 +599,11 @@ def decode(
pin: Static PIN (if used during encoding) pin: Static PIN (if used during encoding)
rsa_key_data: RSA private key PEM bytes (if used during encoding) rsa_key_data: RSA private key PEM bytes (if used during encoding)
rsa_password: Password for RSA key if encrypted rsa_password: Password for RSA key if encrypted
date_str: Date override (defaults to today, then checks header)
embed_mode: 'auto' (default), 'lsb', or 'dct' (v3.0+)
- 'auto': Try LSB first, then DCT if available
- 'lsb': Only try LSB extraction
- 'dct': Only try DCT extraction (requires scipy)
Returns: Returns:
DecodeResult with: DecodeResult with:
@@ -457,9 +618,24 @@ def decode(
SecurityFactorError: If no PIN or RSA key provided SecurityFactorError: If no PIN or RSA key provided
ExtractionError: If data cannot be extracted ExtractionError: If data cannot be extracted
DecryptionError: If decryption fails DecryptionError: If decryption fails
ImportError: If DCT mode explicitly requested but scipy unavailable
Note:
With embed_mode='auto' (default), tries LSB first then DCT.
For best performance, specify the mode if you know it.
""" """
debug.print(f"decode called: stego_image_size={len(stego_image)}, " debug.print(f"decode called: stego_image_size={len(stego_image)}, "
f"day_phrase='{day_phrase[:20]}...'") f"day_phrase='{day_phrase[:20]}...', embed_mode={embed_mode}")
# Validate embed_mode
if embed_mode not in (EMBED_MODE_AUTO, EMBED_MODE_LSB, EMBED_MODE_DCT):
raise ValidationError(f"Invalid embed_mode: {embed_mode}. Use 'auto', 'lsb', or 'dct'")
if embed_mode == EMBED_MODE_DCT and not has_dct_support():
raise ImportError(
"DCT extraction mode requires scipy. "
"Install with: pip install scipy"
)
# Validate inputs # Validate inputs
require_security_factors(pin, rsa_key_data) require_security_factors(pin, rsa_key_data)
@@ -479,7 +655,12 @@ def decode(
debug.data(pixel_key, "Pixel key for extraction") debug.data(pixel_key, "Pixel key for extraction")
encrypted = extract_from_image(stego_image, pixel_key) # Extract with specified mode
encrypted = extract_from_image(
stego_image,
pixel_key,
embed_mode=embed_mode,
)
# If we got data, check if it's from a different date # If we got data, check if it's from a different date
if encrypted: if encrypted:
@@ -490,7 +671,11 @@ def decode(
pixel_key = derive_pixel_key( pixel_key = derive_pixel_key(
reference_photo, day_phrase, header['date'], pin, rsa_key_data reference_photo, day_phrase, header['date'], pin, rsa_key_data
) )
encrypted = extract_from_image(stego_image, pixel_key) encrypted = extract_from_image(
stego_image,
pixel_key,
embed_mode=embed_mode,
)
if not encrypted: if not encrypted:
debug.print("No data extracted from image") debug.print("No data extracted from image")
@@ -503,6 +688,10 @@ def decode(
return decrypt_message(encrypted, reference_photo, day_phrase, pin, rsa_key_data) return decrypt_message(encrypted, reference_photo, day_phrase, pin, rsa_key_data)
# =============================================================================
# DECODE_TEXT FUNCTION
# =============================================================================
def decode_text( def decode_text(
stego_image: bytes, stego_image: bytes,
reference_photo: bytes, reference_photo: bytes,
@@ -511,6 +700,7 @@ def decode_text(
rsa_key_data: Optional[bytes] = None, rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None, rsa_password: Optional[str] = None,
date_str: Optional[str] = None, date_str: Optional[str] = None,
embed_mode: str = EMBED_MODE_AUTO,
) -> str: ) -> str:
""" """
Decode a text message from a stego image. Decode a text message from a stego image.
@@ -525,6 +715,8 @@ def decode_text(
pin: Static PIN (if used during encoding) pin: Static PIN (if used during encoding)
rsa_key_data: RSA private key PEM bytes (if used during encoding) rsa_key_data: RSA private key PEM bytes (if used during encoding)
rsa_password: Password for RSA key if encrypted rsa_password: Password for RSA key if encrypted
date_str: Date override
embed_mode: 'auto' (default), 'lsb', or 'dct' (v3.0+)
Returns: Returns:
Decrypted message string Decrypted message string
@@ -532,8 +724,17 @@ def decode_text(
Raises: Raises:
DecryptionError: If content is a binary file, not text DecryptionError: If content is a binary file, not text
""" """
debug.print("decode_text called") debug.print(f"decode_text called, embed_mode={embed_mode}")
result = decode(stego_image, reference_photo, day_phrase, pin, rsa_key_data, rsa_password) result = decode(
stego_image,
reference_photo,
day_phrase,
pin,
rsa_key_data,
rsa_password,
date_str,
embed_mode,
)
if result.is_file: if result.is_file:
# Try to decode file as text # Try to decode file as text
@@ -553,6 +754,10 @@ def decode_text(
return message return message
# =============================================================================
# EXPORTS
# =============================================================================
__all__ = [ __all__ = [
# Version # Version
'__version__', '__version__',
@@ -565,6 +770,27 @@ __all__ = [
'decode_text', 'decode_text',
'generate_credentials', 'generate_credentials',
# NEW in v3.0 - Embedding modes
'EMBED_MODE_LSB',
'EMBED_MODE_DCT',
'EMBED_MODE_AUTO',
'has_dct_support',
'compare_modes',
'get_available_modes',
'calculate_capacity_by_mode',
'will_fit_by_mode',
'detect_stego_mode',
'HAS_DCT',
# NEW in v3.0 - DCT functions (available if scipy installed)
'embed_in_dct',
'extract_from_dct',
'calculate_dct_capacity',
'will_fit_dct',
'estimate_capacity_comparison',
'DCTEmbedStats',
'DCTCapacityInfo',
# Constants # Constants
'DAY_NAMES', 'DAY_NAMES',
'LOSSLESS_FORMATS', 'LOSSLESS_FORMATS',
@@ -646,7 +872,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 'will_fit',
# Utilities # Utilities
'generate_filename', 'generate_filename',
@@ -657,12 +883,12 @@ __all__ = [
'secure_delete', 'secure_delete',
'SecureDeleter', 'SecureDeleter',
'format_file_size', 'format_file_size',
'strip_image_metadata', # NEW in v2.2.1 'strip_image_metadata',
# Debugging # Debugging
'debug', 'debug',
# Compression (v2.2.0) # Compression
'compress', 'compress',
'decompress', 'decompress',
'CompressionAlgorithm', 'CompressionAlgorithm',
@@ -671,11 +897,11 @@ __all__ = [
'estimate_compressed_size', 'estimate_compressed_size',
'get_available_algorithms', 'get_available_algorithms',
# Batch processing (v2.2.0) # Batch processing
'BatchProcessor', 'BatchProcessor',
'BatchResult', 'BatchResult',
'BatchItem', 'BatchItem',
'BatchStatus', 'BatchStatus',
'batch_capacity_check', 'batch_capacity_check',
'BatchCredentials', # NEW in v2.2.1 'BatchCredentials',
] ]

View File

@@ -12,7 +12,7 @@ from pathlib import Path
# VERSION # VERSION
# ============================================================================ # ============================================================================
__version__ = "2.2.1" __version__ = "3.1.0"
# ============================================================================ # ============================================================================
# FILE FORMAT # FILE FORMAT
@@ -181,3 +181,44 @@ def get_wordlist() -> list[str]:
if _bip39_words is None: if _bip39_words is None:
_bip39_words = get_bip39_words() _bip39_words = get_bip39_words()
return _bip39_words return _bip39_words
# =============================================================================
# DCT STEGANOGRAPHY (v3.0)
# =============================================================================
# Embedding modes
EMBED_MODE_LSB = 'lsb' # Spatial LSB embedding (default, original mode)
EMBED_MODE_DCT = 'dct' # DCT domain embedding (new in v3.0)
EMBED_MODE_AUTO = 'auto' # Auto-detect on decode
# DCT-specific constants
DCT_MAGIC_HEADER = b'\x89DCT' # Magic header for DCT mode
DCT_FORMAT_VERSION = 1
DCT_STEP_SIZE = 8 # QIM quantization step
# Valid embedding modes
VALID_EMBED_MODES = {EMBED_MODE_LSB, EMBED_MODE_DCT}
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':
return EMBED_MODE_DCT
else:
return 'unknown'

View File

@@ -0,0 +1,554 @@
"""
DCT Domain Steganography Module (v3.0.1)
Embeds data in DCT coefficients of grayscale images.
Supports PNG (lossless) or JPEG (natural, smaller) output.
This provides an alternative to LSB embedding with different trade-offs:
- More resistant to visual inspection
- Survives some image processing
- Lower capacity (~20% of LSB)
- Works in frequency domain
Requires: scipy (for DCT transforms)
"""
import io
import struct
import hashlib
from dataclasses import dataclass
from typing import Optional, Literal
from enum import Enum
import numpy as np
from PIL import Image
# Check for scipy availability
try:
from scipy.fftpack import dct, idct
HAS_SCIPY = True
except ImportError:
HAS_SCIPY = False
dct = None
idct = None
# ============================================================================
# CONSTANTS
# ============================================================================
# DCT block size (standard 8x8 like JPEG)
BLOCK_SIZE = 8
# Coefficients to use for embedding (mid-frequency, zig-zag order positions)
# Avoiding DC (0,0) and high-frequency edges
# These positions are relatively stable across JPEG compression
EMBED_POSITIONS = [
(0, 1), (1, 0), (2, 0), (1, 1), (0, 2), (0, 3), (1, 2), (2, 1), (3, 0),
(4, 0), (3, 1), (2, 2), (1, 3), (0, 4), (0, 5), (1, 4), (2, 3), (3, 2),
(4, 1), (5, 0), (5, 1), (4, 2), (3, 3), (2, 4), (1, 5), (0, 6), (0, 7),
(1, 6), (2, 5), (3, 4), (4, 3), (5, 2), (6, 1), (7, 0),
]
# Use subset of mid-frequency coefficients for better robustness
# Positions 4-20 in zig-zag order (skip very low and very high frequencies)
DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20] # 16 coefficients per block
# Quantization step for embedding (larger = more robust, more visible)
QUANT_STEP = 25
# Magic bytes for DCT stego identification
DCT_MAGIC = b'DCTS'
# Header: magic(4) + version(1) + flags(1) + length(4) = 10 bytes
HEADER_SIZE = 10
# Output format options
OUTPUT_FORMAT_PNG = 'png'
OUTPUT_FORMAT_JPEG = 'jpeg'
# JPEG quality for output (high to preserve coefficients)
JPEG_OUTPUT_QUALITY = 95
# ============================================================================
# DATA CLASSES
# ============================================================================
class DCTOutputFormat(Enum):
"""Output format for DCT stego images."""
PNG = 'png'
JPEG = 'jpeg'
@dataclass
class DCTEmbedStats:
"""Statistics from DCT embedding operation."""
blocks_used: int
blocks_available: int
bits_embedded: int
capacity_bits: int
usage_percent: float
image_width: int
image_height: int
output_format: str # 'png' or 'jpeg'
@dataclass
class DCTCapacityInfo:
"""Capacity information for a carrier image."""
width: int
height: int
blocks_x: int
blocks_y: int
total_blocks: int
bits_per_block: int
total_capacity_bits: int
total_capacity_bytes: int
usable_capacity_bytes: int # After header overhead
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
def _check_scipy():
"""Raise ImportError if scipy is not available."""
if not HAS_SCIPY:
raise ImportError(
"DCT steganography requires scipy. "
"Install with: pip install scipy"
)
def _dct2(block: np.ndarray) -> np.ndarray:
"""Apply 2D DCT to a block."""
return dct(dct(block.T, norm='ortho').T, norm='ortho')
def _idct2(block: np.ndarray) -> np.ndarray:
"""Apply 2D inverse DCT to a block."""
return idct(idct(block.T, norm='ortho').T, norm='ortho')
def _to_grayscale(image_data: bytes) -> np.ndarray:
"""Convert image bytes to grayscale numpy array."""
img = Image.open(io.BytesIO(image_data))
gray = img.convert('L')
return np.array(gray, dtype=np.float64)
def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]:
"""Pad image dimensions to be divisible by block size."""
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 image, (h, w)
padded = np.zeros((new_h, new_w), dtype=image.dtype)
padded[:h, :w] = image
# Mirror padding for smoother edges
if new_h > h:
padded[h:, :w] = image[h-(new_h-h):h, :w][::-1, :]
if new_w > w:
padded[:h, w:] = image[:h, w-(new_w-w):w][:, ::-1]
if new_h > h and new_w > w:
padded[h:, w:] = image[h-(new_h-h):h, w-(new_w-w):w][::-1, ::-1]
return padded, (h, w)
def _unpad_image(image: np.ndarray, original_size: tuple[int, int]) -> np.ndarray:
"""Remove padding from image."""
h, w = original_size
return image[:h, :w]
def _embed_bit_in_coeff(coeff: float, bit: int, quant_step: int = QUANT_STEP) -> float:
"""Embed a single bit into a DCT coefficient using QIM."""
# Quantization Index Modulation
quantized = round(coeff / quant_step)
if (quantized % 2) != bit:
# Adjust to embed the bit
if quantized % 2 == 0 and bit == 1:
quantized += 1 if coeff >= quantized * quant_step else -1
elif quantized % 2 == 1 and bit == 0:
quantized += 1 if coeff >= quantized * quant_step else -1
return quantized * quant_step
def _extract_bit_from_coeff(coeff: float, quant_step: int = QUANT_STEP) -> int:
"""Extract a single bit from a DCT coefficient."""
quantized = round(coeff / quant_step)
return quantized % 2
def _generate_block_order(num_blocks: int, seed: bytes) -> list[int]:
"""Generate pseudo-random block order from seed."""
# Create deterministic RNG from seed
hash_bytes = hashlib.sha256(seed).digest()
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big'))
order = list(range(num_blocks))
rng.shuffle(order)
return order
def _save_stego_image(
image: np.ndarray,
output_format: str = OUTPUT_FORMAT_PNG
) -> bytes:
"""Save stego image in specified format."""
# Clip to valid range and convert to uint8
clipped = np.clip(image, 0, 255).astype(np.uint8)
img = Image.fromarray(clipped, mode='L')
buffer = io.BytesIO()
if output_format == OUTPUT_FORMAT_JPEG:
# High-quality JPEG with no chroma subsampling
img.save(
buffer,
format='JPEG',
quality=JPEG_OUTPUT_QUALITY,
subsampling=0, # 4:4:4 - no subsampling
optimize=True
)
else:
# PNG (lossless, default)
img.save(buffer, format='PNG', optimize=True)
return buffer.getvalue()
def _create_header(data_length: int, flags: int = 0) -> bytes:
"""Create DCT stego header."""
# Header format: MAGIC(4) + VERSION(1) + FLAGS(1) + LENGTH(4)
version = 1
return struct.pack('>4sBBI', DCT_MAGIC, version, flags, data_length)
def _parse_header(header_bits: list[int]) -> tuple[int, int, int]:
"""Parse header from extracted bits. Returns (version, flags, data_length)."""
if len(header_bits) < HEADER_SIZE * 8:
raise ValueError("Insufficient header data")
# Convert bits to bytes
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 - not a DCT stego image")
return version, flags, length
# ============================================================================
# PUBLIC API
# ============================================================================
def has_dct_support() -> bool:
"""Check if DCT steganography is available."""
return HAS_SCIPY
def calculate_dct_capacity(image_data: bytes) -> DCTCapacityInfo:
"""
Calculate the DCT embedding capacity of an image.
Args:
image_data: Image file bytes
Returns:
DCTCapacityInfo with capacity details
"""
_check_scipy()
img = Image.open(io.BytesIO(image_data))
width, height = img.size
# Calculate blocks
blocks_x = width // BLOCK_SIZE
blocks_y = height // BLOCK_SIZE
total_blocks = blocks_x * blocks_y
# Bits per block (using selected coefficient positions)
bits_per_block = len(DEFAULT_EMBED_POSITIONS)
# Total capacity
total_bits = total_blocks * bits_per_block
total_bytes = total_bits // 8
# Usable capacity (minus header)
usable_bytes = max(0, total_bytes - HEADER_SIZE)
return DCTCapacityInfo(
width=width,
height=height,
blocks_x=blocks_x,
blocks_y=blocks_y,
total_blocks=total_blocks,
bits_per_block=bits_per_block,
total_capacity_bits=total_bits,
total_capacity_bytes=total_bytes,
usable_capacity_bytes=usable_bytes
)
def will_fit_dct(data_length: int, image_data: bytes) -> bool:
"""
Check if data will fit in the image using DCT embedding.
Args:
data_length: Length of data in bytes
image_data: Carrier image bytes
Returns:
True if data fits, False otherwise
"""
capacity = calculate_dct_capacity(image_data)
return data_length <= capacity.usable_capacity_bytes
def estimate_capacity_comparison(image_data: bytes) -> dict:
"""
Compare LSB and DCT capacity for an image.
Args:
image_data: Image file bytes
Returns:
Dict with 'lsb' and 'dct' capacity info
"""
img = Image.open(io.BytesIO(image_data))
width, height = img.size
pixels = width * height
# LSB capacity (3 bits per pixel for RGB, simplified)
lsb_bytes = (pixels * 3) // 8
# DCT capacity
if HAS_SCIPY:
dct_info = calculate_dct_capacity(image_data)
dct_bytes = dct_info.usable_capacity_bytes
else:
# Estimate without scipy
blocks = (width // 8) * (height // 8)
dct_bytes = (blocks * 16) // 8 - HEADER_SIZE
return {
'width': width,
'height': height,
'lsb': {
'capacity_bytes': lsb_bytes,
'capacity_kb': lsb_bytes / 1024,
'output': 'PNG/BMP (color)',
},
'dct': {
'capacity_bytes': dct_bytes,
'capacity_kb': dct_bytes / 1024,
'output': 'PNG or JPEG (grayscale)',
'ratio_vs_lsb': (dct_bytes / lsb_bytes * 100) if lsb_bytes > 0 else 0,
'available': HAS_SCIPY,
}
}
def embed_in_dct(
data: bytes,
carrier_image: bytes,
seed: bytes,
output_format: str = OUTPUT_FORMAT_PNG,
) -> tuple[bytes, DCTEmbedStats]:
"""
Embed data into image using DCT coefficient modification.
Args:
data: Data to embed
carrier_image: Carrier image bytes
seed: Seed for pseudo-random block selection
output_format: Output format - 'png' (default, lossless) or 'jpeg' (smaller)
Returns:
Tuple of (stego_image_bytes, stats)
Raises:
ImportError: If scipy is not available
ValueError: If data is too large for carrier
"""
_check_scipy()
# Validate output format
if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG):
raise ValueError(f"Invalid output format: {output_format}. Use 'png' or 'jpeg'")
# Calculate capacity
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)"
)
# Prepare image
image = _to_grayscale(carrier_image)
padded, original_size = _pad_to_blocks(image)
# Create header + data
header = _create_header(len(data))
payload = header + data
# Convert payload to bits
bits = []
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)
# Embed bits
bit_idx = 0
blocks_used = 0
h, w = padded.shape
for block_num in block_order:
if bit_idx >= len(bits):
break
# Calculate block position
by = (block_num // (w // BLOCK_SIZE)) * BLOCK_SIZE
bx = (block_num % (w // BLOCK_SIZE)) * BLOCK_SIZE
# Extract and transform block
block = padded[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE].copy()
dct_block = _dct2(block)
# Embed bits in selected coefficients
for pos in DEFAULT_EMBED_POSITIONS:
if bit_idx >= len(bits):
break
dct_block[pos] = _embed_bit_in_coeff(dct_block[pos], bits[bit_idx])
bit_idx += 1
# Inverse transform and store
modified_block = _idct2(dct_block)
padded[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE] = modified_block
blocks_used += 1
# Remove padding and save
result = _unpad_image(padded, original_size)
stego_bytes = _save_stego_image(result, output_format)
stats = DCTEmbedStats(
blocks_used=blocks_used,
blocks_available=capacity_info.total_blocks,
bits_embedded=len(bits),
capacity_bits=capacity_info.total_capacity_bits,
usage_percent=(len(bits) / capacity_info.total_capacity_bits) * 100,
image_width=original_size[1],
image_height=original_size[0],
output_format=output_format,
)
return stego_bytes, stats
def extract_from_dct(
stego_image: bytes,
seed: bytes,
) -> bytes:
"""
Extract data from DCT stego image.
Args:
stego_image: Stego image bytes
seed: Same seed used for embedding
Returns:
Extracted data bytes
Raises:
ImportError: If scipy is not available
ValueError: If image is not a valid DCT stego image
"""
_check_scipy()
# Prepare image
image = _to_grayscale(stego_image)
padded, original_size = _pad_to_blocks(image)
# Calculate capacity
h, w = padded.shape
blocks_x = w // BLOCK_SIZE
blocks_y = h // BLOCK_SIZE
num_blocks = blocks_x * blocks_y
# Generate same block order
block_order = _generate_block_order(num_blocks, seed)
# Extract all bits (we'll stop when we have enough based on header)
all_bits = []
for block_num in block_order:
by = (block_num // blocks_x) * BLOCK_SIZE
bx = (block_num % blocks_x) * BLOCK_SIZE
block = padded[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE]
dct_block = _dct2(block)
for pos in DEFAULT_EMBED_POSITIONS:
bit = _extract_bit_from_coeff(dct_block[pos])
all_bits.append(bit)
# Check if we have enough for header
if len(all_bits) >= HEADER_SIZE * 8:
try:
_, _, data_length = _parse_header(all_bits[:HEADER_SIZE * 8])
total_needed = (HEADER_SIZE + data_length) * 8
if len(all_bits) >= total_needed:
break
except ValueError:
# Not enough data yet or invalid, continue
pass
# Parse header
version, flags, data_length = _parse_header(all_bits)
# Extract data bits
data_bits = all_bits[HEADER_SIZE * 8:(HEADER_SIZE + data_length) * 8]
# Convert bits to bytes
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
# ============================================================================
# CONVENIENCE FUNCTIONS
# ============================================================================
def get_output_extension(output_format: str) -> str:
"""Get file extension for output format."""
if output_format == OUTPUT_FORMAT_JPEG:
return '.jpg'
return '.png'
def get_output_mimetype(output_format: str) -> str:
"""Get MIME type for output format."""
if output_format == OUTPUT_FORMAT_JPEG:
return 'image/jpeg'
return 'image/png'

View File

@@ -1,7 +1,16 @@
""" """
Stegasoo Steganography Functions Stegasoo Steganography Functions (v3.0.1)
LSB embedding and extraction with pseudo-random pixel selection. LSB and DCT embedding modes with pseudo-random pixel/coefficient selection.
New in v3.0:
- DCT domain embedding mode (requires scipy)
- embed_mode parameter for encode/decode
- Auto-detection of embedding mode
- Comparison utilities
New in v3.0.1:
- dct_output_format parameter for DCT mode ('png' or 'jpeg')
""" """
import io import io
@@ -15,6 +24,12 @@ from cryptography.hazmat.backends import default_backend
from .models import EmbedStats, FilePayload 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
from .constants import (
EMBED_MODE_LSB,
EMBED_MODE_DCT,
EMBED_MODE_AUTO,
VALID_EMBED_MODES,
)
# Lossless formats that preserve LSB data # Lossless formats that preserve LSB data
@@ -40,6 +55,48 @@ HEADER_OVERHEAD = 104 # Magic + version + date + salt + iv + tag
LENGTH_PREFIX = 4 # 4 bytes for payload length LENGTH_PREFIX = 4 # 4 bytes for payload length
ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX
# DCT output format options (v3.0.1)
DCT_OUTPUT_PNG = 'png'
DCT_OUTPUT_JPEG = 'jpeg'
# =============================================================================
# DCT MODULE LAZY LOADING
# =============================================================================
_dct_module = None
def _get_dct_module():
"""Lazy load DCT module to avoid scipy import if not needed."""
global _dct_module
if _dct_module is None:
from . import dct_steganography
_dct_module = dct_steganography
return _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')
"""
try:
dct_mod = _get_dct_module()
return dct_mod.has_dct_support()
except ImportError:
return False
# =============================================================================
# FORMAT UTILITIES
# =============================================================================
def get_output_format(input_format: Optional[str]) -> Tuple[str, str]: def get_output_format(input_format: Optional[str]) -> Tuple[str, str]:
""" """
@@ -51,12 +108,6 @@ def get_output_format(input_format: Optional[str]) -> Tuple[str, str]:
Returns: Returns:
Tuple of (PIL format string, file extension) for output Tuple of (PIL format string, file extension) for output
Falls back to PNG for lossy or unknown formats. Falls back to PNG for lossy or unknown formats.
Example:
>>> get_output_format('JPEG')
('PNG', 'png')
>>> get_output_format('PNG')
('PNG', 'png')
""" """
debug.validate(input_format is None or isinstance(input_format, str), debug.validate(input_format is None or isinstance(input_format, str),
"Input format must be string or None") "Input format must be string or None")
@@ -67,11 +118,14 @@ def get_output_format(input_format: Optional[str]) -> Tuple[str, str]:
debug.print(f"Using lossless format: {fmt} -> .{ext}") debug.print(f"Using lossless format: {fmt} -> .{ext}")
return fmt, ext return fmt, ext
# Default to PNG for lossy formats (JPEG, GIF) or unknown
debug.print(f"Input format {input_format} is lossy or unknown, defaulting to PNG") debug.print(f"Input format {input_format} is lossy or unknown, defaulting to PNG")
return 'PNG', 'png' return 'PNG', 'png'
# =============================================================================
# CAPACITY FUNCTIONS
# =============================================================================
def will_fit( def will_fit(
payload: Union[str, bytes, FilePayload, int], payload: Union[str, bytes, FilePayload, int],
carrier_image: bytes, carrier_image: bytes,
@@ -79,38 +133,16 @@ def will_fit(
include_compression_estimate: bool = True, include_compression_estimate: bool = True,
) -> dict: ) -> dict:
""" """
Check if a payload will fit in a carrier image without performing encryption. Check if a payload will fit in a carrier image (LSB mode).
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: Args:
payload: Message string, raw bytes, FilePayload, or size in bytes payload: Message string, raw bytes, FilePayload, or size in bytes
carrier_image: Carrier image bytes carrier_image: Carrier image bytes
bits_per_channel: Bits to use per color channel (1-2) bits_per_channel: Bits to use per color channel (1-2)
include_compression_estimate: Estimate compressed size (requires payload data) include_compression_estimate: Estimate compressed size
Returns: Returns:
Dict with: Dict with fits, capacity, usage info
- 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 # Determine payload size
if isinstance(payload, int): if isinstance(payload, int):
@@ -121,42 +153,35 @@ def will_fit(
payload_size = len(payload_data) payload_size = len(payload_data)
elif isinstance(payload, FilePayload): elif isinstance(payload, FilePayload):
payload_data = payload.data payload_data = payload.data
# Account for filename/mime metadata
filename_overhead = len(payload.filename.encode('utf-8')) if payload.filename else 0 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 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 payload_size = len(payload.data) + filename_overhead + mime_overhead + 5
else: else:
payload_data = payload payload_data = payload
payload_size = len(payload) payload_size = len(payload)
# Calculate capacity
capacity = calculate_capacity(carrier_image, bits_per_channel) 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_padding = 190
estimated_encrypted_size = payload_size + estimated_padding + ENCRYPTION_OVERHEAD estimated_encrypted_size = payload_size + estimated_padding + ENCRYPTION_OVERHEAD
# Compression estimate
compressed_estimate = None compressed_estimate = None
if include_compression_estimate and payload_data is not None and len(payload_data) >= 64: if include_compression_estimate and payload_data is not None and len(payload_data) >= 64:
try: try:
import zlib import zlib
compressed = zlib.compress(payload_data, level=6) compressed = zlib.compress(payload_data, level=6)
# Add compression header overhead (9 bytes)
compressed_size = len(compressed) + 9 compressed_size = len(compressed) + 9
if compressed_size < payload_size: if compressed_size < payload_size:
compressed_estimate = compressed_size compressed_estimate = compressed_size
# Use compressed size for fit calculation
estimated_encrypted_size = compressed_size + estimated_padding + ENCRYPTION_OVERHEAD estimated_encrypted_size = compressed_size + estimated_padding + ENCRYPTION_OVERHEAD
except Exception: except Exception:
pass # Ignore compression errors pass
headroom = capacity - estimated_encrypted_size headroom = capacity - estimated_encrypted_size
fits = headroom >= 0 fits = headroom >= 0
usage_percent = (estimated_encrypted_size / capacity * 100) if capacity > 0 else 100.0 usage_percent = (estimated_encrypted_size / capacity * 100) if capacity > 0 else 100.0
result = { return {
'fits': fits, 'fits': fits,
'payload_size': payload_size, 'payload_size': payload_size,
'estimated_encrypted_size': estimated_encrypted_size, 'estimated_encrypted_size': estimated_encrypted_size,
@@ -164,14 +189,206 @@ def will_fit(
'usage_percent': min(usage_percent, 100.0), 'usage_percent': min(usage_percent, 100.0),
'headroom': headroom, 'headroom': headroom,
'compressed_estimate': compressed_estimate, 'compressed_estimate': compressed_estimate,
'mode': EMBED_MODE_LSB,
} }
debug.print(f"will_fit: payload={payload_size}, encrypted~={estimated_encrypted_size}, "
f"capacity={capacity}, fits={fits}")
return result
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))
img = 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
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
def calculate_capacity_by_mode(
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,
'capacity_bits': dct_info.total_capacity_bits,
'width': dct_info.width,
'height': dct_info.height,
'total_blocks': dct_info.total_blocks,
}
else:
capacity = calculate_capacity(image_data, bits_per_channel)
img = Image.open(io.BytesIO(image_data))
width, height = img.size
return {
'mode': EMBED_MODE_LSB,
'capacity_bytes': capacity,
'capacity_bits': capacity * 8,
'width': width,
'height': height,
'bits_per_channel': bits_per_channel,
}
def will_fit_by_mode(
payload: Union[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):
payload_size = len(payload.encode('utf-8'))
elif hasattr(payload, 'data'):
payload_size = len(payload.data)
else:
payload_size = len(payload)
estimated_size = payload_size + ENCRYPTION_OVERHEAD + 190
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,
'capacity': capacity,
'usage_percent': min(usage_percent, 100.0),
'headroom': capacity - estimated_size,
'mode': EMBED_MODE_DCT,
}
else:
return will_fit(payload, carrier_image, bits_per_channel)
def get_available_modes() -> dict:
"""
Get available embedding modes and their status.
Returns:
Dict mapping mode name to availability info
"""
return {
EMBED_MODE_LSB: {
'available': True,
'name': 'Spatial LSB',
'description': 'Embed in pixel LSBs, outputs PNG/BMP',
'output_format': 'PNG (color)',
},
EMBED_MODE_DCT: {
'available': has_dct_support(),
'name': 'DCT Domain',
'description': 'Embed in DCT coefficients, outputs grayscale PNG or JPEG',
'output_formats': ['PNG (grayscale)', 'JPEG (grayscale)'],
'requires': 'scipy',
},
}
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
"""
img = Image.open(io.BytesIO(image_data))
width, height = img.size
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)
dct_bytes = dct_info.usable_capacity_bytes
dct_available = True
else:
safe_blocks = (height // 8) * (width // 8)
dct_bytes = (safe_blocks * 16) // 8 # Estimated
dct_available = False
return {
'width': width,
'height': height,
'lsb': {
'capacity_bytes': lsb_bytes,
'capacity_kb': lsb_bytes / 1024,
'available': True,
'output': 'PNG (color)',
},
'dct': {
'capacity_bytes': dct_bytes,
'capacity_kb': dct_bytes / 1024,
'available': dct_available,
'output': 'PNG or JPEG (grayscale)',
'ratio_vs_lsb': (dct_bytes / lsb_bytes * 100) if lsb_bytes > 0 else 0,
},
}
# =============================================================================
# PIXEL INDEX GENERATION
# =============================================================================
@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]:
""" """
@@ -179,17 +396,6 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> List
Uses ChaCha20 as a CSPRNG seeded by the key to deterministically Uses ChaCha20 as a CSPRNG seeded by the key to deterministically
select which pixels will hold hidden data. select which pixels will hold hidden data.
Args:
key: 32-byte key for pixel selection
num_pixels: Total pixels in image
num_needed: Number of pixels needed for embedding
Returns:
List of pixel indices
Note:
Optimizes for both small and large num_needed values.
""" """
debug.validate(len(key) == 32, f"Pixel key must be 32 bytes, got {len(key)}") 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_pixels > 0, f"Number of pixels must be positive, got {num_pixels}")
@@ -200,7 +406,6 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> List
debug.print(f"Generating {num_needed} pixel indices from {num_pixels} total pixels") debug.print(f"Generating {num_needed} pixel indices from {num_pixels} total pixels")
if num_needed >= num_pixels // 2: if num_needed >= num_pixels // 2:
# If we need many pixels, shuffle all indices
debug.print(f"Using full shuffle (needed {num_needed}/{num_pixels} pixels)") debug.print(f"Using full shuffle (needed {num_needed}/{num_pixels} pixels)")
nonce = b'\x00' * 16 nonce = b'\x00' * 16
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend()) cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
@@ -209,7 +414,6 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> List
indices = list(range(num_pixels)) indices = list(range(num_pixels))
random_bytes = encryptor.update(b'\x00' * (num_pixels * 4)) random_bytes = encryptor.update(b'\x00' * (num_pixels * 4))
# Fisher-Yates shuffle using CSPRNG
for i in range(num_pixels - 1, 0, -1): for i in range(num_pixels - 1, 0, -1):
j_bytes = random_bytes[(num_pixels - 1 - i) * 4:(num_pixels - i) * 4] j_bytes = random_bytes[(num_pixels - 1 - i) * 4:(num_pixels - i) * 4]
j = int.from_bytes(j_bytes, 'big') % (i + 1) j = int.from_bytes(j_bytes, 'big') % (i + 1)
@@ -219,7 +423,6 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> List
debug.print(f"Generated {len(selected)} indices via shuffle") debug.print(f"Generated {len(selected)} indices via shuffle")
return selected return selected
# Optimized path: generate indices directly (for smaller selections)
debug.print(f"Using optimized selection (needed {num_needed}/{num_pixels} pixels)") debug.print(f"Using optimized selection (needed {num_needed}/{num_pixels} pixels)")
selected = [] selected = []
used = set() used = set()
@@ -228,7 +431,6 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> List
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend()) cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
encryptor = cipher.encryptor() encryptor = cipher.encryptor()
# Generate more than needed to handle collisions
bytes_needed = (num_needed * 2) * 4 bytes_needed = (num_needed * 2) * 4
random_bytes = encryptor.update(b'\x00' * bytes_needed) random_bytes = encryptor.update(b'\x00' * bytes_needed)
@@ -244,11 +446,10 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> List
else: else:
collisions += 1 collisions += 1
# Generate additional if needed (rare)
if len(selected) < num_needed: if len(selected) < num_needed:
debug.print(f"Need {num_needed - len(selected)} more indices, generating...") debug.print(f"Need {num_needed - len(selected)} more indices, generating...")
extra_needed = num_needed - len(selected) extra_needed = num_needed - len(selected)
for _ in range(extra_needed * 2): # Try twice as many to account for collisions for _ in range(extra_needed * 2):
extra_bytes = encryptor.update(b'\x00' * 4) extra_bytes = encryptor.update(b'\x00' * 4)
idx = int.from_bytes(extra_bytes, 'big') % num_pixels idx = int.from_bytes(extra_bytes, 'big') % num_pixels
if idx not in used: if idx not in used:
@@ -263,43 +464,91 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> List
return selected return selected
# =============================================================================
# EMBEDDING FUNCTIONS
# =============================================================================
@debug.time @debug.time
def embed_in_image( def embed_in_image(
carrier_data: bytes, data: bytes,
encrypted_data: bytes, image_data: bytes,
pixel_key: bytes, pixel_key: bytes,
bits_per_channel: int = 1, bits_per_channel: int = 1,
output_format: Optional[str] = None output_format: Optional[str] = None,
) -> Tuple[bytes, EmbedStats, str]: embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = DCT_OUTPUT_PNG, # NEW in v3.0.1
) -> Tuple[bytes, Union[EmbedStats, 'DCTEmbedStats'], str]:
""" """
Embed encrypted data in carrier image using LSB steganography. Embed data into an image using specified mode.
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: Args:
carrier_data: Carrier image bytes data: Data to embed (encrypted payload)
encrypted_data: Data to embed image_data: Carrier image bytes
pixel_key: Key for pixel selection pixel_key: Key for pixel/coefficient selection
bits_per_channel: Bits to use per color channel (1-2) bits_per_channel: Bits per channel (LSB mode only)
output_format: Force specific output format (PNG, BMP). output_format: Force output format (LSB mode only)
If None, auto-detect from carrier (lossless) or default to PNG. embed_mode: 'lsb' (default) or 'dct'
dct_output_format: For DCT mode - 'png' (lossless) or 'jpeg' (smaller)
Returns: Returns:
Tuple of (image bytes, EmbedStats, file extension) Tuple of (stego image bytes, stats, file extension)
Raises: Raises:
CapacityError: If carrier is too small CapacityError: If data won't fit
EmbeddingError: If embedding fails EmbeddingError: If embedding fails
ImportError: If DCT mode requested but scipy unavailable
Example:
>>> stego_bytes, stats, ext = embed_in_image(carrier, encrypted, key)
>>> stats.pixels_modified
1500
""" """
debug.print(f"Embedding {len(encrypted_data)} bytes into image") debug.print(f"embed_in_image: mode={embed_mode}, data={len(data)} bytes")
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():
raise ImportError(
"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
dct_mod = _get_dct_module()
# Pass output_format to DCT module (v3.0.1)
stego_bytes, dct_stats = dct_mod.embed_in_dct(
data,
image_data,
pixel_key,
output_format=dct_output_format,
)
# 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, ext={ext}")
return stego_bytes, dct_stats, ext
# LSB MODE
return _embed_lsb(data, image_data, pixel_key, bits_per_channel, output_format)
def _embed_lsb(
data: bytes,
image_data: bytes,
pixel_key: bytes,
bits_per_channel: int = 1,
output_format: Optional[str] = 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.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}") f"bits_per_channel must be 1 or 2, got {bits_per_channel}")
@@ -307,13 +556,12 @@ def embed_in_image(
f"Pixel key must be 32 bytes, got {len(pixel_key)}") f"Pixel key must be 32 bytes, got {len(pixel_key)}")
try: try:
img_file = Image.open(io.BytesIO(carrier_data)) img_file = Image.open(io.BytesIO(image_data))
input_format = img_file.format input_format = img_file.format
debug.print(f"Carrier image: {img_file.size[0]}x{img_file.size[1]}, format: {input_format}") debug.print(f"Carrier image: {img_file.size[0]}x{img_file.size[1]}, format: {input_format}")
# Convert to RGB - this returns Image.Image, not ImageFile img = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file.copy()
img: Image.Image = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file.copy()
if img_file.mode != 'RGB': if img_file.mode != 'RGB':
debug.print(f"Converting image from {img_file.mode} to RGB") debug.print(f"Converting image from {img_file.mode} to RGB")
@@ -325,8 +573,7 @@ def embed_in_image(
debug.print(f"Image capacity: {max_bytes} bytes at {bits_per_channel} bit(s)/channel") debug.print(f"Image capacity: {max_bytes} bytes at {bits_per_channel} bit(s)/channel")
# Prepend length data_with_len = struct.pack('>I', len(data)) + data
data_with_len = struct.pack('>I', len(encrypted_data)) + encrypted_data
if len(data_with_len) > max_bytes: if len(data_with_len) > max_bytes:
debug.print(f"Capacity error: need {len(data_with_len)}, have {max_bytes}") debug.print(f"Capacity error: need {len(data_with_len)}, have {max_bytes}")
@@ -335,16 +582,13 @@ def embed_in_image(
debug.print(f"Total data to embed: {len(data_with_len)} bytes " debug.print(f"Total data to embed: {len(data_with_len)} bytes "
f"({len(data_with_len)/max_bytes*100:.1f}% of capacity)") f"({len(data_with_len)/max_bytes*100:.1f}% of capacity)")
# Convert to binary string
binary_data = ''.join(format(b, '08b') for b in data_with_len) binary_data = ''.join(format(b, '08b') for b in data_with_len)
pixels_needed = (len(binary_data) + bits_per_pixel - 1) // bits_per_pixel 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") debug.print(f"Need {pixels_needed} pixels to embed {len(binary_data)} bits")
# Get pixel indices
selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed) selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed)
# Embed data
new_pixels = list(pixels) new_pixels = list(pixels)
clear_mask = 0xFF ^ ((1 << bits_per_channel) - 1) clear_mask = 0xFF ^ ((1 << bits_per_channel) - 1)
@@ -381,11 +625,9 @@ 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 (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)
# Determine output format
if output_format: if output_format:
out_fmt = output_format.upper() out_fmt = output_format.upper()
out_ext = FORMAT_TO_EXT.get(out_fmt, 'png') out_ext = FORMAT_TO_EXT.get(out_fmt, 'png')
@@ -405,42 +647,88 @@ def embed_in_image(
bytes_embedded=len(data_with_len) bytes_embedded=len(data_with_len)
) )
debug.print(f"Embedding complete: {out_fmt} image, {len(output.getvalue())} bytes") debug.print(f"LSB embedding complete: {out_fmt} image, {len(output.getvalue())} bytes")
return output.getvalue(), stats, out_ext return output.getvalue(), stats, out_ext
except CapacityError: except CapacityError:
raise raise
except Exception as e: except Exception as e:
debug.exception(e, "embed_in_image") debug.exception(e, "embed_lsb")
raise EmbeddingError(f"Failed to embed data: {e}") from e raise EmbeddingError(f"Failed to embed data: {e}") from e
# =============================================================================
# EXTRACTION FUNCTIONS
# =============================================================================
@debug.time @debug.time
def extract_from_image( def extract_from_image(
image_data: bytes, image_data: bytes,
pixel_key: bytes, pixel_key: bytes,
bits_per_channel: int = 1 bits_per_channel: int = 1,
embed_mode: str = EMBED_MODE_AUTO,
) -> Optional[bytes]: ) -> Optional[bytes]:
""" """
Extract hidden data from a stego image. Extract hidden data from a stego image.
Args: Args:
image_data: Stego image bytes image_data: Stego image bytes
pixel_key: Key for pixel selection (must match encoding) pixel_key: Key for pixel/coefficient selection (must match encoding)
bits_per_channel: Bits per channel (must match encoding) bits_per_channel: Bits per channel (LSB mode only)
embed_mode: 'auto' (try both), 'lsb', or 'dct'
Returns: Returns:
Extracted data bytes, or None if extraction fails Extracted data bytes, or None if extraction fails
Raises:
ExtractionError: If extraction fails critically
Example:
>>> extracted = extract_from_image(stego_bytes, key)
>>> len(extracted)
1024
""" """
debug.print(f"Extracting from {len(image_data)} byte image") 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]:
"""Extract using DCT mode."""
try:
dct_mod = _get_dct_module()
return dct_mod.extract_from_dct(image_data, pixel_key)
except Exception as e:
debug.print(f"DCT extraction failed: {e}")
return None
def _extract_lsb(
image_data: bytes,
pixel_key: bytes,
bits_per_channel: int = 1
) -> Optional[bytes]:
"""
Extract using LSB mode (internal implementation).
"""
debug.print(f"LSB extracting from {len(image_data)} byte image")
debug.data(pixel_key, "Pixel key for extraction") debug.data(pixel_key, "Pixel key for extraction")
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}") f"bits_per_channel must be 1 or 2, got {bits_per_channel}")
@@ -449,8 +737,7 @@ def extract_from_image(
img_file = Image.open(io.BytesIO(image_data)) 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}") debug.print(f"Image: {img_file.size[0]}x{img_file.size[1]}, format: {img_file.format}")
# Convert to RGB img = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file.copy()
img: Image.Image = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file.copy()
if img_file.mode != 'RGB': if img_file.mode != 'RGB':
debug.print(f"Converting image from {img_file.mode} to RGB") debug.print(f"Converting image from {img_file.mode} to RGB")
@@ -460,7 +747,6 @@ def extract_from_image(
debug.print(f"Image has {num_pixels} pixels, {bits_per_pixel} bits/pixel") debug.print(f"Image has {num_pixels} pixels, {bits_per_pixel} bits/pixel")
# First, extract enough to get the length (4 bytes = 32 bits)
initial_pixels = (32 + bits_per_pixel - 1) // bits_per_pixel + 10 initial_pixels = (32 + bits_per_pixel - 1) // bits_per_pixel + 10
debug.print(f"Extracting initial {initial_pixels} pixels to find length") debug.print(f"Extracting initial {initial_pixels} pixels to find length")
@@ -473,7 +759,6 @@ def extract_from_image(
for bit_pos in range(bits_per_channel - 1, -1, -1): for bit_pos in range(bits_per_channel - 1, -1, -1):
binary_data += str((channel >> bit_pos) & 1) binary_data += str((channel >> bit_pos) & 1)
# Parse length
try: try:
length_bits = binary_data[:32] length_bits = binary_data[:32]
if len(length_bits) < 32: if len(length_bits) < 32:
@@ -486,13 +771,11 @@ def extract_from_image(
debug.print(f"Failed to parse length: {e}") debug.print(f"Failed to parse length: {e}")
return None return None
# Sanity check
max_possible = (num_pixels * bits_per_pixel) // 8 - 4 max_possible = (num_pixels * bits_per_pixel) // 8 - 4
if data_length > max_possible or data_length < 10: if data_length > max_possible or data_length < 10:
debug.print(f"Invalid data length: {data_length} (max possible: {max_possible})") debug.print(f"Invalid data length: {data_length} (max possible: {max_possible})")
return None return None
# Extract full data
total_bits = (4 + data_length) * 8 total_bits = (4 + data_length) * 8
pixels_needed = (total_bits + bits_per_pixel - 1) // bits_per_pixel pixels_needed = (total_bits + bits_per_pixel - 1) // bits_per_pixel
@@ -519,63 +802,21 @@ def extract_from_image(
if len(byte_bits) == 8: if len(byte_bits) == 8:
data_bytes.append(int(byte_bits, 2)) data_bytes.append(int(byte_bits, 2))
debug.print(f"Successfully extracted {len(data_bytes)} bytes") debug.print(f"LSB successfully extracted {len(data_bytes)} bytes")
return bytes(data_bytes) return bytes(data_bytes)
except Exception as e: except Exception as e:
debug.exception(e, "extract_from_image") debug.exception(e, "extract_lsb")
raise ExtractionError(f"Failed to extract data: {e}") from e return None
def calculate_capacity(image_data: bytes, bits_per_channel: int = 1) -> int: # =============================================================================
""" # UTILITY FUNCTIONS
Calculate the maximum message capacity of an image. # =============================================================================
Args:
image_data: Image bytes
bits_per_channel: Bits to use per color channel
Returns:
Maximum bytes that can be embedded (minus overhead)
Example:
>>> capacity = calculate_capacity(image_bytes)
>>> capacity
12000
"""
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))
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
max_bytes = (num_pixels * bits_per_pixel) // 8
# Subtract overhead: 4 bytes length + ~100 bytes header
capacity = max(0, max_bytes - ENCRYPTION_OVERHEAD)
debug.print(f"Image capacity: {capacity} bytes at {bits_per_channel} bit(s)/channel")
return capacity
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."""
Get image dimensions without loading full image.
Args:
image_data: Image bytes
Returns:
Tuple of (width, height)
Example:
>>> width, height = get_image_dimensions(image_bytes)
>>> width, height
(800, 600)
"""
debug.validate(len(image_data) > 0, "Image data cannot be empty") debug.validate(len(image_data) > 0, "Image data cannot be empty")
img = Image.open(io.BytesIO(image_data)) img = Image.open(io.BytesIO(image_data))
dimensions = img.size dimensions = img.size
debug.print(f"Image dimensions: {dimensions[0]}x{dimensions[1]}") debug.print(f"Image dimensions: {dimensions[0]}x{dimensions[1]}")
@@ -583,20 +824,7 @@ def get_image_dimensions(image_data: bytes) -> Tuple[int, int]:
def get_image_format(image_data: bytes) -> Optional[str]: def get_image_format(image_data: bytes) -> Optional[str]:
""" """Get image format (PIL format string like 'PNG', 'JPEG')."""
Get image format (PIL format string like 'PNG', 'JPEG').
Args:
image_data: Image bytes
Returns:
Format string or None if invalid
Example:
>>> format = get_image_format(image_bytes)
>>> format
'PNG'
"""
try: try:
img = Image.open(io.BytesIO(image_data)) img = Image.open(io.BytesIO(image_data))
format_str = img.format format_str = img.format
@@ -608,19 +836,7 @@ def get_image_format(image_data: bytes) -> Optional[str]:
def is_lossless_format(image_data: bytes) -> bool: def is_lossless_format(image_data: bytes) -> bool:
""" """Check if image is in a lossless format suitable for steganography."""
Check if image is in a lossless format suitable for steganography.
Args:
image_data: Image bytes
Returns:
True if format is lossless (PNG, BMP, TIFF)
Example:
>>> is_lossless_format(image_bytes)
True
"""
fmt = get_image_format(image_data) fmt = get_image_format(image_data)
is_lossless = fmt is not None and fmt.upper() in LOSSLESS_FORMATS is_lossless = fmt is not None and fmt.upper() in LOSSLESS_FORMATS
debug.print(f"Image is lossless: {is_lossless} (format: {fmt})") debug.print(f"Image is lossless: {is_lossless} (format: {fmt})")