A whoooole lotta 4.0.x fixes.

This commit is contained in:
Aaron D. Lee
2026-01-01 22:18:13 -05:00
parent 12929bf326
commit ef7478b30a
40 changed files with 6003 additions and 1830 deletions

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12.0

View File

@@ -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
View 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"

View File

@@ -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
View File

@@ -2,39 +2,47 @@
A secure steganography system for hiding encrypted messages in images using hybrid authentication.
![Python](https://img.shields.io/badge/Python-3.10+-blue)
![Python](https://img.shields.io/badge/Python-3.10--3.12-blue)
![License](https://img.shields.io/badge/License-MIT-green)
![Security](https://img.shields.io/badge/Security-AES--256--GCM-red)
![Version](https://img.shields.io/badge/Version-3.0.2-purple)
![Version](https://img.shields.io/badge/Version-4.0.0-purple)
## 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

View File

@@ -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 |

View File

@@ -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
View 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
View 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
View 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()

View File

@@ -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'],

View File

@@ -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

View 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.

View File

@@ -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
# ============================================================================

View 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()

View 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

View File

@@ -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 &amp; 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 &amp; 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 }} &bull;
<i class="bi bi-github me-1"></i>Open Source &bull;
Built with Python, Flask/FastAPI, and cryptography
Built with Python, Flask, and cryptography
</p>
</div>
</div>

View File

@@ -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 %}

View File

@@ -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',

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View 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
View 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)

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View 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'

View File

@@ -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

View 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

View 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!")

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

231
test_dct_crash.py Normal file
View 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!")

View File

@@ -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:** _________________

View 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:** _________________

View File

@@ -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

View File

@@ -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
# =============================================================================

BIN
xx_2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB