Version 3.1.0 now with experimental DCT support.
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,26 +407,53 @@ 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}, "
|
||||||
|
f"modified {stats.pixels_modified}/{stats.total_pixels} pixels "
|
||||||
|
f"({stats.modification_percent:.2f}%)")
|
||||||
|
|
||||||
return EncodeResult(
|
return EncodeResult(
|
||||||
stego_image=stego_data,
|
stego_image=stego_data,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
pixels_modified=stats.pixels_modified,
|
pixels_modified=stats.pixels_modified,
|
||||||
total_pixels=stats.total_pixels,
|
total_pixels=stats.total_pixels,
|
||||||
capacity_used=stats.capacity_used,
|
capacity_used=stats.capacity_used,
|
||||||
date_used=date_str
|
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],
|
||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
554
src/stegasoo/dct_steganography.py
Normal file
554
src/stegasoo/dct_steganography.py
Normal 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'
|
||||||
@@ -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,13 +189,205 @@ 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})")
|
||||||
|
|||||||
Reference in New Issue
Block a user