diff --git a/Dockerfile b/Dockerfile index 04fc73b..356dc1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,11 +12,13 @@ ENV PYTHONUNBUFFERED=1 ENV PIP_ROOT_USER_ACTION=ignore # Install system dependencies +# NOTE: libjpeg-dev is required for jpegio compilation RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ libc-dev \ libffi-dev \ libzbar0 \ + libjpeg-dev \ && rm -rf /var/lib/apt/lists/* # ============================================================================ @@ -31,8 +33,10 @@ COPY pyproject.toml README.md ./ COPY src/ src/ COPY data/ data/ -# Install the package with web extras -RUN pip install --no-cache-dir ".[web]" +# 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]" # ============================================================================ # Production stage - Web UI @@ -78,11 +82,14 @@ FROM base as api WORKDIR /app -# Install API extras +# Install API extras (includes DCT dependencies) COPY pyproject.toml README.md ./ COPY src/ src/ COPY data/ data/ -RUN pip install --no-cache-dir ".[api]" + +# 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/ @@ -116,7 +123,10 @@ WORKDIR /app COPY pyproject.toml README.md ./ COPY src/ src/ COPY data/ data/ -RUN pip install --no-cache-dir ".[cli]" + +# 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/ diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..b497525 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,712 @@ +# Stegasoo Installation Guide + +Complete installation instructions for all platforms and deployment methods. + +## Table of Contents + +- [Requirements](#requirements) +- [Quick Install](#quick-install) +- [Installation Methods](#installation-methods) + - [From Source (Development)](#from-source-development) + - [From PyPI](#from-pypi) + - [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) + +--- + +## Requirements + +### Minimum Requirements + +| Requirement | Version | +|-------------|---------| +| Python | 3.10+ | +| RAM | 512 MB minimum (256MB for Argon2) | +| Disk | ~100 MB | + +### System Dependencies + +**Linux (Debian/Ubuntu):** +```bash +sudo apt-get update +sudo apt-get install -y \ + python3 \ + python3-pip \ + python3-dev \ + libzbar0 \ + libjpeg-dev \ + build-essential +``` + +**macOS:** +```bash +brew install python@3.11 zbar jpeg +xcode-select --install # For compilation +``` + +**Windows:** +- Install Python 3.10+ from [python.org](https://python.org) +- Install Visual Studio Build Tools for compilation + +--- + +## Quick Install + +```bash +# Clone and install everything +git clone https://github.com/adlee-was-taken/stegasoo.git +cd stegasoo +pip install -e ".[all]" + +# Verify +stegasoo --version +``` + +--- + +## Installation Methods + +### From Source (Development) + +Best for development or customization. + +```bash +# Clone the repository +git clone https://github.com/adlee-was-taken/stegasoo.git +cd stegasoo + +# Create virtual environment (recommended) +python -m venv venv +source venv/bin/activate # Linux/macOS +# or: venv\Scripts\activate # Windows + +# Install core library only +pip install -e . + +# Install with specific extras +pip install -e ".[cli]" # Command-line interface +pip install -e ".[web]" # Flask web UI + DCT support +pip install -e ".[api]" # FastAPI REST API + DCT support +pip install -e ".[dct]" # DCT steganography only +pip install -e ".[compression]" # LZ4 compression + +# Install everything +pip install -e ".[all]" + +# Install with development tools +pip install -e ".[dev]" +``` + +### From PyPI + +```bash +# Core only +pip install stegasoo + +# With extras +pip install stegasoo[cli] +pip install stegasoo[web] +pip install stegasoo[api] +pip install stegasoo[all] +``` + +### Docker + +Build and run individual containers. + +#### Build Images + +```bash +# Build all targets +docker build -t stegasoo-web --target web . +docker build -t stegasoo-api --target api . +docker build -t stegasoo-cli --target cli . +``` + +#### Run Web UI + +```bash +docker run -d \ + --name stegasoo-web \ + -p 5000:5000 \ + --memory=768m \ + stegasoo-web + +# Visit http://localhost:5000 +``` + +#### Run REST API + +```bash +docker run -d \ + --name stegasoo-api \ + -p 8000:8000 \ + --memory=768m \ + stegasoo-api + +# Docs at http://localhost:8000/docs +``` + +#### Run CLI + +```bash +# Interactive shell +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 + +# With volume for files +docker run --rm \ + -v $(pwd)/images:/data \ + stegasoo-cli encode \ + -r /data/ref.jpg \ + -c /data/carrier.png \ + -p "phrase words here" \ + --pin 123456 \ + -m "Secret message" \ + -o /data/stego.png +``` + +### Docker Compose + +The easiest way to run all services. + +#### Start All Services + +```bash +# Start in background +docker-compose up -d + +# Start specific service +docker-compose up -d web +docker-compose up -d api + +# View logs +docker-compose logs -f + +# Stop all +docker-compose down +``` + +#### Services + +| Service | URL | Description | +|---------|-----|-------------| +| `web` | http://localhost:5000 | Flask Web UI | +| `api` | http://localhost:8000 | FastAPI REST API | + +#### Build and Start + +```bash +# Build images and start +docker-compose up -d --build + +# Force rebuild (no cache) +docker-compose build --no-cache +docker-compose up -d +``` + +#### Resource Configuration + +The `docker-compose.yml` includes resource limits: + +```yaml +services: + web: + deploy: + resources: + limits: + memory: 768M # For Argon2 + scipy + reservations: + memory: 384M +``` + +Adjust based on your available RAM: + +| Available RAM | Recommended Limit | Workers | +|---------------|-------------------|---------| +| 2 GB | 768M | 2 | +| 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 + +### DCT Steganography (scipy + jpegio) + +DCT mode enables JPEG-resilient steganography. It's automatically included with `[web]`, `[api]`, and `[all]` extras. + +#### Install via pip + +```bash +# scipy is straightforward +pip install scipy + +# jpegio - try pip first +pip install jpegio + +# If pip fails, build from source +pip install cython numpy +git clone https://github.com/dwgoon/jpegio.git +cd jpegio +python setup.py install +``` + +#### Linux Build Dependencies + +```bash +sudo apt-get install -y \ + build-essential \ + python3-dev \ + libjpeg-dev \ + cython3 +``` + +#### macOS Build Dependencies + +```bash +brew install jpeg cython +``` + +#### Verify DCT Support + +```python +from stegasoo import has_dct_support +from stegasoo.dct_steganography import has_jpegio_support + +print(f"DCT support (scipy): {has_dct_support()}") +print(f"JPEG native (jpegio): {has_jpegio_support()}") +``` + +Expected output: +``` +DCT support (scipy): True +JPEG native (jpegio): True +``` + +### Compression (lz4) + +Optional LZ4 compression for messages: + +```bash +pip install lz4 +``` + +--- + +## Platform-Specific Notes + +### Linux + +Most straightforward installation. Use your package manager for system dependencies. + +**Ubuntu/Debian:** +```bash +sudo apt-get install python3-dev libzbar0 libjpeg-dev +pip install stegasoo[all] +``` + +**Fedora/RHEL:** +```bash +sudo dnf install python3-devel zbar libjpeg-devel +pip install stegasoo[all] +``` + +**Arch:** +```bash +sudo pacman -S python zbar libjpeg-turbo +pip install stegasoo[all] +``` + +### macOS + +```bash +# Install Homebrew if needed +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +# Install dependencies +brew install python@3.11 zbar jpeg + +# Install Stegasoo +pip3 install stegasoo[all] +``` + +**Apple Silicon (M1/M2/M3):** + +jpegio may need Rosetta or native compilation: +```bash +# Try native first +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) +2. Install Visual Studio Build Tools +3. Install from pip: + +```powershell +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): + +```bash +# System dependencies +sudo apt-get install python3-dev libzbar0 libjpeg-dev + +# Install (may take a while to compile) +pip install stegasoo[cli] + +# For web/api, ensure enough RAM +pip install stegasoo[web] # Needs ~768MB free +``` + +**Note:** Argon2 operations will be slower on Pi due to memory-hardness. + +--- + +## Verification + +### Check Installation + +```bash +# CLI version +stegasoo --version + +# Python import +python -c "import stegasoo; print(stegasoo.__version__)" +``` + +### Check All Features + +```python +#!/usr/bin/env python3 +"""Verify Stegasoo installation.""" + +def check_feature(name, check_fn): + try: + result = check_fn() + status = "✓" if result else "✗" + print(f" {status} {name}: {result}") + return result + except Exception as e: + print(f" ✗ {name}: Error - {e}") + return False + +print("Stegasoo Installation Check") +print("=" * 40) + +# Core +import stegasoo +print(f"\nVersion: {stegasoo.__version__}") + +print("\nCore Features:") +check_feature("Argon2", lambda: stegasoo.has_argon2()) +check_feature("Pillow", lambda: True) # Required, would fail import + +print("\nOptional Features:") +check_feature("DCT (scipy)", stegasoo.has_dct_support) + +try: + from stegasoo.dct_steganography import has_jpegio_support + check_feature("JPEG native (jpegio)", has_jpegio_support) +except ImportError: + print(" ✗ JPEG native (jpegio): Not installed") + +try: + import lz4 + check_feature("Compression (lz4)", lambda: True) +except ImportError: + print(" - Compression (lz4): Not installed (optional)") + +try: + import pyzbar + check_feature("QR codes (pyzbar)", lambda: True) +except ImportError: + print(" - QR codes (pyzbar): Not installed (optional)") + +print("\nInterfaces:") +try: + import click + check_feature("CLI", lambda: True) +except ImportError: + print(" ✗ CLI: Not installed") + +try: + import flask + check_feature("Web UI", lambda: True) +except ImportError: + print(" - Web UI: Not installed") + +try: + import fastapi + check_feature("REST API", lambda: True) +except ImportError: + print(" - REST API: Not installed") + +print("\n" + "=" * 40) +print("Installation check complete!") +``` + +Save as `check_install.py` and run: +```bash +python check_install.py +``` + +### Test Encoding/Decoding + +```bash +# Quick test with CLI +stegasoo generate --pin --words 3 --json > /tmp/creds.json + +# Create test image +python -c " +from PIL import Image +img = Image.new('RGB', (256, 256), 'blue') +img.save('/tmp/test_carrier.png') +img.save('/tmp/test_ref.jpg') +" + +# Encode +stegasoo encode \ + -r /tmp/test_ref.jpg \ + -c /tmp/test_carrier.png \ + -p "test phrase words" \ + --pin 123456 \ + -m "Hello, Stegasoo!" \ + -o /tmp/test_stego.png + +# Decode +stegasoo decode \ + -r /tmp/test_ref.jpg \ + -s /tmp/test_stego.png \ + -p "test phrase words" \ + --pin 123456 +``` + +--- + +## Troubleshooting + +### Common Issues + +#### "No module named 'stegasoo'" + +```bash +# Ensure you're in the right environment +which python +pip list | grep stegasoo + +# Reinstall +pip install -e ".[all]" +``` + +#### "Argon2 not available" + +```bash +# Install argon2-cffi +pip install argon2-cffi + +# On Linux, may need: +sudo apt-get install libffi-dev +pip install --force-reinstall argon2-cffi +``` + +#### "jpegio not available" / DCT JPEG fails + +```bash +# Install build dependencies first +sudo apt-get install libjpeg-dev # Linux +brew install jpeg # macOS + +# Then install jpegio +pip install cython numpy +pip install jpegio + +# If still fails, build from source +git clone https://github.com/dwgoon/jpegio.git +cd jpegio +python setup.py install +``` + +#### "libzbar not found" (QR codes) + +```bash +# Linux +sudo apt-get install libzbar0 + +# macOS +brew install zbar + +# Then reinstall pyzbar +pip install --force-reinstall pyzbar +``` + +#### Docker: "Cannot allocate memory" + +Argon2 needs 256MB per operation. Increase container memory: + +```bash +# Docker run +docker run --memory=768m ... + +# Docker Compose - edit docker-compose.yml +deploy: + resources: + limits: + 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 + +#### "Carrier image too small" + +- LSB needs ~3 bits per pixel +- DCT needs ~0.25 bits per pixel +- For 50KB message: LSB needs ~136K pixels, DCT needs ~1.6M pixels +- Use larger carrier images or shorter messages + +### Getting Help + +1. Check the documentation: + - [README.md](README.md) + - [CLI.md](CLI.md) + - [API.md](API.md) + - [WEB_UI.md](WEB_UI.md) + +2. Check existing issues on GitHub + +3. Open a new issue with: + - Python version (`python --version`) + - OS and version + - Installation method + - Full error message + - Steps to reproduce + +--- + +## Next Steps + +After installation: + +1. **Generate credentials**: `stegasoo generate --pin --words 3` +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` + +Happy steganography! 🦕 diff --git a/README.md b/README.md index bf5ca1c..0fbfc54 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A secure steganography system for hiding encrypted messages in images using hybr ![Python](https://img.shields.io/badge/Python-3.10+-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) ## Features @@ -17,50 +18,40 @@ A secure steganography system for hiding encrypted messages in images using hybr - 🌐 **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+) +## What's New in v3.0.2 + +| 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 | + +### 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 | ## WebUI Preview -Front Page | Encode | Decode | Generate | -:-------------------------:|:-------------------------:|:------------------------:|:--------:| -![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI.webp) | ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI_Encode.webp) | ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI_Decode.webp) | ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI_Generate.webp) +| Front Page | Encode | Decode | Generate | +|:----------:|:------:|:------:|:--------:| +| ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI.webp) | ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI_Encode.webp) | ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI_Decode.webp) | ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI_Generate.webp) | - -## Installation - -### From Source +## Quick Start ```bash -# Clone the repository -git clone https://github.com/adlee-was-taken/stegasoo.git -cd stegasoo - -# Install core library -pip install -e . - -# Install with CLI -pip install -e ".[cli]" - -# Install with Web UI -pip install -e ".[web]" - -# Install with REST API -pip install -e ".[api]" - -# Install everything +# Install with all features pip install -e ".[all]" -``` -### CLI Usage - -```bash -# Generate credentials +# Generate credentials (memorize these!) stegasoo generate --pin --words 3 -# With RSA key -stegasoo generate --rsa --rsa-bits 4096 -o mykey.pem -p "secretpassword" - -# Encode +# Encode a message (LSB mode - default) stegasoo encode \ --ref photo.jpg \ --carrier meme.png \ @@ -68,88 +59,171 @@ stegasoo encode \ --pin 123456 \ --message "Secret message" -# Decode +# Encode for social media (DCT mode) +stegasoo encode \ + --ref photo.jpg \ + --carrier meme.png \ + --phrase "apple forest thunder" \ + --pin 123456 \ + --message "Secret message" \ + --mode dct \ + --format jpeg + +# Decode (auto-detects mode) stegasoo decode \ --ref photo.jpg \ --stego stego.png \ --phrase "apple forest thunder" \ --pin 123456 - -# Pipe-friendly -echo "secret" | stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 > stego.png -stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -q ``` -### Web UI +For detailed installation instructions, see **[INSTALL.md](INSTALL.md)**. -```bash -# Development -cd frontends/web -python app.py - -# Production -gunicorn --bind 0.0.0.0:5000 app:app -``` - -Visit http://localhost:5000 - -### REST API - -```bash -# Development -cd frontends/api -python main.py - -# Production -uvicorn main:app --host 0.0.0.0 --port 8000 -``` - -API docs at http://localhost:8000/docs - -#### Example API Calls - -```bash -# Generate credentials -curl -X POST http://localhost:8000/generate \ - -H "Content-Type: application/json" \ - -d '{"use_pin": true, "use_rsa": false}' - -# Encode (multipart) -curl -X POST http://localhost:8000/encode/multipart \ - -F "message=Secret" \ - -F "day_phrase=apple forest thunder" \ - -F "pin=123456" \ - -F "reference_photo=@photo.jpg" \ - -F "carrier=@meme.png" \ - --output stego.png - -# Decode (multipart) -curl -X POST http://localhost:8000/decode/multipart \ - -F "day_phrase=apple forest thunder" \ - -F "pin=123456" \ - -F "reference_photo=@photo.jpg" \ - -F "stego_image=@stego.png" -``` +--- ## Security Model +Stegasoo uses multiple authentication factors combined with strong cryptography: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ AUTHENTICATION LAYERS │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Reference Photo ──┐ │ +│ (~80-256 bits) │ │ +│ ├──► Argon2id KDF ──► AES-256-GCM Key │ +│ Day Phrase ───────┤ (256MB RAM) │ +│ (~33-132 bits) │ │ +│ │ │ +│ Static PIN ───────┤ │ +│ (~20-30 bits) │ │ +│ │ │ +│ RSA Key ──────────┘ │ +│ (~128 bits) (optional, adds another factor) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Entropy Summary + | Component | Entropy | Purpose | |-----------|---------|---------| | Reference Photo | ~80-256 bits | Something you have | -| Day Phrase (3-12 words) | ~33-100+ bits | Something you know (rotates daily) | -| PIN (6-9 digits) | ~20+ bits | Something you know (static) | -| RSA Key (2048-bit) | ~128 bits | Something you have | -| **Combined** | **~133-400+ bits** | **Beyond brute force** | +| Day Phrase (3-12 words) | ~33-132 bits | Something you know (rotates daily) | +| PIN (6-9 digits) | ~20-30 bits | Something you know (static) | +| RSA Key (2048-4096 bit) | ~112-128 bits | Something you have (optional) | +| **Combined** | **133-400+ bits** | **Beyond brute force** | ### Attack Resistance | Attack | Protection | |--------|------------| -| Brute force | 2^133+ combinations | -| Rainbow tables | Random salt per message | -| Steganalysis | Random pixel selection | +| Brute force | 2^133+ combinations minimum | +| Rainbow tables | Random 16-byte salt per message | +| Steganalysis | Pseudo-random pixel/coefficient selection | | GPU cracking | Argon2id requires 256MB RAM per attempt | -| Side-channel | Constant-time operations in crypto | +| Side-channel | Constant-time operations in cryptography library | +| JPEG recompression | DCT mode embeds in frequency domain (v3.0+) | + +### 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 | + +--- + +## Interfaces + +### Command-Line Interface (CLI) + +Full-featured CLI with piping support: + +```bash +# Generate with RSA key +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 + +# Encode for social media (DCT + JPEG) +stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 \ + -m "Message" --mode dct --format jpeg + +# Decode to stdout (quiet mode) +stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q + +# Check image capacity (shows both LSB and DCT) +stegasoo info carrier.png +``` + +📖 Full documentation: **[CLI.md](CLI.md)** + +### Web UI + +Browser-based interface with drag-and-drop uploads: + +```bash +# Start the server +cd frontends/web +python app.py +# Visit http://localhost:5000 +``` + +Features: +- Drag-and-drop image uploads +- Real-time entropy calculator +- Native mobile sharing (Web Share API) +- DCT mode with advanced options panel +- Automatic day-of-week detection + +📖 Full documentation: **[WEB_UI.md](WEB_UI.md)** + +### REST API + +FastAPI-powered REST API with OpenAPI documentation: + +```bash +# Start the server +cd frontends/api +uvicorn main:app --host 0.0.0.0 --port 8000 +# Docs at http://localhost:8000/docs +``` + +Example API calls: + +```bash +# Generate credentials +curl -X POST http://localhost:8000/generate \ + -H "Content-Type: application/json" \ + -d '{"use_pin": true, "words_per_phrase": 3}' + +# Encode with DCT mode +curl -X POST http://localhost:8000/encode/multipart \ + -F "message=Secret" \ + -F "day_phrase=apple forest thunder" \ + -F "pin=123456" \ + -F "embedding_mode=dct" \ + -F "output_format=jpeg" \ + -F "reference_photo=@photo.jpg" \ + -F "carrier=@meme.png" \ + --output stego.jpg + +# Decode (auto-detects mode) +curl -X POST http://localhost:8000/decode/multipart \ + -F "day_phrase=apple forest thunder" \ + -F "pin=123456" \ + -F "reference_photo=@photo.jpg" \ + -F "stego_image=@stego.jpg" +``` + +📖 Full documentation: **[API.md](API.md)** + +--- ## Project Structure @@ -159,11 +233,13 @@ stegasoo/ │ ├── __init__.py # Public API │ ├── constants.py # Configuration │ ├── crypto.py # Encryption/decryption -│ ├── steganography.py # Image embedding +│ ├── steganography.py # LSB image embedding +│ ├── dct_steganography.py # DCT embedding (v3.0+) │ ├── keygen.py # Credential generation │ ├── validation.py # Input validation │ ├── models.py # Data classes │ ├── exceptions.py # Custom exceptions +│ ├── qr_utils.py # QR code utilities │ └── utils.py # Utilities │ ├── frontends/ @@ -176,18 +252,19 @@ stegasoo/ │ ├── pyproject.toml # Package configuration ├── Dockerfile # Multi-stage Docker build -└── docker-compose.yml # Container orchestration +├── docker-compose.yml # Container orchestration +│ +├── README.md # This file +├── INSTALL.md # Installation guide +├── CLI.md # CLI documentation +├── API.md # API documentation +└── WEB_UI.md # Web UI documentation ``` +--- + ## Configuration -### Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `FLASK_ENV` | production | Flask environment | -| `PYTHONPATH` | - | Include src/ for development | - ### Limits | Limit | Value | @@ -199,6 +276,15 @@ stegasoo/ | Phrase length | 3-12 words | | RSA key sizes | 2048, 3072, 4096 bits | +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `FLASK_ENV` | production | Flask environment | +| `PYTHONPATH` | - | Include `src/` for development | + +--- + ## Development ```bash @@ -214,13 +300,42 @@ ruff check src/ frontends/ # Type checking 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())" ``` +--- + +## Version History + +| 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 | +| **2.2.x** | QR code support, file embedding | +| **2.0.x** | Web UI, REST API, RSA keys | +| **1.0.x** | Initial release, CLI only | + +--- + ## License MIT License - Use responsibly. +--- + ## ⚠️ Disclaimer This tool is for educational and legitimate privacy purposes only. Users are responsible for complying with applicable laws in their jurisdiction. +--- + +## See Also + +- **[INSTALL.md](INSTALL.md)** - Detailed installation instructions +- **[CLI.md](CLI.md)** - Command-line interface reference +- **[API.md](API.md)** - REST API documentation +- **[WEB_UI.md](WEB_UI.md)** - Web interface guide diff --git a/docker-compose.yml b/docker-compose.yml index f3055ba..eca2961 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,9 +17,9 @@ services: deploy: resources: limits: - memory: 512M # Argon2 needs 256MB per operation + memory: 768M # Increased for scipy + Argon2 reservations: - memory: 256M + memory: 384M # ============================================================================ # REST API (FastAPI) @@ -35,9 +35,9 @@ services: deploy: resources: limits: - memory: 512M + memory: 768M # Increased for scipy + Argon2 reservations: - memory: 256M + memory: 384M # ============================================================================ # Nginx Reverse Proxy (optional, for production) diff --git a/frontends/API.md b/frontends/API.md index 6ef0170..667ab6c 100644 --- a/frontends/API.md +++ b/frontends/API.md @@ -16,6 +16,7 @@ Complete REST API reference for Stegasoo steganography operations. - [POST /decode](#post-decode-json) - [POST /decode/multipart](#post-decodemultipart) - [POST /image/info](#post-imageinfo) +- [Embedding Modes](#embedding-modes) - [Data Models](#data-models) - [Error Handling](#error-handling) - [Code Examples](#code-examples) @@ -29,12 +30,19 @@ Complete REST API reference for Stegasoo steganography operations. The Stegasoo REST API provides programmatic access to all steganography operations: - **Generate** credentials (phrases, PINs, RSA keys) -- **Encode** messages into images -- **Decode** messages from images +- **Encode** messages into images (LSB or DCT mode) +- **Decode** messages from images (auto-detects mode) - **Analyze** image capacity The API supports both JSON (base64-encoded images) and multipart form data (direct file uploads). +### What's New in v3.0.2 + +- **DCT Steganography Mode** - JPEG-resilient embedding +- **Output Format Selection** - PNG or JPEG output +- **Color Mode Selection** - Color or grayscale processing +- **jpegio Integration** - Proper JPEG coefficient manipulation + --- ## Installation @@ -45,6 +53,8 @@ The API supports both JSON (base64-encoded images) and multipart form data (dire pip install stegasoo[api] ``` +This automatically installs DCT dependencies (scipy, jpegio) for full functionality. + ### From Source ```bash @@ -107,8 +117,10 @@ Host: localhost:8000 ```json { - "version": "2.0.1", + "version": "3.0.2", "has_argon2": true, + "has_dct": true, + "has_jpegio": true, "day_names": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] } ``` @@ -119,6 +131,8 @@ Host: localhost:8000 |-------|------|-------------| | `version` | string | Stegasoo library version | | `has_argon2` | boolean | Whether Argon2id is available | +| `has_dct` | boolean | Whether DCT mode is available (scipy) | +| `has_jpegio` | boolean | Whether native JPEG DCT is available | | `day_names` | array | Day names for phrase mapping | #### cURL Example @@ -245,22 +259,28 @@ Content-Type: application/json "pin": "123456", "rsa_key_base64": null, "rsa_password": null, - "date_str": null + "date_str": null, + "embedding_mode": "lsb", + "output_format": "png", + "color_mode": "color" } ``` #### Request Body -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `message` | string | ✓ | Message to encode | -| `reference_photo_base64` | string | ✓ | Base64-encoded reference photo | -| `carrier_image_base64` | string | ✓ | Base64-encoded carrier image | -| `day_phrase` | string | ✓ | Today's passphrase | -| `pin` | string | * | Static PIN (6-9 digits) | -| `rsa_key_base64` | string | * | Base64-encoded RSA key PEM | -| `rsa_password` | string | | Password for RSA key | -| `date_str` | string | | Date override (YYYY-MM-DD) | +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `message` | string | ✓ | | Message to encode | +| `reference_photo_base64` | string | ✓ | | Base64-encoded reference photo | +| `carrier_image_base64` | string | ✓ | | Base64-encoded carrier image | +| `day_phrase` | string | ✓ | | Today's passphrase | +| `pin` | string | * | | Static PIN (6-9 digits) | +| `rsa_key_base64` | string | * | | Base64-encoded RSA key PEM | +| `rsa_password` | string | | | Password for RSA key | +| `date_str` | string | | | Date override (YYYY-MM-DD) | +| `embedding_mode` | string | | `"lsb"` | `"lsb"` or `"dct"` | +| `output_format` | string | | `"png"` | `"png"` or `"jpeg"` (DCT only) | +| `color_mode` | string | | `"color"` | `"color"` or `"grayscale"` (DCT only) | \* At least one of `pin` or `rsa_key_base64` required. @@ -272,7 +292,10 @@ Content-Type: application/json "filename": "a1b2c3d4_20251227.png", "capacity_used_percent": 12.4, "date_used": "2025-12-27", - "day_of_week": "Saturday" + "day_of_week": "Saturday", + "embedding_mode": "lsb", + "output_format": "png", + "color_mode": null } ``` @@ -280,13 +303,16 @@ Content-Type: application/json | Field | Type | Description | |-------|------|-------------| -| `stego_image_base64` | string | Base64-encoded stego PNG | +| `stego_image_base64` | string | Base64-encoded stego image | | `filename` | string | Suggested filename | | `capacity_used_percent` | float | Percentage of capacity used | | `date_used` | string | Date embedded in image (YYYY-MM-DD) | | `day_of_week` | string | Day name for passphrase rotation | +| `embedding_mode` | string | Mode used: `"lsb"` or `"dct"` | +| `output_format` | string | Output format: `"png"` or `"jpeg"` | +| `color_mode` | string\|null | Color mode (DCT only): `"color"` or `"grayscale"` | -#### cURL Example +#### cURL Example (LSB Mode - Default) ```bash # Prepare base64-encoded images @@ -304,6 +330,23 @@ curl -X POST http://localhost:8000/encode \ }" | jq -r '.stego_image_base64' | base64 -d > stego.png ``` +#### cURL Example (DCT Mode with JPEG Output) + +```bash +curl -X POST http://localhost:8000/encode \ + -H "Content-Type: application/json" \ + -d "{ + \"message\": \"Secret message\", + \"reference_photo_base64\": \"$REF_B64\", + \"carrier_image_base64\": \"$CARRIER_B64\", + \"day_phrase\": \"apple forest thunder\", + \"pin\": \"123456\", + \"embedding_mode\": \"dct\", + \"output_format\": \"jpeg\", + \"color_mode\": \"color\" + }" | jq -r '.stego_image_base64' | base64 -d > stego.jpg +``` + --- ### POST /encode/multipart @@ -330,6 +373,18 @@ Content-Disposition: form-data; name="pin" 123456 ------FormBoundary +Content-Disposition: form-data; name="embedding_mode" + +dct +------FormBoundary +Content-Disposition: form-data; name="output_format" + +jpeg +------FormBoundary +Content-Disposition: form-data; name="color_mode" + +color +------FormBoundary Content-Disposition: form-data; name="reference_photo"; filename="ref.jpg" Content-Type: image/jpeg @@ -344,83 +399,72 @@ Content-Type: image/png #### Form Fields -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `message` | string | ✓ | Message to encode | -| `reference_photo` | file | ✓ | Reference photo file | -| `carrier` | file | ✓ | Carrier image file | -| `day_phrase` | string | ✓ | Today's passphrase | -| `pin` | string | * | Static PIN | -| `rsa_key` | file | * | RSA key file (.pem) | -| `rsa_password` | string | | Password for RSA key | -| `date_str` | string | | Date override (YYYY-MM-DD) | +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `message` | string | ✓ | | Message to encode | +| `reference_photo` | file | ✓ | | Reference photo file | +| `carrier` | file | ✓ | | Carrier image file | +| `day_phrase` | string | ✓ | | Today's passphrase | +| `pin` | string | * | | Static PIN | +| `rsa_key` | file | * | | RSA key file (.pem) | +| `rsa_password` | string | | | Password for RSA key | +| `date_str` | string | | | Date override (YYYY-MM-DD) | +| `embedding_mode` | string | | `"lsb"` | `"lsb"` or `"dct"` | +| `output_format` | string | | `"png"` | `"png"` or `"jpeg"` (DCT only) | +| `color_mode` | string | | `"color"` | `"color"` or `"grayscale"` (DCT only) | \* At least one of `pin` or `rsa_key` required. #### Response -Returns the PNG image directly with headers: -- `Content-Type: image/png` -- `Content-Disposition: attachment; filename=.png` -- `X-Stegasoo-Date: 2025-12-27` (date used for encoding) -- `X-Stegasoo-Day: Saturday` (day of week for passphrase rotation) -- `X-Stegasoo-Capacity-Percent: 12.4` (capacity used) +Returns the image directly with headers: -#### cURL Examples +```http +HTTP/1.1 200 OK +Content-Type: image/png +Content-Disposition: attachment; filename="a1b2c3d4_20251227.png" +X-Stegasoo-Date: 2025-12-27 +X-Stegasoo-Day: Saturday +X-Stegasoo-Capacity-Used: 12.4 +X-Stegasoo-Embedding-Mode: lsb +X-Stegasoo-Output-Format: png -**With PIN:** -```bash -curl -X POST http://localhost:8000/encode/multipart \ - -F "message=Secret message" \ - -F "day_phrase=apple forest thunder" \ - -F "pin=123456" \ - -F "reference_photo=@reference.jpg" \ - -F "carrier=@carrier.png" \ - --output stego.png + ``` -**With RSA key:** -```bash -curl -X POST http://localhost:8000/encode/multipart \ - -F "message=Secret message" \ - -F "day_phrase=apple forest thunder" \ - -F "rsa_key=@mykey.pem" \ - -F "rsa_password=keypassword" \ - -F "reference_photo=@reference.jpg" \ - -F "carrier=@carrier.png" \ - --output stego.png -``` +#### Response Headers + +| Header | Description | +|--------|-------------| +| `Content-Type` | `image/png` or `image/jpeg` | +| `Content-Disposition` | Suggested filename | +| `X-Stegasoo-Date` | Encoding date | +| `X-Stegasoo-Day` | Day of week | +| `X-Stegasoo-Capacity-Used` | Capacity percentage | +| `X-Stegasoo-Embedding-Mode` | `lsb` or `dct` | +| `X-Stegasoo-Output-Format` | `png` or `jpeg` | +| `X-Stegasoo-Color-Mode` | `color` or `grayscale` (DCT only) | + +#### cURL Example (DCT + JPEG) -**With both PIN and RSA:** ```bash curl -X POST http://localhost:8000/encode/multipart \ - -F "message=Maximum security message" \ + -F "message=Secret message for social media" \ -F "day_phrase=apple forest thunder" \ -F "pin=123456" \ - -F "rsa_key=@mykey.pem" \ - -F "rsa_password=keypassword" \ + -F "embedding_mode=dct" \ + -F "output_format=jpeg" \ + -F "color_mode=color" \ -F "reference_photo=@reference.jpg" \ -F "carrier=@carrier.png" \ - --output stego.png -``` - -**With custom date:** -```bash -curl -X POST http://localhost:8000/encode/multipart \ - -F "message=Backdated message" \ - -F "day_phrase=monday phrase here" \ - -F "pin=123456" \ - -F "date_str=2025-12-29" \ - -F "reference_photo=@reference.jpg" \ - -F "carrier=@carrier.png" \ - --output stego.png + --output stego.jpg ``` --- ### POST /decode (JSON) -Decode a message using base64-encoded images. +Decode a message using base64-encoded images. Auto-detects embedding mode. #### Request @@ -450,20 +494,27 @@ Content-Type: application/json | `rsa_key_base64` | string | * | Base64-encoded RSA key | | `rsa_password` | string | | Password for RSA key | -\* Must match the security factors used during encoding. +\* Must match security factors used during encoding. #### Response ```json { - "message": "Secret message here" + "message": "Secret message here", + "embedding_mode_detected": "dct" } ``` +#### Response Fields + +| Field | Type | Description | +|-------|------|-------------| +| `message` | string | Decoded message | +| `embedding_mode_detected` | string | Detected mode: `"lsb"` or `"dct"` | + #### cURL Example ```bash -# Prepare base64-encoded images STEGO_B64=$(base64 -w0 stego.png) REF_B64=$(base64 -w0 reference.jpg) @@ -481,15 +532,7 @@ curl -X POST http://localhost:8000/decode \ ### POST /decode/multipart -Decode a message using direct file uploads. - -#### Request - -```http -POST /decode/multipart HTTP/1.1 -Host: localhost:8000 -Content-Type: multipart/form-data -``` +Decode using direct file uploads. Auto-detects embedding mode. #### Form Fields @@ -499,20 +542,20 @@ Content-Type: multipart/form-data | `reference_photo` | file | ✓ | Reference photo file | | `day_phrase` | string | ✓ | Passphrase for encoding day | | `pin` | string | * | Static PIN | -| `rsa_key` | file | * | RSA key file | +| `rsa_key` | file | * | RSA key file (.pem) | | `rsa_password` | string | | Password for RSA key | #### Response ```json { - "message": "Secret message here" + "message": "Secret message here", + "embedding_mode_detected": "lsb" } ``` -#### cURL Examples +#### cURL Example -**With PIN:** ```bash curl -X POST http://localhost:8000/decode/multipart \ -F "day_phrase=apple forest thunder" \ @@ -521,35 +564,30 @@ curl -X POST http://localhost:8000/decode/multipart \ -F "stego_image=@stego.png" ``` -**With RSA key:** -```bash -curl -X POST http://localhost:8000/decode/multipart \ - -F "day_phrase=apple forest thunder" \ - -F "rsa_key=@mykey.pem" \ - -F "rsa_password=keypassword" \ - -F "reference_photo=@reference.jpg" \ - -F "stego_image=@stego.png" -``` - --- ### POST /image/info -Get information about an image's capacity. +Get image information and capacity for both LSB and DCT modes. -#### Request +#### Request (JSON) ```http POST /image/info HTTP/1.1 Host: localhost:8000 -Content-Type: multipart/form-data +Content-Type: application/json + +{ + "image_base64": "iVBORw0KGgo..." +} ``` -#### Form Fields +#### Request (Multipart) -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `image` | file | ✓ | Image file to analyze | +```bash +curl -X POST http://localhost:8000/image/info \ + -F "image=@carrier.png" +``` #### Response @@ -558,8 +596,19 @@ Content-Type: multipart/form-data "width": 1920, "height": 1080, "pixels": 2073600, - "capacity_bytes": 776970, - "capacity_kb": 758 + "format": "PNG", + "mode": "RGB", + "capacity": { + "lsb": { + "bytes": 776970, + "kb": 758 + }, + "dct": { + "bytes": 64800, + "kb": 63, + "note": "Approximate - actual capacity depends on image content" + } + } } ``` @@ -570,15 +619,58 @@ Content-Type: multipart/form-data | `width` | integer | Image width in pixels | | `height` | integer | Image height in pixels | | `pixels` | integer | Total pixel count | -| `capacity_bytes` | integer | Maximum message capacity (bytes) | -| `capacity_kb` | integer | Maximum message capacity (KB) | +| `format` | string | Image format (PNG, JPEG, etc.) | +| `mode` | string | Color mode (RGB, L, etc.) | +| `capacity.lsb.bytes` | integer | LSB capacity in bytes | +| `capacity.lsb.kb` | integer | LSB capacity in KB | +| `capacity.dct.bytes` | integer | Estimated DCT capacity in bytes | +| `capacity.dct.kb` | integer | Estimated DCT capacity in KB | +| `capacity.dct.note` | string | Capacity estimation note | -#### cURL Example +--- -```bash -curl -X POST http://localhost:8000/image/info \ - -F "image=@myimage.png" -``` +## Embedding Modes + +### LSB Mode (Default) + +**Least Significant Bit** embedding modifies pixel values directly. + +| Aspect | Details | +|--------|---------| +| **Parameter** | `"embedding_mode": "lsb"` | +| **Capacity** | ~3 bits/pixel (~770 KB for 1920×1080) | +| **Output** | PNG only (lossless required) | +| **Resilience** | ❌ Destroyed by JPEG compression | +| **Best For** | Maximum capacity, controlled channels | + +### DCT Mode (Experimental) + +**Discrete Cosine Transform** embedding hides data in frequency coefficients. + +| Aspect | Details | +|--------|---------| +| **Parameter** | `"embedding_mode": "dct"` | +| **Capacity** | ~0.25 bits/pixel (~65 KB for 1920×1080) | +| **Output** | PNG or JPEG | +| **Resilience** | ✅ Survives JPEG compression | +| **Best For** | Social media, messaging apps | + +> ⚠️ **Experimental**: DCT mode may have edge cases. Test with your workflow. + +### DCT Options + +| Option | Values | Default | Description | +|--------|--------|---------|-------------| +| `output_format` | `"png"`, `"jpeg"` | `"png"` | Output image format | +| `color_mode` | `"color"`, `"grayscale"` | `"color"` | Color processing mode | + +### Capacity Comparison + +| Mode | 1920×1080 Capacity | +|------|-------------------| +| LSB (PNG) | ~770 KB | +| DCT (PNG) | ~65 KB | +| DCT (JPEG) | ~30-50 KB | --- @@ -618,7 +710,10 @@ curl -X POST http://localhost:8000/image/info \ "pin": "string", "rsa_key_base64": "string", "rsa_password": "string", - "date_str": "YYYY-MM-DD" + "date_str": "YYYY-MM-DD", + "embedding_mode": "lsb", + "output_format": "png", + "color_mode": "color" } ``` @@ -630,7 +725,10 @@ curl -X POST http://localhost:8000/image/info \ "filename": "string", "capacity_used_percent": 12.4, "date_used": "YYYY-MM-DD", - "day_of_week": "Saturday" + "day_of_week": "Saturday", + "embedding_mode": "lsb", + "output_format": "png", + "color_mode": null } ``` @@ -651,7 +749,8 @@ curl -X POST http://localhost:8000/image/info \ ```json { - "message": "string" + "message": "string", + "embedding_mode_detected": "lsb" } ``` @@ -662,8 +761,12 @@ curl -X POST http://localhost:8000/image/info \ "width": 1920, "height": 1080, "pixels": 2073600, - "capacity_bytes": 776970, - "capacity_kb": 758 + "format": "PNG", + "mode": "RGB", + "capacity": { + "lsb": {"bytes": 776970, "kb": 758}, + "dct": {"bytes": 64800, "kb": 63, "note": "..."} + } } ``` @@ -705,8 +808,11 @@ curl -X POST http://localhost:8000/image/info \ | 400 | "rsa_bits must be one of [2048, 3072, 4096]" | Use valid RSA key size | | 400 | "Carrier image too small" | Use larger carrier image | | 400 | "PIN must be 6-9 digits" | Fix PIN format | +| 400 | "Invalid embedding_mode" | Use `"lsb"` or `"dct"` | +| 400 | "output_format 'jpeg' requires embedding_mode 'dct'" | Use DCT mode for JPEG | +| 400 | "Message too long for DCT capacity" | Reduce message or use LSB | | 401 | "Decryption failed. Check credentials." | Verify phrase, PIN, ref photo | -| 400 | "Message too long" | Reduce message size or use larger carrier | +| 401 | "Invalid or missing Stegasoo header" | Wrong mode or corrupted image | --- @@ -730,7 +836,7 @@ creds = response.json() print(f"PIN: {creds['pin']}") print(f"Monday phrase: {creds['phrases']['Monday']}") -# Encode using multipart +# Encode using multipart (LSB mode - default) with open("reference.jpg", "rb") as ref, open("carrier.png", "rb") as carrier: response = requests.post(f"{BASE_URL}/encode/multipart", files={ "reference_photo": ref, @@ -744,7 +850,24 @@ with open("reference.jpg", "rb") as ref, open("carrier.png", "rb") as carrier: with open("stego.png", "wb") as f: f.write(response.content) -# Decode using multipart +# Encode using DCT mode for social media +with open("reference.jpg", "rb") as ref, open("carrier.png", "rb") as carrier: + response = requests.post(f"{BASE_URL}/encode/multipart", files={ + "reference_photo": ref, + "carrier": carrier, + }, data={ + "message": "Secret message for Instagram", + "day_phrase": "apple forest thunder", + "pin": "123456", + "embedding_mode": "dct", + "output_format": "jpeg", + "color_mode": "color" + }) + + with open("stego_social.jpg", "wb") as f: + f.write(response.content) + +# Decode using multipart (auto-detects mode) with open("reference.jpg", "rb") as ref, open("stego.png", "rb") as stego: response = requests.post(f"{BASE_URL}/decode/multipart", files={ "reference_photo": ref, @@ -754,7 +877,9 @@ with open("reference.jpg", "rb") as ref, open("stego.png", "rb") as stego: "pin": "123456" }) - print(f"Decoded: {response.json()['message']}") + result = response.json() + print(f"Decoded: {result['message']}") + print(f"Mode detected: {result['embedding_mode_detected']}") ``` ### JavaScript/Node.js @@ -766,11 +891,14 @@ const axios = require('axios'); const BASE_URL = 'http://localhost:8000'; -async function encode() { +async function encodeDCT() { const form = new FormData(); - form.append('message', 'Secret message'); + form.append('message', 'Secret message for social media'); form.append('day_phrase', 'apple forest thunder'); form.append('pin', '123456'); + form.append('embedding_mode', 'dct'); + form.append('output_format', 'jpeg'); + form.append('color_mode', 'color'); form.append('reference_photo', fs.createReadStream('reference.jpg')); form.append('carrier', fs.createReadStream('carrier.png')); @@ -779,8 +907,9 @@ async function encode() { responseType: 'arraybuffer' }); - fs.writeFileSync('stego.png', response.data); - console.log('Encoded successfully'); + fs.writeFileSync('stego.jpg', response.data); + console.log('Encoded with DCT mode'); + console.log('Embedding mode:', response.headers['x-stegasoo-embedding-mode']); } async function decode() { @@ -788,16 +917,17 @@ async function decode() { form.append('day_phrase', 'apple forest thunder'); form.append('pin', '123456'); form.append('reference_photo', fs.createReadStream('reference.jpg')); - form.append('stego_image', fs.createReadStream('stego.png')); + form.append('stego_image', fs.createReadStream('stego.jpg')); const response = await axios.post(`${BASE_URL}/decode/multipart`, form, { headers: form.getHeaders() }); console.log('Decoded:', response.data.message); + console.log('Mode detected:', response.data.embedding_mode_detected); } -encode().then(decode); +encodeDCT().then(decode); ``` ### Go @@ -816,13 +946,16 @@ import ( ) func main() { - // Encode + // Encode with DCT mode body := &bytes.Buffer{} writer := multipart.NewWriter(body) writer.WriteField("message", "Secret message") writer.WriteField("day_phrase", "apple forest thunder") writer.WriteField("pin", "123456") + writer.WriteField("embedding_mode", "dct") + writer.WriteField("output_format", "jpeg") + writer.WriteField("color_mode", "color") ref, _ := os.Open("reference.jpg") refPart, _ := writer.CreateFormFile("reference_photo", "reference.jpg") @@ -842,12 +975,15 @@ func main() { body, ) - stego, _ := os.Create("stego.png") + // Check embedding mode from header + fmt.Println("Embedding mode:", resp.Header.Get("X-Stegasoo-Embedding-Mode")) + + stego, _ := os.Create("stego.jpg") io.Copy(stego, resp.Body) stego.Close() resp.Body.Close() - fmt.Println("Encoded successfully") + fmt.Println("Encoded successfully with DCT mode") } ``` @@ -863,27 +999,43 @@ PHRASE="apple forest thunder" PIN="123456" MESSAGE="Secret message" -# Encode -echo "Encoding..." +# Encode with LSB (default) +echo "Encoding with LSB mode..." curl -s -X POST "$BASE_URL/encode/multipart" \ -F "message=$MESSAGE" \ -F "day_phrase=$PHRASE" \ -F "pin=$PIN" \ -F "reference_photo=@$REF_PHOTO" \ -F "carrier=@$CARRIER" \ - --output stego.png + --output stego_lsb.png -echo "Encoded to stego.png" +echo "Encoded to stego_lsb.png" -# Decode +# Encode with DCT for social media +echo "Encoding with DCT mode..." +curl -s -X POST "$BASE_URL/encode/multipart" \ + -F "message=$MESSAGE" \ + -F "day_phrase=$PHRASE" \ + -F "pin=$PIN" \ + -F "embedding_mode=dct" \ + -F "output_format=jpeg" \ + -F "color_mode=color" \ + -F "reference_photo=@$REF_PHOTO" \ + -F "carrier=@$CARRIER" \ + --output stego_dct.jpg + +echo "Encoded to stego_dct.jpg" + +# Decode (auto-detects mode) echo "Decoding..." -DECODED=$(curl -s -X POST "$BASE_URL/decode/multipart" \ +RESULT=$(curl -s -X POST "$BASE_URL/decode/multipart" \ -F "day_phrase=$PHRASE" \ -F "pin=$PIN" \ -F "reference_photo=@$REF_PHOTO" \ - -F "stego_image=@stego.png" | jq -r '.message') + -F "stego_image=@stego_dct.jpg") -echo "Decoded message: $DECODED" +echo "Decoded message: $(echo $RESULT | jq -r '.message')" +echo "Mode detected: $(echo $RESULT | jq -r '.embedding_mode_detected')" ``` --- @@ -917,9 +1069,15 @@ location /api/ { ### Memory Usage - Argon2id requires 256MB RAM per operation +- DCT mode adds ~100MB for scipy operations - Concurrent requests can exhaust memory - Limit workers based on available RAM +**Worker calculation:** +``` +workers = (available_RAM - 512MB) / 350MB +``` + ### Input Validation The API validates: @@ -927,6 +1085,8 @@ The API validates: - Message size (max 50KB) - Image size (max 5MB file, ~4MP dimensions) - RSA key validity +- Embedding mode values +- Output format compatibility ### Credential Handling @@ -934,6 +1094,15 @@ The API validates: - No persistent storage of secrets - Memory cleared after operations +### Embedding Mode Security + +| Mode | Consideration | +|------|--------------| +| LSB | Maximum capacity but fragile | +| DCT | Lower capacity but survives recompression | + +Both modes use identical encryption (AES-256-GCM with Argon2id). + --- ## Interactive Documentation diff --git a/frontends/CLI.md b/frontends/CLI.md index 9b5a89c..1fdfa23 100644 --- a/frontends/CLI.md +++ b/frontends/CLI.md @@ -11,6 +11,7 @@ Complete command-line interface reference for Stegasoo steganography operations. - [encode](#encode-command) - [decode](#decode-command) - [info](#info-command) +- [Embedding Modes](#embedding-modes) - [Security Factors](#security-factors) - [Workflow Examples](#workflow-examples) - [Piping & Scripting](#piping--scripting) @@ -27,6 +28,9 @@ Complete command-line interface reference for Stegasoo steganography operations. # CLI only pip install stegasoo[cli] +# CLI with DCT support +pip install stegasoo[cli,dct] + # With all extras pip install stegasoo[all] ``` @@ -36,7 +40,7 @@ pip install stegasoo[all] ```bash git clone https://github.com/example/stegasoo.git cd stegasoo -pip install -e ".[cli]" +pip install -e ".[cli,dct]" ``` ### Verify Installation @@ -44,6 +48,9 @@ pip install -e ".[cli]" ```bash stegasoo --version stegasoo --help + +# Check DCT support +python -c "from stegasoo.dct_steganography import has_jpegio_support; print('jpegio:', has_jpegio_support())" ``` --- @@ -54,7 +61,7 @@ stegasoo --help # 1. Generate credentials (do this once, memorize results) stegasoo generate --pin --words 3 -# 2. Encode a message +# 2. Encode a message (LSB mode - default) stegasoo encode \ --ref secret_photo.jpg \ --carrier meme.png \ @@ -62,7 +69,17 @@ stegasoo encode \ --pin 123456 \ --message "Meet at midnight" -# 3. Decode a message +# 3. Encode for social media (DCT mode) +stegasoo encode \ + --ref secret_photo.jpg \ + --carrier meme.png \ + --phrase "apple forest thunder" \ + --pin 123456 \ + --message "Meet at midnight" \ + --mode dct \ + --format jpeg + +# 4. Decode a message (auto-detects mode) stegasoo decode \ --ref secret_photo.jpg \ --stego stego_abc123_20251227.png \ @@ -106,9 +123,9 @@ stegasoo generate Output: ``` -════════════════════════════════════════════════════════════ +═══════════════════════════════════════════════════════════════ STEGASOO CREDENTIALS -════════════════════════════════════════════════════════════ +═══════════════════════════════════════════════════════════════ ⚠️ MEMORIZE THESE AND CLOSE THIS WINDOW Do not screenshot or save to file! @@ -171,19 +188,22 @@ stegasoo encode [OPTIONS] #### Options -| Option | Short | Type | Required | Description | -|--------|-------|------|----------|-------------| -| `--ref` | `-r` | path | ✓ | Reference photo (shared secret) | -| `--carrier` | `-c` | path | ✓ | Carrier image to hide message in | -| `--phrase` | `-p` | string | ✓ | Today's passphrase | -| `--message` | `-m` | string | | Message to encode | -| `--message-file` | `-f` | path | | Read message from file | -| `--pin` | | string | * | Static PIN (6-9 digits) | -| `--key` | `-k` | path | * | RSA key file | -| `--key-password` | | string | | Password for RSA key | -| `--output` | `-o` | path | | Output filename | -| `--date` | | YYYY-MM-DD | | Date override | -| `--quiet` | `-q` | flag | | Suppress output | +| Option | Short | Type | Required | Default | Description | +|--------|-------|------|----------|---------|-------------| +| `--ref` | `-r` | path | ✓ | | Reference photo (shared secret) | +| `--carrier` | `-c` | path | ✓ | | Carrier image to hide message in | +| `--phrase` | `-p` | string | ✓ | | Today's passphrase | +| `--message` | `-m` | string | | | Message to encode | +| `--message-file` | `-f` | path | | | Read message from file | +| `--pin` | | string | * | | Static PIN (6-9 digits) | +| `--key` | `-k` | path | * | | RSA key file | +| `--key-password` | | string | | | Password for RSA key | +| `--output` | `-o` | path | | | Output filename | +| `--date` | | YYYY-MM-DD | | | Date override | +| `--mode` | | choice | | `lsb` | Embedding mode: `lsb` or `dct` | +| `--format` | | choice | | `png` | Output format: `png` or `jpeg` (DCT only) | +| `--color` | | choice | | `color` | Color mode: `color` or `grayscale` (DCT only) | +| `--quiet` | `-q` | flag | | | Suppress output | \* At least one of `--pin` or `--key` is required. @@ -206,7 +226,7 @@ stegasoo encode [OPTIONS] #### Examples -**Basic encoding with PIN:** +**Basic encoding with PIN (LSB mode - default):** ```bash stegasoo encode \ --ref photos/vacation.jpg \ @@ -221,10 +241,60 @@ Output: ✓ Encoded successfully! Output: a1b2c3d4_20251227.png Size: 245,832 bytes + Mode: LSB Capacity used: 12.4% Date: 2025-12-27 ``` +**DCT mode for social media (JPEG output):** +```bash +stegasoo encode \ + --ref photos/vacation.jpg \ + --carrier memes/funny_cat.png \ + --phrase "correct horse battery" \ + --pin 847293 \ + --message "The package arrives Tuesday" \ + --mode dct \ + --format jpeg +``` + +Output: +``` +✓ Encoded successfully! + Output: a1b2c3d4_20251227.jpg + Size: 89,432 bytes + Mode: DCT (color, jpeg) + Capacity used: 45.2% + Date: 2025-12-27 + + ⚠️ DCT mode is experimental +``` + +**DCT mode with PNG output (maximum DCT capacity):** +```bash +stegasoo encode \ + -r ref.jpg \ + -c carrier.png \ + -p "phrase words here" \ + --pin 123456 \ + -m "Longer message that needs more space" \ + --mode dct \ + --format png \ + --color color +``` + +**DCT grayscale mode:** +```bash +stegasoo encode \ + -r ref.jpg \ + -c bw_photo.png \ + -p "phrase" \ + --pin 123456 \ + -m "Message" \ + --mode dct \ + --color grayscale +``` + **With RSA key:** ```bash stegasoo encode \ @@ -291,7 +361,7 @@ stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 -m "msg" -q - ### Decode Command -Decode a secret message from a stego image. +Decode a secret message from a stego image. **Automatically detects LSB vs DCT mode.** #### Synopsis @@ -328,6 +398,24 @@ stegasoo decode \ Output: ``` ✓ Decoded successfully! + Mode detected: LSB + +The package arrives Tuesday +``` + +**Decoding DCT image (auto-detected):** +```bash +stegasoo decode \ + --ref photos/vacation.jpg \ + --stego received_image.jpg \ + --phrase "correct horse battery" \ + --pin 847293 +``` + +Output: +``` +✓ Decoded successfully! + Mode detected: DCT The package arrives Tuesday ``` @@ -377,7 +465,7 @@ stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q | gpg --decr ### Info Command -Display information about an image's capacity and embedded date. +Display information about an image's capacity for both LSB and DCT modes. #### Synopsis @@ -405,10 +493,15 @@ Image: vacation_photo.png Pixels: 2,073,600 Mode: RGB Format: PNG - Capacity: ~776,970 bytes (758 KB) + +Capacity: + LSB Mode: ~776,970 bytes (758 KB) + DCT Mode: ~64,800 bytes (63 KB) [approximate] + + Note: DCT capacity varies based on image content ``` -**Check stego image (shows encoding date):** +**Check stego image (shows encoding date and mode):** ```bash stegasoo info stego_a1b2c3d4_20251227.png ``` @@ -420,12 +513,88 @@ Image: stego_a1b2c3d4_20251227.png Pixels: 2,073,600 Mode: RGB Format: PNG - Capacity: ~776,970 bytes (758 KB) + +Stego Info: Embed date: 2025-12-27 (Saturday) + Embed mode: DCT (detected) + +Capacity: + LSB Mode: ~776,970 bytes (758 KB) + DCT Mode: ~64,800 bytes (63 KB) [approximate] ``` --- +## Embedding Modes + +Stegasoo v3.0+ supports two steganography algorithms. + +### LSB Mode (Default) + +**Least Significant Bit** embedding modifies pixel values directly. + +```bash +stegasoo encode ... --mode lsb +# or just omit --mode (LSB is default) +``` + +| Aspect | Details | +|--------|---------| +| **Capacity** | ~3 bits/pixel (~770 KB for 1920×1080) | +| **Output** | PNG only (lossless required) | +| **Resilience** | ❌ Destroyed by JPEG compression | +| **Best For** | Maximum capacity, controlled channels | + +### DCT Mode (Experimental) + +**Discrete Cosine Transform** embedding hides data in frequency coefficients. + +```bash +stegasoo encode ... --mode dct --format jpeg --color color +``` + +| Aspect | Details | +|--------|---------| +| **Capacity** | ~0.25 bits/pixel (~65 KB for 1920×1080) | +| **Output** | PNG or JPEG | +| **Resilience** | ✅ Survives JPEG compression | +| **Best For** | Social media, messaging apps | + +> ⚠️ **Experimental**: DCT mode may have edge cases. Test with your workflow. + +### DCT Options + +| Option | Values | Default | Description | +|--------|--------|---------|-------------| +| `--format` | `png`, `jpeg` | `png` | Output image format | +| `--color` | `color`, `grayscale` | `color` | Color processing | + +### Choosing the Right Mode + +``` +Will the image be recompressed? +(social media, messaging apps, etc.) + │ + ┌──────┴──────┐ + ▼ ▼ + YES NO + │ │ + ▼ ▼ +Use DCT Use LSB +--mode dct (default) +--format jpeg +``` + +### Capacity Comparison + +| Mode | 1920×1080 Capacity | +|------|-------------------| +| LSB (PNG) | ~770 KB | +| DCT (PNG) | ~65 KB | +| DCT (JPEG) | ~30-50 KB | + +--- + ## Security Factors Stegasoo uses multiple authentication factors: @@ -468,25 +637,33 @@ stegasoo generate --rsa -o shared_key.pem -p "agreedpassword" # Securely transfer shared_key.pem to recipient ``` -**Sender (daily):** +**Sender (daily - private channel):** ```bash -# Get today's phrase from your memorized list -TODAY_PHRASE="monday phrase words" - -# Encode message +# For email, file transfer, etc. (no recompression) stegasoo encode \ -r our_shared_photo.jpg \ -c random_meme.png \ -p "$TODAY_PHRASE" \ --pin 847293 \ -m "Meeting moved to 3pm" +``` -# Share output image via normal channels (email, chat, etc.) +**Sender (daily - social media):** +```bash +# For Instagram, Twitter, WhatsApp, etc. +stegasoo encode \ + -r our_shared_photo.jpg \ + -c random_meme.png \ + -p "$TODAY_PHRASE" \ + --pin 847293 \ + -m "Meeting moved to 3pm" \ + --mode dct \ + --format jpeg ``` **Recipient (daily):** ```bash -# Use the phrase for the day the message was SENT +# Works for both LSB and DCT (auto-detected) stegasoo decode \ -r our_shared_photo.jpg \ -s received_image.png \ @@ -496,7 +673,7 @@ stegasoo decode \ ### Batch Processing -**Encode multiple messages:** +**Encode multiple messages (LSB):** ```bash #!/bin/bash PHRASE="apple forest thunder" @@ -517,6 +694,25 @@ for file in messages/*.txt; do done ``` +**Encode for social media (DCT):** +```bash +#!/bin/bash +for file in messages/*.txt; do + name=$(basename "$file" .txt) + stegasoo encode \ + -r "$REF" \ + -c "carriers/${name}.png" \ + -p "$PHRASE" \ + --pin "$PIN" \ + -f "$file" \ + --mode dct \ + --format jpeg \ + -o "output/${name}_social.jpg" \ + -q + echo "Encoded for social: $name" +done +``` + ### Archive with Date Preservation ```bash @@ -531,6 +727,31 @@ stegasoo encode \ -o archive_2025-01-15.png ``` +### Testing Mode Compatibility + +```bash +# Encode with DCT +stegasoo encode \ + -r ref.jpg \ + -c carrier.png \ + -p "test phrase" \ + --pin 123456 \ + -m "Test message" \ + --mode dct \ + --format jpeg \ + -o test_dct.jpg + +# Simulate social media recompression +convert test_dct.jpg -quality 85 test_recompressed.jpg + +# Decode (should still work!) +stegasoo decode \ + -r ref.jpg \ + -s test_recompressed.jpg \ + -p "test phrase" \ + --pin 123456 +``` + --- ## Piping & Scripting @@ -585,6 +806,15 @@ if ! stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q 2>/dev/ fi ``` +### Mode Detection in Scripts + +```bash +#!/bin/bash +# Get mode from verbose output +MODE=$(stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 2>&1 | grep "Mode detected" | awk '{print $3}') +echo "Image was encoded with: $MODE mode" +``` + --- ## Error Handling @@ -596,16 +826,31 @@ fi | "Must provide --pin or --key" | No security factor given | Add `--pin` or `--key` option | | "PIN must be 6-9 digits" | Invalid PIN format | Use numeric PIN, 6-9 chars | | "PIN cannot start with zero" | Leading zero in PIN | Use PIN starting with 1-9 | -| "Carrier image too small" | Message exceeds capacity | Use larger carrier image | +| "Carrier image too small" | Message exceeds capacity | Use larger carrier or LSB mode | +| "Message too long for DCT capacity" | DCT has less space | Shorten message or use LSB | | "Decryption failed" | Wrong credentials | Verify phrase, PIN, ref photo | +| "Invalid or missing Stegasoo header" | Wrong mode or corruption | Check mode, try other credentials | | "RSA key is password-protected" | Missing key password | Add `--key-password` option | +| "jpegio not available" | Missing library | Install: `pip install jpegio` | +| "Invalid --format for LSB mode" | JPEG with LSB | Use `--mode dct` for JPEG output | ### Troubleshooting Decryption Failures 1. **Check the encoding date:** The filename often contains the date (e.g., `_20251227`) 2. **Use correct phrase:** The phrase must match the day the message was encoded, not today 3. **Verify reference photo:** Must be the exact same file, not a resized copy -4. **Check stego image:** Ensure it wasn't resized, recompressed, or converted +4. **Check stego image:** + - LSB: Ensure it wasn't resized, recompressed, or converted + - DCT: More resilient, but heavy recompression may still destroy data +5. **Check embedding mode:** The decoder auto-detects, but if issues persist, verify the original was encoded with the expected mode + +### DCT-Specific Issues + +| Issue | Cause | Solution | +|-------|-------|----------| +| "Invalid or missing Stegasoo header" after social media | Heavy recompression | Try higher quality original or shorter message | +| JPEG output not working | jpegio not installed | `pip install jpegio` | +| Lower capacity than expected | Normal for DCT | DCT has ~10% of LSB capacity | --- @@ -627,6 +872,33 @@ fi --- +## Dependencies + +### Core Dependencies + +- `pillow` - Image processing +- `cryptography` - Encryption +- `argon2-cffi` - Key derivation +- `click` - CLI framework + +### DCT Mode Dependencies + +- `scipy` - DCT transformations +- `jpegio` - Native JPEG coefficient access (recommended) + +Install DCT dependencies: +```bash +pip install scipy jpegio +``` + +Check availability: +```bash +python -c "import scipy; print('scipy:', scipy.__version__)" +python -c "import jpegio; print('jpegio: available')" +``` + +--- + ## See Also - [API Documentation](API.md) - REST API reference diff --git a/frontends/WEB_UI.md b/frontends/WEB_UI.md index 0b5232a..8e3daf5 100644 --- a/frontends/WEB_UI.md +++ b/frontends/WEB_UI.md @@ -12,6 +12,9 @@ Complete guide for the Stegasoo web-based steganography interface. - [Encode Message](#encode-message) - [Decode Message](#decode-message) - [About Page](#about-page) +- [Embedding Modes](#embedding-modes) + - [LSB Mode (Default)](#lsb-mode-default) + - [DCT Mode (Experimental)](#dct-mode-experimental) - [User Interface Guide](#user-interface-guide) - [Workflow Examples](#workflow-examples) - [Security Features](#security-features) @@ -42,6 +45,8 @@ Built with Flask, Bootstrap 5, and a modern dark theme. - ✅ Password-protected RSA key downloads - ✅ Real-time entropy calculations - ✅ Automatic file cleanup +- ✅ **DCT steganography mode** (v3.0+) - JPEG-resilient embedding +- ✅ **Color mode selection** (v3.0.1+) - Preserve carrier colors --- @@ -53,6 +58,8 @@ Built with Flask, Bootstrap 5, and a modern dark theme. pip install stegasoo[web] ``` +This automatically installs DCT dependencies (scipy, jpegio) for full functionality. + ### From Source ```bash @@ -210,6 +217,18 @@ Hide a secret message inside an image. \* At least one security factor (PIN or RSA Key) required. +#### Advanced Options (v3.0+) + +Expand "Advanced Options" to access embedding mode settings: + +| Option | Values | Default | Description | +|--------|--------|---------|-------------| +| Embedding Mode | LSB / DCT | LSB | Steganography algorithm | +| Output Format | PNG / JPEG | PNG | Output image format (DCT only) | +| Color Mode | Color / Grayscale | Color | Carrier color handling (DCT only) | + +See [Embedding Modes](#embedding-modes) for detailed explanations. + #### Drag-and-Drop Upload Both image upload zones support: @@ -237,9 +256,10 @@ Saturday's Phrase: [ ] #### Encoding Process 1. Fill in all required fields -2. Click "Encode Message" -3. Wait for processing (shows spinner) -4. Redirected to result page +2. (Optional) Expand "Advanced Options" for DCT mode +3. Click "Encode Message" +4. Wait for processing (shows spinner) +5. Redirected to result page #### Result Page @@ -255,6 +275,9 @@ After successful encoding: │ Your secret message is hidden │ │ in this image │ │ │ +│ Mode: DCT (Color, JPEG) │ ← v3.0+ shows mode info +│ Capacity used: 45.2% │ +│ │ │ [ Download Image ] │ │ [ Share Image ] │ │ │ @@ -299,6 +322,10 @@ Extract a hidden message from a stego image. \* Must match security factors used during encoding. +#### Automatic Mode Detection (v3.0+) + +The decoder automatically detects whether a stego image uses LSB or DCT mode. You don't need to specify the mode manually—it just works! + #### Date Detection from Filename When you upload a stego image with a date in the filename (e.g., `stego_20251227.png`), the UI: @@ -333,13 +360,11 @@ This helps you use the correct daily phrase. #### Troubleshooting Tips -The page includes built-in troubleshooting guidance: - -- ✓ Use the **exact same reference photo** file -- ✓ Use the phrase for the **encoding day**, not today -- ✓ Provide the **same security factors** used during encoding -- ✓ Ensure the stego image hasn't been **resized or recompressed** -- ✓ If using RSA key, verify the **password is correct** +If decryption fails: +1. **Check the date** - Use phrase for encoding day, not today +2. **Same reference photo** - Must be identical file +3. **Correct PIN/RSA** - Match what was used for encoding +4. **Image integrity** - Ensure no resizing/recompression --- @@ -347,62 +372,130 @@ The page includes built-in troubleshooting guidance: **URL:** `/about` -Learn about Stegasoo's security model and best practices. +Information about the Stegasoo project, security model, and credits. -#### Sections +--- -**System Status:** -- Argon2id availability (vs PBKDF2 fallback) -- AES-256-GCM encryption status +## Embedding Modes -**Security Model Table:** +Stegasoo v3.0+ offers two steganography algorithms, each with different trade-offs. -| Component | Entropy | Purpose | -|-----------|---------|---------| -| Reference Photo | ~80-256 bits | Something you have | -| 3-Word Phrase | ~33 bits | Something you know (daily) | -| 6-Digit PIN | ~20 bits | Something you know (static) | -| Date | N/A | Automatic key rotation | -| **Combined** | **133+ bits** | **Beyond brute force** | +### LSB Mode (Default) -**Attack Resistance:** +**Least Significant Bit** embedding modifies the least significant bits of pixel values. -What attackers can't do: -- Brute force (2^133 combinations) -- Use rainbow tables (random salt) -- Detect hidden data (random pixels) -- Use GPU farms (256MB RAM per attempt) +| Aspect | Details | +|--------|---------| +| **Capacity** | ~3 bits/pixel (~770 KB for 1920×1080) | +| **Output Format** | PNG only (lossless required) | +| **Resilience** | ❌ Destroyed by JPEG compression | +| **Best For** | Maximum capacity, controlled sharing | -Real threats: -- Social engineering -- Physical device access -- Malware/keyloggers -- Shoulder surfing +**When to use LSB:** +- Sharing via lossless channels (email attachment, file transfer) +- Maximum message capacity needed +- Recipient won't modify the image -**Best Practices:** +### DCT Mode (Experimental) -Do: -- Memorize phrases and PIN -- Use reference photo both parties have -- Use different carrier images each time -- Share stego images through normal channels +**Discrete Cosine Transform** embedding hides data in frequency domain coefficients. -Don't: -- Transmit the reference photo -- Reuse carrier images -- Store credentials digitally -- Resize/recompress stego images +| Aspect | Details | +|--------|---------| +| **Capacity** | ~0.25 bits/pixel (~65 KB for 1920×1080 PNG, ~30-50 KB JPEG) | +| **Output Formats** | PNG or JPEG | +| **Resilience** | ✅ Survives JPEG compression | +| **Best For** | Social media, messaging apps, web sharing | + +> ⚠️ **Experimental Feature**: DCT mode is marked experimental and may have edge cases. Test with your specific workflow before relying on it for critical messages. + +**When to use DCT:** +- Posting to social media (which recompresses images) +- Sharing via messaging apps (WhatsApp, Telegram, etc.) +- When channel may apply JPEG compression +- Smaller messages that fit in reduced capacity + +#### DCT Output Formats + +| Format | Pros | Cons | +|--------|------|------| +| **PNG** | Lossless, predictable | Larger file, obvious if channel expects JPEG | +| **JPEG** | Native format, natural | Slightly lower capacity | + +#### DCT Color Modes + +| Mode | Description | Use Case | +|------|-------------|----------| +| **Color** | Embeds in luminance (Y), preserves chrominance | Most images, photos | +| **Grayscale** | Converts to grayscale before embedding | Black & white images | + +### Capacity Comparison + +For a 1920×1080 image: + +| Mode | Approximate Capacity | +|------|---------------------| +| LSB (PNG) | ~770 KB | +| DCT (PNG, Color) | ~65 KB | +| DCT (JPEG) | ~30-50 KB | + +### Choosing the Right Mode + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Mode Selection Guide │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Will the image be recompressed (social media, chat apps)? │ +│ │ │ +│ ┌───────────┴───────────┐ │ +│ ▼ ▼ │ +│ YES NO │ +│ │ │ │ +│ ▼ ▼ │ +│ Use DCT Mode Use LSB Mode │ +│ │ │ │ +│ ▼ ▼ │ +│ Output: JPEG (natural) Output: PNG (automatic) │ +│ Color: Color (usually) Capacity: ~770 KB │ +│ Capacity: ~30-50 KB │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` --- ## User Interface Guide -### Navigation - -The navbar provides quick access to all pages: +### Layout Structure ``` -[Logo] Stegasoo Home | Encode | Decode | Generate | About +┌──────────────────────────────────────────────────────────────┐ +│ 🦕 Stegasoo [Encode] [Decode] [Generate] │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ Page Content │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ Upload Zone │ │ Upload Zone │ │ │ +│ │ │ (Reference) │ │ (Carrier) │ │ │ +│ │ └──────────────┘ └──────────────┘ │ │ +│ │ │ │ +│ │ [Advanced Options ▼] │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ +│ │ │ Embedding Mode: [LSB ▼] │ │ │ +│ │ │ Output Format: [PNG ▼] (DCT only) │ │ │ +│ │ │ Color Mode: [Color ▼] (DCT only) │ │ │ +│ │ └────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ [ Encode Message ] │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────┘ │ +│ │ +├──────────────────────────────────────────────────────────────┤ +│ Footer │ +└──────────────────────────────────────────────────────────────┘ ``` ### Color Scheme @@ -415,6 +508,7 @@ The navbar provides quick access to all pages: | Success | Green | Positive actions | | Warning | Yellow | Caution messages | | Error | Red | Error states | +| Experimental | Orange badge | DCT mode indicator | ### Form Validation @@ -462,7 +556,7 @@ Types: - The PIN - The reference photo file (if not already shared) -### Sending a Secret Message +### Sending a Secret Message (LSB - Default) 1. Go to `/encode` 2. Upload your shared reference photo @@ -472,7 +566,22 @@ Types: 6. Enter your PIN 7. Click "Encode Message" 8. Download or share the resulting image -9. Send via any channel (email, social media, chat) +9. Send via any channel (email, file transfer) + +### Sending via Social Media (DCT Mode) + +1. Go to `/encode` +2. Upload your shared reference photo +3. Upload carrier image +4. Type your secret message +5. Enter today's phrase and PIN +6. **Expand "Advanced Options"** +7. **Select "DCT" embedding mode** +8. **Select "JPEG" output format** +9. Click "Encode Message" +10. Download and post to social media + +The recipient can decode even after the platform recompresses the image! ### Receiving a Secret Message @@ -486,6 +595,8 @@ Types: 8. Click "Decode Message" 9. Read the secret message +> 💡 Decoding automatically detects LSB vs DCT mode—no configuration needed! + ### Changing Credentials To rotate to new credentials: @@ -527,6 +638,15 @@ To rotate to new credentials: | Access control | Random 16-byte file ID | | Cleanup | Automatic + manual | +### Embedding Mode Security + +| Mode | Security Consideration | +|------|----------------------| +| LSB | Full capacity, but fragile to modification | +| DCT | Lower capacity, but survives recompression | + +Both modes use the same strong encryption (AES-256-GCM with Argon2id key derivation). + --- ## Configuration @@ -561,8 +681,8 @@ gunicorn \ ``` **Worker Calculation:** -- Each encode/decode uses ~256MB RAM (Argon2) -- Formula: `workers = (available_RAM - 512MB) / 256MB` +- Each encode/decode uses ~256MB RAM (Argon2) + ~100MB for scipy (DCT mode) +- Formula: `workers = (available_RAM - 512MB) / 350MB` **With Nginx (reverse proxy):** ```nginx @@ -594,9 +714,9 @@ services: deploy: resources: limits: - memory: 512M + memory: 768M # Increased for scipy/DCT reservations: - memory: 256M + memory: 384M ``` --- @@ -617,7 +737,19 @@ services: 1. Check the date in the stego filename 2. Use the phrase for that specific day 3. Verify you're using the original reference photo -4. Ensure the stego image wasn't resized/recompressed +4. Ensure the stego image wasn't resized/recompressed (LSB mode) + +#### "Invalid or missing Stegasoo header" (DCT Mode) + +**Causes:** +- Image was heavily recompressed +- Wrong credentials +- Corrupted during transfer + +**Solutions:** +1. If sharing via lossy channel, ensure DCT mode was used for encoding +2. Verify credentials match +3. Try obtaining original file #### "Carrier image too small" @@ -626,7 +758,8 @@ services: **Solutions:** 1. Use a larger carrier image (more pixels) 2. Shorten the message -3. Check capacity with `/info` command (CLI) +3. Use LSB mode for more capacity (if channel supports it) +4. Check capacity with `/info` command (CLI) #### "You must provide at least a PIN or RSA Key" @@ -658,6 +791,17 @@ services: 2. If key is unencrypted, leave password blank 3. Re-download or regenerate the key +#### DCT mode shows "jpegio not available" + +**Cause:** jpegio library not installed (required for JPEG output) + +**Solution:** +```bash +pip install jpegio +# Or rebuild Docker image +docker-compose build --no-cache +``` + ### Browser Compatibility | Browser | Status | Notes | @@ -672,10 +816,12 @@ services: **Slow encoding/decoding:** - Normal: Argon2 is intentionally slow (security feature) -- Expected time: 2-5 seconds per operation +- DCT mode adds ~1-2 seconds for transform operations +- Expected time: 3-7 seconds per operation **High memory usage:** - Normal: Argon2 requires 256MB RAM +- DCT mode adds scipy memory overhead (~100MB) - Configure worker count based on available RAM --- @@ -689,6 +835,7 @@ The UI adapts to mobile screens: - Touch-friendly buttons (48px minimum) - Readable text without zooming - Scrollable tables +- Collapsible "Advanced Options" for cleaner mobile view ### Mobile-Specific Features diff --git a/frontends/api/main.py b/frontends/api/main.py index 0441d4c..fc8858c 100644 --- a/frontends/api/main.py +++ b/frontends/api/main.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 """ -Stegasoo REST API (v3.0) +Stegasoo REST API (v3.0.1) FastAPI-based REST API for steganography operations. Supports both text messages and file embedding. NEW in v3.0: LSB and DCT embedding modes. +NEW in v3.0.1: DCT color mode and JPEG output format. """ import io @@ -70,7 +71,12 @@ Secure steganography with hybrid authentication. Supports text messages and file ## Embedding Modes (v3.0) - **LSB mode** (default): Spatial LSB embedding, full color output, higher capacity -- **DCT mode**: Frequency domain embedding, grayscale output, ~20% capacity, better stealth +- **DCT mode**: Frequency domain embedding, ~20% capacity, better stealth + +## DCT Options (v3.0.1) + +- **dct_color_mode**: 'grayscale' (default) or 'color' (preserves original colors) +- **dct_output_format**: 'png' (lossless) or 'jpeg' (smaller, more natural) Use the `/modes` endpoint to check availability and `/compare` to compare capacities. """, @@ -86,6 +92,8 @@ Use the `/modes` endpoint to check availability and `/compare` to compare capaci EmbedModeType = Literal["lsb", "dct"] ExtractModeType = Literal["auto", "lsb", "dct"] +DctColorModeType = Literal["grayscale", "color"] +DctOutputFormatType = Literal["png", "jpeg"] # ============================================================================ @@ -118,7 +126,16 @@ class EncodeRequest(BaseModel): date_str: Optional[str] = None embed_mode: EmbedModeType = Field( default="lsb", - description="Embedding mode: 'lsb' (default, color) or 'dct' (grayscale, requires scipy)" + description="Embedding mode: 'lsb' (default, color) or 'dct' (requires scipy)" + ) + # NEW in v3.0.1 + dct_output_format: DctOutputFormatType = Field( + default="png", + description="DCT output format: 'png' (lossless) or 'jpeg' (smaller). Only applies to DCT mode." + ) + dct_color_mode: DctColorModeType = Field( + default="grayscale", + description="DCT color mode: 'grayscale' (default) or 'color' (preserves colors). Only applies to DCT mode." ) @@ -136,7 +153,16 @@ class EncodeFileRequest(BaseModel): date_str: Optional[str] = None embed_mode: EmbedModeType = Field( default="lsb", - description="Embedding mode: 'lsb' (default, color) or 'dct' (grayscale, requires scipy)" + description="Embedding mode: 'lsb' (default, color) or 'dct' (requires scipy)" + ) + # NEW in v3.0.1 + dct_output_format: DctOutputFormatType = Field( + default="png", + description="DCT output format: 'png' (lossless) or 'jpeg' (smaller). Only applies to DCT mode." + ) + dct_color_mode: DctColorModeType = Field( + default="grayscale", + description="DCT color mode: 'grayscale' (default) or 'color' (preserves colors). Only applies to DCT mode." ) @@ -147,6 +173,15 @@ class EncodeResponse(BaseModel): date_used: str day_of_week: str embed_mode: str = Field(description="Embedding mode used: 'lsb' or 'dct'") + # NEW in v3.0.1 + output_format: str = Field( + default="png", + description="Output format: 'png' or 'jpeg' (for DCT mode)" + ) + color_mode: str = Field( + default="color", + description="Color mode: 'color' (LSB/DCT color) or 'grayscale' (DCT grayscale)" + ) class DecodeRequest(BaseModel): @@ -211,20 +246,36 @@ class CompareModesResponse(BaseModel): recommendation: str +class DctModeInfo(BaseModel): + """Detailed DCT mode information.""" + available: bool + name: str + description: str + output_formats: list[str] + color_modes: list[str] + capacity_ratio: str + requires: str + + class ModesResponse(BaseModel): """Response showing available embedding modes.""" lsb: dict - dct: dict + dct: DctModeInfo class StatusResponse(BaseModel): version: str has_argon2: bool has_qrcode_read: bool - has_dct: bool # NEW in v3.0 + has_dct: bool day_names: list[str] max_payload_kb: int - available_modes: list[str] # NEW in v3.0 + available_modes: list[str] + # NEW in v3.0.1 + dct_features: Optional[dict] = Field( + default=None, + description="DCT mode features (v3.0.1+)" + ) class QrExtractResponse(BaseModel): @@ -263,8 +314,16 @@ class ErrorResponse(BaseModel): async def root(): """Get API status and configuration.""" available_modes = ["lsb"] + dct_features = None + if has_dct_support(): available_modes.append("dct") + dct_features = { + "output_formats": ["png", "jpeg"], + "color_modes": ["grayscale", "color"], + "default_output_format": "png", + "default_color_mode": "grayscale", + } return StatusResponse( version=__version__, @@ -273,7 +332,8 @@ async def root(): has_dct=has_dct_support(), day_names=list(DAY_NAMES), max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024, - available_modes=available_modes + available_modes=available_modes, + dct_features=dct_features, ) @@ -283,6 +343,7 @@ async def api_modes(): Get available embedding modes and their status. NEW in v3.0: Shows LSB and DCT mode availability. + NEW in v3.0.1: Shows DCT color modes and output formats. """ return ModesResponse( lsb={ @@ -292,14 +353,15 @@ async def api_modes(): "output_format": "PNG (color)", "capacity_ratio": "100%", }, - dct={ - "available": has_dct_support(), - "name": "DCT Domain", - "description": "Embed in DCT coefficients, outputs grayscale PNG", - "output_format": "PNG (grayscale)", - "capacity_ratio": "~20% of LSB", - "requires": "scipy", - } + dct=DctModeInfo( + available=has_dct_support(), + name="DCT Domain", + description="Embed in DCT coefficients, frequency domain steganography", + output_formats=["png", "jpeg"], + color_modes=["grayscale", "color"], + capacity_ratio="~20% of LSB", + requires="scipy", + ) ) @@ -328,7 +390,8 @@ async def api_compare_modes(request: CompareModesRequest): "capacity_bytes": comparison['dct']['capacity_bytes'], "capacity_kb": round(comparison['dct']['capacity_kb'], 1), "available": comparison['dct']['available'], - "output_format": comparison['dct']['output'], + "output_formats": ["png", "jpeg"], + "color_modes": ["grayscale", "color"], "ratio_vs_lsb_percent": round(comparison['dct']['ratio_vs_lsb'], 1), }, recommendation="lsb" if not comparison['dct']['available'] else "dct for stealth, lsb for capacity" @@ -464,6 +527,41 @@ async def api_generate(request: GenerateRequest): raise HTTPException(500, str(e)) +# ============================================================================ +# HELPER FUNCTION FOR DCT PARAMETERS +# ============================================================================ + +def _get_dct_params(embed_mode: str, dct_output_format: str, dct_color_mode: str) -> dict: + """ + Get DCT-specific parameters if DCT mode is selected. + Returns kwargs to pass to encode(). + """ + if embed_mode != "dct": + return {} + + return { + "dct_output_format": dct_output_format, + "dct_color_mode": dct_color_mode, + } + + +def _get_output_info(embed_mode: str, dct_output_format: str, dct_color_mode: str) -> tuple: + """ + Get output format and color mode strings for response. + Returns (output_format, color_mode, mime_type). + """ + if embed_mode == "dct": + output_format = dct_output_format + color_mode = dct_color_mode + mime_type = "image/jpeg" if dct_output_format == "jpeg" else "image/png" + else: + output_format = "png" + color_mode = "color" + mime_type = "image/png" + + return output_format, color_mode, mime_type + + # ============================================================================ # ROUTES - ENCODE (JSON) # ============================================================================ @@ -476,6 +574,7 @@ async def api_encode(request: EncodeRequest): Images must be base64-encoded. Returns base64-encoded stego image. NEW in v3.0: Supports embed_mode parameter ('lsb' or 'dct'). + NEW in v3.0.1: Supports dct_output_format ('png' or 'jpeg') and dct_color_mode ('grayscale' or 'color'). """ # Validate mode if request.embed_mode == "dct" and not has_dct_support(): @@ -486,6 +585,13 @@ async def api_encode(request: EncodeRequest): carrier = base64.b64decode(request.carrier_image_base64) rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None + # Get DCT parameters + dct_params = _get_dct_params( + request.embed_mode, + request.dct_output_format, + request.dct_color_mode + ) + result = encode( message=request.message, reference_photo=ref_photo, @@ -495,12 +601,19 @@ async def api_encode(request: EncodeRequest): rsa_key_data=rsa_key, rsa_password=request.rsa_password, date_str=request.date_str, - embed_mode=request.embed_mode, # NEW in v3.0 + embed_mode=request.embed_mode, + **dct_params, # NEW in v3.0.1 ) stego_b64 = base64.b64encode(result.stego_image).decode('utf-8') day_of_week = get_day_from_date(result.date_used) + output_format, color_mode, _ = _get_output_info( + request.embed_mode, + request.dct_output_format, + request.dct_color_mode + ) + return EncodeResponse( stego_image_base64=stego_b64, filename=result.filename, @@ -508,6 +621,8 @@ async def api_encode(request: EncodeRequest): date_used=result.date_used, day_of_week=day_of_week, embed_mode=request.embed_mode, + output_format=output_format, + color_mode=color_mode, ) except CapacityError as e: @@ -526,6 +641,7 @@ async def api_encode_file(request: EncodeFileRequest): File data must be base64-encoded. NEW in v3.0: Supports embed_mode parameter ('lsb' or 'dct'). + NEW in v3.0.1: Supports dct_output_format and dct_color_mode. """ # Validate mode if request.embed_mode == "dct" and not has_dct_support(): @@ -543,6 +659,13 @@ async def api_encode_file(request: EncodeFileRequest): mime_type=request.mime_type ) + # Get DCT parameters + dct_params = _get_dct_params( + request.embed_mode, + request.dct_output_format, + request.dct_color_mode + ) + result = encode( message=payload, reference_photo=ref_photo, @@ -552,12 +675,19 @@ async def api_encode_file(request: EncodeFileRequest): rsa_key_data=rsa_key, rsa_password=request.rsa_password, date_str=request.date_str, - embed_mode=request.embed_mode, # NEW in v3.0 + embed_mode=request.embed_mode, + **dct_params, # NEW in v3.0.1 ) stego_b64 = base64.b64encode(result.stego_image).decode('utf-8') day_of_week = get_day_from_date(result.date_used) + output_format, color_mode, _ = _get_output_info( + request.embed_mode, + request.dct_output_format, + request.dct_color_mode + ) + return EncodeResponse( stego_image_base64=stego_b64, filename=result.filename, @@ -565,6 +695,8 @@ async def api_encode_file(request: EncodeFileRequest): date_used=result.date_used, day_of_week=day_of_week, embed_mode=request.embed_mode, + output_format=output_format, + color_mode=color_mode, ) except CapacityError as e: @@ -588,6 +720,9 @@ async def api_decode(request: DecodeRequest): NEW in v3.0: Supports embed_mode parameter ('auto', 'lsb', or 'dct'). With 'auto' (default), tries LSB first then DCT. + + Note: Extraction works regardless of whether the image was created with + color mode or grayscale mode - both use the same Y channel for data. """ # Validate mode if request.embed_mode == "dct" and not has_dct_support(): @@ -605,7 +740,7 @@ async def api_decode(request: DecodeRequest): pin=request.pin, rsa_key_data=rsa_key, rsa_password=request.rsa_password, - embed_mode=request.embed_mode, # NEW in v3.0 + embed_mode=request.embed_mode, ) if result.is_file: @@ -645,16 +780,20 @@ async def api_encode_multipart( rsa_key_qr: Optional[UploadFile] = File(None), rsa_password: str = Form(""), date_str: str = Form(""), - embed_mode: str = Form("lsb"), # NEW in v3.0 + embed_mode: str = Form("lsb"), + # NEW in v3.0.1 + dct_output_format: str = Form("png"), + dct_color_mode: str = Form("grayscale"), ): """ Encode using multipart form data (file uploads). Provide either 'message' (text) or 'payload_file' (binary file). RSA key can be provided as 'rsa_key' (.pem file) or 'rsa_key_qr' (QR code image). - Returns the stego image directly as PNG with metadata headers. + Returns the stego image directly with metadata headers. NEW in v3.0: Supports embed_mode parameter ('lsb' or 'dct'). + NEW in v3.0.1: Supports dct_output_format ('png' or 'jpeg') and dct_color_mode ('grayscale' or 'color'). """ # Validate mode if embed_mode not in ("lsb", "dct"): @@ -662,6 +801,12 @@ async def api_encode_multipart( if embed_mode == "dct" and not has_dct_support(): raise HTTPException(400, "DCT mode requires scipy. Install with: pip install scipy") + # Validate DCT options + if dct_output_format not in ("png", "jpeg"): + raise HTTPException(400, "dct_output_format must be 'png' or 'jpeg'") + if dct_color_mode not in ("grayscale", "color"): + raise HTTPException(400, "dct_color_mode must be 'grayscale' or 'color'") + try: ref_data = await reference_photo.read() carrier_data = await carrier.read() @@ -701,6 +846,9 @@ async def api_encode_multipart( else: raise HTTPException(400, "Must provide either 'message' or 'payload_file'") + # Get DCT parameters + dct_params = _get_dct_params(embed_mode, dct_output_format, dct_color_mode) + result = encode( message=payload, reference_photo=ref_data, @@ -710,20 +858,26 @@ async def api_encode_multipart( rsa_key_data=rsa_key_data, rsa_password=effective_password, date_str=date_str if date_str else None, - embed_mode=embed_mode, # NEW in v3.0 + embed_mode=embed_mode, + **dct_params, # NEW in v3.0.1 ) day_of_week = get_day_from_date(result.date_used) + output_format, color_mode, mime_type = _get_output_info( + embed_mode, dct_output_format, dct_color_mode + ) return Response( content=result.stego_image, - media_type="image/png", + media_type=mime_type, headers={ "Content-Disposition": f"attachment; filename={result.filename}", "X-Stegasoo-Date": result.date_used, "X-Stegasoo-Day": day_of_week, "X-Stegasoo-Capacity-Percent": f"{result.capacity_percent:.1f}", - "X-Stegasoo-Embed-Mode": embed_mode, # NEW in v3.0 + "X-Stegasoo-Embed-Mode": embed_mode, + "X-Stegasoo-Output-Format": output_format, # NEW in v3.0.1 + "X-Stegasoo-Color-Mode": color_mode, # NEW in v3.0.1 } ) @@ -746,7 +900,7 @@ async def api_decode_multipart( rsa_key: Optional[UploadFile] = File(None), rsa_key_qr: Optional[UploadFile] = File(None), rsa_password: str = Form(""), - embed_mode: str = Form("auto"), # NEW in v3.0 + embed_mode: str = Form("auto"), ): """ Decode using multipart form data (file uploads). @@ -755,6 +909,8 @@ async def api_decode_multipart( Returns JSON with payload_type indicating text or file. NEW in v3.0: Supports embed_mode parameter ('auto', 'lsb', or 'dct'). + + Note: Extraction works the same regardless of color mode used during encoding. """ # Validate mode if embed_mode not in ("auto", "lsb", "dct"): @@ -795,7 +951,7 @@ async def api_decode_multipart( pin=pin, rsa_key_data=rsa_key_data, rsa_password=effective_password, - embed_mode=embed_mode, # NEW in v3.0 + embed_mode=embed_mode, ) if result.is_file: @@ -866,7 +1022,7 @@ async def api_image_info( capacity_bytes=comparison['dct']['capacity_bytes'], capacity_kb=round(comparison['dct']['capacity_kb'], 1), available=comparison['dct']['available'], - output_format=comparison['dct']['output'], + output_format="PNG/JPEG (grayscale or color)", # Updated for v3.0.1 ), } diff --git a/frontends/cli/main.py b/frontends/cli/main.py index 7f3f814..1493455 100644 --- a/frontends/cli/main.py +++ b/frontends/cli/main.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Stegasoo CLI - Command-line interface for steganography operations. +Stegasoo CLI - Command-line interface for steganography operations (v3.0.1). Usage: stegasoo generate [OPTIONS] @@ -8,7 +8,12 @@ Usage: stegasoo decode [OPTIONS] stegasoo verify [OPTIONS] stegasoo info [OPTIONS] - stegasoo compare [OPTIONS] # NEW in v3.0 + stegasoo compare [OPTIONS] + stegasoo modes [OPTIONS] + +New in v3.0.1: + - DCT color mode: --dct-color (grayscale or color) + - DCT output format: --dct-format (png or jpeg) """ import sys @@ -73,14 +78,19 @@ def cli(): Hide encrypted messages or files in images using a combination of: \b - • Reference photo (something you have) - • Daily passphrase (something you know) - • Static PIN or RSA key (additional security) + - Reference photo (something you have) + - Daily passphrase (something you know) + - Static PIN or RSA key (additional security) \b - NEW in v3.0 - Embedding Modes: - • LSB mode (default): Full color output, higher capacity - • DCT mode: Grayscale output, ~20% capacity, better stealth + Embedding Modes (v3.0): + - LSB mode (default): Full color output, higher capacity + - DCT mode: Frequency domain, ~20% capacity, better stealth + + \b + DCT Options (v3.0.1): + - Color mode: grayscale (default) or color (preserves colors) + - Output format: png (lossless) or jpeg (smaller, natural) """ pass @@ -148,29 +158,29 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): # Pretty output click.echo() - click.secho("═" * 60, fg='cyan') + click.secho("=" * 60, fg='cyan') click.secho(" STEGASOO CREDENTIALS", fg='cyan', bold=True) - click.secho("═" * 60, fg='cyan') + click.secho("=" * 60, fg='cyan') click.echo() - click.secho("⚠️ MEMORIZE THESE AND CLOSE THIS WINDOW", fg='yellow', bold=True) + click.secho(" MEMORIZE THESE AND CLOSE THIS WINDOW", fg='yellow', bold=True) click.secho(" Do not screenshot or save to file!", fg='yellow') click.echo() if creds.pin: - click.secho("─── STATIC PIN ───", fg='green') + click.secho("--- STATIC PIN ---", fg='green') click.secho(f" {creds.pin}", fg='bright_yellow', bold=True) click.echo() - click.secho("─── DAILY PHRASES ───", fg='green') + click.secho("--- DAILY PHRASES ---", fg='green') for day in DAY_NAMES: phrase = creds.phrases[day] - click.echo(f" {day:9} │ ", nl=False) + click.echo(f" {day:9} | ", nl=False) click.secho(phrase, fg='bright_white') click.echo() if creds.rsa_key_pem: - click.secho("─── RSA KEY ───", fg='green') + click.secho("--- RSA KEY ---", fg='green') if output: # Save to file private_key = load_rsa_key(creds.rsa_key_pem.encode()) @@ -182,7 +192,7 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): click.echo(creds.rsa_key_pem) click.echo() - click.secho("─── SECURITY ───", fg='green') + click.secho("--- SECURITY ---", fg='green') click.echo(f" Phrase entropy: {creds.phrase_entropy} bits") if creds.pin: click.echo(f" PIN entropy: {creds.pin_entropy} bits") @@ -214,9 +224,14 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): @click.option('--output', '-o', type=click.Path(), help='Output file (default: auto-generated)') @click.option('--date', 'date_str', help='Date override (YYYY-MM-DD)') @click.option('--mode', 'embed_mode', type=click.Choice(['lsb', 'dct']), default='lsb', - help='Embedding mode: lsb (default, color) or dct (grayscale, requires scipy)') + help='Embedding mode: lsb (default, color) or dct (requires scipy)') +@click.option('--dct-format', 'dct_output_format', type=click.Choice(['png', 'jpeg']), default='png', + help='DCT output format: png (lossless, default) or jpeg (smaller)') +@click.option('--dct-color', 'dct_color_mode', type=click.Choice(['grayscale', 'color']), default='grayscale', + help='DCT color mode: grayscale (default) or color (preserves original colors)') @click.option('--quiet', '-q', is_flag=True, help='Suppress output except errors') -def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key, key_qr, key_password, output, date_str, embed_mode, quiet): +def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key, key_qr, + key_password, output, date_str, embed_mode, dct_output_format, dct_color_mode, quiet): """ Encode a secret message or file into an image. @@ -230,27 +245,37 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key \b Embedding Modes (v3.0): --mode lsb Spatial LSB embedding (default) - • Full color output (PNG/BMP) - • Higher capacity (~375 KB/megapixel) + - Full color output (PNG/BMP) + - Higher capacity (~375 KB/megapixel) --mode dct DCT domain embedding (requires scipy) - • Grayscale output only - • Lower capacity (~75 KB/megapixel) - • Better resistance to visual analysis + - Configurable color/grayscale output + - Lower capacity (~75 KB/megapixel) + - Better resistance to visual analysis + + \b + DCT Options (v3.0.1): + --dct-format png Lossless output (default) + --dct-format jpeg Smaller file, more natural appearance + + --dct-color grayscale Convert to grayscale (default, traditional) + --dct-color color Preserve original colors (experimental) \b Examples: # Text message with PIN (LSB mode, default) - stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -m "secret" + stegasoo encode -r photo.jpg -c meme.png -p "apple forest" --pin 123456 -m "secret" - # DCT mode for better stealth + # DCT mode - grayscale PNG (traditional) stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "secret" --mode dct - # With RSA key file - stegasoo encode -r photo.jpg -c meme.png -p "words" -k mykey.pem -m "secret" + # DCT mode - color JPEG (v3.0.1) + stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "secret" \ + --mode dct --dct-color color --dct-format jpeg - # Embed a binary file - stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -e secret.pdf + # DCT mode - color PNG (best quality + color preservation) + stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "secret" \ + --mode dct --dct-color color --dct-format png """ # Check DCT mode availability if embed_mode == 'dct' and not has_dct_support(): @@ -258,6 +283,12 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key "DCT mode requires scipy. Install with: pip install scipy" ) + # Warn if DCT options used with LSB mode + if embed_mode == 'lsb': + if dct_output_format != 'png' or dct_color_mode != 'grayscale': + if not quiet: + click.secho("Note: --dct-format and --dct-color only apply to DCT mode", fg='yellow', dim=True) + # Determine what to encode payload = None @@ -329,7 +360,10 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key ) if not quiet: - click.echo(f"Mode: {embed_mode.upper()} ({fit_check['usage_percent']:.1f}% capacity)") + mode_desc = embed_mode.upper() + if embed_mode == 'dct': + mode_desc += f" ({dct_color_mode}, {dct_output_format.upper()})" + click.echo(f"Mode: {mode_desc} ({fit_check['usage_percent']:.1f}% capacity)") result = encode( message=payload, @@ -340,7 +374,9 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key rsa_key_data=rsa_key_data, rsa_password=effective_key_password, date_str=date_str, - embed_mode=embed_mode, # NEW in v3.0 + embed_mode=embed_mode, + dct_output_format=dct_output_format, + dct_color_mode=dct_color_mode, ) # Determine output path @@ -353,13 +389,15 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key out_path.write_bytes(result.stego_image) if not quiet: - click.secho(f"✓ Encoded successfully!", fg='green') + click.secho(f"[OK] Encoded successfully!", fg='green') click.echo(f" Output: {out_path}") click.echo(f" Size: {len(result.stego_image):,} bytes") click.echo(f" Capacity used: {result.capacity_percent:.1f}%") click.echo(f" Date: {result.date_used}") if embed_mode == 'dct': - click.secho(f" Note: Output is grayscale (DCT mode)", dim=True) + color_note = "color preserved" if dct_color_mode == 'color' else "grayscale" + format_note = dct_output_format.upper() + click.secho(f" DCT output: {format_note} ({color_note})", dim=True) except StegasooError as e: raise click.ClickException(str(e)) @@ -394,6 +432,9 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed Automatically detects whether content is text or a file. RSA key can be provided as a .pem file (--key) or QR code image (--key-qr). + Note: Extraction works the same regardless of whether the image was + created with color mode or grayscale mode - both use the same Y channel. + \b Extraction Modes (v3.0): --mode auto Auto-detect (default) - tries LSB first, then DCT @@ -461,7 +502,7 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed pin=pin or "", rsa_key_data=rsa_key_data, rsa_password=effective_key_password, - embed_mode=embed_mode, # NEW in v3.0 + embed_mode=embed_mode, ) if result.is_file: @@ -481,7 +522,7 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed out_path.write_bytes(result.file_data) if not quiet: - click.secho("✓ Decoded file successfully!", fg='green') + click.secho("[OK] Decoded file successfully!", fg='green') click.echo(f" Saved to: {out_path}") click.echo(f" Size: {len(result.file_data):,} bytes") if result.mime_type: @@ -491,13 +532,13 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed if output: Path(output).write_text(result.message) if not quiet: - click.secho("✓ Decoded successfully!", fg='green') + click.secho("[OK] Decoded successfully!", fg='green') click.echo(f" Saved to: {output}") else: if quiet: click.echo(result.message) else: - click.secho("✓ Decoded successfully!", fg='green') + click.secho("[OK] Decoded successfully!", fg='green') click.echo() click.echo(result.message) @@ -583,7 +624,7 @@ def verify(ref, stego, phrase, pin, key, key_qr, key_password, embed_mode, as_js pin=pin or "", rsa_key_data=rsa_key_data, rsa_password=effective_key_password, - embed_mode=embed_mode, # NEW in v3.0 + embed_mode=embed_mode, ) # Calculate payload size @@ -617,7 +658,7 @@ def verify(ref, stego, phrase, pin, key, key_qr, key_password, embed_mode, as_js output["mime_type"] = result.mime_type click.echo(json.dumps(output, indent=2)) else: - click.secho("✓ Valid stego image", fg='green', bold=True) + click.secho("[OK] Valid stego image", fg='green', bold=True) click.echo(f" Payload: {payload_type} ({payload_desc})") click.echo(f" Size: {payload_size:,} bytes") if date_encoded: @@ -634,7 +675,7 @@ def verify(ref, stego, phrase, pin, key, key_qr, key_password, embed_mode, as_js click.echo(json.dumps(output, indent=2)) sys.exit(1) else: - click.secho("✗ Verification failed", fg='red', bold=True) + click.secho("[FAIL] Verification failed", fg='red', bold=True) click.echo(f" Error: {e}") sys.exit(1) except StegasooError as e: @@ -690,6 +731,8 @@ def info(image, as_json): "kb": round(comparison['dct']['capacity_kb'], 1), "available": comparison['dct']['available'], "ratio_vs_lsb": round(comparison['dct']['ratio_vs_lsb'], 1), + "output_formats": ["png", "jpeg"], + "color_modes": ["grayscale", "color"], }, }, } @@ -701,7 +744,7 @@ def info(image, as_json): click.echo() click.secho(f"Image: {image}", bold=True) - click.echo(f" Dimensions: {result.details['width']} × {result.details['height']}") + click.echo(f" Dimensions: {result.details['width']} x {result.details['height']}") click.echo(f" Pixels: {result.details['pixels']:,}") click.echo(f" Mode: {result.details['mode']}") click.echo(f" Format: {result.details['format']}") @@ -710,10 +753,13 @@ def info(image, as_json): click.secho(" Capacity:", bold=True) click.echo(f" LSB mode: ~{comparison['lsb']['capacity_bytes']:,} bytes ({comparison['lsb']['capacity_kb']:.1f} KB)") - dct_status = "✓" if comparison['dct']['available'] else "✗ (scipy not installed)" + dct_status = "[OK]" if comparison['dct']['available'] else "[X] (scipy not installed)" click.echo(f" DCT mode: ~{comparison['dct']['capacity_bytes']:,} bytes ({comparison['dct']['capacity_kb']:.1f} KB) {dct_status}") click.echo(f" DCT ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB") + if comparison['dct']['available']: + click.secho(" DCT options: grayscale/color, png/jpeg", dim=True) + if date_str: click.echo() click.echo(f" Embed date: {date_str} ({day_name})") @@ -725,7 +771,7 @@ def info(image, as_json): # ============================================================================ -# COMPARE COMMAND (NEW in v3.0) +# COMPARE COMMAND # ============================================================================ @cli.command() @@ -767,7 +813,8 @@ def compare(image, payload_size, as_json): "capacity_bytes": comparison['dct']['capacity_bytes'], "capacity_kb": round(comparison['dct']['capacity_kb'], 1), "available": comparison['dct']['available'], - "output_format": comparison['dct']['output'], + "output_formats": ["png", "jpeg"], + "color_modes": ["grayscale", "color"], "ratio_vs_lsb_percent": round(comparison['dct']['ratio_vs_lsb'], 1), }, }, @@ -784,60 +831,63 @@ def compare(image, payload_size, as_json): return click.echo() - click.secho(f"═══ Mode Comparison: {image} ═══", fg='cyan', bold=True) - click.echo(f" Dimensions: {comparison['width']} × {comparison['height']}") + click.secho(f"=== Mode Comparison: {image} ===", fg='cyan', bold=True) + click.echo(f" Dimensions: {comparison['width']} x {comparison['height']}") click.echo() # LSB mode - click.secho(" ┌─── LSB Mode ───", fg='green') - click.echo(f" │ Capacity: {comparison['lsb']['capacity_bytes']:,} bytes ({comparison['lsb']['capacity_kb']:.1f} KB)") - click.echo(f" │ Output: {comparison['lsb']['output']}") - click.echo(f" │ Status: ✓ Available") - click.echo(" │") + click.secho(" +--- LSB Mode ---", fg='green') + click.echo(f" | Capacity: {comparison['lsb']['capacity_bytes']:,} bytes ({comparison['lsb']['capacity_kb']:.1f} KB)") + click.echo(f" | Output: {comparison['lsb']['output']}") + click.echo(f" | Status: [OK] Available") + click.echo(" |") # DCT mode - click.secho(" ├─── DCT Mode ───", fg='blue') - click.echo(f" │ Capacity: {comparison['dct']['capacity_bytes']:,} bytes ({comparison['dct']['capacity_kb']:.1f} KB)") - click.echo(f" │ Output: {comparison['dct']['output']}") - click.echo(f" │ Ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB capacity") + click.secho(" +--- DCT Mode ---", fg='blue') + click.echo(f" | Capacity: {comparison['dct']['capacity_bytes']:,} bytes ({comparison['dct']['capacity_kb']:.1f} KB)") + click.echo(f" | Ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB capacity") if comparison['dct']['available']: - click.echo(f" │ Status: ✓ Available") + click.echo(f" | Status: [OK] Available") + click.echo(f" | Formats: PNG (lossless), JPEG (smaller)") + click.echo(f" | Colors: Grayscale (default), Color (v3.0.1)") else: - click.secho(f" │ Status: ✗ Requires scipy (pip install scipy)", fg='yellow') - click.echo(" │") + click.secho(f" | Status: [X] Requires scipy (pip install scipy)", fg='yellow') + click.echo(" |") # Payload check if payload_size: - click.secho(" ├─── Payload Check ───", fg='magenta') - click.echo(f" │ Size: {payload_size:,} bytes") + click.secho(" +--- Payload Check ---", fg='magenta') + click.echo(f" | Size: {payload_size:,} bytes") fits_lsb = payload_size <= comparison['lsb']['capacity_bytes'] fits_dct = payload_size <= comparison['dct']['capacity_bytes'] - lsb_icon = "✓" if fits_lsb else "✗" - dct_icon = "✓" if fits_dct else "✗" + lsb_icon = "[OK]" if fits_lsb else "[X]" + dct_icon = "[OK]" if fits_dct else "[X]" lsb_color = 'green' if fits_lsb else 'red' dct_color = 'green' if fits_dct else 'red' - click.echo(f" │ LSB mode: ", nl=False) + click.echo(f" | LSB mode: ", nl=False) click.secho(f"{lsb_icon} {'Fits' if fits_lsb else 'Too large'}", fg=lsb_color) - click.echo(f" │ DCT mode: ", nl=False) + click.echo(f" | DCT mode: ", nl=False) click.secho(f"{dct_icon} {'Fits' if fits_dct else 'Too large'}", fg=dct_color) - click.echo(" │") + click.echo(" |") # Recommendation - click.secho(" └─── Recommendation ───", fg='yellow') + click.secho(" +--- Recommendation ---", fg='yellow') if not comparison['dct']['available']: click.echo(" Use LSB mode (DCT unavailable)") elif payload_size: if fits_dct: click.echo(" DCT mode for better stealth (payload fits both modes)") + click.echo(" Use --dct-color color to preserve original colors") elif fits_lsb: click.echo(" LSB mode (payload too large for DCT)") else: - click.secho(" ✗ Payload too large for both modes!", fg='red') + click.secho(" [X] Payload too large for both modes!", fg='red') else: click.echo(" LSB for larger payloads, DCT for better stealth") + click.echo(" DCT supports color output with --dct-color color") click.echo() @@ -881,7 +931,7 @@ def strip_metadata_cmd(image, output, output_format, quiet): out_path.write_bytes(clean_data) if not quiet: - click.secho("✓ Metadata stripped", fg='green') + click.secho("[OK] Metadata stripped", fg='green') click.echo(f" Input: {image} ({original_size:,} bytes)") click.echo(f" Output: {out_path} ({len(clean_data):,} bytes)") @@ -890,7 +940,7 @@ def strip_metadata_cmd(image, output, output_format, quiet): # ============================================================================ -# MODES COMMAND (NEW in v3.0) +# MODES COMMAND # ============================================================================ @cli.command() @@ -901,12 +951,12 @@ def modes(): Displays which modes are available and their characteristics. """ click.echo() - click.secho("═══ Stegasoo Embedding Modes ═══", fg='cyan', bold=True) + click.secho("=== Stegasoo Embedding Modes ===", fg='cyan', bold=True) click.echo() # LSB Mode click.secho(" LSB Mode (Spatial LSB)", fg='green', bold=True) - click.echo(" Status: ✓ Always available") + click.echo(" Status: [OK] Always available") click.echo(" Output: PNG/BMP (full color)") click.echo(" Capacity: ~375 KB per megapixel") click.echo(" Use case: Larger payloads, color preservation") @@ -916,18 +966,36 @@ def modes(): # DCT Mode click.secho(" DCT Mode (Frequency Domain)", fg='blue', bold=True) if has_dct_support(): - click.echo(" Status: ✓ Available") + click.echo(" Status: [OK] Available") else: - click.secho(" Status: ✗ Requires scipy", fg='yellow') + click.secho(" Status: [X] Requires scipy", fg='yellow') click.echo(" Install: pip install scipy") - click.echo(" Output: PNG (grayscale only)") click.echo(" Capacity: ~75 KB per megapixel (~20% of LSB)") - click.echo(" Use case: Better stealth, smaller messages") + click.echo(" Use case: Better stealth, frequency domain hiding") click.echo(" CLI flag: --mode dct") click.echo() - click.secho(" Tip:", dim=True) - click.echo(" Use 'stegasoo compare ' to see capacity for both modes") + # DCT Options (v3.0.1) + click.secho(" DCT Options (v3.0.1)", fg='magenta', bold=True) + click.echo(" Output format:") + click.echo(" --dct-format png Lossless, larger file (default)") + click.echo(" --dct-format jpeg Lossy, smaller, more natural") + click.echo() + click.echo(" Color mode:") + click.echo(" --dct-color grayscale Traditional DCT (default)") + click.echo(" --dct-color color Preserves original colors") + click.echo() + + # Examples + click.secho(" Examples:", dim=True) + click.echo(" # Traditional DCT (grayscale PNG)") + click.echo(" stegasoo encode ... --mode dct") + click.echo() + click.echo(" # Color-preserving DCT with JPEG output") + click.echo(" stegasoo encode ... --mode dct --dct-color color --dct-format jpeg") + click.echo() + click.echo(" # Compare modes for an image") + click.echo(" stegasoo compare carrier.png") click.echo() diff --git a/frontends/web/app.py b/frontends/web/app.py index 9628c19..45295c2 100644 --- a/frontends/web/app.py +++ b/frontends/web/app.py @@ -5,7 +5,7 @@ Stegasoo Web Frontend (v3.0.1) Flask-based web UI for steganography operations. Supports both text messages and file embedding. NEW in v3.0: LSB and DCT embedding modes with advanced options. -NEW in v3.0.1: DCT output format selection (PNG or JPEG). +NEW in v3.0.1: DCT output format selection (PNG or JPEG) and color mode (grayscale or color). """ import io @@ -532,6 +532,11 @@ def encode_page(): if dct_output_format not in ('png', 'jpeg'): dct_output_format = 'png' + # NEW in v3.0.1 - DCT color mode (default to 'color') + dct_color_mode = request.form.get('dct_color_mode', 'color') + if dct_color_mode not in ('grayscale', 'color'): + dct_color_mode = 'color' + # Check DCT availability if embed_mode == 'dct' and not has_dct_support(): flash('DCT mode requires scipy. Install with: pip install scipy', 'error') @@ -624,7 +629,7 @@ def encode_page(): else: date_str = datetime.now().strftime('%Y-%m-%d') - # Encode with selected mode and output format + # Encode with selected mode, output format, and color mode encode_result = encode( message=payload, reference_photo=ref_data, @@ -634,8 +639,9 @@ def encode_page(): rsa_key_data=rsa_key_data, rsa_password=key_password, date_str=date_str, - embed_mode=embed_mode, # NEW in v3.0 - dct_output_format=dct_output_format if embed_mode == 'dct' else None, # NEW in v3.0.1 + 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, ) # Determine actual output format for filename and storage @@ -660,6 +666,7 @@ def encode_page(): 'timestamp': time.time(), 'embed_mode': embed_mode, 'output_format': dct_output_format if embed_mode == 'dct' else 'png', + 'color_mode': dct_color_mode if embed_mode == 'dct' else None, 'mime_type': output_mime, } @@ -699,7 +706,8 @@ def encode_result(file_id): filename=file_info['filename'], thumbnail_url=url_for('encode_thumbnail', thumb_id=thumbnail_id) if thumbnail_id else None, embed_mode=file_info.get('embed_mode', 'lsb'), - output_format=file_info.get('output_format', 'png'), # NEW in v3.0.1 + output_format=file_info.get('output_format', 'png'), + color_mode=file_info.get('color_mode'), # NEW in v3.0.1 ) @@ -856,7 +864,7 @@ def decode_page(): rsa_key_data=rsa_key_data, rsa_password=key_password, date_str=stego_date if stego_date else None, - embed_mode=embed_mode, # NEW in v3.0 + embed_mode=embed_mode, ) if decode_result.is_file: diff --git a/frontends/web/app.py.orig b/frontends/web/app.py.orig deleted file mode 100644 index 01a9b5b..0000000 --- a/frontends/web/app.py.orig +++ /dev/null @@ -1,766 +0,0 @@ -#!/usr/bin/env python3 -""" -Stegasoo Web Frontend - -Flask-based web UI for steganography operations. -Supports both text messages and file embedding. -""" - -import io -import sys -import time -import secrets -import mimetypes -from pathlib import Path -from datetime import datetime -from PIL import Image - -from flask import ( - Flask, render_template, request, send_file, - jsonify, flash, redirect, url_for -) - -# 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, - export_rsa_key_pem, load_rsa_key, - validate_pin, validate_message, validate_image, - validate_rsa_key, validate_security_factors, - validate_file_payload, - get_today_day, generate_filename, - DAY_NAMES, __version__, - StegasooError, DecryptionError, CapacityError, - has_argon2, - FilePayload, - MAX_FILE_PAYLOAD_SIZE, -) -from stegasoo.constants import ( - MAX_MESSAGE_SIZE, MIN_PIN_LENGTH, MAX_PIN_LENGTH, - VALID_RSA_SIZES, MAX_FILE_SIZE, -) - -# QR Code support -try: - import qrcode - from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M - HAS_QRCODE = True -except ImportError: - HAS_QRCODE = False - -# QR Code reading -try: - from pyzbar.pyzbar import decode as pyzbar_decode - HAS_QRCODE_READ = True -except ImportError: - HAS_QRCODE_READ = False - -import zlib -import base64 - -# Import QR utilities -from stegasoo.qr_utils import ( - compress_data, decompress_data, auto_decompress, - is_compressed, can_fit_in_qr, needs_compression, - generate_qr_code, read_qr_code, extract_key_from_qr, - has_qr_write, has_qr_read, - QR_MAX_BINARY, COMPRESSION_PREFIX -) - - -# ============================================================================ -# FLASK APP CONFIGURATION -# ============================================================================ - -app = Flask(__name__) -app.secret_key = secrets.token_hex(32) -app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE # 20MB max upload - -# Temporary file storage for sharing (file_id -> {data, timestamp, filename}) -TEMP_FILES: dict[str, dict] = {} -THUMBNAIL_FILES: dict[str, bytes] = {} -TEMP_FILE_EXPIRY = 300 # 5 minutes -THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnail - -# ============================================================================ -# CONFIGURATION -# ============================================================================ - -# Override stegasoo limits for larger files -# Note: You might need to modify the stegasoo library itself -# to actually increase these limits in its internal calculations - -# Flask upload limit (30MB) -MAX_UPLOAD_SIZE = 30 * 1024 * 1024 - -# Try to import and override stegasoo constants if possible -try: - # Check current limits - print(f"Current MAX_FILE_SIZE from constants: {MAX_FILE_SIZE}") - print(f"Current MAX_FILE_PAYLOAD_SIZE: {MAX_FILE_PAYLOAD_SIZE}") - - DESIRED_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2MB - - # Note: You might need to patch the stegasoo module - # if MAX_FILE_PAYLOAD_SIZE is used internally - import stegasoo - if hasattr(stegasoo, 'MAX_FILE_PAYLOAD_SIZE'): - print(f"Overriding MAX_FILE_PAYLOAD_SIZE to {DESIRED_PAYLOAD_SIZE}") - stegasoo.MAX_FILE_PAYLOAD_SIZE = DESIRED_PAYLOAD_SIZE - -except Exception as e: - print(f"Could not override stegasoo limits: {e}") - -def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes: - """Generate thumbnail from image data.""" - try: - with Image.open(io.BytesIO(image_data)) as img: - # Convert to RGB if necessary - if img.mode in ('RGBA', 'LA', 'P'): - # Create white background for transparent images - background = Image.new('RGB', img.size, (255, 255, 255)) - if img.mode == 'P': - img = img.convert('RGBA') - background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) - img = background - elif img.mode != 'RGB': - img = img.convert('RGB') - - # Create thumbnail - img.thumbnail(size, Image.Resampling.LANCZOS) - - # Save to bytes - buffer = io.BytesIO() - img.save(buffer, format='JPEG', quality=85, optimize=True) - return buffer.getvalue() - except Exception as e: - # Log error but don't crash - print(f"Thumbnail generation error: {e}") - return None - - -def cleanup_temp_files(): - """Remove expired temporary files.""" - now = time.time() - expired = [fid for fid, info in TEMP_FILES.items() if now - info['timestamp'] > TEMP_FILE_EXPIRY] - - for fid in expired: - TEMP_FILES.pop(fid, None) - # Also clean up corresponding thumbnail - thumb_id = f"{fid}_thumb" - THUMBNAIL_FILES.pop(thumb_id, None) - - -def allowed_image(filename: str) -> bool: - """Check if file has allowed image extension.""" - if not filename or '.' not in filename: - return False - ext = filename.rsplit('.', 1)[1].lower() - return ext in {'png', 'jpg', 'jpeg', 'bmp', 'gif'} - - -def format_size(size_bytes: int) -> str: - """Format file size for display.""" - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - else: - return f"{size_bytes / (1024 * 1024):.1f} MB" - - -# ============================================================================ -# ROUTES -# ============================================================================ - -@app.route('/') -def index(): - return render_template('index.html') - - -@app.route('/generate', methods=['GET', 'POST']) -def generate(): - if request.method == 'POST': - words_per_phrase = int(request.form.get('words_per_phrase', 3)) - use_pin = request.form.get('use_pin') == 'on' - use_rsa = request.form.get('use_rsa') == 'on' - - if not use_pin and not use_rsa: - flash('You must select at least one security factor (PIN or RSA Key)', 'error') - return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) - - pin_length = int(request.form.get('pin_length', 6)) - rsa_bits = int(request.form.get('rsa_bits', 2048)) - - # Clamp values - words_per_phrase = max(3, min(12, words_per_phrase)) - pin_length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, pin_length)) - if rsa_bits not in VALID_RSA_SIZES: - rsa_bits = 2048 - - try: - creds = generate_credentials( - use_pin=use_pin, - use_rsa=use_rsa, - pin_length=pin_length, - rsa_bits=rsa_bits, - words_per_phrase=words_per_phrase - ) - - # Store RSA key temporarily for QR generation - qr_token = None - qr_needs_compression = False - qr_too_large = False - - if creds.rsa_key_pem and HAS_QRCODE: - # Check if key fits in QR code - if can_fit_in_qr(creds.rsa_key_pem, compress=True): - qr_needs_compression = True - else: - qr_too_large = True - - if not qr_too_large: - qr_token = secrets.token_urlsafe(16) - cleanup_temp_files() - TEMP_FILES[qr_token] = { - 'data': creds.rsa_key_pem.encode(), - 'filename': 'rsa_key.pem', - 'timestamp': time.time(), - 'type': 'rsa_key', - 'compress': qr_needs_compression - } - - return render_template('generate.html', - phrases=creds.phrases, - pin=creds.pin, - days=DAY_NAMES, - generated=True, - words_per_phrase=words_per_phrase, - pin_length=pin_length if use_pin else None, - use_pin=use_pin, - use_rsa=use_rsa, - rsa_bits=rsa_bits, - rsa_key_pem=creds.rsa_key_pem, - phrase_entropy=creds.phrase_entropy, - pin_entropy=creds.pin_entropy, - rsa_entropy=creds.rsa_entropy, - total_entropy=creds.total_entropy, - has_qrcode=HAS_QRCODE, - qr_token=qr_token, - qr_needs_compression=qr_needs_compression, - qr_too_large=qr_too_large - ) - except Exception as e: - flash(f'Error generating credentials: {e}', 'error') - return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) - - return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) - - -@app.route('/generate/qr/') -def generate_qr(token): - """Generate QR code for RSA key.""" - if not HAS_QRCODE: - return "QR code support not available", 501 - - if token not in TEMP_FILES: - return "Token expired or invalid", 404 - - file_info = TEMP_FILES[token] - if file_info.get('type') != 'rsa_key': - return "Invalid token type", 400 - - try: - key_pem = file_info['data'].decode('utf-8') - compress = file_info.get('compress', False) - qr_png = generate_qr_code(key_pem, compress=compress) - - return send_file( - io.BytesIO(qr_png), - mimetype='image/png', - as_attachment=False - ) - except Exception as e: - return f"Error generating QR code: {e}", 500 - - -@app.route('/generate/qr-download/') -def generate_qr_download(token): - """Download QR code as PNG file.""" - if not HAS_QRCODE: - return "QR code support not available", 501 - - if token not in TEMP_FILES: - return "Token expired or invalid", 404 - - file_info = TEMP_FILES[token] - if file_info.get('type') != 'rsa_key': - return "Invalid token type", 400 - - try: - key_pem = file_info['data'].decode('utf-8') - compress = file_info.get('compress', False) - qr_png = generate_qr_code(key_pem, compress=compress) - - return send_file( - io.BytesIO(qr_png), - mimetype='image/png', - as_attachment=True, - download_name='stegasoo_rsa_key_qr.png' - ) - except Exception as e: - return f"Error generating QR code: {e}", 500 - - -@app.route('/generate/download-key', methods=['POST']) -def download_key(): - """Download RSA key as password-protected PEM file.""" - key_pem = request.form.get('key_pem', '') - password = request.form.get('key_password', '') - - if not key_pem: - flash('No key to download', 'error') - return redirect(url_for('generate')) - - if not password or len(password) < 8: - flash('Password must be at least 8 characters', 'error') - return redirect(url_for('generate')) - - try: - private_key = load_rsa_key(key_pem.encode('utf-8')) - encrypted_pem = export_rsa_key_pem(private_key, password=password) - - key_id = secrets.token_hex(4) - filename = f'stegasoo_key_{private_key.key_size}_{key_id}.pem' - - return send_file( - io.BytesIO(encrypted_pem), - mimetype='application/x-pem-file', - as_attachment=True, - download_name=filename - ) - except Exception as e: - flash(f'Error creating key file: {e}', 'error') - return redirect(url_for('generate')) - - -@app.route('/extract-key-from-qr', methods=['POST']) -def extract_key_from_qr_route(): - """ - Extract RSA key from uploaded QR code image. - Returns JSON with the extracted key or error. - """ - if not HAS_QRCODE_READ: - return jsonify({ - 'success': False, - 'error': 'QR code reading not available. Install pyzbar and libzbar.' - }), 501 - - qr_image = request.files.get('qr_image') - if not qr_image: - return jsonify({ - 'success': False, - 'error': 'No QR image provided' - }), 400 - - try: - image_data = qr_image.read() - key_pem = extract_key_from_qr(image_data) - - if key_pem: - return jsonify({ - 'success': True, - 'key_pem': key_pem - }) - else: - return jsonify({ - 'success': False, - 'error': 'No valid RSA key found in QR code' - }), 400 - - except Exception as e: - return jsonify({ - 'success': False, - 'error': str(e) - }), 500 - - -@app.route('/encode', methods=['GET', 'POST']) -def encode_page(): - day_of_week = get_today_day() - max_payload_kb = MAX_FILE_PAYLOAD_SIZE // 1024 - - if request.method == 'POST': - try: - # Get files - ref_photo = request.files.get('reference_photo') - carrier = request.files.get('carrier') - rsa_key_file = request.files.get('rsa_key') - payload_file = request.files.get('payload_file') - - if not ref_photo or not carrier: - flash('Both reference photo and carrier image are required', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename): - flash('Invalid file type. Use PNG, JPG, or BMP', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Get form data - message = request.form.get('message', '') - day_phrase = request.form.get('day_phrase', '') - pin = request.form.get('pin', '').strip() - rsa_password = request.form.get('rsa_password', '') - payload_type = request.form.get('payload_type', 'text') - - # Determine payload - if payload_type == 'file' and payload_file and payload_file.filename: - # File payload - file_data = payload_file.read() - - result = validate_file_payload(file_data, payload_file.filename) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - mime_type, _ = mimetypes.guess_type(payload_file.filename) - payload = FilePayload( - data=file_data, - filename=payload_file.filename, - mime_type=mime_type - ) - else: - # Text message - result = validate_message(message) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - payload = message - - if not day_phrase: - flash('Day phrase is required', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Read files - ref_data = ref_photo.read() - carrier_data = carrier.read() - - # Handle RSA key - can come from .pem file or QR code image - rsa_key_data = None - rsa_key_qr = request.files.get('rsa_key_qr') - rsa_key_from_qr = False # Track source for password handling - - if rsa_key_file and rsa_key_file.filename: - # RSA key from .pem file - rsa_key_data = rsa_key_file.read() - elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ: - # RSA key from QR code image - qr_image_data = rsa_key_qr.read() - key_pem = extract_key_from_qr(qr_image_data) - if key_pem: - rsa_key_data = key_pem.encode('utf-8') - rsa_key_from_qr = True # QR keys are never password-protected - else: - flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Validate security factors - result = validate_security_factors(pin, rsa_key_data) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Validate PIN if provided - if pin: - result = validate_pin(pin) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Determine key password - QR code keys are never password-protected - key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) - - # Validate RSA key if provided - if rsa_key_data: - result = validate_rsa_key(rsa_key_data, key_password) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Validate carrier image - result = validate_image(carrier_data, "Carrier image") - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Get date - client_date = request.form.get('client_date', '').strip() - if client_date and len(client_date) == 10 and client_date[4] == '-' and client_date[7] == '-': - date_str = client_date - else: - date_str = datetime.now().strftime('%Y-%m-%d') - - # Encode - encode_result = encode( - message=payload, - reference_photo=ref_data, - carrier_image=carrier_data, - day_phrase=day_phrase, - pin=pin, - rsa_key_data=rsa_key_data, - rsa_password=key_password, - date_str=date_str - ) - - # Store temporarily - file_id = secrets.token_urlsafe(16) - cleanup_temp_files() - TEMP_FILES[file_id] = { - 'data': encode_result.stego_image, - 'filename': encode_result.filename, - 'timestamp': time.time() - } - - return redirect(url_for('encode_result', file_id=file_id)) - - except CapacityError as e: - flash(str(e), 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - except StegasooError as e: - flash(str(e), 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - except Exception as e: - flash(f'Error: {e}', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - -@app.route('/encode/result/') -def encode_result(file_id): - if file_id not in TEMP_FILES: - flash('File expired or not found. Please encode again.', 'error') - return redirect(url_for('encode_page')) - - file_info = TEMP_FILES[file_id] - - # Generate thumbnail - thumbnail_data = generate_thumbnail(file_info['data']) - thumbnail_id = None - - if thumbnail_data: - thumbnail_id = f"{file_id}_thumb" - THUMBNAIL_FILES[thumbnail_id] = thumbnail_data - - return render_template('encode_result.html', - file_id=file_id, - filename=file_info['filename'], - thumbnail_url=url_for('encode_thumbnail', thumb_id=thumbnail_id) if thumbnail_id else None - ) - - -@app.route('/encode/thumbnail/') -def encode_thumbnail(thumb_id): - """Serve thumbnail image.""" - if thumb_id not in THUMBNAIL_FILES: - return "Thumbnail not found", 404 - - return send_file( - io.BytesIO(THUMBNAIL_FILES[thumb_id]), - mimetype='image/jpeg', - as_attachment=False - ) - - -@app.route('/encode/download/') -def encode_download(file_id): - if file_id not in TEMP_FILES: - flash('File expired or not found.', 'error') - return redirect(url_for('encode_page')) - - file_info = TEMP_FILES[file_id] - return send_file( - io.BytesIO(file_info['data']), - mimetype='image/png', - as_attachment=True, - download_name=file_info['filename'] - ) - - -@app.route('/encode/file/') -def encode_file_route(file_id): - """Serve file for Web Share API.""" - if file_id not in TEMP_FILES: - return "Not found", 404 - - file_info = TEMP_FILES[file_id] - return send_file( - io.BytesIO(file_info['data']), - mimetype='image/png', - as_attachment=False, - download_name=file_info['filename'] - ) - - -@app.route('/encode/cleanup/', methods=['POST']) -def encode_cleanup(file_id): - """Manually cleanup a file after sharing.""" - TEMP_FILES.pop(file_id, None) - - # Also cleanup thumbnail if exists - thumb_id = f"{file_id}_thumb" - THUMBNAIL_FILES.pop(thumb_id, None) - - return jsonify({'status': 'ok'}) - - -@app.route('/decode', methods=['GET', 'POST']) -def decode_page(): - if request.method == 'POST': - try: - # Get files - ref_photo = request.files.get('reference_photo') - stego_image = request.files.get('stego_image') - rsa_key_file = request.files.get('rsa_key') - - if not ref_photo or not stego_image: - flash('Both reference photo and stego image are required', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Get form data - day_phrase = request.form.get('day_phrase', '') - pin = request.form.get('pin', '').strip() - rsa_password = request.form.get('rsa_password', '') - - if not day_phrase: - flash('Day phrase is required', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Read files - ref_data = ref_photo.read() - stego_data = stego_image.read() - - # Handle RSA key - can come from .pem file or QR code image - rsa_key_data = None - rsa_key_qr = request.files.get('rsa_key_qr') - rsa_key_from_qr = False # Track source for password handling - - if rsa_key_file and rsa_key_file.filename: - # RSA key from .pem file - rsa_key_data = rsa_key_file.read() - elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ: - # RSA key from QR code image - qr_image_data = rsa_key_qr.read() - key_pem = extract_key_from_qr(qr_image_data) - if key_pem: - rsa_key_data = key_pem.encode('utf-8') - rsa_key_from_qr = True # QR keys are never password-protected - else: - flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Validate security factors - result = validate_security_factors(pin, rsa_key_data) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Validate PIN if provided - if pin: - result = validate_pin(pin) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Determine key password - QR code keys are never password-protected - key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) - - # Validate RSA key if provided - if rsa_key_data: - result = validate_rsa_key(rsa_key_data, key_password) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Decode - decode_result = decode( - stego_image=stego_data, - reference_photo=ref_data, - day_phrase=day_phrase, - pin=pin, - rsa_key_data=rsa_key_data, - rsa_password=key_password - ) - - if decode_result.is_file: - # File content - store temporarily for download - file_id = secrets.token_urlsafe(16) - cleanup_temp_files() - - filename = decode_result.filename or 'decoded_file' - TEMP_FILES[file_id] = { - 'data': decode_result.file_data, - 'filename': filename, - 'mime_type': decode_result.mime_type, - 'timestamp': time.time() - } - - return render_template('decode.html', - decoded_file=True, - file_id=file_id, - filename=filename, - file_size=format_size(len(decode_result.file_data)), - mime_type=decode_result.mime_type - ) - else: - # Text content - return render_template('decode.html', decoded_message=decode_result.message) - - except DecryptionError: - flash('Decryption failed. Check your phrase, PIN, RSA key, and reference photo.', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - except StegasooError as e: - flash(str(e), 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - except Exception as e: - flash(f'Error: {e}', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - -@app.route('/decode/download/') -def decode_download(file_id): - """Download decoded file.""" - if file_id not in TEMP_FILES: - flash('File expired or not found.', 'error') - return redirect(url_for('decode_page')) - - file_info = TEMP_FILES[file_id] - mime_type = file_info.get('mime_type', 'application/octet-stream') - - return send_file( - io.BytesIO(file_info['data']), - mimetype=mime_type, - as_attachment=True, - download_name=file_info['filename'] - ) - - -@app.route('/about') -def about(): - return render_template('about.html', - has_argon2=has_argon2(), - has_qrcode_read=HAS_QRCODE_READ, - max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024 - ) - - -# ============================================================================ -# MAIN -# ============================================================================ - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/frontends/web/app.py_20251229 b/frontends/web/app.py_20251229 deleted file mode 100644 index 2a4f86c..0000000 --- a/frontends/web/app.py_20251229 +++ /dev/null @@ -1,781 +0,0 @@ -#!/usr/bin/env python3 -""" -Stegasoo Web Frontend - -Flask-based web UI for steganography operations. -Supports both text messages and file embedding. -""" - -import io -import sys -import time -import secrets -import mimetypes -from pathlib import Path -from datetime import datetime -from PIL import Image - -from flask import ( - Flask, render_template, request, send_file, - jsonify, flash, redirect, url_for -) - -# 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, - export_rsa_key_pem, load_rsa_key, - validate_pin, validate_message, validate_image, - validate_rsa_key, validate_security_factors, - validate_file_payload, - get_today_day, generate_filename, - DAY_NAMES, __version__, - StegasooError, DecryptionError, CapacityError, - has_argon2, - FilePayload, - MAX_FILE_PAYLOAD_SIZE, -) -from stegasoo.constants import ( - MAX_MESSAGE_SIZE, MIN_PIN_LENGTH, MAX_PIN_LENGTH, - VALID_RSA_SIZES, MAX_FILE_SIZE, -) - -# QR Code support -try: - import qrcode - from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M - HAS_QRCODE = True -except ImportError: - HAS_QRCODE = False - -# QR Code reading -try: - from pyzbar.pyzbar import decode as pyzbar_decode - HAS_QRCODE_READ = True -except ImportError: - HAS_QRCODE_READ = False - -import zlib -import base64 - -# Import QR utilities -from stegasoo.qr_utils import ( - compress_data, decompress_data, auto_decompress, - is_compressed, can_fit_in_qr, needs_compression, - generate_qr_code, read_qr_code, extract_key_from_qr, - has_qr_write, has_qr_read, - QR_MAX_BINARY, COMPRESSION_PREFIX -) - - -# ============================================================================ -# FLASK APP CONFIGURATION -# ============================================================================ - -app = Flask(__name__) -app.secret_key = secrets.token_hex(32) -app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE # 20MB max upload - -# Temporary file storage for sharing (file_id -> {data, timestamp, filename}) -TEMP_FILES: dict[str, dict] = {} -THUMBNAIL_FILES: dict[str, bytes] = {} -TEMP_FILE_EXPIRY = 300 # 5 minutes -THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnail - -# ============================================================================ -# CONFIGURATION -# ============================================================================ - -# Override stegasoo limits for larger files -# Note: You might need to modify the stegasoo library itself -# to actually increase these limits in its internal calculations - -# Flask upload limit (30MB) -MAX_UPLOAD_SIZE = 30 * 1024 * 1024 - -# Try to import and override stegasoo constants if possible -try: - # Check current limits - print(f"Current MAX_FILE_SIZE from constants: {MAX_FILE_SIZE}") - print(f"Current MAX_FILE_PAYLOAD_SIZE: {MAX_FILE_PAYLOAD_SIZE}") - - DESIRED_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2MB - - # Note: You might need to patch the stegasoo module - # if MAX_FILE_PAYLOAD_SIZE is used internally - import stegasoo - if hasattr(stegasoo, 'MAX_FILE_PAYLOAD_SIZE'): - print(f"Overriding MAX_FILE_PAYLOAD_SIZE to {DESIRED_PAYLOAD_SIZE}") - stegasoo.MAX_FILE_PAYLOAD_SIZE = DESIRED_PAYLOAD_SIZE - -except Exception as e: - print(f"Could not override stegasoo limits: {e}") - -def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes: - """Generate thumbnail from image data.""" - try: - with Image.open(io.BytesIO(image_data)) as img: - # Convert to RGB if necessary - if img.mode in ('RGBA', 'LA', 'P'): - # Create white background for transparent images - background = Image.new('RGB', img.size, (255, 255, 255)) - if img.mode == 'P': - img = img.convert('RGBA') - background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) - img = background - elif img.mode != 'RGB': - img = img.convert('RGB') - - # Create thumbnail - img.thumbnail(size, Image.Resampling.LANCZOS) - - # Save to bytes - buffer = io.BytesIO() - img.save(buffer, format='JPEG', quality=85, optimize=True) - return buffer.getvalue() - except Exception as e: - # Log error but don't crash - print(f"Thumbnail generation error: {e}") - return None - - -def cleanup_temp_files(): - """Remove expired temporary files.""" - now = time.time() - expired = [fid for fid, info in TEMP_FILES.items() if now - info['timestamp'] > TEMP_FILE_EXPIRY] - - for fid in expired: - TEMP_FILES.pop(fid, None) - # Also clean up corresponding thumbnail - thumb_id = f"{fid}_thumb" - THUMBNAIL_FILES.pop(thumb_id, None) - - -def allowed_image(filename: str) -> bool: - """Check if file has allowed image extension.""" - if not filename or '.' not in filename: - return False - ext = filename.rsplit('.', 1)[1].lower() - return ext in {'png', 'jpg', 'jpeg', 'bmp', 'gif'} - - -def format_size(size_bytes: int) -> str: - """Format file size for display.""" - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - else: - return f"{size_bytes / (1024 * 1024):.1f} MB" - - -# ============================================================================ -# ROUTES -# ============================================================================ - -@app.route('/') -def index(): - return render_template('index.html') - - -@app.route('/generate', methods=['GET', 'POST']) -def generate(): - if request.method == 'POST': - words_per_phrase = int(request.form.get('words_per_phrase', 3)) - use_pin = request.form.get('use_pin') == 'on' - use_rsa = request.form.get('use_rsa') == 'on' - - if not use_pin and not use_rsa: - flash('You must select at least one security factor (PIN or RSA Key)', 'error') - return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) - - pin_length = int(request.form.get('pin_length', 6)) - rsa_bits = int(request.form.get('rsa_bits', 2048)) - - # Clamp values - words_per_phrase = max(3, min(12, words_per_phrase)) - pin_length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, pin_length)) - if rsa_bits not in VALID_RSA_SIZES: - rsa_bits = 2048 - - try: - creds = generate_credentials( - use_pin=use_pin, - use_rsa=use_rsa, - pin_length=pin_length, - rsa_bits=rsa_bits, - words_per_phrase=words_per_phrase - ) - - # Store RSA key temporarily for QR generation - qr_token = None - qr_needs_compression = False - qr_too_large = False - - if creds.rsa_key_pem and HAS_QRCODE: - # Check if key fits in QR code - if can_fit_in_qr(creds.rsa_key_pem, compress=True): - qr_needs_compression = True - else: - qr_too_large = True - - if not qr_too_large: - qr_token = secrets.token_urlsafe(16) - cleanup_temp_files() - TEMP_FILES[qr_token] = { - 'data': creds.rsa_key_pem.encode(), - 'filename': 'rsa_key.pem', - 'timestamp': time.time(), - 'type': 'rsa_key', - 'compress': qr_needs_compression - } - - return render_template('generate.html', - phrases=creds.phrases, - pin=creds.pin, - days=DAY_NAMES, - generated=True, - words_per_phrase=words_per_phrase, - pin_length=pin_length if use_pin else None, - use_pin=use_pin, - use_rsa=use_rsa, - rsa_bits=rsa_bits, - rsa_key_pem=creds.rsa_key_pem, - phrase_entropy=creds.phrase_entropy, - pin_entropy=creds.pin_entropy, - rsa_entropy=creds.rsa_entropy, - total_entropy=creds.total_entropy, - has_qrcode=HAS_QRCODE, - qr_token=qr_token, - qr_needs_compression=qr_needs_compression, - qr_too_large=qr_too_large - ) - except Exception as e: - flash(f'Error generating credentials: {e}', 'error') - return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) - - return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) - - -@app.route('/generate/qr/') -def generate_qr(token): - """Generate QR code for RSA key.""" - if not HAS_QRCODE: - return "QR code support not available", 501 - - if token not in TEMP_FILES: - return "Token expired or invalid", 404 - - file_info = TEMP_FILES[token] - if file_info.get('type') != 'rsa_key': - return "Invalid token type", 400 - - try: - key_pem = file_info['data'].decode('utf-8') - compress = file_info.get('compress', False) - qr_png = generate_qr_code(key_pem, compress=compress) - - return send_file( - io.BytesIO(qr_png), - mimetype='image/png', - as_attachment=False - ) - except Exception as e: - return f"Error generating QR code: {e}", 500 - - -@app.route('/generate/qr-download/') -def generate_qr_download(token): - """Download QR code as PNG file.""" - if not HAS_QRCODE: - return "QR code support not available", 501 - - if token not in TEMP_FILES: - return "Token expired or invalid", 404 - - file_info = TEMP_FILES[token] - if file_info.get('type') != 'rsa_key': - return "Invalid token type", 400 - - try: - key_pem = file_info['data'].decode('utf-8') - compress = file_info.get('compress', False) - qr_png = generate_qr_code(key_pem, compress=compress) - - return send_file( - io.BytesIO(qr_png), - mimetype='image/png', - as_attachment=True, - download_name='stegasoo_rsa_key_qr.png' - ) - except Exception as e: - return f"Error generating QR code: {e}", 500 - - -@app.route('/generate/download-key', methods=['POST']) -def download_key(): - """Download RSA key as password-protected PEM file.""" - key_pem = request.form.get('key_pem', '') - password = request.form.get('key_password', '') - - if not key_pem: - flash('No key to download', 'error') - return redirect(url_for('generate')) - - if not password or len(password) < 8: - flash('Password must be at least 8 characters', 'error') - return redirect(url_for('generate')) - - try: - private_key = load_rsa_key(key_pem.encode('utf-8')) - encrypted_pem = export_rsa_key_pem(private_key, password=password) - - key_id = secrets.token_hex(4) - filename = f'stegasoo_key_{private_key.key_size}_{key_id}.pem' - - return send_file( - io.BytesIO(encrypted_pem), - mimetype='application/x-pem-file', - as_attachment=True, - download_name=filename - ) - except Exception as e: - flash(f'Error creating key file: {e}', 'error') - return redirect(url_for('generate')) - - -@app.route('/extract-key-from-qr', methods=['POST']) -def extract_key_from_qr_route(): - """ - Extract RSA key from uploaded QR code image. - Returns JSON with the extracted key or error. - """ - if not HAS_QRCODE_READ: - return jsonify({ - 'success': False, - 'error': 'QR code reading not available. Install pyzbar and libzbar.' - }), 501 - - qr_image = request.files.get('qr_image') - if not qr_image: - return jsonify({ - 'success': False, - 'error': 'No QR image provided' - }), 400 - - try: - image_data = qr_image.read() - key_pem = extract_key_from_qr(image_data) - - if key_pem: - return jsonify({ - 'success': True, - 'key_pem': key_pem - }) - else: - return jsonify({ - 'success': False, - 'error': 'No valid RSA key found in QR code' - }), 400 - - except Exception as e: - return jsonify({ - 'success': False, - 'error': str(e) - }), 500 - - -@app.route('/encode', methods=['GET', 'POST']) -def encode_page(): - day_of_week = get_today_day() - max_payload_kb = MAX_FILE_PAYLOAD_SIZE // 1024 - - if request.method == 'POST': - try: - # Get files - ref_photo = request.files.get('reference_photo') - carrier = request.files.get('carrier') - rsa_key_file = request.files.get('rsa_key') - payload_file = request.files.get('payload_file') - - if not ref_photo or not carrier: - flash('Both reference photo and carrier image are required', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename): - flash('Invalid file type. Use PNG, JPG, or BMP', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Get form data - message = request.form.get('message', '') - day_phrase = request.form.get('day_phrase', '') - pin = request.form.get('pin', '').strip() - rsa_password = request.form.get('rsa_password', '') - payload_type = request.form.get('payload_type', 'text') - - # Determine payload - if payload_type == 'file' and payload_file and payload_file.filename: - # File payload - file_data = payload_file.read() - - result = validate_file_payload(file_data, payload_file.filename) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - mime_type, _ = mimetypes.guess_type(payload_file.filename) - payload = FilePayload( - data=file_data, - filename=payload_file.filename, - mime_type=mime_type - ) - else: - # Text message - result = validate_message(message) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - payload = message - - if not day_phrase: - flash('Day phrase is required', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Read files - ref_data = ref_photo.read() - carrier_data = carrier.read() - - # Handle RSA key - can come from .pem file or QR code image - rsa_key_data = None - rsa_key_qr = request.files.get('rsa_key_qr') - rsa_key_from_qr = False # Track source for password handling - - if rsa_key_file and rsa_key_file.filename: - # RSA key from .pem file - rsa_key_data = rsa_key_file.read() - elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ: - # RSA key from QR code image - qr_image_data = rsa_key_qr.read() - key_pem = extract_key_from_qr(qr_image_data) - if key_pem: - rsa_key_data = key_pem.encode('utf-8') - rsa_key_from_qr = True # QR keys are never password-protected - else: - flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Validate security factors - result = validate_security_factors(pin, rsa_key_data) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Validate PIN if provided - if pin: - result = validate_pin(pin) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Determine key password - QR code keys are never password-protected - key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) - - # Validate RSA key if provided - if rsa_key_data: - result = validate_rsa_key(rsa_key_data, key_password) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Validate carrier image - result = validate_image(carrier_data, "Carrier image") - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Get date - client_date = request.form.get('client_date', '').strip() - if client_date and len(client_date) == 10 and client_date[4] == '-' and client_date[7] == '-': - date_str = client_date - else: - date_str = datetime.now().strftime('%Y-%m-%d') - - # Encode - encode_result = encode( - message=payload, - reference_photo=ref_data, - carrier_image=carrier_data, - day_phrase=day_phrase, - pin=pin, - rsa_key_data=rsa_key_data, - rsa_password=key_password, - date_str=date_str - ) - - # Store temporarily - file_id = secrets.token_urlsafe(16) - cleanup_temp_files() - TEMP_FILES[file_id] = { - 'data': encode_result.stego_image, - 'filename': encode_result.filename, - 'timestamp': time.time() - } - - return redirect(url_for('encode_result', file_id=file_id)) - - except CapacityError as e: - flash(str(e), 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - except StegasooError as e: - flash(str(e), 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - except Exception as e: - flash(f'Error: {e}', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - -@app.route('/encode/result/') -def encode_result(file_id): - if file_id not in TEMP_FILES: - flash('File expired or not found. Please encode again.', 'error') - return redirect(url_for('encode_page')) - - file_info = TEMP_FILES[file_id] - - # Generate thumbnail - thumbnail_data = generate_thumbnail(file_info['data']) - thumbnail_id = None - - if thumbnail_data: - thumbnail_id = f"{file_id}_thumb" - THUMBNAIL_FILES[thumbnail_id] = thumbnail_data - - return render_template('encode_result.html', - file_id=file_id, - filename=file_info['filename'], - thumbnail_url=url_for('encode_thumbnail', thumb_id=thumbnail_id) if thumbnail_id else None - ) - - -@app.route('/encode/thumbnail/') -def encode_thumbnail(thumb_id): - """Serve thumbnail image.""" - if thumb_id not in THUMBNAIL_FILES: - return "Thumbnail not found", 404 - - return send_file( - io.BytesIO(THUMBNAIL_FILES[thumb_id]), - mimetype='image/jpeg', - as_attachment=False - ) - - -@app.route('/encode/download/') -def encode_download(file_id): - if file_id not in TEMP_FILES: - flash('File expired or not found.', 'error') - return redirect(url_for('encode_page')) - - file_info = TEMP_FILES[file_id] - return send_file( - io.BytesIO(file_info['data']), - mimetype='image/png', - as_attachment=True, - download_name=file_info['filename'] - ) - - -@app.route('/encode/file/') -def encode_file_route(file_id): - """Serve file for Web Share API.""" - if file_id not in TEMP_FILES: - return "Not found", 404 - - file_info = TEMP_FILES[file_id] - return send_file( - io.BytesIO(file_info['data']), - mimetype='image/png', - as_attachment=False, - download_name=file_info['filename'] - ) - - -@app.route('/encode/cleanup/', methods=['POST']) -def encode_cleanup(file_id): - """Manually cleanup a file after sharing.""" - TEMP_FILES.pop(file_id, None) - - # Also cleanup thumbnail if exists - thumb_id = f"{file_id}_thumb" - THUMBNAIL_FILES.pop(thumb_id, None) - - return jsonify({'status': 'ok'}) - - -@app.route('/decode', methods=['GET', 'POST']) -def decode_page(): - if request.method == 'POST': - try: - # Get files - ref_photo = request.files.get('reference_photo') - stego_image = request.files.get('stego_image') - rsa_key_file = request.files.get('rsa_key') - - if not ref_photo or not stego_image: - flash('Both reference photo and stego image are required', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Get form data - day_phrase = request.form.get('day_phrase', '') - pin = request.form.get('pin', '').strip() - rsa_password = request.form.get('rsa_password', '') - - # Get encoding date from form (detected from filename in JS) - stego_date = request.form.get('stego_date', '').strip() - - if not day_phrase: - flash('Day phrase is required', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Read files - ref_data = ref_photo.read() - stego_data = stego_image.read() - - # Handle RSA key - can come from .pem file or QR code image - rsa_key_data = None - rsa_key_qr = request.files.get('rsa_key_qr') - rsa_key_from_qr = False # Track source for password handling - - if rsa_key_file and rsa_key_file.filename: - # RSA key from .pem file - rsa_key_data = rsa_key_file.read() - elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ: - # RSA key from QR code image - qr_image_data = rsa_key_qr.read() - key_pem = extract_key_from_qr(qr_image_data) - if key_pem: - rsa_key_data = key_pem.encode('utf-8') - rsa_key_from_qr = True # QR keys are never password-protected - else: - flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Validate security factors - result = validate_security_factors(pin, rsa_key_data) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Validate PIN if provided - if pin: - result = validate_pin(pin) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Determine key password - QR code keys are never password-protected - key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) - - # Validate RSA key if provided - if rsa_key_data: - result = validate_rsa_key(rsa_key_data, key_password) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - with open('/tmp/debug_stego.png', 'wb') as f: - f.write(stego_data) - with open('/tmp/debug_ref.png', 'wb') as f: - f.write(ref_data) - with open('/tmp/debug_params.txt', 'w') as f: - f.write(f"day_phrase: {day_phrase}\n") - f.write(f"pin: {pin}\n") - f.write(f"date_str: {stego_date}\n") - f.write(f"rsa_key: {len(rsa_key_data) if rsa_key_data else None}\n") - - print(f"DEBUG: Saved inputs to /tmp/debug_*") - # Decode - decode_result = decode( - stego_image=stego_data, - reference_photo=ref_data, - day_phrase=day_phrase, - pin=pin, - rsa_key_data=rsa_key_data, - rsa_password=key_password, - date_str=stego_date if stego_date else None - ) - - if decode_result.is_file: - # File content - store temporarily for download - file_id = secrets.token_urlsafe(16) - cleanup_temp_files() - - filename = decode_result.filename or 'decoded_file' - TEMP_FILES[file_id] = { - 'data': decode_result.file_data, - 'filename': filename, - 'mime_type': decode_result.mime_type, - 'timestamp': time.time() - } - - return render_template('decode.html', - decoded_file=True, - file_id=file_id, - filename=filename, - file_size=format_size(len(decode_result.file_data)), - mime_type=decode_result.mime_type - ) - else: - # Text content - return render_template('decode.html', decoded_message=decode_result.message) - - except DecryptionError: - flash('Decryption failed. Check your phrase, PIN, RSA key, and reference photo.', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - except StegasooError as e: - flash(str(e), 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - except Exception as e: - flash(f'Error: {e}', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - -@app.route('/decode/download/') -def decode_download(file_id): - """Download decoded file.""" - if file_id not in TEMP_FILES: - flash('File expired or not found.', 'error') - return redirect(url_for('decode_page')) - - file_info = TEMP_FILES[file_id] - mime_type = file_info.get('mime_type', 'application/octet-stream') - - return send_file( - io.BytesIO(file_info['data']), - mimetype=mime_type, - as_attachment=True, - download_name=file_info['filename'] - ) - - -@app.route('/about') -def about(): - return render_template('about.html', - has_argon2=has_argon2(), - has_qrcode_read=HAS_QRCODE_READ, - max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024 - ) - - -# ============================================================================ -# MAIN -# ============================================================================ - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/frontends/web/templates/encode.html b/frontends/web/templates/encode.html index 6c01bf0..dae0c9f 100644 --- a/frontends/web/templates/encode.html +++ b/frontends/web/templates/encode.html @@ -241,13 +241,13 @@ DCT Mode {% if has_dct %} - Stealth + Experimental {% else %} Unavailable {% endif %}
    -
  • Grayscale output (PNG/JPEG)
  • +
  • Color or grayscale output
  • Lower capacity (~75 KB/MP)
  • Better detection resistance
