From 8581b86104c5bff0bd48ded222f80067db7f99d6 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sat, 27 Dec 2025 22:40:31 -0500 Subject: [PATCH] New Version 2 -- prolly doesn't work. --- .gitignore | 43 +- Dockerfile | 113 ++++- README.md | 282 ++++++++---- docker-compose.yml | 45 +- frontends/api/main.py | 368 ++++++++++++++++ frontends/cli/main.py | 371 ++++++++++++++++ frontends/web/app.py | 405 ++++++++++++++++++ frontends/web/static/favicon.svg | 15 + frontends/web/static/logo.svg | 23 + {static => frontends/web/static}/style.css | 0 .../web/templates}/about.html | 0 .../web/templates}/base.html | 0 .../web/templates}/decode.html | 0 .../web/templates}/encode.html | 0 .../web/templates}/encode_result.html | 0 .../web/templates}/generate.html | 0 .../web/templates}/index.html | 0 pyproject.toml | 105 +++++ requirements.txt | 20 +- static/.gitkeep => src/__init__.py | 0 src/main.py | 11 + src/stegasoo/__init__.py | 357 +++++++++++++++ src/stegasoo/cli.py | 66 +++ src/stegasoo/constants.py | 119 +++++ src/stegasoo/crypto.py | 358 ++++++++++++++++ src/stegasoo/exceptions.py | 150 +++++++ src/stegasoo/keygen.py | 228 ++++++++++ src/stegasoo/models.py | 134 ++++++ src/stegasoo/steganography.py | 286 +++++++++++++ src/stegasoo/utils.py | 201 +++++++++ src/stegasoo/validation.py | 344 +++++++++++++++ uploads/.gitkeep => tests/__init__.py | 0 tests/test_stegasoo.py | 220 ++++++++++ .dockerignore => v1_old_files/.dockerignore | 0 v1_old_files/Dockerfile | 44 ++ v1_old_files/README.md | 137 ++++++ .../STEGASOO_WEB_README.md | 0 app.py => v1_old_files/app.py | 0 .../bip39-words.txt | 0 .../requirements-ml.txt | 0 v1_old_files/requirements.txt | 16 + .../secureDeleter.py | 0 v1_old_files/static/.gitkeep | 0 {static => v1_old_files/static}/favicon.svg | 0 {static => v1_old_files/static}/logo.svg | 0 v1_old_files/static/style.css | 257 +++++++++++ v1_old_files/templates/about.html | 178 ++++++++ v1_old_files/templates/base.html | 74 ++++ v1_old_files/templates/decode.html | 259 +++++++++++ v1_old_files/templates/encode.html | 259 +++++++++++ v1_old_files/templates/encode_result.html | 142 ++++++ v1_old_files/templates/generate.html | 345 +++++++++++++++ v1_old_files/templates/index.html | 108 +++++ .../test}/story_generator.py | 0 v1_old_files/uploads/.gitkeep | 0 55 files changed, 5970 insertions(+), 113 deletions(-) create mode 100644 frontends/api/main.py create mode 100644 frontends/cli/main.py create mode 100644 frontends/web/app.py create mode 100644 frontends/web/static/favicon.svg create mode 100644 frontends/web/static/logo.svg rename {static => frontends/web/static}/style.css (100%) rename {templates => frontends/web/templates}/about.html (100%) rename {templates => frontends/web/templates}/base.html (100%) rename {templates => frontends/web/templates}/decode.html (100%) rename {templates => frontends/web/templates}/encode.html (100%) rename {templates => frontends/web/templates}/encode_result.html (100%) rename {templates => frontends/web/templates}/generate.html (100%) rename {templates => frontends/web/templates}/index.html (100%) create mode 100644 pyproject.toml rename static/.gitkeep => src/__init__.py (100%) create mode 100644 src/main.py create mode 100644 src/stegasoo/__init__.py create mode 100644 src/stegasoo/cli.py create mode 100644 src/stegasoo/constants.py create mode 100644 src/stegasoo/crypto.py create mode 100644 src/stegasoo/exceptions.py create mode 100644 src/stegasoo/keygen.py create mode 100644 src/stegasoo/models.py create mode 100644 src/stegasoo/steganography.py create mode 100644 src/stegasoo/utils.py create mode 100644 src/stegasoo/validation.py rename uploads/.gitkeep => tests/__init__.py (100%) create mode 100644 tests/test_stegasoo.py rename .dockerignore => v1_old_files/.dockerignore (100%) create mode 100644 v1_old_files/Dockerfile create mode 100644 v1_old_files/README.md rename STEGASOO_WEB_README.md => v1_old_files/STEGASOO_WEB_README.md (100%) rename app.py => v1_old_files/app.py (100%) rename bip39-words.txt => v1_old_files/bip39-words.txt (100%) rename requirements-ml.txt => v1_old_files/requirements-ml.txt (100%) create mode 100644 v1_old_files/requirements.txt rename secureDeleter.py => v1_old_files/secureDeleter.py (100%) create mode 100644 v1_old_files/static/.gitkeep rename {static => v1_old_files/static}/favicon.svg (100%) rename {static => v1_old_files/static}/logo.svg (100%) create mode 100644 v1_old_files/static/style.css create mode 100644 v1_old_files/templates/about.html create mode 100644 v1_old_files/templates/base.html create mode 100644 v1_old_files/templates/decode.html create mode 100644 v1_old_files/templates/encode.html create mode 100644 v1_old_files/templates/encode_result.html create mode 100644 v1_old_files/templates/generate.html create mode 100644 v1_old_files/templates/index.html rename {test => v1_old_files/test}/story_generator.py (100%) create mode 100644 v1_old_files/uploads/.gitkeep diff --git a/.gitignore b/.gitignore index 2fd8ca0..1e611b0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,23 +4,50 @@ __pycache__/ *$py.class *.so .Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments venv/ +.venv/ ENV/ +env/ # IDE -.vscode/ .idea/ +.vscode/ *.swp *.swo -# Uploads (user data) -uploads/* -!uploads/.gitkeep +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Type checking +.mypy_cache/ +.dmypy.json # Environment .env -.env.local +.env.* +*.log -# OS -.DS_Store -Thumbs.db +# Distribution +*.manifest +*.spec diff --git a/Dockerfile b/Dockerfile index 9b67308..da619e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ -FROM python:3.11-slim +# Stegasoo Docker Image +# Multi-stage build for smaller image size + +FROM python:3.11-slim as base # Set environment variables ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 -# Set work directory -WORKDIR /app - # Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ @@ -14,25 +14,47 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libffi-dev \ && rm -rf /var/lib/apt/lists/* -# Install Python dependencies +# ============================================================================ +# Builder stage - install Python packages +# ============================================================================ +FROM base as builder -COPY requirements.txt . -#COPY requirements-ml.txt . +WORKDIR /build -RUN pip install --upgrade pip -RUN pip install --no-cache-dir -r requirements.txt --root-user-action=ignore -#RUN pip install --no-cache-dir -r requirements-ml.txt +# Copy package files (including README.md which pyproject.toml references) +COPY pyproject.toml README.md ./ +COPY src/ src/ +COPY data/ data/ -# Copy application -COPY . . +# Install the package with web extras +RUN 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 for security +# 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 @@ -40,5 +62,68 @@ EXPOSE 5000 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 in production +# 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 +COPY pyproject.toml README.md ./ +COPY src/ src/ +COPY data/ data/ +RUN 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/ +RUN pip install --no-cache-dir ".[cli]" + +# 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/README.md b/README.md index c2113ad..9dd4688 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,268 @@ -# Stegasoo Web Service +# Stegasoo -A containerized Flask + Bootstrap web UI for hybrid Photo + Day-Phrase + PIN steganography. +A secure steganography system for hiding encrypted messages in images using hybrid authentication. -![Python](https://img.shields.io/badge/Python-3.11+-blue) -![Flask](https://img.shields.io/badge/Flask-3.0+-green) -![Docker](https://img.shields.io/badge/Docker-Ready-blue) +![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) ## Features - 🔐 **AES-256-GCM** authenticated encryption -- 🧠 **Argon2id** memory-hard key derivation (256MB) +- 🧠 **Argon2id** memory-hard key derivation (256MB RAM requirement) - 🎲 **Pseudo-random pixel selection** defeats steganalysis -- 📅 **Daily key rotation** with customizable phrases (3-12 words) -- 🔢 **Static PIN** for additional entropy (6-8 digits) +- 📅 **Daily key rotation** with BIP-39 passphrases +- 🔑 **Multi-factor authentication**: PIN, RSA key, or both - 🖼️ **Reference photo** as "something you have" -- 🌐 **Web UI** with Bootstrap 5 dark theme -- 📖 **Memory aid stories** to help memorize phrases (template or AI-powered) +- 🌐 **Multiple interfaces**: CLI, Web UI, REST API + +## Installation + +### From PyPI (coming soon) + +```bash +# Core library only +pip install stegasoo + +# With CLI +pip install stegasoo[cli] + +# With Web UI +pip install stegasoo[web] + +# With REST API +pip install stegasoo[api] + +# Everything +pip install stegasoo[all] +``` + +### From Source + +```bash +git clone https://github.com/example/stegasoo.git +cd stegasoo + +# Install with all extras +pip install -e ".[all]" +``` + +### Docker + +```bash +# Web UI only +docker-compose up web + +# REST API only +docker-compose up api + +# Both +docker-compose up +``` ## Quick Start -### Docker (Recommended) +### Python Library -```bash -# Build and run -docker-compose up -d +```python +import stegasoo -# Access at http://localhost:5000 +# Generate credentials +creds = stegasoo.generate_credentials(use_pin=True, use_rsa=False) +print(f"Today's phrase: {creds.phrases['Monday']}") +print(f"PIN: {creds.pin}") + +# Encode a message +with open('secret_photo.jpg', 'rb') as f: + ref_photo = f.read() +with open('meme.png', 'rb') as f: + carrier = f.read() + +result = stegasoo.encode( + message="Meet at midnight", + reference_photo=ref_photo, + carrier_image=carrier, + day_phrase="apple forest thunder", + pin="123456" +) + +with open('stego.png', 'wb') as f: + f.write(result.stego_image) + +# Decode a message +message = stegasoo.decode( + stego_image=result.stego_image, + reference_photo=ref_photo, + day_phrase="apple forest thunder", + pin="123456" +) +print(message) # "Meet at midnight" ``` -### Manual Installation +### CLI ```bash -# Create virtual environment -python -m venv venv -source venv/bin/activate # Linux/Mac -# or: venv\Scripts\activate # Windows +# Generate credentials +stegasoo generate --pin --words 3 -# Install dependencies -pip install -r requirements.txt +# With RSA key +stegasoo generate --rsa --rsa-bits 4096 -o mykey.pem -p "secretpassword" -# Optional: Enable AI-powered story generation -pip install -r requirements-ml.txt +# Encode +stegasoo encode \ + --ref photo.jpg \ + --carrier meme.png \ + --phrase "apple forest thunder" \ + --pin 123456 \ + --message "Secret message" -# Run development server +# Decode +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 + +```bash +# Development +cd frontends/web python app.py -# Or production with gunicorn +# Production gunicorn --bind 0.0.0.0:5000 app:app ``` -## Usage +Visit http://localhost:5000 -### 1. Generate Credentials +### REST API -Visit `/generate` to create: -- **7 phrases** (one per day of week, 3-12 words each) -- **1 PIN** (6-8 digits, same every day) -- **Memory aid stories** (optional, helps memorize phrases) +```bash +# Development +cd frontends/api +python main.py -Memorize these! Don't save them. +# Production +uvicorn main:app --host 0.0.0.0 --port 8000 +``` -### 2. Encode a Message +API docs at http://localhost:8000/docs -Visit `/encode` and provide: -- **Reference photo** - A photo both parties have (NOT transmitted) -- **Carrier image** - The image to hide your message in -- **Message** - Your secret text -- **Day phrase** - Today's phrase -- **PIN** - Your static PIN +#### Example API Calls -Download the stego image and share it through any channel. +```bash +# Generate credentials +curl -X POST http://localhost:8000/generate \ + -H "Content-Type: application/json" \ + -d '{"use_pin": true, "use_rsa": false}' -### 3. Decode a Message +# 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 -Visit `/decode` and provide: -- **Reference photo** - Same photo used for encoding -- **Stego image** - The image containing the hidden message -- **Day phrase** - The phrase for the day it was encoded -- **PIN** - Your static PIN +# 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 | Component | Entropy | Purpose | |-----------|---------|---------| | Reference Photo | ~80-256 bits | Something you have | -| Day Phrase | ~33-132 bits | Something you know (rotates daily) | -| PIN | ~20-27 bits | Something you know (static) | -| **Combined** | **133-415+ bits** | **Beyond brute force** | +| Day Phrase (3 words) | ~33 bits | Something you know (rotates daily) | +| PIN (6 digits) | ~20 bits | Something you know (static) | +| RSA Key (2048-bit) | ~128 bits | Something you have | +| **Combined** | **133-400+ bits** | **Beyond brute force** | ### Attack Resistance -| Attack | Result | -|--------|--------| -| Brute force | 2^133+ combinations = impossible | +| Attack | Protection | +|--------|------------| +| Brute force | 2^133+ combinations | | Rainbow tables | Random salt per message | -| Steganalysis | Random pixel selection defeats detection | -| GPU cracking | Argon2 requires 256MB RAM per attempt | +| Steganalysis | Random pixel selection | +| GPU cracking | Argon2id requires 256MB RAM per attempt | +| Side-channel | Constant-time operations in crypto | -## Memory Aid Stories +## Project Structure -The generate page can create stories to help you memorize your phrases: - -**Template-based** (default): -> Monday morning began when I discovered a **APPLE** near the **FOREST**. I had to **THUNDER** quickly, then grab the **CRYSTAL** before reaching the **BRAVE**. - -**AI-powered** (with `requirements-ml.txt`): -- Uses DistilGPT-2 (~350MB model) -- Generates more coherent, natural stories -- Words highlighted in RED CAPS +``` +stegasoo/ +├── src/stegasoo/ # Core library +│ ├── __init__.py # Public API +│ ├── constants.py # Configuration +│ ├── crypto.py # Encryption/decryption +│ ├── steganography.py # Image embedding +│ ├── keygen.py # Credential generation +│ ├── validation.py # Input validation +│ ├── models.py # Data classes +│ ├── exceptions.py # Custom exceptions +│ └── utils.py # Utilities +│ +├── frontends/ +│ ├── web/ # Flask web UI +│ ├── cli/ # Command-line interface +│ └── api/ # FastAPI REST API +│ +├── data/ +│ └── bip39-words.txt # BIP-39 wordlist +│ +├── pyproject.toml # Package configuration +├── Dockerfile # Multi-stage Docker build +└── docker-compose.yml # Container orchestration +``` ## Configuration -Environment variables: +### Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `FLASK_ENV` | production | Flask environment | -| `SECRET_KEY` | random | Session secret (auto-generated) | +| `PYTHONPATH` | - | Include src/ for development | -## Production Deployment +### Limits -For production, consider: +| Limit | Value | +|-------|-------| +| Max image size | 4 megapixels | +| Max message size | 50 KB | +| Max file upload | 5 MB | +| PIN length | 6-9 digits | +| Phrase length | 3-12 words | +| RSA key sizes | 2048, 3072, 4096 bits | -1. **HTTPS** - Use nginx reverse proxy with SSL -2. **Rate limiting** - Prevent abuse -3. **Logging** - Monitor for security events -4. **Memory** - Allocate at least 512MB (Argon2 needs 256MB) +## Development + +```bash +# Install dev dependencies +pip install -e ".[dev]" + +# Run tests +pytest + +# Format code +black src/ frontends/ +ruff check src/ frontends/ + +# Type checking +mypy src/ +``` ## License diff --git a/docker-compose.yml b/docker-compose.yml index 900ff3b..f3055ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,18 @@ version: '3.8' services: - stegasoo: - build: . - container_name: stegasoo + # ============================================================================ + # Web UI (Flask) + # ============================================================================ + web: + build: + context: . + target: web + container_name: stegasoo-web ports: - "5000:5000" environment: - FLASK_ENV=production - # Uncomment to persist uploads between restarts: - # volumes: - # - ./uploads:/tmp/stego_uploads restart: unless-stopped deploy: resources: @@ -19,9 +21,30 @@ services: reservations: memory: 256M - # Optional: Add nginx reverse proxy for production + # ============================================================================ + # REST API (FastAPI) + # ============================================================================ + api: + build: + context: . + target: api + container_name: stegasoo-api + ports: + - "8000:8000" + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + + # ============================================================================ + # Nginx Reverse Proxy (optional, for production) + # ============================================================================ # nginx: # image: nginx:alpine + # container_name: stegasoo-nginx # ports: # - "80:80" # - "443:443" @@ -29,5 +52,11 @@ services: # - ./nginx.conf:/etc/nginx/nginx.conf:ro # - ./certs:/etc/nginx/certs:ro # depends_on: - # - stegasoo + # - web + # - api # restart: unless-stopped + +# ============================================================================ +# Development overrides +# ============================================================================ +# Use: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up diff --git a/frontends/api/main.py b/frontends/api/main.py new file mode 100644 index 0000000..a139bba --- /dev/null +++ b/frontends/api/main.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +""" +Stegasoo REST API + +FastAPI-based REST API for steganography operations. +Designed for integration with other services and automation. +""" + +import io +import sys +import base64 +from pathlib import Path +from typing import Optional +from datetime import date + +from fastapi import FastAPI, HTTPException, UploadFile, File, Form +from fastapi.responses import Response, JSONResponse +from pydantic import BaseModel, Field + +# 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, + validate_image, calculate_capacity, + DAY_NAMES, __version__, + StegasooError, DecryptionError, CapacityError, + has_argon2, +) +from stegasoo.constants import ( + MIN_PIN_LENGTH, MAX_PIN_LENGTH, + MIN_PHRASE_WORDS, MAX_PHRASE_WORDS, + VALID_RSA_SIZES, +) + + +# ============================================================================ +# FASTAPI APP +# ============================================================================ + +app = FastAPI( + title="Stegasoo API", + description="Secure steganography with hybrid authentication", + version=__version__, + docs_url="/docs", + redoc_url="/redoc", +) + + +# ============================================================================ +# MODELS +# ============================================================================ + +class GenerateRequest(BaseModel): + use_pin: bool = True + use_rsa: bool = False + pin_length: int = Field(default=6, ge=MIN_PIN_LENGTH, le=MAX_PIN_LENGTH) + rsa_bits: int = Field(default=2048) + words_per_phrase: int = Field(default=3, ge=MIN_PHRASE_WORDS, le=MAX_PHRASE_WORDS) + + +class GenerateResponse(BaseModel): + phrases: dict[str, str] + pin: Optional[str] = None + rsa_key_pem: Optional[str] = None + entropy: dict[str, int] + + +class EncodeRequest(BaseModel): + message: str + reference_photo_base64: str + carrier_image_base64: str + day_phrase: str + pin: str = "" + rsa_key_base64: Optional[str] = None + rsa_password: Optional[str] = None + date_str: Optional[str] = None + + +class EncodeResponse(BaseModel): + stego_image_base64: str + filename: str + capacity_used_percent: float + date_used: str + + +class DecodeRequest(BaseModel): + stego_image_base64: str + reference_photo_base64: str + day_phrase: str + pin: str = "" + rsa_key_base64: Optional[str] = None + rsa_password: Optional[str] = None + + +class DecodeResponse(BaseModel): + message: str + + +class ImageInfoResponse(BaseModel): + width: int + height: int + pixels: int + capacity_bytes: int + capacity_kb: int + + +class StatusResponse(BaseModel): + version: str + has_argon2: bool + day_names: list[str] + + +class ErrorResponse(BaseModel): + error: str + detail: Optional[str] = None + + +# ============================================================================ +# ROUTES +# ============================================================================ + +@app.get("/", response_model=StatusResponse) +async def root(): + """Get API status and configuration.""" + return StatusResponse( + version=__version__, + has_argon2=has_argon2(), + day_names=list(DAY_NAMES) + ) + + +@app.post("/generate", response_model=GenerateResponse) +async def api_generate(request: GenerateRequest): + """ + Generate credentials for encoding/decoding. + + At least one of use_pin or use_rsa must be True. + """ + if not request.use_pin and not request.use_rsa: + raise HTTPException(400, "Must enable at least one of use_pin or use_rsa") + + if request.rsa_bits not in VALID_RSA_SIZES: + raise HTTPException(400, f"rsa_bits must be one of {VALID_RSA_SIZES}") + + try: + creds = generate_credentials( + use_pin=request.use_pin, + use_rsa=request.use_rsa, + pin_length=request.pin_length, + rsa_bits=request.rsa_bits, + words_per_phrase=request.words_per_phrase + ) + + return GenerateResponse( + phrases=creds.phrases, + pin=creds.pin, + rsa_key_pem=creds.rsa_key_pem, + entropy={ + "phrase": creds.phrase_entropy, + "pin": creds.pin_entropy, + "rsa": creds.rsa_entropy, + "total": creds.total_entropy + } + ) + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.post("/encode", response_model=EncodeResponse) +async def api_encode(request: EncodeRequest): + """ + Encode a secret message into an image. + + Images must be base64-encoded. Returns base64-encoded stego image. + """ + try: + ref_photo = base64.b64decode(request.reference_photo_base64) + carrier = base64.b64decode(request.carrier_image_base64) + rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None + + result = encode( + message=request.message, + reference_photo=ref_photo, + carrier_image=carrier, + day_phrase=request.day_phrase, + pin=request.pin, + rsa_key_data=rsa_key, + rsa_password=request.rsa_password, + date_str=request.date_str + ) + + stego_b64 = base64.b64encode(result.stego_image).decode('utf-8') + + return EncodeResponse( + stego_image_base64=stego_b64, + filename=result.filename, + capacity_used_percent=result.capacity_percent, + date_used=result.date_used + ) + + except CapacityError as e: + raise HTTPException(400, str(e)) + except StegasooError as e: + raise HTTPException(400, str(e)) + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.post("/decode", response_model=DecodeResponse) +async def api_decode(request: DecodeRequest): + """ + Decode a secret message from a stego image. + + Images must be base64-encoded. + """ + try: + stego = base64.b64decode(request.stego_image_base64) + ref_photo = base64.b64decode(request.reference_photo_base64) + rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None + + message = decode( + stego_image=stego, + reference_photo=ref_photo, + day_phrase=request.day_phrase, + pin=request.pin, + rsa_key_data=rsa_key, + rsa_password=request.rsa_password + ) + + return DecodeResponse(message=message) + + except DecryptionError as e: + raise HTTPException(401, "Decryption failed. Check credentials.") + except StegasooError as e: + raise HTTPException(400, str(e)) + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.post("/encode/multipart") +async def api_encode_multipart( + message: str = Form(...), + day_phrase: str = Form(...), + reference_photo: UploadFile = File(...), + carrier: UploadFile = File(...), + pin: str = Form(""), + rsa_key: Optional[UploadFile] = File(None), + rsa_password: str = Form(""), + date_str: str = Form("") +): + """ + Encode using multipart form data (file uploads). + + Returns the stego image directly as PNG. + """ + try: + ref_data = await reference_photo.read() + carrier_data = await carrier.read() + rsa_key_data = await rsa_key.read() if rsa_key else None + + result = encode( + message=message, + reference_photo=ref_data, + carrier_image=carrier_data, + day_phrase=day_phrase, + pin=pin, + rsa_key_data=rsa_key_data, + rsa_password=rsa_password if rsa_password else None, + date_str=date_str if date_str else None + ) + + return Response( + content=result.stego_image, + media_type="image/png", + headers={"Content-Disposition": f"attachment; filename={result.filename}"} + ) + + except CapacityError as e: + raise HTTPException(400, str(e)) + except StegasooError as e: + raise HTTPException(400, str(e)) + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.post("/decode/multipart", response_model=DecodeResponse) +async def api_decode_multipart( + day_phrase: str = Form(...), + reference_photo: UploadFile = File(...), + stego_image: UploadFile = File(...), + pin: str = Form(""), + rsa_key: Optional[UploadFile] = File(None), + rsa_password: str = Form("") +): + """ + Decode using multipart form data (file uploads). + """ + try: + ref_data = await reference_photo.read() + stego_data = await stego_image.read() + rsa_key_data = await rsa_key.read() if rsa_key else None + + message = decode( + stego_image=stego_data, + reference_photo=ref_data, + day_phrase=day_phrase, + pin=pin, + rsa_key_data=rsa_key_data, + rsa_password=rsa_password if rsa_password else None + ) + + return DecodeResponse(message=message) + + except DecryptionError: + raise HTTPException(401, "Decryption failed. Check credentials.") + except StegasooError as e: + raise HTTPException(400, str(e)) + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.post("/image/info", response_model=ImageInfoResponse) +async def api_image_info(image: UploadFile = File(...)): + """Get information about an image's capacity.""" + try: + image_data = await image.read() + + result = validate_image(image_data, check_size=False) + if not result.is_valid: + raise HTTPException(400, result.error_message) + + capacity = calculate_capacity(image_data) + + return ImageInfoResponse( + width=result.details['width'], + height=result.details['height'], + pixels=result.details['pixels'], + capacity_bytes=capacity, + capacity_kb=capacity // 1024 + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(500, str(e)) + + +# ============================================================================ +# ERROR HANDLERS +# ============================================================================ + +@app.exception_handler(StegasooError) +async def stegasoo_error_handler(request, exc): + return JSONResponse( + status_code=400, + content={"error": type(exc).__name__, "detail": str(exc)} + ) + + +# ============================================================================ +# MAIN +# ============================================================================ + +if __name__ == '__main__': + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/frontends/cli/main.py b/frontends/cli/main.py new file mode 100644 index 0000000..05575ae --- /dev/null +++ b/frontends/cli/main.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +""" +Stegasoo CLI - Command-line interface for steganography operations. + +Usage: + stegasoo generate [OPTIONS] + stegasoo encode [OPTIONS] + stegasoo decode [OPTIONS] + stegasoo info [OPTIONS] +""" + +import sys +from pathlib import Path +from typing import Optional + +import click + +# 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_image, calculate_capacity, + get_day_from_date, parse_date_from_filename, + DAY_NAMES, __version__, + StegasooError, DecryptionError, ExtractionError, +) + + +# ============================================================================ +# CLI SETUP +# ============================================================================ + +CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) + + +@click.group(context_settings=CONTEXT_SETTINGS) +@click.version_option(__version__, '-v', '--version') +def cli(): + """ + Stegasoo - Secure steganography with hybrid authentication. + + Hide encrypted messages in images using a combination of: + + \b + • Reference photo (something you have) + • Daily passphrase (something you know) + • Static PIN or RSA key (additional security) + """ + pass + + +# ============================================================================ +# GENERATE COMMAND +# ============================================================================ + +@cli.command() +@click.option('--pin/--no-pin', default=True, help='Generate a PIN (default: yes)') +@click.option('--rsa/--no-rsa', default=False, help='Generate an RSA key') +@click.option('--pin-length', type=click.IntRange(6, 9), default=6, help='PIN length (6-9)') +@click.option('--rsa-bits', type=click.Choice(['2048', '3072', '4096']), default='2048', help='RSA key size') +@click.option('--words', type=click.IntRange(3, 12), default=3, help='Words per phrase (3-12)') +@click.option('--output', '-o', type=click.Path(), help='Save RSA key to file (requires password)') +@click.option('--password', '-p', help='Password for RSA key file') +@click.option('--json', 'as_json', is_flag=True, help='Output as JSON') +def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): + """ + Generate credentials for encoding/decoding. + + Creates daily passphrases and optionally a PIN and/or RSA key. + At least one of --pin or --rsa must be enabled. + + \b + Examples: + stegasoo generate + stegasoo generate --rsa --rsa-bits 4096 + stegasoo generate --rsa -o mykey.pem -p "secretpassword" + stegasoo generate --no-pin --rsa + """ + if not pin and not rsa: + raise click.UsageError("Must enable at least one of --pin or --rsa") + + if output and not password: + raise click.UsageError("--password is required when saving RSA key to file") + + if password and len(password) < 8: + raise click.UsageError("Password must be at least 8 characters") + + try: + creds = generate_credentials( + use_pin=pin, + use_rsa=rsa, + pin_length=pin_length, + rsa_bits=int(rsa_bits), + words_per_phrase=words + ) + + if as_json: + import json + data = { + 'phrases': creds.phrases, + 'pin': creds.pin, + 'rsa_key': creds.rsa_key_pem, + 'entropy': { + 'phrase': creds.phrase_entropy, + 'pin': creds.pin_entropy, + 'rsa': creds.rsa_entropy, + 'total': creds.total_entropy, + } + } + click.echo(json.dumps(data, indent=2)) + return + + # Pretty output + click.echo() + click.secho("═" * 60, fg='cyan') + click.secho(" STEGASOO CREDENTIALS", fg='cyan', bold=True) + click.secho("═" * 60, fg='cyan') + click.echo() + + 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(f" {creds.pin}", fg='bright_yellow', bold=True) + click.echo() + + click.secho("─── DAILY PHRASES ───", fg='green') + for day in DAY_NAMES: + phrase = creds.phrases[day] + 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') + if output: + # Save to file + private_key = load_rsa_key(creds.rsa_key_pem.encode()) + encrypted_pem = export_rsa_key_pem(private_key, password) + Path(output).write_bytes(encrypted_pem) + click.secho(f" Saved to: {output}", fg='bright_white') + click.secho(f" Password: {'*' * len(password)}", fg='dim') + else: + click.echo(creds.rsa_key_pem) + click.echo() + + 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") + if creds.rsa_key_pem: + click.echo(f" RSA entropy: {creds.rsa_entropy} bits") + click.echo(f" Combined: {creds.total_entropy} bits") + click.secho(f" + photo entropy: 80-256 bits", fg='dim') + click.echo() + + except Exception as e: + raise click.ClickException(str(e)) + + +# ============================================================================ +# ENCODE COMMAND +# ============================================================================ + +@cli.command() +@click.option('--ref', '-r', required=True, type=click.Path(exists=True), help='Reference photo') +@click.option('--carrier', '-c', required=True, type=click.Path(exists=True), help='Carrier image') +@click.option('--message', '-m', help='Message to encode (or use stdin)') +@click.option('--message-file', '-f', type=click.Path(exists=True), help='Read message from file') +@click.option('--phrase', '-p', required=True, help='Day phrase') +@click.option('--pin', help='Static PIN') +@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file') +@click.option('--key-password', help='RSA key password') +@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('--quiet', '-q', is_flag=True, help='Suppress output except errors') +def encode_cmd(ref, carrier, message, message_file, phrase, pin, key, key_password, output, date_str, quiet): + """ + Encode a secret message into an image. + + Requires a reference photo, carrier image, and day phrase. + Must provide either --pin or --key (or both). + + \b + Examples: + stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -m "secret" + echo "secret" | stegasoo encode -r photo.jpg -c meme.png -p "word1 word2 word3" --pin 123456 + stegasoo encode -r photo.jpg -c meme.png -p "words" -k mykey.pem --key-password "pass" + """ + # Get message + if message: + msg = message + elif message_file: + msg = Path(message_file).read_text() + elif not sys.stdin.isatty(): + msg = sys.stdin.read() + else: + raise click.UsageError("Must provide message via -m, -f, or stdin") + + # Load key if provided + rsa_key_data = None + if key: + rsa_key_data = Path(key).read_bytes() + + # Validate security factors + if not pin and not rsa_key_data: + raise click.UsageError("Must provide --pin or --key (or both)") + + try: + ref_photo = Path(ref).read_bytes() + carrier_image = Path(carrier).read_bytes() + + result = encode( + message=msg, + reference_photo=ref_photo, + carrier_image=carrier_image, + day_phrase=phrase, + pin=pin or "", + rsa_key_data=rsa_key_data, + rsa_password=key_password, + date_str=date_str, + ) + + # Determine output path + if output: + out_path = Path(output) + else: + out_path = Path(result.filename) + + # Write output + out_path.write_bytes(result.stego_image) + + if not quiet: + click.secho(f"✓ 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}") + + except StegasooError as e: + raise click.ClickException(str(e)) + except Exception as e: + raise click.ClickException(f"Error: {e}") + + +# ============================================================================ +# DECODE COMMAND +# ============================================================================ + +@cli.command() +@click.option('--ref', '-r', required=True, type=click.Path(exists=True), help='Reference photo') +@click.option('--stego', '-s', required=True, type=click.Path(exists=True), help='Stego image') +@click.option('--phrase', '-p', required=True, help='Day phrase') +@click.option('--pin', help='Static PIN') +@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file') +@click.option('--key-password', help='RSA key password') +@click.option('--output', '-o', type=click.Path(), help='Save message to file') +@click.option('--quiet', '-q', is_flag=True, help='Output only the message') +def decode_cmd(ref, stego, phrase, pin, key, key_password, output, quiet): + """ + Decode a secret message from a stego image. + + Must use the same credentials that were used for encoding. + + \b + Examples: + stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456 + stegasoo decode -r photo.jpg -s stego.png -p "words" -k mykey.pem --key-password "pass" + stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -o message.txt + """ + # Load key if provided + rsa_key_data = None + if key: + rsa_key_data = Path(key).read_bytes() + + # Validate security factors + if not pin and not rsa_key_data: + raise click.UsageError("Must provide --pin or --key (or both)") + + try: + ref_photo = Path(ref).read_bytes() + stego_image = Path(stego).read_bytes() + + message = decode( + stego_image=stego_image, + reference_photo=ref_photo, + day_phrase=phrase, + pin=pin or "", + rsa_key_data=rsa_key_data, + rsa_password=key_password, + ) + + if output: + Path(output).write_text(message) + if not quiet: + click.secho(f"✓ Decoded successfully!", fg='green') + click.echo(f" Saved to: {output}") + else: + if quiet: + click.echo(message) + else: + click.secho("✓ Decoded successfully!", fg='green') + click.echo() + click.echo(message) + + except (DecryptionError, ExtractionError) as e: + raise click.ClickException(f"Decryption failed: {e}") + except StegasooError as e: + raise click.ClickException(str(e)) + except Exception as e: + raise click.ClickException(f"Error: {e}") + + +# ============================================================================ +# INFO COMMAND +# ============================================================================ + +@cli.command() +@click.argument('image', type=click.Path(exists=True)) +def info(image): + """ + Show information about an image. + + Displays dimensions, capacity, and attempts to detect date from filename. + """ + try: + image_data = Path(image).read_bytes() + + result = validate_image(image_data, check_size=False) + if not result.is_valid: + raise click.ClickException(result.error_message) + + capacity = calculate_capacity(image_data) + + # Try to get date from filename + date_str = parse_date_from_filename(image) + day_name = get_day_from_date(date_str) if date_str else None + + click.echo() + click.secho(f"Image: {image}", bold=True) + click.echo(f" Dimensions: {result.details['width']} × {result.details['height']}") + click.echo(f" Pixels: {result.details['pixels']:,}") + click.echo(f" Mode: {result.details['mode']}") + click.echo(f" Format: {result.details['format']}") + click.echo(f" Capacity: ~{capacity:,} bytes ({capacity // 1024} KB)") + + if date_str: + click.echo(f" Embed date: {date_str} ({day_name})") + + click.echo() + + except Exception as e: + raise click.ClickException(str(e)) + + +# ============================================================================ +# MAIN +# ============================================================================ + +def main(): + """Entry point.""" + cli() + + +if __name__ == '__main__': + main() diff --git a/frontends/web/app.py b/frontends/web/app.py new file mode 100644 index 0000000..f854d70 --- /dev/null +++ b/frontends/web/app.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +""" +Stegasoo Web Frontend + +Flask-based web UI for steganography operations. +This is a thin wrapper around the stegasoo library. +""" + +import io +import sys +import time +import secrets +from pathlib import Path +from datetime import datetime + +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, + get_today_day, generate_filename, + DAY_NAMES, __version__, + StegasooError, DecryptionError, CapacityError, + has_argon2, +) +from stegasoo.constants import ( + MAX_MESSAGE_SIZE, MIN_PIN_LENGTH, MAX_PIN_LENGTH, + VALID_RSA_SIZES, +) + + +# ============================================================================ +# FLASK APP CONFIGURATION +# ============================================================================ + +app = Flask(__name__) +app.secret_key = secrets.token_hex(32) +app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 # 5MB max upload + +# Temporary file storage for sharing (file_id -> {data, timestamp, filename}) +TEMP_FILES: dict[str, dict] = {} +TEMP_FILE_EXPIRY = 300 # 5 minutes + + +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) + + +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'} + + +# ============================================================================ +# 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_ml=False) + + 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 + ) + + 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_ml=False + ) + except Exception as e: + flash(f'Error generating credentials: {e}', 'error') + return render_template('generate.html', generated=False, has_ml=False) + + return render_template('generate.html', generated=False, has_ml=False) + + +@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()) + 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('/encode', methods=['GET', 'POST']) +def encode_page(): + day_of_week = get_today_day() + + 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') + + 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) + + 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) + + # 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', '') + + # Validate 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) + + if not day_phrase: + flash('Day phrase is required', 'error') + return render_template('encode.html', day_of_week=day_of_week) + + # Read files + ref_data = ref_photo.read() + carrier_data = carrier.read() + rsa_key_data = rsa_key_file.read() if rsa_key_file and rsa_key_file.filename else None + + # 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) + + # 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) + + # Validate RSA key if provided + if rsa_key_data: + result = validate_rsa_key(rsa_key_data, rsa_password if rsa_password else None) + if not result.is_valid: + flash(result.error_message, 'error') + return render_template('encode.html', day_of_week=day_of_week) + + # 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) + + # 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=message, + reference_photo=ref_data, + carrier_image=carrier_data, + day_phrase=day_phrase, + pin=pin, + rsa_key_data=rsa_key_data, + rsa_password=rsa_password if rsa_password else None, + 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) + except StegasooError as e: + flash(str(e), 'error') + return render_template('encode.html', day_of_week=day_of_week) + except Exception as e: + flash(f'Error: {e}', 'error') + return render_template('encode.html', day_of_week=day_of_week) + + return render_template('encode.html', day_of_week=day_of_week) + + +@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] + return render_template('encode_result.html', + file_id=file_id, + filename=file_info['filename'] + ) + + +@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(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) + 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') + + # 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') + + # Read files + ref_data = ref_photo.read() + stego_data = stego_image.read() + rsa_key_data = rsa_key_file.read() if rsa_key_file and rsa_key_file.filename else None + + # 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') + + # 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') + + # Validate RSA key if provided + if rsa_key_data: + result = validate_rsa_key(rsa_key_data, rsa_password if rsa_password else None) + if not result.is_valid: + flash(result.error_message, 'error') + return render_template('decode.html') + + # Decode + message = decode( + stego_image=stego_data, + reference_photo=ref_data, + day_phrase=day_phrase, + pin=pin, + rsa_key_data=rsa_key_data, + rsa_password=rsa_password if rsa_password else None + ) + + return render_template('decode.html', decoded_message=message) + + except DecryptionError: + flash('Decryption failed. Check your phrase, PIN, RSA key, and reference photo.', 'error') + return render_template('decode.html') + except StegasooError as e: + flash(str(e), 'error') + return render_template('decode.html') + except Exception as e: + flash(f'Error: {e}', 'error') + return render_template('decode.html') + + return render_template('decode.html') + + +@app.route('/about') +def about(): + return render_template('about.html', has_argon2=has_argon2()) + + +# ============================================================================ +# MAIN +# ============================================================================ + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/frontends/web/static/favicon.svg b/frontends/web/static/favicon.svg new file mode 100644 index 0000000..afcea85 --- /dev/null +++ b/frontends/web/static/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontends/web/static/logo.svg b/frontends/web/static/logo.svg new file mode 100644 index 0000000..39b1811 --- /dev/null +++ b/frontends/web/static/logo.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/style.css b/frontends/web/static/style.css similarity index 100% rename from static/style.css rename to frontends/web/static/style.css diff --git a/templates/about.html b/frontends/web/templates/about.html similarity index 100% rename from templates/about.html rename to frontends/web/templates/about.html diff --git a/templates/base.html b/frontends/web/templates/base.html similarity index 100% rename from templates/base.html rename to frontends/web/templates/base.html diff --git a/templates/decode.html b/frontends/web/templates/decode.html similarity index 100% rename from templates/decode.html rename to frontends/web/templates/decode.html diff --git a/templates/encode.html b/frontends/web/templates/encode.html similarity index 100% rename from templates/encode.html rename to frontends/web/templates/encode.html diff --git a/templates/encode_result.html b/frontends/web/templates/encode_result.html similarity index 100% rename from templates/encode_result.html rename to frontends/web/templates/encode_result.html diff --git a/templates/generate.html b/frontends/web/templates/generate.html similarity index 100% rename from templates/generate.html rename to frontends/web/templates/generate.html diff --git a/templates/index.html b/frontends/web/templates/index.html similarity index 100% rename from templates/index.html rename to frontends/web/templates/index.html diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4a98148 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,105 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "stegasoo" +version = "2.0.0" +description = "Secure steganography with hybrid photo + passphrase + PIN authentication" +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +authors = [ + { name = "Aaron D. Lee" } +] +keywords = [ + "steganography", + "encryption", + "security", + "privacy", + "cryptography", + "image", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Security :: Cryptography", + "Topic :: Multimedia :: Graphics", +] + +dependencies = [ + "pillow>=10.0.0", + "cryptography>=41.0.0", + "argon2-cffi>=23.0.0", +] + +[project.optional-dependencies] +cli = [ + "click>=8.0.0", +] +web = [ + "flask>=3.0.0", + "gunicorn>=21.0.0", +] +api = [ + "fastapi>=0.100.0", + "uvicorn[standard]>=0.20.0", + "python-multipart>=0.0.6", +] +all = [ + "stegasoo[cli,web,api]", +] +dev = [ + "stegasoo[all]", + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "black>=23.0.0", + "ruff>=0.1.0", + "mypy>=1.0.0", +] + +[project.scripts] +stegasoo = "stegasoo.cli:main" + +[project.urls] +Homepage = "https://github.com/example/stegasoo" +Documentation = "https://github.com/example/stegasoo#readme" +Repository = "https://github.com/example/stegasoo" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/data", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/stegasoo"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +addopts = "-v --cov=stegasoo --cov-report=term-missing" + +[tool.black] +line-length = 100 +target-version = ["py310", "py311", "py312"] + +[tool.ruff] +line-length = 100 +select = ["E", "F", "I", "N", "W", "UP"] +ignore = ["E501"] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true diff --git a/requirements.txt b/requirements.txt index 9605ef7..f7f4276 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,16 @@ # Core dependencies -flask>=3.0.0 -gunicorn>=21.0.0 pillow>=10.0.0 cryptography>=41.0.0 - -# Memory-hard key derivation (highly recommended) argon2-cffi>=23.0.0 -# Optional: ML story generation (adds ~1GB disk space) -# Uncomment for AI-powered memory aid stories -# transformers>=4.35.0 -# torch>=2.0.0 +# CLI (optional) +click>=8.0.0 -# Optional: For production deployment -# gevent>=23.0.0 +# Web UI (optional) +flask>=3.0.0 +gunicorn>=21.0.0 + +# REST API (optional) +# fastapi>=0.100.0 +# uvicorn[standard]>=0.20.0 +# python-multipart>=0.0.6 diff --git a/static/.gitkeep b/src/__init__.py similarity index 100% rename from static/.gitkeep rename to src/__init__.py diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..fcb14ea --- /dev/null +++ b/src/main.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""Main entry point.""" + + +def main(): + """Main function.""" + print("Hello, World!") + + +if __name__ == "__main__": + main() diff --git a/src/stegasoo/__init__.py b/src/stegasoo/__init__.py new file mode 100644 index 0000000..9b5af30 --- /dev/null +++ b/src/stegasoo/__init__.py @@ -0,0 +1,357 @@ +""" +Stegasoo - Secure Steganography Library + +A Python library for hiding encrypted messages in images using +hybrid photo + passphrase + PIN authentication. + +Basic Usage: + from stegasoo import encode, decode, generate_credentials + + # Generate credentials + creds = generate_credentials(use_pin=True, use_rsa=False) + print(creds.phrases['Monday']) + print(creds.pin) + + # Encode a message + with open('secret.jpg', 'rb') as f: + ref_photo = f.read() + with open('meme.png', 'rb') as f: + carrier = f.read() + + result = encode( + message="Meet at midnight", + reference_photo=ref_photo, + carrier_image=carrier, + day_phrase="apple forest thunder", + pin="123456" + ) + + with open('stego.png', 'wb') as f: + f.write(result.stego_image) + + # Decode a message + message = decode( + stego_image=result.stego_image, + reference_photo=ref_photo, + day_phrase="apple forest thunder", + pin="123456" + ) + print(message) # "Meet at midnight" +""" + +from .constants import __version__, DAY_NAMES +from .models import ( + Credentials, + EncodeInput, + EncodeResult, + DecodeInput, + DecodeResult, + EmbedStats, + KeyInfo, + ValidationResult, +) +from .exceptions import ( + StegasooError, + ValidationError, + PinValidationError, + MessageValidationError, + ImageValidationError, + KeyValidationError, + SecurityFactorError, + CryptoError, + EncryptionError, + DecryptionError, + KeyDerivationError, + KeyGenerationError, + KeyPasswordError, + SteganographyError, + CapacityError, + ExtractionError, + EmbeddingError, + InvalidHeaderError, +) +from .keygen import ( + generate_credentials, + generate_pin, + generate_phrase, + generate_day_phrases, + generate_rsa_key, + export_rsa_key_pem, + load_rsa_key, + get_key_info, +) +from .validation import ( + validate_pin, + validate_message, + validate_image, + validate_rsa_key, + validate_security_factors, + validate_phrase, + validate_date_string, + require_valid_pin, + require_valid_message, + require_valid_image, + require_valid_rsa_key, + require_security_factors, +) +from .crypto import ( + encrypt_message, + decrypt_message, + derive_hybrid_key, + derive_pixel_key, + hash_photo, + parse_header, + get_date_from_encrypted, + has_argon2, +) +from .steganography import ( + embed_in_image, + extract_from_image, + calculate_capacity, + get_image_dimensions, +) +from .utils import ( + generate_filename, + parse_date_from_filename, + get_day_from_date, + get_today_date, + get_today_day, + secure_delete, + SecureDeleter, + format_file_size, +) + +from datetime import date +from typing import Optional + + +def encode( + message: str, + reference_photo: bytes, + carrier_image: bytes, + day_phrase: str, + pin: str = "", + rsa_key_data: Optional[bytes] = None, + rsa_password: Optional[str] = None, + date_str: Optional[str] = None, +) -> EncodeResult: + """ + Encode a secret message into an image. + + High-level convenience function that handles validation, + encryption, and embedding in one call. + + Args: + message: Secret message to hide + reference_photo: Shared reference photo bytes + carrier_image: Image to hide message in + day_phrase: Today's passphrase + pin: Static PIN (optional if using RSA key) + rsa_key_data: RSA private key PEM bytes (optional if using PIN) + rsa_password: Password for RSA key if encrypted + date_str: Date string YYYY-MM-DD (defaults to today) + + Returns: + EncodeResult with stego image and metadata + + Raises: + ValidationError: If inputs are invalid + SecurityFactorError: If no PIN or RSA key provided + CapacityError: If carrier is too small + EncryptionError: If encryption fails + """ + # Validate inputs + require_valid_message(message) + require_valid_image(carrier_image, "Carrier image") + require_security_factors(pin, rsa_key_data) + + if pin: + require_valid_pin(pin) + if rsa_key_data: + require_valid_rsa_key(rsa_key_data, rsa_password) + + # Default date to today + if date_str is None: + date_str = date.today().isoformat() + + # Encrypt message + encrypted = encrypt_message( + message, reference_photo, day_phrase, date_str, pin, rsa_key_data + ) + + # Get pixel key + pixel_key = derive_pixel_key( + reference_photo, day_phrase, date_str, pin, rsa_key_data + ) + + # Embed in image + stego_data, stats = embed_in_image(carrier_image, encrypted, pixel_key) + + # Generate filename + filename = generate_filename(date_str) + + return EncodeResult( + stego_image=stego_data, + filename=filename, + pixels_modified=stats.pixels_modified, + total_pixels=stats.total_pixels, + capacity_used=stats.capacity_used, + date_used=date_str + ) + + +def decode( + stego_image: bytes, + reference_photo: bytes, + day_phrase: str, + pin: str = "", + rsa_key_data: Optional[bytes] = None, + rsa_password: Optional[str] = None, +) -> str: + """ + Decode a secret message from a stego image. + + High-level convenience function that handles extraction + and decryption in one call. + + Args: + stego_image: Image containing hidden message + reference_photo: Shared reference photo bytes + day_phrase: Passphrase for the day message was encoded + pin: Static PIN (if used during encoding) + rsa_key_data: RSA private key PEM bytes (if used during encoding) + rsa_password: Password for RSA key if encrypted + + Returns: + Decrypted message string + + Raises: + ValidationError: If inputs are invalid + SecurityFactorError: If no PIN or RSA key provided + ExtractionError: If data cannot be extracted + DecryptionError: If decryption fails + """ + # Validate inputs + require_security_factors(pin, rsa_key_data) + + if pin: + require_valid_pin(pin) + if rsa_key_data: + require_valid_rsa_key(rsa_key_data, rsa_password) + + # Try to extract with today's date first + date_str = date.today().isoformat() + pixel_key = derive_pixel_key( + reference_photo, day_phrase, date_str, pin, rsa_key_data + ) + + encrypted = extract_from_image(stego_image, pixel_key) + + # If we got data, check if it's from a different date + if encrypted: + header = parse_header(encrypted) + if header and header['date'] != date_str: + # Re-extract with correct date + pixel_key = derive_pixel_key( + reference_photo, day_phrase, header['date'], pin, rsa_key_data + ) + encrypted = extract_from_image(stego_image, pixel_key) + + if not encrypted: + raise ExtractionError("Could not extract data. Check your inputs.") + + # Decrypt + return decrypt_message(encrypted, reference_photo, day_phrase, pin, rsa_key_data) + + +__all__ = [ + # Version + '__version__', + + # High-level API + 'encode', + 'decode', + 'generate_credentials', + + # Constants + 'DAY_NAMES', + + # Models + 'Credentials', + 'EncodeInput', + 'EncodeResult', + 'DecodeInput', + 'DecodeResult', + 'EmbedStats', + 'KeyInfo', + 'ValidationResult', + + # Exceptions + 'StegasooError', + 'ValidationError', + 'PinValidationError', + 'MessageValidationError', + 'ImageValidationError', + 'KeyValidationError', + 'SecurityFactorError', + 'CryptoError', + 'EncryptionError', + 'DecryptionError', + 'KeyDerivationError', + 'KeyGenerationError', + 'KeyPasswordError', + 'SteganographyError', + 'CapacityError', + 'ExtractionError', + 'EmbeddingError', + 'InvalidHeaderError', + + # Key generation + 'generate_pin', + 'generate_phrase', + 'generate_day_phrases', + 'generate_rsa_key', + 'export_rsa_key_pem', + 'load_rsa_key', + 'get_key_info', + + # Validation + 'validate_pin', + 'validate_message', + 'validate_image', + 'validate_rsa_key', + 'validate_security_factors', + 'validate_phrase', + 'validate_date_string', + 'require_valid_pin', + 'require_valid_message', + 'require_valid_image', + 'require_valid_rsa_key', + 'require_security_factors', + + # Crypto + 'encrypt_message', + 'decrypt_message', + 'derive_hybrid_key', + 'derive_pixel_key', + 'hash_photo', + 'parse_header', + 'get_date_from_encrypted', + 'has_argon2', + + # Steganography + 'embed_in_image', + 'extract_from_image', + 'calculate_capacity', + 'get_image_dimensions', + + # Utilities + 'generate_filename', + 'parse_date_from_filename', + 'get_day_from_date', + 'get_today_date', + 'get_today_day', + 'secure_delete', + 'SecureDeleter', + 'format_file_size', +] diff --git a/src/stegasoo/cli.py b/src/stegasoo/cli.py new file mode 100644 index 0000000..a3ebab0 --- /dev/null +++ b/src/stegasoo/cli.py @@ -0,0 +1,66 @@ +""" +Stegasoo CLI - Command-line interface for steganography operations. + +This is the package entry point. For full CLI, install with: pip install stegasoo[cli] +""" + +def main(): + """Main entry point for the CLI.""" + try: + import click + except ImportError: + print("CLI requires click. Install with: pip install stegasoo[cli]") + return 1 + + # Import the CLI from frontends + import sys + from pathlib import Path + + # Add frontends to path for development + root = Path(__file__).parent.parent.parent + cli_path = root / 'frontends' / 'cli' + if cli_path.exists(): + sys.path.insert(0, str(cli_path)) + + try: + from main import cli + cli() + except ImportError: + # Minimal fallback CLI + _minimal_cli() + + +def _minimal_cli(): + """Minimal CLI when full CLI is not available.""" + import sys + from . import __version__, generate_credentials, DAY_NAMES + + if len(sys.argv) < 2 or sys.argv[1] in ['-h', '--help']: + print(f"Stegasoo v{__version__} - Secure Steganography") + print() + print("Usage: stegasoo ") + print() + print("Commands:") + print(" generate Generate credentials") + print(" encode Encode a message (requires full CLI)") + print(" decode Decode a message (requires full CLI)") + print() + print("For full CLI functionality:") + print(" pip install stegasoo[cli]") + return + + if sys.argv[1] == 'generate': + creds = generate_credentials(use_pin=True, use_rsa=False) + print("\n=== STEGASOO CREDENTIALS ===\n") + print(f"PIN: {creds.pin}\n") + print("Daily Phrases:") + for day in DAY_NAMES: + print(f" {day:9} | {creds.phrases[day]}") + print(f"\nEntropy: {creds.total_entropy} bits (+ photo)") + else: + print(f"Command '{sys.argv[1]}' requires full CLI.") + print("Install with: pip install stegasoo[cli]") + + +if __name__ == '__main__': + main() diff --git a/src/stegasoo/constants.py b/src/stegasoo/constants.py new file mode 100644 index 0000000..0e24333 --- /dev/null +++ b/src/stegasoo/constants.py @@ -0,0 +1,119 @@ +""" +Stegasoo Constants and Configuration + +Central location for all magic numbers, limits, and crypto parameters. +""" + +import os +from pathlib import Path + +# ============================================================================ +# VERSION +# ============================================================================ + +__version__ = "2.0.0" + +# ============================================================================ +# FILE FORMAT +# ============================================================================ + +MAGIC_HEADER = b'\x89ST3' +FORMAT_VERSION = 3 + +# ============================================================================ +# CRYPTO PARAMETERS +# ============================================================================ + +SALT_SIZE = 32 +IV_SIZE = 12 +TAG_SIZE = 16 + +# Argon2 parameters (memory-hard KDF) +ARGON2_TIME_COST = 4 +ARGON2_MEMORY_COST = 256 * 1024 # 256 MB +ARGON2_PARALLELISM = 4 + +# PBKDF2 fallback parameters +PBKDF2_ITERATIONS = 600000 + +# ============================================================================ +# INPUT LIMITS +# ============================================================================ + +MAX_IMAGE_PIXELS = 4_000_000 # ~4 megapixels (2000x2000) +MAX_MESSAGE_SIZE = 50_000 # 50 KB +MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB + +MIN_PIN_LENGTH = 6 +MAX_PIN_LENGTH = 9 +DEFAULT_PIN_LENGTH = 6 + +MIN_PHRASE_WORDS = 3 +MAX_PHRASE_WORDS = 12 +DEFAULT_PHRASE_WORDS = 3 + +MIN_RSA_BITS = 2048 +VALID_RSA_SIZES = (2048, 3072, 4096) +DEFAULT_RSA_BITS = 2048 + +MIN_KEY_PASSWORD_LENGTH = 8 + +# ============================================================================ +# FILE TYPES +# ============================================================================ + +ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'bmp', 'gif'} +ALLOWED_KEY_EXTENSIONS = {'pem', 'key'} + +# ============================================================================ +# DAYS +# ============================================================================ + +DAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday') + +# ============================================================================ +# DATA FILES +# ============================================================================ + +def get_data_dir() -> Path: + """Get the data directory path.""" + # Check multiple locations + candidates = [ + Path(__file__).parent.parent.parent.parent / 'data', # Development + Path(__file__).parent / 'data', # Installed package + Path('/app/data'), # Docker + Path.cwd() / 'data', # Current directory + ] + + for path in candidates: + if path.exists(): + return path + + # Default to first candidate + return candidates[0] + + +def get_bip39_words() -> list[str]: + """Load BIP-39 wordlist.""" + wordlist_path = get_data_dir() / 'bip39-words.txt' + + if not wordlist_path.exists(): + raise FileNotFoundError( + f"BIP-39 wordlist not found at {wordlist_path}. " + "Please ensure bip39-words.txt is in the data directory." + ) + + with open(wordlist_path, 'r') as f: + return [line.strip() for line in f if line.strip()] + + +# Lazy-loaded wordlist +_bip39_words: list[str] | None = None + + +def get_wordlist() -> list[str]: + """Get the BIP-39 wordlist (cached).""" + global _bip39_words + if _bip39_words is None: + _bip39_words = get_bip39_words() + return _bip39_words diff --git a/src/stegasoo/crypto.py b/src/stegasoo/crypto.py new file mode 100644 index 0000000..43ae47c --- /dev/null +++ b/src/stegasoo/crypto.py @@ -0,0 +1,358 @@ +""" +Stegasoo Cryptographic Functions + +Key derivation, encryption, and decryption using AES-256-GCM. +""" + +import io +import hashlib +import secrets +import struct +from typing import Optional + +from PIL import Image +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.backends import default_backend + +from .constants import ( + MAGIC_HEADER, FORMAT_VERSION, + SALT_SIZE, IV_SIZE, TAG_SIZE, + ARGON2_TIME_COST, ARGON2_MEMORY_COST, ARGON2_PARALLELISM, + PBKDF2_ITERATIONS, +) +from .exceptions import ( + EncryptionError, DecryptionError, KeyDerivationError, InvalidHeaderError +) + +# Check for Argon2 availability +try: + from argon2.low_level import hash_secret_raw, Type + HAS_ARGON2 = True +except ImportError: + HAS_ARGON2 = False + from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + from cryptography.hazmat.primitives import hashes + + +def hash_photo(image_data: bytes) -> bytes: + """ + Compute deterministic hash of photo pixel content. + + This normalizes the image to RGB and hashes the raw pixel data, + making it resistant to metadata changes. + + Args: + image_data: Raw image file bytes + + Returns: + 32-byte SHA-256 hash + """ + img = Image.open(io.BytesIO(image_data)) + img = img.convert('RGB') + pixels = img.tobytes() + + # Double-hash with prefix for additional mixing + h = hashlib.sha256(pixels).digest() + h = hashlib.sha256(h + pixels[:1024]).digest() + return h + + +def derive_hybrid_key( + photo_data: bytes, + day_phrase: str, + date_str: str, + salt: bytes, + pin: str = "", + rsa_key_data: Optional[bytes] = None +) -> bytes: + """ + Derive encryption key from multiple factors. + + Combines: + - Photo hash (something you have) + - Day phrase (something you know, rotates daily) + - PIN (something you know, static) + - RSA key (something you have) + - Date (automatic rotation) + - Salt (random per message) + + Uses Argon2id if available, falls back to PBKDF2. + + Args: + photo_data: Reference photo bytes + day_phrase: The day's phrase + date_str: Date string (YYYY-MM-DD) + salt: Random salt for this message + pin: Optional static PIN + rsa_key_data: Optional RSA key bytes + + Returns: + 32-byte derived key + + Raises: + KeyDerivationError: If key derivation fails + """ + try: + photo_hash = hash_photo(photo_data) + + key_material = ( + photo_hash + + day_phrase.lower().encode() + + pin.encode() + + date_str.encode() + + salt + ) + + # Add RSA key hash if provided + if rsa_key_data: + key_material += hashlib.sha256(rsa_key_data).digest() + + if HAS_ARGON2: + key = hash_secret_raw( + secret=key_material, + salt=salt[:32], + time_cost=ARGON2_TIME_COST, + memory_cost=ARGON2_MEMORY_COST, + parallelism=ARGON2_PARALLELISM, + hash_len=32, + type=Type.ID + ) + else: + kdf = PBKDF2HMAC( + algorithm=hashes.SHA512(), + length=32, + salt=salt, + iterations=PBKDF2_ITERATIONS, + backend=default_backend() + ) + key = kdf.derive(key_material) + + return key + + except Exception as e: + raise KeyDerivationError(f"Failed to derive key: {e}") from e + + +def derive_pixel_key( + photo_data: bytes, + day_phrase: str, + date_str: str, + pin: str = "", + rsa_key_data: Optional[bytes] = None +) -> bytes: + """ + Derive key for pseudo-random pixel selection. + + This key determines which pixels are used for embedding, + making the message location unpredictable without the correct inputs. + + Args: + photo_data: Reference photo bytes + day_phrase: The day's phrase + date_str: Date string (YYYY-MM-DD) + pin: Optional static PIN + rsa_key_data: Optional RSA key bytes + + Returns: + 32-byte key for pixel selection + """ + photo_hash = hash_photo(photo_data) + + material = ( + photo_hash + + day_phrase.lower().encode() + + pin.encode() + + date_str.encode() + ) + + if rsa_key_data: + material += hashlib.sha256(rsa_key_data).digest() + + return hashlib.sha256(material + b"pixel_selection").digest() + + +def encrypt_message( + message: str | bytes, + photo_data: bytes, + day_phrase: str, + date_str: str, + pin: str = "", + rsa_key_data: Optional[bytes] = None +) -> bytes: + """ + Encrypt message using AES-256-GCM with hybrid key derivation. + + Message format: + - Magic header (4 bytes) + - Version (1 byte) + - Date length (1 byte) + - Date string (variable) + - Salt (32 bytes) + - IV (12 bytes) + - Auth tag (16 bytes) + - Ciphertext (variable, padded) + + Args: + message: Message to encrypt + photo_data: Reference photo bytes + day_phrase: The day's phrase + date_str: Date string (YYYY-MM-DD) + pin: Optional static PIN + rsa_key_data: Optional RSA key bytes + + Returns: + Encrypted message bytes + + Raises: + EncryptionError: If encryption fails + """ + try: + salt = secrets.token_bytes(SALT_SIZE) + key = derive_hybrid_key(photo_data, day_phrase, date_str, salt, pin, rsa_key_data) + iv = secrets.token_bytes(IV_SIZE) + + if isinstance(message, str): + message = message.encode('utf-8') + + # Random padding to hide message length + padding_len = secrets.randbelow(256) + 64 + padded_len = ((len(message) + padding_len + 255) // 256) * 256 + padding_needed = padded_len - len(message) + padding = secrets.token_bytes(padding_needed - 4) + struct.pack('>I', len(message)) + padded_message = message + padding + + # Encrypt with AES-256-GCM + cipher = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend()) + encryptor = cipher.encryptor() + encryptor.authenticate_additional_data(MAGIC_HEADER + bytes([FORMAT_VERSION])) + ciphertext = encryptor.update(padded_message) + encryptor.finalize() + + date_bytes = date_str.encode() + + return ( + MAGIC_HEADER + + bytes([FORMAT_VERSION]) + + bytes([len(date_bytes)]) + + date_bytes + + salt + + iv + + encryptor.tag + + ciphertext + ) + + except Exception as e: + raise EncryptionError(f"Encryption failed: {e}") from e + + +def parse_header(encrypted_data: bytes) -> Optional[dict]: + """ + Parse the header from encrypted data. + + Args: + encrypted_data: Raw encrypted bytes + + Returns: + Dict with date, salt, iv, tag, ciphertext or None if invalid + """ + if len(encrypted_data) < 10 or encrypted_data[:4] != MAGIC_HEADER: + return None + + try: + version = encrypted_data[4] + if version != FORMAT_VERSION: + return None + + date_len = encrypted_data[5] + date_str = encrypted_data[6:6 + date_len].decode() + + offset = 6 + date_len + salt = encrypted_data[offset:offset + SALT_SIZE] + offset += SALT_SIZE + iv = encrypted_data[offset:offset + IV_SIZE] + offset += IV_SIZE + tag = encrypted_data[offset:offset + TAG_SIZE] + offset += TAG_SIZE + ciphertext = encrypted_data[offset:] + + return { + 'date': date_str, + 'salt': salt, + 'iv': iv, + 'tag': tag, + 'ciphertext': ciphertext + } + except Exception: + return None + + +def decrypt_message( + encrypted_data: bytes, + photo_data: bytes, + day_phrase: str, + pin: str = "", + rsa_key_data: Optional[bytes] = None +) -> str: + """ + Decrypt message using the embedded date from the header. + + Args: + encrypted_data: Encrypted message bytes + photo_data: Reference photo bytes + day_phrase: The day's phrase (must match encoding day) + pin: Optional static PIN + rsa_key_data: Optional RSA key bytes + + Returns: + Decrypted message string + + Raises: + InvalidHeaderError: If data doesn't have valid Stegasoo header + DecryptionError: If decryption fails (wrong credentials) + """ + header = parse_header(encrypted_data) + if not header: + raise InvalidHeaderError("Invalid or missing Stegasoo header") + + try: + key = derive_hybrid_key( + photo_data, day_phrase, header['date'], header['salt'], pin, rsa_key_data + ) + + cipher = Cipher( + algorithms.AES(key), + modes.GCM(header['iv'], header['tag']), + backend=default_backend() + ) + decryptor = cipher.decryptor() + decryptor.authenticate_additional_data(MAGIC_HEADER + bytes([FORMAT_VERSION])) + + padded_plaintext = decryptor.update(header['ciphertext']) + decryptor.finalize() + original_length = struct.unpack('>I', padded_plaintext[-4:])[0] + + return padded_plaintext[:original_length].decode('utf-8') + + except Exception as e: + raise DecryptionError( + "Decryption failed. Check your phrase, PIN, RSA key, and reference photo." + ) from e + + +def get_date_from_encrypted(encrypted_data: bytes) -> Optional[str]: + """ + Extract the date string from encrypted data without decrypting. + + Useful for determining which day's phrase to use. + + Args: + encrypted_data: Encrypted message bytes + + Returns: + Date string (YYYY-MM-DD) or None if invalid + """ + header = parse_header(encrypted_data) + return header['date'] if header else None + + +def has_argon2() -> bool: + """Check if Argon2 is available.""" + return HAS_ARGON2 diff --git a/src/stegasoo/exceptions.py b/src/stegasoo/exceptions.py new file mode 100644 index 0000000..324e58c --- /dev/null +++ b/src/stegasoo/exceptions.py @@ -0,0 +1,150 @@ +""" +Stegasoo Exceptions + +Custom exception classes for clear error handling across all frontends. +""" + + +class StegasooError(Exception): + """Base exception for all Stegasoo errors.""" + pass + + +# ============================================================================ +# VALIDATION ERRORS +# ============================================================================ + +class ValidationError(StegasooError): + """Base class for validation errors.""" + pass + + +class PinValidationError(ValidationError): + """PIN validation failed.""" + pass + + +class MessageValidationError(ValidationError): + """Message validation failed.""" + pass + + +class ImageValidationError(ValidationError): + """Image validation failed.""" + pass + + +class KeyValidationError(ValidationError): + """RSA key validation failed.""" + pass + + +class SecurityFactorError(ValidationError): + """Security factor requirements not met.""" + pass + + +# ============================================================================ +# CRYPTO ERRORS +# ============================================================================ + +class CryptoError(StegasooError): + """Base class for cryptographic errors.""" + pass + + +class EncryptionError(CryptoError): + """Encryption failed.""" + pass + + +class DecryptionError(CryptoError): + """Decryption failed (wrong key, corrupted data, etc.).""" + pass + + +class KeyDerivationError(CryptoError): + """Key derivation failed.""" + pass + + +class KeyGenerationError(CryptoError): + """Key generation failed.""" + pass + + +class KeyPasswordError(CryptoError): + """RSA key password is incorrect or missing.""" + pass + + +# ============================================================================ +# STEGANOGRAPHY ERRORS +# ============================================================================ + +class SteganographyError(StegasooError): + """Base class for steganography errors.""" + pass + + +class CapacityError(SteganographyError): + """Carrier image too small for message.""" + + def __init__(self, needed: int, available: int): + self.needed = needed + self.available = available + super().__init__( + f"Carrier image too small. Need {needed:,} bytes, have {available:,} bytes capacity." + ) + + +class ExtractionError(SteganographyError): + """Failed to extract hidden data from image.""" + pass + + +class EmbeddingError(SteganographyError): + """Failed to embed data in image.""" + pass + + +class InvalidHeaderError(SteganographyError): + """Invalid or missing Stegasoo header in extracted data.""" + pass + + +# ============================================================================ +# FILE ERRORS +# ============================================================================ + +class FileError(StegasooError): + """Base class for file-related errors.""" + pass + + +class FileNotFoundError(FileError): + """Required file not found.""" + pass + + +class FileTooLargeError(FileError): + """File exceeds size limit.""" + + def __init__(self, size: int, limit: int, filename: str = "File"): + self.size = size + self.limit = limit + self.filename = filename + super().__init__( + f"{filename} too large ({size:,} bytes). Maximum allowed: {limit:,} bytes." + ) + + +class UnsupportedFileTypeError(FileError): + """File type not supported.""" + + def __init__(self, extension: str, allowed: set[str]): + self.extension = extension + self.allowed = allowed + super().__init__( + f"Unsupported file type: .{extension}. Allowed: {', '.join(sorted(allowed))}" + ) diff --git a/src/stegasoo/keygen.py b/src/stegasoo/keygen.py new file mode 100644 index 0000000..ec60f9a --- /dev/null +++ b/src/stegasoo/keygen.py @@ -0,0 +1,228 @@ +""" +Stegasoo Key Generation + +Generate PINs, passphrases, and RSA keys. +""" + +import secrets +from typing import Optional + +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.hazmat.backends import default_backend + +from .constants import ( + DAY_NAMES, + MIN_PIN_LENGTH, MAX_PIN_LENGTH, DEFAULT_PIN_LENGTH, + MIN_PHRASE_WORDS, MAX_PHRASE_WORDS, DEFAULT_PHRASE_WORDS, + MIN_RSA_BITS, VALID_RSA_SIZES, DEFAULT_RSA_BITS, + get_wordlist, +) +from .models import Credentials, KeyInfo +from .exceptions import KeyGenerationError, KeyPasswordError + + +def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str: + """ + Generate a random PIN. + + PINs never start with zero for usability. + + Args: + length: PIN length (6-9 digits) + + Returns: + PIN string + """ + length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, length)) + + # First digit: 1-9 (no leading zero) + first_digit = str(secrets.randbelow(9) + 1) + + # Remaining digits: 0-9 + rest = ''.join(str(secrets.randbelow(10)) for _ in range(length - 1)) + + return first_digit + rest + + +def generate_phrase(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> str: + """ + Generate a random passphrase from BIP-39 wordlist. + + Args: + words_per_phrase: Number of words (3-12) + + Returns: + Space-separated phrase + """ + words_per_phrase = max(MIN_PHRASE_WORDS, min(MAX_PHRASE_WORDS, words_per_phrase)) + wordlist = get_wordlist() + + words = [secrets.choice(wordlist) for _ in range(words_per_phrase)] + return ' '.join(words) + + +def generate_day_phrases(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> dict[str, str]: + """ + Generate phrases for all days of the week. + + Args: + words_per_phrase: Number of words per phrase (3-12) + + Returns: + Dict mapping day names to phrases + """ + return {day: generate_phrase(words_per_phrase) for day in DAY_NAMES} + + +def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey: + """ + Generate an RSA private key. + + Args: + bits: Key size (2048, 3072, or 4096) + + Returns: + RSA private key object + + Raises: + KeyGenerationError: If generation fails + """ + if bits not in VALID_RSA_SIZES: + bits = DEFAULT_RSA_BITS + + try: + return rsa.generate_private_key( + public_exponent=65537, + key_size=bits, + backend=default_backend() + ) + except Exception as e: + raise KeyGenerationError(f"Failed to generate RSA key: {e}") from e + + +def export_rsa_key_pem( + private_key: rsa.RSAPrivateKey, + password: Optional[str] = None +) -> bytes: + """ + Export RSA key to PEM format. + + Args: + private_key: RSA private key object + password: Optional password for encryption + + Returns: + PEM-encoded key bytes + """ + if password: + encryption = serialization.BestAvailableEncryption(password.encode()) + else: + encryption = serialization.NoEncryption() + + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=encryption + ) + + +def load_rsa_key( + key_data: bytes, + password: Optional[str] = None +) -> rsa.RSAPrivateKey: + """ + Load RSA private key from PEM data. + + Args: + key_data: PEM-encoded key bytes + password: Password if key is encrypted + + Returns: + RSA private key object + + Raises: + KeyPasswordError: If password is wrong or missing + KeyGenerationError: If key is invalid + """ + try: + pwd_bytes = password.encode() if password else None + return load_pem_private_key(key_data, password=pwd_bytes, backend=default_backend()) + except TypeError: + raise KeyPasswordError("RSA key is password-protected. Please provide the password.") + except ValueError as e: + if "password" in str(e).lower() or "encrypted" in str(e).lower(): + raise KeyPasswordError("Incorrect password for RSA key.") + raise KeyGenerationError(f"Invalid RSA key: {e}") from e + except Exception as e: + raise KeyGenerationError(f"Could not load RSA key: {e}") from e + + +def get_key_info(key_data: bytes, password: Optional[str] = None) -> KeyInfo: + """ + Get information about an RSA key. + + Args: + key_data: PEM-encoded key bytes + password: Password if key is encrypted + + Returns: + KeyInfo with key size and encryption status + """ + # Check if encrypted + is_encrypted = b'ENCRYPTED' in key_data + + private_key = load_rsa_key(key_data, password) + + return KeyInfo( + key_size=private_key.key_size, + is_encrypted=is_encrypted, + pem_data=key_data + ) + + +def generate_credentials( + use_pin: bool = True, + use_rsa: bool = False, + pin_length: int = DEFAULT_PIN_LENGTH, + rsa_bits: int = DEFAULT_RSA_BITS, + words_per_phrase: int = DEFAULT_PHRASE_WORDS +) -> Credentials: + """ + Generate a complete set of credentials. + + At least one of use_pin or use_rsa must be True. + + Args: + use_pin: Whether to generate a PIN + use_rsa: Whether to generate an RSA key + pin_length: PIN length if generating + rsa_bits: RSA key size if generating + words_per_phrase: Words per daily phrase + + Returns: + Credentials object + + Raises: + ValueError: If neither PIN nor RSA is selected + """ + if not use_pin and not use_rsa: + raise ValueError("Must select at least one security factor (PIN or RSA key)") + + phrases = generate_day_phrases(words_per_phrase) + + pin = generate_pin(pin_length) if use_pin else None + + rsa_key_pem = None + if use_rsa: + private_key = generate_rsa_key(rsa_bits) + rsa_key_pem = export_rsa_key_pem(private_key).decode('utf-8') + + return Credentials( + phrases=phrases, + pin=pin, + rsa_key_pem=rsa_key_pem, + rsa_bits=rsa_bits if use_rsa else None, + words_per_phrase=words_per_phrase + ) diff --git a/src/stegasoo/models.py b/src/stegasoo/models.py new file mode 100644 index 0000000..6a9109a --- /dev/null +++ b/src/stegasoo/models.py @@ -0,0 +1,134 @@ +""" +Stegasoo Data Models + +Dataclasses for structured data exchange between modules and frontends. +""" + +from dataclasses import dataclass, field +from datetime import date +from typing import Optional + + +@dataclass +class Credentials: + """Generated credentials for encoding/decoding.""" + phrases: dict[str, str] # Day -> phrase mapping + pin: Optional[str] = None + rsa_key_pem: Optional[str] = None + rsa_bits: Optional[int] = None + words_per_phrase: int = 3 + + @property + def phrase_entropy(self) -> int: + """Entropy in bits from phrases (~11 bits per BIP-39 word).""" + return self.words_per_phrase * 11 + + @property + def pin_entropy(self) -> int: + """Entropy in bits from PIN (~3.32 bits per digit).""" + if self.pin: + return int(len(self.pin) * 3.32) + return 0 + + @property + def rsa_entropy(self) -> int: + """Effective entropy from RSA key.""" + if self.rsa_key_pem and self.rsa_bits: + return min(self.rsa_bits // 16, 128) + return 0 + + @property + def total_entropy(self) -> int: + """Total entropy in bits (excluding reference photo).""" + return self.phrase_entropy + self.pin_entropy + self.rsa_entropy + + +@dataclass +class EncodeInput: + """Input parameters for encoding a message.""" + message: str + reference_photo: bytes + carrier_image: bytes + day_phrase: str + pin: str = "" + rsa_key_data: Optional[bytes] = None + rsa_password: Optional[str] = None + date_str: Optional[str] = None # YYYY-MM-DD, defaults to today + + def __post_init__(self): + if self.date_str is None: + self.date_str = date.today().isoformat() + + +@dataclass +class EncodeResult: + """Result of encoding operation.""" + stego_image: bytes + filename: str + pixels_modified: int + total_pixels: int + capacity_used: float # 0.0 - 1.0 + date_used: str + + @property + def capacity_percent(self) -> float: + """Capacity used as percentage.""" + return self.capacity_used * 100 + + +@dataclass +class DecodeInput: + """Input parameters for decoding a message.""" + stego_image: bytes + reference_photo: bytes + day_phrase: str + pin: str = "" + rsa_key_data: Optional[bytes] = None + rsa_password: Optional[str] = None + + +@dataclass +class DecodeResult: + """Result of decoding operation.""" + message: str + date_encoded: str + + +@dataclass +class EmbedStats: + """Statistics from image embedding.""" + pixels_modified: int + total_pixels: int + capacity_used: float + bytes_embedded: int + + @property + def modification_percent(self) -> float: + """Percentage of pixels modified.""" + return (self.pixels_modified / self.total_pixels) * 100 if self.total_pixels > 0 else 0 + + +@dataclass +class KeyInfo: + """Information about an RSA key.""" + key_size: int + is_encrypted: bool + pem_data: bytes + + +@dataclass +class ValidationResult: + """Result of input validation.""" + is_valid: bool + error_message: str = "" + details: dict = field(default_factory=dict) + + @classmethod + def ok(cls, **details) -> 'ValidationResult': + """Create a successful validation result.""" + return cls(is_valid=True, details=details) + + @classmethod + def error(cls, message: str, **details) -> 'ValidationResult': + """Create a failed validation result.""" + return cls(is_valid=False, error_message=message, details=details) diff --git a/src/stegasoo/steganography.py b/src/stegasoo/steganography.py new file mode 100644 index 0000000..510c8a4 --- /dev/null +++ b/src/stegasoo/steganography.py @@ -0,0 +1,286 @@ +""" +Stegasoo Steganography Functions + +LSB embedding and extraction with pseudo-random pixel selection. +""" + +import io +import struct +from typing import Optional + +from PIL import Image +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms +from cryptography.hazmat.backends import default_backend + +from .models import EmbedStats +from .exceptions import CapacityError, ExtractionError, EmbeddingError + + +def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list[int]: + """ + Generate pseudo-random pixel indices for embedding. + + Uses ChaCha20 as a CSPRNG seeded by the key to deterministically + select which pixels will hold hidden data. + + Args: + key: 32-byte key for pixel selection + num_pixels: Total pixels in image + num_needed: Number of pixels needed for embedding + + Returns: + List of pixel indices + """ + if num_needed >= num_pixels // 2: + # If we need many pixels, shuffle all indices + nonce = b'\x00' * 16 + cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend()) + encryptor = cipher.encryptor() + + indices = list(range(num_pixels)) + random_bytes = encryptor.update(b'\x00' * (num_pixels * 4)) + + for i in range(num_pixels - 1, 0, -1): + j_bytes = random_bytes[(num_pixels - 1 - i) * 4:(num_pixels - i) * 4] + j = int.from_bytes(j_bytes, 'big') % (i + 1) + indices[i], indices[j] = indices[j], indices[i] + + return indices[:num_needed] + + # Optimized path: generate indices directly + selected = [] + used = set() + + nonce = b'\x00' * 16 + cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend()) + encryptor = cipher.encryptor() + + # Generate more than needed to handle collisions + bytes_needed = (num_needed * 2) * 4 + random_bytes = encryptor.update(b'\x00' * bytes_needed) + + byte_offset = 0 + while len(selected) < num_needed and byte_offset < len(random_bytes) - 4: + idx = int.from_bytes(random_bytes[byte_offset:byte_offset + 4], 'big') % num_pixels + byte_offset += 4 + + if idx not in used: + used.add(idx) + selected.append(idx) + + # Generate additional if needed (rare) + while len(selected) < num_needed: + extra_bytes = encryptor.update(b'\x00' * 4) + idx = int.from_bytes(extra_bytes, 'big') % num_pixels + if idx not in used: + used.add(idx) + selected.append(idx) + + return selected + + +def embed_in_image( + carrier_data: bytes, + encrypted_data: bytes, + pixel_key: bytes, + bits_per_channel: int = 1 +) -> tuple[bytes, EmbedStats]: + """ + Embed encrypted data in carrier image using LSB steganography. + + Uses pseudo-random pixel selection based on pixel_key to scatter + the data across the image, defeating statistical analysis. + + Args: + carrier_data: Carrier image bytes + encrypted_data: Data to embed + pixel_key: Key for pixel selection + bits_per_channel: Bits to use per color channel (1-2) + + Returns: + Tuple of (PNG image bytes, EmbedStats) + + Raises: + CapacityError: If carrier is too small + EmbeddingError: If embedding fails + """ + try: + img = Image.open(io.BytesIO(carrier_data)) + if img.mode != 'RGB': + img = img.convert('RGB') + + pixels = list(img.getdata()) + num_pixels = len(pixels) + + bits_per_pixel = 3 * bits_per_channel + max_bytes = (num_pixels * bits_per_pixel) // 8 + + # Prepend length + data_with_len = struct.pack('>I', len(encrypted_data)) + encrypted_data + + if len(data_with_len) > max_bytes: + raise CapacityError(len(data_with_len), max_bytes) + + # Convert to binary string + binary_data = ''.join(format(b, '08b') for b in data_with_len) + pixels_needed = (len(binary_data) + bits_per_pixel - 1) // bits_per_pixel + + # Get pixel indices + selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed) + + # Embed data + new_pixels = list(pixels) + clear_mask = 0xFF ^ ((1 << bits_per_channel) - 1) + + bit_idx = 0 + for pixel_idx in selected_indices: + if bit_idx >= len(binary_data): + break + + r, g, b = new_pixels[pixel_idx] + + for channel_idx, channel_val in enumerate([r, g, b]): + if bit_idx >= len(binary_data): + break + bits = binary_data[bit_idx:bit_idx + bits_per_channel].ljust(bits_per_channel, '0') + new_val = (channel_val & clear_mask) | int(bits, 2) + + if channel_idx == 0: + r = new_val + elif channel_idx == 1: + g = new_val + else: + b = new_val + + bit_idx += bits_per_channel + + new_pixels[pixel_idx] = (r, g, b) + + # Create output image + stego_img = Image.new('RGB', img.size) + stego_img.putdata(new_pixels) + + output = io.BytesIO() + stego_img.save(output, 'PNG') + output.seek(0) + + stats = EmbedStats( + pixels_modified=len(selected_indices), + total_pixels=num_pixels, + capacity_used=len(data_with_len) / max_bytes, + bytes_embedded=len(data_with_len) + ) + + return output.getvalue(), stats + + except CapacityError: + raise + except Exception as e: + raise EmbeddingError(f"Failed to embed data: {e}") from e + + +def extract_from_image( + image_data: bytes, + pixel_key: bytes, + bits_per_channel: int = 1 +) -> Optional[bytes]: + """ + Extract hidden data from a stego image. + + Args: + image_data: Stego image bytes + pixel_key: Key for pixel selection (must match encoding) + bits_per_channel: Bits per channel (must match encoding) + + Returns: + Extracted data bytes, or None if extraction fails + + Raises: + ExtractionError: If extraction fails critically + """ + try: + img = Image.open(io.BytesIO(image_data)) + if img.mode != 'RGB': + img = img.convert('RGB') + + pixels = list(img.getdata()) + num_pixels = len(pixels) + bits_per_pixel = 3 * bits_per_channel + + # First, extract enough to get the length (4 bytes = 32 bits) + initial_pixels = (32 + bits_per_pixel - 1) // bits_per_pixel + 10 + initial_indices = generate_pixel_indices(pixel_key, num_pixels, initial_pixels) + + binary_data = '' + for pixel_idx in initial_indices: + r, g, b = pixels[pixel_idx] + for channel in [r, g, b]: + for bit_pos in range(bits_per_channel - 1, -1, -1): + binary_data += str((channel >> bit_pos) & 1) + + # Parse length + try: + length_bits = binary_data[:32] + data_length = struct.unpack('>I', int(length_bits, 2).to_bytes(4, 'big'))[0] + except Exception: + return None + + # Sanity check + max_possible = (num_pixels * bits_per_pixel) // 8 - 4 + if data_length > max_possible or data_length < 10: + return None + + # Extract full data + total_bits = (4 + data_length) * 8 + pixels_needed = (total_bits + bits_per_pixel - 1) // bits_per_pixel + + selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed) + + binary_data = '' + for pixel_idx in selected_indices: + r, g, b = pixels[pixel_idx] + for channel in [r, g, b]: + for bit_pos in range(bits_per_channel - 1, -1, -1): + binary_data += str((channel >> bit_pos) & 1) + + data_bits = binary_data[32:32 + (data_length * 8)] + + data_bytes = bytearray() + for i in range(0, len(data_bits), 8): + byte_bits = data_bits[i:i + 8] + if len(byte_bits) == 8: + data_bytes.append(int(byte_bits, 2)) + + return bytes(data_bytes) + + except Exception as e: + raise ExtractionError(f"Failed to extract data: {e}") from e + + +def calculate_capacity(image_data: bytes, bits_per_channel: int = 1) -> int: + """ + Calculate the maximum message capacity of an image. + + Args: + image_data: Image bytes + bits_per_channel: Bits to use per color channel + + Returns: + Maximum bytes that can be embedded (minus overhead) + """ + img = Image.open(io.BytesIO(image_data)) + if img.mode != 'RGB': + img = img.convert('RGB') + + num_pixels = img.size[0] * img.size[1] + bits_per_pixel = 3 * bits_per_channel + max_bytes = (num_pixels * bits_per_pixel) // 8 + + # Subtract overhead: 4 bytes length + ~100 bytes header + return max(0, max_bytes - 104) + + +def get_image_dimensions(image_data: bytes) -> tuple[int, int]: + """Get image dimensions without loading full image.""" + img = Image.open(io.BytesIO(image_data)) + return img.size diff --git a/src/stegasoo/utils.py b/src/stegasoo/utils.py new file mode 100644 index 0000000..cafbf54 --- /dev/null +++ b/src/stegasoo/utils.py @@ -0,0 +1,201 @@ +""" +Stegasoo Utilities + +Secure deletion, filename generation, and other helpers. +""" + +import os +import random +import secrets +import shutil +from datetime import date, datetime +from pathlib import Path +from typing import Optional + +from .constants import DAY_NAMES + + +def generate_filename(date_str: Optional[str] = None, prefix: str = "") -> str: + """ + Generate a filename for stego images. + + Format: {prefix}{random}_{YYYYMMDD}.png + + Args: + date_str: Date string (YYYY-MM-DD), defaults to today + prefix: Optional prefix + + Returns: + Filename string + """ + if date_str is None: + date_str = date.today().isoformat() + + date_compact = date_str.replace('-', '') + random_hex = secrets.token_hex(4) + + return f"{prefix}{random_hex}_{date_compact}.png" + + +def parse_date_from_filename(filename: str) -> Optional[str]: + """ + Extract date from a stego filename. + + Looks for patterns like _20251227 or _2025-12-27 + + Args: + filename: Filename to parse + + Returns: + Date string (YYYY-MM-DD) or None + """ + import re + + # Try YYYYMMDD format + match = re.search(r'_(\d{4})(\d{2})(\d{2})(?:\.|$)', filename) + if match: + year, month, day = match.groups() + return f"{year}-{month}-{day}" + + # Try YYYY-MM-DD format + match = re.search(r'_(\d{4})-(\d{2})-(\d{2})(?:\.|$)', filename) + if match: + year, month, day = match.groups() + return f"{year}-{month}-{day}" + + return None + + +def get_day_from_date(date_str: str) -> str: + """ + Get day of week name from date string. + + Args: + date_str: Date string (YYYY-MM-DD) + + Returns: + Day name (e.g., "Monday") + """ + try: + year, month, day = map(int, date_str.split('-')) + d = date(year, month, day) + return DAY_NAMES[d.weekday()] + except Exception: + return "" + + +def get_today_date() -> str: + """Get today's date as YYYY-MM-DD.""" + return date.today().isoformat() + + +def get_today_day() -> str: + """Get today's day name.""" + return DAY_NAMES[date.today().weekday()] + + +class SecureDeleter: + """ + Securely delete files by overwriting with random data. + + Implements multi-pass overwriting before deletion. + """ + + def __init__(self, path: str | Path, passes: int = 7): + """ + Initialize secure deleter. + + Args: + path: Path to file or directory + passes: Number of overwrite passes + """ + self.path = Path(path) + self.passes = passes + + def _overwrite_file(self, file_path: Path) -> None: + """Overwrite file with random data multiple times.""" + if not file_path.exists() or not file_path.is_file(): + return + + length = file_path.stat().st_size + if length == 0: + return + + patterns = [b'\x00', b'\xFF', bytes([random.randint(0, 255)])] + + for _ in range(self.passes): + with open(file_path, 'r+b') as f: + for pattern in patterns: + f.seek(0) + for _ in range(length): + f.write(pattern) + + # Final pass with random data + f.seek(0) + f.write(os.urandom(length)) + + def delete_file(self) -> None: + """Securely delete a single file.""" + if self.path.is_file(): + self._overwrite_file(self.path) + self.path.unlink() + + def delete_directory(self) -> None: + """Securely delete a directory and all contents.""" + if not self.path.is_dir(): + return + + # First, securely overwrite all files + for file_path in self.path.rglob('*'): + if file_path.is_file(): + self._overwrite_file(file_path) + + # Then remove the directory tree + shutil.rmtree(self.path) + + def execute(self) -> None: + """Securely delete the path (file or directory).""" + if self.path.is_file(): + self.delete_file() + elif self.path.is_dir(): + self.delete_directory() + + +def secure_delete(path: str | Path, passes: int = 7) -> None: + """ + Convenience function for secure deletion. + + Args: + path: Path to file or directory + passes: Number of overwrite passes + """ + SecureDeleter(path, passes).execute() + + +def format_file_size(size_bytes: int) -> str: + """ + Format file size for display. + + Args: + size_bytes: Size in bytes + + Returns: + Human-readable string (e.g., "1.5 MB") + """ + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024: + if unit == 'B': + return f"{size_bytes} {unit}" + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024 + return f"{size_bytes:.1f} TB" + + +def format_number(n: int) -> str: + """Format number with commas.""" + return f"{n:,}" + + +def clamp(value: int, min_val: int, max_val: int) -> int: + """Clamp value to range.""" + return max(min_val, min(max_val, value)) diff --git a/src/stegasoo/validation.py b/src/stegasoo/validation.py new file mode 100644 index 0000000..da338aa --- /dev/null +++ b/src/stegasoo/validation.py @@ -0,0 +1,344 @@ +""" +Stegasoo Input Validation + +Validators for all user inputs with clear error messages. +""" + +import io +from typing import Optional + +from PIL import Image + +from .constants import ( + MIN_PIN_LENGTH, MAX_PIN_LENGTH, + MAX_MESSAGE_SIZE, MAX_IMAGE_PIXELS, MAX_FILE_SIZE, + MIN_RSA_BITS, MIN_KEY_PASSWORD_LENGTH, + ALLOWED_IMAGE_EXTENSIONS, ALLOWED_KEY_EXTENSIONS, +) +from .models import ValidationResult +from .exceptions import ( + ValidationError, PinValidationError, MessageValidationError, + ImageValidationError, KeyValidationError, SecurityFactorError, + FileTooLargeError, UnsupportedFileTypeError, +) +from .keygen import load_rsa_key + + +def validate_pin(pin: str, required: bool = False) -> ValidationResult: + """ + Validate PIN format. + + Rules: + - 6-9 digits only + - Cannot start with zero + - Empty is OK if not required + + Args: + pin: PIN string to validate + required: Whether PIN is required + + Returns: + ValidationResult + """ + if not pin: + if required: + return ValidationResult.error("PIN is required") + return ValidationResult.ok() + + if not pin.isdigit(): + return ValidationResult.error("PIN must contain only digits") + + if len(pin) < MIN_PIN_LENGTH or len(pin) > MAX_PIN_LENGTH: + return ValidationResult.error( + f"PIN must be {MIN_PIN_LENGTH}-{MAX_PIN_LENGTH} digits" + ) + + if pin[0] == '0': + return ValidationResult.error("PIN cannot start with zero") + + return ValidationResult.ok(length=len(pin)) + + +def validate_message(message: str) -> ValidationResult: + """ + Validate message content and size. + + Args: + message: Message text + + Returns: + ValidationResult + """ + if not message: + return ValidationResult.error("Message is required") + + if len(message) > MAX_MESSAGE_SIZE: + return ValidationResult.error( + f"Message too long ({len(message):,} chars). Maximum: {MAX_MESSAGE_SIZE:,} characters" + ) + + return ValidationResult.ok(length=len(message)) + + +def validate_image( + image_data: bytes, + name: str = "Image", + check_size: bool = True +) -> ValidationResult: + """ + Validate image data and dimensions. + + Args: + image_data: Raw image bytes + name: Name for error messages + check_size: Whether to check pixel dimensions + + Returns: + ValidationResult with width, height, pixels + """ + if not image_data: + return ValidationResult.error(f"{name} is required") + + if len(image_data) > MAX_FILE_SIZE: + return ValidationResult.error( + f"{name} too large ({len(image_data):,} bytes). Maximum: {MAX_FILE_SIZE:,} bytes" + ) + + try: + img = Image.open(io.BytesIO(image_data)) + width, height = img.size + num_pixels = width * height + + if check_size and num_pixels > MAX_IMAGE_PIXELS: + max_dim = int(MAX_IMAGE_PIXELS ** 0.5) + return ValidationResult.error( + f"{name} too large ({width}×{height} = {num_pixels:,} pixels). " + f"Maximum: ~{MAX_IMAGE_PIXELS:,} pixels ({max_dim}×{max_dim})" + ) + + return ValidationResult.ok( + width=width, + height=height, + pixels=num_pixels, + mode=img.mode, + format=img.format + ) + + except Exception as e: + return ValidationResult.error(f"Could not read {name}: {e}") + + +def validate_rsa_key( + key_data: bytes, + password: Optional[str] = None, + required: bool = False +) -> ValidationResult: + """ + Validate RSA private key. + + Args: + key_data: PEM-encoded key bytes + password: Password if key is encrypted + required: Whether key is required + + Returns: + ValidationResult with key_size + """ + if not key_data: + if required: + return ValidationResult.error("RSA key is required") + return ValidationResult.ok() + + try: + private_key = load_rsa_key(key_data, password) + key_size = private_key.key_size + + if key_size < MIN_RSA_BITS: + return ValidationResult.error( + f"RSA key must be at least {MIN_RSA_BITS} bits (got {key_size})" + ) + + return ValidationResult.ok(key_size=key_size) + + except Exception as e: + return ValidationResult.error(str(e)) + + +def validate_security_factors( + pin: str, + rsa_key_data: Optional[bytes] +) -> ValidationResult: + """ + Validate that at least one security factor is provided. + + Args: + pin: PIN string (may be empty) + rsa_key_data: RSA key bytes (may be None/empty) + + Returns: + ValidationResult + """ + has_pin = bool(pin and pin.strip()) + has_key = bool(rsa_key_data and len(rsa_key_data) > 0) + + if not has_pin and not has_key: + return ValidationResult.error( + "You must provide at least a PIN or RSA Key" + ) + + return ValidationResult.ok(has_pin=has_pin, has_key=has_key) + + +def validate_file_extension( + filename: str, + allowed: set[str], + file_type: str = "File" +) -> ValidationResult: + """ + Validate file extension. + + Args: + filename: Filename to check + allowed: Set of allowed extensions (lowercase, no dot) + file_type: Name for error messages + + Returns: + ValidationResult with extension + """ + if not filename or '.' not in filename: + return ValidationResult.error(f"{file_type} must have a file extension") + + ext = filename.rsplit('.', 1)[1].lower() + + if ext not in allowed: + return ValidationResult.error( + f"Unsupported {file_type.lower()} type: .{ext}. " + f"Allowed: {', '.join(sorted('.' + e for e in allowed))}" + ) + + return ValidationResult.ok(extension=ext) + + +def validate_image_file(filename: str) -> ValidationResult: + """Validate image file extension.""" + return validate_file_extension(filename, ALLOWED_IMAGE_EXTENSIONS, "Image") + + +def validate_key_file(filename: str) -> ValidationResult: + """Validate key file extension.""" + return validate_file_extension(filename, ALLOWED_KEY_EXTENSIONS, "Key file") + + +def validate_key_password(password: str) -> ValidationResult: + """ + Validate password for key encryption. + + Args: + password: Password string + + Returns: + ValidationResult + """ + if not password: + return ValidationResult.error("Password is required") + + if len(password) < MIN_KEY_PASSWORD_LENGTH: + return ValidationResult.error( + f"Password must be at least {MIN_KEY_PASSWORD_LENGTH} characters" + ) + + return ValidationResult.ok(length=len(password)) + + +def validate_phrase(phrase: str) -> ValidationResult: + """ + Validate day phrase. + + Args: + phrase: Phrase string + + Returns: + ValidationResult with word_count + """ + if not phrase or not phrase.strip(): + return ValidationResult.error("Day phrase is required") + + words = phrase.strip().split() + + return ValidationResult.ok(word_count=len(words)) + + +def validate_date_string(date_str: str) -> ValidationResult: + """ + Validate date string format (YYYY-MM-DD). + + Args: + date_str: Date string + + Returns: + ValidationResult + """ + if not date_str: + return ValidationResult.error("Date is required") + + if len(date_str) != 10: + return ValidationResult.error("Date must be in YYYY-MM-DD format") + + if date_str[4] != '-' or date_str[7] != '-': + return ValidationResult.error("Date must be in YYYY-MM-DD format") + + try: + year = int(date_str[0:4]) + month = int(date_str[5:7]) + day = int(date_str[8:10]) + + if not (1 <= month <= 12 and 1 <= day <= 31 and 2000 <= year <= 2100): + return ValidationResult.error("Invalid date values") + + return ValidationResult.ok(year=year, month=month, day=day) + + except ValueError: + return ValidationResult.error("Date must contain valid numbers") + + +# ============================================================================ +# EXCEPTION-RAISING VALIDATORS (for CLI/API use) +# ============================================================================ + +def require_valid_pin(pin: str, required: bool = False) -> None: + """Validate PIN, raising exception on failure.""" + result = validate_pin(pin, required) + if not result.is_valid: + raise PinValidationError(result.error_message) + + +def require_valid_message(message: str) -> None: + """Validate message, raising exception on failure.""" + result = validate_message(message) + if not result.is_valid: + raise MessageValidationError(result.error_message) + + +def require_valid_image(image_data: bytes, name: str = "Image") -> None: + """Validate image, raising exception on failure.""" + result = validate_image(image_data, name) + if not result.is_valid: + raise ImageValidationError(result.error_message) + + +def require_valid_rsa_key( + key_data: bytes, + password: Optional[str] = None, + required: bool = False +) -> None: + """Validate RSA key, raising exception on failure.""" + result = validate_rsa_key(key_data, password, required) + if not result.is_valid: + raise KeyValidationError(result.error_message) + + +def require_security_factors(pin: str, rsa_key_data: Optional[bytes]) -> None: + """Validate security factors, raising exception on failure.""" + result = validate_security_factors(pin, rsa_key_data) + if not result.is_valid: + raise SecurityFactorError(result.error_message) diff --git a/uploads/.gitkeep b/tests/__init__.py similarity index 100% rename from uploads/.gitkeep rename to tests/__init__.py diff --git a/tests/test_stegasoo.py b/tests/test_stegasoo.py new file mode 100644 index 0000000..f9ea972 --- /dev/null +++ b/tests/test_stegasoo.py @@ -0,0 +1,220 @@ +""" +Basic tests for Stegasoo library. +""" + +import io +import sys +from pathlib import Path + +import pytest + +# Add src to path for development +sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) + +import stegasoo +from stegasoo import ( + generate_credentials, + generate_pin, + generate_phrase, + validate_pin, + validate_message, + encode, + decode, + DAY_NAMES, +) + + +class TestKeygen: + """Test credential generation.""" + + def test_generate_pin_default(self): + pin = generate_pin() + assert len(pin) == 6 + assert pin.isdigit() + assert pin[0] != '0' + + def test_generate_pin_lengths(self): + for length in range(6, 10): + pin = generate_pin(length) + assert len(pin) == length + + def test_generate_phrase_default(self): + phrase = generate_phrase() + words = phrase.split() + assert len(words) == 3 + + def test_generate_phrase_lengths(self): + for length in range(3, 13): + phrase = generate_phrase(length) + words = phrase.split() + assert len(words) == length + + def test_generate_credentials_pin_only(self): + creds = generate_credentials(use_pin=True, use_rsa=False) + assert creds.pin is not None + assert creds.rsa_key_pem is None + assert len(creds.phrases) == 7 + assert set(creds.phrases.keys()) == set(DAY_NAMES) + + def test_generate_credentials_rsa_only(self): + creds = generate_credentials(use_pin=False, use_rsa=True) + assert creds.pin is None + assert creds.rsa_key_pem is not None + assert '-----BEGIN PRIVATE KEY-----' in creds.rsa_key_pem + + def test_generate_credentials_both(self): + creds = generate_credentials(use_pin=True, use_rsa=True) + assert creds.pin is not None + assert creds.rsa_key_pem is not None + + def test_generate_credentials_neither_fails(self): + with pytest.raises(ValueError): + generate_credentials(use_pin=False, use_rsa=False) + + def test_entropy_calculation(self): + creds = generate_credentials( + use_pin=True, + use_rsa=True, + pin_length=6, + rsa_bits=2048, + words_per_phrase=3 + ) + assert creds.phrase_entropy == 33 # 3 * 11 + assert creds.pin_entropy == 19 # floor(6 * 3.32) + assert creds.rsa_entropy == 128 + assert creds.total_entropy == 33 + 19 + 128 + + +class TestValidation: + """Test input validation.""" + + def test_validate_pin_valid(self): + result = validate_pin("123456") + assert result.is_valid + + def test_validate_pin_empty_ok(self): + result = validate_pin("") + assert result.is_valid + + def test_validate_pin_too_short(self): + result = validate_pin("12345") + assert not result.is_valid + assert "6-9" in result.error_message + + def test_validate_pin_too_long(self): + result = validate_pin("1234567890") + assert not result.is_valid + + def test_validate_pin_leading_zero(self): + result = validate_pin("012345") + assert not result.is_valid + assert "zero" in result.error_message.lower() + + def test_validate_pin_non_digits(self): + result = validate_pin("12345a") + assert not result.is_valid + + def test_validate_message_valid(self): + result = validate_message("Hello, world!") + assert result.is_valid + + def test_validate_message_empty(self): + result = validate_message("") + assert not result.is_valid + + def test_validate_message_too_long(self): + result = validate_message("x" * 60000) + assert not result.is_valid + + +class TestEncodeDecode: + """Test encoding and decoding (requires test images).""" + + @pytest.fixture + def test_image(self): + """Create a simple test image.""" + from PIL import Image + img = Image.new('RGB', (100, 100), color='red') + buf = io.BytesIO() + img.save(buf, format='PNG') + return buf.getvalue() + + def test_encode_decode_roundtrip(self, test_image): + """Test full encode/decode cycle.""" + message = "Secret message!" + phrase = "apple forest thunder" + pin = "123456" + + result = encode( + message=message, + reference_photo=test_image, + carrier_image=test_image, + day_phrase=phrase, + pin=pin + ) + + assert result.stego_image is not None + assert len(result.stego_image) > 0 + assert result.filename.endswith('.png') + + decoded = decode( + stego_image=result.stego_image, + reference_photo=test_image, + day_phrase=phrase, + pin=pin + ) + + assert decoded == message + + def test_wrong_pin_fails(self, test_image): + """Test that wrong PIN fails to decode.""" + result = encode( + message="Secret", + reference_photo=test_image, + carrier_image=test_image, + day_phrase="test phrase here", + pin="123456" + ) + + with pytest.raises(stegasoo.DecryptionError): + decode( + stego_image=result.stego_image, + reference_photo=test_image, + day_phrase="test phrase here", + pin="654321" # Wrong PIN + ) + + def test_wrong_phrase_fails(self, test_image): + """Test that wrong phrase fails to decode.""" + result = encode( + message="Secret", + reference_photo=test_image, + carrier_image=test_image, + day_phrase="correct phrase here", + pin="123456" + ) + + with pytest.raises(stegasoo.DecryptionError): + decode( + stego_image=result.stego_image, + reference_photo=test_image, + day_phrase="wrong phrase here", + pin="123456" + ) + + +class TestVersion: + """Test version information.""" + + def test_version_exists(self): + assert hasattr(stegasoo, '__version__') + assert stegasoo.__version__ == "2.0.0" + + def test_day_names(self): + assert len(stegasoo.DAY_NAMES) == 7 + assert stegasoo.DAY_NAMES[0] == 'Monday' + assert stegasoo.DAY_NAMES[6] == 'Sunday' + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/.dockerignore b/v1_old_files/.dockerignore similarity index 100% rename from .dockerignore rename to v1_old_files/.dockerignore diff --git a/v1_old_files/Dockerfile b/v1_old_files/Dockerfile new file mode 100644 index 0000000..9b67308 --- /dev/null +++ b/v1_old_files/Dockerfile @@ -0,0 +1,44 @@ +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libc-dev \ + libffi-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies + +COPY requirements.txt . +#COPY requirements-ml.txt . + +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt --root-user-action=ignore +#RUN pip install --no-cache-dir -r requirements-ml.txt + +# Copy application +COPY . . + +# Create upload directory +RUN mkdir -p /tmp/stego_uploads + +# Create non-root user for security +RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads +USER stego + +# 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 in production +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "60", "app:app"] diff --git a/v1_old_files/README.md b/v1_old_files/README.md new file mode 100644 index 0000000..c2113ad --- /dev/null +++ b/v1_old_files/README.md @@ -0,0 +1,137 @@ +# Stegasoo Web Service + +A containerized Flask + Bootstrap web UI for hybrid Photo + Day-Phrase + PIN steganography. + +![Python](https://img.shields.io/badge/Python-3.11+-blue) +![Flask](https://img.shields.io/badge/Flask-3.0+-green) +![Docker](https://img.shields.io/badge/Docker-Ready-blue) +![Security](https://img.shields.io/badge/Security-AES--256--GCM-red) + +## Features + +- 🔐 **AES-256-GCM** authenticated encryption +- 🧠 **Argon2id** memory-hard key derivation (256MB) +- 🎲 **Pseudo-random pixel selection** defeats steganalysis +- 📅 **Daily key rotation** with customizable phrases (3-12 words) +- 🔢 **Static PIN** for additional entropy (6-8 digits) +- 🖼️ **Reference photo** as "something you have" +- 🌐 **Web UI** with Bootstrap 5 dark theme +- 📖 **Memory aid stories** to help memorize phrases (template or AI-powered) + +## Quick Start + +### Docker (Recommended) + +```bash +# Build and run +docker-compose up -d + +# Access at http://localhost:5000 +``` + +### Manual Installation + +```bash +# Create virtual environment +python -m venv venv +source venv/bin/activate # Linux/Mac +# or: venv\Scripts\activate # Windows + +# Install dependencies +pip install -r requirements.txt + +# Optional: Enable AI-powered story generation +pip install -r requirements-ml.txt + +# Run development server +python app.py + +# Or production with gunicorn +gunicorn --bind 0.0.0.0:5000 app:app +``` + +## Usage + +### 1. Generate Credentials + +Visit `/generate` to create: +- **7 phrases** (one per day of week, 3-12 words each) +- **1 PIN** (6-8 digits, same every day) +- **Memory aid stories** (optional, helps memorize phrases) + +Memorize these! Don't save them. + +### 2. Encode a Message + +Visit `/encode` and provide: +- **Reference photo** - A photo both parties have (NOT transmitted) +- **Carrier image** - The image to hide your message in +- **Message** - Your secret text +- **Day phrase** - Today's phrase +- **PIN** - Your static PIN + +Download the stego image and share it through any channel. + +### 3. Decode a Message + +Visit `/decode` and provide: +- **Reference photo** - Same photo used for encoding +- **Stego image** - The image containing the hidden message +- **Day phrase** - The phrase for the day it was encoded +- **PIN** - Your static PIN + +## Security Model + +| Component | Entropy | Purpose | +|-----------|---------|---------| +| Reference Photo | ~80-256 bits | Something you have | +| Day Phrase | ~33-132 bits | Something you know (rotates daily) | +| PIN | ~20-27 bits | Something you know (static) | +| **Combined** | **133-415+ bits** | **Beyond brute force** | + +### Attack Resistance + +| Attack | Result | +|--------|--------| +| Brute force | 2^133+ combinations = impossible | +| Rainbow tables | Random salt per message | +| Steganalysis | Random pixel selection defeats detection | +| GPU cracking | Argon2 requires 256MB RAM per attempt | + +## Memory Aid Stories + +The generate page can create stories to help you memorize your phrases: + +**Template-based** (default): +> Monday morning began when I discovered a **APPLE** near the **FOREST**. I had to **THUNDER** quickly, then grab the **CRYSTAL** before reaching the **BRAVE**. + +**AI-powered** (with `requirements-ml.txt`): +- Uses DistilGPT-2 (~350MB model) +- Generates more coherent, natural stories +- Words highlighted in RED CAPS + +## Configuration + +Environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `FLASK_ENV` | production | Flask environment | +| `SECRET_KEY` | random | Session secret (auto-generated) | + +## Production Deployment + +For production, consider: + +1. **HTTPS** - Use nginx reverse proxy with SSL +2. **Rate limiting** - Prevent abuse +3. **Logging** - Monitor for security events +4. **Memory** - Allocate at least 512MB (Argon2 needs 256MB) + +## 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. diff --git a/STEGASOO_WEB_README.md b/v1_old_files/STEGASOO_WEB_README.md similarity index 100% rename from STEGASOO_WEB_README.md rename to v1_old_files/STEGASOO_WEB_README.md diff --git a/app.py b/v1_old_files/app.py similarity index 100% rename from app.py rename to v1_old_files/app.py diff --git a/bip39-words.txt b/v1_old_files/bip39-words.txt similarity index 100% rename from bip39-words.txt rename to v1_old_files/bip39-words.txt diff --git a/requirements-ml.txt b/v1_old_files/requirements-ml.txt similarity index 100% rename from requirements-ml.txt rename to v1_old_files/requirements-ml.txt diff --git a/v1_old_files/requirements.txt b/v1_old_files/requirements.txt new file mode 100644 index 0000000..9605ef7 --- /dev/null +++ b/v1_old_files/requirements.txt @@ -0,0 +1,16 @@ +# Core dependencies +flask>=3.0.0 +gunicorn>=21.0.0 +pillow>=10.0.0 +cryptography>=41.0.0 + +# Memory-hard key derivation (highly recommended) +argon2-cffi>=23.0.0 + +# Optional: ML story generation (adds ~1GB disk space) +# Uncomment for AI-powered memory aid stories +# transformers>=4.35.0 +# torch>=2.0.0 + +# Optional: For production deployment +# gevent>=23.0.0 diff --git a/secureDeleter.py b/v1_old_files/secureDeleter.py similarity index 100% rename from secureDeleter.py rename to v1_old_files/secureDeleter.py diff --git a/v1_old_files/static/.gitkeep b/v1_old_files/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/static/favicon.svg b/v1_old_files/static/favicon.svg similarity index 100% rename from static/favicon.svg rename to v1_old_files/static/favicon.svg diff --git a/static/logo.svg b/v1_old_files/static/logo.svg similarity index 100% rename from static/logo.svg rename to v1_old_files/static/logo.svg diff --git a/v1_old_files/static/style.css b/v1_old_files/static/style.css new file mode 100644 index 0000000..9f8a840 --- /dev/null +++ b/v1_old_files/static/style.css @@ -0,0 +1,257 @@ +/* ============================================================================ + Stegasoo - Main Stylesheet + ============================================================================ */ + +/* ---------------------------------------------------------------------------- + CSS Variables + ---------------------------------------------------------------------------- */ +:root { + --gradient-start: #667eea; + --gradient-end: #764ba2; + --bg-dark-1: #1a1a2e; + --bg-dark-2: #16213e; + --bg-dark-3: #0f3460; + --text-muted: rgba(255, 255, 255, 0.5); + --border-light: rgba(255, 255, 255, 0.1); + --overlay-dark: rgba(0, 0, 0, 0.3); + --overlay-light: rgba(255, 255, 255, 0.05); +} + +/* ---------------------------------------------------------------------------- + Base Styles + ---------------------------------------------------------------------------- */ +body { + min-height: 100vh; + background: linear-gradient(135deg, var(--bg-dark-1) 0%, var(--bg-dark-2) 50%, var(--bg-dark-3) 100%); +} + +/* ---------------------------------------------------------------------------- + Navigation + ---------------------------------------------------------------------------- */ +.navbar { + background: var(--overlay-dark) !important; + backdrop-filter: blur(10px); +} + +/* ---------------------------------------------------------------------------- + Cards + ---------------------------------------------------------------------------- */ +.card { + background: var(--overlay-light); + backdrop-filter: blur(10px); + border: 1px solid var(--border-light); +} + +.card-header { + background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); + border-bottom: none; +} + +.feature-card { + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.feature-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 40px rgba(102, 126, 234, 0.2); +} + +/* ---------------------------------------------------------------------------- + Buttons + ---------------------------------------------------------------------------- */ +.btn-primary { + background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); + border: none; +} + +.btn-primary:hover { + background: linear-gradient(135deg, var(--gradient-end), var(--gradient-start)); + transform: translateY(-2px); + box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); +} + +/* ---------------------------------------------------------------------------- + Forms + ---------------------------------------------------------------------------- */ +.form-control, +.form-select { + background: var(--overlay-light); + border: 1px solid var(--border-light); + color: #fff; +} + +.form-control:focus, +.form-select:focus { + background: rgba(255, 255, 255, 0.1); + border-color: var(--gradient-start); + box-shadow: 0 0 0 0.25rem rgba(102, 126, 234, 0.25); + color: #fff; +} + +.form-control::placeholder { + color: var(--text-muted); +} + +/* Fix dropdown options for dark theme */ +.form-select option { + background: var(--bg-dark-1); + color: #fff; +} + +/* ---------------------------------------------------------------------------- + Hero & Icons + ---------------------------------------------------------------------------- */ +.hero-icon { + font-size: 4rem; + background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ---------------------------------------------------------------------------- + Phrase Display + ---------------------------------------------------------------------------- */ +.phrase-display { + font-family: 'Courier New', monospace; + font-size: 1rem; + background: var(--overlay-dark); + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + border-left: 4px solid var(--gradient-start); + display: inline-block; + line-height: 1.6; + word-spacing: 0.3rem; +} + +/* ---------------------------------------------------------------------------- + PIN Display + ---------------------------------------------------------------------------- */ +.pin-display { + font-family: 'Courier New', monospace; + font-size: 3rem; + font-weight: bold; + letter-spacing: 0.75rem; + background: linear-gradient(135deg, #fef08a, #fcd34d, #fb923c); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + display: inline-block; + line-height: 1; +} + +.pin-container { + background: var(--overlay-dark); + border: 1px solid var(--border-light); + border-radius: 0.75rem; + padding: 1.5rem 2rem; + display: inline-block; +} + +/* ---------------------------------------------------------------------------- + Story Cards (Memory Aid) + ---------------------------------------------------------------------------- */ +.story-word { + color: #ff6b6b; + font-weight: bold; + text-transform: uppercase; +} + +.story-card { + background: rgba(0, 0, 0, 0.2); + border-left: 3px solid var(--gradient-start); + padding: 1rem; + margin-bottom: 0.75rem; + border-radius: 0.5rem; + font-size: 0.95rem; + line-height: 1.6; +} + +.story-card .day-label { + font-weight: bold; + color: var(--gradient-start); + margin-bottom: 0.5rem; +} + +/* ---------------------------------------------------------------------------- + Alert / Message Display + ---------------------------------------------------------------------------- */ +.alert-message { + background: var(--overlay-dark); + border: 1px solid var(--border-light); + border-radius: 0.5rem; + padding: 1.5rem; + white-space: pre-wrap; + font-family: 'Courier New', monospace; +} + +/* ---------------------------------------------------------------------------- + Drop Zone (Drag & Drop File Upload) + ---------------------------------------------------------------------------- */ +.drop-zone { + position: relative; + border: 2px dashed rgba(255, 255, 255, 0.2); + border-radius: 0.5rem; + padding: 1.5rem; + text-align: center; + transition: all 0.2s ease; +} + +.drop-zone.drag-over { + border-color: var(--gradient-start); + background: rgba(102, 126, 234, 0.1); +} + +.drop-zone input[type="file"] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.drop-zone-label { + pointer-events: none; +} + +.drop-zone-preview { + max-height: 120px; + border-radius: 0.375rem; + margin-top: 0.75rem; +} + +/* ---------------------------------------------------------------------------- + Footer + ---------------------------------------------------------------------------- */ +footer { + background: rgba(0, 0, 0, 0.2); +} + +/* ---------------------------------------------------------------------------- + Custom Alert Variants + ---------------------------------------------------------------------------- */ +.alert-success-bright { + background: rgba(34, 197, 94, 0.2); + border-color: #22c55e; + color: #4ade80; +} + +/* ---------------------------------------------------------------------------- + Utility Classes + ---------------------------------------------------------------------------- */ +.bg-dark-subtle { + background: rgba(0, 0, 0, 0.2); +} + +.status-box { + background: rgba(0, 0, 0, 0.2); + padding: 1rem; + border-radius: 0.5rem; +} + +.result-icon { + font-size: 4rem; +} + +.footer-icon { + vertical-align: text-bottom; +} diff --git a/v1_old_files/templates/about.html b/v1_old_files/templates/about.html new file mode 100644 index 0000000..7ac6af0 --- /dev/null +++ b/v1_old_files/templates/about.html @@ -0,0 +1,178 @@ +{% extends "base.html" %} + +{% block title %}About - Stegasoo{% endblock %} + +{% block content %} +
+
+
+
+
About Stegasoo
+
+
+

