A whoooole lotta 4.0.x fixes.
This commit is contained in:
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12.0
|
||||
86
Dockerfile
86
Dockerfile
@@ -1,19 +1,32 @@
|
||||
# Stegasoo Docker Image
|
||||
# Multi-stage build for smaller image size
|
||||
# Uses pre-built base image for fast rebuilds
|
||||
#
|
||||
# First time setup:
|
||||
# docker build -f Dockerfile.base -t stegasoo-base:latest .
|
||||
#
|
||||
# Then build normally (fast!):
|
||||
# docker-compose build
|
||||
#
|
||||
# Or if you don't have the base image, this falls back to building deps
|
||||
# (slow, but works)
|
||||
|
||||
# Pin the base image digest for reproducibility
|
||||
# To update: docker manifest inspect python:3.11-slim -v | jq -r '.[0].Descriptor.digest'
|
||||
FROM python:3.11-slim@sha256:5501a4fe605abe24de87c2f3d6cf9fd760354416a0cad0296cf284fddcdca9e2 as base
|
||||
# ============================================================================
|
||||
# ARG to switch between base image and full build
|
||||
# ============================================================================
|
||||
ARG USE_BASE_IMAGE=true
|
||||
|
||||
# ============================================================================
|
||||
# Base stage - use pre-built image if available
|
||||
# ============================================================================
|
||||
FROM stegasoo-base:latest AS base-prebuilt
|
||||
|
||||
FROM python:3.12-slim AS base-full
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
# Suppress pip "running as root" warnings during build
|
||||
ENV PIP_ROOT_USER_ACTION=ignore
|
||||
|
||||
# Install system dependencies
|
||||
# NOTE: g++ is required for jpegio C++ compilation
|
||||
# NOTE: libjpeg-dev is required for jpegio
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
@@ -21,37 +34,30 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libffi-dev \
|
||||
libzbar0 \
|
||||
libjpeg-dev \
|
||||
zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install ALL dependencies (slow path)
|
||||
RUN pip install --no-cache-dir \
|
||||
cython numpy scipy>=1.10.0 jpegio>=0.2.0 \
|
||||
argon2-cffi>=23.0.0 pillow>=10.0.0 cryptography>=41.0.0 \
|
||||
flask>=3.0.0 gunicorn>=21.0.0 \
|
||||
fastapi>=0.100.0 "uvicorn[standard]>=0.20.0" python-multipart>=0.0.6 \
|
||||
qrcode>=7.3.0 pyzbar>=0.1.9 click>=8.0.0 lz4>=4.0.0
|
||||
|
||||
# ============================================================================
|
||||
# Builder stage - install Python packages
|
||||
# Select which base to use (default: prebuilt)
|
||||
# ============================================================================
|
||||
FROM base as builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy package files (including README.md which pyproject.toml references)
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src/ src/
|
||||
COPY data/ data/
|
||||
|
||||
# Install build dependencies for jpegio, then install the package
|
||||
# jpegio requires Cython and numpy to compile
|
||||
RUN pip install --no-cache-dir cython numpy && \
|
||||
pip install --no-cache-dir ".[web]"
|
||||
FROM base-prebuilt AS base
|
||||
|
||||
# ============================================================================
|
||||
# Production stage - Web UI
|
||||
# ============================================================================
|
||||
FROM base as web
|
||||
FROM base AS web
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy installed packages from builder
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||
|
||||
# Copy application files
|
||||
# Copy application files (this is all that rebuilds normally!)
|
||||
COPY src/ src/
|
||||
COPY data/ data/
|
||||
COPY frontends/web/ frontends/web/
|
||||
@@ -75,25 +81,18 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
|
||||
# Run with gunicorn
|
||||
WORKDIR /app/frontends/web
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "60", "app:app"]
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]
|
||||
|
||||
# ============================================================================
|
||||
# API stage - REST API
|
||||
# ============================================================================
|
||||
FROM base as api
|
||||
FROM base AS api
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install API extras (includes DCT dependencies)
|
||||
COPY pyproject.toml README.md ./
|
||||
# Copy application files
|
||||
COPY src/ src/
|
||||
COPY data/ data/
|
||||
|
||||
# Install build dependencies for jpegio, then install the package
|
||||
RUN pip install --no-cache-dir cython numpy && \
|
||||
pip install --no-cache-dir ".[api]"
|
||||
|
||||
# Copy API files
|
||||
COPY frontends/api/ frontends/api/
|
||||
|
||||
# Create non-root user
|
||||
@@ -117,20 +116,13 @@ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# ============================================================================
|
||||
# CLI stage - Command line tool
|
||||
# ============================================================================
|
||||
FROM base as cli
|
||||
FROM base AS cli
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install CLI extras
|
||||
COPY pyproject.toml README.md ./
|
||||
# Copy application files
|
||||
COPY src/ src/
|
||||
COPY data/ data/
|
||||
|
||||
# Install build dependencies for jpegio (if dct extras needed), then install
|
||||
RUN pip install --no-cache-dir cython numpy && \
|
||||
pip install --no-cache-dir ".[cli,dct]"
|
||||
|
||||
# Copy CLI files
|
||||
COPY frontends/cli/ frontends/cli/
|
||||
|
||||
# Create non-root user
|
||||
|
||||
55
Dockerfile.base
Normal file
55
Dockerfile.base
Normal file
@@ -0,0 +1,55 @@
|
||||
# Stegasoo Base Image
|
||||
# Contains all slow-to-compile dependencies (jpegio, scipy, argon2)
|
||||
# Build once: docker build -f Dockerfile.base -t stegasoo-base:latest .
|
||||
# Push to registry for team use: docker push yourregistry/stegasoo-base:latest
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PIP_ROOT_USER_ACTION=ignore
|
||||
|
||||
# Install system dependencies
|
||||
# NOTE: g++ is required for jpegio C++ compilation
|
||||
# NOTE: libjpeg-dev is required for jpegio
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
libc-dev \
|
||||
libffi-dev \
|
||||
libzbar0 \
|
||||
libjpeg-dev \
|
||||
zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install the slow-to-compile packages
|
||||
# These rarely change, so they get cached in this base image
|
||||
RUN pip install --no-cache-dir \
|
||||
cython \
|
||||
numpy \
|
||||
scipy>=1.10.0 \
|
||||
jpegio>=0.2.0 \
|
||||
argon2-cffi>=23.0.0 \
|
||||
pillow>=10.0.0 \
|
||||
cryptography>=41.0.0
|
||||
|
||||
# Install web/api framework packages (also stable)
|
||||
RUN pip install --no-cache-dir \
|
||||
flask>=3.0.0 \
|
||||
gunicorn>=21.0.0 \
|
||||
fastapi>=0.100.0 \
|
||||
"uvicorn[standard]>=0.20.0" \
|
||||
python-multipart>=0.0.6 \
|
||||
qrcode>=7.3.0 \
|
||||
pyzbar>=0.1.9 \
|
||||
click>=8.0.0 \
|
||||
lz4>=4.0.0
|
||||
|
||||
# Verify key packages work
|
||||
RUN python -c "import jpegio; import scipy; import numpy; print('jpegio + scipy + numpy OK')"
|
||||
|
||||
# Label for tracking
|
||||
LABEL org.opencontainers.image.title="Stegasoo Base"
|
||||
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
|
||||
LABEL org.opencontainers.image.version="4.0.0"
|
||||
251
INSTALL.md
251
INSTALL.md
@@ -12,8 +12,6 @@ Complete installation instructions for all platforms and deployment methods.
|
||||
- [Docker](#docker)
|
||||
- [Docker Compose](#docker-compose)
|
||||
- [Optional Dependencies](#optional-dependencies)
|
||||
- [DCT Steganography (scipy + jpegio)](#dct-steganography-scipy--jpegio)
|
||||
- [Compression (lz4)](#compression-lz4)
|
||||
- [Platform-Specific Notes](#platform-specific-notes)
|
||||
- [Verification](#verification)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
@@ -22,11 +20,22 @@ Complete installation instructions for all platforms and deployment methods.
|
||||
|
||||
## Requirements
|
||||
|
||||
### ⚠️ Python Version Requirements
|
||||
|
||||
| Python Version | Status | Notes |
|
||||
|----------------|--------|-------|
|
||||
| 3.10 | ✅ Supported | |
|
||||
| 3.11 | ✅ Supported | Recommended |
|
||||
| 3.12 | ✅ Supported | Recommended |
|
||||
| 3.13 | ❌ **Not Supported** | jpegio C extension incompatible |
|
||||
|
||||
**Important:** Python 3.13 (released October 2024) is **not compatible** with jpegio due to C extension ABI changes. Use Python 3.12 or earlier.
|
||||
|
||||
### Minimum Requirements
|
||||
|
||||
| Requirement | Version |
|
||||
|-------------|---------|
|
||||
| Python | 3.10+ |
|
||||
| Requirement | Value |
|
||||
|-------------|-------|
|
||||
| Python | 3.10-3.12 |
|
||||
| RAM | 512 MB minimum (256MB for Argon2) |
|
||||
| Disk | ~100 MB |
|
||||
|
||||
@@ -36,7 +45,8 @@ Complete installation instructions for all platforms and deployment methods.
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
python3 \
|
||||
python3.12 \
|
||||
python3.12-venv \
|
||||
python3-pip \
|
||||
python3-dev \
|
||||
libzbar0 \
|
||||
@@ -44,14 +54,24 @@ sudo apt-get install -y \
|
||||
build-essential
|
||||
```
|
||||
|
||||
**Linux (Arch):**
|
||||
```bash
|
||||
# Use pyenv for Python version management
|
||||
curl https://pyenv.run | bash
|
||||
pyenv install 3.12
|
||||
pyenv local 3.12
|
||||
|
||||
sudo pacman -S zbar libjpeg-turbo base-devel
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install python@3.11 zbar jpeg
|
||||
brew install python@3.12 zbar jpeg
|
||||
xcode-select --install # For compilation
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
- Install Python 3.10+ from [python.org](https://python.org)
|
||||
- Install Python 3.12 from [python.org](https://python.org)
|
||||
- Install Visual Studio Build Tools for compilation
|
||||
|
||||
---
|
||||
@@ -62,10 +82,18 @@ xcode-select --install # For compilation
|
||||
# Clone and install everything
|
||||
git clone https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo
|
||||
|
||||
# Create venv with Python 3.12 (critical!)
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate # Linux/macOS
|
||||
# or: venv\Scripts\activate # Windows
|
||||
|
||||
# Install all dependencies
|
||||
pip install -e ".[all]"
|
||||
|
||||
# Verify
|
||||
stegasoo --version
|
||||
python -c "from stegasoo import has_dct_support; print(f'DCT: {has_dct_support()}')"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -81,11 +109,14 @@ Best for development or customization.
|
||||
git clone https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo
|
||||
|
||||
# Create virtual environment (recommended)
|
||||
python -m venv venv
|
||||
# Create virtual environment with Python 3.12 (recommended)
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate # Linux/macOS
|
||||
# or: venv\Scripts\activate # Windows
|
||||
|
||||
# Verify Python version
|
||||
python -V # Should show 3.12.x
|
||||
|
||||
# Install core library only
|
||||
pip install -e .
|
||||
|
||||
@@ -161,7 +192,7 @@ docker run -it --rm stegasoo-cli /bin/bash
|
||||
|
||||
# Run commands directly
|
||||
docker run --rm stegasoo-cli --help
|
||||
docker run --rm stegasoo-cli generate --pin --words 3
|
||||
docker run --rm stegasoo-cli generate --pin --words 4
|
||||
|
||||
# With volume for files
|
||||
docker run --rm \
|
||||
@@ -169,7 +200,7 @@ docker run --rm \
|
||||
stegasoo-cli encode \
|
||||
-r /data/ref.jpg \
|
||||
-c /data/carrier.png \
|
||||
-p "phrase words here" \
|
||||
-p "passphrase words here more" \
|
||||
--pin 123456 \
|
||||
-m "Secret message" \
|
||||
-o /data/stego.png
|
||||
@@ -237,77 +268,6 @@ Adjust based on your available RAM:
|
||||
| 4 GB | 1G | 3 |
|
||||
| 8 GB+ | 1.5G | 4 |
|
||||
|
||||
#### Development Mode
|
||||
|
||||
For development with hot-reload:
|
||||
|
||||
```bash
|
||||
# Create docker-compose.override.yml
|
||||
cat > docker-compose.override.yml << 'EOF'
|
||||
version: '3.8'
|
||||
services:
|
||||
web:
|
||||
volumes:
|
||||
- ./src:/app/src:ro
|
||||
- ./frontends/web:/app/frontends/web:ro
|
||||
environment:
|
||||
- FLASK_ENV=development
|
||||
command: ["python", "app.py"]
|
||||
api:
|
||||
volumes:
|
||||
- ./src:/app/src:ro
|
||||
- ./frontends/api:/app/frontends/api:ro
|
||||
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
EOF
|
||||
|
||||
# Start with override
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
#### Production with Nginx
|
||||
|
||||
For production deployment with SSL:
|
||||
|
||||
```yaml
|
||||
# docker-compose.prod.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
target: web
|
||||
expose:
|
||||
- "5000"
|
||||
restart: always
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
target: api
|
||||
expose:
|
||||
- "8000"
|
||||
restart: always
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certs:/etc/nginx/certs:ro
|
||||
depends_on:
|
||||
- web
|
||||
- api
|
||||
restart: always
|
||||
```
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optional Dependencies
|
||||
@@ -320,9 +280,9 @@ DCT mode enables JPEG-resilient steganography. It's automatically included with
|
||||
|
||||
```bash
|
||||
# scipy is straightforward
|
||||
pip install scipy
|
||||
pip install scipy numpy
|
||||
|
||||
# jpegio - try pip first
|
||||
# jpegio - MUST use Python 3.12 or earlier!
|
||||
pip install jpegio
|
||||
|
||||
# If pip fails, build from source
|
||||
@@ -382,19 +342,37 @@ Most straightforward installation. Use your package manager for system dependenc
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt-get install python3-dev libzbar0 libjpeg-dev
|
||||
sudo apt-get install python3.12 python3.12-venv python3-dev libzbar0 libjpeg-dev
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install stegasoo[all]
|
||||
```
|
||||
|
||||
**Fedora/RHEL:**
|
||||
```bash
|
||||
sudo dnf install python3-devel zbar libjpeg-devel
|
||||
sudo dnf install python3.12 python3-devel zbar libjpeg-devel
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install stegasoo[all]
|
||||
```
|
||||
|
||||
**Arch:**
|
||||
**Arch (using pyenv):**
|
||||
```bash
|
||||
sudo pacman -S python zbar libjpeg-turbo
|
||||
# Install pyenv
|
||||
curl https://pyenv.run | bash
|
||||
|
||||
# Add to ~/.bashrc or ~/.zshrc
|
||||
export PATH="$HOME/.pyenv/bin:$PATH"
|
||||
eval "$(pyenv init -)"
|
||||
|
||||
# Install Python 3.12
|
||||
pyenv install 3.12
|
||||
cd ~/Sources/stegasoo
|
||||
pyenv local 3.12
|
||||
|
||||
# Create venv and install
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install stegasoo[all]
|
||||
```
|
||||
|
||||
@@ -405,46 +383,39 @@ pip install stegasoo[all]
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
|
||||
# Install dependencies
|
||||
brew install python@3.11 zbar jpeg
|
||||
brew install python@3.12 zbar jpeg
|
||||
|
||||
# Create venv
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install Stegasoo
|
||||
pip3 install stegasoo[all]
|
||||
pip install stegasoo[all]
|
||||
```
|
||||
|
||||
**Apple Silicon (M1/M2/M3):**
|
||||
|
||||
jpegio may need Rosetta or native compilation:
|
||||
jpegio may need native compilation:
|
||||
```bash
|
||||
# Try native first
|
||||
# Ensure you have native Python
|
||||
arch -arm64 brew install python@3.12
|
||||
arch -arm64 python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install jpegio
|
||||
|
||||
# If fails, ensure you have native Python
|
||||
arch -arm64 brew install python@3.11
|
||||
arch -arm64 pip3 install jpegio
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
1. Install Python 3.10+ from [python.org](https://python.org)
|
||||
1. Install Python 3.12 from [python.org](https://python.org) (NOT 3.13!)
|
||||
2. Install Visual Studio Build Tools
|
||||
3. Install from pip:
|
||||
|
||||
```powershell
|
||||
python -m venv venv
|
||||
.\venv\Scripts\activate
|
||||
pip install stegasoo[all]
|
||||
```
|
||||
|
||||
**For jpegio on Windows:**
|
||||
```powershell
|
||||
# May need pre-built wheel
|
||||
pip install jpegio
|
||||
|
||||
# If fails, install build tools and compile
|
||||
pip install cython numpy
|
||||
git clone https://github.com/dwgoon/jpegio.git
|
||||
cd jpegio
|
||||
python setup.py install
|
||||
```
|
||||
|
||||
### Raspberry Pi
|
||||
|
||||
Stegasoo works on Raspberry Pi 4 (2GB+ RAM recommended):
|
||||
@@ -474,6 +445,9 @@ stegasoo --version
|
||||
|
||||
# Python import
|
||||
python -c "import stegasoo; print(stegasoo.__version__)"
|
||||
|
||||
# Check Python version (must be 3.10-3.12)
|
||||
python -V
|
||||
```
|
||||
|
||||
### Check All Features
|
||||
@@ -482,6 +456,8 @@ python -c "import stegasoo; print(stegasoo.__version__)"
|
||||
#!/usr/bin/env python3
|
||||
"""Verify Stegasoo installation."""
|
||||
|
||||
import sys
|
||||
|
||||
def check_feature(name, check_fn):
|
||||
try:
|
||||
result = check_fn()
|
||||
@@ -495,9 +471,20 @@ def check_feature(name, check_fn):
|
||||
print("Stegasoo Installation Check")
|
||||
print("=" * 40)
|
||||
|
||||
# Python version check
|
||||
py_version = sys.version_info
|
||||
print(f"\nPython: {py_version.major}.{py_version.minor}.{py_version.micro}")
|
||||
if py_version >= (3, 13):
|
||||
print(" ⚠️ WARNING: Python 3.13+ not supported!")
|
||||
print(" jpegio will not work. Use Python 3.12.")
|
||||
elif py_version >= (3, 10):
|
||||
print(" ✓ Python version OK")
|
||||
else:
|
||||
print(" ✗ Python 3.10+ required")
|
||||
|
||||
# Core
|
||||
import stegasoo
|
||||
print(f"\nVersion: {stegasoo.__version__}")
|
||||
print(f"\nStegasoo Version: {stegasoo.__version__}")
|
||||
|
||||
print("\nCore Features:")
|
||||
check_feature("Argon2", lambda: stegasoo.has_argon2())
|
||||
@@ -556,7 +543,7 @@ python check_install.py
|
||||
|
||||
```bash
|
||||
# Quick test with CLI
|
||||
stegasoo generate --pin --words 3 --json > /tmp/creds.json
|
||||
stegasoo generate --pin --words 4 --json > /tmp/creds.json
|
||||
|
||||
# Create test image
|
||||
python -c "
|
||||
@@ -570,7 +557,7 @@ img.save('/tmp/test_ref.jpg')
|
||||
stegasoo encode \
|
||||
-r /tmp/test_ref.jpg \
|
||||
-c /tmp/test_carrier.png \
|
||||
-p "test phrase words" \
|
||||
-p "test phrase words here" \
|
||||
--pin 123456 \
|
||||
-m "Hello, Stegasoo!" \
|
||||
-o /tmp/test_stego.png
|
||||
@@ -579,7 +566,7 @@ stegasoo encode \
|
||||
stegasoo decode \
|
||||
-r /tmp/test_ref.jpg \
|
||||
-s /tmp/test_stego.png \
|
||||
-p "test phrase words" \
|
||||
-p "test phrase words here" \
|
||||
--pin 123456
|
||||
```
|
||||
|
||||
@@ -589,6 +576,25 @@ stegasoo decode \
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### "jpegio crashes" / "free(): invalid size" / Core dump
|
||||
|
||||
**This is the #1 issue!** You're using Python 3.13.
|
||||
|
||||
```bash
|
||||
# Check your Python version
|
||||
python -V
|
||||
|
||||
# If it shows 3.13, you need to use 3.12
|
||||
# Option 1: Use pyenv
|
||||
pyenv install 3.12
|
||||
pyenv local 3.12
|
||||
|
||||
# Option 2: Use system Python 3.12
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -e ".[all]"
|
||||
```
|
||||
|
||||
#### "No module named 'stegasoo'"
|
||||
|
||||
```bash
|
||||
@@ -611,7 +617,7 @@ sudo apt-get install libffi-dev
|
||||
pip install --force-reinstall argon2-cffi
|
||||
```
|
||||
|
||||
#### "jpegio not available" / DCT JPEG fails
|
||||
#### "jpegio not available" (not crash, just missing)
|
||||
|
||||
```bash
|
||||
# Install build dependencies first
|
||||
@@ -656,23 +662,12 @@ deploy:
|
||||
memory: 768M
|
||||
```
|
||||
|
||||
#### Docker: Build fails on jpegio
|
||||
|
||||
The Dockerfile includes jpegio build dependencies. If still failing:
|
||||
|
||||
```bash
|
||||
# Rebuild without cache
|
||||
docker-compose build --no-cache
|
||||
|
||||
# Or build manually
|
||||
docker build --no-cache -t stegasoo-web --target web .
|
||||
```
|
||||
|
||||
#### Slow performance
|
||||
|
||||
- **Argon2 is intentionally slow** - This is a security feature
|
||||
- Expected encode/decode time: 2-5 seconds
|
||||
- DCT mode adds ~1-2 seconds for transforms
|
||||
- Large images (10MB+) may take 15-30 seconds
|
||||
|
||||
#### "Carrier image too small"
|
||||
|
||||
@@ -704,7 +699,7 @@ docker build --no-cache -t stegasoo-web --target web .
|
||||
|
||||
After installation:
|
||||
|
||||
1. **Generate credentials**: `stegasoo generate --pin --words 3`
|
||||
1. **Generate credentials**: `stegasoo generate --pin --words 4`
|
||||
2. **Read the CLI docs**: [CLI.md](CLI.md)
|
||||
3. **Try the Web UI**: `cd frontends/web && python app.py`
|
||||
4. **Explore the API**: `cd frontends/api && python main.py`
|
||||
|
||||
177
README.md
177
README.md
@@ -2,39 +2,47 @@
|
||||
|
||||
A secure steganography system for hiding encrypted messages in images using hybrid authentication.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **AES-256-GCM** authenticated encryption
|
||||
- 🧠 **Argon2id** memory-hard key derivation (256MB RAM requirement)
|
||||
- 🎲 **Pseudo-random pixel selection** defeats steganalysis
|
||||
- 📅 **Daily key rotation** with BIP-39 passphrases
|
||||
- 🔑 **Multi-factor authentication**: PIN, RSA key, or both
|
||||
- 🖼️ **Reference photo** as "something you have"
|
||||
- 🌐 **Multiple interfaces**: CLI, Web UI, REST API
|
||||
- 📁 **File embedding** - Hide any file type (PDF, ZIP, documents)
|
||||
- 📱 **QR code support** - Encode/decode RSA keys via QR codes
|
||||
- 🆕 **DCT steganography** - JPEG-resilient embedding for social media (v3.0+)
|
||||
- 🆕 **DCT steganography** - JPEG-resilient embedding for social media
|
||||
- 🆕 **Large image support** - Process images up to 14MB+
|
||||
|
||||
## What's New in v3.0.2
|
||||
## What's New in v4.0.0
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **DCT Mode** | Frequency-domain embedding survives JPEG recompression |
|
||||
| **JPEG Output** | Native JPEG output using jpegio library |
|
||||
| **Color Preservation** | DCT color mode preserves carrier image colors |
|
||||
| **Auto-Detection** | Decoder automatically detects LSB vs DCT mode |
|
||||
| **Simplified Auth** | Removed date dependency - encode/decode anytime without tracking dates |
|
||||
| **Passphrase** | Renamed from "day phrase" to "passphrase" (no more daily rotation) |
|
||||
| **Python 3.12** | Requires Python 3.10-3.12 (jpegio incompatible with 3.13) |
|
||||
| **Large Image Fix** | JPEG normalization prevents crashes with quality=100 images |
|
||||
| **Subprocess Isolation** | WebUI runs encode/decode in subprocesses for stability |
|
||||
| **4-Word Default** | Default passphrase increased from 3 to 4 words |
|
||||
|
||||
### Breaking Changes from v3.x
|
||||
|
||||
- `day_phrase` parameter renamed to `passphrase` in all APIs
|
||||
- `date_str` parameter removed from encode/decode functions
|
||||
- Python 3.13 not supported (jpegio C extension incompatibility)
|
||||
|
||||
### Embedding Mode Comparison
|
||||
|
||||
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|
||||
|------|------------------|----------------|----------|
|
||||
| **LSB** (default) | ~770 KB | ❌ No | Email, file transfer |
|
||||
| **DCT** (experimental) | ~65 KB | ✅ Yes | Social media, messaging apps |
|
||||
| **DCT** | ~65 KB | ✅ Yes | Social media, messaging apps |
|
||||
|
||||
## WebUI Preview
|
||||
|
||||
@@ -45,35 +53,36 @@ A secure steganography system for hiding encrypted messages in images using hybr
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install with all features
|
||||
# Install with all features (requires Python 3.10-3.12)
|
||||
pip install -e ".[all]"
|
||||
|
||||
# Generate credentials (memorize these!)
|
||||
stegasoo generate --pin --words 3
|
||||
stegasoo generate --pin --words 4
|
||||
|
||||
# Encode a message (LSB mode - default)
|
||||
stegasoo encode \
|
||||
--ref photo.jpg \
|
||||
--carrier meme.png \
|
||||
--phrase "apple forest thunder" \
|
||||
--passphrase "apple forest thunder mountain" \
|
||||
--pin 123456 \
|
||||
--message "Secret message"
|
||||
|
||||
# Encode for social media (DCT mode)
|
||||
stegasoo encode \
|
||||
--ref photo.jpg \
|
||||
--carrier meme.png \
|
||||
--phrase "apple forest thunder" \
|
||||
--carrier meme.jpg \
|
||||
--passphrase "apple forest thunder mountain" \
|
||||
--pin 123456 \
|
||||
--message "Secret message" \
|
||||
--mode dct \
|
||||
--format jpeg
|
||||
--dct-format jpeg \
|
||||
--dct-color color
|
||||
|
||||
# Decode (auto-detects mode)
|
||||
stegasoo decode \
|
||||
--ref photo.jpg \
|
||||
--stego stego.png \
|
||||
--phrase "apple forest thunder" \
|
||||
--passphrase "apple forest thunder mountain" \
|
||||
--pin 123456
|
||||
```
|
||||
|
||||
@@ -93,8 +102,8 @@ Stegasoo uses multiple authentication factors combined with strong cryptography:
|
||||
│ Reference Photo ──┐ │
|
||||
│ (~80-256 bits) │ │
|
||||
│ ├──► Argon2id KDF ──► AES-256-GCM Key │
|
||||
│ Day Phrase ───────┤ (256MB RAM) │
|
||||
│ (~33-132 bits) │ │
|
||||
│ Passphrase ───────┤ (256MB RAM) │
|
||||
│ (~43-132 bits) │ │
|
||||
│ │ │
|
||||
│ Static PIN ───────┤ │
|
||||
│ (~20-30 bits) │ │
|
||||
@@ -110,8 +119,8 @@ Stegasoo uses multiple authentication factors combined with strong cryptography:
|
||||
| Component | Entropy | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| Reference Photo | ~80-256 bits | Something you have |
|
||||
| Day Phrase (3-12 words) | ~33-132 bits | Something you know (rotates daily) |
|
||||
| PIN (6-9 digits) | ~20-30 bits | Something you know (static) |
|
||||
| Passphrase (3-12 words) | ~33-132 bits | Something you know |
|
||||
| PIN (6-9 digits) | ~20-30 bits | Something you know |
|
||||
| RSA Key (2048-4096 bit) | ~112-128 bits | Something you have (optional) |
|
||||
| **Combined** | **133-400+ bits** | **Beyond brute force** |
|
||||
|
||||
@@ -124,16 +133,16 @@ Stegasoo uses multiple authentication factors combined with strong cryptography:
|
||||
| Steganalysis | Pseudo-random pixel/coefficient selection |
|
||||
| GPU cracking | Argon2id requires 256MB RAM per attempt |
|
||||
| Side-channel | Constant-time operations in cryptography library |
|
||||
| JPEG recompression | DCT mode embeds in frequency domain (v3.0+) |
|
||||
| JPEG recompression | DCT mode embeds in frequency domain |
|
||||
|
||||
### Security Configurations
|
||||
|
||||
| Configuration | Entropy | Use Case |
|
||||
|--------------|---------|----------|
|
||||
| 3-word phrase + 6-digit PIN | ~133 bits | Casual private messaging |
|
||||
| 6-word phrase + 9-digit PIN | ~176 bits | Standard security |
|
||||
| 3-word phrase + RSA 2048 | ~241 bits | File-based authentication |
|
||||
| 6-word phrase + PIN + RSA 4096 | ~304 bits | Maximum security |
|
||||
| 3-word passphrase + 6-digit PIN | ~133 bits | Casual private messaging |
|
||||
| 4-word passphrase + 9-digit PIN | ~176 bits | Standard security (recommended) |
|
||||
| 4-word passphrase + RSA 2048 | ~241 bits | File-based authentication |
|
||||
| 6-word passphrase + PIN + RSA 4096 | ~304 bits | Maximum security |
|
||||
|
||||
---
|
||||
|
||||
@@ -148,17 +157,20 @@ Full-featured CLI with piping support:
|
||||
stegasoo generate --rsa --rsa-bits 4096 -o mykey.pem -p "password"
|
||||
|
||||
# Encode from file
|
||||
stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 -f secret.txt
|
||||
stegasoo encode -r ref.jpg -c carrier.png -p "passphrase words here" --pin 123456 -f secret.txt
|
||||
|
||||
# Encode for social media (DCT + JPEG)
|
||||
stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 \
|
||||
-m "Message" --mode dct --format jpeg
|
||||
# Encode for social media (DCT + JPEG with color preservation)
|
||||
stegasoo encode -r ref.jpg -c carrier.jpg -p "passphrase words here" --pin 123456 \
|
||||
-m "Message" --mode dct --dct-format jpeg --dct-color color
|
||||
|
||||
# Decode to stdout (quiet mode)
|
||||
stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q
|
||||
stegasoo decode -r ref.jpg -s stego.png -p "passphrase words here" --pin 123456 -q
|
||||
|
||||
# Check image capacity (shows both LSB and DCT)
|
||||
stegasoo info carrier.png
|
||||
# Compare LSB vs DCT capacity for an image
|
||||
stegasoo compare carrier.png
|
||||
|
||||
# Check available modes
|
||||
stegasoo modes
|
||||
```
|
||||
|
||||
📖 Full documentation: **[CLI.md](CLI.md)**
|
||||
@@ -179,7 +191,8 @@ Features:
|
||||
- Real-time entropy calculator
|
||||
- Native mobile sharing (Web Share API)
|
||||
- DCT mode with advanced options panel
|
||||
- Automatic day-of-week detection
|
||||
- Subprocess isolation for stability
|
||||
- Large image support (14MB+ tested)
|
||||
|
||||
📖 Full documentation: **[WEB_UI.md](WEB_UI.md)**
|
||||
|
||||
@@ -200,22 +213,23 @@ Example API calls:
|
||||
# Generate credentials
|
||||
curl -X POST http://localhost:8000/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"use_pin": true, "words_per_phrase": 3}'
|
||||
-d '{"use_pin": true, "passphrase_words": 4}'
|
||||
|
||||
# Encode with DCT mode
|
||||
curl -X POST http://localhost:8000/encode/multipart \
|
||||
-F "message=Secret" \
|
||||
-F "day_phrase=apple forest thunder" \
|
||||
-F "passphrase=apple forest thunder mountain" \
|
||||
-F "pin=123456" \
|
||||
-F "embedding_mode=dct" \
|
||||
-F "output_format=jpeg" \
|
||||
-F "embed_mode=dct" \
|
||||
-F "dct_output_format=jpeg" \
|
||||
-F "dct_color_mode=color" \
|
||||
-F "reference_photo=@photo.jpg" \
|
||||
-F "carrier=@meme.png" \
|
||||
-F "carrier=@meme.jpg" \
|
||||
--output stego.jpg
|
||||
|
||||
# Decode (auto-detects mode)
|
||||
curl -X POST http://localhost:8000/decode/multipart \
|
||||
-F "day_phrase=apple forest thunder" \
|
||||
-F "passphrase=apple forest thunder mountain" \
|
||||
-F "pin=123456" \
|
||||
-F "reference_photo=@photo.jpg" \
|
||||
-F "stego_image=@stego.jpg"
|
||||
@@ -234,7 +248,7 @@ stegasoo/
|
||||
│ ├── constants.py # Configuration
|
||||
│ ├── crypto.py # Encryption/decryption
|
||||
│ ├── steganography.py # LSB image embedding
|
||||
│ ├── dct_steganography.py # DCT embedding (v3.0+)
|
||||
│ ├── dct_steganography.py # DCT embedding
|
||||
│ ├── keygen.py # Credential generation
|
||||
│ ├── validation.py # Input validation
|
||||
│ ├── models.py # Data classes
|
||||
@@ -244,6 +258,9 @@ stegasoo/
|
||||
│
|
||||
├── frontends/
|
||||
│ ├── web/ # Flask web UI
|
||||
│ │ ├── app.py
|
||||
│ │ ├── subprocess_stego.py # Subprocess isolation
|
||||
│ │ └── stego_worker.py # Worker script
|
||||
│ ├── cli/ # Command-line interface
|
||||
│ └── api/ # FastAPI REST API
|
||||
│
|
||||
@@ -251,6 +268,7 @@ stegasoo/
|
||||
│ └── bip39-words.txt # BIP-39 wordlist
|
||||
│
|
||||
├── pyproject.toml # Package configuration
|
||||
├── requirements.txt # Dependencies
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Container orchestration
|
||||
│
|
||||
@@ -258,22 +276,45 @@ stegasoo/
|
||||
├── INSTALL.md # Installation guide
|
||||
├── CLI.md # CLI documentation
|
||||
├── API.md # API documentation
|
||||
└── WEB_UI.md # Web UI documentation
|
||||
├── WEB_UI.md # Web UI documentation
|
||||
├── SECURITY.md # Security documentation
|
||||
└── UNDER_THE_HOOD.md # Technical deep-dive
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Version | Notes |
|
||||
|-------------|---------|-------|
|
||||
| Python | 3.10-3.12 | **3.13 not supported** (jpegio incompatibility) |
|
||||
| RAM | 512 MB+ | 256MB for Argon2 operations |
|
||||
| Disk | ~100 MB | |
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `cryptography` | AES-256-GCM encryption |
|
||||
| `Pillow` | Image processing |
|
||||
| `argon2-cffi` | Memory-hard key derivation |
|
||||
| `scipy` | DCT transforms |
|
||||
| `jpegio` | JPEG coefficient manipulation |
|
||||
| `numpy` | Array operations |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Limits
|
||||
|
||||
| Limit | Value |
|
||||
|-------|-------|
|
||||
| Max image size | 4 megapixels |
|
||||
| Max image size | Tested up to 14MB |
|
||||
| Max message size | 50 KB |
|
||||
| Max file upload | 5 MB |
|
||||
| PIN length | 6-9 digits |
|
||||
| Phrase length | 3-12 words |
|
||||
| Passphrase length | 3-12 words |
|
||||
| RSA key sizes | 2048, 3072, 4096 bits |
|
||||
|
||||
### Environment Variables
|
||||
@@ -302,8 +343,8 @@ ruff check src/ frontends/
|
||||
mypy src/
|
||||
|
||||
# Check DCT support
|
||||
python -c "from stegasoo import has_dct_support; print(has_dct_support())"
|
||||
python -c "from stegasoo.dct_steganography import has_jpegio_support; print(has_jpegio_support())"
|
||||
python -c "from stegasoo import has_dct_support; print(f'DCT: {has_dct_support()}')"
|
||||
python -c "from stegasoo.dct_steganography import has_jpegio_support; print(f'jpegio: {has_jpegio_support()}')"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -312,15 +353,51 @@ python -c "from stegasoo.dct_steganography import has_jpegio_support; print(has_
|
||||
|
||||
| Version | Changes |
|
||||
|---------|---------|
|
||||
| **3.0.2** | Fixed JPEG output with jpegio integration |
|
||||
| **3.0.1** | Added DCT color mode, JPEG output (broken) |
|
||||
| **3.0.0** | Added DCT steganography mode |
|
||||
| **4.0.0** | Removed date dependency, renamed day_phrase→passphrase, Python 3.12 requirement, JPEG normalization fix, subprocess isolation, large image support |
|
||||
| **3.2.x** | DCT color mode, JPEG output fixes |
|
||||
| **3.0.x** | Added DCT steganography mode |
|
||||
| **2.2.x** | QR code support, file embedding |
|
||||
| **2.0.x** | Web UI, REST API, RSA keys |
|
||||
| **1.0.x** | Initial release, CLI only |
|
||||
|
||||
---
|
||||
|
||||
## Upgrading from v3.x
|
||||
|
||||
### Code Changes Required
|
||||
|
||||
```python
|
||||
# Old (v3.x)
|
||||
result = encode(
|
||||
message="secret",
|
||||
day_phrase="apple forest thunder",
|
||||
date_str="2024-01-15",
|
||||
...
|
||||
)
|
||||
|
||||
# New (v4.0)
|
||||
result = encode(
|
||||
message="secret",
|
||||
passphrase="apple forest thunder mountain",
|
||||
# No date_str needed!
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### CLI Changes
|
||||
|
||||
```bash
|
||||
# Old (v3.x)
|
||||
stegasoo encode --phrase "words" --date 2024-01-15 ...
|
||||
|
||||
# New (v4.0)
|
||||
stegasoo encode --passphrase "words here more" ...
|
||||
# or short form
|
||||
stegasoo encode -p "words here more" ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Use responsibly.
|
||||
@@ -339,3 +416,5 @@ This tool is for educational and legitimate privacy purposes only. Users are res
|
||||
- **[CLI.md](CLI.md)** - Command-line interface reference
|
||||
- **[API.md](API.md)** - REST API documentation
|
||||
- **[WEB_UI.md](WEB_UI.md)** - Web interface guide
|
||||
- **[SECURITY.md](SECURITY.md)** - Security model and threat analysis
|
||||
- **[UNDER_THE_HOOD.md](UNDER_THE_HOOD.md)** - Technical implementation details
|
||||
|
||||
123
SECURITY.md
123
SECURITY.md
@@ -2,10 +2,12 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x.x | :white_check_mark: |
|
||||
| 1.x.x | :x: |
|
||||
| Version | Supported | Notes |
|
||||
| ------- | ------------------ | ----- |
|
||||
| 4.x.x | ✅ Active | Current release |
|
||||
| 3.x.x | ⚠️ Security fixes only | Upgrade recommended |
|
||||
| 2.x.x | ❌ End of life | |
|
||||
| 1.x.x | ❌ End of life | |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -34,7 +36,7 @@ Stegasoo is designed to hide the **existence** of a secret message within an ord
|
||||
| Goal | How It's Achieved |
|
||||
|------|-------------------|
|
||||
| **Confidentiality** | AES-256-GCM encryption with Argon2id key derivation |
|
||||
| **Steganography** | LSB embedding with pseudo-random pixel selection |
|
||||
| **Steganography** | LSB/DCT embedding with pseudo-random pixel/coefficient selection |
|
||||
| **Authentication** | Multi-factor: reference photo + passphrase + PIN (or RSA key) |
|
||||
| **Integrity** | GCM authentication tag detects tampering |
|
||||
|
||||
@@ -43,20 +45,43 @@ Stegasoo is designed to hide the **existence** of a secret message within an ord
|
||||
Stegasoo combines multiple authentication factors:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Key Derivation │
|
||||
│ │
|
||||
│ Reference Photo ─────┐ │
|
||||
│ (something you have) │ │
|
||||
│ ├──► Argon2id ──► AES-256 Key │
|
||||
│ Day Passphrase ──────┤ (256MB RAM) │
|
||||
│ (something you know) │ │
|
||||
│ │ │
|
||||
│ PIN or RSA Key ──────┘ │
|
||||
│ (second factor) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Key Derivation │
|
||||
│ │
|
||||
│ Reference Photo ───────┐ │
|
||||
│ (something you have) │ │
|
||||
│ ├──► Argon2id ──► AES-256 Key │
|
||||
│ Passphrase ────────────┤ (256MB RAM) │
|
||||
│ (something you know) │ │
|
||||
│ │ │
|
||||
│ PIN or RSA Key ────────┘ │
|
||||
│ (second factor) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Changes in v4.0
|
||||
|
||||
### Removed: Date-Based Key Rotation
|
||||
|
||||
**Previous versions (v3.x and earlier):**
|
||||
- Required a date parameter for encode/decode
|
||||
- Keys rotated daily based on "day phrase"
|
||||
- Users had to remember which date they used
|
||||
|
||||
**Version 4.0:**
|
||||
- No date dependency
|
||||
- Single passphrase (no rotation)
|
||||
- Simpler but slightly reduced entropy per-message
|
||||
|
||||
**Security Impact:**
|
||||
- Minimal - the date only added ~10 bits of entropy
|
||||
- Passphrase default increased from 3 to 4 words to compensate (+11 bits)
|
||||
- Overall entropy remains similar or higher with 4-word default
|
||||
|
||||
### Renamed: day_phrase → passphrase
|
||||
|
||||
Terminology change only. No security impact.
|
||||
|
||||
## What Stegasoo Does NOT Protect Against
|
||||
|
||||
### 1. Statistical Steganalysis
|
||||
@@ -68,7 +93,9 @@ Stegasoo combines multiple authentication factors:
|
||||
- RS analysis
|
||||
- Machine learning classifiers
|
||||
|
||||
**Mitigation:** Stegasoo uses pseudo-random pixel selection (not sequential), which helps but doesn't eliminate detectability.
|
||||
**DCT mode is more resilient** but not undetectable.
|
||||
|
||||
**Mitigation:** Stegasoo uses pseudo-random pixel/coefficient selection, which helps but doesn't eliminate detectability.
|
||||
|
||||
**Recommendation:** Don't rely on Stegasoo if your adversary has:
|
||||
- Access to the original carrier image
|
||||
@@ -113,24 +140,28 @@ Stegasoo combines multiple authentication factors:
|
||||
|
||||
**Recommendation:**
|
||||
- Use 8+ digit PINs
|
||||
- Use 4+ word passphrases
|
||||
- Use 4+ word passphrases (v4.0 default)
|
||||
- Consider RSA keys for high-security use cases
|
||||
|
||||
### 5. Image Modification
|
||||
|
||||
**Risk:** Lossy compression destroys hidden data.
|
||||
|
||||
**Data is destroyed by:**
|
||||
**LSB mode - data is destroyed by:**
|
||||
- JPEG compression
|
||||
- Resizing
|
||||
- Filters/effects
|
||||
- Screenshots
|
||||
- Social media upload (Instagram, Twitter, etc.)
|
||||
- Social media upload
|
||||
|
||||
**DCT mode - more resilient but not immune:**
|
||||
- Survives moderate JPEG recompression
|
||||
- May fail with aggressive compression (quality < 70)
|
||||
- Still destroyed by resizing, filters, screenshots
|
||||
|
||||
**Recommendation:**
|
||||
- Always use lossless formats (PNG, BMP)
|
||||
- Transfer files directly (email, Signal, USB)
|
||||
- Never upload stego images to social media
|
||||
- LSB: Always use lossless formats (PNG, BMP), direct transfer
|
||||
- DCT: Use for social media, but test with your specific platform
|
||||
|
||||
### 6. Metadata Leakage
|
||||
|
||||
@@ -165,49 +196,52 @@ Stegasoo combines multiple authentication factors:
|
||||
| Encryption | AES-256-GCM | 12-byte IV, 16-byte tag |
|
||||
| Photo Hash | SHA-256 | Full image bytes |
|
||||
|
||||
### Pixel Selection
|
||||
### Pixel/Coefficient Selection
|
||||
|
||||
Pixels are selected pseudo-randomly using a key derived from:
|
||||
Selection key is derived from:
|
||||
```
|
||||
pixel_key = SHA256(photo_hash || passphrase || date || pin/rsa_signature)
|
||||
selection_key = SHA256(photo_hash || passphrase || pin/rsa_signature)
|
||||
```
|
||||
|
||||
This prevents:
|
||||
- Sequential embedding patterns
|
||||
- Statistical detection of modified regions
|
||||
|
||||
### Format
|
||||
### Message Format (v4.0)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Magic (4B) │ Version (1B) │ Date (10B) │ Salt (32B) │ IV (12B) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Encrypted Payload (AES-256-GCM) │
|
||||
│ ├── Type (1B): 0x01=text, 0x02=file │
|
||||
│ ├── Length (4B) │
|
||||
│ ├── Data (variable) │
|
||||
│ └── [Filename if file] (variable) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ GCM Auth Tag (16B) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Magic (4B) │ Version (1B) │ Salt (32B) │ IV (12B) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ Encrypted Payload (AES-256-GCM) │
|
||||
│ ├── Type (1B): 0x01=text, 0x02=file │
|
||||
│ ├── Length (4B) │
|
||||
│ ├── Data (variable) │
|
||||
│ └── [Filename if file] (variable) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ GCM Auth Tag (16B) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Note:** v4.0 removed the date field from the header, reducing overhead by 10 bytes.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Maximum Security
|
||||
|
||||
1. **Use RSA keys** instead of PINs for authentication
|
||||
2. **Use unique reference photos** not available online
|
||||
3. **Use long passphrases** (4+ random words)
|
||||
3. **Use long passphrases** (4+ random words, recommend 6+)
|
||||
4. **Transfer via secure channels** (Signal, encrypted email)
|
||||
5. **Delete stego images** after message is read
|
||||
6. **Keep software updated** for security fixes
|
||||
7. **Use DCT mode** for social media sharing
|
||||
|
||||
### For Casual Privacy
|
||||
|
||||
1. **6-digit PIN** is sufficient for non-adversarial use
|
||||
2. **3-word passphrase** provides reasonable security
|
||||
3. **PNG format** always for output
|
||||
2. **4-word passphrase** provides reasonable security (v4.0 default)
|
||||
3. **PNG format** for LSB mode output
|
||||
4. **Direct file transfer** (email attachment, AirDrop)
|
||||
|
||||
## Known Limitations
|
||||
@@ -216,8 +250,8 @@ This prevents:
|
||||
|------------|--------|--------|
|
||||
| LSB is detectable | Statistical analysis can detect hidden data | By design (tradeoff for capacity) |
|
||||
| No forward secrecy | Compromised key decrypts all messages | Use different keys per message for high security |
|
||||
| Date in header | Reveals when message was encoded | By design (enables day-specific passphrases) |
|
||||
| No deniability | Single password = single message | Future: plausible deniability layers |
|
||||
| Python 3.13 incompatible | jpegio C extension crashes | Use Python 3.12 or earlier |
|
||||
|
||||
## Security Audit Status
|
||||
|
||||
@@ -231,6 +265,9 @@ If you're a security researcher interested in auditing Stegasoo, please reach ou
|
||||
|
||||
| Version | Security Changes |
|
||||
|---------|------------------|
|
||||
| 4.0.0 | Removed date dependency, increased default passphrase to 4 words, added JPEG normalization |
|
||||
| 3.2.0 | DCT color mode added |
|
||||
| 3.0.0 | Added DCT steganography mode |
|
||||
| 2.2.0 | Added compression (no security impact) |
|
||||
| 2.1.0 | Upgraded to Argon2id, increased iterations |
|
||||
| 2.0.0 | Added RSA key support |
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work under the hood.
|
||||
|
||||
**Version 4.0** - Updated for simplified authentication (no date dependency)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
@@ -20,14 +22,14 @@ A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work unde
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ STEGASOO ARCHITECTURE │
|
||||
│ STEGASOO ARCHITECTURE (v4.0) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ INPUTS PROCESSING OUTPUT │
|
||||
│ ─────── ────────── ────── │
|
||||
│ │
|
||||
│ Reference Photo ─┐ │
|
||||
│ Day Phrase ──────┼──► Argon2id KDF ──► AES-256 Key │
|
||||
│ Passphrase ──────┼──► Argon2id KDF ──► AES-256 Key │
|
||||
│ PIN/RSA Key ─────┘ │ │
|
||||
│ ▼ │
|
||||
│ Message/File ────────────────────────► AES-256-GCM ──► Ciphertext │
|
||||
@@ -39,6 +41,15 @@ A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work unde
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### v4.0 Changes
|
||||
|
||||
| Change | v3.x | v4.0 |
|
||||
|--------|------|------|
|
||||
| Authentication | day_phrase + date | passphrase (no date) |
|
||||
| Default words | 3 | 4 |
|
||||
| Header size | 75 bytes | 65 bytes (no date field) |
|
||||
| Python support | 3.10+ | 3.10-3.12 only |
|
||||
|
||||
### Module Responsibilities
|
||||
|
||||
| Module | File | Purpose |
|
||||
@@ -58,10 +69,10 @@ A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work unde
|
||||
|
||||
```python
|
||||
# validation.py
|
||||
def validate_encode_inputs(reference_photo, carrier, message, day_phrase, pin, rsa_key):
|
||||
def validate_encode_inputs(reference_photo, carrier, message, passphrase, pin, rsa_key):
|
||||
# Check image dimensions (max 24 megapixels)
|
||||
# Validate PIN format (6-9 digits)
|
||||
# Validate day phrase (3-12 words from BIP-39)
|
||||
# Validate passphrase (3-12 words from BIP-39)
|
||||
# Check payload size vs carrier capacity
|
||||
# Ensure reference != carrier (security)
|
||||
```
|
||||
@@ -88,27 +99,28 @@ def get_image_hash(image_bytes: bytes) -> bytes:
|
||||
|
||||
```python
|
||||
# crypto.py
|
||||
def derive_key(reference_hash: bytes, day_phrase: str, pin: str,
|
||||
def derive_key(reference_hash: bytes, passphrase: str, pin: str,
|
||||
rsa_signature: bytes = None) -> bytes:
|
||||
"""
|
||||
Combine all authentication factors into one AES key.
|
||||
v4.0: No date parameter - simplified authentication.
|
||||
"""
|
||||
# Concatenate all factors
|
||||
key_material = reference_hash + day_phrase.encode() + pin.encode()
|
||||
key_material = reference_hash + passphrase.encode() + pin.encode()
|
||||
|
||||
if rsa_signature:
|
||||
key_material += rsa_signature
|
||||
|
||||
# Argon2id parameters (memory-hard to resist GPU attacks)
|
||||
# - Memory: 256 MB
|
||||
# - Iterations: 3
|
||||
# - Iterations: 4
|
||||
# - Parallelism: 4
|
||||
# - Output: 32 bytes (256 bits)
|
||||
|
||||
key = argon2.hash_password_raw(
|
||||
password=key_material,
|
||||
salt=random_salt, # 16 bytes, stored with ciphertext
|
||||
time_cost=3,
|
||||
time_cost=4,
|
||||
memory_cost=262144, # 256 MB
|
||||
parallelism=4,
|
||||
hash_len=32,
|
||||
@@ -186,16 +198,19 @@ def encrypt(plaintext: bytes, key: bytes) -> bytes:
|
||||
def build_stego_header(encrypted_data: bytes, mode: str) -> bytes:
|
||||
"""
|
||||
Build the header that precedes embedded data.
|
||||
v4.0: Simplified header (no date field)
|
||||
"""
|
||||
# Header format:
|
||||
# [8 bytes] - Magic number: "STGSOO" + version + mode
|
||||
# [4 bytes] - Magic number: "STGO" (v4)
|
||||
# [1 byte] - Version (0x04)
|
||||
# [1 byte] - Mode (0x01=LSB, 0x02=DCT)
|
||||
# [4 bytes] - Payload length
|
||||
# [N bytes] - Encrypted payload
|
||||
|
||||
if mode == 'lsb':
|
||||
magic = b'STGSOO\x03\x01' # v3, mode 1 (LSB)
|
||||
magic = b'STGO\x04\x01' # v4, mode 1 (LSB)
|
||||
else:
|
||||
magic = b'STGSOO\x03\x02' # v3, mode 2 (DCT)
|
||||
magic = b'STGO\x04\x02' # v4, mode 2 (DCT)
|
||||
|
||||
length = struct.pack('>I', len(encrypted_data))
|
||||
|
||||
@@ -210,452 +225,364 @@ This is where LSB and DCT diverge. See detailed sections below.
|
||||
|
||||
## The Decoding Pipeline
|
||||
|
||||
Decoding is essentially the reverse:
|
||||
|
||||
```
|
||||
Stego Image ──► Extract Header ──► Detect Mode ──► Extract Data ──► Decrypt ──► Decompress ──► Output
|
||||
│ │
|
||||
▼ ▼
|
||||
Validate Magic LSB or DCT
|
||||
Get Payload Size extraction
|
||||
```
|
||||
|
||||
### Step 1: Mode Detection
|
||||
|
||||
```python
|
||||
# __init__.py
|
||||
def decode(stego_image: bytes, reference_photo: bytes,
|
||||
day_phrase: str, pin: str, rsa_key: bytes = None) -> bytes:
|
||||
def detect_mode(stego_image: bytes) -> str:
|
||||
"""
|
||||
Auto-detect embedding mode and decode.
|
||||
Detect which embedding mode was used.
|
||||
Checks format and magic bytes.
|
||||
"""
|
||||
# Try to read magic header from LSB positions first
|
||||
header = extract_header_lsb(stego_image, seed=derive_seed(...))
|
||||
img = Image.open(io.BytesIO(stego_image))
|
||||
|
||||
if header.startswith(b'STGSOO'):
|
||||
version = header[6]
|
||||
mode = header[7]
|
||||
|
||||
if mode == 0x01:
|
||||
return decode_lsb(...)
|
||||
elif mode == 0x02:
|
||||
return decode_dct(...)
|
||||
# JPEG images with JPGS magic = DCT mode with jpegio
|
||||
if img.format == 'JPEG':
|
||||
# Check for jpegio magic
|
||||
return 'dct'
|
||||
|
||||
raise InvalidStegoError("No valid Stegasoo header found")
|
||||
# PNG/BMP: Read first few bytes from LSB
|
||||
# Check for STGO or DCTS magic
|
||||
magic = extract_header_lsb(stego_image, 6)
|
||||
|
||||
if magic.startswith(b'STGO'):
|
||||
mode_byte = magic[5]
|
||||
return 'lsb' if mode_byte == 0x01 else 'dct'
|
||||
elif magic.startswith(b'DCTS'):
|
||||
return 'dct'
|
||||
|
||||
return 'lsb' # Default fallback
|
||||
```
|
||||
|
||||
### Step 2: Key Re-derivation
|
||||
|
||||
The receiver must provide the **exact same inputs** to derive the same key:
|
||||
- Same reference photo (processed to same hash)
|
||||
- Same day phrase
|
||||
- Same PIN
|
||||
- Same RSA key (if used)
|
||||
```python
|
||||
# Same process as encoding
|
||||
def derive_key_for_decode(reference_hash, passphrase, pin, rsa_signature=None):
|
||||
# Must use SAME parameters as encoding
|
||||
# No date parameter in v4.0
|
||||
return derive_key(reference_hash, passphrase, pin, rsa_signature)
|
||||
```
|
||||
|
||||
Any mismatch → wrong key → decryption fails (GCM tag mismatch)
|
||||
|
||||
### Step 3: Extraction & Decryption
|
||||
### Step 3: Data Extraction
|
||||
|
||||
```python
|
||||
# crypto.py
|
||||
def decrypt(encrypted_blob: bytes, key: bytes) -> bytes:
|
||||
def extract_data(stego_image: bytes, mode: str) -> bytes:
|
||||
"""
|
||||
Decrypt AES-256-GCM encrypted data.
|
||||
Extract raw bytes from stego image.
|
||||
Mode-specific extraction.
|
||||
"""
|
||||
salt = encrypted_blob[0:16]
|
||||
nonce = encrypted_blob[16:28]
|
||||
tag = encrypted_blob[28:44]
|
||||
ciphertext = encrypted_blob[44:]
|
||||
if mode == 'dct':
|
||||
return extract_from_dct(stego_image, pixel_key)
|
||||
else:
|
||||
return extract_from_lsb(stego_image, pixel_key)
|
||||
```
|
||||
|
||||
### Step 4: Decryption & Payload Recovery
|
||||
|
||||
```python
|
||||
def decrypt_and_recover(encrypted_data: bytes, key: bytes) -> Union[str, bytes]:
|
||||
"""
|
||||
Decrypt and extract original message/file.
|
||||
"""
|
||||
# Parse header
|
||||
salt = encrypted_data[:16]
|
||||
nonce = encrypted_data[16:28]
|
||||
tag = encrypted_data[28:44]
|
||||
ciphertext = encrypted_data[44:]
|
||||
|
||||
# Decrypt
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
|
||||
|
||||
try:
|
||||
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
|
||||
return plaintext
|
||||
except ValueError:
|
||||
raise DecryptionError("Authentication failed - wrong key or corrupted data")
|
||||
# Decompress if needed
|
||||
if plaintext[0] & FLAG_COMPRESSED:
|
||||
plaintext = lz4.frame.decompress(plaintext[5:])
|
||||
|
||||
# Extract payload
|
||||
return parse_payload(plaintext)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LSB Mode Deep Dive
|
||||
|
||||
### What is LSB Steganography?
|
||||
### How LSB Embedding Works
|
||||
|
||||
LSB (Least Significant Bit) hides data in the lowest bit of each color channel. Changing the LSB changes the pixel value by at most 1 (e.g., 142 → 141 or 143), which is imperceptible.
|
||||
LSB (Least Significant Bit) embedding modifies the lowest bit of each color channel in selected pixels.
|
||||
|
||||
```
|
||||
Original pixel (RGB): [142, 87, 203]
|
||||
Binary: [10001110, 01010111, 11001011]
|
||||
^ ^ ^
|
||||
LSB LSB LSB
|
||||
Original Pixel (RGB):
|
||||
R: 11010110 G: 01101001 B: 10110100
|
||||
↓ ↓ ↓
|
||||
└─────────┴─────────┘
|
||||
3 bits available
|
||||
|
||||
To embed bits [1, 0, 1]:
|
||||
Modified: [10001111, 01010110, 11001011]
|
||||
New pixel (RGB): [143, 86, 203]
|
||||
|
||||
Difference: Imperceptible to human eye
|
||||
After embedding "101":
|
||||
R: 1101011[1] G: 0110100[0] B: 1011010[1]
|
||||
↑ ↑ ↑
|
||||
modified modified modified
|
||||
```
|
||||
|
||||
### LSB Embedding Process
|
||||
### Pixel Selection Algorithm
|
||||
|
||||
```python
|
||||
# steganography.py
|
||||
def embed_lsb(carrier_image: bytes, payload: bytes, seed: bytes) -> bytes:
|
||||
def select_pixels(carrier_shape, num_bits, seed: bytes) -> List[Tuple[int, int, int]]:
|
||||
"""
|
||||
Embed payload using LSB steganography with pseudo-random pixel selection.
|
||||
Generate pseudo-random pixel coordinates.
|
||||
Distributes modifications across entire image.
|
||||
"""
|
||||
img = Image.open(io.BytesIO(carrier_image))
|
||||
pixels = np.array(img)
|
||||
height, width, channels = carrier_shape
|
||||
total_positions = height * width * 3 # RGB channels
|
||||
|
||||
# 1. Calculate capacity
|
||||
height, width, channels = pixels.shape
|
||||
total_bits = height * width * channels # 3 bits per pixel (RGB)
|
||||
capacity_bytes = total_bits // 8
|
||||
# Use seed to generate reproducible random order
|
||||
rng = np.random.RandomState(int.from_bytes(seed[:4], 'big'))
|
||||
all_positions = np.arange(total_positions)
|
||||
rng.shuffle(all_positions)
|
||||
|
||||
if len(payload) > capacity_bytes:
|
||||
raise CapacityError(f"Payload {len(payload)} > capacity {capacity_bytes}")
|
||||
# Convert flat indices to (y, x, channel)
|
||||
selected = []
|
||||
for idx in all_positions[:num_bits]:
|
||||
y = idx // (width * 3)
|
||||
x = (idx % (width * 3)) // 3
|
||||
c = idx % 3
|
||||
selected.append((y, x, c))
|
||||
|
||||
# 2. Generate pseudo-random pixel order (defeats steganalysis)
|
||||
rng = np.random.default_rng(seed=int.from_bytes(seed[:8], 'big'))
|
||||
|
||||
# Create list of all (y, x, channel) positions
|
||||
positions = [(y, x, c)
|
||||
for y in range(height)
|
||||
for x in range(width)
|
||||
for c in range(channels)]
|
||||
|
||||
# Shuffle deterministically based on seed
|
||||
rng.shuffle(positions)
|
||||
|
||||
# 3. Convert payload to bits
|
||||
payload_bits = np.unpackbits(np.frombuffer(payload, dtype=np.uint8))
|
||||
|
||||
# 4. Embed each bit
|
||||
for i, bit in enumerate(payload_bits):
|
||||
y, x, c = positions[i]
|
||||
|
||||
# Clear LSB, then set to our bit
|
||||
pixels[y, x, c] = (pixels[y, x, c] & 0xFE) | bit
|
||||
|
||||
# 5. Save as PNG (lossless - preserves LSBs)
|
||||
output = io.BytesIO()
|
||||
Image.fromarray(pixels).save(output, format='PNG')
|
||||
return output.getvalue()
|
||||
return selected
|
||||
```
|
||||
|
||||
### Why Pseudo-Random Positions?
|
||||
|
||||
Sequential embedding (left-to-right, top-to-bottom) creates statistical patterns detectable by steganalysis tools. Random scattering:
|
||||
|
||||
```
|
||||
Sequential (detectable): Random (undetectable):
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│█████████████........│ │.█..█.█...█..█.█..█.│
|
||||
│.....................│ │█...█..█.█..█...█.█.│
|
||||
│.....................│ │..█.█..█...█.█.█...█│
|
||||
│.....................│ │.█..█.█..█...█..█..█│
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
↑ Pattern visible ↑ Uniform distribution
|
||||
```
|
||||
|
||||
### LSB Extraction Process
|
||||
### Embedding Process
|
||||
|
||||
```python
|
||||
# steganography.py
|
||||
def extract_lsb(stego_image: bytes, seed: bytes, length: int) -> bytes:
|
||||
def embed_lsb(carrier: np.ndarray, data: bytes, seed: bytes) -> np.ndarray:
|
||||
"""
|
||||
Extract payload from LSB positions.
|
||||
Embed data using LSB substitution.
|
||||
"""
|
||||
img = Image.open(io.BytesIO(stego_image))
|
||||
pixels = np.array(img)
|
||||
bits = bytes_to_bits(data)
|
||||
positions = select_pixels(carrier.shape, len(bits), seed)
|
||||
|
||||
# Regenerate same position sequence
|
||||
rng = np.random.default_rng(seed=int.from_bytes(seed[:8], 'big'))
|
||||
positions = [...] # Same as embedding
|
||||
rng.shuffle(positions)
|
||||
stego = carrier.copy()
|
||||
for i, (y, x, c) in enumerate(positions):
|
||||
# Clear LSB and set to our bit
|
||||
stego[y, x, c] = (stego[y, x, c] & 0xFE) | bits[i]
|
||||
|
||||
# Extract bits
|
||||
bits = []
|
||||
for i in range(length * 8):
|
||||
y, x, c = positions[i]
|
||||
bits.append(pixels[y, x, c] & 1) # Get LSB
|
||||
|
||||
# Convert bits to bytes
|
||||
return np.packbits(bits).tobytes()
|
||||
return stego
|
||||
```
|
||||
|
||||
### LSB Capacity Formula
|
||||
### Capacity Calculation
|
||||
|
||||
```
|
||||
Capacity (bytes) = (Width × Height × 3 channels) / 8 bits
|
||||
|
||||
Example: 1920×1080 image
|
||||
= 1920 × 1080 × 3 / 8
|
||||
= 777,600 bytes (~759 KB)
|
||||
```python
|
||||
def calculate_lsb_capacity(width: int, height: int) -> int:
|
||||
"""
|
||||
Calculate maximum payload size for LSB mode.
|
||||
"""
|
||||
total_bits = width * height * 3 # 3 bits per pixel (RGB)
|
||||
header_bits = 10 * 8 # 10-byte stego header
|
||||
available_bits = total_bits - header_bits
|
||||
|
||||
return available_bits // 8 # Convert to bytes
|
||||
```
|
||||
|
||||
### LSB Limitations
|
||||
|
||||
| Limitation | Description |
|
||||
|------------|-------------|
|
||||
| **JPEG destroys data** | JPEG recompression changes pixel values, destroying LSBs |
|
||||
| **Screenshots may corrupt** | Screen capture may alter pixels |
|
||||
| **Social media unusable** | All platforms recompress images |
|
||||
| **Steganalysis vulnerable** | Chi-square analysis can detect LSB patterns |
|
||||
**Example capacities:**
|
||||
- 1920×1080: ~770 KB
|
||||
- 4000×3000: ~4.5 MB
|
||||
- 800×600: ~180 KB
|
||||
|
||||
---
|
||||
|
||||
## DCT Mode Deep Dive
|
||||
|
||||
### What is DCT Steganography?
|
||||
### How DCT Embedding Works
|
||||
|
||||
DCT (Discrete Cosine Transform) hides data in the frequency coefficients of the image rather than raw pixels. JPEG images are already stored as DCT coefficients, making this approach survive JPEG recompression.
|
||||
|
||||
### Understanding DCT Basics
|
||||
DCT (Discrete Cosine Transform) mode embeds data in the frequency-domain coefficients, making it resilient to JPEG compression.
|
||||
|
||||
```
|
||||
Spatial Domain (pixels) Frequency Domain (DCT)
|
||||
┌────────────────────┐ ┌────────────────────┐
|
||||
│ 52 55 61 66 ... │ │ 415 -30 -6 3 │ DC + low freq
|
||||
│ 62 59 55 90 ... │ DCT │ -22 -17 5 -3 │
|
||||
│ 63 59 66 88 ... │ ────► │ -9 9 4 2 │
|
||||
│ 67 61 68 96 ... │ │ -4 2 1 -1 │ high freq
|
||||
│ ... │ │ ... │
|
||||
└────────────────────┘ └────────────────────┘
|
||||
8×8 pixel block 8×8 coefficient block
|
||||
Image Block (8×8 pixels)
|
||||
↓
|
||||
DCT Transform
|
||||
↓
|
||||
DCT Coefficients (8×8)
|
||||
┌────────────────────┐
|
||||
│ DC AC₁ AC₂ AC₃ ...│ ← Lower frequencies (top-left)
|
||||
│ AC₄ AC₅ AC₆ ... │
|
||||
│ ... ... │ ← Mid frequencies (embed here)
|
||||
│ ... ... │
|
||||
│ AC₆₃ ────│ ← Higher frequencies (bottom-right)
|
||||
└────────────────────┘
|
||||
↓
|
||||
Modify select ACs
|
||||
↓
|
||||
IDCT Transform
|
||||
↓
|
||||
Modified Image Block
|
||||
```
|
||||
|
||||
**Key insight**: Modifying mid-frequency coefficients is:
|
||||
1. Less visible than modifying pixels
|
||||
2. Survives JPEG recompression (coefficients are preserved)
|
||||
3. Harder to detect statistically
|
||||
|
||||
### DCT Embedding Process (scipy-based, PNG output)
|
||||
### Coefficient Selection
|
||||
|
||||
```python
|
||||
# dct_steganography.py
|
||||
def embed_in_dct(payload: bytes, carrier: bytes, seed: bytes,
|
||||
output_format: str = 'png', color_mode: str = 'color') -> bytes:
|
||||
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 positions 4-20 (mid-frequency, good balance)
|
||||
DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20] # 16 positions per block
|
||||
```
|
||||
|
||||
**Why mid-frequency?**
|
||||
- DC coefficient (0,0): Too visible, contains brightness
|
||||
- Low AC: Visible changes, but survives compression
|
||||
- Mid AC: Best balance of invisibility + resilience
|
||||
- High AC: Invisible but destroyed by compression
|
||||
|
||||
### Block Processing
|
||||
|
||||
```python
|
||||
def embed_in_block(block: np.ndarray, bits: List[int]) -> np.ndarray:
|
||||
"""
|
||||
Embed payload in DCT coefficients.
|
||||
Embed bits in a single 8×8 block.
|
||||
"""
|
||||
img = Image.open(io.BytesIO(carrier))
|
||||
# Forward DCT
|
||||
dct_block = dct_2d(block)
|
||||
|
||||
if color_mode == 'grayscale':
|
||||
img = img.convert('L')
|
||||
channels = [np.array(img)]
|
||||
else:
|
||||
# Convert to YCbCr (JPEG color space)
|
||||
# Embed only in Y (luminance) channel
|
||||
ycbcr = img.convert('YCbCr')
|
||||
y, cb, cr = ycbcr.split()
|
||||
channels = [np.array(y)]
|
||||
color_channels = (cb, cr) # Preserve for reconstruction
|
||||
|
||||
y_channel = channels[0].astype(float)
|
||||
height, width = y_channel.shape
|
||||
|
||||
# 1. Pad to multiple of 8
|
||||
pad_h = (8 - height % 8) % 8
|
||||
pad_w = (8 - width % 8) % 8
|
||||
y_padded = np.pad(y_channel, ((0, pad_h), (0, pad_w)), mode='edge')
|
||||
|
||||
# 2. Process 8x8 blocks
|
||||
blocks_h = y_padded.shape[0] // 8
|
||||
blocks_w = y_padded.shape[1] // 8
|
||||
total_blocks = blocks_h * blocks_w
|
||||
|
||||
# 3. Generate random block order
|
||||
rng = np.random.default_rng(seed=int.from_bytes(seed[:8], 'big'))
|
||||
block_indices = list(range(total_blocks))
|
||||
rng.shuffle(block_indices)
|
||||
|
||||
# 4. Embed using QIM (Quantization Index Modulation)
|
||||
payload_bits = np.unpackbits(np.frombuffer(payload, dtype=np.uint8))
|
||||
bit_index = 0
|
||||
|
||||
# Positions within 8x8 block for embedding (mid-frequency)
|
||||
# Avoid DC (0,0) and very high frequencies
|
||||
embed_positions = [(1,2), (2,1), (2,2), (1,3), (3,1), (2,3), (3,2), (3,3),
|
||||
(0,4), (4,0), (1,4), (4,1), (2,4), (4,2), (3,4), (4,3)]
|
||||
|
||||
DELTA = 16 # Quantization step
|
||||
|
||||
for block_idx in block_indices:
|
||||
if bit_index >= len(payload_bits):
|
||||
# Embed using quantization
|
||||
for i, pos in enumerate(DEFAULT_EMBED_POSITIONS):
|
||||
if i >= len(bits):
|
||||
break
|
||||
|
||||
by = (block_idx // blocks_w) * 8
|
||||
bx = (block_idx % blocks_w) * 8
|
||||
|
||||
# Extract 8x8 block
|
||||
block = y_padded[by:by+8, bx:bx+8]
|
||||
|
||||
# Apply DCT
|
||||
dct_block = scipy.fftpack.dct(
|
||||
scipy.fftpack.dct(block.T, norm='ortho').T,
|
||||
norm='ortho'
|
||||
)
|
||||
|
||||
# Embed bits in each position
|
||||
for pos in embed_positions:
|
||||
if bit_index >= len(payload_bits):
|
||||
break
|
||||
|
||||
coef = dct_block[pos]
|
||||
bit = payload_bits[bit_index]
|
||||
|
||||
# QIM embedding:
|
||||
# Quantize coefficient, then adjust to encode bit
|
||||
quantized = round(coef / DELTA)
|
||||
if quantized % 2 != bit:
|
||||
quantized += 1 if bit else -1
|
||||
dct_block[pos] = quantized * DELTA
|
||||
|
||||
bit_index += 1
|
||||
|
||||
# Apply inverse DCT
|
||||
block = scipy.fftpack.idct(
|
||||
scipy.fftpack.idct(dct_block.T, norm='ortho').T,
|
||||
norm='ortho'
|
||||
)
|
||||
|
||||
y_padded[by:by+8, bx:bx+8] = block
|
||||
coef = dct_block[pos[0], pos[1]]
|
||||
# Quantize and modify LSB
|
||||
quantized = round(coef / QUANT_STEP)
|
||||
if (quantized % 2) != bits[i]:
|
||||
quantized += 1 if coef > 0 else -1
|
||||
dct_block[pos[0], pos[1]] = quantized * QUANT_STEP
|
||||
|
||||
# 5. Reconstruct image
|
||||
y_modified = y_padded[:height, :width] # Remove padding
|
||||
y_modified = np.clip(y_modified, 0, 255).astype(np.uint8)
|
||||
|
||||
if color_mode == 'color':
|
||||
# Merge modified Y with original Cb, Cr
|
||||
result = Image.merge('YCbCr', (
|
||||
Image.fromarray(y_modified),
|
||||
color_channels[0],
|
||||
color_channels[1]
|
||||
)).convert('RGB')
|
||||
else:
|
||||
result = Image.fromarray(y_modified)
|
||||
|
||||
# 6. Save
|
||||
output = io.BytesIO()
|
||||
if output_format == 'jpeg':
|
||||
result.save(output, format='JPEG', quality=95)
|
||||
else:
|
||||
result.save(output, format='PNG')
|
||||
|
||||
return output.getvalue()
|
||||
# Inverse DCT
|
||||
return idct_2d(dct_block)
|
||||
```
|
||||
|
||||
### DCT Embedding Process (jpegio-based, native JPEG)
|
||||
### jpegio Integration (Native JPEG Output)
|
||||
|
||||
```python
|
||||
# dct_steganography.py
|
||||
def embed_in_jpeg_native(payload: bytes, jpeg_carrier: bytes, seed: bytes) -> bytes:
|
||||
def embed_jpegio(data: bytes, carrier_jpeg: bytes, seed: bytes) -> bytes:
|
||||
"""
|
||||
Embed directly in JPEG DCT coefficients using jpegio.
|
||||
This preserves coefficients WITHOUT re-encoding.
|
||||
Preserves JPEG structure perfectly.
|
||||
|
||||
Note: Requires Python 3.12 or earlier (jpegio incompatible with 3.13)
|
||||
"""
|
||||
import jpegio
|
||||
import jpegio as jio
|
||||
|
||||
# 1. Read JPEG structure (coefficients, quantization tables, etc.)
|
||||
jpeg = jpegio.read(io.BytesIO(jpeg_carrier))
|
||||
# Normalize problematic JPEGs (quality=100 causes crashes)
|
||||
carrier_jpeg = normalize_jpeg_for_jpegio(carrier_jpeg)
|
||||
|
||||
# Y channel coefficients (component 0)
|
||||
y_coefs = jpeg.coef_arrays[0] # Shape: (height/8, width/8, 8, 8)
|
||||
# Read existing JPEG coefficients
|
||||
jpeg = jio.read(temp_file_from_bytes(carrier_jpeg))
|
||||
coef_array = jpeg.coef_arrays[0] # Y channel
|
||||
|
||||
# 2. Find embeddable coefficients
|
||||
# Rules:
|
||||
# - Skip DC coefficient (index 0,0 in each block)
|
||||
# - Skip zero coefficients (would become non-zero, visible)
|
||||
# - Skip ±1 coefficients (LSB change might zero them)
|
||||
# Find usable coefficients (magnitude >= 2, non-DC)
|
||||
positions = get_usable_positions(coef_array)
|
||||
order = generate_order(len(positions), seed)
|
||||
|
||||
embeddable = []
|
||||
for by in range(y_coefs.shape[0]):
|
||||
for bx in range(y_coefs.shape[1]):
|
||||
for i in range(8):
|
||||
for j in range(8):
|
||||
if i == 0 and j == 0: # Skip DC
|
||||
continue
|
||||
coef = y_coefs[by, bx, i, j]
|
||||
if abs(coef) >= 2: # Safe to modify
|
||||
embeddable.append((by, bx, i, j))
|
||||
|
||||
# 3. Shuffle positions
|
||||
rng = np.random.default_rng(seed=int.from_bytes(seed[:8], 'big'))
|
||||
rng.shuffle(embeddable)
|
||||
|
||||
# 4. Embed bits in LSB of coefficients
|
||||
payload_bits = np.unpackbits(np.frombuffer(payload, dtype=np.uint8))
|
||||
|
||||
for i, bit in enumerate(payload_bits):
|
||||
if i >= len(embeddable):
|
||||
raise CapacityError("Payload too large for carrier")
|
||||
# Embed by modifying coefficient LSBs
|
||||
bits = bytes_to_bits(data)
|
||||
for i, pos_idx in enumerate(order[:len(bits)]):
|
||||
row, col = positions[pos_idx]
|
||||
coef = coef_array[row, col]
|
||||
|
||||
by, bx, ci, cj = embeddable[i]
|
||||
coef = y_coefs[by, bx, ci, cj]
|
||||
|
||||
# Modify LSB
|
||||
if coef > 0:
|
||||
y_coefs[by, bx, ci, cj] = (abs(coef) & ~1) | bit
|
||||
else:
|
||||
y_coefs[by, bx, ci, cj] = -((abs(coef) & ~1) | bit)
|
||||
if (coef & 1) != bits[i]:
|
||||
# Flip LSB while preserving sign
|
||||
if coef > 0:
|
||||
coef_array[row, col] = coef - 1 if (coef & 1) else coef + 1
|
||||
else:
|
||||
coef_array[row, col] = coef + 1 if (coef & 1) else coef - 1
|
||||
|
||||
# 5. Write JPEG (preserves structure, no re-encoding)
|
||||
jpeg.coef_arrays[0] = y_coefs
|
||||
# Write modified JPEG
|
||||
jio.write(jpeg, output_path)
|
||||
return read_bytes(output_path)
|
||||
```
|
||||
|
||||
### JPEG Normalization (v4.0)
|
||||
|
||||
```python
|
||||
def normalize_jpeg_for_jpegio(image_data: bytes) -> bytes:
|
||||
"""
|
||||
Normalize problematic JPEGs before jpegio processing.
|
||||
|
||||
output = io.BytesIO()
|
||||
jpegio.write(jpeg, output)
|
||||
return output.getvalue()
|
||||
JPEGs with quality=100 have quantization tables with all values=1,
|
||||
which causes jpegio to crash. Re-save at quality 95.
|
||||
"""
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
|
||||
if img.format != 'JPEG':
|
||||
return image_data
|
||||
|
||||
# Check if any quantization table has all values <= 1
|
||||
needs_normalization = False
|
||||
if hasattr(img, 'quantization'):
|
||||
for table in img.quantization.values():
|
||||
if max(table) <= 1:
|
||||
needs_normalization = True
|
||||
break
|
||||
|
||||
if not needs_normalization:
|
||||
return image_data
|
||||
|
||||
# Re-save at safe quality
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='JPEG', quality=95, subsampling=0)
|
||||
return buffer.getvalue()
|
||||
```
|
||||
|
||||
### Why jpegio Matters
|
||||
### DCT Capacity Calculation
|
||||
|
||||
```
|
||||
WITHOUT jpegio (broken):
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐
|
||||
│ JPEG Input │───►│ PIL Decode │───►│ Modify │───►│ PIL Encode │
|
||||
│ │ │ (decompress) │ │ Pixels │ │ (recompress) │
|
||||
└─────────────┘ └──────────────┘ └─────────────┘ └──────────────┘
|
||||
│
|
||||
▼
|
||||
RE-QUANTIZATION
|
||||
DESTROYS DATA!
|
||||
|
||||
WITH jpegio (correct):
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐
|
||||
│ JPEG Input │───►│ jpegio.read │───►│ Modify │───►│ jpegio.write │
|
||||
│ │ │ (raw coefs) │ │ Coefs │ │ (raw coefs) │
|
||||
└─────────────┘ └──────────────┘ └─────────────┘ └──────────────┘
|
||||
│
|
||||
▼
|
||||
COEFFICIENTS
|
||||
PRESERVED!
|
||||
```python
|
||||
def calculate_dct_capacity(width: int, height: int) -> int:
|
||||
"""
|
||||
Calculate maximum payload for DCT mode.
|
||||
"""
|
||||
blocks_x = width // 8
|
||||
blocks_y = height // 8
|
||||
total_blocks = blocks_x * blocks_y
|
||||
|
||||
bits_per_block = len(DEFAULT_EMBED_POSITIONS) # 16
|
||||
total_bits = total_blocks * bits_per_block
|
||||
|
||||
header_bits = 10 * 8 # Stego header
|
||||
available_bits = total_bits - header_bits
|
||||
|
||||
return available_bits // 8
|
||||
```
|
||||
|
||||
### DCT Capacity Formula
|
||||
**Example capacities:**
|
||||
- 1920×1080: ~64 KB
|
||||
- 4000×3000: ~375 KB
|
||||
- 800×600: ~14 KB
|
||||
|
||||
### Why DCT Survives JPEG Compression
|
||||
|
||||
```
|
||||
Capacity depends on image content (more texture = more non-zero coefficients)
|
||||
|
||||
Rough estimate:
|
||||
Capacity (bytes) ≈ (Width × Height × bits_per_block) / (64 × 8)
|
||||
|
||||
Where bits_per_block ≈ 8-16 depending on image complexity
|
||||
|
||||
Example: 1920×1080 image
|
||||
≈ 1920 × 1080 × 12 / 512
|
||||
≈ 48,600 bytes (~47 KB)
|
||||
|
||||
Actual capacity varies from ~30 KB to ~75 KB for 1080p
|
||||
Original JPEG: Stego JPEG: Re-compressed:
|
||||
|
||||
DCT coefficients Modified DCT Coefficients
|
||||
preserved in coefficients re-quantized
|
||||
file format still valid
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
[DCT] ──────► [Modified] ──────► [Still
|
||||
[coefs] [DCT coefs] Modified!]
|
||||
|
||||
LSB changes survive because they're embedded in
|
||||
the frequency domain, not spatial pixel values.
|
||||
```
|
||||
|
||||
### DCT Advantages
|
||||
|
||||
| Advantage | Description |
|
||||
|-----------|-------------|
|
||||
| **Survives JPEG recompression** | Coefficients mostly preserved |
|
||||
| **Social media compatible** | Works after Instagram/WhatsApp compression |
|
||||
| **JPEG resilient** | Survives social media upload |
|
||||
| **Better steganalysis resistance** | Harder to detect statistically |
|
||||
| **Natural-looking output** | JPEG artifacts expected |
|
||||
|
||||
@@ -667,6 +594,7 @@ Actual capacity varies from ~30 KB to ~75 KB for 1080p
|
||||
| **Slower processing** | DCT transforms are compute-intensive |
|
||||
| **Requires scipy/jpegio** | Additional dependencies |
|
||||
| **Quality-dependent** | Heavy recompression still degrades data |
|
||||
| **Python version** | jpegio requires Python 3.12 or earlier |
|
||||
|
||||
---
|
||||
|
||||
@@ -683,6 +611,7 @@ Actual capacity varies from ~30 KB to ~75 KB for 1080p
|
||||
| **Color Support** | Full color | Color or Grayscale |
|
||||
| **Detection Resistance** | Moderate | Better |
|
||||
| **Best For** | Email, cloud storage | Social media, messaging |
|
||||
| **Max Tested Image** | 14MB+ | 14MB+ |
|
||||
|
||||
---
|
||||
|
||||
@@ -691,16 +620,16 @@ Actual capacity varies from ~30 KB to ~75 KB for 1080p
|
||||
### What Makes Stegasoo Secure?
|
||||
|
||||
```
|
||||
MULTI-FACTOR AUTHENTICATION
|
||||
────────────────────────────
|
||||
MULTI-FACTOR AUTHENTICATION (v4.0)
|
||||
──────────────────────────────────
|
||||
Factor 1: Reference Photo ─┐
|
||||
• 80-256 bits entropy │
|
||||
• "Something you have" │
|
||||
├──► Combined entropy: 133-400+ bits
|
||||
Factor 2: Day Phrase │ (Beyond brute force)
|
||||
• 33-132 bits entropy │
|
||||
Factor 2: Passphrase │ (Beyond brute force)
|
||||
• 43-132 bits entropy │
|
||||
• "Something you know" │
|
||||
• Rotates daily │
|
||||
• 4 words default (v4.0) │
|
||||
│
|
||||
Factor 3: PIN │
|
||||
• 20-30 bits entropy │
|
||||
@@ -755,11 +684,11 @@ AUTHENTICATED ENCRYPTION (AES-256-GCM)
|
||||
|
||||
## Data Flow Diagrams
|
||||
|
||||
### Complete Encode Flow
|
||||
### Complete Encode Flow (v4.0)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ENCODE FLOW │
|
||||
│ ENCODE FLOW (v4.0) │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
User Inputs Processing Output
|
||||
@@ -768,7 +697,7 @@ User Inputs Processing Output
|
||||
Reference Photo ──────┐
|
||||
├──► get_image_hash() ──► ref_hash (32 bytes)
|
||||
│ │
|
||||
Day Phrase ───────────┤ ▼
|
||||
Passphrase ───────────┤ ▼
|
||||
├──► derive_key() ──────► aes_key (32 bytes)
|
||||
PIN ──────────────────┤ (Argon2id) │
|
||||
│ │
|
||||
@@ -801,11 +730,11 @@ Carrier Image ──────────────────────
|
||||
(downloadable)
|
||||
```
|
||||
|
||||
### Complete Decode Flow
|
||||
### Complete Decode Flow (v4.0)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ DECODE FLOW │
|
||||
│ DECODE FLOW (v4.0) │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
User Inputs Processing Output
|
||||
@@ -814,7 +743,7 @@ User Inputs Processing Output
|
||||
Reference Photo ──────┐
|
||||
├──► get_image_hash() ──► ref_hash (32 bytes)
|
||||
│ │
|
||||
Day Phrase ───────────┤ ▼
|
||||
Passphrase ───────────┤ ▼
|
||||
├──► derive_key() ──────► aes_key (32 bytes)
|
||||
PIN ──────────────────┤ (Argon2id) │
|
||||
│ (MUST MATCH!) │
|
||||
@@ -866,3 +795,11 @@ Both modes share the same cryptographic foundation (Argon2id + AES-256-GCM) and
|
||||
The choice comes down to your use case:
|
||||
- **Private channel?** → LSB (maximum capacity)
|
||||
- **Public platform?** → DCT (maximum compatibility)
|
||||
|
||||
### v4.0 Simplifications
|
||||
|
||||
- **No more date tracking** - encode/decode anytime without remembering dates
|
||||
- **Single passphrase** - no daily rotation to manage
|
||||
- **Default 4 words** - better security out of the box
|
||||
- **JPEG normalization** - handles quality=100 images automatically
|
||||
- **Large image support** - tested with 14MB+ images
|
||||
|
||||
61
build.sh
Executable file
61
build.sh
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# Stegasoo Build Script
|
||||
# Usage: ./build.sh [base|fast|full|clean]
|
||||
|
||||
set -e
|
||||
|
||||
case "${1:-fast}" in
|
||||
base)
|
||||
# Build base image with all dependencies (run once, or when deps change)
|
||||
echo "🔨 Building base image (this takes 5-10 minutes)..."
|
||||
docker build -f Dockerfile.base -t stegasoo-base:latest .
|
||||
echo "✅ Base image built! Future builds will be fast."
|
||||
echo ""
|
||||
echo "Optional: Push to registry for team use:"
|
||||
echo " docker tag stegasoo-base:latest yourregistry/stegasoo-base:latest"
|
||||
echo " docker push yourregistry/stegasoo-base:latest"
|
||||
;;
|
||||
|
||||
fast)
|
||||
# Fast build using pre-built base image
|
||||
if ! docker image inspect stegasoo-base:latest >/dev/null 2>&1; then
|
||||
echo "⚠️ Base image not found. Building it first (one-time)..."
|
||||
$0 base
|
||||
fi
|
||||
echo "🚀 Fast build using base image..."
|
||||
docker-compose build
|
||||
echo "✅ Done! Start with: docker-compose up -d"
|
||||
;;
|
||||
|
||||
full)
|
||||
# Full rebuild from scratch (slow, but no base image needed)
|
||||
echo "🐢 Full build from scratch (slow)..."
|
||||
docker-compose build --no-cache
|
||||
echo "✅ Done! Start with: docker-compose up -d"
|
||||
;;
|
||||
|
||||
clean)
|
||||
# Clean up everything
|
||||
echo "🧹 Cleaning up..."
|
||||
docker-compose down --rmi local -v 2>/dev/null || true
|
||||
docker rmi stegasoo-base:latest 2>/dev/null || true
|
||||
echo "✅ Cleaned!"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Stegasoo Build Script"
|
||||
echo ""
|
||||
echo "Usage: $0 [command]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " base Build the base image (one-time, 5-10 min)"
|
||||
echo " fast Fast build using base image (default, ~10 sec)"
|
||||
echo " full Full rebuild from scratch (slow, no base needed)"
|
||||
echo " clean Remove all images and volumes"
|
||||
echo ""
|
||||
echo "Typical workflow:"
|
||||
echo " 1. First time: $0 base"
|
||||
echo " 2. Daily dev: $0 fast (or just 'docker-compose build')"
|
||||
echo " 3. Deps change: $0 base (rebuild base image)"
|
||||
;;
|
||||
esac
|
||||
170
check_scipy.py
Normal file
170
check_scipy.py
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Diagnostic script to check for scipy/numpy issues.
|
||||
Run this BEFORE starting the web app.
|
||||
|
||||
Usage:
|
||||
python check_scipy.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
print(f"Python version: {sys.version}")
|
||||
print()
|
||||
|
||||
# Check numpy
|
||||
try:
|
||||
import numpy as np
|
||||
print(f"NumPy version: {np.__version__}")
|
||||
print(f"NumPy config:")
|
||||
np.show_config()
|
||||
except ImportError as e:
|
||||
print(f"NumPy not installed: {e}")
|
||||
except Exception as e:
|
||||
print(f"NumPy error: {e}")
|
||||
|
||||
print()
|
||||
print("-" * 50)
|
||||
print()
|
||||
|
||||
# Check scipy
|
||||
try:
|
||||
import scipy
|
||||
print(f"SciPy version: {scipy.__version__}")
|
||||
except ImportError as e:
|
||||
print(f"SciPy not installed: {e}")
|
||||
|
||||
print()
|
||||
|
||||
# Check PIL
|
||||
try:
|
||||
from PIL import Image
|
||||
print(f"Pillow version: {Image.__version__}")
|
||||
except ImportError as e:
|
||||
print(f"Pillow not installed: {e}")
|
||||
|
||||
print()
|
||||
print("-" * 50)
|
||||
print()
|
||||
|
||||
# Test scipy DCT directly
|
||||
print("Testing scipy DCT...")
|
||||
try:
|
||||
from scipy.fftpack import dct, idct
|
||||
import numpy as np
|
||||
|
||||
# Create test array
|
||||
test = np.random.rand(8, 8).astype(np.float64)
|
||||
print(f"Input array shape: {test.shape}, dtype: {test.dtype}")
|
||||
|
||||
# Test 1D DCT
|
||||
row = test[0, :]
|
||||
result = dct(row, norm='ortho')
|
||||
print(f"1D DCT result shape: {result.shape}, dtype: {result.dtype}")
|
||||
|
||||
# Test 2D DCT (the potentially problematic operation)
|
||||
result2d = dct(dct(test.T, norm='ortho').T, norm='ortho')
|
||||
print(f"2D DCT result shape: {result2d.shape}, dtype: {result2d.dtype}")
|
||||
|
||||
# Test inverse
|
||||
recovered = idct(idct(result2d.T, norm='ortho').T, norm='ortho')
|
||||
error = np.max(np.abs(test - recovered))
|
||||
print(f"Round-trip error: {error}")
|
||||
|
||||
if error < 1e-10:
|
||||
print("✓ scipy DCT working correctly")
|
||||
else:
|
||||
print("⚠ scipy DCT has precision issues")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ scipy DCT failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print()
|
||||
print("-" * 50)
|
||||
print()
|
||||
|
||||
# Test with larger array (more like real image processing)
|
||||
print("Testing with larger arrays (512x512)...")
|
||||
try:
|
||||
from scipy.fftpack import dct, idct
|
||||
import numpy as np
|
||||
import gc
|
||||
|
||||
# Simulate processing many 8x8 blocks
|
||||
large_array = np.random.rand(512, 512).astype(np.float64)
|
||||
print(f"Large array shape: {large_array.shape}, size: {large_array.nbytes} bytes")
|
||||
|
||||
count = 0
|
||||
for y in range(0, 512, 8):
|
||||
for x in range(0, 512, 8):
|
||||
block = large_array[y:y+8, x:x+8].copy()
|
||||
dct_block = dct(dct(block.T, norm='ortho').T, norm='ortho')
|
||||
recovered = idct(idct(dct_block.T, norm='ortho').T, norm='ortho')
|
||||
large_array[y:y+8, x:x+8] = recovered
|
||||
count += 1
|
||||
|
||||
print(f"Processed {count} blocks successfully")
|
||||
|
||||
del large_array
|
||||
gc.collect()
|
||||
|
||||
print("✓ Large array processing completed")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Large array processing failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print()
|
||||
print("-" * 50)
|
||||
print()
|
||||
|
||||
# Test PIL with large image
|
||||
print("Testing PIL with large image...")
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Create a large test image
|
||||
img = Image.new('RGB', (4000, 3000), color=(128, 128, 128))
|
||||
|
||||
# Save to bytes
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
img_bytes = buffer.getvalue()
|
||||
print(f"Test image size: {len(img_bytes)} bytes")
|
||||
|
||||
# Re-open and process
|
||||
buffer2 = io.BytesIO(img_bytes)
|
||||
img2 = Image.open(buffer2)
|
||||
print(f"Re-opened image: {img2.size}, mode: {img2.mode}")
|
||||
|
||||
# Convert to numpy array
|
||||
import numpy as np
|
||||
arr = np.array(img2)
|
||||
print(f"NumPy array: {arr.shape}, dtype: {arr.dtype}")
|
||||
|
||||
# Clean up
|
||||
img.close()
|
||||
img2.close()
|
||||
buffer.close()
|
||||
buffer2.close()
|
||||
del arr
|
||||
gc.collect()
|
||||
|
||||
print("✓ PIL large image test completed")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ PIL test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("Diagnostics complete")
|
||||
print()
|
||||
print("If no errors above but web app still crashes, try:")
|
||||
print("1. pip install --upgrade scipy numpy pillow")
|
||||
print("2. pip install scipy==1.11.4 numpy==1.26.4 # Known stable versions")
|
||||
print("3. Check if using conda vs pip (mixing can cause issues)")
|
||||
215
debug_jpegio.py
Normal file
215
debug_jpegio.py
Normal file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug script for DCT/jpegio extraction issues.
|
||||
Run from the stegasoo directory.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent / 'src'))
|
||||
|
||||
import hashlib
|
||||
import numpy as np
|
||||
|
||||
# Check for jpegio
|
||||
try:
|
||||
import jpegio as jio
|
||||
print("✓ jpegio available")
|
||||
except ImportError:
|
||||
print("✗ jpegio NOT available")
|
||||
sys.exit(1)
|
||||
|
||||
def get_usable_positions(coef_array, min_magnitude=2):
|
||||
"""Get positions of usable coefficients."""
|
||||
positions = []
|
||||
h, w = coef_array.shape
|
||||
for row in range(h):
|
||||
for col in range(w):
|
||||
# Skip DC coefficients (top-left of each 8x8 block)
|
||||
if (row % 8 == 0) and (col % 8 == 0):
|
||||
continue
|
||||
if abs(coef_array[row, col]) >= min_magnitude:
|
||||
positions.append((row, col))
|
||||
return positions
|
||||
|
||||
def generate_order(num_positions, seed):
|
||||
"""Generate pseudo-random order for coefficient selection."""
|
||||
hash_bytes = hashlib.sha256(seed + b"jpeg_coef_order").digest()
|
||||
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big'))
|
||||
order = list(range(num_positions))
|
||||
rng.shuffle(order)
|
||||
return order
|
||||
|
||||
def extract_bits(coef_array, positions, order, num_bits):
|
||||
"""Extract bits from coefficients."""
|
||||
bits = []
|
||||
for i, pos_idx in enumerate(order):
|
||||
if i >= num_bits:
|
||||
break
|
||||
row, col = positions[pos_idx]
|
||||
coef = coef_array[row, col]
|
||||
bits.append(coef & 1)
|
||||
return bits
|
||||
|
||||
def bits_to_bytes(bits):
|
||||
"""Convert list of bits to bytes."""
|
||||
result = []
|
||||
for i in range(0, len(bits), 8):
|
||||
byte_bits = bits[i:i+8]
|
||||
if len(byte_bits) == 8:
|
||||
byte_val = sum(byte_bits[j] << (7-j) for j in range(8))
|
||||
result.append(byte_val)
|
||||
return bytes(result)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python debug_jpegio.py <stego_image.jpg> <reference_photo>")
|
||||
print("\nOptional: add passphrase, pin, key path")
|
||||
print(" python debug_jpegio.py stego.jpg ref.jpg 'passphrase' '123456' key.pem")
|
||||
sys.exit(1)
|
||||
|
||||
stego_path = sys.argv[1]
|
||||
ref_path = sys.argv[2]
|
||||
passphrase = sys.argv[3] if len(sys.argv) > 3 else "test"
|
||||
pin = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||
key_path = sys.argv[5] if len(sys.argv) > 5 else None
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("JPEGIO DCT EXTRACTION DEBUG")
|
||||
print(f"{'='*60}")
|
||||
print(f"Stego image: {stego_path}")
|
||||
print(f"Reference: {ref_path}")
|
||||
print(f"Passphrase: '{passphrase}'")
|
||||
print(f"PIN: '{pin}'")
|
||||
print(f"Key: {key_path}")
|
||||
|
||||
# Load stego image with jpegio
|
||||
print(f"\n[1] Loading stego image with jpegio...")
|
||||
try:
|
||||
jpeg = jio.read(stego_path)
|
||||
print(f" ✓ jpegio.read() succeeded")
|
||||
print(f" Number of components: {len(jpeg.coef_arrays)}")
|
||||
for i, arr in enumerate(jpeg.coef_arrays):
|
||||
print(f" Component {i}: shape={arr.shape}, dtype={arr.dtype}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Get coefficient array (channel 0)
|
||||
coef_array = jpeg.coef_arrays[0]
|
||||
print(f"\n[2] Coefficient array analysis...")
|
||||
print(f" Shape: {coef_array.shape}")
|
||||
print(f" Non-zero coefficients: {np.count_nonzero(coef_array)}")
|
||||
print(f" Min value: {coef_array.min()}")
|
||||
print(f" Max value: {coef_array.max()}")
|
||||
|
||||
# Get usable positions
|
||||
print(f"\n[3] Finding usable positions (|coef| >= 2, non-DC)...")
|
||||
positions = get_usable_positions(coef_array)
|
||||
print(f" Usable positions: {len(positions)}")
|
||||
print(f" Capacity: ~{len(positions) // 8} bytes")
|
||||
|
||||
# Generate seed (this needs to match the encode seed!)
|
||||
print(f"\n[4] Generating seed...")
|
||||
|
||||
# Load reference photo
|
||||
ref_data = Path(ref_path).read_bytes()
|
||||
ref_hash = hashlib.sha256(ref_data).digest()
|
||||
print(f" Reference hash: {ref_hash[:8].hex()}...")
|
||||
|
||||
# Load RSA key if provided
|
||||
rsa_component = b""
|
||||
if key_path:
|
||||
try:
|
||||
from stegasoo import load_rsa_key
|
||||
key_data = Path(key_path).read_bytes()
|
||||
# Try without password first
|
||||
try:
|
||||
rsa_key = load_rsa_key(key_data, password=None)
|
||||
except:
|
||||
rsa_key = load_rsa_key(key_data, password="testpass")
|
||||
|
||||
# Get public key bytes for seed
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
pub_bytes = rsa_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
rsa_component = hashlib.sha256(pub_bytes).digest()
|
||||
print(f" RSA key loaded, hash: {rsa_component[:8].hex()}...")
|
||||
except Exception as e:
|
||||
print(f" ✗ Could not load RSA key: {e}")
|
||||
|
||||
# Build seed like stegasoo does
|
||||
# This is the critical part - must match encoding!
|
||||
seed_parts = [
|
||||
ref_hash,
|
||||
passphrase.encode('utf-8'),
|
||||
pin.encode('utf-8') if pin else b"",
|
||||
rsa_component,
|
||||
]
|
||||
seed = hashlib.sha256(b"".join(seed_parts)).digest()
|
||||
print(f" Combined seed: {seed[:8].hex()}...")
|
||||
|
||||
# Generate order
|
||||
print(f"\n[5] Generating coefficient order...")
|
||||
order = generate_order(len(positions), seed)
|
||||
print(f" First 10 indices: {order[:10]}")
|
||||
|
||||
# Try to extract header
|
||||
print(f"\n[6] Extracting header (first 80 bits = 10 bytes)...")
|
||||
HEADER_SIZE = 10
|
||||
header_bits = extract_bits(coef_array, positions, order, HEADER_SIZE * 8)
|
||||
header_bytes = bits_to_bytes(header_bits)
|
||||
print(f" Raw header bytes: {header_bytes.hex()}")
|
||||
print(f" As ASCII (if printable): {repr(header_bytes)}")
|
||||
|
||||
# Check for JPGS magic
|
||||
JPEGIO_MAGIC = b'JPGS'
|
||||
if header_bytes[:4] == JPEGIO_MAGIC:
|
||||
print(f" ✓ Found JPEGIO magic bytes!")
|
||||
version = header_bytes[4]
|
||||
flags = header_bytes[5]
|
||||
data_length = struct.unpack('>I', header_bytes[6:10])[0]
|
||||
print(f" Version: {version}")
|
||||
print(f" Flags: {flags}")
|
||||
print(f" Data length: {data_length} bytes")
|
||||
|
||||
if data_length > 0 and data_length < len(positions) // 8:
|
||||
print(f"\n[7] Extracting payload ({data_length} bytes)...")
|
||||
total_bits = (HEADER_SIZE + data_length) * 8
|
||||
all_bits = extract_bits(coef_array, positions, order, total_bits)
|
||||
data_bits = all_bits[HEADER_SIZE * 8:]
|
||||
payload = bits_to_bytes(data_bits)
|
||||
print(f" Payload (first 64 bytes): {payload[:64].hex()}")
|
||||
print(f" This should be encrypted data starting with salt/IV")
|
||||
else:
|
||||
print(f" ✗ Invalid data length: {data_length}")
|
||||
else:
|
||||
print(f" ✗ No JPEGIO magic found")
|
||||
print(f" Expected: {JPEGIO_MAGIC.hex()} ('JPGS')")
|
||||
print(f" Got: {header_bytes[:4].hex()} ('{header_bytes[:4]}')")
|
||||
|
||||
# Try alternate interpretations
|
||||
print(f"\n[7] Trying alternate header interpretations...")
|
||||
|
||||
# Maybe it's scipy DCT format?
|
||||
DCT_MAGIC = b'DCTS'
|
||||
if header_bytes[:4] == DCT_MAGIC:
|
||||
print(f" Found SCIPY DCT magic - wrong extraction method!")
|
||||
else:
|
||||
# Show bit distribution
|
||||
print(f" First 32 extracted bits: {header_bits[:32]}")
|
||||
|
||||
# Check if bits look random or patterned
|
||||
ones = sum(header_bits[:80])
|
||||
print(f" Bit distribution: {ones}/80 ones ({100*ones/80:.1f}%)")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("DEBUG COMPLETE")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,10 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stegasoo REST API (v3.2.0)
|
||||
Stegasoo REST API (v4.0.0)
|
||||
|
||||
FastAPI-based REST API for steganography operations.
|
||||
Supports both text messages and file embedding.
|
||||
|
||||
CHANGES in v4.0.0:
|
||||
- Updated from v3.2.0 with no functional API changes
|
||||
- Internal: JPEG normalization for jpegio compatibility
|
||||
- Internal: Python 3.12 recommended
|
||||
|
||||
CHANGES in v3.2.0:
|
||||
- Removed date dependency from all operations
|
||||
- Renamed day_phrase → passphrase
|
||||
@@ -32,7 +37,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src'))
|
||||
import stegasoo
|
||||
from stegasoo import (
|
||||
encode, decode, generate_credentials,
|
||||
validate_image, calculate_capacity,
|
||||
validate_image,
|
||||
__version__,
|
||||
StegasooError, DecryptionError, CapacityError,
|
||||
has_argon2,
|
||||
@@ -75,6 +80,11 @@ app = FastAPI(
|
||||
description="""
|
||||
Secure steganography with hybrid authentication. Supports text messages and file embedding.
|
||||
|
||||
## Version 4.0.0 Changes
|
||||
|
||||
- **Python 3.12 recommended** - jpegio compatibility improvements
|
||||
- **JPEG normalization** - Handles quality=100 images automatically
|
||||
|
||||
## Version 3.2.0 Changes
|
||||
|
||||
- **No date parameters needed** - Encode and decode anytime without tracking dates
|
||||
@@ -1052,7 +1062,7 @@ async def api_image_info(
|
||||
if not result.is_valid:
|
||||
raise HTTPException(400, result.error_message)
|
||||
|
||||
capacity = calculate_capacity(image_data)
|
||||
capacity = calculate_capacity_by_mode(image_data, 'lsb')
|
||||
|
||||
response = ImageInfoResponse(
|
||||
width=result.details['width'],
|
||||
|
||||
@@ -64,15 +64,37 @@ from stegasoo import (
|
||||
|
||||
# Models
|
||||
FilePayload,
|
||||
|
||||
# Constants
|
||||
EMBED_MODE_LSB,
|
||||
EMBED_MODE_DCT,
|
||||
EMBED_MODE_AUTO,
|
||||
DEFAULT_PASSPHRASE_WORDS,
|
||||
DEFAULT_PIN_LENGTH,
|
||||
)
|
||||
|
||||
# Import constants - try main module first, then constants submodule
|
||||
try:
|
||||
from stegasoo import (
|
||||
EMBED_MODE_LSB,
|
||||
EMBED_MODE_DCT,
|
||||
EMBED_MODE_AUTO,
|
||||
)
|
||||
except ImportError:
|
||||
from stegasoo.constants import (
|
||||
EMBED_MODE_LSB,
|
||||
EMBED_MODE_DCT,
|
||||
EMBED_MODE_AUTO,
|
||||
)
|
||||
|
||||
# Import constants that may not be in main __init__
|
||||
try:
|
||||
from stegasoo.constants import (
|
||||
DEFAULT_PASSPHRASE_WORDS,
|
||||
DEFAULT_PIN_LENGTH,
|
||||
MIN_PIN_LENGTH,
|
||||
MAX_PIN_LENGTH,
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback defaults if constants not available
|
||||
DEFAULT_PASSPHRASE_WORDS = 4
|
||||
DEFAULT_PIN_LENGTH = 6
|
||||
MIN_PIN_LENGTH = 6
|
||||
MAX_PIN_LENGTH = 9
|
||||
|
||||
# Optional: strip_image_metadata from utils
|
||||
try:
|
||||
from stegasoo.utils import strip_image_metadata
|
||||
|
||||
62
frontends/web/README_subprocess.md
Normal file
62
frontends/web/README_subprocess.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Subprocess Isolation for Stegasoo WebUI
|
||||
|
||||
This update runs encode/decode/compare operations in isolated subprocesses
|
||||
to prevent jpegio/scipy crashes from taking down the Flask server.
|
||||
|
||||
## Files
|
||||
|
||||
- **app.py** - Updated Flask app using subprocess isolation
|
||||
- **subprocess_stego.py** - Flask-side wrapper with clean API
|
||||
- **stego_worker.py** - Subprocess script that does actual stegasoo operations
|
||||
|
||||
## Setup
|
||||
|
||||
1. Place all three files in your `webui/` directory (same level as templates/)
|
||||
|
||||
2. Make sure stego_worker.py is executable (optional):
|
||||
```bash
|
||||
chmod +x stego_worker.py
|
||||
```
|
||||
|
||||
3. Run the Flask app:
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
Instead of calling stegasoo functions directly in the Flask process:
|
||||
|
||||
```python
|
||||
# OLD (crashes could kill Flask)
|
||||
result = encode(...)
|
||||
```
|
||||
|
||||
We now run them in subprocesses:
|
||||
|
||||
```python
|
||||
# NEW (crashes only kill the subprocess)
|
||||
result = subprocess_stego.encode(...)
|
||||
```
|
||||
|
||||
If jpegio or scipy crashes due to memory corruption, only the subprocess
|
||||
dies. Flask logs the error and continues running. The next request spawns
|
||||
a fresh subprocess.
|
||||
|
||||
## Configuration
|
||||
|
||||
In `app.py`, you can adjust the timeout:
|
||||
|
||||
```python
|
||||
subprocess_stego = SubprocessStego(timeout=180) # 3 minutes
|
||||
```
|
||||
|
||||
Larger images may need longer timeouts.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you see "Worker script not found" errors, make sure `stego_worker.py`
|
||||
is in the same directory as `app.py`.
|
||||
|
||||
If subprocess operations fail, check the Flask logs for error details.
|
||||
The subprocess wrapper captures both stdout and stderr from the worker.
|
||||
@@ -29,12 +29,16 @@ from flask import (
|
||||
jsonify, flash, redirect, url_for
|
||||
)
|
||||
|
||||
import os
|
||||
os.environ['NUMPY_MADVISE_HUGEPAGE'] = '0'
|
||||
os.environ['OMP_NUM_THREADS'] = '1'
|
||||
|
||||
# Add parent to path for development
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src'))
|
||||
|
||||
import stegasoo
|
||||
from stegasoo import (
|
||||
encode, decode, generate_credentials,
|
||||
generate_credentials,
|
||||
export_rsa_key_pem, load_rsa_key,
|
||||
validate_pin, validate_message, validate_image,
|
||||
validate_rsa_key, validate_security_factors,
|
||||
@@ -48,8 +52,7 @@ from stegasoo import (
|
||||
EMBED_MODE_DCT,
|
||||
EMBED_MODE_AUTO,
|
||||
has_dct_support,
|
||||
compare_modes,
|
||||
will_fit_by_mode,
|
||||
# NOTE: encode, decode, compare_modes, will_fit_by_mode now use subprocess isolation
|
||||
)
|
||||
from stegasoo.constants import (
|
||||
__version__,
|
||||
@@ -90,6 +93,17 @@ from stegasoo.qr_utils import (
|
||||
QR_MAX_BINARY, COMPRESSION_PREFIX
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# SUBPROCESS ISOLATION FOR STEGASOO OPERATIONS
|
||||
# ============================================================================
|
||||
# Runs encode/decode/compare in subprocesses to prevent jpegio/scipy crashes
|
||||
# from taking down the Flask server.
|
||||
|
||||
from subprocess_stego import SubprocessStego
|
||||
|
||||
# Initialize subprocess wrapper (worker script must be in same directory)
|
||||
subprocess_stego = SubprocessStego(timeout=180) # 3 minute timeout for large images
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FLASK APP CONFIGURATION
|
||||
@@ -436,6 +450,7 @@ def api_compare_capacity():
|
||||
"""
|
||||
Compare LSB and DCT capacity for an uploaded carrier image.
|
||||
Returns JSON with capacity info for both modes.
|
||||
Uses subprocess isolation to prevent crashes.
|
||||
"""
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
@@ -443,23 +458,28 @@ def api_compare_capacity():
|
||||
|
||||
try:
|
||||
carrier_data = carrier.read()
|
||||
comparison = compare_modes(carrier_data)
|
||||
|
||||
# Use subprocess-isolated compare_modes
|
||||
result = subprocess_stego.compare_modes(carrier_data)
|
||||
|
||||
if not result.success:
|
||||
return jsonify({'error': result.error or 'Comparison failed'}), 500
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'width': comparison['width'],
|
||||
'height': comparison['height'],
|
||||
'width': result.width,
|
||||
'height': result.height,
|
||||
'lsb': {
|
||||
'capacity_bytes': comparison['lsb']['capacity_bytes'],
|
||||
'capacity_kb': round(comparison['lsb']['capacity_kb'], 1),
|
||||
'output': comparison['lsb']['output'],
|
||||
'capacity_bytes': result.lsb['capacity_bytes'],
|
||||
'capacity_kb': round(result.lsb['capacity_kb'], 1),
|
||||
'output': result.lsb.get('output', 'PNG'),
|
||||
},
|
||||
'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),
|
||||
'capacity_bytes': result.dct['capacity_bytes'],
|
||||
'capacity_kb': round(result.dct['capacity_kb'], 1),
|
||||
'output': result.dct.get('output', 'JPEG'),
|
||||
'available': result.dct.get('available', True),
|
||||
'ratio': round(result.dct.get('ratio_vs_lsb', 0), 1),
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -471,6 +491,7 @@ def api_check_fit():
|
||||
"""
|
||||
Check if a payload will fit in the carrier with selected mode.
|
||||
Returns JSON with fit status and details.
|
||||
Uses subprocess isolation to prevent crashes.
|
||||
"""
|
||||
carrier = request.files.get('carrier')
|
||||
payload_size = request.form.get('payload_size', type=int)
|
||||
@@ -487,16 +508,25 @@ def api_check_fit():
|
||||
|
||||
try:
|
||||
carrier_data = carrier.read()
|
||||
result = will_fit_by_mode(payload_size, carrier_data, embed_mode=embed_mode)
|
||||
|
||||
# Use subprocess-isolated capacity check
|
||||
result = subprocess_stego.check_capacity(
|
||||
carrier_data=carrier_data,
|
||||
payload_size=payload_size,
|
||||
embed_mode=embed_mode,
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
return jsonify({'error': result.error or 'Capacity check failed'}), 500
|
||||
|
||||
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,
|
||||
'fits': result.fits,
|
||||
'payload_size': result.payload_size,
|
||||
'capacity': result.capacity,
|
||||
'usage_percent': round(result.usage_percent, 1),
|
||||
'headroom': result.headroom,
|
||||
'mode': result.mode,
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -641,37 +671,63 @@ def encode_page():
|
||||
return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
# v3.2.0: No date parameter needed
|
||||
encode_result = encode(
|
||||
message=payload,
|
||||
reference_photo=ref_data,
|
||||
carrier_image=carrier_data,
|
||||
passphrase=passphrase, # v3.2.0: Renamed from day_phrase
|
||||
pin=pin,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=key_password,
|
||||
# date_str removed in v3.2.0
|
||||
embed_mode=embed_mode,
|
||||
dct_output_format=dct_output_format if embed_mode == 'dct' else None,
|
||||
dct_color_mode=dct_color_mode if embed_mode == 'dct' else None,
|
||||
)
|
||||
# Use subprocess-isolated encode to prevent crashes
|
||||
if payload_type == 'file' and payload_file and payload_file.filename:
|
||||
encode_result = subprocess_stego.encode(
|
||||
carrier_data=carrier_data,
|
||||
reference_data=ref_data,
|
||||
file_data=payload.data,
|
||||
file_name=payload.filename,
|
||||
file_mime=payload.mime_type,
|
||||
passphrase=passphrase,
|
||||
pin=pin if pin else None,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=key_password,
|
||||
embed_mode=embed_mode,
|
||||
dct_output_format=dct_output_format if embed_mode == 'dct' else 'png',
|
||||
dct_color_mode=dct_color_mode if embed_mode == 'dct' else 'color',
|
||||
)
|
||||
else:
|
||||
encode_result = subprocess_stego.encode(
|
||||
carrier_data=carrier_data,
|
||||
reference_data=ref_data,
|
||||
message=payload,
|
||||
passphrase=passphrase,
|
||||
pin=pin if pin else None,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=key_password,
|
||||
embed_mode=embed_mode,
|
||||
dct_output_format=dct_output_format if embed_mode == 'dct' else 'png',
|
||||
dct_color_mode=dct_color_mode if embed_mode == 'dct' else 'color',
|
||||
)
|
||||
|
||||
# Check for subprocess errors
|
||||
if not encode_result.success:
|
||||
error_msg = encode_result.error or 'Encoding failed'
|
||||
if 'capacity' in error_msg.lower():
|
||||
raise CapacityError(error_msg)
|
||||
raise StegasooError(error_msg)
|
||||
|
||||
# Determine actual output format for filename and storage
|
||||
if embed_mode == 'dct' and dct_output_format == 'jpeg':
|
||||
output_ext = '.jpg'
|
||||
output_mime = 'image/jpeg'
|
||||
filename = encode_result.filename
|
||||
if filename.endswith('.png'):
|
||||
filename = filename[:-4] + '.jpg'
|
||||
else:
|
||||
output_ext = '.png'
|
||||
output_mime = 'image/png'
|
||||
filename = encode_result.filename
|
||||
|
||||
# Use filename from result or generate one
|
||||
filename = encode_result.filename
|
||||
if not filename:
|
||||
filename = generate_filename('stego', output_ext)
|
||||
elif embed_mode == 'dct' and dct_output_format == 'jpeg' and filename.endswith('.png'):
|
||||
filename = filename[:-4] + '.jpg'
|
||||
|
||||
# Store temporarily
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
TEMP_FILES[file_id] = {
|
||||
'data': encode_result.stego_image,
|
||||
'data': encode_result.stego_data,
|
||||
'filename': filename,
|
||||
'timestamp': time.time(),
|
||||
'embed_mode': embed_mode,
|
||||
@@ -864,17 +920,24 @@ def decode_page():
|
||||
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
# v3.2.0: No date_str parameter needed
|
||||
decode_result = decode(
|
||||
stego_image=stego_data,
|
||||
reference_photo=ref_data,
|
||||
passphrase=passphrase, # v3.2.0: Renamed from day_phrase
|
||||
pin=pin,
|
||||
# Use subprocess-isolated decode to prevent crashes
|
||||
decode_result = subprocess_stego.decode(
|
||||
stego_data=stego_data,
|
||||
reference_data=ref_data,
|
||||
passphrase=passphrase,
|
||||
pin=pin if pin else None,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=key_password,
|
||||
# date_str removed in v3.2.0
|
||||
embed_mode=embed_mode,
|
||||
)
|
||||
|
||||
# Check for subprocess errors
|
||||
if not decode_result.success:
|
||||
error_msg = decode_result.error or 'Decoding failed'
|
||||
if 'decrypt' in error_msg.lower() or decode_result.error_type == 'DecryptionError':
|
||||
raise DecryptionError(error_msg)
|
||||
raise StegasooError(error_msg)
|
||||
|
||||
if decode_result.is_file:
|
||||
# File content - store temporarily for download
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
@@ -942,6 +1005,54 @@ def about():
|
||||
)
|
||||
|
||||
|
||||
# Add these two test routes anywhere in app.py after the app = Flask(...) line:
|
||||
|
||||
@app.route('/test-capacity', methods=['POST'])
|
||||
def test_capacity():
|
||||
"""Minimal capacity test - no stegasoo code, just PIL."""
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier image provided'}), 400
|
||||
|
||||
try:
|
||||
carrier_data = carrier.read()
|
||||
buffer = io.BytesIO(carrier_data)
|
||||
img = Image.open(buffer)
|
||||
width, height = img.size
|
||||
fmt = img.format
|
||||
img.close()
|
||||
buffer.close()
|
||||
|
||||
pixels = width * height
|
||||
lsb_bytes = (pixels * 3) // 8
|
||||
dct_bytes = ((width // 8) * (height // 8) * 16) // 8 - 10
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'format': fmt,
|
||||
'lsb_kb': round(lsb_bytes / 1024, 1),
|
||||
'dct_kb': round(dct_bytes / 1024, 1),
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/test-capacity-nopil', methods=['POST'])
|
||||
def test_capacity_nopil():
|
||||
"""Ultra-minimal test - no PIL, no stegasoo."""
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier image provided'}), 400
|
||||
|
||||
carrier_data = carrier.read()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data_size': len(carrier_data),
|
||||
})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
191
frontends/web/stego_worker.py
Normal file
191
frontends/web/stego_worker.py
Normal file
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stegasoo Subprocess Worker
|
||||
|
||||
This script runs in a subprocess and handles encode/decode operations.
|
||||
If it crashes due to jpegio/scipy issues, the parent Flask process survives.
|
||||
|
||||
Communication is via JSON over stdin/stdout:
|
||||
- Input: JSON object with operation parameters
|
||||
- Output: JSON object with results or error
|
||||
|
||||
Usage:
|
||||
echo '{"operation": "encode", ...}' | python stego_worker.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import base64
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure stegasoo is importable
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src'))
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
|
||||
def encode_operation(params: dict) -> dict:
|
||||
"""Handle encode operation."""
|
||||
from stegasoo import encode, FilePayload
|
||||
|
||||
# Decode base64 inputs
|
||||
carrier_data = base64.b64decode(params['carrier_b64'])
|
||||
reference_data = base64.b64decode(params['reference_b64'])
|
||||
|
||||
# Optional RSA key
|
||||
rsa_key_data = None
|
||||
if params.get('rsa_key_b64'):
|
||||
rsa_key_data = base64.b64decode(params['rsa_key_b64'])
|
||||
|
||||
# Determine payload type
|
||||
if params.get('file_b64'):
|
||||
file_data = base64.b64decode(params['file_b64'])
|
||||
payload = FilePayload(
|
||||
data=file_data,
|
||||
filename=params.get('file_name', 'file'),
|
||||
mime_type=params.get('file_mime', 'application/octet-stream'),
|
||||
)
|
||||
else:
|
||||
payload = params.get('message', '')
|
||||
|
||||
# Call encode with correct parameter names
|
||||
result = encode(
|
||||
message=payload,
|
||||
reference_photo=reference_data,
|
||||
carrier_image=carrier_data,
|
||||
passphrase=params.get('passphrase', ''),
|
||||
pin=params.get('pin'),
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=params.get('rsa_password'),
|
||||
embed_mode=params.get('embed_mode', 'lsb'),
|
||||
dct_output_format=params.get('dct_output_format', 'png'),
|
||||
dct_color_mode=params.get('dct_color_mode', 'color'),
|
||||
)
|
||||
|
||||
# Build stats dict if available
|
||||
stats = None
|
||||
if hasattr(result, 'stats') and result.stats:
|
||||
stats = {
|
||||
'pixels_modified': getattr(result.stats, 'pixels_modified', 0),
|
||||
'capacity_used': getattr(result.stats, 'capacity_used', 0),
|
||||
'bytes_embedded': getattr(result.stats, 'bytes_embedded', 0),
|
||||
}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'stego_b64': base64.b64encode(result.stego_image).decode('ascii'),
|
||||
'filename': getattr(result, 'filename', None),
|
||||
'stats': stats,
|
||||
}
|
||||
|
||||
|
||||
def decode_operation(params: dict) -> dict:
|
||||
"""Handle decode operation."""
|
||||
from stegasoo import decode
|
||||
|
||||
# Decode base64 inputs
|
||||
stego_data = base64.b64decode(params['stego_b64'])
|
||||
reference_data = base64.b64decode(params['reference_b64'])
|
||||
|
||||
# Optional RSA key
|
||||
rsa_key_data = None
|
||||
if params.get('rsa_key_b64'):
|
||||
rsa_key_data = base64.b64decode(params['rsa_key_b64'])
|
||||
|
||||
# Call decode with correct parameter names
|
||||
result = decode(
|
||||
stego_image=stego_data,
|
||||
reference_photo=reference_data,
|
||||
passphrase=params.get('passphrase', ''),
|
||||
pin=params.get('pin'),
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=params.get('rsa_password'),
|
||||
embed_mode=params.get('embed_mode', 'auto'),
|
||||
)
|
||||
|
||||
if result.is_file:
|
||||
return {
|
||||
'success': True,
|
||||
'is_file': True,
|
||||
'file_b64': base64.b64encode(result.file_data).decode('ascii'),
|
||||
'filename': result.filename,
|
||||
'mime_type': result.mime_type,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'success': True,
|
||||
'is_file': False,
|
||||
'message': result.message,
|
||||
}
|
||||
|
||||
|
||||
def compare_operation(params: dict) -> dict:
|
||||
"""Handle compare_modes operation."""
|
||||
from stegasoo import compare_modes
|
||||
|
||||
carrier_data = base64.b64decode(params['carrier_b64'])
|
||||
result = compare_modes(carrier_data)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'comparison': result,
|
||||
}
|
||||
|
||||
|
||||
def capacity_check_operation(params: dict) -> dict:
|
||||
"""Handle will_fit_by_mode operation."""
|
||||
from stegasoo import will_fit_by_mode
|
||||
|
||||
carrier_data = base64.b64decode(params['carrier_b64'])
|
||||
|
||||
result = will_fit_by_mode(
|
||||
payload=params['payload_size'],
|
||||
carrier_image=carrier_data,
|
||||
embed_mode=params.get('embed_mode', 'lsb'),
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'result': result,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point - read JSON from stdin, write JSON to stdout."""
|
||||
try:
|
||||
# Read all input
|
||||
input_text = sys.stdin.read()
|
||||
|
||||
if not input_text.strip():
|
||||
output = {'success': False, 'error': 'No input provided'}
|
||||
else:
|
||||
params = json.loads(input_text)
|
||||
operation = params.get('operation')
|
||||
|
||||
if operation == 'encode':
|
||||
output = encode_operation(params)
|
||||
elif operation == 'decode':
|
||||
output = decode_operation(params)
|
||||
elif operation == 'compare':
|
||||
output = compare_operation(params)
|
||||
elif operation == 'capacity':
|
||||
output = capacity_check_operation(params)
|
||||
else:
|
||||
output = {'success': False, 'error': f'Unknown operation: {operation}'}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
output = {'success': False, 'error': f'Invalid JSON: {e}'}
|
||||
except Exception as e:
|
||||
output = {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'error_type': type(e).__name__,
|
||||
'traceback': traceback.format_exc(),
|
||||
}
|
||||
|
||||
# Write output as JSON
|
||||
print(json.dumps(output), flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
425
frontends/web/subprocess_stego.py
Normal file
425
frontends/web/subprocess_stego.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
Subprocess Steganography Wrapper
|
||||
|
||||
Runs stegasoo operations in isolated subprocesses to prevent crashes
|
||||
from taking down the Flask server.
|
||||
|
||||
Usage:
|
||||
from subprocess_stego import SubprocessStego
|
||||
|
||||
stego = SubprocessStego()
|
||||
|
||||
# Encode
|
||||
result = stego.encode(
|
||||
carrier_data=carrier_bytes,
|
||||
reference_data=ref_bytes,
|
||||
message="secret message",
|
||||
passphrase="my passphrase",
|
||||
pin="123456",
|
||||
embed_mode="dct",
|
||||
)
|
||||
|
||||
if result.success:
|
||||
stego_bytes = result.stego_data
|
||||
extension = result.extension
|
||||
else:
|
||||
error_message = result.error
|
||||
|
||||
# Decode
|
||||
result = stego.decode(
|
||||
stego_data=stego_bytes,
|
||||
reference_data=ref_bytes,
|
||||
passphrase="my passphrase",
|
||||
pin="123456",
|
||||
)
|
||||
|
||||
# Compare modes (capacity)
|
||||
result = stego.compare_modes(carrier_bytes)
|
||||
"""
|
||||
|
||||
import json
|
||||
import base64
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any, Union
|
||||
|
||||
|
||||
# Default timeout for operations (seconds)
|
||||
DEFAULT_TIMEOUT = 120
|
||||
|
||||
# Path to worker script - adjust if needed
|
||||
WORKER_SCRIPT = Path(__file__).parent / 'stego_worker.py'
|
||||
|
||||
|
||||
@dataclass
|
||||
class EncodeResult:
|
||||
"""Result from encode operation."""
|
||||
success: bool
|
||||
stego_data: Optional[bytes] = None
|
||||
filename: Optional[str] = None
|
||||
stats: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
error_type: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecodeResult:
|
||||
"""Result from decode operation."""
|
||||
success: bool
|
||||
is_file: bool = False
|
||||
message: Optional[str] = None
|
||||
file_data: Optional[bytes] = None
|
||||
filename: Optional[str] = None
|
||||
mime_type: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
error_type: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompareResult:
|
||||
"""Result from compare_modes operation."""
|
||||
success: bool
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
lsb: Optional[Dict[str, Any]] = None
|
||||
dct: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CapacityResult:
|
||||
"""Result from capacity check operation."""
|
||||
success: bool
|
||||
fits: bool = False
|
||||
payload_size: int = 0
|
||||
capacity: int = 0
|
||||
usage_percent: float = 0.0
|
||||
headroom: int = 0
|
||||
mode: str = ""
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class SubprocessStego:
|
||||
"""
|
||||
Subprocess-isolated steganography operations.
|
||||
|
||||
All operations run in a separate Python process. If jpegio or scipy
|
||||
crashes, only the subprocess dies - Flask keeps running.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
worker_path: Optional[Path] = None,
|
||||
python_executable: Optional[str] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
):
|
||||
"""
|
||||
Initialize subprocess wrapper.
|
||||
|
||||
Args:
|
||||
worker_path: Path to stego_worker.py (default: same directory)
|
||||
python_executable: Python interpreter to use (default: same as current)
|
||||
timeout: Default timeout in seconds
|
||||
"""
|
||||
self.worker_path = worker_path or WORKER_SCRIPT
|
||||
self.python = python_executable or sys.executable
|
||||
self.timeout = timeout
|
||||
|
||||
if not self.worker_path.exists():
|
||||
raise FileNotFoundError(f"Worker script not found: {self.worker_path}")
|
||||
|
||||
def _run_worker(self, params: Dict[str, Any], timeout: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Run the worker subprocess with given parameters.
|
||||
|
||||
Args:
|
||||
params: Dictionary of parameters (will be JSON-encoded)
|
||||
timeout: Operation timeout in seconds
|
||||
|
||||
Returns:
|
||||
Dictionary with results from worker
|
||||
"""
|
||||
timeout = timeout or self.timeout
|
||||
input_json = json.dumps(params)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self.python, str(self.worker_path)],
|
||||
input=input_json,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=str(self.worker_path.parent),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
# Worker crashed
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Worker crashed (exit code {result.returncode})',
|
||||
'stderr': result.stderr,
|
||||
}
|
||||
|
||||
if not result.stdout.strip():
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Worker returned empty output',
|
||||
'stderr': result.stderr,
|
||||
}
|
||||
|
||||
return json.loads(result.stdout)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Operation timed out after {timeout} seconds',
|
||||
'error_type': 'TimeoutError',
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Invalid JSON from worker: {e}',
|
||||
'raw_output': result.stdout if 'result' in dir() else None,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'error_type': type(e).__name__,
|
||||
}
|
||||
|
||||
def encode(
|
||||
self,
|
||||
carrier_data: bytes,
|
||||
reference_data: bytes,
|
||||
message: Optional[str] = None,
|
||||
file_data: Optional[bytes] = None,
|
||||
file_name: Optional[str] = None,
|
||||
file_mime: Optional[str] = None,
|
||||
passphrase: str = "",
|
||||
pin: Optional[str] = None,
|
||||
rsa_key_data: Optional[bytes] = None,
|
||||
rsa_password: Optional[str] = None,
|
||||
embed_mode: str = "lsb",
|
||||
dct_output_format: str = "png",
|
||||
dct_color_mode: str = "color",
|
||||
timeout: Optional[int] = None,
|
||||
) -> EncodeResult:
|
||||
"""
|
||||
Encode a message or file into an image.
|
||||
|
||||
Args:
|
||||
carrier_data: Carrier image bytes
|
||||
reference_data: Reference photo bytes
|
||||
message: Text message to encode (if not file)
|
||||
file_data: File bytes to encode (if not message)
|
||||
file_name: Original filename (for file payload)
|
||||
file_mime: MIME type (for file payload)
|
||||
passphrase: Encryption passphrase
|
||||
pin: Optional PIN
|
||||
rsa_key_data: Optional RSA key PEM bytes
|
||||
rsa_password: RSA key password if encrypted
|
||||
embed_mode: 'lsb' or 'dct'
|
||||
dct_output_format: 'png' or 'jpeg' (for DCT mode)
|
||||
dct_color_mode: 'grayscale' or 'color' (for DCT mode)
|
||||
timeout: Operation timeout in seconds
|
||||
|
||||
Returns:
|
||||
EncodeResult with stego_data and extension on success
|
||||
"""
|
||||
params = {
|
||||
'operation': 'encode',
|
||||
'carrier_b64': base64.b64encode(carrier_data).decode('ascii'),
|
||||
'reference_b64': base64.b64encode(reference_data).decode('ascii'),
|
||||
'message': message,
|
||||
'passphrase': passphrase,
|
||||
'pin': pin,
|
||||
'embed_mode': embed_mode,
|
||||
'dct_output_format': dct_output_format,
|
||||
'dct_color_mode': dct_color_mode,
|
||||
}
|
||||
|
||||
if file_data:
|
||||
params['file_b64'] = base64.b64encode(file_data).decode('ascii')
|
||||
params['file_name'] = file_name
|
||||
params['file_mime'] = file_mime
|
||||
|
||||
if rsa_key_data:
|
||||
params['rsa_key_b64'] = base64.b64encode(rsa_key_data).decode('ascii')
|
||||
params['rsa_password'] = rsa_password
|
||||
|
||||
result = self._run_worker(params, timeout)
|
||||
|
||||
if result.get('success'):
|
||||
return EncodeResult(
|
||||
success=True,
|
||||
stego_data=base64.b64decode(result['stego_b64']),
|
||||
filename=result.get('filename'),
|
||||
stats=result.get('stats'),
|
||||
)
|
||||
else:
|
||||
return EncodeResult(
|
||||
success=False,
|
||||
error=result.get('error', 'Unknown error'),
|
||||
error_type=result.get('error_type'),
|
||||
)
|
||||
|
||||
def decode(
|
||||
self,
|
||||
stego_data: bytes,
|
||||
reference_data: bytes,
|
||||
passphrase: str = "",
|
||||
pin: Optional[str] = None,
|
||||
rsa_key_data: Optional[bytes] = None,
|
||||
rsa_password: Optional[str] = None,
|
||||
embed_mode: str = "auto",
|
||||
timeout: Optional[int] = None,
|
||||
) -> DecodeResult:
|
||||
"""
|
||||
Decode a message or file from a stego image.
|
||||
|
||||
Args:
|
||||
stego_data: Stego image bytes
|
||||
reference_data: Reference photo bytes
|
||||
passphrase: Decryption passphrase
|
||||
pin: Optional PIN
|
||||
rsa_key_data: Optional RSA key PEM bytes
|
||||
rsa_password: RSA key password if encrypted
|
||||
embed_mode: 'auto', 'lsb', or 'dct'
|
||||
timeout: Operation timeout in seconds
|
||||
|
||||
Returns:
|
||||
DecodeResult with message or file_data on success
|
||||
"""
|
||||
params = {
|
||||
'operation': 'decode',
|
||||
'stego_b64': base64.b64encode(stego_data).decode('ascii'),
|
||||
'reference_b64': base64.b64encode(reference_data).decode('ascii'),
|
||||
'passphrase': passphrase,
|
||||
'pin': pin,
|
||||
'embed_mode': embed_mode,
|
||||
}
|
||||
|
||||
if rsa_key_data:
|
||||
params['rsa_key_b64'] = base64.b64encode(rsa_key_data).decode('ascii')
|
||||
params['rsa_password'] = rsa_password
|
||||
|
||||
result = self._run_worker(params, timeout)
|
||||
|
||||
if result.get('success'):
|
||||
if result.get('is_file'):
|
||||
return DecodeResult(
|
||||
success=True,
|
||||
is_file=True,
|
||||
file_data=base64.b64decode(result['file_b64']),
|
||||
filename=result.get('filename'),
|
||||
mime_type=result.get('mime_type'),
|
||||
)
|
||||
else:
|
||||
return DecodeResult(
|
||||
success=True,
|
||||
is_file=False,
|
||||
message=result.get('message'),
|
||||
)
|
||||
else:
|
||||
return DecodeResult(
|
||||
success=False,
|
||||
error=result.get('error', 'Unknown error'),
|
||||
error_type=result.get('error_type'),
|
||||
)
|
||||
|
||||
def compare_modes(
|
||||
self,
|
||||
carrier_data: bytes,
|
||||
timeout: Optional[int] = None,
|
||||
) -> CompareResult:
|
||||
"""
|
||||
Compare LSB and DCT capacity for a carrier image.
|
||||
|
||||
Args:
|
||||
carrier_data: Carrier image bytes
|
||||
timeout: Operation timeout in seconds
|
||||
|
||||
Returns:
|
||||
CompareResult with capacity information
|
||||
"""
|
||||
params = {
|
||||
'operation': 'compare',
|
||||
'carrier_b64': base64.b64encode(carrier_data).decode('ascii'),
|
||||
}
|
||||
|
||||
result = self._run_worker(params, timeout)
|
||||
|
||||
if result.get('success'):
|
||||
comparison = result.get('comparison', {})
|
||||
return CompareResult(
|
||||
success=True,
|
||||
width=comparison.get('width', 0),
|
||||
height=comparison.get('height', 0),
|
||||
lsb=comparison.get('lsb'),
|
||||
dct=comparison.get('dct'),
|
||||
)
|
||||
else:
|
||||
return CompareResult(
|
||||
success=False,
|
||||
error=result.get('error', 'Unknown error'),
|
||||
)
|
||||
|
||||
def check_capacity(
|
||||
self,
|
||||
carrier_data: bytes,
|
||||
payload_size: int,
|
||||
embed_mode: str = "lsb",
|
||||
timeout: Optional[int] = None,
|
||||
) -> CapacityResult:
|
||||
"""
|
||||
Check if a payload will fit in the carrier.
|
||||
|
||||
Args:
|
||||
carrier_data: Carrier image bytes
|
||||
payload_size: Size of payload in bytes
|
||||
embed_mode: 'lsb' or 'dct'
|
||||
timeout: Operation timeout in seconds
|
||||
|
||||
Returns:
|
||||
CapacityResult with fit information
|
||||
"""
|
||||
params = {
|
||||
'operation': 'capacity',
|
||||
'carrier_b64': base64.b64encode(carrier_data).decode('ascii'),
|
||||
'payload_size': payload_size,
|
||||
'embed_mode': embed_mode,
|
||||
}
|
||||
|
||||
result = self._run_worker(params, timeout)
|
||||
|
||||
if result.get('success'):
|
||||
r = result.get('result', {})
|
||||
return CapacityResult(
|
||||
success=True,
|
||||
fits=r.get('fits', False),
|
||||
payload_size=r.get('payload_size', 0),
|
||||
capacity=r.get('capacity', 0),
|
||||
usage_percent=r.get('usage_percent', 0.0),
|
||||
headroom=r.get('headroom', 0),
|
||||
mode=r.get('mode', embed_mode),
|
||||
)
|
||||
else:
|
||||
return CapacityResult(
|
||||
success=False,
|
||||
error=result.get('error', 'Unknown error'),
|
||||
)
|
||||
|
||||
|
||||
# Convenience function for quick usage
|
||||
_default_stego: Optional[SubprocessStego] = None
|
||||
|
||||
|
||||
def get_subprocess_stego() -> SubprocessStego:
|
||||
"""Get or create default SubprocessStego instance."""
|
||||
global _default_stego
|
||||
if _default_stego is None:
|
||||
_default_stego = SubprocessStego()
|
||||
return _default_stego
|
||||
@@ -11,11 +11,11 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="lead">
|
||||
Stegasoo is a secure steganography tool that hides encrypted messages and files
|
||||
Stegasoo is a steganography tool that hides encrypted messages and files
|
||||
inside ordinary images using multi-factor authentication.
|
||||
</p>
|
||||
|
||||
<h6 class="text-primary mt-4 mb-3"><i class="bi bi-stars me-2"></i>Key Features</h6>
|
||||
<h6 class="text-primary mt-4 mb-3">Features</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<ul class="list-unstyled">
|
||||
@@ -32,39 +32,37 @@
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
<strong>AES-256-GCM Encryption</strong>
|
||||
<br><small class="text-muted">Military-grade authenticated encryption</small>
|
||||
<br><small class="text-muted">Authenticated encryption with integrity verification</small>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
<strong>Single Passphrase</strong>
|
||||
<span class="badge bg-success ms-1">v3.2.0</span>
|
||||
<br><small class="text-muted">Stronger default security</small>
|
||||
<strong>LSB & DCT Modes</strong>
|
||||
<br><small class="text-muted">Choose capacity (LSB) or JPEG resilience (DCT)</small>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
<strong>DCT Mode</strong>
|
||||
<span class="badge bg-warning text-dark ms-1">v3.0</span>
|
||||
<br><small class="text-muted">Survives JPEG recompression for social media</small>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
<strong>Random Pixel Embedding</strong>
|
||||
<br><small class="text-muted">Defeats statistical steganalysis</small>
|
||||
<br><small class="text-muted">Key-derived selection defeats statistical analysis</small>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
<strong>Large Capacity</strong>
|
||||
<br><small class="text-muted">Up to {{ max_payload_kb }} KB payload, 24MP images</small>
|
||||
<strong>Large Image Support</strong>
|
||||
<br><small class="text-muted">Up to {{ max_payload_kb }} KB payload, tested with 14MB+ images</small>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
<strong>Zero Server Storage</strong>
|
||||
<br><small class="text-muted">Nothing saved, files auto-expire</small>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
<strong>QR Code Keys</strong>
|
||||
<br><small class="text-muted">Import/export RSA keys via QR codes</small>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,8 +76,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
<span class="badge bg-warning text-dark me-1">New in v3.0</span>
|
||||
Stegasoo now supports two embedding modes, each optimized for different use cases.
|
||||
Stegasoo supports two embedding modes, each optimized for different use cases.
|
||||
</p>
|
||||
|
||||
<div class="row mt-4">
|
||||
@@ -120,7 +117,6 @@
|
||||
<div class="card-header">
|
||||
<i class="bi bi-soundwave text-warning me-2"></i>
|
||||
<strong>DCT Mode</strong>
|
||||
<span class="badge bg-warning text-dark ms-2">v3.0</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small">
|
||||
@@ -200,7 +196,7 @@
|
||||
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>How Security Works</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Stegasoo uses <strong>hybrid multi-factor authentication</strong> to derive encryption keys:</p>
|
||||
<p>Stegasoo uses <strong>multi-factor authentication</strong> to derive encryption keys:</p>
|
||||
|
||||
<div class="row text-center my-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
@@ -215,7 +211,6 @@
|
||||
<div class="p-3 bg-dark rounded">
|
||||
<i class="bi bi-chat-quote text-warning fs-2 d-block mb-2"></i>
|
||||
<strong>Passphrase</strong>
|
||||
<span class="badge bg-success ms-1">v3.2.0</span>
|
||||
<div class="small text-muted mt-1">Something you know</div>
|
||||
<div class="small text-success">~44 bits (4 words)</div>
|
||||
</div>
|
||||
@@ -224,7 +219,7 @@
|
||||
<div class="p-3 bg-dark rounded">
|
||||
<i class="bi bi-123 text-danger fs-2 d-block mb-2"></i>
|
||||
<strong>Static PIN</strong>
|
||||
<div class="small text-muted mt-1">Something you know (fixed)</div>
|
||||
<div class="small text-muted mt-1">Something you know</div>
|
||||
<div class="small text-success">~20 bits (6 digits)</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,7 +228,7 @@
|
||||
<i class="bi bi-key text-primary fs-2 d-block mb-2"></i>
|
||||
<strong>RSA Key</strong>
|
||||
<div class="small text-muted mt-1">Something you have (optional)</div>
|
||||
<div class="small text-success">~128 bits (2048-bit)</div>
|
||||
<div class="small text-success">~128 bits</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,148 +242,77 @@
|
||||
<h6 class="mt-4">Key Derivation</h6>
|
||||
<p>
|
||||
{% if has_argon2 %}
|
||||
<span class="badge bg-success me-1"><i class="bi bi-check"></i> Argon2id Available</span>
|
||||
Using <strong>Argon2id</strong> with 256MB memory cost — the winner of the Password Hashing Competition
|
||||
and current best practice for key derivation. This makes GPU/ASIC attacks infeasible.
|
||||
<span class="badge bg-success me-1"><i class="bi bi-check"></i> Argon2id</span>
|
||||
Using <strong>Argon2id</strong> with 256MB memory cost — memory-hard KDF that
|
||||
makes GPU/ASIC attacks infeasible.
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark me-1"><i class="bi bi-exclamation-triangle"></i> Argon2 Not Available</span>
|
||||
Falling back to <strong>PBKDF2-SHA512</strong> with 600,000 iterations.
|
||||
Install <code>argon2-cffi</code> for stronger security.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<h6 class="mt-4">Steganography Techniques</h6>
|
||||
<p>
|
||||
<strong>LSB Mode:</strong> Uses Least Significant Bit embedding with pseudo-random pixel selection.
|
||||
The pixel locations are determined by a key derived from your credentials, making the
|
||||
hidden data's location unpredictable without the correct inputs.
|
||||
</p>
|
||||
<p>
|
||||
<strong>DCT Mode:</strong> Uses Discrete Cosine Transform embedding with Quantization Index Modulation (QIM).
|
||||
Data is hidden in mid-frequency coefficients of 8×8 blocks, making it resilient to JPEG recompression.
|
||||
{% if has_dct %}
|
||||
<span class="badge bg-success"><i class="bi bi-check"></i> DCT Available</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">DCT Requires scipy</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Version History -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-file-earmark-binary me-2"></i>File Embedding</h5>
|
||||
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Version History</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
Stegasoo supports embedding <strong>any file type</strong>, not just text messages.
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6><i class="bi bi-check2-square text-success me-2"></i>Supported</h6>
|
||||
<ul class="small">
|
||||
<li>PDF documents</li>
|
||||
<li>ZIP/RAR archives</li>
|
||||
<li>Office documents (DOCX, XLSX, PPTX)</li>
|
||||
<li>Source code files</li>
|
||||
<li>Any binary file up to {{ max_payload_kb }} KB</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6><i class="bi bi-info-circle text-info me-2"></i>How It Works</h6>
|
||||
<ul class="small">
|
||||
<li>Original filename is preserved</li>
|
||||
<li>MIME type is stored for proper handling</li>
|
||||
<li>File is encrypted identically to text</li>
|
||||
<li>Decoding auto-detects text vs. file</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-sm small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>Changes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>4.0.0</strong></td>
|
||||
<td>
|
||||
Simplified auth (no date dependency), passphrase replaces day_phrase,
|
||||
4-word default, JPEG normalization fix, large image support (14MB+ tested),
|
||||
subprocess isolation for stability, Python 3.10-3.12 required
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3.2.0</td>
|
||||
<td>Single passphrase (removed day-of-week rotation), increased default words</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3.0.0</td>
|
||||
<td>DCT steganography mode, JPEG output, color preservation option</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2.2.0</td>
|
||||
<td>QR code RSA key import/export</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2.1.0</td>
|
||||
<td>File embedding, compression support</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2.0.0</td>
|
||||
<td>Web UI, REST API, RSA key support</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1.0.0</td>
|
||||
<td>Initial release, CLI only, LSB mode</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info small mt-3">
|
||||
<i class="bi bi-lightbulb me-2"></i>
|
||||
<strong>Tip:</strong> For larger files, compress them first (ZIP) to maximize capacity.
|
||||
Note that DCT mode has ~10× less capacity than LSB mode.
|
||||
<div class="alert alert-warning small mt-3 mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Compatibility:</strong> v4.0 cannot decode messages from v3.1 or earlier (different format).
|
||||
Messages encoded with v3.2 should decode correctly.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- REST API Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-braces me-2"></i>REST API</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
<span class="badge bg-success me-1"><i class="bi bi-check-circle"></i> FastAPI</span>
|
||||
Stegasoo includes a complete REST API with automatic documentation and type validation.
|
||||
</p>
|
||||
|
||||
<h6 class="mt-4"><i class="bi bi-layers me-2"></i>Endpoints</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<ul class="small">
|
||||
<li><code>POST /generate</code> – Generate credentials</li>
|
||||
<li><code>POST /encode</code> – Encode text (JSON)</li>
|
||||
<li><code>POST /encode/multipart</code> – Encode with uploads</li>
|
||||
<li><code>POST /decode</code> – Decode message (JSON)</li>
|
||||
<li><code>POST /decode/multipart</code> – Decode with uploads</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<ul class="small">
|
||||
<li><code>POST /image/info</code> – Get image capacity</li>
|
||||
<li><code>POST /extract-key-from-qr</code> – Extract RSA from QR</li>
|
||||
<li><code>GET /</code> – API status and capabilities</li>
|
||||
<li><code>GET /docs</code> – Swagger documentation</li>
|
||||
<li><code>GET /redoc</code> – ReDoc documentation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="mt-4"><i class="bi bi-code-slash me-2"></i>Example: DCT Encode</h6>
|
||||
<pre class="bg-dark p-3 rounded small"><code># Encode with DCT mode for social media
|
||||
curl -X POST "http://localhost:8000/encode/multipart" \
|
||||
-F "passphrase=apple forest thunder mountain" \
|
||||
-F "pin=123456" \
|
||||
-F "embed_mode=dct" \
|
||||
-F "dct_output_format=jpeg" \
|
||||
-F "reference_photo=@photo.jpg" \
|
||||
-F "carrier=@meme.png" \
|
||||
-F "message=secret message" \
|
||||
--output stego.jpg</code></pre>
|
||||
|
||||
<h6 class="mt-4"><i class="bi bi-terminal me-2"></i>Command Line</h6>
|
||||
<pre class="bg-dark p-3 rounded small"><code># Generate credentials
|
||||
stegasoo generate --pin --words 4
|
||||
|
||||
# Encode with LSB (default)
|
||||
stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder mountain" \
|
||||
--pin 123456 -m "secret"
|
||||
|
||||
# Encode with DCT for social media
|
||||
stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder mountain" \
|
||||
--pin 123456 -m "secret" --mode dct --dct-format jpeg
|
||||
|
||||
# Decode (auto-detects mode)
|
||||
stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder mountain" \
|
||||
--pin 123456</code></pre>
|
||||
|
||||
<p class="small text-muted mt-3 mb-0">
|
||||
<span class="badge bg-{% if has_argon2 %}success{% else %}warning{% endif %} me-1">
|
||||
{% if has_argon2 %}Argon2{% else %}PBKDF2{% endif %}
|
||||
</span>
|
||||
<span class="badge bg-{% if has_dct %}success{% else %}secondary{% endif %} me-1">
|
||||
{% if has_dct %}DCT Available{% else %}DCT Unavailable{% endif %}
|
||||
</span>
|
||||
<span class="badge bg-{% if has_qrcode_read %}success{% else %}secondary{% endif %}">
|
||||
{% if has_qrcode_read %}QR Reading{% else %}No QR Reading{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-question-circle me-2"></i>Usage Guide</h5>
|
||||
@@ -470,7 +394,7 @@ stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder mountain" \
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Limits & Specifications</h5>
|
||||
</div>
|
||||
@@ -514,11 +438,13 @@ stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder mountain" \
|
||||
<td><strong>2048, 3072, 4096 bits</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-chat-quote me-2"></i>Passphrase length
|
||||
<span class="badge bg-success ms-1">v3.2.0</span>
|
||||
</td>
|
||||
<td><i class="bi bi-chat-quote me-2"></i>Passphrase length</td>
|
||||
<td><strong>3-12 words</strong> (BIP-39, recommended: 4+ words)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-code me-2"></i>Python version</td>
|
||||
<td><strong>3.10-3.12</strong> (3.13 not supported)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -528,7 +454,7 @@ stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder mountain" \
|
||||
<p>
|
||||
Stegasoo v{{ version }} •
|
||||
<i class="bi bi-github me-1"></i>Open Source •
|
||||
Built with Python, Flask/FastAPI, and cryptography
|
||||
Built with Python, Flask, and cryptography
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,53 @@
|
||||
{% block title %}Decode Message - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
/* Glowing passphrase input */
|
||||
.passphrase-input {
|
||||
background: rgba(30, 40, 50, 0.8) !important;
|
||||
border: 2px solid rgba(99, 179, 237, 0.3) !important;
|
||||
color: #63b3ed !important;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 12px 16px;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
|
||||
.passphrase-input:focus {
|
||||
border-color: rgba(99, 179, 237, 0.8) !important;
|
||||
box-shadow: 0 0 20px rgba(99, 179, 237, 0.4), 0 0 40px rgba(99, 179, 237, 0.2) !important;
|
||||
background: rgba(30, 40, 50, 0.95) !important;
|
||||
}
|
||||
|
||||
.passphrase-input::placeholder {
|
||||
color: rgba(99, 179, 237, 0.4);
|
||||
}
|
||||
|
||||
/* Glowing PIN input */
|
||||
.pin-input-container .form-control {
|
||||
background: rgba(30, 40, 50, 0.8) !important;
|
||||
border: 2px solid rgba(246, 173, 85, 0.3) !important;
|
||||
color: #f6ad55 !important;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 3px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.pin-input-container .form-control:focus {
|
||||
border-color: rgba(246, 173, 85, 0.8) !important;
|
||||
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4), 0 0 40px rgba(246, 173, 85, 0.2) !important;
|
||||
background: rgba(30, 40, 50, 0.95) !important;
|
||||
}
|
||||
|
||||
.pin-input-container .form-control::placeholder {
|
||||
color: rgba(246, 173, 85, 0.4);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
@@ -17,14 +64,14 @@
|
||||
</div>
|
||||
|
||||
<label class="form-label text-muted">Decoded Message:</label>
|
||||
<div class="position-relative">
|
||||
<div class="alert-message p-3 rounded bg-dark border border-secondary" id="decodedContent" style="white-space: pre-wrap;">{{ decoded_message }}</div>
|
||||
<button class="btn btn-sm btn-outline-light position-absolute top-0 end-0 m-2" onclick="navigator.clipboard.writeText(document.getElementById('decodedContent').innerText).then(() => this.innerHTML = '<i class=\'bi bi-check\'></i>').catch(() => alert('Failed to copy'))">
|
||||
<i class="bi bi-clipboard"></i> Copy
|
||||
</button>
|
||||
<div class="alert-message p-3 rounded bg-dark border border-secondary mb-2" id="decodedContent" style="white-space: pre-wrap;">{{ decoded_message }}</div>
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<button class="btn btn-sm btn-outline-light" onclick="navigator.clipboard.writeText(document.getElementById('decodedContent').innerText).then(() => { this.innerHTML = '<i class=\'bi bi-check\'></i> Copied!'; setTimeout(() => this.innerHTML = '<i class=\'bi bi-clipboard\'></i> Copy', 2000); }).catch(() => alert('Failed to copy'))">
|
||||
<i class="bi bi-clipboard"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a href="/decode" class="btn btn-outline-light w-100 mt-3">
|
||||
<a href="/decode" class="btn btn-outline-light w-100">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
|
||||
</a>
|
||||
|
||||
@@ -99,7 +146,7 @@
|
||||
<label class="form-label">
|
||||
<i class="bi bi-chat-quote me-1"></i> Passphrase
|
||||
</label>
|
||||
<input type="text" name="passphrase" class="form-control"
|
||||
<input type="text" name="passphrase" id="passphraseInput" class="form-control passphrase-input"
|
||||
placeholder="e.g., correct horse battery staple" required>
|
||||
<div class="form-text">
|
||||
The passphrase used during encoding (typically 4 words)
|
||||
@@ -114,64 +161,62 @@
|
||||
</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="6-9 digits" maxlength="9">
|
||||
<div class="input-group pin-input-container">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
|
||||
<button class="btn btn-outline-secondary" type="button" id="togglePin">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
If PIN was used during encoding
|
||||
</div>
|
||||
|
||||
<div class="form-text">If PIN was used during encoding</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
|
||||
</label>
|
||||
<ul class="nav nav-tabs nav-tabs-sm mb-2" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaFileTabDec" type="button">
|
||||
<i class="bi bi-file-earmark me-1"></i>.pem File
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaQrTabDec" type="button">
|
||||
<i class="bi bi-qr-code me-1"></i>QR Code
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="rsaFileTabDec" role="tabpanel">
|
||||
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
|
||||
</div>
|
||||
<div class="tab-pane fade" id="rsaQrTabDec" role="tabpanel">
|
||||
<input type="file" name="rsa_key_qr" class="form-control form-control-sm" id="rsaKeyQrInput" accept="image/*">
|
||||
<div class="form-text small">PNG, JPG, or other image of QR code</div>
|
||||
|
||||
<!-- RSA Input Method Toggle -->
|
||||
<div class="btn-group w-100 mb-2" role="group">
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile">
|
||||
<i class="bi bi-file-earmark me-1"></i>.pem File
|
||||
</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr">
|
||||
<i class="bi bi-qr-code me-1"></i>QR Code
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- .pem File Input -->
|
||||
<div id="rsaFileSection">
|
||||
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
|
||||
</div>
|
||||
|
||||
<!-- QR Code Input -->
|
||||
<div id="rsaQrSection" class="d-none">
|
||||
<div class="drop-zone p-3" id="qrDropZone">
|
||||
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaKeyQrInput">
|
||||
<div class="drop-zone-label text-center">
|
||||
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
|
||||
<span class="text-muted small">Drop QR image or click to browse</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="qrPreview" style="max-height: 80px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
If RSA key was used during encoding (file or QR image)
|
||||
|
||||
<!-- Key Password (always visible) -->
|
||||
<div class="input-group input-group-sm mt-2">
|
||||
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
|
||||
<button class="btn btn-outline-secondary" type="button" id="toggleRsaPassword">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSA Key Password (shown when key selected) -->
|
||||
<div class="mb-3 d-none" id="rsaPasswordGroup">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key me-1"></i> RSA Key Password
|
||||
</label>
|
||||
<input type="password" name="rsa_password" class="form-control"
|
||||
placeholder="Password for the .pem file (if encrypted)">
|
||||
<div class="form-text">
|
||||
Leave blank if your key file is not password-protected
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
ADVANCED OPTIONS (v3.0) - Extraction Mode
|
||||
================================================================ -->
|
||||
@@ -276,7 +321,7 @@
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>
|
||||
<strong>Format compatibility:</strong> v3.2.0 cannot decode messages from v3.1.0 (different format)
|
||||
<strong>Format compatibility:</strong> v4.0 cannot decode messages from v3.1 or earlier (different format)
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-info-circle-fill text-info me-1"></i>
|
||||
@@ -304,22 +349,6 @@ document.getElementById('decodeForm')?.addEventListener('submit', function() {
|
||||
btn.disabled = true;
|
||||
});
|
||||
|
||||
// Show RSA password field when key is selected
|
||||
const rsaKeyInput = document.getElementById('rsaKeyInput');
|
||||
const rsaKeyQrInput = document.getElementById('rsaKeyQrInput');
|
||||
const rsaPasswordGroup = document.getElementById('rsaPasswordGroup');
|
||||
|
||||
function checkRsaKeySelected() {
|
||||
const hasFile = (rsaKeyInput && rsaKeyInput.files.length > 0) ||
|
||||
(rsaKeyQrInput && rsaKeyQrInput.files.length > 0);
|
||||
if (rsaPasswordGroup) {
|
||||
rsaPasswordGroup.classList.toggle('d-none', !hasFile);
|
||||
}
|
||||
}
|
||||
|
||||
rsaKeyInput?.addEventListener('change', checkRsaKeySelected);
|
||||
rsaKeyQrInput?.addEventListener('change', checkRsaKeySelected);
|
||||
|
||||
// PIN Toggle
|
||||
document.getElementById('togglePin')?.addEventListener('click', function() {
|
||||
const input = document.getElementById('pinInput');
|
||||
@@ -333,6 +362,35 @@ document.getElementById('togglePin')?.addEventListener('click', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// RSA Password Toggle
|
||||
document.getElementById('toggleRsaPassword')?.addEventListener('click', function() {
|
||||
const input = document.getElementById('rsaPasswordInput');
|
||||
const icon = this.querySelector('i');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
||||
}
|
||||
});
|
||||
|
||||
// RSA Input Method Toggle (File vs QR)
|
||||
const rsaMethodFile = document.getElementById('rsaMethodFile');
|
||||
const rsaMethodQr = document.getElementById('rsaMethodQr');
|
||||
const rsaFileSection = document.getElementById('rsaFileSection');
|
||||
const rsaQrSection = document.getElementById('rsaQrSection');
|
||||
|
||||
function updateRsaInputMethod() {
|
||||
if (!rsaMethodFile || !rsaFileSection || !rsaQrSection) return;
|
||||
const isFile = rsaMethodFile.checked;
|
||||
rsaFileSection.classList.toggle('d-none', !isFile);
|
||||
rsaQrSection.classList.toggle('d-none', isFile);
|
||||
}
|
||||
|
||||
rsaMethodFile?.addEventListener('change', updateRsaInputMethod);
|
||||
rsaMethodQr?.addEventListener('change', updateRsaInputMethod);
|
||||
|
||||
// Mode card highlighting
|
||||
const autoModeCard = document.getElementById('autoModeCard');
|
||||
const lsbModeCardDec = document.getElementById('lsbModeCardDec');
|
||||
@@ -428,12 +486,85 @@ document.querySelectorAll('.drop-zone').forEach(zone => {
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
preview.src = e.target.result;
|
||||
preview.classList.remove('d-none');
|
||||
if (preview) {
|
||||
preview.src = e.target.result;
|
||||
preview.classList.remove('d-none');
|
||||
}
|
||||
label.innerHTML = '<i class="bi bi-check-circle text-success me-1"></i>' + file.name;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
|
||||
// QR Code RSA Key scanning
|
||||
const rsaKeyQrInput = document.getElementById('rsaKeyQrInput');
|
||||
const qrPreview = document.getElementById('qrPreview');
|
||||
if (rsaKeyQrInput) {
|
||||
rsaKeyQrInput.addEventListener('change', function() {
|
||||
if (this.files && this.files[0]) {
|
||||
const file = this.files[0];
|
||||
|
||||
// Show image preview
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
if (qrPreview) {
|
||||
qrPreview.src = e.target.result;
|
||||
qrPreview.classList.remove('d-none');
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// Extract key from QR
|
||||
const formData = new FormData();
|
||||
formData.append('qr_image', file);
|
||||
|
||||
fetch('/extract-key-from-qr', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
alert('QR decode failed: ' + data.error);
|
||||
return;
|
||||
}
|
||||
// Visual feedback
|
||||
document.querySelector('#qrDropZone .drop-zone-label').innerHTML =
|
||||
'<i class="bi bi-check-circle text-success me-1"></i>RSA Key loaded from QR';
|
||||
})
|
||||
.catch(err => {
|
||||
alert('QR decode failed: ' + err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-resize passphrase input font to fit long passphrases
|
||||
const passphraseInput = document.getElementById('passphraseInput');
|
||||
if (passphraseInput) {
|
||||
// Stepped font sizes (characters -> rem)
|
||||
const fontSizeSteps = [
|
||||
{ maxChars: 30, size: 1.1 },
|
||||
{ maxChars: 45, size: 1.0 },
|
||||
{ maxChars: 60, size: 0.95 },
|
||||
{ maxChars: Infinity, size: 0.9 }
|
||||
];
|
||||
|
||||
function adjustPassphraseFontSize() {
|
||||
const len = passphraseInput.value.length;
|
||||
|
||||
for (const step of fontSizeSteps) {
|
||||
if (len <= step.maxChars) {
|
||||
passphraseInput.style.fontSize = step.size + 'rem';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
passphraseInput.addEventListener('input', adjustPassphraseFontSize);
|
||||
adjustPassphraseFontSize(); // Initial call in case of pre-filled value
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,57 @@
|
||||
{% block title %}Encode Message - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
/* Glowing passphrase input */
|
||||
.passphrase-input-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.passphrase-input {
|
||||
background: rgba(30, 40, 50, 0.8) !important;
|
||||
border: 2px solid rgba(99, 179, 237, 0.3) !important;
|
||||
color: #63b3ed !important;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 12px 16px;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
|
||||
.passphrase-input:focus {
|
||||
border-color: rgba(99, 179, 237, 0.8) !important;
|
||||
box-shadow: 0 0 20px rgba(99, 179, 237, 0.4), 0 0 40px rgba(99, 179, 237, 0.2) !important;
|
||||
background: rgba(30, 40, 50, 0.95) !important;
|
||||
}
|
||||
|
||||
.passphrase-input::placeholder {
|
||||
color: rgba(99, 179, 237, 0.4);
|
||||
}
|
||||
|
||||
/* Glowing PIN input */
|
||||
.pin-input-container .form-control {
|
||||
background: rgba(30, 40, 50, 0.8) !important;
|
||||
border: 2px solid rgba(246, 173, 85, 0.3) !important;
|
||||
color: #f6ad55 !important;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 3px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.pin-input-container .form-control:focus {
|
||||
border-color: rgba(246, 173, 85, 0.8) !important;
|
||||
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4), 0 0 40px rgba(246, 173, 85, 0.2) !important;
|
||||
background: rgba(30, 40, 50, 0.95) !important;
|
||||
}
|
||||
|
||||
.pin-input-container .form-control::placeholder {
|
||||
color: rgba(246, 173, 85, 0.4);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
@@ -11,7 +62,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" enctype="multipart/form-data" id="encodeForm">
|
||||
<!-- v3.2.0: Removed client_date hidden field -->
|
||||
<!-- Removed client_date hidden field -->
|
||||
|
||||
<!-- Embedding Mode Selection -->
|
||||
<div class="mb-4">
|
||||
@@ -182,15 +233,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- v3.2.0: Renamed from day_phrase to passphrase, removed date selection -->
|
||||
<!-- Passphrase input with glow styling -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label" id="passphraseLabel">
|
||||
<i class="bi bi-chat-quote me-1"></i> Passphrase
|
||||
<span class="badge bg-success ms-2">v3.2.0</span>
|
||||
</label>
|
||||
<input type="text" name="passphrase" class="form-control"
|
||||
placeholder="e.g., apple forest thunder mountain" required
|
||||
id="passphraseInput">
|
||||
<div class="passphrase-input-container">
|
||||
<input type="text" name="passphrase" class="form-control passphrase-input"
|
||||
placeholder="e.g., apple forest thunder mountain" required
|
||||
id="passphraseInput">
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Your passphrase for this message
|
||||
</div>
|
||||
@@ -210,8 +262,8 @@
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="6-9 digits" maxlength="9" style="max-width: 140px;">
|
||||
<div class="input-group pin-input-container">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
|
||||
<button class="btn btn-outline-secondary" type="button" id="togglePin">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
@@ -250,6 +302,7 @@
|
||||
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
|
||||
<span class="text-muted small">Drop QR image or click to browse</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="qrPreview" style="max-height: 80px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -406,12 +459,33 @@ function updatePayloadSection() {
|
||||
payloadTextRadio.addEventListener('change', updatePayloadSection);
|
||||
payloadFileRadio.addEventListener('change', updatePayloadSection);
|
||||
|
||||
// Passphrase validation (v3.2.0)
|
||||
// Passphrase validation and auto-resize font
|
||||
const passphraseInput = document.getElementById('passphraseInput');
|
||||
const passphraseWarning = document.getElementById('passphraseWarning');
|
||||
|
||||
// Stepped font sizes (characters -> rem)
|
||||
const fontSizeSteps = [
|
||||
{ maxChars: 30, size: 1.1 },
|
||||
{ maxChars: 45, size: 1.0 },
|
||||
{ maxChars: 60, size: 0.95 },
|
||||
{ maxChars: Infinity, size: 0.9 }
|
||||
];
|
||||
|
||||
function adjustPassphraseFontSize() {
|
||||
if (!passphraseInput) return;
|
||||
const len = passphraseInput.value.length;
|
||||
|
||||
for (const step of fontSizeSteps) {
|
||||
if (len <= step.maxChars) {
|
||||
passphraseInput.style.fontSize = step.size + 'rem';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (passphraseInput) {
|
||||
passphraseInput.addEventListener('input', function() {
|
||||
// Word count warning
|
||||
const words = this.value.trim().split(/\s+/).filter(w => w.length > 0);
|
||||
const recommendedWords = {{ recommended_passphrase_words }};
|
||||
|
||||
@@ -420,7 +494,13 @@ if (passphraseInput) {
|
||||
} else {
|
||||
passphraseWarning.style.display = 'none';
|
||||
}
|
||||
|
||||
// Auto-resize font
|
||||
adjustPassphraseFontSize();
|
||||
});
|
||||
|
||||
// Initial font size adjustment
|
||||
adjustPassphraseFontSize();
|
||||
}
|
||||
|
||||
// Payload file info display
|
||||
@@ -728,11 +808,27 @@ document.addEventListener('paste', function(e) {
|
||||
|
||||
// QR Code RSA Key scanning
|
||||
const rsaQrInput = document.getElementById('rsaQrInput');
|
||||
const qrPreview = document.getElementById('qrPreview');
|
||||
if (rsaQrInput) {
|
||||
rsaQrInput.addEventListener('change', function() {
|
||||
if (this.files && this.files[0]) {
|
||||
const file = this.files[0];
|
||||
|
||||
// Show image preview
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
if (qrPreview) {
|
||||
qrPreview.src = e.target.result;
|
||||
qrPreview.classList.remove('d-none');
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// Extract key from QR
|
||||
const formData = new FormData();
|
||||
formData.append('qr_image', this.files[0]);
|
||||
formData.append('qr_image', file);
|
||||
|
||||
fetch('/extract-key-from-qr', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
{% if embed_mode == 'dct' %}
|
||||
<li>Recipient needs <strong>DCT mode</strong> or <strong>Auto</strong> detection to decode</li>
|
||||
{% if color_mode == 'color' %}
|
||||
<li><span class="badge bg-success">v3.0.1</span> Color preserved - extraction works on both color and grayscale</li>
|
||||
<li>Color preserved - extraction works on both color and grayscale</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
@@ -112,7 +112,6 @@
|
||||
<div class="mb-4">
|
||||
<h6 class="text-muted">
|
||||
<i class="bi bi-chat-quote me-2"></i>PASSPHRASE
|
||||
<span class="badge bg-success ms-2">v3.2.0</span>
|
||||
</h6>
|
||||
|
||||
<div class="passphrase-container">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div style="margin-bottom: 40px;">
|
||||
<h1 class="display-4 fw-bold mb-2">
|
||||
Stegasoo
|
||||
<span class="badge bg-success fs-6 ms-2">v3.2.0</span>
|
||||
<span class="badge bg-success fs-6 ms-2">v4.0</span>
|
||||
</h1>
|
||||
<p class="lead text-muted mb-0">Hide encrypted data in plain sight.</p>
|
||||
</div>
|
||||
@@ -94,7 +94,6 @@
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-soundwave text-warning fs-2 d-block mb-2"></i>
|
||||
<strong>DCT Mode</strong>
|
||||
<span class="badge bg-warning text-dark ms-1">v3.0</span>
|
||||
<div class="small text-muted mt-2">
|
||||
Survives JPEG recompression<br>
|
||||
Best for social media
|
||||
@@ -108,7 +107,7 @@
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-diagram-3 me-2"></i>How It Works</h5>
|
||||
<a href="/about" class="btn btn-sm btn-outline-secondary">Learn More</a>
|
||||
<a href="/about" class="btn btn-sm btn-outline-light">Learn More</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
@@ -117,21 +116,20 @@
|
||||
<ul class="list-unstyled small">
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-image text-info me-2"></i>
|
||||
<strong>Reference Photo</strong> – shared secret image
|
||||
<strong>Reference Photo</strong> — shared secret image
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-chat-quote text-info me-2"></i>
|
||||
<strong>Passphrase</strong> – 4+ words
|
||||
<span class="badge bg-success ms-1">v3.2.0</span>
|
||||
<strong>Passphrase</strong> — 4+ words
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-123 text-info me-2"></i>
|
||||
<strong>PIN</strong> – 6-9 digits (and/or RSA key)
|
||||
<strong>PIN</strong> — 6-9 digits (and/or RSA key)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-primary"><i class="bi bi-shield-check me-2"></i>We Provide</h6>
|
||||
<h6 class="text-primary"><i class="bi bi-shield-check me-2"></i>Security</h6>
|
||||
<ul class="list-unstyled small">
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-lock text-success me-2"></i>
|
||||
|
||||
90
frontends/web/test_routes.py
Normal file
90
frontends/web/test_routes.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Minimal test to isolate the memory corruption crash.
|
||||
|
||||
Add this route to your app.py temporarily to test if the crash
|
||||
is in Flask/Pillow or in stegasoo code.
|
||||
|
||||
Usage:
|
||||
1. Add this code to app.py
|
||||
2. Restart the server
|
||||
3. Use the /test-capacity endpoint instead of /api/compare-capacity
|
||||
4. If it crashes: Flask or Pillow issue
|
||||
5. If it works: Stegasoo code issue
|
||||
"""
|
||||
|
||||
# Add these imports at the top of app.py if not present:
|
||||
# from PIL import Image
|
||||
# import io
|
||||
|
||||
# Add this route to app.py:
|
||||
|
||||
@app.route('/test-capacity', methods=['POST'])
|
||||
def test_capacity():
|
||||
"""
|
||||
Minimal capacity test - no stegasoo code, just PIL.
|
||||
"""
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier image provided'}), 400
|
||||
|
||||
try:
|
||||
# Read the file data
|
||||
carrier_data = carrier.read()
|
||||
|
||||
# Method 1: Just get size from PIL
|
||||
buffer = io.BytesIO(carrier_data)
|
||||
img = Image.open(buffer)
|
||||
width, height = img.size
|
||||
fmt = img.format
|
||||
mode = img.mode
|
||||
img.close()
|
||||
buffer.close()
|
||||
|
||||
# Simple capacity calculation (no scipy, no numpy)
|
||||
pixels = width * height
|
||||
lsb_bytes = (pixels * 3) // 8
|
||||
blocks = (width // 8) * (height // 8)
|
||||
dct_bytes = (blocks * 16) // 8 - 10
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'format': fmt,
|
||||
'mode': mode,
|
||||
'lsb': {
|
||||
'capacity_bytes': lsb_bytes,
|
||||
'capacity_kb': round(lsb_bytes / 1024, 1),
|
||||
},
|
||||
'dct': {
|
||||
'capacity_bytes': dct_bytes,
|
||||
'capacity_kb': round(dct_bytes / 1024, 1),
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
|
||||
# Alternative: completely bypass PIL too
|
||||
@app.route('/test-capacity-nopil', methods=['POST'])
|
||||
def test_capacity_nopil():
|
||||
"""
|
||||
Ultra-minimal test - no PIL, no stegasoo.
|
||||
"""
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier image provided'}), 400
|
||||
|
||||
try:
|
||||
carrier_data = carrier.read()
|
||||
|
||||
# Just return size info, no image processing at all
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data_size': len(carrier_data),
|
||||
'first_bytes': carrier_data[:20].hex() if len(carrier_data) >= 20 else carrier_data.hex(),
|
||||
})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
289
minimal_flask_crash.py
Normal file
289
minimal_flask_crash.py
Normal file
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal Flask app to isolate the crash.
|
||||
Run with: python minimal_flask_crash.py
|
||||
|
||||
Then test with:
|
||||
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test1
|
||||
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test2
|
||||
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test3
|
||||
"""
|
||||
|
||||
import io
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Minimal imports first
|
||||
from flask import Flask, request, jsonify
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB
|
||||
|
||||
# Check for jpegio
|
||||
try:
|
||||
import jpegio as jio
|
||||
HAS_JPEGIO = True
|
||||
print("jpegio: available")
|
||||
except ImportError:
|
||||
HAS_JPEGIO = False
|
||||
print("jpegio: NOT available")
|
||||
|
||||
|
||||
@app.route('/test1', methods=['POST'])
|
||||
def test1_pil_only():
|
||||
"""Test 1: PIL only, no jpegio, no scipy"""
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier'}), 400
|
||||
|
||||
data = carrier.read()
|
||||
print(f"[test1] Read {len(data)} bytes")
|
||||
|
||||
img = Image.open(io.BytesIO(data))
|
||||
width, height = img.size
|
||||
fmt = img.format
|
||||
img.close()
|
||||
print(f"[test1] Image: {width}x{height} {fmt}")
|
||||
|
||||
gc.collect()
|
||||
print("[test1] Returning response...")
|
||||
|
||||
return jsonify({
|
||||
'test': 'pil_only',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'format': fmt,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/test2', methods=['POST'])
|
||||
def test2_multiple_opens():
|
||||
"""Test 2: Open image multiple times like compare_modes does"""
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier'}), 400
|
||||
|
||||
data = carrier.read()
|
||||
print(f"[test2] Read {len(data)} bytes")
|
||||
|
||||
# First open
|
||||
img1 = Image.open(io.BytesIO(data))
|
||||
width, height = img1.size
|
||||
img1.close()
|
||||
print(f"[test2] Open 1: {width}x{height}")
|
||||
|
||||
# Second open
|
||||
img2 = Image.open(io.BytesIO(data))
|
||||
pixels = img2.size[0] * img2.size[1]
|
||||
img2.close()
|
||||
print(f"[test2] Open 2: {pixels} pixels")
|
||||
|
||||
# Third open
|
||||
img3 = Image.open(io.BytesIO(data))
|
||||
blocks = (img3.size[0] // 8) * (img3.size[1] // 8)
|
||||
img3.close()
|
||||
print(f"[test2] Open 3: {blocks} blocks")
|
||||
|
||||
gc.collect()
|
||||
print("[test2] Returning response...")
|
||||
|
||||
return jsonify({
|
||||
'test': 'multiple_opens',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'pixels': pixels,
|
||||
'blocks': blocks,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/test3', methods=['POST'])
|
||||
def test3_with_jpegio():
|
||||
"""Test 3: Include jpegio operations"""
|
||||
if not HAS_JPEGIO:
|
||||
return jsonify({'error': 'jpegio not available'}), 501
|
||||
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier'}), 400
|
||||
|
||||
data = carrier.read()
|
||||
print(f"[test3] Read {len(data)} bytes")
|
||||
|
||||
# Check if JPEG
|
||||
img = Image.open(io.BytesIO(data))
|
||||
is_jpeg = img.format == 'JPEG'
|
||||
width, height = img.size
|
||||
img.close()
|
||||
print(f"[test3] Image: {width}x{height}, JPEG: {is_jpeg}")
|
||||
|
||||
if not is_jpeg:
|
||||
return jsonify({'error': 'Not a JPEG'}), 400
|
||||
|
||||
# Write to temp file
|
||||
fd, temp_path = tempfile.mkstemp(suffix='.jpg')
|
||||
os.write(fd, data)
|
||||
os.close(fd)
|
||||
print(f"[test3] Temp file: {temp_path}")
|
||||
|
||||
try:
|
||||
# Read with jpegio
|
||||
jpeg = jio.read(temp_path)
|
||||
print(f"[test3] jpegio.read() OK")
|
||||
|
||||
coef = jpeg.coef_arrays[0]
|
||||
coef_shape = coef.shape
|
||||
print(f"[test3] Coef shape: {coef_shape}")
|
||||
|
||||
# Count positions like the real code does
|
||||
positions = 0
|
||||
h, w = coef.shape
|
||||
for row in range(h):
|
||||
for col in range(w):
|
||||
if (row % 8 == 0) and (col % 8 == 0):
|
||||
continue
|
||||
if abs(coef[row, col]) >= 2:
|
||||
positions += 1
|
||||
print(f"[test3] Usable positions: {positions}")
|
||||
|
||||
# Cleanup
|
||||
del coef
|
||||
del jpeg
|
||||
print(f"[test3] Deleted jpegio objects")
|
||||
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
print(f"[test3] Removed temp file")
|
||||
|
||||
gc.collect()
|
||||
print("[test3] Returning response...")
|
||||
|
||||
return jsonify({
|
||||
'test': 'with_jpegio',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'coef_shape': list(coef_shape),
|
||||
'positions': positions,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/test4', methods=['POST'])
|
||||
def test4_numpy_array_from_pil():
|
||||
"""Test 4: Create numpy array from PIL image (like DCT does)"""
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier'}), 400
|
||||
|
||||
data = carrier.read()
|
||||
print(f"[test4] Read {len(data)} bytes")
|
||||
|
||||
img = Image.open(io.BytesIO(data))
|
||||
width, height = img.size
|
||||
print(f"[test4] Image: {width}x{height}")
|
||||
|
||||
# Convert to grayscale and numpy array
|
||||
gray = img.convert('L')
|
||||
arr = np.array(gray, dtype=np.float64, copy=True)
|
||||
print(f"[test4] Array: {arr.shape} {arr.dtype}")
|
||||
|
||||
# Close PIL images
|
||||
gray.close()
|
||||
img.close()
|
||||
print(f"[test4] PIL closed")
|
||||
|
||||
# Do some numpy operations
|
||||
mean_val = float(np.mean(arr))
|
||||
std_val = float(np.std(arr))
|
||||
print(f"[test4] Stats: mean={mean_val:.2f}, std={std_val:.2f}")
|
||||
|
||||
# Clear array
|
||||
del arr
|
||||
gc.collect()
|
||||
print("[test4] Returning response...")
|
||||
|
||||
return jsonify({
|
||||
'test': 'numpy_from_pil',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'mean': mean_val,
|
||||
'std': std_val,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/test5', methods=['POST'])
|
||||
def test5_file_read_keep_reference():
|
||||
"""Test 5: Keep reference to file data in request scope"""
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier'}), 400
|
||||
|
||||
# Don't read into local variable - read directly each time
|
||||
# This mimics potential issues with Flask's file handling
|
||||
|
||||
print(f"[test5] File object: {carrier}")
|
||||
|
||||
# Read once
|
||||
carrier.seek(0)
|
||||
data1 = carrier.read()
|
||||
print(f"[test5] First read: {len(data1)} bytes")
|
||||
|
||||
img = Image.open(io.BytesIO(data1))
|
||||
width, height = img.size
|
||||
img.close()
|
||||
|
||||
# Try to read again (should be empty or need seek)
|
||||
data2 = carrier.read()
|
||||
print(f"[test5] Second read (no seek): {len(data2)} bytes")
|
||||
|
||||
carrier.seek(0)
|
||||
data3 = carrier.read()
|
||||
print(f"[test5] Third read (after seek): {len(data3)} bytes")
|
||||
|
||||
gc.collect()
|
||||
print("[test5] Returning response...")
|
||||
|
||||
return jsonify({
|
||||
'test': 'file_handling',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'read1': len(data1),
|
||||
'read2': len(data2),
|
||||
'read3': len(data3),
|
||||
})
|
||||
|
||||
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
"""Log after each request"""
|
||||
print(f"[after_request] Response status: {response.status}")
|
||||
return response
|
||||
|
||||
|
||||
@app.teardown_request
|
||||
def teardown_request(exception):
|
||||
"""Log during teardown"""
|
||||
if exception:
|
||||
print(f"[teardown] Exception: {exception}")
|
||||
else:
|
||||
print("[teardown] Clean teardown")
|
||||
gc.collect()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("\n" + "=" * 60)
|
||||
print("MINIMAL FLASK CRASH TEST")
|
||||
print("=" * 60)
|
||||
print("\nTest endpoints:")
|
||||
print(" /test1 - PIL only")
|
||||
print(" /test2 - Multiple PIL opens")
|
||||
print(" /test3 - With jpegio")
|
||||
print(" /test4 - NumPy array from PIL")
|
||||
print(" /test5 - File handling test")
|
||||
print("\nUsage:")
|
||||
print(' curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test1')
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
app.run(host='0.0.0.0', port=5001, debug=False, threaded=False)
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "stegasoo"
|
||||
version = "3.2.0"
|
||||
version = "4.0.0"
|
||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -27,6 +27,11 @@ click>=8.1.0
|
||||
pytest>=7.4.0
|
||||
pytest-cov>=4.1.0
|
||||
|
||||
|
||||
scipy>=1.16.3
|
||||
jpegio>=0.2.8
|
||||
numpy>=2.4.0
|
||||
|
||||
# Optional: Better performance for Pillow
|
||||
# pillow-simd>=9.0.0 # Uncomment if available for your platform
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from pathlib import Path
|
||||
# VERSION
|
||||
# ============================================================================
|
||||
|
||||
__version__ = "3.2.0"
|
||||
__version__ = "4.0.0"
|
||||
|
||||
# ============================================================================
|
||||
# FILE FORMAT
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
974
src/stegasoo/dct_steganography.py_old
Normal file
974
src/stegasoo/dct_steganography.py_old
Normal file
@@ -0,0 +1,974 @@
|
||||
"""
|
||||
DCT Domain Steganography Module (v3.2.0)
|
||||
|
||||
Embeds data in DCT coefficients with two approaches:
|
||||
1. PNG output: Scipy-based DCT transform (grayscale or color)
|
||||
2. JPEG output: jpegio-based coefficient manipulation (if available)
|
||||
|
||||
The JPEG approach is the "correct" way to do JPEG steganography because
|
||||
it directly modifies the already-quantized coefficients without re-encoding.
|
||||
|
||||
Changes in v3.0.2:
|
||||
- jpegio integration for proper JPEG coefficient embedding
|
||||
- Falls back to warning if jpegio not available for JPEG output
|
||||
- Maintains backward compatibility with v3.0.1
|
||||
|
||||
Changes in v3.2.0:
|
||||
- Fixed color-mode extraction to properly extract from Y channel
|
||||
- Added _extract_from_y_channel() for accurate color-mode extraction
|
||||
- Improved extraction robustness for both grayscale and color modes
|
||||
|
||||
Requires: scipy (for PNG mode), optionally jpegio (for JPEG mode)
|
||||
"""
|
||||
|
||||
import io
|
||||
import struct
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Literal, Tuple
|
||||
from enum import Enum
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Check for scipy availability (for PNG/DCT mode)
|
||||
try:
|
||||
from scipy.fftpack import dct, idct
|
||||
HAS_SCIPY = True
|
||||
except ImportError:
|
||||
HAS_SCIPY = False
|
||||
dct = None
|
||||
idct = None
|
||||
|
||||
# Check for jpegio availability (for proper JPEG mode)
|
||||
try:
|
||||
import jpegio as jio
|
||||
HAS_JPEGIO = True
|
||||
except ImportError:
|
||||
HAS_JPEGIO = False
|
||||
jio = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONSTANTS
|
||||
# ============================================================================
|
||||
|
||||
# DCT block size (standard 8x8 like JPEG)
|
||||
BLOCK_SIZE = 8
|
||||
|
||||
# Coefficients to use for embedding (mid-frequency, zig-zag order positions)
|
||||
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
|
||||
DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20] # 16 coefficients per block
|
||||
|
||||
# Quantization step for QIM embedding (larger = more robust, more visible)
|
||||
QUANT_STEP = 25
|
||||
|
||||
# Magic bytes for DCT stego identification
|
||||
DCT_MAGIC = b'DCTS'
|
||||
|
||||
# Header size: 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 output quality (only for fallback mode, not jpegio)
|
||||
JPEG_OUTPUT_QUALITY = 95
|
||||
|
||||
# jpegio constants for JPEG coefficient embedding
|
||||
JPEGIO_MAGIC = b'JPGS'
|
||||
JPEGIO_MIN_COEF_MAGNITUDE = 2
|
||||
JPEGIO_EMBED_CHANNEL = 0 # Y channel
|
||||
|
||||
# Flag bits for header
|
||||
FLAG_COLOR_MODE = 0x01 # Set if embedded in color mode (Y channel of YCbCr)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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
|
||||
jpeg_native: bool = False # True if used jpegio for proper JPEG embedding
|
||||
color_mode: str = 'grayscale' # 'color' or 'grayscale' (v3.0.1+)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AVAILABILITY CHECKS
|
||||
# ============================================================================
|
||||
|
||||
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 has_dct_support() -> bool:
|
||||
"""Check if DCT steganography is available (scipy installed)."""
|
||||
return HAS_SCIPY
|
||||
|
||||
|
||||
def has_jpegio_support() -> bool:
|
||||
"""Check if jpegio is available for proper JPEG coefficient embedding."""
|
||||
return HAS_JPEGIO
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SCIPY DCT HELPERS (for PNG output)
|
||||
# ============================================================================
|
||||
|
||||
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 _extract_y_channel(image_data: bytes) -> np.ndarray:
|
||||
"""
|
||||
Extract Y (luminance) channel from image for color-mode extraction.
|
||||
|
||||
This uses the same YCbCr conversion as embedding to ensure
|
||||
accurate extraction from color-mode stego images.
|
||||
|
||||
Args:
|
||||
image_data: Image file bytes
|
||||
|
||||
Returns:
|
||||
Y channel as float64 numpy array
|
||||
"""
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# Convert to RGB if needed
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
rgb_array = np.array(img, dtype=np.float64)
|
||||
|
||||
# Extract Y channel using ITU-R BT.601 (same as embedding)
|
||||
R = rgb_array[:, :, 0]
|
||||
G = rgb_array[:, :, 1]
|
||||
B = rgb_array[:, :, 2]
|
||||
|
||||
Y = 0.299 * R + 0.587 * G + 0.114 * B
|
||||
|
||||
return Y
|
||||
|
||||
|
||||
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
|
||||
|
||||
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(coef: float, bit: int, quant_step: int = QUANT_STEP) -> float:
|
||||
"""Embed a single bit into a DCT coefficient using QIM."""
|
||||
quantized = round(coef / quant_step)
|
||||
if (quantized % 2) != bit:
|
||||
if quantized % 2 == 0 and bit == 1:
|
||||
quantized += 1 if coef >= quantized * quant_step else -1
|
||||
elif quantized % 2 == 1 and bit == 0:
|
||||
quantized += 1 if coef >= quantized * quant_step else -1
|
||||
return quantized * quant_step
|
||||
|
||||
|
||||
def _extract_bit_from_coeff(coef: float, quant_step: int = QUANT_STEP) -> int:
|
||||
"""Extract a single bit from a DCT coefficient."""
|
||||
quantized = round(coef / quant_step)
|
||||
return quantized % 2
|
||||
|
||||
|
||||
def _generate_block_order(num_blocks: int, seed: bytes) -> list:
|
||||
"""Generate pseudo-random block order 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 (grayscale)."""
|
||||
clipped = np.clip(image, 0, 255).astype(np.uint8)
|
||||
img = Image.fromarray(clipped, mode='L')
|
||||
|
||||
buffer = io.BytesIO()
|
||||
|
||||
if output_format == OUTPUT_FORMAT_JPEG:
|
||||
img.save(buffer, format='JPEG', quality=JPEG_OUTPUT_QUALITY,
|
||||
subsampling=0, optimize=True)
|
||||
else:
|
||||
img.save(buffer, format='PNG', optimize=True)
|
||||
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _save_color_image(rgb_array: np.ndarray, output_format: str = OUTPUT_FORMAT_PNG) -> bytes:
|
||||
"""Save color RGB image in specified format."""
|
||||
clipped = np.clip(rgb_array, 0, 255).astype(np.uint8)
|
||||
img = Image.fromarray(clipped, mode='RGB')
|
||||
|
||||
buffer = io.BytesIO()
|
||||
|
||||
if output_format == OUTPUT_FORMAT_JPEG:
|
||||
img.save(buffer, format='JPEG', quality=JPEG_OUTPUT_QUALITY,
|
||||
subsampling=0, optimize=True)
|
||||
else:
|
||||
img.save(buffer, format='PNG', optimize=True)
|
||||
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _rgb_to_ycbcr(rgb: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""
|
||||
Convert RGB array to YCbCr components.
|
||||
|
||||
Uses ITU-R BT.601 conversion (standard for JPEG).
|
||||
|
||||
Args:
|
||||
rgb: RGB image array (H, W, 3), float64
|
||||
|
||||
Returns:
|
||||
Tuple of (Y, Cb, Cr) arrays
|
||||
"""
|
||||
R = rgb[:, :, 0]
|
||||
G = rgb[:, :, 1]
|
||||
B = rgb[:, :, 2]
|
||||
|
||||
# ITU-R BT.601 conversion
|
||||
Y = 0.299 * R + 0.587 * G + 0.114 * B
|
||||
Cb = 128 - 0.168736 * R - 0.331264 * G + 0.5 * B
|
||||
Cr = 128 + 0.5 * R - 0.418688 * G - 0.081312 * B
|
||||
|
||||
return Y, Cb, Cr
|
||||
|
||||
|
||||
def _ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Convert YCbCr components back to RGB array.
|
||||
|
||||
Args:
|
||||
Y: Luminance channel
|
||||
Cb: Blue-difference chroma
|
||||
Cr: Red-difference chroma
|
||||
|
||||
Returns:
|
||||
RGB array (H, W, 3)
|
||||
"""
|
||||
R = Y + 1.402 * (Cr - 128)
|
||||
G = Y - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 128)
|
||||
B = Y + 1.772 * (Cb - 128)
|
||||
|
||||
rgb = np.stack([R, G, B], axis=-1)
|
||||
return rgb
|
||||
|
||||
|
||||
def _create_header(data_length: int, flags: int = 0) -> bytes:
|
||||
"""Create DCT stego header."""
|
||||
version = 1
|
||||
return struct.pack('>4sBBI', DCT_MAGIC, version, flags, data_length)
|
||||
|
||||
|
||||
def _parse_header(header_bits: list) -> 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")
|
||||
|
||||
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")
|
||||
|
||||
return version, flags, length
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# JPEGIO HELPERS (for proper JPEG output)
|
||||
# ============================================================================
|
||||
|
||||
def _jpegio_bytes_to_file(data: bytes, suffix: str = '.jpg') -> str:
|
||||
"""Write bytes to temp file for jpegio."""
|
||||
import tempfile
|
||||
import os
|
||||
fd, path = tempfile.mkstemp(suffix=suffix)
|
||||
try:
|
||||
os.write(fd, data)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return path
|
||||
|
||||
|
||||
def _jpegio_file_to_bytes(path: str) -> bytes:
|
||||
"""Read file to bytes and delete it."""
|
||||
import os
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
return f.read()
|
||||
finally:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _jpegio_get_usable_positions(coef_array: np.ndarray) -> list:
|
||||
"""Get usable coefficient positions for jpegio embedding."""
|
||||
positions = []
|
||||
h, w = coef_array.shape
|
||||
|
||||
for row in range(h):
|
||||
for col in range(w):
|
||||
# Skip DC coefficients
|
||||
if (row % BLOCK_SIZE == 0) and (col % BLOCK_SIZE == 0):
|
||||
continue
|
||||
# Check magnitude
|
||||
if abs(coef_array[row, col]) >= JPEGIO_MIN_COEF_MAGNITUDE:
|
||||
positions.append((row, col))
|
||||
|
||||
return positions
|
||||
|
||||
|
||||
def _jpegio_generate_order(num_positions: int, seed: bytes) -> list:
|
||||
"""Generate pseudo-random order for jpegio embedding."""
|
||||
hash_bytes = hashlib.sha256(seed + b"jpeg_coef_order").digest()
|
||||
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big'))
|
||||
order = list(range(num_positions))
|
||||
rng.shuffle(order)
|
||||
return order
|
||||
|
||||
|
||||
def _jpegio_create_header(data_length: int, flags: int = 0) -> bytes:
|
||||
"""Create header for jpegio embedding."""
|
||||
return struct.pack('>4sBBI', JPEGIO_MAGIC, 1, flags, data_length)
|
||||
|
||||
|
||||
def _jpegio_parse_header(header_bytes: bytes) -> Tuple[int, int, int]:
|
||||
"""Parse jpegio header."""
|
||||
if len(header_bytes) < HEADER_SIZE:
|
||||
raise ValueError("Insufficient header data")
|
||||
|
||||
magic, version, flags, length = struct.unpack('>4sBBI', header_bytes[:HEADER_SIZE])
|
||||
|
||||
if magic != JPEGIO_MAGIC:
|
||||
raise ValueError(f"Invalid JPEG stego magic: {magic}")
|
||||
|
||||
return version, flags, length
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PUBLIC API
|
||||
# ============================================================================
|
||||
|
||||
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
|
||||
|
||||
blocks_x = width // BLOCK_SIZE
|
||||
blocks_y = height // BLOCK_SIZE
|
||||
total_blocks = blocks_x * blocks_y
|
||||
|
||||
bits_per_block = len(DEFAULT_EMBED_POSITIONS)
|
||||
total_bits = total_blocks * bits_per_block
|
||||
total_bytes = total_bits // 8
|
||||
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."""
|
||||
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."""
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
width, height = img.size
|
||||
pixels = width * height
|
||||
|
||||
lsb_bytes = (pixels * 3) // 8
|
||||
|
||||
if HAS_SCIPY:
|
||||
dct_info = calculate_dct_capacity(image_data)
|
||||
dct_bytes = dct_info.usable_capacity_bytes
|
||||
else:
|
||||
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,
|
||||
},
|
||||
'jpeg_native': {
|
||||
'available': HAS_JPEGIO,
|
||||
'note': 'Uses jpegio for proper JPEG coefficient embedding',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def embed_in_dct(
|
||||
data: bytes,
|
||||
carrier_image: bytes,
|
||||
seed: bytes,
|
||||
output_format: str = OUTPUT_FORMAT_PNG,
|
||||
color_mode: str = 'color', # v3.0.1: 'color' or 'grayscale'
|
||||
) -> Tuple[bytes, DCTEmbedStats]:
|
||||
"""
|
||||
Embed data into image using DCT coefficient modification.
|
||||
|
||||
For PNG output: Uses scipy DCT transform
|
||||
For JPEG output: Uses jpegio if available for proper coefficient embedding
|
||||
|
||||
Args:
|
||||
data: Data to embed
|
||||
carrier_image: Carrier image bytes
|
||||
seed: Seed for pseudo-random selection
|
||||
output_format: 'png' (default, lossless) or 'jpeg'
|
||||
color_mode: 'color' (preserve colors) or 'grayscale' (v3.0.1+)
|
||||
|
||||
Returns:
|
||||
Tuple of (stego_image_bytes, stats)
|
||||
"""
|
||||
# Validate output format
|
||||
if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG):
|
||||
raise ValueError(f"Invalid output format: {output_format}")
|
||||
|
||||
# Validate color mode
|
||||
if color_mode not in ('color', 'grayscale'):
|
||||
color_mode = 'color' # Default to color
|
||||
|
||||
# For JPEG output, try to use jpegio for proper coefficient embedding
|
||||
# Note: jpegio naturally preserves color (works in YCbCr space)
|
||||
if output_format == OUTPUT_FORMAT_JPEG:
|
||||
if HAS_JPEGIO:
|
||||
return _embed_jpegio(data, carrier_image, seed, color_mode)
|
||||
else:
|
||||
# Fall back to scipy + PIL JPEG (WARNING: may not decode properly)
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"jpegio not available. JPEG output may not decode correctly. "
|
||||
"Install jpegio for proper JPEG steganography support.",
|
||||
RuntimeWarning
|
||||
)
|
||||
# Continue with scipy method but output as JPEG
|
||||
|
||||
# PNG output or JPEG fallback: use scipy DCT method
|
||||
_check_scipy()
|
||||
return _embed_scipy_dct(data, carrier_image, seed, output_format, color_mode)
|
||||
|
||||
|
||||
def _embed_scipy_dct(
|
||||
data: bytes,
|
||||
carrier_image: bytes,
|
||||
seed: bytes,
|
||||
output_format: str,
|
||||
color_mode: str = 'color',
|
||||
) -> Tuple[bytes, DCTEmbedStats]:
|
||||
"""Embed using scipy DCT (for PNG output), with color preservation option."""
|
||||
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)"
|
||||
)
|
||||
|
||||
# Load image
|
||||
img = Image.open(io.BytesIO(carrier_image))
|
||||
width, height = img.size
|
||||
|
||||
# Set flags for header
|
||||
flags = FLAG_COLOR_MODE if color_mode == 'color' else 0
|
||||
|
||||
if color_mode == 'color' and img.mode in ('RGB', 'RGBA'):
|
||||
# Color mode: convert to YCbCr, embed in Y only, preserve Cb/Cr
|
||||
if img.mode == 'RGBA':
|
||||
img = img.convert('RGB')
|
||||
|
||||
rgb_array = np.array(img, dtype=np.float64)
|
||||
Y, Cb, Cr = _rgb_to_ycbcr(rgb_array)
|
||||
|
||||
# Pad Y channel
|
||||
Y_padded, original_size = _pad_to_blocks(Y)
|
||||
|
||||
# Embed in Y channel (with color flag)
|
||||
Y_embedded = _embed_in_channel(Y_padded, data, seed, capacity_info, flags)
|
||||
|
||||
# Unpad
|
||||
Y_result = _unpad_image(Y_embedded, original_size)
|
||||
|
||||
# Convert back to RGB
|
||||
result_rgb = _ycbcr_to_rgb(Y_result, Cb, Cr)
|
||||
|
||||
# Save as color image
|
||||
stego_bytes = _save_color_image(result_rgb, output_format)
|
||||
else:
|
||||
# Grayscale mode: original behavior
|
||||
image = _to_grayscale(carrier_image)
|
||||
padded, original_size = _pad_to_blocks(image)
|
||||
|
||||
embedded = _embed_in_channel(padded, data, seed, capacity_info, flags)
|
||||
|
||||
result = _unpad_image(embedded, original_size)
|
||||
stego_bytes = _save_stego_image(result, output_format)
|
||||
|
||||
# Calculate stats
|
||||
header = _create_header(len(data), flags)
|
||||
payload = header + data
|
||||
bits = len(payload) * 8
|
||||
|
||||
stats = DCTEmbedStats(
|
||||
blocks_used=(bits + len(DEFAULT_EMBED_POSITIONS) - 1) // len(DEFAULT_EMBED_POSITIONS),
|
||||
blocks_available=capacity_info.total_blocks,
|
||||
bits_embedded=bits,
|
||||
capacity_bits=capacity_info.total_capacity_bits,
|
||||
usage_percent=(bits / capacity_info.total_capacity_bits) * 100,
|
||||
image_width=width,
|
||||
image_height=height,
|
||||
output_format=output_format,
|
||||
jpeg_native=False,
|
||||
color_mode=color_mode,
|
||||
)
|
||||
|
||||
return stego_bytes, stats
|
||||
|
||||
|
||||
def _embed_in_channel(
|
||||
channel: np.ndarray,
|
||||
data: bytes,
|
||||
seed: bytes,
|
||||
capacity_info: DCTCapacityInfo,
|
||||
flags: int = 0,
|
||||
) -> np.ndarray:
|
||||
"""Embed data in a single channel using DCT."""
|
||||
header = _create_header(len(data), flags)
|
||||
payload = header + data
|
||||
|
||||
bits = []
|
||||
for byte in payload:
|
||||
for i in range(7, -1, -1):
|
||||
bits.append((byte >> i) & 1)
|
||||
|
||||
num_blocks = capacity_info.total_blocks
|
||||
block_order = _generate_block_order(num_blocks, seed)
|
||||
|
||||
h, w = channel.shape
|
||||
result = channel.copy()
|
||||
|
||||
bit_idx = 0
|
||||
for block_num in block_order:
|
||||
if bit_idx >= len(bits):
|
||||
break
|
||||
|
||||
by = (block_num // (w // BLOCK_SIZE)) * BLOCK_SIZE
|
||||
bx = (block_num % (w // BLOCK_SIZE)) * BLOCK_SIZE
|
||||
|
||||
block = result[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE].copy()
|
||||
dct_block = _dct2(block)
|
||||
|
||||
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
|
||||
|
||||
modified_block = _idct2(dct_block)
|
||||
result[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE] = modified_block
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _embed_jpegio(
|
||||
data: bytes,
|
||||
carrier_image: bytes,
|
||||
seed: bytes,
|
||||
color_mode: str = 'color',
|
||||
) -> Tuple[bytes, DCTEmbedStats]:
|
||||
"""
|
||||
Embed using jpegio for proper JPEG coefficient modification.
|
||||
|
||||
Note: jpegio naturally preserves color since JPEG stores YCbCr
|
||||
and we only modify Y channel coefficients.
|
||||
"""
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Check if carrier is JPEG - if not, convert it
|
||||
img = Image.open(io.BytesIO(carrier_image))
|
||||
width, height = img.size
|
||||
|
||||
if img.format != 'JPEG':
|
||||
# Convert to JPEG first
|
||||
buffer = io.BytesIO()
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
img.save(buffer, format='JPEG', quality=95, subsampling=0)
|
||||
carrier_image = buffer.getvalue()
|
||||
|
||||
# Write carrier to temp file
|
||||
input_path = _jpegio_bytes_to_file(carrier_image, suffix='.jpg')
|
||||
output_path = tempfile.mktemp(suffix='.jpg')
|
||||
|
||||
# Set flags
|
||||
flags = FLAG_COLOR_MODE if color_mode == 'color' else 0
|
||||
|
||||
try:
|
||||
# Read JPEG with jpegio
|
||||
jpeg = jio.read(input_path)
|
||||
|
||||
# Get Y channel coefficients (channel 0)
|
||||
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
|
||||
|
||||
# Find usable positions
|
||||
all_positions = _jpegio_get_usable_positions(coef_array)
|
||||
|
||||
# Generate pseudo-random order
|
||||
order = _jpegio_generate_order(len(all_positions), seed)
|
||||
|
||||
# Create payload with flags
|
||||
header = _jpegio_create_header(len(data), flags)
|
||||
payload = header + data
|
||||
|
||||
# Convert to bits
|
||||
bits = []
|
||||
for byte in payload:
|
||||
for i in range(7, -1, -1):
|
||||
bits.append((byte >> i) & 1)
|
||||
|
||||
if len(bits) > len(all_positions):
|
||||
raise ValueError(
|
||||
f"Payload too large: {len(bits)} bits, "
|
||||
f"only {len(all_positions)} usable coefficients"
|
||||
)
|
||||
|
||||
# Embed using LSB
|
||||
coefs_used = 0
|
||||
for bit_idx, pos_idx in enumerate(order):
|
||||
if bit_idx >= len(bits):
|
||||
break
|
||||
|
||||
row, col = all_positions[pos_idx]
|
||||
coef = coef_array[row, col]
|
||||
|
||||
# Embed bit in LSB
|
||||
if (coef & 1) != bits[bit_idx]:
|
||||
if coef > 0:
|
||||
coef_array[row, col] = coef - 1 if (coef & 1) else coef + 1
|
||||
else:
|
||||
coef_array[row, col] = coef + 1 if (coef & 1) else coef - 1
|
||||
|
||||
coefs_used += 1
|
||||
|
||||
# Write modified JPEG
|
||||
jio.write(jpeg, output_path)
|
||||
|
||||
# Read back as bytes
|
||||
with open(output_path, 'rb') as f:
|
||||
stego_bytes = f.read()
|
||||
|
||||
stats = DCTEmbedStats(
|
||||
blocks_used=coefs_used // 63, # Approximate blocks
|
||||
blocks_available=len(all_positions) // 63,
|
||||
bits_embedded=len(bits),
|
||||
capacity_bits=len(all_positions),
|
||||
usage_percent=(len(bits) / len(all_positions)) * 100 if all_positions else 0,
|
||||
image_width=width,
|
||||
image_height=height,
|
||||
output_format=OUTPUT_FORMAT_JPEG,
|
||||
jpeg_native=True,
|
||||
color_mode=color_mode, # JPEG naturally preserves color
|
||||
)
|
||||
|
||||
return stego_bytes, stats
|
||||
|
||||
finally:
|
||||
for path in [input_path, output_path]:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def extract_from_dct(
|
||||
stego_image: bytes,
|
||||
seed: bytes,
|
||||
) -> bytes:
|
||||
"""
|
||||
Extract data from DCT stego image.
|
||||
|
||||
Automatically detects whether image uses scipy DCT or jpegio embedding,
|
||||
and handles both grayscale and color modes.
|
||||
|
||||
Args:
|
||||
stego_image: Stego image bytes
|
||||
seed: Same seed used for embedding
|
||||
|
||||
Returns:
|
||||
Extracted data bytes
|
||||
"""
|
||||
# Check image format
|
||||
img = Image.open(io.BytesIO(stego_image))
|
||||
|
||||
if img.format == 'JPEG' and HAS_JPEGIO:
|
||||
# Try jpegio extraction first
|
||||
try:
|
||||
return _extract_jpegio(stego_image, seed)
|
||||
except ValueError:
|
||||
# If jpegio magic not found, fall back to scipy method
|
||||
pass
|
||||
|
||||
# PNG or fallback: use scipy DCT method
|
||||
_check_scipy()
|
||||
return _extract_scipy_dct(stego_image, seed)
|
||||
|
||||
|
||||
def _extract_scipy_dct(stego_image: bytes, seed: bytes) -> bytes:
|
||||
"""
|
||||
Extract using scipy DCT (for PNG images).
|
||||
|
||||
v3.2.0: Now properly handles both grayscale and color modes by
|
||||
first trying to detect the mode from header flags, then extracting
|
||||
from the appropriate channel.
|
||||
"""
|
||||
# First, try extracting from grayscale to get header and detect mode
|
||||
# This works because even color-mode images can be converted to grayscale
|
||||
# and the Y channel ≈ grayscale for extraction purposes
|
||||
|
||||
# Try Y channel extraction first (works for both color and grayscale)
|
||||
img = Image.open(io.BytesIO(stego_image))
|
||||
|
||||
if img.mode in ('RGB', 'RGBA'):
|
||||
# Extract from Y channel (more accurate for color-mode images)
|
||||
channel = _extract_y_channel(stego_image)
|
||||
else:
|
||||
# Grayscale image
|
||||
channel = _to_grayscale(stego_image)
|
||||
|
||||
padded, original_size = _pad_to_blocks(channel)
|
||||
|
||||
h, w = padded.shape
|
||||
blocks_x = w // BLOCK_SIZE
|
||||
blocks_y = h // BLOCK_SIZE
|
||||
num_blocks = blocks_x * blocks_y
|
||||
|
||||
block_order = _generate_block_order(num_blocks, seed)
|
||||
|
||||
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)
|
||||
|
||||
if len(all_bits) >= HEADER_SIZE * 8:
|
||||
try:
|
||||
_, flags, 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:
|
||||
pass
|
||||
|
||||
version, flags, data_length = _parse_header(all_bits)
|
||||
|
||||
# Check if color mode flag is set (for informational purposes)
|
||||
is_color_mode = bool(flags & FLAG_COLOR_MODE)
|
||||
|
||||
data_bits = all_bits[HEADER_SIZE * 8:(HEADER_SIZE + data_length) * 8]
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
||||
"""Extract using jpegio for JPEG images."""
|
||||
import os
|
||||
|
||||
temp_path = _jpegio_bytes_to_file(stego_image, suffix='.jpg')
|
||||
|
||||
try:
|
||||
jpeg = jio.read(temp_path)
|
||||
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
|
||||
|
||||
all_positions = _jpegio_get_usable_positions(coef_array)
|
||||
order = _jpegio_generate_order(len(all_positions), seed)
|
||||
|
||||
# Extract header bits
|
||||
header_bits = []
|
||||
for pos_idx in order[:HEADER_SIZE * 8]:
|
||||
row, col = all_positions[pos_idx]
|
||||
coef = coef_array[row, col]
|
||||
header_bits.append(coef & 1)
|
||||
|
||||
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)
|
||||
])
|
||||
|
||||
version, flags, data_length = _jpegio_parse_header(header_bytes)
|
||||
|
||||
# Extract all needed bits
|
||||
total_bits_needed = (HEADER_SIZE + data_length) * 8
|
||||
|
||||
all_bits = []
|
||||
for bit_idx, pos_idx in enumerate(order):
|
||||
if bit_idx >= total_bits_needed:
|
||||
break
|
||||
row, col = all_positions[pos_idx]
|
||||
coef = coef_array[row, col]
|
||||
all_bits.append(coef & 1)
|
||||
|
||||
# Extract data
|
||||
data_bits = all_bits[HEADER_SIZE * 8:]
|
||||
|
||||
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
|
||||
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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'
|
||||
@@ -234,15 +234,16 @@ def calculate_capacity(image_data: bytes, bits_per_channel: int = 1) -> int:
|
||||
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
|
||||
try:
|
||||
num_pixels = img_file.size[0] * img_file.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
|
||||
finally:
|
||||
img_file.close()
|
||||
|
||||
|
||||
def calculate_capacity_by_mode(
|
||||
@@ -279,7 +280,10 @@ def calculate_capacity_by_mode(
|
||||
else:
|
||||
capacity = calculate_capacity(image_data, bits_per_channel)
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
width, height = img.size
|
||||
try:
|
||||
width, height = img.size
|
||||
finally:
|
||||
img.close()
|
||||
|
||||
return {
|
||||
'mode': EMBED_MODE_LSB,
|
||||
@@ -378,7 +382,10 @@ def compare_modes(image_data: bytes) -> dict:
|
||||
Dict with comparison of LSB vs DCT modes
|
||||
"""
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
width, height = img.size
|
||||
try:
|
||||
width, height = img.size
|
||||
finally:
|
||||
img.close()
|
||||
|
||||
lsb_bytes = calculate_capacity(image_data, 1)
|
||||
|
||||
@@ -590,6 +597,10 @@ def _embed_lsb(
|
||||
debug.validate(len(pixel_key) == 32,
|
||||
f"Pixel key must be 32 bytes, got {len(pixel_key)}")
|
||||
|
||||
img_file = None
|
||||
img = None
|
||||
stego_img = None
|
||||
|
||||
try:
|
||||
img_file = Image.open(io.BytesIO(image_data))
|
||||
input_format = img_file.format
|
||||
@@ -690,6 +701,14 @@ def _embed_lsb(
|
||||
except Exception as e:
|
||||
debug.exception(e, "embed_lsb")
|
||||
raise EmbeddingError(f"Failed to embed data: {e}") from e
|
||||
finally:
|
||||
# Properly close all PIL Images to prevent memory leaks
|
||||
if stego_img is not None:
|
||||
stego_img.close()
|
||||
if img is not None and img is not img_file:
|
||||
img.close()
|
||||
if img_file is not None:
|
||||
img_file.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -768,6 +787,9 @@ def _extract_lsb(
|
||||
debug.validate(bits_per_channel in (1, 2),
|
||||
f"bits_per_channel must be 1 or 2, got {bits_per_channel}")
|
||||
|
||||
img_file = None
|
||||
img = None
|
||||
|
||||
try:
|
||||
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}")
|
||||
@@ -843,6 +865,12 @@ def _extract_lsb(
|
||||
except Exception as e:
|
||||
debug.exception(e, "extract_lsb")
|
||||
return None
|
||||
finally:
|
||||
# Properly close all PIL Images to prevent memory leaks
|
||||
if img is not None and img is not img_file:
|
||||
img.close()
|
||||
if img_file is not None:
|
||||
img_file.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -853,18 +881,24 @@ def get_image_dimensions(image_data: bytes) -> Tuple[int, int]:
|
||||
"""Get image dimensions without loading full image."""
|
||||
debug.validate(len(image_data) > 0, "Image data cannot be empty")
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
dimensions = img.size
|
||||
debug.print(f"Image dimensions: {dimensions[0]}x{dimensions[1]}")
|
||||
return dimensions
|
||||
try:
|
||||
dimensions = img.size
|
||||
debug.print(f"Image dimensions: {dimensions[0]}x{dimensions[1]}")
|
||||
return dimensions
|
||||
finally:
|
||||
img.close()
|
||||
|
||||
|
||||
def get_image_format(image_data: bytes) -> Optional[str]:
|
||||
"""Get image format (PIL format string like 'PNG', 'JPEG')."""
|
||||
try:
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
format_str = img.format
|
||||
debug.print(f"Image format: {format_str}")
|
||||
return format_str
|
||||
try:
|
||||
format_str = img.format
|
||||
debug.print(f"Image format: {format_str}")
|
||||
return format_str
|
||||
finally:
|
||||
img.close()
|
||||
except Exception as e:
|
||||
debug.print(f"Failed to get image format: {e}")
|
||||
return None
|
||||
|
||||
878
src/stegasoo/steganography.py_old
Normal file
878
src/stegasoo/steganography.py_old
Normal file
@@ -0,0 +1,878 @@
|
||||
"""
|
||||
Stegasoo Steganography Functions (v3.2.0)
|
||||
|
||||
LSB and DCT embedding modes with pseudo-random pixel/coefficient selection.
|
||||
|
||||
Changes in v3.0:
|
||||
- DCT domain embedding mode (requires scipy)
|
||||
- embed_mode parameter for encode/decode
|
||||
- Auto-detection of embedding mode
|
||||
- Comparison utilities
|
||||
|
||||
Changes in v3.0.1:
|
||||
- dct_output_format parameter for DCT mode ('png' or 'jpeg')
|
||||
- dct_color_mode parameter for DCT mode ('grayscale' or 'color')
|
||||
|
||||
Changes in v3.2.0:
|
||||
- Fixed HEADER_OVERHEAD constant (65 bytes, not 104 - date field removed)
|
||||
- Updated ENCRYPTION_OVERHEAD calculation
|
||||
"""
|
||||
|
||||
import io
|
||||
import struct
|
||||
from typing import Optional, Tuple, List, Union
|
||||
|
||||
from PIL import Image
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
from .models import EmbedStats, FilePayload
|
||||
from .exceptions import CapacityError, ExtractionError, EmbeddingError
|
||||
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 = {'PNG', 'BMP', 'TIFF'}
|
||||
|
||||
# Format to extension mapping
|
||||
FORMAT_TO_EXT = {
|
||||
'PNG': 'png',
|
||||
'BMP': 'bmp',
|
||||
'TIFF': 'tiff',
|
||||
}
|
||||
|
||||
# Extension to PIL format mapping
|
||||
EXT_TO_FORMAT = {
|
||||
'png': 'PNG',
|
||||
'bmp': 'BMP',
|
||||
'tiff': 'TIFF',
|
||||
'tif': 'TIFF',
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# OVERHEAD CONSTANTS (v3.2.0 - Updated for date-independent format)
|
||||
# =============================================================================
|
||||
# v3.2.0 Header format (no date field):
|
||||
# Magic: 4 bytes (\x89ST3)
|
||||
# Version: 1 byte (4 for v3.2.0)
|
||||
# Salt: 32 bytes
|
||||
# IV: 12 bytes
|
||||
# Tag: 16 bytes
|
||||
# -----------------
|
||||
# Total: 65 bytes
|
||||
#
|
||||
# Previous v3.1.0 had date field (10 bytes + 1 byte length) = 76 bytes header
|
||||
# The old value of 104 was incorrect even for v3.1.0
|
||||
|
||||
HEADER_OVERHEAD = 65 # v3.2.0: Magic + version + salt + iv + tag
|
||||
LENGTH_PREFIX = 4 # 4 bytes for payload length in LSB embedding
|
||||
ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX # 69 bytes total
|
||||
|
||||
# DCT output format options (v3.0.1)
|
||||
DCT_OUTPUT_PNG = 'png'
|
||||
DCT_OUTPUT_JPEG = 'jpeg'
|
||||
|
||||
# DCT color mode options (v3.0.1)
|
||||
DCT_COLOR_GRAYSCALE = 'grayscale'
|
||||
DCT_COLOR_COLOR = 'color'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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]:
|
||||
"""
|
||||
Determine the output format based on input format.
|
||||
|
||||
Args:
|
||||
input_format: PIL format string of input image (e.g., 'JPEG', 'PNG')
|
||||
|
||||
Returns:
|
||||
Tuple of (PIL format string, file extension) for output
|
||||
Falls back to PNG for lossy or unknown formats.
|
||||
"""
|
||||
debug.validate(input_format is None or isinstance(input_format, str),
|
||||
"Input format must be string or None")
|
||||
|
||||
if input_format and input_format.upper() in LOSSLESS_FORMATS:
|
||||
fmt = input_format.upper()
|
||||
ext = FORMAT_TO_EXT.get(fmt, 'png')
|
||||
debug.print(f"Using lossless format: {fmt} -> .{ext}")
|
||||
return fmt, ext
|
||||
|
||||
debug.print(f"Input format {input_format} is lossy or unknown, defaulting to PNG")
|
||||
return 'PNG', 'png'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CAPACITY FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
def will_fit(
|
||||
payload: Union[str, bytes, FilePayload, int],
|
||||
carrier_image: bytes,
|
||||
bits_per_channel: int = 1,
|
||||
include_compression_estimate: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Check if a payload will fit in a carrier image (LSB mode).
|
||||
|
||||
Args:
|
||||
payload: Message string, raw bytes, FilePayload, or size in bytes
|
||||
carrier_image: Carrier image bytes
|
||||
bits_per_channel: Bits to use per color channel (1-2)
|
||||
include_compression_estimate: Estimate compressed size
|
||||
|
||||
Returns:
|
||||
Dict with fits, capacity, usage info
|
||||
"""
|
||||
# Determine payload size
|
||||
if isinstance(payload, int):
|
||||
payload_size = payload
|
||||
payload_data = None
|
||||
elif isinstance(payload, str):
|
||||
payload_data = payload.encode('utf-8')
|
||||
payload_size = len(payload_data)
|
||||
elif isinstance(payload, FilePayload):
|
||||
payload_data = payload.data
|
||||
filename_overhead = len(payload.filename.encode('utf-8')) if payload.filename else 0
|
||||
mime_overhead = len(payload.mime_type.encode('utf-8')) if payload.mime_type else 0
|
||||
payload_size = len(payload.data) + filename_overhead + mime_overhead + 5
|
||||
else:
|
||||
payload_data = payload
|
||||
payload_size = len(payload)
|
||||
|
||||
capacity = calculate_capacity(carrier_image, bits_per_channel)
|
||||
|
||||
# Estimate encrypted size with padding
|
||||
# Padding adds 64-319 bytes, rounded up to 256-byte boundary
|
||||
# Average case: ~190 bytes padding
|
||||
estimated_padding = 190
|
||||
estimated_encrypted_size = payload_size + estimated_padding + ENCRYPTION_OVERHEAD
|
||||
|
||||
compressed_estimate = None
|
||||
if include_compression_estimate and payload_data is not None and len(payload_data) >= 64:
|
||||
try:
|
||||
import zlib
|
||||
compressed = zlib.compress(payload_data, level=6)
|
||||
compressed_size = len(compressed) + 9 # Compression header
|
||||
if compressed_size < payload_size:
|
||||
compressed_estimate = compressed_size
|
||||
estimated_encrypted_size = compressed_size + estimated_padding + ENCRYPTION_OVERHEAD
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headroom = capacity - estimated_encrypted_size
|
||||
fits = headroom >= 0
|
||||
usage_percent = (estimated_encrypted_size / capacity * 100) if capacity > 0 else 100.0
|
||||
|
||||
return {
|
||||
'fits': fits,
|
||||
'payload_size': payload_size,
|
||||
'estimated_encrypted_size': estimated_encrypted_size,
|
||||
'capacity': capacity,
|
||||
'usage_percent': min(usage_percent, 100.0),
|
||||
'headroom': headroom,
|
||||
'compressed_estimate': compressed_estimate,
|
||||
'mode': EMBED_MODE_LSB,
|
||||
}
|
||||
|
||||
|
||||
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 # padding estimate
|
||||
|
||||
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
|
||||
def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> List[int]:
|
||||
"""
|
||||
Generate pseudo-random pixel indices for embedding.
|
||||
|
||||
Uses ChaCha20 as a CSPRNG seeded by the key to deterministically
|
||||
select which pixels will hold hidden data.
|
||||
"""
|
||||
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_needed > 0, f"Number needed must be positive, got {num_needed}")
|
||||
debug.validate(num_needed <= num_pixels,
|
||||
f"Cannot select {num_needed} pixels from {num_pixels} available")
|
||||
|
||||
debug.print(f"Generating {num_needed} pixel indices from {num_pixels} total pixels")
|
||||
|
||||
if num_needed >= num_pixels // 2:
|
||||
debug.print(f"Using full shuffle (needed {num_needed}/{num_pixels} pixels)")
|
||||
nonce = b'\x00' * 16
|
||||
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
|
||||
encryptor = cipher.encryptor()
|
||||
|
||||
indices = list(range(num_pixels))
|
||||
random_bytes = encryptor.update(b'\x00' * (num_pixels * 4))
|
||||
|
||||
for i in range(num_pixels - 1, 0, -1):
|
||||
j_bytes = random_bytes[(num_pixels - 1 - i) * 4:(num_pixels - i) * 4]
|
||||
j = int.from_bytes(j_bytes, 'big') % (i + 1)
|
||||
indices[i], indices[j] = indices[j], indices[i]
|
||||
|
||||
selected = indices[:num_needed]
|
||||
debug.print(f"Generated {len(selected)} indices via shuffle")
|
||||
return selected
|
||||
|
||||
debug.print(f"Using optimized selection (needed {num_needed}/{num_pixels} pixels)")
|
||||
selected = []
|
||||
used = set()
|
||||
|
||||
nonce = b'\x00' * 16
|
||||
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
|
||||
encryptor = cipher.encryptor()
|
||||
|
||||
bytes_needed = (num_needed * 2) * 4
|
||||
random_bytes = encryptor.update(b'\x00' * bytes_needed)
|
||||
|
||||
byte_offset = 0
|
||||
collisions = 0
|
||||
while len(selected) < num_needed and byte_offset < len(random_bytes) - 4:
|
||||
idx = int.from_bytes(random_bytes[byte_offset:byte_offset + 4], 'big') % num_pixels
|
||||
byte_offset += 4
|
||||
|
||||
if idx not in used:
|
||||
used.add(idx)
|
||||
selected.append(idx)
|
||||
else:
|
||||
collisions += 1
|
||||
|
||||
if len(selected) < num_needed:
|
||||
debug.print(f"Need {num_needed - len(selected)} more indices, generating...")
|
||||
extra_needed = num_needed - len(selected)
|
||||
for _ in range(extra_needed * 2):
|
||||
extra_bytes = encryptor.update(b'\x00' * 4)
|
||||
idx = int.from_bytes(extra_bytes, 'big') % num_pixels
|
||||
if idx not in used:
|
||||
used.add(idx)
|
||||
selected.append(idx)
|
||||
if len(selected) == num_needed:
|
||||
break
|
||||
|
||||
debug.print(f"Generated {len(selected)} indices with {collisions} collisions")
|
||||
debug.validate(len(selected) == num_needed,
|
||||
f"Failed to generate enough indices: {len(selected)}/{num_needed}")
|
||||
return selected
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EMBEDDING FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
@debug.time
|
||||
def embed_in_image(
|
||||
data: bytes,
|
||||
image_data: bytes,
|
||||
pixel_key: bytes,
|
||||
bits_per_channel: int = 1,
|
||||
output_format: Optional[str] = None,
|
||||
embed_mode: str = EMBED_MODE_LSB,
|
||||
dct_output_format: str = DCT_OUTPUT_PNG,
|
||||
dct_color_mode: str = 'grayscale',
|
||||
) -> Tuple[bytes, Union[EmbedStats, 'DCTEmbedStats'], str]:
|
||||
"""
|
||||
Embed data into an image using specified mode.
|
||||
|
||||
Args:
|
||||
data: Data to embed (encrypted payload)
|
||||
image_data: Carrier image bytes
|
||||
pixel_key: Key for pixel/coefficient selection
|
||||
bits_per_channel: Bits per channel (LSB mode only)
|
||||
output_format: Force output format (LSB mode only)
|
||||
embed_mode: 'lsb' (default) or 'dct'
|
||||
dct_output_format: For DCT mode - 'png' (lossless) or 'jpeg' (smaller)
|
||||
dct_color_mode: For DCT mode - 'grayscale' (default) or 'color' (preserves colors)
|
||||
|
||||
Returns:
|
||||
Tuple of (stego image bytes, stats, file extension)
|
||||
|
||||
Raises:
|
||||
CapacityError: If data won't fit
|
||||
EmbeddingError: If embedding fails
|
||||
ImportError: If DCT mode requested but scipy unavailable
|
||||
"""
|
||||
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
|
||||
|
||||
# Validate DCT color mode (v3.0.1)
|
||||
if dct_color_mode not in ('grayscale', 'color'):
|
||||
debug.print(f"Invalid dct_color_mode '{dct_color_mode}', defaulting to grayscale")
|
||||
dct_color_mode = 'grayscale'
|
||||
|
||||
dct_mod = _get_dct_module()
|
||||
|
||||
# Pass output_format and color_mode 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,
|
||||
color_mode=dct_color_mode,
|
||||
)
|
||||
|
||||
# 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, "
|
||||
f"color_mode={dct_color_mode}, 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.validate(bits_per_channel in (1, 2),
|
||||
f"bits_per_channel must be 1 or 2, got {bits_per_channel}")
|
||||
debug.validate(len(pixel_key) == 32,
|
||||
f"Pixel key must be 32 bytes, got {len(pixel_key)}")
|
||||
|
||||
try:
|
||||
img_file = Image.open(io.BytesIO(image_data))
|
||||
input_format = img_file.format
|
||||
|
||||
debug.print(f"Carrier image: {img_file.size[0]}x{img_file.size[1]}, format: {input_format}")
|
||||
|
||||
img = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file.copy()
|
||||
if img_file.mode != 'RGB':
|
||||
debug.print(f"Converting image from {img_file.mode} to RGB")
|
||||
|
||||
pixels = list(img.getdata())
|
||||
num_pixels = len(pixels)
|
||||
|
||||
bits_per_pixel = 3 * bits_per_channel
|
||||
max_bytes = (num_pixels * bits_per_pixel) // 8
|
||||
|
||||
debug.print(f"Image capacity: {max_bytes} bytes at {bits_per_channel} bit(s)/channel")
|
||||
|
||||
data_with_len = struct.pack('>I', len(data)) + data
|
||||
|
||||
if len(data_with_len) > max_bytes:
|
||||
debug.print(f"Capacity error: need {len(data_with_len)}, have {max_bytes}")
|
||||
raise CapacityError(len(data_with_len), max_bytes)
|
||||
|
||||
debug.print(f"Total data to embed: {len(data_with_len)} bytes "
|
||||
f"({len(data_with_len)/max_bytes*100:.1f}% of capacity)")
|
||||
|
||||
binary_data = ''.join(format(b, '08b') for b in data_with_len)
|
||||
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")
|
||||
|
||||
selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed)
|
||||
|
||||
new_pixels = list(pixels)
|
||||
clear_mask = 0xFF ^ ((1 << bits_per_channel) - 1)
|
||||
|
||||
bit_idx = 0
|
||||
modified_pixels = 0
|
||||
|
||||
for pixel_idx in selected_indices:
|
||||
if bit_idx >= len(binary_data):
|
||||
break
|
||||
|
||||
r, g, b = new_pixels[pixel_idx]
|
||||
modified = False
|
||||
|
||||
for channel_idx, channel_val in enumerate([r, g, b]):
|
||||
if bit_idx >= len(binary_data):
|
||||
break
|
||||
bits = binary_data[bit_idx:bit_idx + bits_per_channel].ljust(bits_per_channel, '0')
|
||||
new_val = (channel_val & clear_mask) | int(bits, 2)
|
||||
|
||||
if channel_val != new_val:
|
||||
modified = True
|
||||
if channel_idx == 0:
|
||||
r = new_val
|
||||
elif channel_idx == 1:
|
||||
g = new_val
|
||||
else:
|
||||
b = new_val
|
||||
|
||||
bit_idx += bits_per_channel
|
||||
|
||||
if modified:
|
||||
new_pixels[pixel_idx] = (r, g, b)
|
||||
modified_pixels += 1
|
||||
|
||||
debug.print(f"Modified {modified_pixels} pixels (out of {len(selected_indices)} selected)")
|
||||
|
||||
stego_img = Image.new('RGB', img.size)
|
||||
stego_img.putdata(new_pixels)
|
||||
|
||||
if output_format:
|
||||
out_fmt = output_format.upper()
|
||||
out_ext = FORMAT_TO_EXT.get(out_fmt, 'png')
|
||||
debug.print(f"Using forced output format: {out_fmt}")
|
||||
else:
|
||||
out_fmt, out_ext = get_output_format(input_format)
|
||||
debug.print(f"Auto-selected output format: {out_fmt}")
|
||||
|
||||
output = io.BytesIO()
|
||||
stego_img.save(output, out_fmt)
|
||||
output.seek(0)
|
||||
|
||||
stats = EmbedStats(
|
||||
pixels_modified=modified_pixels,
|
||||
total_pixels=num_pixels,
|
||||
capacity_used=len(data_with_len) / max_bytes,
|
||||
bytes_embedded=len(data_with_len)
|
||||
)
|
||||
|
||||
debug.print(f"LSB embedding complete: {out_fmt} image, {len(output.getvalue())} bytes")
|
||||
return output.getvalue(), stats, out_ext
|
||||
|
||||
except CapacityError:
|
||||
raise
|
||||
except Exception as e:
|
||||
debug.exception(e, "embed_lsb")
|
||||
raise EmbeddingError(f"Failed to embed data: {e}") from e
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EXTRACTION FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
@debug.time
|
||||
def extract_from_image(
|
||||
image_data: bytes,
|
||||
pixel_key: bytes,
|
||||
bits_per_channel: int = 1,
|
||||
embed_mode: str = EMBED_MODE_AUTO,
|
||||
) -> Optional[bytes]:
|
||||
"""
|
||||
Extract hidden data from a stego image.
|
||||
|
||||
Args:
|
||||
image_data: Stego image bytes
|
||||
pixel_key: Key for pixel/coefficient selection (must match encoding)
|
||||
bits_per_channel: Bits per channel (LSB mode only)
|
||||
embed_mode: 'auto' (try both), 'lsb', or 'dct'
|
||||
|
||||
Returns:
|
||||
Extracted data bytes, or None if extraction fails
|
||||
"""
|
||||
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.validate(bits_per_channel in (1, 2),
|
||||
f"bits_per_channel must be 1 or 2, got {bits_per_channel}")
|
||||
|
||||
try:
|
||||
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}")
|
||||
|
||||
img = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file.copy()
|
||||
if img_file.mode != 'RGB':
|
||||
debug.print(f"Converting image from {img_file.mode} to RGB")
|
||||
|
||||
pixels = list(img.getdata())
|
||||
num_pixels = len(pixels)
|
||||
bits_per_pixel = 3 * bits_per_channel
|
||||
|
||||
debug.print(f"Image has {num_pixels} pixels, {bits_per_pixel} bits/pixel")
|
||||
|
||||
initial_pixels = (32 + bits_per_pixel - 1) // bits_per_pixel + 10
|
||||
debug.print(f"Extracting initial {initial_pixels} pixels to find length")
|
||||
|
||||
initial_indices = generate_pixel_indices(pixel_key, num_pixels, initial_pixels)
|
||||
|
||||
binary_data = ''
|
||||
for pixel_idx in initial_indices:
|
||||
r, g, b = pixels[pixel_idx]
|
||||
for channel in [r, g, b]:
|
||||
for bit_pos in range(bits_per_channel - 1, -1, -1):
|
||||
binary_data += str((channel >> bit_pos) & 1)
|
||||
|
||||
try:
|
||||
length_bits = binary_data[:32]
|
||||
if len(length_bits) < 32:
|
||||
debug.print(f"Not enough bits for length: {len(length_bits)}/32")
|
||||
return None
|
||||
|
||||
data_length = struct.unpack('>I', int(length_bits, 2).to_bytes(4, 'big'))[0]
|
||||
debug.print(f"Extracted length: {data_length} bytes")
|
||||
except Exception as e:
|
||||
debug.print(f"Failed to parse length: {e}")
|
||||
return None
|
||||
|
||||
max_possible = (num_pixels * bits_per_pixel) // 8 - 4
|
||||
if data_length > max_possible or data_length < 10:
|
||||
debug.print(f"Invalid data length: {data_length} (max possible: {max_possible})")
|
||||
return None
|
||||
|
||||
total_bits = (4 + data_length) * 8
|
||||
pixels_needed = (total_bits + bits_per_pixel - 1) // bits_per_pixel
|
||||
|
||||
debug.print(f"Need {pixels_needed} pixels to extract {data_length} bytes")
|
||||
|
||||
selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed)
|
||||
|
||||
binary_data = ''
|
||||
for pixel_idx in selected_indices:
|
||||
r, g, b = pixels[pixel_idx]
|
||||
for channel in [r, g, b]:
|
||||
for bit_pos in range(bits_per_channel - 1, -1, -1):
|
||||
binary_data += str((channel >> bit_pos) & 1)
|
||||
|
||||
data_bits = binary_data[32:32 + (data_length * 8)]
|
||||
|
||||
if len(data_bits) < data_length * 8:
|
||||
debug.print(f"Insufficient bits: {len(data_bits)} < {data_length * 8}")
|
||||
return None
|
||||
|
||||
data_bytes = bytearray()
|
||||
for i in range(0, len(data_bits), 8):
|
||||
byte_bits = data_bits[i:i + 8]
|
||||
if len(byte_bits) == 8:
|
||||
data_bytes.append(int(byte_bits, 2))
|
||||
|
||||
debug.print(f"LSB successfully extracted {len(data_bytes)} bytes")
|
||||
return bytes(data_bytes)
|
||||
|
||||
except Exception as e:
|
||||
debug.exception(e, "extract_lsb")
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# UTILITY FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
def get_image_dimensions(image_data: bytes) -> Tuple[int, int]:
|
||||
"""Get image dimensions without loading full image."""
|
||||
debug.validate(len(image_data) > 0, "Image data cannot be empty")
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
dimensions = img.size
|
||||
debug.print(f"Image dimensions: {dimensions[0]}x{dimensions[1]}")
|
||||
return dimensions
|
||||
|
||||
|
||||
def get_image_format(image_data: bytes) -> Optional[str]:
|
||||
"""Get image format (PIL format string like 'PNG', 'JPEG')."""
|
||||
try:
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
format_str = img.format
|
||||
debug.print(f"Image format: {format_str}")
|
||||
return format_str
|
||||
except Exception as e:
|
||||
debug.print(f"Failed to get image format: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def is_lossless_format(image_data: bytes) -> bool:
|
||||
"""Check if image is in a lossless format suitable for steganography."""
|
||||
fmt = get_image_format(image_data)
|
||||
is_lossless = fmt is not None and fmt.upper() in LOSSLESS_FORMATS
|
||||
debug.print(f"Image is lossless: {is_lossless} (format: {fmt})")
|
||||
return is_lossless
|
||||
205
test_compare_capacity_flow.py
Normal file
205
test_compare_capacity_flow.py
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test that mimics the exact /api/compare-capacity flow.
|
||||
Run with: python test_compare_capacity_flow.py ./xx_2.jpg
|
||||
"""
|
||||
|
||||
import sys
|
||||
import io
|
||||
import gc
|
||||
import json
|
||||
import time
|
||||
|
||||
print("=" * 60)
|
||||
print("COMPARE-CAPACITY FLOW TEST")
|
||||
print("=" * 60)
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python test_compare_capacity_flow.py <image_path>")
|
||||
sys.exit(1)
|
||||
|
||||
image_path = sys.argv[1]
|
||||
|
||||
# Read the file
|
||||
with open(image_path, 'rb') as f:
|
||||
carrier_data = f.read()
|
||||
print(f"Loaded {len(carrier_data)} bytes from {image_path}")
|
||||
|
||||
# Import everything like Flask does
|
||||
print("\n[1] Importing modules...")
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import jpegio as jio
|
||||
HAS_JPEGIO = True
|
||||
print(f" jpegio: available")
|
||||
except ImportError:
|
||||
HAS_JPEGIO = False
|
||||
print(f" jpegio: NOT available")
|
||||
|
||||
try:
|
||||
from scipy.fft import dct, idct
|
||||
print(f" scipy.fft: available")
|
||||
except ImportError:
|
||||
from scipy.fftpack import dct, idct
|
||||
print(f" scipy.fftpack: available (fallback)")
|
||||
|
||||
print(" Imports complete")
|
||||
|
||||
# Simulate the compare_modes function
|
||||
print("\n[2] Opening image (1st time - for dimensions)...")
|
||||
img1 = Image.open(io.BytesIO(carrier_data))
|
||||
width, height = img1.size
|
||||
print(f" Size: {width}x{height}")
|
||||
img1.close()
|
||||
print(" Closed img1")
|
||||
gc.collect()
|
||||
|
||||
print("\n[3] Opening image (2nd time - for LSB capacity)...")
|
||||
img2 = Image.open(io.BytesIO(carrier_data))
|
||||
num_pixels = img2.size[0] * img2.size[1]
|
||||
lsb_bytes = (num_pixels * 3) // 8 - 69
|
||||
print(f" LSB capacity: {lsb_bytes} bytes")
|
||||
img2.close()
|
||||
print(" Closed img2")
|
||||
gc.collect()
|
||||
|
||||
print("\n[4] Opening image (3rd time - for DCT capacity)...")
|
||||
img3 = Image.open(io.BytesIO(carrier_data))
|
||||
w, h = img3.size
|
||||
blocks_x = w // 8
|
||||
blocks_y = h // 8
|
||||
total_blocks = blocks_x * blocks_y
|
||||
dct_bits = total_blocks * 16
|
||||
dct_bytes = dct_bits // 8 - 10
|
||||
print(f" DCT capacity: {dct_bytes} bytes ({total_blocks} blocks)")
|
||||
img3.close()
|
||||
print(" Closed img3")
|
||||
gc.collect()
|
||||
|
||||
print("\n[5] Building response dict...")
|
||||
response = {
|
||||
'success': True,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'lsb': {
|
||||
'capacity_bytes': lsb_bytes,
|
||||
'capacity_kb': round(lsb_bytes / 1024, 1),
|
||||
'output': 'PNG',
|
||||
},
|
||||
'dct': {
|
||||
'capacity_bytes': dct_bytes,
|
||||
'capacity_kb': round(dct_bytes / 1024, 1),
|
||||
'output': 'JPEG',
|
||||
'available': True,
|
||||
'ratio': round(dct_bytes / lsb_bytes * 100, 1),
|
||||
}
|
||||
}
|
||||
print(f" Response built")
|
||||
|
||||
print("\n[6] Serializing to JSON...")
|
||||
json_str = json.dumps(response)
|
||||
print(f" JSON length: {len(json_str)} bytes")
|
||||
print(f" Content: {json_str[:200]}...")
|
||||
|
||||
print("\n[7] Simulating Flask response completion...")
|
||||
# In Flask, after the response is sent, Python may garbage collect
|
||||
del carrier_data
|
||||
del response
|
||||
del json_str
|
||||
gc.collect()
|
||||
print(" GC after response simulation")
|
||||
|
||||
print("\n[8] Additional cleanup (simulating request end)...")
|
||||
gc.collect()
|
||||
gc.collect()
|
||||
print(" Multiple GC cycles complete")
|
||||
|
||||
print("\n[9] Waiting for delayed crash...")
|
||||
for i in range(3):
|
||||
time.sleep(1)
|
||||
print(f" {i+1}s...")
|
||||
gc.collect()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST PASSED - No crash detected")
|
||||
print("=" * 60)
|
||||
|
||||
# Now test with jpegio if available
|
||||
if HAS_JPEGIO:
|
||||
print("\n" + "=" * 60)
|
||||
print("JPEGIO SPECIFIC TEST")
|
||||
print("=" * 60)
|
||||
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Reload image data
|
||||
with open(image_path, 'rb') as f:
|
||||
carrier_data = f.read()
|
||||
|
||||
print("\n[J1] Checking if image is JPEG...")
|
||||
img = Image.open(io.BytesIO(carrier_data))
|
||||
is_jpeg = img.format == 'JPEG'
|
||||
img.close()
|
||||
print(f" Is JPEG: {is_jpeg}")
|
||||
|
||||
if is_jpeg:
|
||||
print("\n[J2] Writing to temp file...")
|
||||
fd, temp_path = tempfile.mkstemp(suffix='.jpg')
|
||||
os.write(fd, carrier_data)
|
||||
os.close(fd)
|
||||
print(f" Temp file: {temp_path}")
|
||||
|
||||
print("\n[J3] Reading with jpegio...")
|
||||
try:
|
||||
jpeg = jio.read(temp_path)
|
||||
print(f" jpegio.read() OK")
|
||||
|
||||
print("\n[J4] Accessing coefficient arrays...")
|
||||
coef = jpeg.coef_arrays[0]
|
||||
print(f" Coef shape: {coef.shape}, dtype: {coef.dtype}")
|
||||
|
||||
print("\n[J5] Counting usable positions...")
|
||||
positions = []
|
||||
h, w = coef.shape
|
||||
for row in range(h):
|
||||
for col in range(w):
|
||||
if (row % 8 == 0) and (col % 8 == 0):
|
||||
continue
|
||||
if abs(coef[row, col]) >= 2:
|
||||
positions.append((row, col))
|
||||
print(f" Usable positions: {len(positions)}")
|
||||
|
||||
print("\n[J6] Cleaning up jpegio object...")
|
||||
del coef
|
||||
del jpeg
|
||||
gc.collect()
|
||||
print(" Deleted jpeg object")
|
||||
|
||||
print("\n[J7] Removing temp file...")
|
||||
os.unlink(temp_path)
|
||||
print(" Temp file removed")
|
||||
|
||||
gc.collect()
|
||||
print("\n[J8] Final GC...")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("\n[J9] Waiting for delayed crash...")
|
||||
for i in range(3):
|
||||
time.sleep(1)
|
||||
print(f" {i+1}s...")
|
||||
gc.collect()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("JPEGIO TEST PASSED - No crash detected")
|
||||
print("=" * 60)
|
||||
else:
|
||||
print(" Skipping jpegio test (not a JPEG)")
|
||||
|
||||
print("\n\nAll tests completed successfully!")
|
||||
BIN
test_data/1mb-jpg-example-file.jpg
Normal file
BIN
test_data/1mb-jpg-example-file.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
test_data/2mb-jpg-example-file.jpg
Normal file
BIN
test_data/2mb-jpg-example-file.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
231
test_dct_crash.py
Normal file
231
test_dct_crash.py
Normal file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone DCT crash diagnostic script.
|
||||
Run this outside of Flask to isolate the issue.
|
||||
|
||||
Usage:
|
||||
python test_dct_crash.py /path/to/your/large_image.jpg
|
||||
"""
|
||||
|
||||
import sys
|
||||
import gc
|
||||
import traceback
|
||||
import io
|
||||
|
||||
print("=" * 60)
|
||||
print("DCT CRASH DIAGNOSTIC TOOL")
|
||||
print("=" * 60)
|
||||
|
||||
# Step 1: Check Python and library versions
|
||||
print("\n[1] ENVIRONMENT INFO")
|
||||
print(f"Python: {sys.version}")
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
print(f"NumPy: {np.__version__}")
|
||||
except ImportError as e:
|
||||
print(f"NumPy: NOT INSTALLED - {e}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import scipy
|
||||
print(f"SciPy: {scipy.__version__}")
|
||||
except ImportError as e:
|
||||
print(f"SciPy: NOT INSTALLED - {e}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
import PIL
|
||||
print(f"Pillow: {PIL.__version__}")
|
||||
except ImportError as e:
|
||||
print(f"Pillow: NOT INSTALLED - {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 2: Check which DCT module we're using
|
||||
print("\n[2] DCT MODULE CHECK")
|
||||
try:
|
||||
from scipy.fft import dct, idct
|
||||
print("Using: scipy.fft (preferred)")
|
||||
DCT_MODULE = "scipy.fft"
|
||||
except ImportError:
|
||||
try:
|
||||
from scipy.fftpack import dct, idct
|
||||
print("Using: scipy.fftpack (legacy)")
|
||||
DCT_MODULE = "scipy.fftpack"
|
||||
except ImportError:
|
||||
print("ERROR: No DCT module available!")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 3: Test basic DCT on small array
|
||||
print("\n[3] BASIC DCT TEST (8x8 block)")
|
||||
try:
|
||||
test_block = np.random.rand(8, 8).astype(np.float64)
|
||||
|
||||
# 1D DCT on rows
|
||||
result = dct(test_block[0, :], norm='ortho')
|
||||
print(f" 1D DCT: OK (output shape: {result.shape})")
|
||||
|
||||
# 1D IDCT
|
||||
recovered = idct(result, norm='ortho')
|
||||
error = np.max(np.abs(test_block[0, :] - recovered))
|
||||
print(f" 1D IDCT: OK (roundtrip error: {error:.2e})")
|
||||
|
||||
# 2D via separable
|
||||
temp = np.zeros_like(test_block)
|
||||
for i in range(8):
|
||||
temp[:, i] = dct(test_block[:, i], norm='ortho')
|
||||
result2d = np.zeros_like(temp)
|
||||
for i in range(8):
|
||||
result2d[i, :] = dct(temp[i, :], norm='ortho')
|
||||
print(f" 2D DCT: OK")
|
||||
|
||||
gc.collect()
|
||||
print(" GC after basic test: OK")
|
||||
|
||||
except Exception as e:
|
||||
print(f" FAILED: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
# Step 4: Test with larger arrays (stress test)
|
||||
print("\n[4] STRESS TEST (many 8x8 blocks)")
|
||||
try:
|
||||
NUM_BLOCKS = 10000
|
||||
print(f" Processing {NUM_BLOCKS} blocks...")
|
||||
|
||||
for i in range(NUM_BLOCKS):
|
||||
block = np.random.rand(8, 8).astype(np.float64)
|
||||
|
||||
# Forward DCT
|
||||
temp = np.zeros_like(block)
|
||||
for j in range(8):
|
||||
temp[:, j] = dct(block[:, j], norm='ortho')
|
||||
result = np.zeros_like(temp)
|
||||
for j in range(8):
|
||||
result[j, :] = dct(temp[j, :], norm='ortho')
|
||||
|
||||
# Inverse DCT
|
||||
temp2 = np.zeros_like(result)
|
||||
for j in range(8):
|
||||
temp2[j, :] = idct(result[j, :], norm='ortho')
|
||||
recovered = np.zeros_like(temp2)
|
||||
for j in range(8):
|
||||
recovered[:, j] = idct(temp2[:, j], norm='ortho')
|
||||
|
||||
if i % 1000 == 0:
|
||||
gc.collect()
|
||||
print(f" {i}/{NUM_BLOCKS} blocks processed...")
|
||||
|
||||
gc.collect()
|
||||
print(f" Stress test PASSED")
|
||||
|
||||
except Exception as e:
|
||||
print(f" FAILED at block {i}: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
# Step 5: Test with actual image if provided
|
||||
if len(sys.argv) > 1:
|
||||
image_path = sys.argv[1]
|
||||
print(f"\n[5] IMAGE TEST: {image_path}")
|
||||
|
||||
try:
|
||||
with open(image_path, 'rb') as f:
|
||||
image_data = f.read()
|
||||
print(f" File size: {len(image_data) / 1024 / 1024:.2f} MB")
|
||||
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
width, height = img.size
|
||||
print(f" Dimensions: {width}x{height}")
|
||||
print(f" Format: {img.format}")
|
||||
print(f" Mode: {img.mode}")
|
||||
|
||||
# Convert to grayscale float array
|
||||
gray = img.convert('L')
|
||||
arr = np.array(gray, dtype=np.float64)
|
||||
img.close()
|
||||
gray.close()
|
||||
print(f" Array shape: {arr.shape}")
|
||||
print(f" Array dtype: {arr.dtype}")
|
||||
|
||||
# Pad to block boundary
|
||||
BLOCK_SIZE = 8
|
||||
h, w = arr.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 or new_w != w:
|
||||
padded = np.zeros((new_h, new_w), dtype=np.float64)
|
||||
padded[:h, :w] = arr
|
||||
arr = padded
|
||||
print(f" Padded to: {arr.shape}")
|
||||
|
||||
blocks_y = arr.shape[0] // BLOCK_SIZE
|
||||
blocks_x = arr.shape[1] // BLOCK_SIZE
|
||||
total_blocks = blocks_y * blocks_x
|
||||
print(f" Total 8x8 blocks: {total_blocks}")
|
||||
|
||||
# Process ALL blocks
|
||||
print(f" Processing all blocks with DCT...")
|
||||
|
||||
processed = 0
|
||||
for by in range(blocks_y):
|
||||
for bx in range(blocks_x):
|
||||
y = by * BLOCK_SIZE
|
||||
x = bx * BLOCK_SIZE
|
||||
|
||||
block = arr[y:y+BLOCK_SIZE, x:x+BLOCK_SIZE].copy()
|
||||
|
||||
# Forward DCT
|
||||
temp = np.zeros((8, 8), dtype=np.float64)
|
||||
for i in range(8):
|
||||
temp[:, i] = dct(block[:, i], norm='ortho')
|
||||
dct_block = np.zeros((8, 8), dtype=np.float64)
|
||||
for i in range(8):
|
||||
dct_block[i, :] = dct(temp[i, :], norm='ortho')
|
||||
|
||||
# Inverse DCT
|
||||
temp2 = np.zeros((8, 8), dtype=np.float64)
|
||||
for i in range(8):
|
||||
temp2[i, :] = idct(dct_block[i, :], norm='ortho')
|
||||
recovered = np.zeros((8, 8), dtype=np.float64)
|
||||
for i in range(8):
|
||||
recovered[:, i] = idct(temp2[:, i], norm='ortho')
|
||||
|
||||
processed += 1
|
||||
|
||||
# GC after each row of blocks
|
||||
if by % 50 == 0:
|
||||
gc.collect()
|
||||
print(f" Row {by}/{blocks_y} ({processed}/{total_blocks} blocks)")
|
||||
|
||||
gc.collect()
|
||||
print(f" Image DCT test PASSED ({processed} blocks)")
|
||||
|
||||
except Exception as e:
|
||||
print(f" FAILED: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
else:
|
||||
print("\n[5] IMAGE TEST: Skipped (no image path provided)")
|
||||
print(" Usage: python test_dct_crash.py /path/to/image.jpg")
|
||||
|
||||
# Step 6: Final cleanup test
|
||||
print("\n[6] FINAL CLEANUP TEST")
|
||||
try:
|
||||
gc.collect()
|
||||
gc.collect()
|
||||
gc.collect()
|
||||
print(" Multiple GC cycles: OK")
|
||||
except Exception as e:
|
||||
print(f" FAILED: {e}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("If this script completes without 'free(): invalid size',")
|
||||
print("the issue is likely in PIL/jpegio interaction, not scipy DCT.")
|
||||
print("=" * 60)
|
||||
|
||||
# Keep process alive briefly to catch delayed crashes
|
||||
import time
|
||||
print("\nWaiting 2 seconds for delayed crashes...")
|
||||
time.sleep(2)
|
||||
print("Done - no crash detected!")
|
||||
@@ -1,525 +0,0 @@
|
||||
# Stegasoo v3.2.0 Release Checklist
|
||||
|
||||
## Overview
|
||||
|
||||
This checklist covers comprehensive functionality testing for the v3.2.0 release, which introduces breaking changes from v3.1.x.
|
||||
|
||||
### Breaking Changes in v3.2.0
|
||||
|
||||
| Change | v3.1.x | v3.2.0 |
|
||||
|--------|--------|--------|
|
||||
| Passphrase model | 7 daily phrases (`day_phrase`) | Single `passphrase` |
|
||||
| Date parameter | Required `date_str` | Removed |
|
||||
| Default words | 3 | 4 |
|
||||
| Format version | 3 | 4 |
|
||||
| Backward compatible | N/A | ❌ Cannot decode v3.1.x images |
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Library Tests
|
||||
|
||||
### 1.1 Key Generation (`src/stegasoo/keygen.py`)
|
||||
|
||||
- [ ] **generate_pin()** - Default 6 digits, no leading zero
|
||||
- [ ] **generate_pin(length=9)** - Custom length works
|
||||
- [ ] **generate_phrase(words=4)** - Default 4 words
|
||||
- [ ] **generate_phrase(words=6)** - Custom word count
|
||||
- [ ] **generate_credentials(use_pin=True)** - Returns single passphrase
|
||||
- [ ] **generate_credentials(use_rsa=True)** - RSA key generation
|
||||
- [ ] **generate_credentials(use_pin=False, use_rsa=False)** - Raises error
|
||||
- [ ] **Credentials.passphrase** - Single string, not dict
|
||||
- [ ] **Credentials.passphrase_entropy** - Correct entropy (4 words = 44 bits)
|
||||
- [ ] **Credentials.total_entropy** - Sum is correct
|
||||
|
||||
### 1.2 Encoding (`src/stegasoo/steganography.py`)
|
||||
|
||||
- [ ] **encode() with passphrase** - New parameter name works
|
||||
- [ ] **encode() without date_str** - No date parameter needed
|
||||
- [ ] **HEADER_OVERHEAD = 65** - Correct constant
|
||||
- [ ] **LSB mode** - Default, full color PNG output
|
||||
- [ ] **DCT mode** - Frequency domain embedding
|
||||
- [ ] **DCT + JPEG output** - Works correctly
|
||||
- [ ] **DCT + color mode** - Preserves colors
|
||||
- [ ] **Capacity calculation** - Uses 65-byte overhead
|
||||
|
||||
### 1.3 Decoding (`src/stegasoo/steganography.py`)
|
||||
|
||||
- [ ] **decode() with passphrase** - New parameter name works
|
||||
- [ ] **decode() without date_str** - No date parameter needed
|
||||
- [ ] **Auto mode detection** - LSB vs DCT automatic
|
||||
- [ ] **Wrong passphrase** - Raises DecryptionError
|
||||
- [ ] **Wrong PIN** - Raises DecryptionError
|
||||
- [ ] **Wrong reference photo** - Raises DecryptionError
|
||||
|
||||
### 1.4 DCT Steganography (`src/stegasoo/dct_steganography.py`)
|
||||
|
||||
- [ ] **Y channel extraction** - Uses correct formula (not just R channel)
|
||||
- [ ] **Color mode encoding** - YCbCr conversion works
|
||||
- [ ] **Grayscale mode** - Converts to grayscale
|
||||
- [ ] **JPEG output** - Quality 95, proper format
|
||||
- [ ] **PNG output** - Lossless DCT output
|
||||
|
||||
### 1.5 Batch Processing (`src/stegasoo/batch.py`)
|
||||
|
||||
- [ ] **BatchCredentials.passphrase** - Single field, not dict
|
||||
- [ ] **BatchCredentials.from_dict()** - Accepts both old and new format
|
||||
- [ ] **batch_encode()** - Uses passphrase parameter
|
||||
- [ ] **batch_decode()** - Uses passphrase parameter
|
||||
|
||||
### 1.6 Validation
|
||||
|
||||
- [ ] **validate_passphrase()** - New function works
|
||||
- [ ] **validate_passphrase() warning** - Warns if < 4 words
|
||||
- [ ] **validate_pin()** - 6-9 digits, no leading zero
|
||||
- [ ] **validate_message()** - Non-empty, within size limits
|
||||
|
||||
---
|
||||
|
||||
## 2. CLI Frontend Tests (`frontends/cli/main.py`)
|
||||
|
||||
### 2.1 Generate Command
|
||||
|
||||
```bash
|
||||
# Test default generation (4 words, PIN)
|
||||
stegasoo generate --pin
|
||||
|
||||
# Test custom word count
|
||||
stegasoo generate --pin --words 6
|
||||
|
||||
# Test RSA generation
|
||||
stegasoo generate --rsa
|
||||
|
||||
# Test JSON output
|
||||
stegasoo generate --pin --json
|
||||
```
|
||||
|
||||
- [ ] Output shows single `PASSPHRASE:` not daily phrases
|
||||
- [ ] Default is 4 words
|
||||
- [ ] JSON has `passphrase` field, not `phrases` dict
|
||||
- [ ] Entropy shows `passphrase_entropy`
|
||||
|
||||
### 2.2 Encode Command
|
||||
|
||||
```bash
|
||||
# Test basic encode
|
||||
stegasoo encode -r ref.jpg -c carrier.png \
|
||||
-p "word1 word2 word3 word4" --pin 123456 \
|
||||
-m "Secret message"
|
||||
|
||||
# Test DCT mode
|
||||
stegasoo encode -r ref.jpg -c carrier.png \
|
||||
-p "word1 word2 word3 word4" --pin 123456 \
|
||||
-m "Secret" --mode dct
|
||||
|
||||
# Test DCT + JPEG
|
||||
stegasoo encode -r ref.jpg -c carrier.png \
|
||||
-p "word1 word2 word3 word4" --pin 123456 \
|
||||
-m "Secret" --mode dct --dct-format jpeg
|
||||
```
|
||||
|
||||
- [ ] `-p` / `--passphrase` parameter works
|
||||
- [ ] No `--date` parameter exists
|
||||
- [ ] LSB mode produces PNG
|
||||
- [ ] DCT mode works
|
||||
- [ ] DCT + JPEG output works
|
||||
- [ ] Output filename has no date suffix
|
||||
|
||||
### 2.3 Decode Command
|
||||
|
||||
```bash
|
||||
# Test basic decode
|
||||
stegasoo decode -r ref.jpg -s stego.png \
|
||||
-p "word1 word2 word3 word4" --pin 123456
|
||||
|
||||
# Test auto mode detection
|
||||
stegasoo decode -r ref.jpg -s stego.png \
|
||||
-p "word1 word2 word3 word4" --pin 123456 --mode auto
|
||||
```
|
||||
|
||||
- [ ] `-p` / `--passphrase` parameter works
|
||||
- [ ] No `--date` parameter exists
|
||||
- [ ] Auto-detects LSB vs DCT
|
||||
- [ ] Outputs decoded message
|
||||
|
||||
### 2.4 Other Commands
|
||||
|
||||
```bash
|
||||
# Verify command
|
||||
stegasoo verify -s stego.png
|
||||
|
||||
# Compare command
|
||||
stegasoo compare original.png stego.png
|
||||
|
||||
# Modes command
|
||||
stegasoo modes
|
||||
|
||||
# Capacity command
|
||||
stegasoo capacity carrier.png
|
||||
```
|
||||
|
||||
- [ ] All commands work without errors
|
||||
- [ ] No references to "day phrase" or dates
|
||||
|
||||
---
|
||||
|
||||
## 3. API Frontend Tests (`frontends/api/main.py`)
|
||||
|
||||
### 3.1 Status Endpoint
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/
|
||||
```
|
||||
|
||||
- [ ] Returns `version: "3.2.0"`
|
||||
- [ ] Includes `breaking_changes` object
|
||||
- [ ] No `day_names` field
|
||||
|
||||
### 3.2 Generate Endpoint
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"use_pin": true, "words_per_passphrase": 4}'
|
||||
```
|
||||
|
||||
- [ ] Parameter is `words_per_passphrase` (not `words_per_phrase`)
|
||||
- [ ] Response has `passphrase` string field
|
||||
- [ ] Response has `phrases: null`
|
||||
- [ ] Entropy field is `passphrase` not `phrase`
|
||||
|
||||
### 3.3 Encode Endpoint
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/encode \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "Secret",
|
||||
"passphrase": "word1 word2 word3 word4",
|
||||
"pin": "123456",
|
||||
"reference_photo_base64": "...",
|
||||
"carrier_image_base64": "..."
|
||||
}'
|
||||
```
|
||||
|
||||
- [ ] Parameter is `passphrase` (not `day_phrase`)
|
||||
- [ ] No `date_str` parameter accepted
|
||||
- [ ] Response has `date_used: null`
|
||||
- [ ] Response has `day_of_week: null`
|
||||
|
||||
### 3.4 Decode Endpoint
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/decode \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"passphrase": "word1 word2 word3 word4",
|
||||
"pin": "123456",
|
||||
"stego_image_base64": "...",
|
||||
"reference_photo_base64": "..."
|
||||
}'
|
||||
```
|
||||
|
||||
- [ ] Parameter is `passphrase` (not `day_phrase`)
|
||||
- [ ] No `date_str` parameter needed
|
||||
- [ ] Auto-detects embedding mode
|
||||
|
||||
### 3.5 Multipart Endpoints
|
||||
|
||||
```bash
|
||||
# Encode multipart
|
||||
curl -X POST http://localhost:8000/encode/multipart \
|
||||
-F "passphrase=word1 word2 word3 word4" \
|
||||
-F "pin=123456" \
|
||||
-F "message=Secret" \
|
||||
-F "reference_photo=@ref.jpg" \
|
||||
-F "carrier=@carrier.png"
|
||||
|
||||
# Decode multipart
|
||||
curl -X POST http://localhost:8000/decode/multipart \
|
||||
-F "passphrase=word1 word2 word3 word4" \
|
||||
-F "pin=123456" \
|
||||
-F "reference_photo=@ref.jpg" \
|
||||
-F "stego_image=@stego.png"
|
||||
```
|
||||
|
||||
- [ ] Form field is `passphrase` (not `day_phrase`)
|
||||
- [ ] No `date_str` field
|
||||
- [ ] Headers include `X-Stegasoo-Version: 3.2.0`
|
||||
- [ ] No date headers in response
|
||||
|
||||
---
|
||||
|
||||
## 4. Web Frontend Tests (`frontends/web/app.py`)
|
||||
|
||||
### 4.1 Generate Page (`/generate`)
|
||||
|
||||
- [ ] Form field is `words_per_passphrase`
|
||||
- [ ] Default slider value is 4
|
||||
- [ ] Output shows single passphrase, not 7 daily phrases
|
||||
- [ ] Memory aid works with single passphrase
|
||||
- [ ] Entropy display shows `passphrase_entropy`
|
||||
- [ ] v3.2.0 badge visible
|
||||
|
||||
### 4.2 Encode Page (`/encode`)
|
||||
|
||||
- [ ] Form field is `passphrase`
|
||||
- [ ] No date selection field
|
||||
- [ ] v3.2.0 badge on passphrase label
|
||||
- [ ] Passphrase validation warning works (< 4 words)
|
||||
- [ ] DCT mode options work
|
||||
- [ ] Success result shows no date info
|
||||
|
||||
### 4.3 Decode Page (`/decode`)
|
||||
|
||||
- [ ] Form field is `passphrase`
|
||||
- [ ] No date input field
|
||||
- [ ] No date detection from filename JavaScript
|
||||
- [ ] Troubleshooting mentions v3.2.0 compatibility
|
||||
- [ ] Auto mode detection works
|
||||
|
||||
### 4.4 Other Pages
|
||||
|
||||
- [ ] **Home** (`/`) - Shows v3.2.0 badge, passphrase terminology
|
||||
- [ ] **About** (`/about`) - Updated terminology, v3.2.0 features
|
||||
- [ ] **Footer** - Says "Passphrase" not "Day-Phrase"
|
||||
|
||||
---
|
||||
|
||||
## 5. Integration Tests
|
||||
|
||||
### 5.1 Full Roundtrip Tests
|
||||
|
||||
```bash
|
||||
# Generate → Encode → Decode (LSB)
|
||||
stegasoo generate --pin > creds.json
|
||||
stegasoo encode -r ref.jpg -c carrier.png -p "..." --pin 123456 -m "Test" -o stego.png
|
||||
stegasoo decode -r ref.jpg -s stego.png -p "..." --pin 123456
|
||||
|
||||
# Generate → Encode → Decode (DCT)
|
||||
stegasoo encode -r ref.jpg -c carrier.png -p "..." --pin 123456 -m "Test" --mode dct -o stego_dct.png
|
||||
stegasoo decode -r ref.jpg -s stego_dct.png -p "..." --pin 123456
|
||||
```
|
||||
|
||||
- [ ] LSB roundtrip works
|
||||
- [ ] DCT roundtrip works
|
||||
- [ ] DCT + JPEG roundtrip works
|
||||
- [ ] File embedding roundtrip works
|
||||
|
||||
### 5.2 Cross-Frontend Tests
|
||||
|
||||
- [ ] Encode via CLI, decode via API
|
||||
- [ ] Encode via API, decode via Web
|
||||
- [ ] Encode via Web, decode via CLI
|
||||
|
||||
### 5.3 Error Handling
|
||||
|
||||
- [ ] Wrong passphrase shows clear error
|
||||
- [ ] Wrong PIN shows clear error
|
||||
- [ ] Wrong reference photo shows clear error
|
||||
- [ ] Capacity exceeded shows clear error
|
||||
- [ ] Invalid image shows clear error
|
||||
|
||||
---
|
||||
|
||||
## 6. Documentation Tests
|
||||
|
||||
### 6.1 CLI Documentation (`frontends/CLI.md`)
|
||||
|
||||
- [ ] "What's New in v3.2.0" section exists
|
||||
- [ ] All examples use 4-word passphrases
|
||||
- [ ] No `--date` parameter in examples
|
||||
- [ ] Command reference is complete
|
||||
- [ ] Migration notes for v3.1.x users
|
||||
|
||||
### 6.2 API Documentation (`frontends/API.md`)
|
||||
|
||||
- [ ] "What's New in v3.2.0" section exists
|
||||
- [ ] All request examples use `passphrase`
|
||||
- [ ] No `date_str` in request models
|
||||
- [ ] Response models show `date_used: null`
|
||||
- [ ] Code examples updated
|
||||
|
||||
### 6.3 Web UI Documentation (`frontends/WEB_UI.md`)
|
||||
|
||||
- [ ] "What's New in v3.2.0" section exists
|
||||
- [ ] Workflow examples use passphrase
|
||||
- [ ] No date selection in screenshots/descriptions
|
||||
- [ ] Troubleshooting updated
|
||||
|
||||
---
|
||||
|
||||
## 7. Backward Compatibility Tests
|
||||
|
||||
### 7.1 v3.1.x Image Decoding
|
||||
|
||||
- [ ] Attempting to decode v3.1.x image with v3.2.0 fails gracefully
|
||||
- [ ] Error message mentions version incompatibility
|
||||
- [ ] Suggests using v3.1.x for old images
|
||||
|
||||
### 7.2 Migration Path
|
||||
|
||||
- [ ] `BatchCredentials.from_dict()` accepts old `day_phrase` key
|
||||
- [ ] `generate_credentials_legacy()` available if needed
|
||||
- [ ] Documentation explains migration steps
|
||||
|
||||
---
|
||||
|
||||
## 8. Unit Test Updates
|
||||
|
||||
### 8.1 Test Files to Update
|
||||
|
||||
- [ ] `tests/test_stegasoo.py` - Use `passphrase` parameter
|
||||
- [ ] `tests/test_batch.py` - Use `passphrase` in credentials
|
||||
- [ ] `tests/test_compression.py` - No changes needed (compression unchanged)
|
||||
|
||||
### 8.2 New Tests Needed
|
||||
|
||||
- [ ] Test single passphrase generation
|
||||
- [ ] Test `passphrase_words` parameter
|
||||
- [ ] Test `validate_passphrase()` function
|
||||
- [ ] Test DCT Y channel extraction
|
||||
- [ ] Test 65-byte header overhead
|
||||
|
||||
---
|
||||
|
||||
## 9. Release Artifacts
|
||||
|
||||
### 9.1 Version Bumps
|
||||
|
||||
- [ ] `src/stegasoo/constants.py` - `__version__ = "3.2.0"`
|
||||
- [ ] `pyproject.toml` or `setup.py` - version updated
|
||||
- [ ] `CHANGELOG.md` - v3.2.0 section added
|
||||
|
||||
### 9.2 Documentation
|
||||
|
||||
- [ ] `README.md` - Updated for v3.2.0
|
||||
- [ ] `frontends/CLI.md` - Complete
|
||||
- [ ] `frontends/API.md` - Complete
|
||||
- [ ] `frontends/WEB_UI.md` - Complete
|
||||
|
||||
### 9.3 Git
|
||||
|
||||
- [ ] All changes committed
|
||||
- [ ] Tag created: `v3.2.0`
|
||||
- [ ] Release notes written
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick Smoke Test Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# v3.2.0 Smoke Test
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Stegasoo v3.2.0 Smoke Test ==="
|
||||
|
||||
# Check version
|
||||
echo "1. Checking version..."
|
||||
python -c "import stegasoo; print(f'Version: {stegasoo.__version__}')"
|
||||
|
||||
# Generate credentials
|
||||
echo "2. Generating credentials..."
|
||||
python -c "
|
||||
from stegasoo import generate_credentials
|
||||
creds = generate_credentials(use_pin=True, passphrase_words=4)
|
||||
print(f'Passphrase: {creds.passphrase}')
|
||||
print(f'PIN: {creds.pin}')
|
||||
print(f'Entropy: {creds.total_entropy} bits')
|
||||
assert ' ' in creds.passphrase, 'Passphrase should have spaces'
|
||||
assert len(creds.passphrase.split()) == 4, 'Should have 4 words'
|
||||
print('✓ Credentials OK')
|
||||
"
|
||||
|
||||
# Test encode/decode roundtrip
|
||||
echo "3. Testing encode/decode roundtrip..."
|
||||
python -c "
|
||||
from stegasoo import encode, decode
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Create test image
|
||||
img = Image.new('RGB', (200, 200), color='blue')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
test_image = buf.getvalue()
|
||||
|
||||
# Encode
|
||||
result = encode(
|
||||
message='Hello v3.2.0!',
|
||||
reference_photo=test_image,
|
||||
carrier_image=test_image,
|
||||
passphrase='test phrase four words',
|
||||
pin='123456'
|
||||
)
|
||||
print(f'Encoded: {result.filename}')
|
||||
|
||||
# Decode
|
||||
decoded = decode(
|
||||
stego_image=result.stego_image,
|
||||
reference_photo=test_image,
|
||||
passphrase='test phrase four words',
|
||||
pin='123456'
|
||||
)
|
||||
assert decoded.message == 'Hello v3.2.0!', 'Message mismatch'
|
||||
print(f'Decoded: {decoded.message}')
|
||||
print('✓ Roundtrip OK')
|
||||
"
|
||||
|
||||
# Test DCT mode
|
||||
echo "4. Testing DCT mode..."
|
||||
python -c "
|
||||
from stegasoo import encode, decode, has_dct_support
|
||||
if has_dct_support():
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
img = Image.new('RGB', (200, 200), color='green')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
test_image = buf.getvalue()
|
||||
|
||||
result = encode(
|
||||
message='DCT test',
|
||||
reference_photo=test_image,
|
||||
carrier_image=test_image,
|
||||
passphrase='dct test phrase here',
|
||||
pin='123456',
|
||||
embed_mode='dct'
|
||||
)
|
||||
|
||||
decoded = decode(
|
||||
stego_image=result.stego_image,
|
||||
reference_photo=test_image,
|
||||
passphrase='dct test phrase here',
|
||||
pin='123456'
|
||||
)
|
||||
assert decoded.message == 'DCT test'
|
||||
print('✓ DCT Mode OK')
|
||||
else:
|
||||
print('⚠ DCT mode not available (scipy not installed)')
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "=== All smoke tests passed! ==="
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
| Area | Tested By | Date | Status |
|
||||
|------|-----------|------|--------|
|
||||
| Core Library | | | ☐ |
|
||||
| CLI Frontend | | | ☐ |
|
||||
| API Frontend | | | ☐ |
|
||||
| Web Frontend | | | ☐ |
|
||||
| Documentation | | | ☐ |
|
||||
| Integration | | | ☐ |
|
||||
|
||||
**Release Approved:** ☐
|
||||
|
||||
**Released By:** _________________
|
||||
|
||||
**Release Date:** _________________
|
||||
528
tests/RELEASE_CHECKLIST_V4_0_0.md
Normal file
528
tests/RELEASE_CHECKLIST_V4_0_0.md
Normal file
@@ -0,0 +1,528 @@
|
||||
# Stegasoo v4.0.0 Release Checklist
|
||||
|
||||
## Overview
|
||||
|
||||
This checklist covers functionality testing for the v4.0.0 release.
|
||||
|
||||
### Changes in v4.0.0
|
||||
|
||||
| Change | v3.2.0 | v4.0.0 |
|
||||
|--------|--------|--------|
|
||||
| Python version | 3.10-3.12 | 3.10-3.12 (3.13 NOT supported) |
|
||||
| JPEG handling | Could crash on quality=100 | Normalized before jpegio |
|
||||
| Header size | 65 bytes | 65 bytes (unchanged) |
|
||||
| API | passphrase, no date_str | Same (no breaking changes) |
|
||||
| Format version | 4 | 4 (compatible with v3.2.0) |
|
||||
|
||||
### Key Points
|
||||
- **No breaking API changes from v3.2.0**
|
||||
- **v4.0 CAN decode v3.2.0 images** (same format version)
|
||||
- **v4.0 CANNOT decode v3.1.x or earlier images**
|
||||
- **Python 3.13 is NOT supported** (jpegio C extension ABI incompatibility)
|
||||
|
||||
---
|
||||
|
||||
## 1. Pre-Release Checks
|
||||
|
||||
### 1.1 Python Version
|
||||
|
||||
```bash
|
||||
python --version # Must be 3.10, 3.11, or 3.12
|
||||
```
|
||||
|
||||
- [ ] Python version is 3.10, 3.11, or 3.12
|
||||
- [ ] NOT Python 3.13 (jpegio will crash)
|
||||
|
||||
### 1.2 Dependencies
|
||||
|
||||
```bash
|
||||
pip list | grep -E "jpegio|scipy|pillow|argon2"
|
||||
```
|
||||
|
||||
- [ ] jpegio installed (for DCT JPEG support)
|
||||
- [ ] scipy installed (for DCT mode)
|
||||
- [ ] pillow installed
|
||||
- [ ] argon2-cffi installed
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Library Tests
|
||||
|
||||
### 2.1 Run Unit Tests
|
||||
|
||||
```bash
|
||||
cd /path/to/stegasoo
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
- [ ] All tests pass
|
||||
- [ ] No deprecation warnings for removed parameters
|
||||
|
||||
### 2.2 JPEG Normalization Test (NEW in v4.0)
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
from PIL import Image
|
||||
import io
|
||||
from stegasoo import encode, decode
|
||||
|
||||
# Create quality=100 JPEG (triggers normalization)
|
||||
img = Image.new('RGB', (400, 400), 'red')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='JPEG', quality=100)
|
||||
jpeg_data = buf.getvalue()
|
||||
|
||||
# This should NOT crash (v3.2.0 would crash here)
|
||||
result = encode(
|
||||
message='Test quality 100',
|
||||
reference_photo=jpeg_data,
|
||||
carrier_image=jpeg_data,
|
||||
passphrase='test phrase four words',
|
||||
pin='123456',
|
||||
embed_mode='dct'
|
||||
)
|
||||
print('✓ Quality=100 JPEG encode OK')
|
||||
|
||||
decoded = decode(
|
||||
stego_image=result.stego_image,
|
||||
reference_photo=jpeg_data,
|
||||
passphrase='test phrase four words',
|
||||
pin='123456'
|
||||
)
|
||||
assert decoded.message == 'Test quality 100'
|
||||
print('✓ Quality=100 JPEG decode OK')
|
||||
"
|
||||
```
|
||||
|
||||
- [ ] Quality=100 JPEG encoding works (no crash)
|
||||
- [ ] Quality=100 JPEG decoding works
|
||||
|
||||
### 2.3 Large Image Test (NEW in v4.0)
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
from PIL import Image
|
||||
import io
|
||||
from stegasoo import encode, decode
|
||||
|
||||
# Create large image (similar to 14MB real photo)
|
||||
img = Image.new('RGB', (4000, 3000), 'blue')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
large_image = buf.getvalue()
|
||||
print(f'Test image size: {len(large_image) / 1024 / 1024:.1f} MB')
|
||||
|
||||
result = encode(
|
||||
message='Large image test',
|
||||
reference_photo=large_image,
|
||||
carrier_image=large_image,
|
||||
passphrase='large image test phrase',
|
||||
pin='123456'
|
||||
)
|
||||
print('✓ Large image encode OK')
|
||||
|
||||
decoded = decode(
|
||||
stego_image=result.stego_image,
|
||||
reference_photo=large_image,
|
||||
passphrase='large image test phrase',
|
||||
pin='123456'
|
||||
)
|
||||
assert decoded.message == 'Large image test'
|
||||
print('✓ Large image decode OK')
|
||||
"
|
||||
```
|
||||
|
||||
- [ ] Large image (12MP+) encoding works
|
||||
- [ ] Large image decoding works
|
||||
|
||||
---
|
||||
|
||||
## 3. Docker Build Tests
|
||||
|
||||
### 3.1 Base Image Build
|
||||
|
||||
```bash
|
||||
# Build base image (one-time, 5-10 min)
|
||||
sudo docker build -f Dockerfile.base -t stegasoo-base:latest .
|
||||
```
|
||||
|
||||
- [ ] Base image builds successfully
|
||||
- [ ] jpegio + scipy + numpy verification passes
|
||||
|
||||
### 3.2 Application Build
|
||||
|
||||
```bash
|
||||
# Fast build using base image
|
||||
sudo docker-compose build
|
||||
```
|
||||
|
||||
- [ ] Web container builds
|
||||
- [ ] API container builds
|
||||
|
||||
### 3.3 Container Startup
|
||||
|
||||
```bash
|
||||
sudo docker-compose up -d
|
||||
sudo docker-compose logs
|
||||
```
|
||||
|
||||
- [ ] Web container starts without errors
|
||||
- [ ] API container starts without errors
|
||||
- [ ] No import errors in logs
|
||||
|
||||
---
|
||||
|
||||
## 4. Web UI Tests (`http://localhost:5000`)
|
||||
|
||||
### 4.1 Home Page
|
||||
|
||||
- [ ] v4.0 badge visible
|
||||
- [ ] "Learn More" button is white/visible
|
||||
- [ ] No references to "day phrase" or dates
|
||||
|
||||
### 4.2 Generate Page (`/generate`)
|
||||
|
||||
- [ ] Default is 4 words
|
||||
- [ ] Single passphrase generated (not 7 daily)
|
||||
- [ ] PIN toggle shows/hides digits
|
||||
- [ ] Memory aid generator works
|
||||
|
||||
### 4.3 Encode Page (`/encode`)
|
||||
|
||||
- [ ] Passphrase field has blue glow on focus
|
||||
- [ ] PIN field has orange glow on focus
|
||||
- [ ] PIN box is 180px wide (fits LastPass icon)
|
||||
- [ ] Passphrase font shrinks for long input (stepped)
|
||||
- [ ] RSA .pem/QR toggle works
|
||||
- [ ] QR image preview shows when selected
|
||||
- [ ] DCT mode options appear when selected
|
||||
- [ ] Encoding works (LSB mode)
|
||||
- [ ] Encoding works (DCT mode)
|
||||
|
||||
### 4.4 Decode Page (`/decode`)
|
||||
|
||||
- [ ] Same styling as encode (glowing inputs)
|
||||
- [ ] RSA .pem/QR toggle works (matches encode layout)
|
||||
- [ ] QR image preview shows when selected
|
||||
- [ ] Copy button is below message (not overlapping)
|
||||
- [ ] Decoding works (LSB mode)
|
||||
- [ ] Decoding works (DCT mode)
|
||||
- [ ] Auto mode detection works
|
||||
|
||||
### 4.5 About Page (`/about`)
|
||||
|
||||
- [ ] Version history table present
|
||||
- [ ] v4.0.0 entry in table
|
||||
- [ ] Python 3.10-3.12 requirement noted
|
||||
- [ ] No marketing language ("military-grade" removed)
|
||||
|
||||
---
|
||||
|
||||
## 5. API Tests (`http://localhost:8000`)
|
||||
|
||||
### 5.1 Status Endpoint
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/
|
||||
```
|
||||
|
||||
- [ ] Returns version "4.0.0"
|
||||
- [ ] No import errors
|
||||
|
||||
### 5.2 Generate Endpoint
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"use_pin": true}'
|
||||
```
|
||||
|
||||
- [ ] Returns single `passphrase` string
|
||||
- [ ] Returns 4 words by default
|
||||
|
||||
### 5.3 OpenAPI Docs
|
||||
|
||||
- [ ] `/docs` loads (Swagger UI)
|
||||
- [ ] `/redoc` loads (ReDoc)
|
||||
- [ ] All endpoints documented
|
||||
|
||||
---
|
||||
|
||||
## 6. CLI Tests
|
||||
|
||||
### 6.1 Version
|
||||
|
||||
```bash
|
||||
stegasoo --version
|
||||
```
|
||||
|
||||
- [ ] Shows 4.0.0
|
||||
|
||||
### 6.2 Generate
|
||||
|
||||
```bash
|
||||
stegasoo generate --pin --words 4
|
||||
```
|
||||
|
||||
- [ ] Single passphrase output
|
||||
- [ ] 4 words generated
|
||||
|
||||
### 6.3 Encode/Decode Roundtrip
|
||||
|
||||
```bash
|
||||
# Generate test image
|
||||
python -c "from PIL import Image; Image.new('RGB', (200,200), 'red').save('/tmp/test.png')"
|
||||
|
||||
# Encode
|
||||
stegasoo encode \
|
||||
-r /tmp/test.png \
|
||||
-c /tmp/test.png \
|
||||
-p "cli test phrase here" \
|
||||
--pin 123456 \
|
||||
-m "CLI roundtrip test" \
|
||||
-o /tmp/stego.png
|
||||
|
||||
# Decode
|
||||
stegasoo decode \
|
||||
-r /tmp/test.png \
|
||||
-s /tmp/stego.png \
|
||||
-p "cli test phrase here" \
|
||||
--pin 123456
|
||||
```
|
||||
|
||||
- [ ] Encode succeeds
|
||||
- [ ] Decode returns correct message
|
||||
|
||||
---
|
||||
|
||||
## 7. Cross-Version Compatibility
|
||||
|
||||
### 7.1 v3.2.0 Compatibility
|
||||
|
||||
- [ ] v4.0 can decode v3.2.0 images (same format version 4)
|
||||
|
||||
### 7.2 v3.1.x Incompatibility
|
||||
|
||||
- [ ] v4.0 fails gracefully on v3.1.x images
|
||||
- [ ] Error message is clear
|
||||
|
||||
---
|
||||
|
||||
## 8. Documentation Review
|
||||
|
||||
### 8.1 Updated Files
|
||||
|
||||
- [ ] README.md - v4.0 references
|
||||
- [ ] INSTALL.md - Python 3.13 warning prominent
|
||||
- [ ] SECURITY.md - v4.0 changes documented
|
||||
- [ ] UNDER_THE_HOOD.md - JPEG normalization section
|
||||
|
||||
### 8.2 Template Updates
|
||||
|
||||
- [ ] All 7 templates updated
|
||||
- [ ] No v3.x badges remaining
|
||||
- [ ] Version history in About page
|
||||
|
||||
---
|
||||
|
||||
## 9. Quick Smoke Test Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# v4.0.0 Smoke Test
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Stegasoo v4.0.0 Smoke Test ==="
|
||||
|
||||
# Check version
|
||||
echo "1. Checking version..."
|
||||
python -c "import stegasoo; assert stegasoo.__version__.startswith('4.'), f'Wrong version: {stegasoo.__version__}'; print(f'✓ Version: {stegasoo.__version__}')"
|
||||
|
||||
# Check Python version
|
||||
echo "2. Checking Python version..."
|
||||
python -c "
|
||||
import sys
|
||||
v = sys.version_info
|
||||
assert v.major == 3 and 10 <= v.minor <= 12, f'Python {v.major}.{v.minor} not supported'
|
||||
print(f'✓ Python {v.major}.{v.minor}.{v.micro}')
|
||||
"
|
||||
|
||||
# Check DCT support
|
||||
echo "3. Checking DCT support..."
|
||||
python -c "
|
||||
from stegasoo import has_dct_support
|
||||
from stegasoo.dct_steganography import has_jpegio_support
|
||||
print(f' DCT (scipy): {has_dct_support()}')
|
||||
print(f' JPEG native (jpegio): {has_jpegio_support()}')
|
||||
assert has_dct_support(), 'DCT not available'
|
||||
print('✓ DCT support OK')
|
||||
"
|
||||
|
||||
# Test encode/decode roundtrip
|
||||
echo "4. Testing encode/decode roundtrip..."
|
||||
python -c "
|
||||
from stegasoo import encode, decode
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
img = Image.new('RGB', (200, 200), color='blue')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
test_image = buf.getvalue()
|
||||
|
||||
result = encode(
|
||||
message='Hello v4.0.0!',
|
||||
reference_photo=test_image,
|
||||
carrier_image=test_image,
|
||||
passphrase='test phrase four words',
|
||||
pin='123456'
|
||||
)
|
||||
|
||||
decoded = decode(
|
||||
stego_image=result.stego_image,
|
||||
reference_photo=test_image,
|
||||
passphrase='test phrase four words',
|
||||
pin='123456'
|
||||
)
|
||||
|
||||
assert decoded.message == 'Hello v4.0.0!', f'Got: {decoded.message}'
|
||||
print('✓ LSB roundtrip OK')
|
||||
"
|
||||
|
||||
# Test DCT mode
|
||||
echo "5. Testing DCT mode..."
|
||||
python -c "
|
||||
from stegasoo import encode, decode
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
img = Image.new('RGB', (400, 400), color='green')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
test_image = buf.getvalue()
|
||||
|
||||
result = encode(
|
||||
message='DCT v4.0 test',
|
||||
reference_photo=test_image,
|
||||
carrier_image=test_image,
|
||||
passphrase='dct test phrase here',
|
||||
pin='123456',
|
||||
embed_mode='dct'
|
||||
)
|
||||
|
||||
decoded = decode(
|
||||
stego_image=result.stego_image,
|
||||
reference_photo=test_image,
|
||||
passphrase='dct test phrase here',
|
||||
pin='123456'
|
||||
)
|
||||
|
||||
assert decoded.message == 'DCT v4.0 test'
|
||||
print('✓ DCT roundtrip OK')
|
||||
"
|
||||
|
||||
# Test JPEG quality=100 (v4.0 fix)
|
||||
echo "6. Testing JPEG quality=100 handling..."
|
||||
python -c "
|
||||
from stegasoo import encode, decode
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
img = Image.new('RGB', (400, 400), color='red')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='JPEG', quality=100)
|
||||
jpeg_q100 = buf.getvalue()
|
||||
|
||||
result = encode(
|
||||
message='Quality 100 test',
|
||||
reference_photo=jpeg_q100,
|
||||
carrier_image=jpeg_q100,
|
||||
passphrase='jpeg quality test here',
|
||||
pin='123456',
|
||||
embed_mode='dct'
|
||||
)
|
||||
|
||||
decoded = decode(
|
||||
stego_image=result.stego_image,
|
||||
reference_photo=jpeg_q100,
|
||||
passphrase='jpeg quality test here',
|
||||
pin='123456'
|
||||
)
|
||||
|
||||
assert decoded.message == 'Quality 100 test'
|
||||
print('✓ JPEG quality=100 OK (v4.0 fix working)')
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "=== All smoke tests passed! ==="
|
||||
echo "Ready for release."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Release Steps
|
||||
|
||||
### 10.1 Final Checks
|
||||
|
||||
- [ ] All tests pass
|
||||
- [ ] All Docker containers work
|
||||
- [ ] Documentation updated
|
||||
- [ ] Version bumped in `constants.py` and `pyproject.toml`
|
||||
|
||||
### 10.2 Git
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git status # Review changes
|
||||
git commit -m "v4.0.0: JPEG normalization, Python 3.12, UI polish"
|
||||
git tag v4.0.0
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
- [ ] Changes committed
|
||||
- [ ] Tag created
|
||||
- [ ] Pushed to remote
|
||||
|
||||
### 10.3 Release Notes
|
||||
|
||||
```markdown
|
||||
## v4.0.0
|
||||
|
||||
### What's New
|
||||
- **JPEG Normalization**: Quality=100 JPEGs now work with DCT mode
|
||||
- **Python 3.12**: Recommended version (3.13 NOT supported due to jpegio)
|
||||
- **UI Polish**: Glowing input fields, better layout, version history
|
||||
|
||||
### Fixes
|
||||
- Fixed jpegio crash on quality=100 JPEG images
|
||||
- Fixed QR code input on decode page
|
||||
- Fixed passphrase font sizing (stepped instead of smooth)
|
||||
|
||||
### Breaking Changes
|
||||
- Python 3.13 is NOT supported
|
||||
|
||||
### Compatibility
|
||||
- v4.0 can decode v3.2.0 images (same format)
|
||||
- v4.0 CANNOT decode v3.1.x or earlier
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
| Area | Tested By | Date | Status |
|
||||
|------|-----------|------|--------|
|
||||
| Python/Dependencies | | | ☐ |
|
||||
| Unit Tests | | | ☐ |
|
||||
| Docker Build | | | ☐ |
|
||||
| Web UI | | | ☐ |
|
||||
| API | | | ☐ |
|
||||
| CLI | | | ☐ |
|
||||
| Documentation | | | ☐ |
|
||||
|
||||
**Release Approved:** ☐
|
||||
|
||||
**Released By:** _________________
|
||||
|
||||
**Release Date:** _________________
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Tests for Stegasoo batch processing module (v3.2.0).
|
||||
Tests for Stegasoo batch processing module (v4.0.0).
|
||||
|
||||
Updated for v3.2.0:
|
||||
Updated for v4.0.0:
|
||||
- Uses 'passphrase' instead of 'phrase' in credentials dict
|
||||
- No date_str parameter
|
||||
- BatchCredentials.passphrase is a single string
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
Stegasoo Tests (v3.2.0)
|
||||
Stegasoo Tests (v4.0.0)
|
||||
|
||||
Tests for key generation, validation, encoding/decoding, and output formats.
|
||||
|
||||
Updated for v3.2.0:
|
||||
- Single passphrase instead of daily phrases
|
||||
- No date_str parameter
|
||||
- passphrase_words parameter (default 4)
|
||||
Updated for v4.0.0:
|
||||
- Same API as v3.2.0 (passphrase, no date_str)
|
||||
- JPEG normalization for jpegio compatibility
|
||||
- Python 3.12 recommended (3.13 not supported)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -569,14 +569,11 @@ class TestVersion:
|
||||
assert len(parts) >= 2
|
||||
assert all(p.isdigit() for p in parts[:2])
|
||||
|
||||
def test_version_is_3_2_0(self):
|
||||
"""Version should be 3.2.0 or higher."""
|
||||
def test_version_is_4_0_0(self):
|
||||
"""Version should be 4.0.0 or higher."""
|
||||
parts = stegasoo.__version__.split('.')
|
||||
major = int(parts[0])
|
||||
minor = int(parts[1])
|
||||
assert major >= 3
|
||||
if major == 3:
|
||||
assert minor >= 2
|
||||
assert major >= 4
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user