@@ -266,47 +266,98 @@
LSB is best for most uses. - DCT provides better stealth but smaller capacity and grayscale output. + DCT provides better stealth but lower capacity.
- -
- + +
-
-
-
- - +
+ +
+ + Experimental Feature: DCT embedding is still being refined. + Color mode preserves original colors but extraction uses Y channel only. +
+ + +
+ + +
+
+
+ + +
+
+
+
+ + +
-
-
- - -
+ +
+ + Color preserves original image colors (recommended). + Grayscale converts to B&W (traditional DCT steganography).
-
- - PNG is 100% reliable. JPEG produces smaller, more natural-looking files but uses lossy compression (Q=95). + +
+ + +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + PNG is 100% reliable. JPEG produces smaller, more natural-looking files but uses lossy compression (Q=95). +
+
+
@@ -353,7 +404,7 @@
Limits: - Carrier image max ~24 megapixels (6000×4000). + Carrier image max ~24 megapixels (6000x4000). Files max 30MB upload. Payload max {{ max_payload_kb }} KB.
@@ -470,8 +521,9 @@ document.getElementById('encodeForm').addEventListener('submit', function(e) { let modeLabel = selectedMode.toUpperCase(); if (selectedMode === 'dct') { + const colorMode = document.querySelector('input[name="dct_color_mode"]:checked')?.value || 'color'; const outputFormat = document.querySelector('input[name="dct_output_format"]:checked')?.value || 'png'; - modeLabel += ` → ${outputFormat.toUpperCase()}`; + modeLabel += ` (${colorMode}, ${outputFormat.toUpperCase()})`; } btn.innerHTML = `Encoding (${modeLabel})...`; @@ -535,7 +587,7 @@ async function fetchCapacityComparison(file) { function updateCapacityDisplay(data) { // Update top panel - carrierDimensions.textContent = `${data.width} × ${data.height}`; + carrierDimensions.textContent = `${data.width} x ${data.height}`; lsbCapacityBadge.textContent = `LSB: ${data.lsb.capacity_kb} KB`; if (data.dct.available) { @@ -573,19 +625,27 @@ if (carrierInput) { } // ============================================================================ -// Mode card highlighting & DCT output format visibility +// Mode card highlighting & DCT options visibility // ============================================================================ const lsbModeCard = document.getElementById('lsbModeCard'); const dctModeCard = document.getElementById('dctModeCard'); const modeLsb = document.getElementById('modeLsb'); const modeDct = document.getElementById('modeDct'); -const dctOutputFormatGroup = document.getElementById('dctOutputFormatGroup'); +const dctOptionsPanel = document.getElementById('dctOptionsPanel'); + +// DCT format cards const dctPngCard = document.getElementById('dctPngCard'); const dctJpegCard = document.getElementById('dctJpegCard'); const dctFormatPng = document.getElementById('dctFormatPng'); const dctFormatJpeg = document.getElementById('dctFormatJpeg'); +// DCT color mode cards +const dctColorCard = document.getElementById('dctColorCard'); +const dctGrayscaleCard = document.getElementById('dctGrayscaleCard'); +const dctColorColor = document.getElementById('dctColorColor'); +const dctColorGrayscale = document.getElementById('dctColorGrayscale'); + function updateModeCardHighlight() { // Mode cards lsbModeCard.classList.toggle('border-primary', modeLsb.checked); @@ -593,28 +653,40 @@ function updateModeCardHighlight() { dctModeCard.classList.toggle('border-info', modeDct.checked); dctModeCard.classList.toggle('border-2', modeDct.checked); - // Show/hide DCT output format selector - if (dctOutputFormatGroup) { - dctOutputFormatGroup.classList.toggle('d-none', !modeDct.checked); + // Show/hide DCT options panel + if (dctOptionsPanel) { + dctOptionsPanel.classList.toggle('d-none', !modeDct.checked); } } function updateDctFormatCardHighlight() { if (dctPngCard && dctJpegCard) { - dctPngCard.classList.toggle('border-success', dctFormatPng.checked); + dctPngCard.classList.toggle('border-primary', dctFormatPng.checked); dctPngCard.classList.toggle('border-2', dctFormatPng.checked); dctJpegCard.classList.toggle('border-warning', dctFormatJpeg.checked); dctJpegCard.classList.toggle('border-2', dctFormatJpeg.checked); } } +function updateDctColorCardHighlight() { + if (dctColorCard && dctGrayscaleCard) { + dctColorCard.classList.toggle('border-success', dctColorColor.checked); + dctColorCard.classList.toggle('border-2', dctColorColor.checked); + dctGrayscaleCard.classList.toggle('border-secondary', dctColorGrayscale.checked); + dctGrayscaleCard.classList.toggle('border-2', dctColorGrayscale.checked); + } +} + modeLsb.addEventListener('change', updateModeCardHighlight); modeDct.addEventListener('change', updateModeCardHighlight); dctFormatPng?.addEventListener('change', updateDctFormatCardHighlight); dctFormatJpeg?.addEventListener('change', updateDctFormatCardHighlight); +dctColorColor?.addEventListener('change', updateDctColorCardHighlight); +dctColorGrayscale?.addEventListener('change', updateDctColorCardHighlight); updateModeCardHighlight(); // Initial state updateDctFormatCardHighlight(); // Initial state +updateDctColorCardHighlight(); // Initial state // Advanced options chevron rotation document.getElementById('advancedOptions').addEventListener('show.bs.collapse', function() { diff --git a/frontends/web/templates/encode_result.html b/frontends/web/templates/encode_result.html index 4c08c4f..e2e7b5c 100644 --- a/frontends/web/templates/encode_result.html +++ b/frontends/web/templates/encode_result.html @@ -34,31 +34,60 @@ {{ filename }}
- +
{% if embed_mode == 'dct' %} DCT Mode + + + {% if color_mode == 'color' %} + + Color + + {% else %} + + Grayscale + + {% endif %} + + {% if output_format == 'jpeg' %} JPEG -
Grayscale JPEG, frequency domain embedding (Q=95)
+
+ {% if color_mode == 'color' %} + Color JPEG, frequency domain embedding (Q=95) + {% else %} + Grayscale JPEG, frequency domain embedding (Q=95) + {% endif %} +
{% else %} - + PNG -
Grayscale PNG, frequency domain embedding (lossless)
+
+ {% if color_mode == 'color' %} + Color PNG, frequency domain embedding (lossless) + {% else %} + Grayscale PNG, frequency domain embedding (lossless) + {% endif %} +
{% endif %} + {% else %} LSB Mode + Full Color + + PNG -
Full color PNG, spatial LSB embedding
+
Full color PNG, spatial LSB embedding
{% endif %}
@@ -88,6 +117,9 @@ {% endif %} {% if embed_mode == 'dct' %}
  • Recipient needs DCT mode or Auto detection to decode
  • + {% if color_mode == 'color' %} +
  • v3.0.1 Color preserved - extraction works on both color and grayscale
  • + {% endif %} {% endif %}
    diff --git a/pyproject.toml b/pyproject.toml index 299f391..a8f285f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "stegasoo" -version = "2.2.1" +version = "3.0.2" description = "Secure steganography with hybrid photo + passphrase + PIN authentication" readme = "README.md" license = "MIT" @@ -43,6 +43,11 @@ dependencies = [ ] [project.optional-dependencies] +# DCT steganography support (v3.0+) +dct = [ + "scipy>=1.10.0", + "jpegio>=0.2.0", +] cli = [ "click>=8.0.0", "qrcode>=7.30" @@ -55,6 +60,9 @@ web = [ "gunicorn>=21.0.0", "qrcode>=7.3.0", "pyzbar>=0.1.9", + # Include DCT support for web UI + "scipy>=1.10.0", + "jpegio>=0.2.0", ] api = [ "fastapi>=0.100.0", @@ -62,9 +70,12 @@ api = [ "python-multipart>=0.0.6", "qrcode>=7.30", "pyzbar>=0.1.9", + # Include DCT support for API + "scipy>=1.10.0", + "jpegio>=0.2.0", ] all = [ - "stegasoo[cli,web,api]", + "stegasoo[cli,web,api,dct,compression]", ] dev = [ "stegasoo[all]", diff --git a/src/stegasoo/Dockerfile b/src/stegasoo/Dockerfile new file mode 100644 index 0000000..356dc1b --- /dev/null +++ b/src/stegasoo/Dockerfile @@ -0,0 +1,144 @@ +# Stegasoo Docker Image +# Multi-stage build for smaller image size + +# Pin the base image digest for reproducibility +# To update: docker manifest inspect python:3.11-slim -v | jq -r '.[0].Descriptor.digest' +FROM python:3.11-slim@sha256:5501a4fe605abe24de87c2f3d6cf9fd760354416a0cad0296cf284fddcdca9e2 as base + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +# Suppress pip "running as root" warnings during build +ENV PIP_ROOT_USER_ACTION=ignore + +# Install system dependencies +# NOTE: libjpeg-dev is required for jpegio compilation +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libc-dev \ + libffi-dev \ + libzbar0 \ + libjpeg-dev \ + && rm -rf /var/lib/apt/lists/* + +# ============================================================================ +# Builder stage - install Python packages +# ============================================================================ +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]" + +# ============================================================================ +# Production stage - Web UI +# ============================================================================ +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 src/ src/ +COPY data/ data/ +COPY frontends/web/ frontends/web/ + +# Create upload directory +RUN mkdir -p /tmp/stego_uploads + +# Create non-root user +RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads +USER stego + +# Set Python path +ENV PYTHONPATH=/app/src + +# Expose port +EXPOSE 5000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/')" || exit 1 + +# Run with gunicorn +WORKDIR /app/frontends/web +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "60", "app:app"] + +# ============================================================================ +# API stage - REST API +# ============================================================================ +FROM base as api + +WORKDIR /app + +# Install API extras (includes DCT dependencies) +COPY pyproject.toml README.md ./ +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 +RUN useradd -m -u 1000 stego && chown -R stego:stego /app +USER stego + +# Set Python path +ENV PYTHONPATH=/app/src + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" || exit 1 + +# Run with uvicorn +WORKDIR /app/frontends/api +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] + +# ============================================================================ +# CLI stage - Command line tool +# ============================================================================ +FROM base as cli + +WORKDIR /app + +# Install CLI extras +COPY pyproject.toml README.md ./ +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 +RUN useradd -m -u 1000 stego && chown -R stego:stego /app +USER stego + +# Set Python path +ENV PYTHONPATH=/app/src + +# Default to help +WORKDIR /app/frontends/cli +ENTRYPOINT ["python", "main.py"] +CMD ["--help"] diff --git a/src/stegasoo/__init__.py b/src/stegasoo/__init__.py index fc636bd..8b0f101 100644 --- a/src/stegasoo/__init__.py +++ b/src/stegasoo/__init__.py @@ -315,6 +315,7 @@ def encode( output_format = None, # Optional[str] embed_mode: str = EMBED_MODE_LSB, dct_output_format: str = "png", # NEW in v3.0.1: 'png' or 'jpeg' + dct_color_mode: str = "grayscale", # NEW in v3.0.1: 'grayscale' or 'color' ) -> EncodeResult: """ Encode a secret message or file into an image. @@ -334,6 +335,7 @@ def encode( output_format: Force output format ('PNG', 'BMP') - LSB mode only embed_mode: Embedding mode - 'lsb' (default) or 'dct' (v3.0+) dct_output_format: For DCT mode - 'png' (lossless) or 'jpeg' (smaller) + dct_color_mode: For DCT mode - 'grayscale' (default) or 'color' (preserves colors) Returns: EncodeResult with stego image and metadata @@ -349,16 +351,18 @@ def encode( # Default LSB mode >>> result = encode(message="Secret", ...) - # DCT mode with PNG output (lossless) + # DCT mode with grayscale PNG output (default) >>> result = encode(message="Secret", ..., embed_mode='dct') - # DCT mode with JPEG output (smaller, natural) - >>> result = encode(message="Secret", ..., embed_mode='dct', dct_output_format='jpeg') + # DCT mode with color JPEG output + >>> result = encode(message="Secret", ..., embed_mode='dct', + ... dct_output_format='jpeg', dct_color_mode='color') """ # Debug logging debug.print(f"encode called: message type={type(message).__name__}, " f"day_phrase='{day_phrase[:20]}...', pin_length={len(pin)}, " - f"embed_mode={embed_mode}, dct_output_format={dct_output_format}") + f"embed_mode={embed_mode}, dct_output_format={dct_output_format}, " + f"dct_color_mode={dct_color_mode}") # Validate embed_mode if embed_mode not in (EMBED_MODE_LSB, EMBED_MODE_DCT): @@ -375,6 +379,11 @@ def encode( debug.print(f"Invalid dct_output_format '{dct_output_format}', defaulting to 'png'") dct_output_format = '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' + # Validate inputs require_valid_payload(message) require_valid_image(carrier_image, "Carrier image") @@ -407,7 +416,7 @@ def encode( debug.data(pixel_key, "Pixel key") # Embed in image (returns extension too) - # CRITICAL: Pass dct_output_format to embed_in_image + # CRITICAL: Pass dct_output_format and dct_color_mode to embed_in_image stego_data, stats, extension = embed_in_image( encrypted, carrier_image, @@ -415,6 +424,7 @@ def encode( output_format=output_format, embed_mode=embed_mode, dct_output_format=dct_output_format, # NEW in v3.0.1 + dct_color_mode=dct_color_mode, # NEW in v3.0.1 ) # Generate filename with correct extension @@ -468,6 +478,7 @@ def encode_file( filename_override: Optional[str] = None, embed_mode: str = EMBED_MODE_LSB, dct_output_format: str = "png", # NEW in v3.0.1 + dct_color_mode: str = "grayscale", # NEW in v3.0.1 ) -> EncodeResult: """ Encode a file into an image. @@ -487,12 +498,13 @@ def encode_file( filename_override: Override the stored filename embed_mode: 'lsb' (default) or 'dct' (v3.0+) dct_output_format: For DCT mode - 'png' or 'jpeg' (v3.0.1+) + dct_color_mode: For DCT mode - 'grayscale' or 'color' (v3.0.1+) Returns: EncodeResult with stego image and metadata """ debug.print(f"encode_file called: filepath={filepath}, embed_mode={embed_mode}, " - f"dct_output_format={dct_output_format}") + f"dct_output_format={dct_output_format}, dct_color_mode={dct_color_mode}") payload = FilePayload.from_file(str(filepath), filename_override) return encode( @@ -507,6 +519,7 @@ def encode_file( output_format=output_format, embed_mode=embed_mode, dct_output_format=dct_output_format, # NEW in v3.0.1 + dct_color_mode=dct_color_mode, # NEW in v3.0.1 ) @@ -528,6 +541,7 @@ def encode_bytes( mime_type: Optional[str] = None, embed_mode: str = EMBED_MODE_LSB, dct_output_format: str = "png", # NEW in v3.0.1 + dct_color_mode: str = "grayscale", # NEW in v3.0.1 ) -> EncodeResult: """ Encode raw bytes with a filename into an image. @@ -548,12 +562,14 @@ def encode_bytes( mime_type: MIME type of the data embed_mode: 'lsb' (default) or 'dct' (v3.0+) dct_output_format: For DCT mode - 'png' or 'jpeg' (v3.0.1+) + dct_color_mode: For DCT mode - 'grayscale' or 'color' (v3.0.1+) Returns: EncodeResult with stego image and metadata """ debug.print(f"encode_bytes called: filename={filename}, data_size={len(data)}, " - f"embed_mode={embed_mode}, dct_output_format={dct_output_format}") + f"embed_mode={embed_mode}, dct_output_format={dct_output_format}, " + f"dct_color_mode={dct_color_mode}") payload = FilePayload(data=data, filename=filename, mime_type=mime_type) return encode( @@ -568,6 +584,7 @@ def encode_bytes( output_format=output_format, embed_mode=embed_mode, dct_output_format=dct_output_format, # NEW in v3.0.1 + dct_color_mode=dct_color_mode, # NEW in v3.0.1 ) diff --git a/src/stegasoo/dct_steganography.py b/src/stegasoo/dct_steganography.py index a517a25..a136a64 100644 --- a/src/stegasoo/dct_steganography.py +++ b/src/stegasoo/dct_steganography.py @@ -1,29 +1,32 @@ """ -DCT Domain Steganography Module (v3.0.1) +DCT Domain Steganography Module (v3.0.2) -Embeds data in DCT coefficients of grayscale images. -Supports PNG (lossless) or JPEG (natural, smaller) output. +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) -This provides an alternative to LSB embedding with different trade-offs: -- More resistant to visual inspection -- Survives some image processing -- Lower capacity (~20% of LSB) -- Works in frequency domain +The JPEG approach is the "correct" way to do JPEG steganography because +it directly modifies the already-quantized coefficients without re-encoding. -Requires: scipy (for DCT transforms) +New 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 + +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 +from typing import Optional, Literal, Tuple from enum import Enum import numpy as np from PIL import Image -# Check for scipy availability +# Check for scipy availability (for PNG/DCT mode) try: from scipy.fftpack import dct, idct HAS_SCIPY = True @@ -32,6 +35,14 @@ except ImportError: 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 @@ -41,8 +52,6 @@ except ImportError: BLOCK_SIZE = 8 # Coefficients to use for embedding (mid-frequency, zig-zag order positions) -# Avoiding DC (0,0) and high-frequency edges -# These positions are relatively stable across JPEG compression EMBED_POSITIONS = [ (0, 1), (1, 0), (2, 0), (1, 1), (0, 2), (0, 3), (1, 2), (2, 1), (3, 0), (4, 0), (3, 1), (2, 2), (1, 3), (0, 4), (0, 5), (1, 4), (2, 3), (3, 2), @@ -51,25 +60,29 @@ EMBED_POSITIONS = [ ] # Use subset of mid-frequency coefficients for better robustness -# Positions 4-20 in zig-zag order (skip very low and very high frequencies) DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20] # 16 coefficients per block -# Quantization step for embedding (larger = more robust, more visible) +# Quantization step for QIM embedding (larger = more robust, more visible) QUANT_STEP = 25 # Magic bytes for DCT stego identification DCT_MAGIC = b'DCTS' -# Header: magic(4) + version(1) + flags(1) + length(4) = 10 bytes +# Header size: magic(4) + version(1) + flags(1) + length(4) = 10 bytes HEADER_SIZE = 10 # Output format options OUTPUT_FORMAT_PNG = 'png' OUTPUT_FORMAT_JPEG = 'jpeg' -# JPEG quality for output (high to preserve coefficients) +# JPEG output quality (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 + # ============================================================================ # DATA CLASSES @@ -91,7 +104,9 @@ class DCTEmbedStats: usage_percent: float image_width: int image_height: int - output_format: str # 'png' or 'jpeg' + 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 @@ -105,11 +120,11 @@ class DCTCapacityInfo: bits_per_block: int total_capacity_bits: int total_capacity_bytes: int - usable_capacity_bytes: int # After header overhead + usable_capacity_bytes: int # ============================================================================ -# HELPER FUNCTIONS +# AVAILABILITY CHECKS # ============================================================================ def _check_scipy(): @@ -121,6 +136,20 @@ def _check_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') @@ -138,7 +167,7 @@ def _to_grayscale(image_data: bytes) -> np.ndarray: return np.array(gray, dtype=np.float64) -def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]: +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 @@ -150,7 +179,6 @@ def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]: padded = np.zeros((new_h, new_w), dtype=image.dtype) padded[:h, :w] = image - # Mirror padding for smoother edges if new_h > h: padded[h:, :w] = image[h-(new_h-h):h, :w][::-1, :] if new_w > w: @@ -161,82 +189,125 @@ def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]: return padded, (h, w) -def _unpad_image(image: np.ndarray, original_size: tuple[int, int]) -> np.ndarray: +def _unpad_image(image: np.ndarray, original_size: Tuple[int, int]) -> np.ndarray: """Remove padding from image.""" h, w = original_size return image[:h, :w] -def _embed_bit_in_coeff(coeff: float, bit: int, quant_step: int = QUANT_STEP) -> float: +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.""" - # Quantization Index Modulation - quantized = round(coeff / quant_step) + quantized = round(coef / quant_step) if (quantized % 2) != bit: - # Adjust to embed the bit if quantized % 2 == 0 and bit == 1: - quantized += 1 if coeff >= quantized * quant_step else -1 + quantized += 1 if coef >= quantized * quant_step else -1 elif quantized % 2 == 1 and bit == 0: - quantized += 1 if coeff >= quantized * quant_step else -1 + quantized += 1 if coef >= quantized * quant_step else -1 return quantized * quant_step -def _extract_bit_from_coeff(coeff: float, quant_step: int = QUANT_STEP) -> int: +def _extract_bit_from_coeff(coef: float, quant_step: int = QUANT_STEP) -> int: """Extract a single bit from a DCT coefficient.""" - quantized = round(coeff / quant_step) + quantized = round(coef / quant_step) return quantized % 2 -def _generate_block_order(num_blocks: int, seed: bytes) -> list[int]: +def _generate_block_order(num_blocks: int, seed: bytes) -> list: """Generate pseudo-random block order from seed.""" - # Create deterministic RNG from seed hash_bytes = hashlib.sha256(seed).digest() rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big')) - order = list(range(num_blocks)) rng.shuffle(order) return order -def _save_stego_image( - image: np.ndarray, - output_format: str = OUTPUT_FORMAT_PNG -) -> bytes: - """Save stego image in specified format.""" - # Clip to valid range and convert to uint8 +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: - # High-quality JPEG with no chroma subsampling - img.save( - buffer, - format='JPEG', - quality=JPEG_OUTPUT_QUALITY, - subsampling=0, # 4:4:4 - no subsampling - optimize=True - ) + img.save(buffer, format='JPEG', quality=JPEG_OUTPUT_QUALITY, + subsampling=0, optimize=True) else: - # PNG (lossless, default) 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.""" - # Header format: MAGIC(4) + VERSION(1) + FLAGS(1) + LENGTH(4) version = 1 return struct.pack('>4sBBI', DCT_MAGIC, version, flags, data_length) -def _parse_header(header_bits: list[int]) -> tuple[int, int, int]: +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") - # Convert bits to bytes header_bytes = bytes([ sum(header_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8)) for i in range(HEADER_SIZE) @@ -245,7 +316,80 @@ def _parse_header(header_bits: list[int]) -> tuple[int, int, int]: magic, version, flags, length = struct.unpack('>4sBBI', header_bytes) if magic != DCT_MAGIC: - raise ValueError("Invalid DCT stego magic bytes - not a DCT stego image") + 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) -> bytes: + """Create header for jpegio embedding.""" + return struct.pack('>4sBBI', JPEGIO_MAGIC, 1, 0, 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 @@ -254,11 +398,6 @@ def _parse_header(header_bits: list[int]) -> tuple[int, int, int]: # PUBLIC API # ============================================================================ -def has_dct_support() -> bool: - """Check if DCT steganography is available.""" - return HAS_SCIPY - - def calculate_dct_capacity(image_data: bytes) -> DCTCapacityInfo: """ Calculate the DCT embedding capacity of an image. @@ -274,19 +413,13 @@ def calculate_dct_capacity(image_data: bytes) -> DCTCapacityInfo: img = Image.open(io.BytesIO(image_data)) width, height = img.size - # Calculate blocks blocks_x = width // BLOCK_SIZE blocks_y = height // BLOCK_SIZE total_blocks = blocks_x * blocks_y - # Bits per block (using selected coefficient positions) bits_per_block = len(DEFAULT_EMBED_POSITIONS) - - # Total capacity total_bits = total_blocks * bits_per_block total_bytes = total_bits // 8 - - # Usable capacity (minus header) usable_bytes = max(0, total_bytes - HEADER_SIZE) return DCTCapacityInfo( @@ -303,43 +436,23 @@ def calculate_dct_capacity(image_data: bytes) -> DCTCapacityInfo: def will_fit_dct(data_length: int, image_data: bytes) -> bool: - """ - Check if data will fit in the image using DCT embedding. - - Args: - data_length: Length of data in bytes - image_data: Carrier image bytes - - Returns: - True if data fits, False otherwise - """ + """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. - - Args: - image_data: Image file bytes - - Returns: - Dict with 'lsb' and 'dct' capacity info - """ + """Compare LSB and DCT capacity for an image.""" img = Image.open(io.BytesIO(image_data)) width, height = img.size pixels = width * height - # LSB capacity (3 bits per pixel for RGB, simplified) lsb_bytes = (pixels * 3) // 8 - # DCT capacity if HAS_SCIPY: dct_info = calculate_dct_capacity(image_data) dct_bytes = dct_info.usable_capacity_bytes else: - # Estimate without scipy blocks = (width // 8) * (height // 8) dct_bytes = (blocks * 16) // 8 - HEADER_SIZE @@ -357,6 +470,10 @@ def estimate_capacity_comparison(image_data: bytes) -> dict: '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', } } @@ -366,30 +483,60 @@ def embed_in_dct( carrier_image: bytes, seed: bytes, output_format: str = OUTPUT_FORMAT_PNG, -) -> tuple[bytes, DCTEmbedStats]: + 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 block selection - output_format: Output format - 'png' (default, lossless) or 'jpeg' (smaller) + 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) - - Raises: - ImportError: If scipy is not available - ValueError: If data is too large for carrier """ - _check_scipy() - # Validate output format if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG): - raise ValueError(f"Invalid output format: {output_format}. Use 'png' or 'jpeg'") + raise ValueError(f"Invalid output format: {output_format}") - # Calculate capacity + # 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: @@ -398,69 +545,216 @@ def embed_in_dct( f"(capacity: {capacity_info.usable_capacity_bytes} bytes)" ) - # Prepare image - image = _to_grayscale(carrier_image) - padded, original_size = _pad_to_blocks(image) + # Load image + img = Image.open(io.BytesIO(carrier_image)) + width, height = img.size - # Create header + data + 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 + Y_embedded = _embed_in_channel(Y_padded, data, seed, capacity_info) + + # 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) + + result = _unpad_image(embedded, original_size) + stego_bytes = _save_stego_image(result, output_format) + + # Calculate stats + header = _create_header(len(data)) + 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, +) -> np.ndarray: + """Embed data in a single channel using DCT.""" header = _create_header(len(data)) payload = header + data - # Convert payload to bits bits = [] for byte in payload: for i in range(7, -1, -1): bits.append((byte >> i) & 1) - # Generate block order num_blocks = capacity_info.total_blocks block_order = _generate_block_order(num_blocks, seed) - # Embed bits - bit_idx = 0 - blocks_used = 0 - h, w = padded.shape + h, w = channel.shape + result = channel.copy() + bit_idx = 0 for block_num in block_order: if bit_idx >= len(bits): break - - # Calculate block position + by = (block_num // (w // BLOCK_SIZE)) * BLOCK_SIZE bx = (block_num % (w // BLOCK_SIZE)) * BLOCK_SIZE - # Extract and transform block - block = padded[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE].copy() + block = result[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE].copy() dct_block = _dct2(block) - # Embed bits in selected coefficients for pos in DEFAULT_EMBED_POSITIONS: if bit_idx >= len(bits): break dct_block[pos] = _embed_bit_in_coeff(dct_block[pos], bits[bit_idx]) bit_idx += 1 - # Inverse transform and store modified_block = _idct2(dct_block) - padded[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE] = modified_block - blocks_used += 1 + result[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE] = modified_block - # Remove padding and save - result = _unpad_image(padded, original_size) - stego_bytes = _save_stego_image(result, output_format) + 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. - stats = DCTEmbedStats( - blocks_used=blocks_used, - blocks_available=capacity_info.total_blocks, - bits_embedded=len(bits), - capacity_bits=capacity_info.total_capacity_bits, - usage_percent=(len(bits) / capacity_info.total_capacity_bits) * 100, - image_width=original_size[1], - image_height=original_size[0], - output_format=output_format, - ) + Note: jpegio naturally preserves color since JPEG stores YCbCr + and we only modify Y channel coefficients. + """ + import tempfile + import os - return stego_bytes, stats + # 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') + + try: + # Read JPEG with jpegio + jpeg = jio.read(input_path) + + # Get Y channel coefficients (channel 0) + # For grayscale mode, we could convert to grayscale, but jpegio + # works with the original JPEG which already has color info. + # The color_mode primarily affects the output interpretation. + 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 + header = _jpegio_create_header(len(data)) + 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( @@ -470,33 +764,43 @@ def extract_from_dct( """ Extract data from DCT stego image. + Automatically detects whether image uses scipy DCT or jpegio embedding. + Args: stego_image: Stego image bytes seed: Same seed used for embedding Returns: Extracted data bytes - - Raises: - ImportError: If scipy is not available - ValueError: If image is not a valid DCT stego image """ - _check_scipy() + # Check image format + img = Image.open(io.BytesIO(stego_image)) - # Prepare 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).""" image = _to_grayscale(stego_image) padded, original_size = _pad_to_blocks(image) - # Calculate capacity h, w = padded.shape blocks_x = w // BLOCK_SIZE blocks_y = h // BLOCK_SIZE num_blocks = blocks_x * blocks_y - # Generate same block order block_order = _generate_block_order(num_blocks, seed) - # Extract all bits (we'll stop when we have enough based on header) all_bits = [] for block_num in block_order: @@ -510,7 +814,6 @@ def extract_from_dct( bit = _extract_bit_from_coeff(dct_block[pos]) all_bits.append(bit) - # Check if we have enough for header if len(all_bits) >= HEADER_SIZE * 8: try: _, _, data_length = _parse_header(all_bits[:HEADER_SIZE * 8]) @@ -518,16 +821,12 @@ def extract_from_dct( if len(all_bits) >= total_needed: break except ValueError: - # Not enough data yet or invalid, continue pass - # Parse header version, flags, data_length = _parse_header(all_bits) - # Extract data bits data_bits = all_bits[HEADER_SIZE * 8:(HEADER_SIZE + data_length) * 8] - # Convert bits to bytes data = bytes([ sum(data_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8)) for i in range(data_length) @@ -536,6 +835,61 @@ def extract_from_dct( 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 # ============================================================================ diff --git a/src/stegasoo/steganography.py b/src/stegasoo/steganography.py index 58a64d0..b1fd7fe 100644 --- a/src/stegasoo/steganography.py +++ b/src/stegasoo/steganography.py @@ -11,6 +11,7 @@ New in v3.0: New in v3.0.1: - dct_output_format parameter for DCT mode ('png' or 'jpeg') +- dct_color_mode parameter for DCT mode ('grayscale' or 'color') """ import io @@ -59,6 +60,10 @@ ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX 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 @@ -477,6 +482,7 @@ def embed_in_image( output_format: Optional[str] = None, embed_mode: str = EMBED_MODE_LSB, dct_output_format: str = DCT_OUTPUT_PNG, # NEW in v3.0.1 + dct_color_mode: str = 'grayscale', # NEW in v3.0.1: 'grayscale' or 'color' ) -> Tuple[bytes, Union[EmbedStats, 'DCTEmbedStats'], str]: """ Embed data into an image using specified mode. @@ -489,6 +495,7 @@ def embed_in_image( 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) @@ -515,14 +522,20 @@ def embed_in_image( 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 to DCT module (v3.0.1) + # 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, # NEW in v3.0.1 ) # Determine extension based on output format @@ -531,7 +544,8 @@ def embed_in_image( else: ext = 'png' - debug.print(f"DCT embedding complete: {dct_output_format.upper()} output, ext={ext}") + 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