+ Stegasoo is a hybrid steganography system that hides encrypted messages inside + ordinary images. It combines multiple security layers to create a system that is + both highly secure and practical to use. +

+ +
System Status
+
+
+
+ {% if has_argon2 %} + +
+ Argon2id Available +
Memory-hard key derivation (256MB)
+
+ {% else %} + +
+ Using PBKDF2 Fallback +
Install argon2-cffi for better security
+
+ {% endif %} +
+
+
+
+ +
+ AES-256-GCM +
Authenticated encryption enabled
+
+
+
+
+
+
+ +
+
+
Security Model
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentEntropyPurpose
Reference Photo~80-256 bitsSomething you have (plausible deniability)
3-Word Phrase~33 bitsSomething you know (changes daily)
6-Digit PIN~20 bitsSomething you know (static)
DateN/AAutomatic key rotation
Combined133+ bitsBeyond brute force
+
+
+
+ +
+
+
Attack Resistance
+
+
+
+
+
What Attackers Can't Do
+
    +
  • + + Brute force the passphrase (2133 combinations) +
  • +
  • + + Use rainbow tables (random salt per message) +
  • +
  • + + Detect hidden data (random pixel selection) +
  • +
  • + + Use GPU farms (Argon2 requires 256MB RAM per attempt) +
  • +
+
+
+
Real Threats
+
    +
  • + + Social engineering (someone tricks you) +
  • +
  • + + Physical access to your devices +
  • +
  • + + Malware/keyloggers on your system +
  • +
  • + + Shoulder surfing while you type +
  • +
+
+
+
+
+ +
+
+
Best Practices
+
+
+
+
+
Do
+
    +
  • Memorize your phrases and PIN, never write them down
  • +
  • Use a reference photo that both parties already have
  • +
  • Use different carrier images for each message
  • +
  • Share stego images through normal channels (looks innocent)
  • +
+
+
+
Don't
+
    +
  • Don't transmit the reference photo
  • +
  • Don't reuse the same carrier image
  • +
  • Don't store phrases or PIN digitally
  • +
  • Don't resize or recompress stego images
  • +
+
+
+
+
+
+
+{% endblock %} diff --git a/v1_old_files/templates/base.html b/v1_old_files/templates/base.html new file mode 100644 index 0000000..7254369 --- /dev/null +++ b/v1_old_files/templates/base.html @@ -0,0 +1,74 @@ + + + + + + {% block title %}Stegasoo{% endblock %} + + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ +
+
+ + + Stegasoo v1.1 — Hybrid Photo + Day-Phrase + PIN Steganography + +
+
+ + + {% block scripts %}{% endblock %} + + diff --git a/v1_old_files/templates/decode.html b/v1_old_files/templates/decode.html new file mode 100644 index 0000000..226d6fd --- /dev/null +++ b/v1_old_files/templates/decode.html @@ -0,0 +1,259 @@ +{% extends "base.html" %} + +{% block title %}Decode Message - Stegasoo{% endblock %} + +{% block content %} +
+
+
+
+
Decode Secret Message
+
+
+ {% if decoded_message %} +
+
Message Decrypted Successfully!
+
+ +
+ +
{{ decoded_message }}
+
+ + + Decode Another Message + + + {% else %} + +
+
+
+ +
+ +
+ + Drop image or click to browse +
+ +
+
+ The same reference photo used for encoding +
+
+ +
+ +
+ +
+ + Drop image or click to browse +
+ +
+
+ The image containing the hidden message +
+
+
+ +
+ + +
+ The phrase for the day the message was encoded +
+
+ +
+ +
+ SECURITY FACTORS + (provide same factors used during encoding) +
+ +
+
+ + +
+ If PIN was used during encoding +
+
+ +
+ + +
+ If RSA key was used during encoding +
+
+
+ + +
+ + +
+ Leave blank if your key file is not password-protected +
+
+ + +
+ + {% endif %} +
+
+ +
+
+
Troubleshooting
+
    +
  • + + Make sure you're using the exact same reference photo file +
  • +
  • + + Use the phrase for the day the message was encoded, not today +
  • +
  • + + Provide the same security factors (PIN and/or RSA key) used during encoding +
  • +
  • + + Ensure the stego image hasn't been resized or recompressed +
  • +
  • + + If using an RSA key, make sure the password is correct +
  • +
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/v1_old_files/templates/encode.html b/v1_old_files/templates/encode.html new file mode 100644 index 0000000..8498c19 --- /dev/null +++ b/v1_old_files/templates/encode.html @@ -0,0 +1,259 @@ +{% extends "base.html" %} + +{% block title %}Encode Message - Stegasoo{% endblock %} + +{% block content %} +
+
+
+
+
Encode Secret Message
+
+
+
+ + +
+
+ +
+ +
+ + Drop image or click to browse +
+ +
+
+ The secret photo both parties have (NOT transmitted) +
+
+ +
+ +
+ +
+ + Drop image or click to browse +
+ +
+
+ The image to hide your message in (e.g., a meme) +
+
+
+ +
+ + +
+ + 0 / 50,000 characters + + Getting long! + + + 0% +
+
+ +
+ + +
+ Your phrase for today (based on your local timezone) +
+
+ +
+ +
+ SECURITY FACTORS + (provide at least one: PIN or RSA Key) +
+ +
+
+ + +
+ Your static 6-9 digit PIN (if configured) +
+
+ +
+ + +
+ Your shared .pem key file (if configured) +
+
+
+ + +
+ + +
+ Leave blank if your key file is not password-protected +
+
+ + +
+ +
+ +
+
+ + AES-256-GCM Encryption +
+
+ + Random Pixel Embedding +
+
+ + Undetectable by Analysis +
+
+ +
+ + Limits: + Carrier image max ~4 megapixels (2000×2000). + Files max 5MB each. + Message max 50KB. +
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/v1_old_files/templates/encode_result.html b/v1_old_files/templates/encode_result.html new file mode 100644 index 0000000..1837833 --- /dev/null +++ b/v1_old_files/templates/encode_result.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} + +{% block title %}Message Encoded - Stegasoo{% endblock %} + +{% block content %} +
+
+
+
+
Message Encoded Successfully!
+
+
+
+ +
{{ filename }}
+

Your secret message is hidden in this image

+
+ +
+ + Download Image + + + +
+ + +
+

Share via:

+ +
+ +
+ +
+ + File expires in 5 minutes. + Download or share now. The file will be securely deleted after expiry. +
+ + + Encode Another Message + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/v1_old_files/templates/generate.html b/v1_old_files/templates/generate.html new file mode 100644 index 0000000..5d6af8d --- /dev/null +++ b/v1_old_files/templates/generate.html @@ -0,0 +1,345 @@ +{% extends "base.html" %} + +{% block title %}Generate Credentials - Stegasoo{% endblock %} + +{% block content %} +
+
+
+
+
Generate Credentials
+
+
+ {% if not generated %} +

+ Generate your weekly phrase card and security factors. You must choose at least one: PIN or RSA Key. +

+ +
+
+ + +
More words = more security, harder to memorize
+
+ +
+ +
SECURITY FACTORS (select at least one)
+ + +
+
+
+ + +
+
+ + +
Memorizable, same PIN used every day
+
+
+
+ + +
+
+
+ + +
+
+ + +
File-based key, both parties need the same .pem file
+
+
+
+ +
+
+ Estimated entropy: + ~53 bits +
+
+
+
+ + Good for most use cases + • Reference photo adds ~80-256 bits more + +
+ +
+ + You must select at least one security factor (PIN or RSA Key) +
+ + +
+ + {% else %} + + +
+ + Credentials Generated! + +
+ +
+ + Memorize phrases, save key securely, then close! - Do not screenshot + +
+ + {% if pin %} +
+
+
YOUR STATIC PIN
+
+
{{ pin }}
+
+
+ Use this {{ pin_length }}-digit PIN every day +
+
+ {% endif %} + + {% if rsa_key_pem %} +
+
+
+ YOUR RSA KEY ({{ rsa_bits }}-bit) +
+ +
+ + Save this key securely! Share it with your recipient through a secure channel. You cannot recover it later. +
+ + +
+ +
+ + + + + + + +
+
+
+
+ +
+ + +
You'll need this password when using the key
+
+ +
+
+
+
+
+ {% endif %} + +
+ +
DAILY PHRASES ({{ words_per_phrase }} words each)
+ +
+ + + + + + + + + {% for day in days %} + + + + + {% endfor %} + +
DayPhrase
+ {{ day }} + + {{ phrases[day] }} +
+
+ +
+
Security Summary
+
+
+
{{ phrase_entropy }}
+ bits/phrase +
+ {% if pin %} +
+
{{ pin_entropy }}
+ bits/PIN +
+ {% endif %} + {% if rsa_key_pem %} +
+
{{ rsa_entropy }}
+ bits/RSA +
+ {% endif %} +
+
{{ total_entropy }}
+ bits total +
+
+ + + reference photo (~80-256 bits) = {{ total_entropy + 80 }}+ bits combined + +
+ + + Generate New Credentials + + + {% endif %} +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/v1_old_files/templates/index.html b/v1_old_files/templates/index.html new file mode 100644 index 0000000..19deb0e --- /dev/null +++ b/v1_old_files/templates/index.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} + +{% block title %}Stegasoo - Secure Steganography{% endblock %} + +{% block content %} +
+ Stegasoo +

Stegasoo

+

Create hidden encrypted messages in images and photos using advanced steganography.

+
+
+ +
+
+
+ +
+
+
Encode Message
+

+ Hide your secret message inside an innocent-looking image using your daily phrase + PIN. +

+ + Encode + +
+
+
+ +
+
+
+ +
+
+
Decode Message
+

+ Extract and decrypt hidden messages from Stegasoo-encoded images using your credentials. +

+ + Decode + +
+
+
+ +
+
+
+ +
+
+
Generate Keys
+

+ Create your weekly phrase card and PIN. Memorize 21 words + 6 digits for maximum security. +

+ + Generate + +
+
+
+
+ +
+
+
How It Works
+
+
+
+
+
Key Components
+
    +
  • + + Reference Photo — Any photo you and recipient both have +
  • +
  • + + Day Phrase — 3 words, different each day of the week +
  • +
  • + + Static PIN — 6 digits, same every day +
  • +
+
+
+
Security Features
+
    +
  • + + Argon2id memory-hard key derivation (256MB) +
  • +
  • + + Pseudo-random pixel selection (defeats steganalysis) +
  • +
  • + + AES-256-GCM authenticated encryption +
  • +
+
+
+
+
+{% endblock %} diff --git a/test/story_generator.py b/v1_old_files/test/story_generator.py similarity index 100% rename from test/story_generator.py rename to v1_old_files/test/story_generator.py diff --git a/v1_old_files/uploads/.gitkeep b/v1_old_files/uploads/.gitkeep new file mode 100644 index 0000000..e69de29